Merge branch 'release/v0.2.8' of github.com:SuanmoSuanyangTechnology/MemoryBear into release/v0.2.8

This commit is contained in:
yujiangping
2026-03-18 18:17:20 +08:00
44 changed files with 710 additions and 301 deletions

View File

@@ -194,6 +194,7 @@ def delete_app(
def copy_app( def copy_app(
app_id: uuid.UUID, app_id: uuid.UUID,
new_name: Optional[str] = None, new_name: Optional[str] = None,
payload: app_schema.CopyAppRequest = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
): ):
@@ -205,6 +206,8 @@ def copy_app(
- 不影响原应用 - 不影响原应用
""" """
workspace_id = current_user.current_workspace_id workspace_id = current_user.current_workspace_id
# body takes precedence over query param for backward compatibility
new_name = (payload.new_name if payload else None) or new_name
logger.info( logger.info(
"用户请求复制应用", "用户请求复制应用",
extra={ extra={
@@ -517,7 +520,7 @@ async def draft_run(
# 提前验证和准备(在流式响应开始前完成) # 提前验证和准备(在流式响应开始前完成)
from app.services.app_service import AppService from app.services.app_service import AppService
from app.services.multi_agent_service import MultiAgentService from app.services.multi_agent_service import MultiAgentService
from app.models import AgentConfig, ModelConfig from app.models import AgentConfig, ModelConfig, AppRelease
from sqlalchemy import select from sqlalchemy import select
from app.core.exceptions import BusinessException from app.core.exceptions import BusinessException
from app.services.draft_run_service import AgentRunService from app.services.draft_run_service import AgentRunService
@@ -537,6 +540,7 @@ async def draft_run(
# 先获取 app 的 workspace_id # 先获取 app 的 workspace_id
end_user_repo = EndUserRepository(db) end_user_repo = EndUserRepository(db)
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=app_id,
workspace_id=app.workspace_id, workspace_id=app.workspace_id,
other_id=str(current_user.id), other_id=str(current_user.id),
) )
@@ -555,18 +559,29 @@ async def draft_run(
service._check_agent_config(app_id) service._check_agent_config(app_id)
# 2. 获取 Agent 配置 # 2. 获取 Agent 配置
stmt = select(AgentConfig).where(AgentConfig.app_id == app_id) # 共享应用:从最新发布版本读配置快照,而非草稿
agent_cfg = db.scalars(stmt).first() is_shared = app.workspace_id != workspace_id
if not agent_cfg: if is_shared:
raise BusinessException("Agent 配置不存在", BizCode.AGENT_CONFIG_MISSING) if not app.current_release_id:
raise BusinessException("该应用尚未发布,无法使用", BizCode.AGENT_CONFIG_MISSING)
release = db.get(AppRelease, app.current_release_id)
if not release:
raise BusinessException("发布版本不存在", BizCode.AGENT_CONFIG_MISSING)
agent_cfg = service._agent_config_from_release(release)
model_config = db.get(ModelConfig, release.default_model_config_id) if release.default_model_config_id else None
else:
stmt = select(AgentConfig).where(AgentConfig.app_id == app_id)
agent_cfg = db.scalars(stmt).first()
if not agent_cfg:
raise BusinessException("Agent 配置不存在", BizCode.AGENT_CONFIG_MISSING)
# 3. 获取模型配置 # 3. 获取模型配置
model_config = None model_config = None
if agent_cfg.default_model_config_id: if agent_cfg.default_model_config_id:
model_config = db.get(ModelConfig, agent_cfg.default_model_config_id) model_config = db.get(ModelConfig, agent_cfg.default_model_config_id)
if not model_config: if not model_config:
from app.core.exceptions import ResourceNotFoundException from app.core.exceptions import ResourceNotFoundException
raise ResourceNotFoundException("模型配置", str(agent_cfg.default_model_config_id)) raise ResourceNotFoundException("模型配置", str(agent_cfg.default_model_config_id))
# 流式返回 # 流式返回
if payload.stream: if payload.stream:
@@ -722,7 +737,17 @@ async def draft_run(
msg="多 Agent 任务执行成功" msg="多 Agent 任务执行成功"
) )
elif app.type == AppType.WORKFLOW: # 工作流 elif app.type == AppType.WORKFLOW: # 工作流
config = workflow_service.check_config(app_id) # 共享应用:从最新发布版本读配置快照,而非草稿
is_shared = app.workspace_id != workspace_id
if is_shared:
if not app.current_release_id:
raise BusinessException("该应用尚未发布,无法使用", BizCode.AGENT_CONFIG_MISSING)
release = db.get(AppRelease, app.current_release_id)
if not release:
raise BusinessException("发布版本不存在", BizCode.AGENT_CONFIG_MISSING)
config = service._workflow_config_from_release(release)
else:
config = workflow_service.check_config(app_id)
# 3. 流式返回 # 3. 流式返回
if payload.stream: if payload.stream:
logger.debug( logger.debug(
@@ -869,6 +894,7 @@ async def draft_run_compare(
# 先获取 app 的 workspace_id # 先获取 app 的 workspace_id
end_user_repo = EndUserRepository(db) end_user_repo = EndUserRepository(db)
new_end_user = end_user_repo.get_or_create_end_user( new_end_user = end_user_repo.get_or_create_end_user(
app_id=app_id,
workspace_id=app.workspace_id, workspace_id=app.workspace_id,
other_id=str(current_user.id), other_id=str(current_user.id),
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -525,6 +525,13 @@ class AppRelease(BaseModel):
return int(dt.timestamp() * 1000) if dt else None return int(dt.timestamp() * 1000) if dt else None
# ---------- App Copy Schema ----------
class CopyAppRequest(BaseModel):
"""复制应用请求"""
new_name: Optional[str] = Field(None, description="新应用名称,不填则使用原名称-副本")
# ---------- App Share Schemas ---------- # ---------- App Share Schemas ----------
class AppShareCreate(BaseModel): class AppShareCreate(BaseModel):

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

@@ -1426,6 +1426,47 @@ class AppService:
logger.info("Agent 配置更新成功", extra={"app_id": str(app_id)}) logger.info("Agent 配置更新成功", extra={"app_id": str(app_id)})
return agent_cfg return agent_cfg
def _agent_config_from_release(self, release: "AppRelease") -> "AgentConfig":
"""从发布版本快照重建 AgentConfig 对象(不入库,仅用于运行)"""
cfg = release.config or {}
now = release.created_at or datetime.datetime.now()
agent_cfg = AgentConfig(
id=uuid.uuid4(),
app_id=release.app_id,
system_prompt=cfg.get("system_prompt", ""),
default_model_config_id=release.default_model_config_id,
model_parameters=cfg.get("model_parameters"),
knowledge_retrieval=cfg.get("knowledge_retrieval"),
memory=cfg.get("memory", {}),
variables=cfg.get("variables", []),
tools=cfg.get("tools", []),
skills=cfg.get("skills", {}),
features=cfg.get("features", {}),
is_active=True,
created_at=now,
updated_at=now,
)
return agent_cfg
def _workflow_config_from_release(self, release: "AppRelease") -> "WorkflowConfig":
"""从发布版本快照重建 WorkflowConfig 对象(不入库,仅用于运行)"""
cfg = release.config or {}
now = release.created_at or datetime.datetime.now()
from app.models.workflow_model import WorkflowConfig as WorkflowConfigModel
wf_cfg = WorkflowConfigModel(
id=uuid.uuid4(),
app_id=release.app_id,
nodes=cfg.get("nodes", []),
edges=cfg.get("edges", []),
variables=cfg.get("variables", []),
execution_config=cfg.get("execution_config", {}),
triggers=cfg.get("triggers", []),
is_active=True,
created_at=now,
updated_at=now,
)
return wf_cfg
def get_agent_config( def get_agent_config(
self, self,
*, *,
@@ -1457,6 +1498,15 @@ class AppService:
# 只读操作,允许访问共享应用 # 只读操作,允许访问共享应用
self._validate_app_accessible(app, workspace_id) self._validate_app_accessible(app, workspace_id)
# 共享应用:返回最新发布版本的配置快照,而非草稿
if workspace_id and app.workspace_id != workspace_id:
if not app.current_release_id:
raise BusinessException("该应用尚未发布,无法使用", BizCode.AGENT_CONFIG_MISSING)
release = self.db.get(AppRelease, app.current_release_id)
if not release:
raise BusinessException("发布版本不存在", BizCode.AGENT_CONFIG_MISSING)
return self._agent_config_from_release(release)
stmt = select(AgentConfig).where( stmt = select(AgentConfig).where(
AgentConfig.app_id == app_id, AgentConfig.app_id == app_id,
AgentConfig.is_active.is_(True) AgentConfig.is_active.is_(True)
@@ -1555,6 +1605,16 @@ class AppService:
# 只读操作,允许访问共享应用 # 只读操作,允许访问共享应用
self._validate_app_accessible(app, workspace_id) self._validate_app_accessible(app, workspace_id)
# 共享应用:返回最新发布版本的配置快照,而非草稿
if workspace_id and app.workspace_id != workspace_id:
if not app.current_release_id:
raise BusinessException("该应用尚未发布,无法使用", BizCode.CONFIG_MISSING)
release = self.db.get(AppRelease, app.current_release_id)
if not release:
raise BusinessException("发布版本不存在", BizCode.CONFIG_MISSING)
return self._workflow_config_from_release(release)
repo = WorkflowConfigRepository(self.db) repo = WorkflowConfigRepository(self.db)
config = repo.get_by_app_id(app_id) config = repo.get_by_app_id(app_id)
if config: if config:
@@ -1609,6 +1669,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 +1683,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 +1937,8 @@ class AppService:
"edges": workflow_cfg.edges, "edges": workflow_cfg.edges,
"variables": workflow_cfg.variables, "variables": workflow_cfg.variables,
"execution_config": workflow_cfg.execution_config, "execution_config": workflow_cfg.execution_config,
"triggers": workflow_cfg.triggers "triggers": workflow_cfg.triggers,
"features": workflow_cfg.features or {}
} }
is_valid, errors = WorkflowValidator.validate_for_publish(config) is_valid, errors = WorkflowValidator.validate_for_publish(config)
@@ -2062,7 +2125,8 @@ class AppService:
) )
if memory_config_id: if memory_config_id:
updated_count = self._update_endusers_memory_config(app_id, memory_config_id)
updated_count = self._update_endusers_memory_config_by_workspace(app.workspace_id, memory_config_id)
logger.info( logger.info(
f"回滚时更新终端用户记忆配置: app_id={app_id}, version={version}, " f"回滚时更新终端用户记忆配置: app_id={app_id}, version={version}, "
f"memory_config_id={memory_config_id}, updated_count={updated_count}" f"memory_config_id={memory_config_id}, updated_count={updated_count}"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,12 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-06 21:11:51 * @Date: 2026-02-06 21:11:51
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 18:06:00 * @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'
@@ -20,6 +22,7 @@ interface AudioRecorderProps {
/** Additional config passed to the upload request */ /** Additional config passed to the upload request */
requestConfig?: Record<string, any>; requestConfig?: Record<string, any>;
disabled?: boolean; disabled?: boolean;
maxSize?: number;
} }
const AudioRecorder: FC<AudioRecorderProps> = ({ const AudioRecorder: FC<AudioRecorderProps> = ({
@@ -27,8 +30,11 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
className = '', className = '',
action = fileUploadUrlWithoutApiPrefix, action = fileUploadUrlWithoutApiPrefix,
requestConfig = {}, requestConfig = {},
disabled = false 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
@@ -57,6 +63,12 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
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

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-03-17 14:22:25 * @Date: 2026-03-17 14:22:25
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-17 14:22:25 * @Last Modified time: 2026-03-18 15:55:13
*/ */
// Toolbar component for chat input area, supporting file upload, audio recording, and variable configuration // Toolbar component for chat input area, supporting file upload, audio recording, and variable configuration
import { useRef, forwardRef, useImperativeHandle, type ReactNode, useEffect } from 'react' import { useRef, forwardRef, useImperativeHandle, type ReactNode, useEffect } from 'react'
@@ -120,7 +120,10 @@ const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
// Build dropdown menu items based on allowed transfer methods // Build dropdown menu items based on allowed transfer methods
const fileMenus: MenuProps['items'] = [] const fileMenus: MenuProps['items'] = []
if (file_upload?.allowed_transfer_methods?.includes('remote_url')) { 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({ fileMenus.push({
key: 'url', key: 'url',
label: t('memoryConversation.addRemoteFile'), label: t('memoryConversation.addRemoteFile'),
@@ -133,9 +136,6 @@ const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
} }
}) })
} }
const enabledTypes = ['image', 'document', 'video', 'audio'].filter(
type => file_upload?.[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]
)
if (file_upload?.allowed_transfer_methods?.includes('local_file') && enabledTypes.length > 0) { if (file_upload?.allowed_transfer_methods?.includes('local_file') && enabledTypes.length > 0) {
fileMenus.push({ fileMenus.push({
key: 'upload', key: 'upload',
@@ -151,13 +151,11 @@ const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
}) })
} }
console.log('queryValues', queryValues)
return ( return (
<Form form={form} initialValues={{ files: [], variables: [] }}> <Form form={form} initialValues={{ files: [], variables: [] }}>
<Flex justify="space-between" className="rb:flex-1"> <Flex justify="space-between" className="rb:flex-1">
<Flex gap={8} align="center"> <Flex gap={8} align="center">
<Form.Item name="files" noStyle hidden={!file_upload?.enabled}> <Form.Item name="files" noStyle hidden={!file_upload?.enabled || fileMenus.length === 0}>
<Dropdown menu={{ items: fileMenus }}> <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')]" /> <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> </Dropdown>
@@ -183,6 +181,7 @@ const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
action={uploadAction} action={uploadAction}
requestConfig={uploadRequestConfig} requestConfig={uploadRequestConfig}
onRecordingComplete={handleRecordingComplete} onRecordingComplete={handleRecordingComplete}
maxSize={file_upload?.audio_max_size_mb}
/> />
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" /> <Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
</Flex> </Flex>

View File

@@ -776,7 +776,7 @@ export const zh = {
singleMaxSize: '单文件最大大小', singleMaxSize: '单文件最大大小',
unix: '个', unix: '个',
text_to_speech: '文字转语音', text_to_speech: '文字转语音',
text_to_speech_desc: '文本可以转换成语', text_to_speech_desc: '文本可以转换成语',
apps: '我的应用', apps: '我的应用',
sharing: '共享', sharing: '共享',

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

@@ -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)
}) })

View File

@@ -38,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()
@@ -131,6 +131,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
} else { } else {
setSubAgents(sub_agents) setSubAgents(sub_agents)
} }
onFeaturesLoad?.(response.features)
}) })
} }
/** /**

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

@@ -193,7 +193,10 @@ const TestChat: FC<TestChatProps> = ({
formatParams(message, conversationId, files, params), formatParams(message, conversationId, files, params),
handleStreamMessage handleStreamMessage
) )
.catch(() => setLoading(false)) .catch(() => {
updateErrorAssistantMessage(0)
setLoading(false)
})
.finally(() => { .finally(() => {
setLoading(false) setLoading(false)
setStreamLoading(false) setStreamLoading(false)
@@ -243,11 +246,12 @@ const TestChat: FC<TestChatProps> = ({
handleWorkflowStreamMessage handleWorkflowStreamMessage
) )
.catch((error) => { .catch((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], status: 'failed', content: null, subContent: error.error } newList[lastIndex] = { ...newList[lastIndex], status: 'failed', content: null, subContent: errorInfo.error }
} }
return newList return newList
}) })

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:52 * @Date: 2026-02-03 16:27:52
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 17:04:56 * @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';
@@ -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 features = useMemo(() => {
return (appRef?.current?.features || { file_type: [] }) as FeaturesConfigForm
}, [appRef])
const handleSaveFeaturesConfig = useCallback((value: FeaturesConfigForm) => { const handleSaveFeaturesConfig = useCallback((value: FeaturesConfigForm) => {
appRef?.current?.handleSaveFeaturesConfig?.(value) appRef?.current?.handleSaveFeaturesConfig?.(value)
}, [appRef]) onFeaturesChange?.(value)
}, [appRef, onFeaturesChange])
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">
<FeaturesConfig value={features} refresh={handleSaveFeaturesConfig} /> <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

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:27:56 * @Date: 2026-02-03 16:27:56
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 18:31:58 * @Last Modified time: 2026-03-18 15:38:14
*/ */
/** /**
* Copy Application Modal * Copy Application Modal
@@ -18,9 +18,11 @@ import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
import RbModal from '@/components/RbModal' import RbModal from '@/components/RbModal'
import SwitchFormItem from '@/components/FormItem/SwitchFormItem' import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
import FileUploadSettingModal from './FileUploadSettingModal' import FileUploadSettingModal from './FileUploadSettingModal'
import type { Application } from '@/views/ApplicationManagement/types';
interface FeaturesConfigModalProps { interface FeaturesConfigModalProps {
refresh: (value: FeaturesConfigForm) => void; refresh: (value: FeaturesConfigForm) => void;
source?: Application['type'];
} }
/** /**
@@ -28,6 +30,7 @@ interface FeaturesConfigModalProps {
*/ */
const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigModalProps>(({ const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigModalProps>(({
refresh, refresh,
source,
}, ref) => { }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
@@ -44,6 +47,7 @@ const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigMod
/** Open modal */ /** Open modal */
const handleOpen = (initValue: FeaturesConfigForm) => { const handleOpen = (initValue: FeaturesConfigForm) => {
setVisible(true); setVisible(true);
console.log('initValue', initValue)
form.setFieldsValue(initValue) form.setFieldsValue(initValue)
}; };
/** Copy application with new name */ /** Copy application with new name */
@@ -66,7 +70,6 @@ const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigMod
handleClose handleClose
})); }));
console.log('settings values', values)
return ( return (
<> <>
<RbModal <RbModal
@@ -81,20 +84,22 @@ const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigMod
layout="vertical" layout="vertical"
> >
<Flex vertical gap={12}> <Flex vertical gap={12}>
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]"> {source !== 'workflow' && <>
<SwitchFormItem <div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
title={t(`memoryConversation.web_search`)} <SwitchFormItem
name={['web_search', "enabled"]} title={t(`memoryConversation.web_search`)}
/> name={['web_search', "enabled"]}
</div> />
</div>
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]"> <div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
<SwitchFormItem <SwitchFormItem
title={t('application.text_to_speech')} title={t('application.text_to_speech')}
name={['text_to_speech', "enabled"]} name={['text_to_speech', "enabled"]}
desc={t('application.text_to_speech_desc')} desc={t('application.text_to_speech_desc')}
/> />
</div> </div>
</>}
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]"> <div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
<SwitchFormItem <SwitchFormItem

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-03-05 * @Date: 2026-03-05
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 18:36:09 * @Last Modified time: 2026-03-17 18:10:47
*/ */
import { forwardRef, useImperativeHandle, useState } from 'react'; import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, InputNumber, Flex, Switch, Row, Col, Radio } from 'antd'; import { Form, InputNumber, Flex, Switch, Row, Col, Radio } from 'antd';
@@ -128,7 +128,7 @@ const FileUploadSettingModal = forwardRef<FileUploadSettingModalRef, FileUploadS
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-1">{t('application.maxCount')}</div> <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"> <Form.Item label={t('application.maxCount')} name="max_file_count">
<InputNumber min={1} max={100} className="rb:w-full!" placeholder={t('common.pleaseEnter')} /> <InputNumber min={1} max={100} precision={0} className="rb:w-full!" placeholder={t('common.pleaseEnter')} />
</Form.Item> </Form.Item>
<Form.Item label={t('application.supportedTypes')}> <Form.Item label={t('application.supportedTypes')}>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-03-13 17:20:21 * @Date: 2026-03-13 17:20:21
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-16 18:31:43 * @Last Modified time: 2026-03-18 15:38:59
*/ */
import { type FC, useRef } from 'react'; import { type FC, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -10,6 +10,7 @@ import { Button } from 'antd';
import FeaturesConfigModal from './FeaturesConfigModal' import FeaturesConfigModal from './FeaturesConfigModal'
import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types' import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
import type { Application } from '@/views/ApplicationManagement/types';
/** Props for the FeaturesConfig component */ /** Props for the FeaturesConfig component */
interface FeaturesConfigProps { interface FeaturesConfigProps {
@@ -17,11 +18,13 @@ interface FeaturesConfigProps {
value: FeaturesConfigForm; value: FeaturesConfigForm;
/** Callback to propagate updated config back to the parent */ /** Callback to propagate updated config back to the parent */
refresh: (value: FeaturesConfigForm) => void; refresh: (value: FeaturesConfigForm) => void;
source?: Application['type'];
} }
const FeaturesConfig: FC<FeaturesConfigProps> = ({ const FeaturesConfig: FC<FeaturesConfigProps> = ({
value, value,
refresh refresh,
source
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Ref used to imperatively open the config modal // Ref used to imperatively open the config modal
@@ -29,6 +32,7 @@ const FeaturesConfig: FC<FeaturesConfigProps> = ({
/** Open the feature config modal pre-populated with the current values */ /** Open the feature config modal pre-populated with the current values */
const handleFeaturesConfig = () => { const handleFeaturesConfig = () => {
console.log('handleFeaturesConfig', value)
funConfigModalRef.current?.handleOpen(value) funConfigModalRef.current?.handleOpen(value)
} }
@@ -41,6 +45,7 @@ const FeaturesConfig: FC<FeaturesConfigProps> = ({
<FeaturesConfigModal <FeaturesConfigModal
ref={funConfigModalRef} ref={funConfigModalRef}
refresh={refresh} refresh={refresh}
source={source}
/> />
</> </>
) )

View File

@@ -2,7 +2,7 @@
* @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-03-17 15:53:06 * @Last Modified time: 2026-03-18 14:01:13
*/ */
/** /**
* Tool List Component * Tool List Component
@@ -107,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)
} }

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,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:47 * @Date: 2026-02-06 21:09:47
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-17 10:28:04 * @Last Modified time: 2026-03-18 15:50:31
*/ */
/** /**
* Upload File List Modal Component * Upload File List Modal Component
@@ -115,7 +115,6 @@ 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

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-17 15:39:17 * @Last Modified time: 2026-03-18 15:35:05
*/ */
/** /**
* Conversation Page * Conversation Page
@@ -60,6 +60,7 @@ const Conversation: FC = () => {
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 [fileList, setFileList] = useState<any[]>([])
const [webSearch, setWebSearch] = useState(false) const [webSearch, setWebSearch] = useState(false)
const [isHasMemory, setIsHasMemory] = useState(false)
const [memory, setMemory] = useState(true) const [memory, setMemory] = useState(true)
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm) const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
@@ -85,9 +86,10 @@ const Conversation: FC = () => {
if (shareToken && token) { if (shareToken && token) {
getExperienceConfig(token) getExperienceConfig(token)
.then(res => { .then(res => {
const response = res as { variables: Variable[]; features: FeaturesConfigForm } const response = res as { variables: Variable[]; features: FeaturesConfigForm; app_type: string; memory?: boolean; }
toolbarRef.current?.setVariables(response.variables || []) toolbarRef.current?.setVariables(response.variables || [])
setFeatures(response.features) setFeatures(response.features)
setIsHasMemory((response.app_type === 'workflow' && response.memory) || (response.app_type !== 'workflow'))
}) })
} else { } else {
setChatList([]) setChatList([])
@@ -369,7 +371,7 @@ const Conversation: FC = () => {
}} }}
extra={ extra={
<> <>
{features.web_search?.enabled && {features?.web_search?.enabled &&
<ButtonCheckbox <ButtonCheckbox
icon={OnlineIcon} icon={OnlineIcon}
checkedIcon={OnlineCheckedIcon} checkedIcon={OnlineCheckedIcon}
@@ -379,14 +381,16 @@ const Conversation: FC = () => {
{t('memoryConversation.web_search')} {t('memoryConversation.web_search')}
</ButtonCheckbox> </ButtonCheckbox>
} }
<ButtonCheckbox {isHasMemory &&
icon={MemoryFunctionIcon} <ButtonCheckbox
checkedIcon={MemoryFunctionCheckedIcon} icon={MemoryFunctionIcon}
checked={memory} checkedIcon={MemoryFunctionCheckedIcon}
onChange={handleChangeMemory} checked={memory}
> onChange={handleChangeMemory}
{t('memoryConversation.memory')} >
</ButtonCheckbox> {t('memoryConversation.memory')}
</ButtonCheckbox>
}
</> </>
} }
/> />

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-17 15:05:21 * @Last Modified time: 2026-03-18 14:34:20
*/ */
/** /**
* Workflow Chat Component * Workflow Chat Component
@@ -359,7 +359,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
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; data: Work
...newList[lastIndex], ...newList[lastIndex],
status: 'failed', status: 'failed',
content: null, content: null,
subContent: error.error subContent: errorInfo.error
} }
} }
return newList return newList

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,
features: config?.features 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>