Merge branch 'release/v0.2.8' of github.com:SuanmoSuanyangTechnology/MemoryBear into release/v0.2.8
This commit is contained in:
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -47,6 +47,19 @@ router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_scheme(request: Request, url: str) -> str:
|
||||||
|
"""
|
||||||
|
将 presigned URL 的协议替换为与当前请求一致的协议(http/https)。
|
||||||
|
解决反向代理场景下 presigned URL 协议与请求协议不匹配的问题。
|
||||||
|
"""
|
||||||
|
incoming_scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||||
|
if url.startswith("http://") and incoming_scheme == "https":
|
||||||
|
return "https://" + url[7:]
|
||||||
|
if url.startswith("https://") and incoming_scheme == "http":
|
||||||
|
return "http://" + url[8:]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
@router.post("/files", response_model=ApiResponse)
|
@router.post("/files", response_model=ApiResponse)
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -280,6 +293,7 @@ async def upload_file_with_share_token(
|
|||||||
|
|
||||||
@router.get("/files/{file_id}", response_model=Any)
|
@router.get("/files/{file_id}", response_model=Any)
|
||||||
async def download_file(
|
async def download_file(
|
||||||
|
request: Request,
|
||||||
file_id: uuid.UUID,
|
file_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@@ -327,6 +341,7 @@ async def download_file(
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
presigned_url = await storage_service.get_file_url(file_key, expires=3600)
|
presigned_url = await storage_service.get_file_url(file_key, expires=3600)
|
||||||
|
presigned_url = _match_scheme(request, presigned_url)
|
||||||
api_logger.info(f"Redirecting to presigned URL: file_key={file_key}")
|
api_logger.info(f"Redirecting to presigned URL: file_key={file_key}")
|
||||||
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -400,6 +415,7 @@ async def delete_file(
|
|||||||
|
|
||||||
@router.get("/files/{file_id}/url", response_model=ApiResponse)
|
@router.get("/files/{file_id}/url", response_model=ApiResponse)
|
||||||
async def get_file_url(
|
async def get_file_url(
|
||||||
|
request: Request,
|
||||||
file_id: uuid.UUID,
|
file_id: uuid.UUID,
|
||||||
expires: int = None,
|
expires: int = None,
|
||||||
permanent: bool = False,
|
permanent: bool = False,
|
||||||
@@ -463,6 +479,7 @@ async def get_file_url(
|
|||||||
else:
|
else:
|
||||||
# For remote storage (OSS/S3), get presigned URL
|
# For remote storage (OSS/S3), get presigned URL
|
||||||
url = await storage_service.get_file_url(file_key, expires=expires)
|
url = await storage_service.get_file_url(file_key, expires=expires)
|
||||||
|
url = _match_scheme(request, url)
|
||||||
|
|
||||||
api_logger.info(f"Generated file URL: file_id={file_id}")
|
api_logger.info(f"Generated file URL: file_id={file_id}")
|
||||||
return success(
|
return success(
|
||||||
@@ -484,6 +501,7 @@ async def get_file_url(
|
|||||||
|
|
||||||
@router.get("/public/{file_id}", response_model=Any)
|
@router.get("/public/{file_id}", response_model=Any)
|
||||||
async def public_download_file(
|
async def public_download_file(
|
||||||
|
request: Request,
|
||||||
file_id: uuid.UUID,
|
file_id: uuid.UUID,
|
||||||
expires: int = 0,
|
expires: int = 0,
|
||||||
signature: str = "",
|
signature: str = "",
|
||||||
@@ -555,6 +573,7 @@ async def public_download_file(
|
|||||||
# For remote storage, redirect to presigned URL
|
# For remote storage, redirect to presigned URL
|
||||||
try:
|
try:
|
||||||
presigned_url = await storage_service.get_file_url(file_key, expires=3600)
|
presigned_url = await storage_service.get_file_url(file_key, expires=3600)
|
||||||
|
presigned_url = _match_scheme(request, presigned_url)
|
||||||
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
api_logger.error(f"Failed to get presigned URL: {e}")
|
api_logger.error(f"Failed to get presigned URL: {e}")
|
||||||
@@ -566,6 +585,7 @@ async def public_download_file(
|
|||||||
|
|
||||||
@router.get("/permanent/{file_id}", response_model=Any)
|
@router.get("/permanent/{file_id}", response_model=Any)
|
||||||
async def permanent_download_file(
|
async def permanent_download_file(
|
||||||
|
request: Request,
|
||||||
file_id: uuid.UUID,
|
file_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
storage_service: FileStorageService = Depends(get_file_storage_service),
|
storage_service: FileStorageService = Depends(get_file_storage_service),
|
||||||
@@ -625,6 +645,7 @@ async def permanent_download_file(
|
|||||||
try:
|
try:
|
||||||
# Use a very long expiration (7 days max for most cloud providers)
|
# Use a very long expiration (7 days max for most cloud providers)
|
||||||
presigned_url = await storage_service.get_file_url(file_key, expires=604800)
|
presigned_url = await storage_service.get_file_url(file_key, expires=604800)
|
||||||
|
presigned_url = _match_scheme(request, presigned_url)
|
||||||
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
api_logger.error(f"Failed to get presigned URL: {e}")
|
api_logger.error(f"Failed to get presigned URL: {e}")
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ def list_conversations(
|
|||||||
app_service = AppService(db)
|
app_service = AppService(db)
|
||||||
app = app_service._get_app_or_404(share.app_id)
|
app = app_service._get_app_or_404(share.app_id)
|
||||||
new_end_user = end_user_repo.get_or_create_end_user(
|
new_end_user = end_user_repo.get_or_create_end_user(
|
||||||
|
app_id=share.app_id,
|
||||||
workspace_id=app.workspace_id,
|
workspace_id=app.workspace_id,
|
||||||
other_id=other_id
|
other_id=other_id
|
||||||
)
|
)
|
||||||
@@ -315,6 +316,7 @@ async def chat(
|
|||||||
app = app_service._get_app_or_404(share.app_id)
|
app = app_service._get_app_or_404(share.app_id)
|
||||||
workspace_id = app.workspace_id
|
workspace_id = app.workspace_id
|
||||||
new_end_user = end_user_repo.get_or_create_end_user(
|
new_end_user = end_user_repo.get_or_create_end_user(
|
||||||
|
app_id=share.app_id,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
other_id=other_id,
|
other_id=other_id,
|
||||||
original_user_id=user_id
|
original_user_id=user_id
|
||||||
@@ -661,6 +663,7 @@ async def config_query(
|
|||||||
content = {
|
content = {
|
||||||
"app_type": release.app.type,
|
"app_type": release.app.type,
|
||||||
"variables": workflow_service.get_start_node_variables(release.config),
|
"variables": workflow_service.get_start_node_variables(release.config),
|
||||||
|
"memory": workflow_service.is_memory_enable(release.config),
|
||||||
"features": release.config.get("features")
|
"features": release.config.get("features")
|
||||||
}
|
}
|
||||||
elif release.app.type == AppType.AGENT:
|
elif release.app.type == AppType.AGENT:
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ async def chat(
|
|||||||
workspace_id = app.workspace_id
|
workspace_id = app.workspace_id
|
||||||
end_user_repo = EndUserRepository(db)
|
end_user_repo = EndUserRepository(db)
|
||||||
new_end_user = end_user_repo.get_or_create_end_user(
|
new_end_user = end_user_repo.get_or_create_end_user(
|
||||||
|
app_id=app.id,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
other_id=other_id,
|
other_id=other_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from typing import Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.error_codes import BizCode
|
||||||
from app.schemas.tool_schema import (
|
from app.schemas.tool_schema import (
|
||||||
ToolCreateRequest, ToolUpdateRequest, ToolExecuteRequest, ParseSchemaRequest,
|
ToolCreateRequest, ToolUpdateRequest, ToolExecuteRequest, ParseSchemaRequest,
|
||||||
CustomToolTestRequest, ToolActiveUpdate
|
CustomToolTestRequest, ToolActiveUpdate
|
||||||
@@ -250,8 +252,10 @@ async def sync_mcp_tools(
|
|||||||
try:
|
try:
|
||||||
result = await service.sync_mcp_tools(tool_id, current_user.tenant_id)
|
result = await service.sync_mcp_tools(tool_id, current_user.tenant_id)
|
||||||
if not result.get("success", False):
|
if not result.get("success", False):
|
||||||
raise HTTPException(status_code=400, detail=result.get("message", "同步失败"))
|
raise BusinessException(result.get("message", "工具列表同步失败"), BizCode.BAD_REQUEST)
|
||||||
return success(data=result, msg="MCP工具列表同步完成")
|
return success(data=result, msg="MCP工具列表同步完成")
|
||||||
|
except BusinessException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@@ -274,8 +278,10 @@ async def test_tool_connection(
|
|||||||
# 普通连接测试
|
# 普通连接测试
|
||||||
result = await service.test_connection(tool_id, current_user.tenant_id)
|
result = await service.test_connection(tool_id, current_user.tenant_id)
|
||||||
if result["success"] is False:
|
if result["success"] is False:
|
||||||
raise HTTPException(status_code=400, detail=result["message"])
|
raise BusinessException(result["message"], BizCode.SERVICE_UNAVAILABLE)
|
||||||
return success(data=result, msg="连接测试完成")
|
return success(data=result, msg="连接测试完成")
|
||||||
|
except BusinessException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,6 @@ class MCPToolManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": str(e),
|
"error": "连接失败",
|
||||||
"message": "连接失败"
|
"message": str(e)
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
import re
|
import re
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, PrivateAttr
|
||||||
|
|
||||||
from app.core.logging_config import get_logger
|
from app.core.logging_config import get_logger
|
||||||
from app.core.workflow.engine.variable_pool import VariablePool
|
from app.core.workflow.engine.variable_pool import VariablePool
|
||||||
@@ -52,10 +52,11 @@ class OutputContent(BaseModel):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
_SCOPE: str | None = None
|
_SCOPE: str | None = PrivateAttr(default=None)
|
||||||
|
|
||||||
def get_scope(self) -> str:
|
def get_scope(self) -> str | None:
|
||||||
self._SCOPE = SCOPE_PATTERN.findall(self.literal)[0]
|
matches = SCOPE_PATTERN.findall(self.literal)
|
||||||
|
self._SCOPE = matches[0] if matches else None
|
||||||
return self._SCOPE
|
return self._SCOPE
|
||||||
|
|
||||||
def depends_on_scope(self, scope: str) -> bool:
|
def depends_on_scope(self, scope: str) -> bool:
|
||||||
@@ -68,6 +69,8 @@ class OutputContent(BaseModel):
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if this segment references the given scope.
|
bool: True if this segment references the given scope.
|
||||||
"""
|
"""
|
||||||
|
if not self.is_variable:
|
||||||
|
return False
|
||||||
if self._SCOPE:
|
if self._SCOPE:
|
||||||
return self._SCOPE == scope
|
return self._SCOPE == scope
|
||||||
return self.get_scope() == scope
|
return self.get_scope() == scope
|
||||||
@@ -152,7 +155,7 @@ class StreamOutputConfig(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Case 1: resolve control branch dependency
|
# Case 1: resolve control branch dependency
|
||||||
if scope in self.control_nodes.keys():
|
if scope in self.control_nodes:
|
||||||
if status is None:
|
if status is None:
|
||||||
raise RuntimeError("[Stream Output] Control node activation status not provided")
|
raise RuntimeError("[Stream Output] Control node activation status not provided")
|
||||||
if status in self.control_nodes[scope]:
|
if status in self.control_nodes[scope]:
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class ToolNode(BaseNode):
|
|||||||
def _output_types(self) -> dict[str, VariableType]:
|
def _output_types(self) -> dict[str, VariableType]:
|
||||||
return {
|
return {
|
||||||
"data": VariableType.STRING,
|
"data": VariableType.STRING,
|
||||||
"error_code": VariableType.STRING,
|
|
||||||
"execution_time": VariableType.NUMBER
|
"execution_time": VariableType.NUMBER
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +47,7 @@ class ToolNode(BaseNode):
|
|||||||
|
|
||||||
if not tenant_id:
|
if not tenant_id:
|
||||||
logger.error(f"节点 {self.node_id} 缺少租户ID")
|
logger.error(f"节点 {self.node_id} 缺少租户ID")
|
||||||
return {
|
raise ValueError("缺少租户ID")
|
||||||
"success": False,
|
|
||||||
"data": "缺少租户ID"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 渲染工具参数
|
# 渲染工具参数
|
||||||
rendered_parameters = {}
|
rendered_parameters = {}
|
||||||
@@ -83,13 +79,8 @@ class ToolNode(BaseNode):
|
|||||||
logger.info(f"节点 {self.node_id} 工具执行成功")
|
logger.info(f"节点 {self.node_id} 工具执行成功")
|
||||||
return {
|
return {
|
||||||
"data": result.data if isinstance(result.data, str) else json.dumps(result.data, ensure_ascii=False),
|
"data": result.data if isinstance(result.data, str) else json.dumps(result.data, ensure_ascii=False),
|
||||||
"error_code": "",
|
|
||||||
"execution_time": result.execution_time
|
"execution_time": result.execution_time
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
logger.error(f"节点 {self.node_id} 工具执行失败: {result.error}")
|
logger.error(f"节点 {self.node_id} 工具执行失败: {result.error}")
|
||||||
return {
|
raise ValueError(f"工具执行失败: {result.error if isinstance(result.error, str) else json.dumps(result.error, ensure_ascii=False)}")
|
||||||
"data": result.error if isinstance(result.error, str) else json.dumps(result.error, ensure_ascii=False),
|
|
||||||
"error_code": result.error_code,
|
|
||||||
"execution_time": result.execution_time
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class WorkflowConfig(Base):
|
|||||||
|
|
||||||
# 执行配置
|
# 执行配置
|
||||||
execution_config = Column(JSONB, nullable=False, default=dict)
|
execution_config = Column(JSONB, nullable=False, default=dict)
|
||||||
|
features = Column(JSONB, nullable=True, default=dict)
|
||||||
|
|
||||||
# 触发器配置(可选)
|
# 触发器配置(可选)
|
||||||
triggers = Column(JSONB, default=list)
|
triggers = Column(JSONB, default=list)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|
||||||
# ==================== 工作流执行 ====================
|
# ==================== 工作流执行 ====================
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ class AgentRunService:
|
|||||||
files: Optional[List[FileInput]]
|
files: Optional[List[FileInput]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""校验上传文件是否符合 file_upload 配置"""
|
"""校验上传文件是否符合 file_upload 配置"""
|
||||||
if not files:
|
if not files or not features_config:
|
||||||
return
|
return
|
||||||
fu = features_config.get("file_upload", {})
|
fu = features_config.get("file_upload", {})
|
||||||
if not (isinstance(fu, dict) and fu.get("enabled")):
|
if not (isinstance(fu, dict) and fu.get("enabled")):
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ from urllib.parse import urlparse, unquote
|
|||||||
|
|
||||||
import json_repair
|
import json_repair
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.error_codes import BizCode
|
from app.core.error_codes import BizCode
|
||||||
from app.core.exceptions import BusinessException
|
from app.core.exceptions import BusinessException
|
||||||
from app.core.logging_config import get_business_logger
|
from app.core.logging_config import get_business_logger
|
||||||
from app.core.models import RedBearLLM, RedBearModelConfig
|
from app.core.models import RedBearLLM, RedBearModelConfig
|
||||||
|
from app.models import FileMetadata
|
||||||
from app.models.memory_perceptual_model import PerceptualType, FileStorageService
|
from app.models.memory_perceptual_model import PerceptualType, FileStorageService
|
||||||
from app.models.prompt_optimizer_model import RoleType
|
from app.models.prompt_optimizer_model import RoleType
|
||||||
from app.repositories.memory_perceptual_repository import MemoryPerceptualRepository
|
from app.repositories.memory_perceptual_repository import MemoryPerceptualRepository
|
||||||
@@ -245,6 +247,18 @@ class MemoryPerceptualService:
|
|||||||
filename = os.path.basename(path)
|
filename = os.path.basename(path)
|
||||||
filename = unquote(filename)
|
filename = unquote(filename)
|
||||||
file_ext = os.path.splitext(filename)[1]
|
file_ext = os.path.splitext(filename)[1]
|
||||||
|
try:
|
||||||
|
file_id = uuid.UUID(filename)
|
||||||
|
stmt = select(FileMetadata).where(
|
||||||
|
FileMetadata.id == file_id
|
||||||
|
)
|
||||||
|
file = self.db.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
if file:
|
||||||
|
filename = file.file_name
|
||||||
|
file_ext = file.file_ext
|
||||||
|
except ValueError:
|
||||||
|
business_logger.debug(f"Remote file, file_id={filename}")
|
||||||
if not file_ext:
|
if not file_ext:
|
||||||
if file_type == FileType.AUDIO:
|
if file_type == FileType.AUDIO:
|
||||||
file_ext = ".mp3"
|
file_ext = ".mp3"
|
||||||
@@ -262,17 +276,17 @@ class MemoryPerceptualService:
|
|||||||
}
|
}
|
||||||
if file_type in [FileType.IMAGE, FileType.VIDEO]:
|
if file_type in [FileType.IMAGE, FileType.VIDEO]:
|
||||||
file_modalities = {
|
file_modalities = {
|
||||||
"scene": content.get("scene")
|
"scene": content.get("scene", [])
|
||||||
}
|
}
|
||||||
elif file_type in [FileType.DOCUMENT]:
|
elif file_type in [FileType.DOCUMENT]:
|
||||||
file_modalities = {
|
file_modalities = {
|
||||||
"section_count": content.get("section_count"),
|
"section_count": content.get("section_count", 0),
|
||||||
"title": content.get("title"),
|
"title": content.get("title", ""),
|
||||||
"first_line": content.get("first_line")
|
"first_line": content.get("first_line", "")
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
file_modalities = {
|
file_modalities = {
|
||||||
"speaker_count": content.get("speaker_count")
|
"speaker_count": content.get("speaker_count", 0)
|
||||||
}
|
}
|
||||||
self.repository.create_perceptual_memory(
|
self.repository.create_perceptual_memory(
|
||||||
end_user_id=uuid.UUID(end_user_id),
|
end_user_id=uuid.UUID(end_user_id),
|
||||||
@@ -280,7 +294,7 @@ class MemoryPerceptualService:
|
|||||||
file_path=file_url,
|
file_path=file_url,
|
||||||
file_name=filename,
|
file_name=filename,
|
||||||
file_ext=file_ext,
|
file_ext=file_ext,
|
||||||
summary=content.get('summary'),
|
summary=content.get('summary', ""),
|
||||||
meta_data={
|
meta_data={
|
||||||
"content": file_content,
|
"content": file_content,
|
||||||
"modalities": file_modalities
|
"modalities": file_modalities
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ import uuid
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
|
||||||
import PyPDF2
|
import PyPDF2
|
||||||
import httpx
|
import httpx
|
||||||
import magic
|
import magic
|
||||||
|
import openpyxl
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -39,6 +43,13 @@ DOC_MIME = [
|
|||||||
'application/msword',
|
'application/msword',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
]
|
]
|
||||||
|
XLSX_MIME = [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/zip'
|
||||||
|
]
|
||||||
|
CSV_MIME = ['text/csv', 'application/csv']
|
||||||
|
JSON_MIME = ['application/json']
|
||||||
|
|
||||||
|
|
||||||
class MultimodalFormatStrategy(ABC):
|
class MultimodalFormatStrategy(ABC):
|
||||||
@@ -48,22 +59,22 @@ class MultimodalFormatStrategy(ABC):
|
|||||||
self.file = file
|
self.file = file
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""格式化图片"""
|
"""格式化图片"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]:
|
async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""格式化文档"""
|
"""格式化文档"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def format_audio(self, file_type: str, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
async def format_audio(self, file_type: str, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""格式化音频"""
|
"""格式化音频"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def format_video(self, url: str) -> Dict[str, Any]:
|
async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""格式化视频"""
|
"""格式化视频"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -71,16 +82,16 @@ class MultimodalFormatStrategy(ABC):
|
|||||||
class DashScopeFormatStrategy(MultimodalFormatStrategy):
|
class DashScopeFormatStrategy(MultimodalFormatStrategy):
|
||||||
"""通义千问策略"""
|
"""通义千问策略"""
|
||||||
|
|
||||||
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""通义千问图片格式:{"type": "image", "image": "url"}"""
|
"""通义千问图片格式:{"type": "image", "image": "url"}"""
|
||||||
return {
|
return True, {
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"image": url
|
"image": url
|
||||||
}
|
}
|
||||||
|
|
||||||
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]:
|
async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""通义千问文档格式"""
|
"""通义千问文档格式"""
|
||||||
return {
|
return True, {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
|
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
|
||||||
}
|
}
|
||||||
@@ -91,26 +102,26 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
|
|||||||
url: str,
|
url: str,
|
||||||
content: bytes | None = None,
|
content: bytes | None = None,
|
||||||
transcription: Optional[str] = None
|
transcription: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
通义千问音频格式
|
通义千问音频格式
|
||||||
- 原生支持: qwen-audio 系列
|
- 原生支持: qwen-audio 系列
|
||||||
- 其他模型: 需要转录为文本
|
- 其他模型: 需要转录为文本
|
||||||
"""
|
"""
|
||||||
if transcription:
|
if transcription:
|
||||||
return {
|
return True, {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": f"<audio url=\"{url}\">\ntext_transcription:{transcription}\n</audio>"
|
"text": f"<audio url=\"{url}\">\ntext_transcription:{transcription}\n</audio>"
|
||||||
}
|
}
|
||||||
# 通义千问音频格式:{"type": "audio", "audio": "url"}
|
# 通义千问音频格式:{"type": "audio", "audio": "url"}
|
||||||
return {
|
return True, {
|
||||||
"type": "audio",
|
"type": "audio",
|
||||||
"audio": url
|
"audio": url
|
||||||
}
|
}
|
||||||
|
|
||||||
async def format_video(self, url: str) -> Dict[str, Any]:
|
async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""通义千问视频格式(qwen-vl 系列原生支持)"""
|
"""通义千问视频格式(qwen-vl 系列原生支持)"""
|
||||||
return {
|
return True, {
|
||||||
"type": "video",
|
"type": "video",
|
||||||
"video": url
|
"video": url
|
||||||
}
|
}
|
||||||
@@ -119,7 +130,7 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
|
|||||||
class BedrockFormatStrategy(MultimodalFormatStrategy):
|
class BedrockFormatStrategy(MultimodalFormatStrategy):
|
||||||
"""Bedrock/Anthropic 策略"""
|
"""Bedrock/Anthropic 策略"""
|
||||||
|
|
||||||
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Bedrock/Anthropic 格式: base64 编码
|
Bedrock/Anthropic 格式: base64 编码
|
||||||
{"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}}
|
{"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}}
|
||||||
@@ -142,7 +153,7 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
|
|||||||
|
|
||||||
logger.info(f"图片编码完成: media_type={media_type}, size={len(base64_data)}")
|
logger.info(f"图片编码完成: media_type={media_type}, size={len(base64_data)}")
|
||||||
|
|
||||||
return {
|
return True, {
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "base64",
|
"type": "base64",
|
||||||
@@ -151,13 +162,13 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]:
|
async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""Bedrock/Anthropic 文档格式(需要 base64 编码)"""
|
"""Bedrock/Anthropic 文档格式(需要 base64 编码)"""
|
||||||
# Bedrock 文档需要 base64 编码
|
# Bedrock 文档需要 base64 编码
|
||||||
text_bytes = text.encode('utf-8')
|
text_bytes = text.encode('utf-8')
|
||||||
base64_text = base64.b64encode(text_bytes).decode('utf-8')
|
base64_text = base64.b64encode(text_bytes).decode('utf-8')
|
||||||
|
|
||||||
return {
|
return True, {
|
||||||
"type": "document",
|
"type": "document",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "base64",
|
"type": "base64",
|
||||||
@@ -171,24 +182,24 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
|
|||||||
url: str,
|
url: str,
|
||||||
content: bytes | None = None,
|
content: bytes | None = None,
|
||||||
transcription: Optional[str] = None
|
transcription: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Bedrock/Anthropic 音频格式
|
Bedrock/Anthropic 音频格式
|
||||||
不支持原生音频,必须转录为文本
|
不支持原生音频,必须转录为文本
|
||||||
"""
|
"""
|
||||||
if transcription:
|
if transcription:
|
||||||
return {
|
return True, {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": f"[音频转录]\n{transcription}"
|
"text": f"[音频转录]\n{transcription}"
|
||||||
}
|
}
|
||||||
return {
|
return False, {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": "[音频文件:Bedrock 不支持原生音频,请启用音频转文本功能]"
|
"text": "[音频文件:Bedrock 不支持原生音频,请启用音频转文本功能]"
|
||||||
}
|
}
|
||||||
|
|
||||||
async def format_video(self, url: str) -> Dict[str, Any]:
|
async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""Bedrock/Anthropic 视频格式"""
|
"""Bedrock/Anthropic 视频格式"""
|
||||||
return {
|
return False, {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": f"<video url=\"{url}\">\n[视频文件,当前 provider 暂不支持]\n</video>"
|
"text": f"<video url=\"{url}\">\n[视频文件,当前 provider 暂不支持]\n</video>"
|
||||||
}
|
}
|
||||||
@@ -197,18 +208,18 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
|
|||||||
class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
||||||
"""OpenAI 策略"""
|
"""OpenAI 策略"""
|
||||||
|
|
||||||
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""OpenAI 格式: {"type": "image_url", "image_url": {"url": "..."}}"""
|
"""OpenAI 格式: {"type": "image_url", "image_url": {"url": "..."}}"""
|
||||||
return {
|
return True, {
|
||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
"image_url": {
|
"image_url": {
|
||||||
"url": url
|
"url": url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]:
|
async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""OpenAI 文档格式"""
|
"""OpenAI 文档格式"""
|
||||||
return {
|
return True, {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
|
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
|
||||||
}
|
}
|
||||||
@@ -219,14 +230,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
|||||||
url: str,
|
url: str,
|
||||||
content: bytes | None = None,
|
content: bytes | None = None,
|
||||||
transcription: Optional[str] = None
|
transcription: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
OpenAI 音频格式
|
OpenAI 音频格式
|
||||||
- gpt-4o-audio 系列支持原生音频(需要 base64 编码)
|
- gpt-4o-audio 系列支持原生音频(需要 base64 编码)
|
||||||
- 其他模型使用转录文本
|
- 其他模型使用转录文本
|
||||||
"""
|
"""
|
||||||
if transcription:
|
if transcription:
|
||||||
return {
|
return True, {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": f"<audio url=\"{url}\">\n{transcription}\n</audio>"
|
"text": f"<audio url=\"{url}\">\n{transcription}\n</audio>"
|
||||||
}
|
}
|
||||||
@@ -255,7 +266,7 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
|||||||
# supported_ext = {"wav", "mp3", "mp4", "ogg", "flac", "webm", "m4a", "wave", "x-m4a"}
|
# supported_ext = {"wav", "mp3", "mp4", "ogg", "flac", "webm", "m4a", "wave", "x-m4a"}
|
||||||
file_ext = "wav" if not file_ext else file_ext
|
file_ext = "wav" if not file_ext else file_ext
|
||||||
|
|
||||||
return {
|
return True, {
|
||||||
"type": "input_audio",
|
"type": "input_audio",
|
||||||
"input_audio": {
|
"input_audio": {
|
||||||
"data": f"data:;base64,{base64_audio}",
|
"data": f"data:;base64,{base64_audio}",
|
||||||
@@ -264,14 +275,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"下载音频失败: {e}")
|
logger.error(f"下载音频失败: {e}")
|
||||||
return {
|
return False, {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": f"[音频处理失败: {str(e)}]"
|
"text": f"[音频处理失败: {str(e)}]"
|
||||||
}
|
}
|
||||||
|
|
||||||
async def format_video(self, url: str) -> Dict[str, Any]:
|
async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""OpenAI 视频格式"""
|
"""OpenAI 视频格式"""
|
||||||
return {
|
return True, {
|
||||||
"type": "video_url",
|
"type": "video_url",
|
||||||
"video_url": {
|
"video_url": {
|
||||||
"url": url
|
"url": url
|
||||||
@@ -366,21 +377,25 @@ class MultimodalService:
|
|||||||
file.url = await self.get_file_url(file)
|
file.url = await self.get_file_url(file)
|
||||||
try:
|
try:
|
||||||
if file.type == FileType.IMAGE and "vision" in self.capability:
|
if file.type == FileType.IMAGE and "vision" in self.capability:
|
||||||
content = await self._process_image(file, strategy)
|
is_support, content = await self._process_image(file, strategy)
|
||||||
result.append(content)
|
result.append(content)
|
||||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
if is_support:
|
||||||
|
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||||
elif file.type == FileType.DOCUMENT:
|
elif file.type == FileType.DOCUMENT:
|
||||||
content = await self._process_document(file, strategy)
|
is_support, content = await self._process_document(file, strategy)
|
||||||
result.append(content)
|
result.append(content)
|
||||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
if is_support:
|
||||||
|
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||||
elif file.type == FileType.AUDIO and "audio" in self.capability:
|
elif file.type == FileType.AUDIO and "audio" in self.capability:
|
||||||
content = await self._process_audio(file, strategy)
|
is_support, content = await self._process_audio(file, strategy)
|
||||||
result.append(content)
|
result.append(content)
|
||||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
if is_support:
|
||||||
|
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||||
elif file.type == FileType.VIDEO and "video" in self.capability:
|
elif file.type == FileType.VIDEO and "video" in self.capability:
|
||||||
content = await self._process_video(file, strategy)
|
is_support, content = await self._process_video(file, strategy)
|
||||||
result.append(content)
|
result.append(content)
|
||||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
if is_support:
|
||||||
|
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"不支持的文件类型: {file.type}")
|
logger.warning(f"不支持的文件类型: {file.type}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -413,7 +428,7 @@ class MultimodalService:
|
|||||||
if end_user_id and self.api_config:
|
if end_user_id and self.api_config:
|
||||||
write_perceptual_memory.delay(end_user_id, self.api_config.model_dump(), file_type, file_url, file_message)
|
write_perceptual_memory.delay(end_user_id, self.api_config.model_dump(), file_type, file_url, file_message)
|
||||||
|
|
||||||
async def _process_image(self, file: FileInput, strategy) -> Dict[str, Any]:
|
async def _process_image(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
处理图片文件
|
处理图片文件
|
||||||
|
|
||||||
@@ -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:
|
||||||
"""获取多模态服务实例(依赖注入)"""
|
"""获取多模态服务实例(依赖注入)"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==================== 依赖注入函数 ====================
|
# ==================== 依赖注入函数 ====================
|
||||||
|
|
||||||
|
|||||||
30
api/migrations/versions/f017efe4831c_202603181652.py
Normal file
30
api/migrations/versions/f017efe4831c_202603181652.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""202603181652
|
||||||
|
|
||||||
|
Revision ID: f017efe4831c
|
||||||
|
Revises: 818c6c535e14
|
||||||
|
Create Date: 2026-03-18 16:52:21.639695
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f017efe4831c'
|
||||||
|
down_revision: Union[str, None] = '818c6c535e14'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('workflow_configs', sa.Column('features', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('workflow_configs', 'features')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-06 21:11:51
|
* @Date: 2026-02-06 21:11:51
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '共享',
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)]">
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-03-13 17:19:13
|
* @Date: 2026-03-13 17:19:13
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-13 17:26:57
|
* @Last Modified time: 2026-03-18 16:03:46
|
||||||
*/
|
*/
|
||||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
import { Checkbox, App, Form } from 'antd';
|
import { Checkbox, App, Form } from 'antd';
|
||||||
@@ -78,7 +78,7 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
|
|||||||
*/
|
*/
|
||||||
const handleToggle = (id: string, isShared: boolean) => {
|
const handleToggle = (id: string, isShared: boolean) => {
|
||||||
if (isShared) return;
|
if (isShared) return;
|
||||||
const prev = form.getFieldValue('target_workspace_ids') as string[] ?? [];
|
const prev: string[] = form.getFieldValue('target_workspace_ids') ?? [];
|
||||||
form.setFieldValue(
|
form.setFieldValue(
|
||||||
'target_workspace_ids',
|
'target_workspace_ids',
|
||||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||||
@@ -135,10 +135,16 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
|
|||||||
|
|
||||||
{/* Target space: scrollable list of workspaces with checkbox selection */}
|
{/* Target space: scrollable list of workspaces with checkbox selection */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="target_workspace_ids"
|
|
||||||
label={t('application.selectTargetSpace')}
|
label={t('application.selectTargetSpace')}
|
||||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
required
|
||||||
>
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="target_workspace_ids"
|
||||||
|
noStyle
|
||||||
|
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||||
|
>
|
||||||
|
<input type="hidden" />
|
||||||
|
</Form.Item>
|
||||||
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:divide-y rb:divide-[#EBEBEB] rb:max-h-50 rb:overflow-y-auto">
|
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:divide-y rb:divide-[#EBEBEB] rb:max-h-50 rb:overflow-y-auto">
|
||||||
{spaceList.map(space => {
|
{spaceList.map(space => {
|
||||||
const isShared = sharedIds.includes(space.id);
|
const isShared = sharedIds.includes(space.id);
|
||||||
@@ -146,11 +152,11 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
|
|||||||
<div key={space.id} className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-3 rb:cursor-pointer" onClick={() => handleToggle(space.id, isShared)}>
|
<div key={space.id} className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-3 rb:cursor-pointer" onClick={() => handleToggle(space.id, isShared)}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isShared || selectedIds.includes(space.id)}
|
checked={isShared || selectedIds.includes(space.id)}
|
||||||
disabled={isShared} // already-shared workspaces cannot be unselected
|
disabled={isShared}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={() => handleToggle(space.id, isShared)}
|
onChange={() => handleToggle(space.id, isShared)}
|
||||||
/>
|
/>
|
||||||
<span className="rb:flex-1 rb:text-sm">{space.name}</span>
|
<span className="rb:flex-1 rb:text-sm">{space.name}</span>
|
||||||
{/* Badge shown when the app is already shared with this workspace */}
|
|
||||||
{isShared && (
|
{isShared && (
|
||||||
<span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span>
|
<span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 16:27: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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')}>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const ApplicationConfig: React.FC = () => {
|
|||||||
// State
|
// State
|
||||||
const [application, setApplication] = useState<Application | null>(null);
|
const [application, setApplication] = useState<Application | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState('arrangement');
|
const [activeTab, setActiveTab] = useState('arrangement');
|
||||||
|
const [features, setFeatures] = useState<import('./types').FeaturesConfigForm | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTab(source === 'sharing' ? 'test' : 'arrangement')
|
setActiveTab(source === 'sharing' ? 'test' : 'arrangement')
|
||||||
@@ -114,10 +115,12 @@ const ApplicationConfig: React.FC = () => {
|
|||||||
refresh={getApplicationInfo}
|
refresh={getApplicationInfo}
|
||||||
appRef={application?.type === 'agent' ? agentRef : application?.type === 'multi_agent' ? clusterRef : application?.type === 'workflow' ? workflowRef : undefined}
|
appRef={application?.type === 'agent' ? agentRef : application?.type === 'multi_agent' ? clusterRef : application?.type === 'workflow' ? workflowRef : undefined}
|
||||||
workflowRef={workflowRef}
|
workflowRef={workflowRef}
|
||||||
|
features={features}
|
||||||
|
onFeaturesChange={setFeatures}
|
||||||
/>
|
/>
|
||||||
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} />}
|
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} onFeaturesLoad={setFeatures} />}
|
||||||
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} />}
|
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} onFeaturesLoad={setFeatures} />}
|
||||||
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} />}
|
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} onFeaturesLoad={setFeatures} />}
|
||||||
{activeTab === 'api' && <Api application={application} />}
|
{activeTab === 'api' && <Api application={application} />}
|
||||||
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
|
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
|
||||||
{activeTab === 'statistics' && <Statistics application={application} />}
|
{activeTab === 'statistics' && <Statistics application={application} />}
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 16:34:12
|
* @Date: 2026-02-03 16:34:12
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-13 17:36:16
|
* @Last Modified time: 2026-03-18 16:15:43
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, type MouseEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, App, Flex, Row, Col, Collapse } from 'antd';
|
import { Button, App, Flex, Row, Col, Collapse } from 'antd';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import type { MySharedOutItem } from './types';
|
import type { MySharedOutItem } from './types';
|
||||||
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
|
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
|
||||||
|
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
||||||
|
|
||||||
const MySharing: React.FC = () => {
|
const MySharing: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -20,7 +21,8 @@ const MySharing: React.FC = () => {
|
|||||||
useEffect(() => { getList() }, [])
|
useEffect(() => { getList() }, [])
|
||||||
|
|
||||||
const getList = () => {
|
const getList = () => {
|
||||||
mySharedOutList().then(res => setData(res as MySharedOutItem[]))
|
mySharedOutList()
|
||||||
|
.then(res => setData(res as MySharedOutItem[]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Group items by target_workspace_id */
|
/** Group items by target_workspace_id */
|
||||||
@@ -57,7 +59,8 @@ const MySharing: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelOne = (item: MySharedOutItem) => {
|
const handleCancelOne = (item: MySharedOutItem, e: MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }),
|
title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }),
|
||||||
okText: t('common.confirm'),
|
okText: t('common.confirm'),
|
||||||
@@ -71,87 +74,94 @@ const MySharing: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
/** Navigate to application configuration page */
|
||||||
|
const handleEdit = (item: MySharedOutItem) => {
|
||||||
|
let url = `/#/application/config/${item.source_app_id}`
|
||||||
|
window.open(url);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12} className="rb:h-[calc(100vh-148px)]! rb:overflow-y-auto!">
|
||||||
{grouped.map(({ workspace, items }) => (
|
<BodyWrapper loading={false} empty={data.length === 0}>
|
||||||
<Collapse
|
{grouped.map(({ workspace, items }) => (
|
||||||
key={workspace.target_workspace_id}
|
<Collapse
|
||||||
defaultActiveKey={[workspace.target_workspace_id]}
|
key={workspace.target_workspace_id}
|
||||||
items={[{
|
defaultActiveKey={[workspace.target_workspace_id]}
|
||||||
key: workspace.target_workspace_id,
|
items={[{
|
||||||
label: (
|
key: workspace.target_workspace_id,
|
||||||
<Flex align="center" gap={12}>
|
label: (
|
||||||
{workspace.target_workspace_icon
|
<Flex align="center" gap={12}>
|
||||||
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
|
{workspace.target_workspace_icon
|
||||||
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
|
||||||
{workspace.target_workspace_name[0]}
|
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||||
</div>
|
{workspace.target_workspace_name[0]}
|
||||||
}
|
|
||||||
<div>
|
|
||||||
<span className="rb:font-medium">{workspace.target_workspace_name}</span>
|
|
||||||
<div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div>
|
|
||||||
</div>
|
|
||||||
</Flex>
|
|
||||||
),
|
|
||||||
extra: (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
|
|
||||||
>
|
|
||||||
{t('application.allCancel')}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<Row gutter={[12, 12]}>
|
|
||||||
{items.map(item => (
|
|
||||||
<Col key={item.id} span={6} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative">
|
|
||||||
<div
|
|
||||||
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/close.svg')]"
|
|
||||||
onClick={() => handleCancelOne(item)}
|
|
||||||
/>
|
|
||||||
<Flex gap={8} align="center">
|
|
||||||
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
|
||||||
{item.source_app_name[0]}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rb:font-medium">{item.source_app_name}</div>
|
}
|
||||||
</Flex>
|
<div>
|
||||||
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
|
<span className="rb:font-medium">{workspace.target_workspace_name}</span>
|
||||||
<Flex gap={5} justify="space-between">
|
<div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div>
|
||||||
<span className="rb:text-[#5B6167]">{t('application.type')}</span>
|
</div>
|
||||||
<span className={clsx({
|
</Flex>
|
||||||
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent',
|
),
|
||||||
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent',
|
extra: (
|
||||||
})}>
|
<Button
|
||||||
{t(`application.${item.source_app_type}`)}
|
size="small"
|
||||||
</span>
|
onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
|
||||||
|
>
|
||||||
|
{t('application.allCancel')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
{items.map(item => (
|
||||||
|
<Col key={item.id} span={6} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative rb:cursor-pointer" onClick={() => handleEdit(item)}>
|
||||||
|
<div
|
||||||
|
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]"
|
||||||
|
onClick={(e) => handleCancelOne(item, e)}
|
||||||
|
/>
|
||||||
|
<Flex gap={8} align="center">
|
||||||
|
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||||
|
{item.source_app_name[0]}
|
||||||
|
</div>
|
||||||
|
<div className="rb:font-medium">{item.source_app_name}</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex gap={5} justify="space-between">
|
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
|
||||||
<span className="rb:text-[#5B6167]">{t('application.version')}</span>
|
<Flex gap={5} justify="space-between">
|
||||||
<span>{item.source_app_version}</span>
|
<span className="rb:text-[#5B6167]">{t('application.type')}</span>
|
||||||
|
<span className={clsx({
|
||||||
|
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent',
|
||||||
|
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent',
|
||||||
|
})}>
|
||||||
|
{t(`application.${item.source_app_type}`)}
|
||||||
|
</span>
|
||||||
|
</Flex>
|
||||||
|
<Flex gap={5} justify="space-between">
|
||||||
|
<span className="rb:text-[#5B6167]">{t('application.version')}</span>
|
||||||
|
<span>{item.source_app_version}</span>
|
||||||
|
</Flex>
|
||||||
|
<Flex gap={5} justify="space-between">
|
||||||
|
<span className="rb:text-[#5B6167]">{t('application.permission')}</span>
|
||||||
|
<span className={clsx({
|
||||||
|
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable',
|
||||||
|
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly',
|
||||||
|
})}>
|
||||||
|
{t(`application.${item.permission}`)}
|
||||||
|
</span>
|
||||||
|
</Flex>
|
||||||
|
<Flex gap={5} justify="space-between">
|
||||||
|
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span>
|
||||||
|
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex gap={5} justify="space-between">
|
</Col>
|
||||||
<span className="rb:text-[#5B6167]">{t('application.permission')}</span>
|
))}
|
||||||
<span className={clsx({
|
</Row>
|
||||||
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable',
|
),
|
||||||
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly',
|
}]}
|
||||||
})}>
|
/>
|
||||||
{t(`application.${item.permission}`)}
|
))}
|
||||||
</span>
|
</BodyWrapper>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex gap={5} justify="space-between">
|
|
||||||
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span>
|
|
||||||
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Col>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
),
|
|
||||||
}]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 16:34:12
|
* @Date: 2026-02-03 16:34:12
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-16 09:56:02
|
* @Last Modified time: 2026-03-18 10:50:33
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Application Management Page
|
* Application Management Page
|
||||||
@@ -185,7 +185,7 @@ const ApplicationManagement: React.FC = () => {
|
|||||||
<PageScrollList<Application, Query>
|
<PageScrollList<Application, Query>
|
||||||
ref={scrollListRef}
|
ref={scrollListRef}
|
||||||
url={getApplicationListUrl}
|
url={getApplicationListUrl}
|
||||||
query={{ ...query, shared_only: activeTab === 'sharing' }}
|
query={{ ...query, shared_only: activeTab === 'sharing', include_shared: activeTab !== 'apps' }}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<RbCard
|
<RbCard
|
||||||
title={item.name}
|
title={item.name}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 16:34:15
|
* @Date: 2026-02-03 16:34:15
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-16 09:55:52
|
* @Last Modified time: 2026-03-18 10:50:27
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Type definitions for Application Management
|
* Type definitions for Application Management
|
||||||
@@ -16,6 +16,7 @@ export interface Query {
|
|||||||
search: string;
|
search: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
shared_only?: boolean;
|
shared_only?: boolean;
|
||||||
|
include_shared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-06 21:09: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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/*
|
/*
|
||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 18:32:23
|
* @Date: 2026-02-03 18:32:23
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-02-03 18:32:23
|
* @Last Modified time: 2026-03-17 17:36:49
|
||||||
*/
|
*/
|
||||||
import { type FC, useEffect, useState } from 'react'
|
import { type FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -90,7 +90,7 @@ const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text'
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = async () => {
|
||||||
if (!data.file_path) return
|
if (!data.file_path) return
|
||||||
window.open(data.file_path, '_blank')
|
window.open(data.file_path, '_blank')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ interface CanvasToolbarProps {
|
|||||||
isHandMode: boolean;
|
isHandMode: boolean;
|
||||||
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
canUndo: boolean;
|
|
||||||
canRedo: boolean;
|
|
||||||
onUndo: () => void;
|
|
||||||
onRedo: () => void;
|
|
||||||
addNotes: () => void;
|
addNotes: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-06 21:10:56
|
* @Date: 2026-02-06 21:10:56
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user