Compare commits
181 Commits
release/v0
...
release/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f092e08f4 | ||
|
|
8e7603bcc4 | ||
|
|
a079358028 | ||
|
|
fa29a39920 | ||
|
|
2146c555d2 | ||
|
|
240f1d431b | ||
|
|
726148d7ee | ||
|
|
0f1b1d7d10 | ||
|
|
11aa2e1f9e | ||
|
|
ca654cca74 | ||
|
|
bd1f649bd0 | ||
|
|
ea00747c66 | ||
|
|
3db031891e | ||
|
|
fb6ca3909a | ||
|
|
929afb1770 | ||
|
|
6235584b2e | ||
|
|
0b1ea33b41 | ||
|
|
3929f811b8 | ||
|
|
551a2b59a5 | ||
|
|
9a765ac71e | ||
|
|
83e26732de | ||
|
|
52fdfc7744 | ||
|
|
4e544325a0 | ||
|
|
99a2f396fd | ||
|
|
0157c9d262 | ||
|
|
5ddacab162 | ||
|
|
a51e34852c | ||
|
|
36f670b2e9 | ||
|
|
cbcbc8822c | ||
|
|
aa2d1e7a35 | ||
|
|
39b2f3ba0e | ||
|
|
43064ab71b | ||
|
|
4144f0b9b5 | ||
|
|
08f0be17ce | ||
|
|
2915e464bf | ||
|
|
152559ae46 | ||
|
|
1f531f1ace | ||
|
|
7ec947189c | ||
|
|
b4615bacdc | ||
|
|
e849fed5c1 | ||
|
|
0f5cae4590 | ||
|
|
1c3029f360 | ||
|
|
e2411e0bdd | ||
|
|
7af88b19cf | ||
|
|
c3f8dbd4bc | ||
|
|
c1e48fde86 | ||
|
|
f644c84fbb | ||
|
|
d0afce27c4 | ||
|
|
b84aba71e7 | ||
|
|
2e481df465 | ||
|
|
a322ec4fd5 | ||
|
|
bdbf9c0609 | ||
|
|
ef7d59e442 | ||
|
|
27b782e12a | ||
|
|
37a22fbfa9 | ||
|
|
d798d101f7 | ||
|
|
825f225f63 | ||
|
|
4d5e2958dc | ||
|
|
6105d46198 | ||
|
|
7aec157859 | ||
|
|
13abb03d87 | ||
|
|
e8947ad0bb | ||
|
|
7056865726 | ||
|
|
c2c832f8c9 | ||
|
|
6bc4f04293 | ||
|
|
9d150ab353 | ||
|
|
f045b59b2d | ||
|
|
d584b47280 | ||
|
|
3e995cd971 | ||
|
|
b018e35ada | ||
|
|
86a0aa1f9f | ||
|
|
d523e4f3c6 | ||
|
|
186d097e00 | ||
|
|
c5cfe557da | ||
|
|
f786a66a3c | ||
|
|
ebd51928d7 | ||
|
|
2258b5c43c | ||
|
|
8c804a1011 | ||
|
|
1a4c2d7cd0 | ||
|
|
83fcabadae | ||
|
|
33d522b387 | ||
|
|
5997458aaf | ||
|
|
68f9471caf | ||
|
|
ecbb61db27 | ||
|
|
b42815ee7a | ||
|
|
49d7398e14 | ||
|
|
91589c1497 | ||
|
|
18ca83d763 | ||
|
|
4bbc561625 | ||
|
|
f52b681133 | ||
|
|
f6efa0d711 | ||
|
|
0fccc91dac | ||
|
|
8d8c6c695a | ||
|
|
57342259ce | ||
|
|
be46ed8865 | ||
|
|
04b2205769 | ||
|
|
76ba357982 | ||
|
|
2c318f6e60 | ||
|
|
3df8af3852 | ||
|
|
8b9ab8a841 | ||
|
|
750dbcc7c3 | ||
|
|
291767031c | ||
|
|
22ffe6ef1d | ||
|
|
02df1a70f3 | ||
|
|
8c5fa9c441 | ||
|
|
e6c558c2a0 | ||
|
|
1089a52ca0 | ||
|
|
c7fb9ab8e3 | ||
|
|
e24217a6ba | ||
|
|
f042f44501 | ||
|
|
56c98648f9 | ||
|
|
956efe6a09 | ||
|
|
bb64ad23dd | ||
|
|
a97326df74 | ||
|
|
1503f8781a | ||
|
|
163ddbb6ed | ||
|
|
7bbfd33ca0 | ||
|
|
0ea47ce890 | ||
|
|
38f891235c | ||
|
|
4d83c074d9 | ||
|
|
0e9672df80 | ||
|
|
abc7460539 | ||
|
|
4bb2ccfba7 | ||
|
|
969d428320 | ||
|
|
ff64522c50 | ||
|
|
65dc1a8f48 | ||
|
|
859b7f3c7f | ||
|
|
da3f875555 | ||
|
|
44d63a44da | ||
|
|
7e5e1609b0 | ||
|
|
d94adcb19c | ||
|
|
83894df260 | ||
|
|
7b99a32a1e | ||
|
|
06d1f54030 | ||
|
|
599ccb6bde | ||
|
|
db9050c302 | ||
|
|
71b3b665b5 | ||
|
|
3b8a806661 | ||
|
|
774719fb50 | ||
|
|
8ddacb7bc9 | ||
|
|
262a9ddc48 | ||
|
|
70f84b65ec | ||
|
|
ec5cb42f67 | ||
|
|
0802481fd2 | ||
|
|
548ba0ae36 | ||
|
|
376d5ca7d0 | ||
|
|
55438136b0 | ||
|
|
82db3517d7 | ||
|
|
130490c022 | ||
|
|
ff6459e439 | ||
|
|
dfcc85a466 | ||
|
|
be2ce854a1 | ||
|
|
e492dcd968 | ||
|
|
55bfee856d | ||
|
|
f951075551 | ||
|
|
964086a08a | ||
|
|
67501025b3 | ||
|
|
e1cc5c841a | ||
|
|
6b839bd5a8 | ||
|
|
1e63dd8d2d | ||
|
|
fab9272124 | ||
|
|
2f66fd9aae | ||
|
|
5616583fa1 | ||
|
|
3f0e991112 | ||
|
|
72bba0662f | ||
|
|
090f46006a | ||
|
|
abe0c7e7d1 | ||
|
|
6516f56ada | ||
|
|
ea391dc44e | ||
|
|
e21f713de0 | ||
|
|
3498e2e884 | ||
|
|
ea8edc5914 | ||
|
|
b62c40dba3 | ||
|
|
0832337839 | ||
|
|
b82f4491fb | ||
|
|
bdf0c256b3 | ||
|
|
3d91a9e926 | ||
|
|
779dbdea26 | ||
|
|
e8e342c206 | ||
|
|
78829d36cc | ||
|
|
396493ad2b |
@@ -194,6 +194,7 @@ def delete_app(
|
||||
def copy_app(
|
||||
app_id: uuid.UUID,
|
||||
new_name: Optional[str] = None,
|
||||
payload: app_schema.CopyAppRequest = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
@@ -205,6 +206,8 @@ def copy_app(
|
||||
- 不影响原应用
|
||||
"""
|
||||
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(
|
||||
"用户请求复制应用",
|
||||
extra={
|
||||
@@ -254,6 +257,27 @@ def get_agent_config(
|
||||
return success(data=app_schema.AgentConfig.model_validate(cfg))
|
||||
|
||||
|
||||
@router.get("/{app_id}/opening", summary="获取应用开场白配置")
|
||||
@cur_workspace_access_guard()
|
||||
def get_opening(
|
||||
app_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
"""返回开场白文本和预设问题,供前端对话界面初始化时展示"""
|
||||
workspace_id = current_user.current_workspace_id
|
||||
cfg = app_service.get_agent_config(db, app_id=app_id, workspace_id=workspace_id)
|
||||
features = cfg.features or {}
|
||||
if hasattr(features, "model_dump"):
|
||||
features = features.model_dump()
|
||||
opening = features.get("opening_statement", {})
|
||||
return success(data=app_schema.OpeningResponse(
|
||||
enabled=opening.get("enabled", False),
|
||||
statement=opening.get("statement"),
|
||||
suggested_questions=opening.get("suggested_questions", []),
|
||||
))
|
||||
|
||||
|
||||
@router.post("/{app_id}/publish", summary="发布应用(生成不可变快照)")
|
||||
@cur_workspace_access_guard()
|
||||
def publish_app(
|
||||
@@ -496,7 +520,7 @@ async def draft_run(
|
||||
# 提前验证和准备(在流式响应开始前完成)
|
||||
from app.services.app_service import AppService
|
||||
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 app.core.exceptions import BusinessException
|
||||
from app.services.draft_run_service import AgentRunService
|
||||
@@ -513,11 +537,12 @@ async def draft_run(
|
||||
service._validate_app_accessible(app, workspace_id)
|
||||
|
||||
if payload.user_id is None:
|
||||
# 先获取 app 的 workspace_id
|
||||
end_user_repo = EndUserRepository(db)
|
||||
new_end_user = end_user_repo.get_or_create_end_user(
|
||||
app_id=app_id,
|
||||
workspace_id=app.workspace_id,
|
||||
other_id=str(current_user.id),
|
||||
original_user_id=str(current_user.id) # Save original user_id to other_id
|
||||
)
|
||||
payload.user_id = str(new_end_user.id)
|
||||
|
||||
@@ -534,18 +559,29 @@ async def draft_run(
|
||||
service._check_agent_config(app_id)
|
||||
|
||||
# 2. 获取 Agent 配置
|
||||
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)
|
||||
# 共享应用:从最新发布版本读配置快照,而非草稿
|
||||
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)
|
||||
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. 获取模型配置
|
||||
model_config = None
|
||||
if agent_cfg.default_model_config_id:
|
||||
model_config = db.get(ModelConfig, agent_cfg.default_model_config_id)
|
||||
if not model_config:
|
||||
from app.core.exceptions import ResourceNotFoundException
|
||||
raise ResourceNotFoundException("模型配置", str(agent_cfg.default_model_config_id))
|
||||
# 3. 获取模型配置
|
||||
model_config = None
|
||||
if agent_cfg.default_model_config_id:
|
||||
model_config = db.get(ModelConfig, agent_cfg.default_model_config_id)
|
||||
if not model_config:
|
||||
from app.core.exceptions import ResourceNotFoundException
|
||||
raise ResourceNotFoundException("模型配置", str(agent_cfg.default_model_config_id))
|
||||
|
||||
# 流式返回
|
||||
if payload.stream:
|
||||
@@ -701,7 +737,17 @@ async def draft_run(
|
||||
msg="多 Agent 任务执行成功"
|
||||
)
|
||||
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. 流式返回
|
||||
if payload.stream:
|
||||
logger.debug(
|
||||
@@ -845,11 +891,12 @@ async def draft_run_compare(
|
||||
service._validate_app_accessible(app, workspace_id)
|
||||
|
||||
if payload.user_id is None:
|
||||
# 先获取 app 的 workspace_id
|
||||
end_user_repo = EndUserRepository(db)
|
||||
new_end_user = end_user_repo.get_or_create_end_user(
|
||||
app_id=app_id,
|
||||
workspace_id=app.workspace_id,
|
||||
other_id=str(current_user.id),
|
||||
original_user_id=str(current_user.id) # Save original user_id to other_id
|
||||
)
|
||||
payload.user_id = str(new_end_user.id)
|
||||
|
||||
@@ -898,7 +945,12 @@ async def draft_run_compare(
|
||||
"conversation_id": model_item.conversation_id # 传递每个模型的 conversation_id
|
||||
})
|
||||
|
||||
|
||||
# 从 features 中读取功能开关(与 draft_run 保持一致)
|
||||
features_config: dict = agent_cfg.features or {}
|
||||
if hasattr(features_config, 'model_dump'):
|
||||
features_config = features_config.model_dump()
|
||||
web_search_feature = features_config.get("web_search", {})
|
||||
web_search = isinstance(web_search_feature, dict) and web_search_feature.get("enabled", False)
|
||||
|
||||
# 流式返回
|
||||
if payload.stream:
|
||||
@@ -915,7 +967,7 @@ async def draft_run_compare(
|
||||
variables=payload.variables,
|
||||
storage_type=storage_type,
|
||||
user_rag_memory_id=user_rag_memory_id,
|
||||
web_search=True,
|
||||
web_search=web_search,
|
||||
memory=True,
|
||||
parallel=payload.parallel,
|
||||
timeout=payload.timeout or 60,
|
||||
@@ -946,7 +998,7 @@ async def draft_run_compare(
|
||||
variables=payload.variables,
|
||||
storage_type=storage_type,
|
||||
user_rag_memory_id=user_rag_memory_id,
|
||||
web_search=True,
|
||||
web_search=web_search,
|
||||
memory=True,
|
||||
parallel=payload.parallel,
|
||||
timeout=payload.timeout or 60,
|
||||
|
||||
@@ -15,7 +15,7 @@ import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -47,6 +47,19 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _match_scheme(request: Request, url: str) -> str:
|
||||
"""
|
||||
将 presigned URL 的协议替换为与当前请求一致的协议(http/https)。
|
||||
解决反向代理场景下 presigned URL 协议与请求协议不匹配的问题。
|
||||
"""
|
||||
incoming_scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
if url.startswith("http://") and incoming_scheme == "https":
|
||||
return "https://" + url[7:]
|
||||
if url.startswith("https://") and incoming_scheme == "http":
|
||||
return "http://" + url[8:]
|
||||
return url
|
||||
|
||||
|
||||
@router.post("/files", response_model=ApiResponse)
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
@@ -78,7 +91,7 @@ async def upload_file(
|
||||
|
||||
if file_size > settings.MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail=f"The file size exceeds the {settings.MAX_FILE_SIZE} byte limit"
|
||||
)
|
||||
|
||||
@@ -159,7 +172,6 @@ async def upload_file_with_share_token(
|
||||
|
||||
# Get share and release info from share_token
|
||||
service = ReleaseShareService(db)
|
||||
share_info = service.get_shared_release_info(share_token=share_data.share_token)
|
||||
|
||||
# Get share object to access app_id
|
||||
share = service.repo.get_by_share_token(share_data.share_token)
|
||||
@@ -280,6 +292,7 @@ async def upload_file_with_share_token(
|
||||
|
||||
@router.get("/files/{file_id}", response_model=Any)
|
||||
async def download_file(
|
||||
request: Request,
|
||||
file_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -327,6 +340,7 @@ async def download_file(
|
||||
else:
|
||||
try:
|
||||
presigned_url = await storage_service.get_file_url(file_key, expires=3600)
|
||||
presigned_url = _match_scheme(request, presigned_url)
|
||||
api_logger.info(f"Redirecting to presigned URL: file_key={file_key}")
|
||||
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
|
||||
except FileNotFoundError:
|
||||
@@ -400,6 +414,7 @@ async def delete_file(
|
||||
|
||||
@router.get("/files/{file_id}/url", response_model=ApiResponse)
|
||||
async def get_file_url(
|
||||
request: Request,
|
||||
file_id: uuid.UUID,
|
||||
expires: int = None,
|
||||
permanent: bool = False,
|
||||
@@ -463,6 +478,7 @@ async def get_file_url(
|
||||
else:
|
||||
# For remote storage (OSS/S3), get presigned URL
|
||||
url = await storage_service.get_file_url(file_key, expires=expires)
|
||||
url = _match_scheme(request, url)
|
||||
|
||||
api_logger.info(f"Generated file URL: file_id={file_id}")
|
||||
return success(
|
||||
@@ -482,8 +498,54 @@ async def get_file_url(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/public-url", response_model=ApiResponse)
|
||||
async def get_permanent_file_url(
|
||||
file_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
storage_service: FileStorageService = Depends(get_file_storage_service),
|
||||
):
|
||||
"""
|
||||
获取文件的永久公开 URL(无过期时间)。
|
||||
|
||||
- 本地存储:返回 API 永久访问地址(基于 FILE_LOCAL_SERVER_URL 配置)
|
||||
- 远程存储(OSS/S3):返回 bucket 公读地址(需 bucket 已配置公共读权限)
|
||||
"""
|
||||
file_metadata = db.query(FileMetadata).filter(FileMetadata.id == file_id).first()
|
||||
if not file_metadata:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="The file does not exist")
|
||||
|
||||
if file_metadata.status != "completed":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File upload not completed, status: {file_metadata.status}")
|
||||
|
||||
file_key = file_metadata.file_key
|
||||
storage = storage_service.storage
|
||||
|
||||
try:
|
||||
if isinstance(storage, LocalStorage):
|
||||
url = f"{settings.FILE_LOCAL_SERVER_URL}/storage/permanent/{file_id}"
|
||||
else:
|
||||
url = await storage.get_permanent_url(file_key)
|
||||
if not url:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Permanent URL not supported for current storage backend")
|
||||
|
||||
api_logger.info(f"Generated permanent URL: file_id={file_id}")
|
||||
return success(
|
||||
data={"url": url, "expires_in": None, "permanent": True, "file_name": file_metadata.file_name},
|
||||
msg="Permanent file URL generated successfully"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
api_logger.error(f"Failed to generate permanent URL: {e}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to generate permanent URL: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/public/{file_id}", response_model=Any)
|
||||
async def public_download_file(
|
||||
request: Request,
|
||||
file_id: uuid.UUID,
|
||||
expires: int = 0,
|
||||
signature: str = "",
|
||||
@@ -555,6 +617,7 @@ async def public_download_file(
|
||||
# For remote storage, redirect to presigned URL
|
||||
try:
|
||||
presigned_url = await storage_service.get_file_url(file_key, expires=3600)
|
||||
presigned_url = _match_scheme(request, presigned_url)
|
||||
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
|
||||
except Exception as e:
|
||||
api_logger.error(f"Failed to get presigned URL: {e}")
|
||||
@@ -566,6 +629,7 @@ async def public_download_file(
|
||||
|
||||
@router.get("/permanent/{file_id}", response_model=Any)
|
||||
async def permanent_download_file(
|
||||
request: Request,
|
||||
file_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
storage_service: FileStorageService = Depends(get_file_storage_service),
|
||||
@@ -625,6 +689,7 @@ async def permanent_download_file(
|
||||
try:
|
||||
# Use a very long expiration (7 days max for most cloud providers)
|
||||
presigned_url = await storage_service.get_file_url(file_key, expires=604800)
|
||||
presigned_url = _match_scheme(request, presigned_url)
|
||||
return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND)
|
||||
except Exception as e:
|
||||
api_logger.error(f"Failed to get presigned URL: {e}")
|
||||
|
||||
@@ -195,10 +195,9 @@ async def get_workspace_end_users(
|
||||
api_logger.warning(f"Redis 缓存写入失败: {str(e)}")
|
||||
|
||||
# 触发社区聚类补全任务(异步,不阻塞接口响应)
|
||||
# 对有 ExtractedEntity 但无 Community 节点的存量用户自动补跑全量聚类
|
||||
try:
|
||||
from app.tasks import init_community_clustering_for_users
|
||||
init_community_clustering_for_users.delay(end_user_ids=end_user_ids)
|
||||
init_community_clustering_for_users.delay(end_user_ids=end_user_ids, workspace_id=str(workspace_id))
|
||||
api_logger.info(f"已触发社区聚类补全任务,候选用户数: {len(end_user_ids)}")
|
||||
except Exception as e:
|
||||
api_logger.warning(f"触发社区聚类补全任务失败(不影响主流程): {str(e)}")
|
||||
@@ -603,9 +602,12 @@ async def dashboard_data(
|
||||
)
|
||||
neo4j_data["total_memory"] = total_memory_data.get("total_memory_count", 0)
|
||||
# total_app: 统计当前空间下的所有app数量
|
||||
from app.repositories import app_repository
|
||||
apps_orm = app_repository.get_apps_by_workspace_id(db, workspace_id)
|
||||
neo4j_data["total_app"] = len(apps_orm)
|
||||
# 包含自有app + 被分享给本工作空间的app
|
||||
from app.services import app_service as _app_svc
|
||||
_, total_app = _app_svc.AppService(db).list_apps(
|
||||
workspace_id=workspace_id, include_shared=True, pagesize=1
|
||||
)
|
||||
neo4j_data["total_app"] = total_app
|
||||
api_logger.info(f"成功获取记忆总量: {neo4j_data['total_memory']}, 应用数量: {neo4j_data['total_app']}")
|
||||
except Exception as e:
|
||||
api_logger.warning(f"获取记忆总量失败: {str(e)}")
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.core.response_utils import success
|
||||
from app.db import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas import conversation_schema
|
||||
from app.schemas.response_schema import ApiResponse
|
||||
from app.services.conversation_service import ConversationService
|
||||
|
||||
@@ -32,35 +33,47 @@ def get_memory_count(
|
||||
@router.get("/{end_user_id}/conversations", response_model=ApiResponse)
|
||||
def get_conversations(
|
||||
end_user_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
pagesize: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Retrieve all conversations for the current user in a specific group.
|
||||
Retrieve conversations for the current user in a specific group with pagination.
|
||||
|
||||
Args:
|
||||
end_user_id (UUID): The group identifier.
|
||||
page (int): Page number (1-based). Defaults to 1.
|
||||
pagesize (int): Number of items per page. Defaults to 20.
|
||||
current_user (User, optional): The authenticated user.
|
||||
db (Session, optional): SQLAlchemy session.
|
||||
|
||||
Returns:
|
||||
ApiResponse: Contains a list of conversation IDs.
|
||||
|
||||
Notes:
|
||||
- Initializes the ConversationService with the current DB session.
|
||||
- Returns only conversation IDs for lightweight response.
|
||||
- Logs can be added to trace requests in production.
|
||||
ApiResponse: Contains a paginated list of conversations.
|
||||
"""
|
||||
page = max(1, page)
|
||||
page_size = max(1, min(pagesize, 100)) # Limit page size between 1 and 100
|
||||
conversation_service = ConversationService(db)
|
||||
conversations = conversation_service.get_user_conversations(
|
||||
end_user_id
|
||||
conversations, total = conversation_service.get_user_conversations(
|
||||
end_user_id,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
return success(data=[
|
||||
{
|
||||
"id": conversation.id,
|
||||
"title": conversation.title
|
||||
} for conversation in conversations
|
||||
], msg="get conversations success")
|
||||
return success(data={
|
||||
"items": [
|
||||
{
|
||||
"id": conversation.id,
|
||||
"title": conversation.title
|
||||
} for conversation in conversations
|
||||
],
|
||||
"total": total,
|
||||
"page": {
|
||||
"page": page,
|
||||
"pagesize": page_size,
|
||||
"total": total,
|
||||
"hasnext": (page * page_size) < total
|
||||
},
|
||||
}, msg="get conversations success")
|
||||
|
||||
|
||||
@router.get("/{end_user_id}/messages", response_model=ApiResponse)
|
||||
@@ -90,11 +103,7 @@ def get_messages(
|
||||
conversation_id,
|
||||
)
|
||||
messages = [
|
||||
{
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"created_at": int(message.created_at.timestamp() * 1000),
|
||||
}
|
||||
conversation_schema.Message.model_validate(message)
|
||||
for message in messages_obj
|
||||
]
|
||||
return success(data=messages, msg="get conversation history success")
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.core.logging_config import get_business_logger
|
||||
from app.core.response_utils import success, fail
|
||||
from app.db import get_db, get_db_read
|
||||
from app.dependencies import get_share_user_id, ShareTokenData
|
||||
from app.models.app_model import App
|
||||
from app.models.app_model import AppType
|
||||
from app.repositories import knowledge_repository
|
||||
from app.repositories.end_user_repository import EndUserRepository
|
||||
@@ -22,6 +21,7 @@ from app.schemas import release_share_schema, conversation_schema
|
||||
from app.schemas.response_schema import PageData, PageMeta
|
||||
from app.services import workspace_service
|
||||
from app.services.app_chat_service import AppChatService, get_app_chat_service
|
||||
from app.services.app_service import AppService
|
||||
from app.services.auth_service import create_access_token
|
||||
from app.services.conversation_service import ConversationService
|
||||
from app.services.release_share_service import ReleaseShareService
|
||||
@@ -215,8 +215,11 @@ def list_conversations(
|
||||
service = SharedChatService(db)
|
||||
share, release = service.get_release_by_share_token(share_data.share_token, password)
|
||||
end_user_repo = EndUserRepository(db)
|
||||
app_service = AppService(db)
|
||||
app = app_service._get_app_or_404(share.app_id)
|
||||
new_end_user = end_user_repo.get_or_create_end_user(
|
||||
app_id=share.app_id,
|
||||
workspace_id=app.workspace_id,
|
||||
other_id=other_id
|
||||
)
|
||||
logger.debug(new_end_user.id)
|
||||
@@ -308,25 +311,29 @@ async def chat(
|
||||
|
||||
# Store end_user_id in database with original user_id
|
||||
end_user_repo = EndUserRepository(db)
|
||||
app_service = AppService(db)
|
||||
app = app_service._get_app_or_404(share.app_id)
|
||||
workspace_id = app.workspace_id
|
||||
new_end_user = end_user_repo.get_or_create_end_user(
|
||||
app_id=share.app_id,
|
||||
workspace_id=workspace_id,
|
||||
other_id=other_id,
|
||||
original_user_id=user_id # Save original user_id to other_id
|
||||
original_user_id=user_id
|
||||
)
|
||||
end_user_id = str(new_end_user.id)
|
||||
|
||||
appid = share.app_id
|
||||
# appid = share.app_id
|
||||
"""获取存储类型和工作空间的ID"""
|
||||
|
||||
# 直接通过 SQLAlchemy 查询 app(仅查询未删除的应用)
|
||||
app = db.query(App).filter(
|
||||
App.id == appid,
|
||||
App.is_active.is_(True)
|
||||
).first()
|
||||
if not app:
|
||||
raise BusinessException("应用不存在", BizCode.APP_NOT_FOUND)
|
||||
# app = db.query(App).filter(
|
||||
# App.id == appid,
|
||||
# App.is_active.is_(True)
|
||||
# ).first()
|
||||
# if not app:
|
||||
# raise BusinessException("应用不存在", BizCode.APP_NOT_FOUND)
|
||||
|
||||
workspace_id = app.workspace_id
|
||||
# workspace_id = app.workspace_id
|
||||
|
||||
# 直接从 workspace 获取 storage_type(公开分享场景无需权限检查)
|
||||
storage_type = workspace_service.get_workspace_storage_type_without_auth(
|
||||
@@ -610,11 +617,11 @@ async def chat(
|
||||
|
||||
# 多 Agent 非流式返回
|
||||
result = await app_chat_service.workflow_chat(
|
||||
|
||||
message=payload.message,
|
||||
conversation_id=conversation.id, # 使用已创建的会话 ID
|
||||
user_id=end_user_id, # 转换为字符串
|
||||
variables=payload.variables,
|
||||
files=payload.files,
|
||||
config=config,
|
||||
web_search=payload.web_search,
|
||||
memory=payload.memory,
|
||||
@@ -654,17 +661,21 @@ async def config_query(
|
||||
workflow_service = WorkflowService(db)
|
||||
content = {
|
||||
"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")
|
||||
}
|
||||
elif release.app.type == AppType.AGENT:
|
||||
content = {
|
||||
"app_type": release.app.type,
|
||||
"variables": release.config.get("variables")
|
||||
"variables": release.config.get("variables"),
|
||||
"features": release.config.get("features")
|
||||
}
|
||||
elif release.app.type == AppType.MULTI_AGENT:
|
||||
content = {
|
||||
"app_type": release.app.type,
|
||||
"variables": []
|
||||
"variables": [],
|
||||
"features": release.config.get("features")
|
||||
}
|
||||
else:
|
||||
return fail(msg="Unsupported app type", code=BizCode.APP_TYPE_NOT_SUPPORTED)
|
||||
|
||||
@@ -95,8 +95,8 @@ async def chat(
|
||||
end_user_repo = EndUserRepository(db)
|
||||
new_end_user = end_user_repo.get_or_create_end_user(
|
||||
app_id=app.id,
|
||||
workspace_id=workspace_id,
|
||||
other_id=other_id,
|
||||
original_user_id=other_id # Save original user_id to other_id
|
||||
)
|
||||
end_user_id = str(new_end_user.id)
|
||||
web_search = True
|
||||
@@ -280,6 +280,7 @@ async def chat(
|
||||
memory=memory,
|
||||
storage_type=storage_type,
|
||||
user_rag_memory_id=user_rag_memory_id,
|
||||
files=payload.files,
|
||||
app_id=app.id,
|
||||
workspace_id=workspace_id,
|
||||
release_id=app.current_release.id
|
||||
|
||||
@@ -3,8 +3,11 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.error_codes import BizCode
|
||||
from app.schemas.tool_schema import (
|
||||
ToolCreateRequest, ToolUpdateRequest, ToolExecuteRequest, ParseSchemaRequest, CustomToolTestRequest
|
||||
ToolCreateRequest, ToolUpdateRequest, ToolExecuteRequest, ParseSchemaRequest,
|
||||
CustomToolTestRequest, ToolActiveUpdate
|
||||
)
|
||||
|
||||
from app.core.response_utils import success
|
||||
@@ -73,6 +76,8 @@ async def get_tool_methods(
|
||||
if methods is None:
|
||||
raise HTTPException(status_code=404, detail="工具不存在")
|
||||
return success(data=methods, msg="获取工具方法成功")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -118,6 +123,8 @@ async def create_tool(
|
||||
raise HTTPException(status_code=400, detail=e.message)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -146,6 +153,8 @@ async def update_tool(
|
||||
return success(msg="工具更新成功")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -156,7 +165,7 @@ async def delete_tool(
|
||||
current_user: User = Depends(get_current_user),
|
||||
service: ToolService = Depends(get_tool_service)
|
||||
):
|
||||
"""删除工具"""
|
||||
"""删除工具(逻辑删除,is_active=False)"""
|
||||
try:
|
||||
success_flag = service.delete_tool(tool_id, current_user.tenant_id)
|
||||
if not success_flag:
|
||||
@@ -168,6 +177,32 @@ async def delete_tool(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{tool_id}/active", response_model=ApiResponse)
|
||||
async def set_tool_active(
|
||||
tool_id: str,
|
||||
request: ToolActiveUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
service: ToolService = Depends(get_tool_service)
|
||||
):
|
||||
"""设置工具可用状态(启用/禁用)
|
||||
|
||||
- is_active=true: 启用工具
|
||||
- is_active=false: 禁用工具(等同于删除,但可恢复)
|
||||
"""
|
||||
try:
|
||||
success_flag = service.set_tool_active(tool_id, current_user.tenant_id, request.is_active)
|
||||
if not success_flag:
|
||||
raise HTTPException(status_code=404, detail="工具不存在")
|
||||
action = "启用" if request.is_active else "禁用"
|
||||
return success(msg=f"工具已{action}")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/execution/execute", response_model=ApiResponse)
|
||||
async def execute_tool(
|
||||
request: ToolExecuteRequest,
|
||||
@@ -196,6 +231,8 @@ async def execute_tool(
|
||||
},
|
||||
msg="工具执行完成"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -225,8 +262,10 @@ async def sync_mcp_tools(
|
||||
try:
|
||||
result = await service.sync_mcp_tools(tool_id, current_user.tenant_id)
|
||||
if not result.get("success", False):
|
||||
raise HTTPException(status_code=400, detail=result.get("message", "同步失败"))
|
||||
raise BusinessException(result.get("message", "工具列表同步失败"), BizCode.BAD_REQUEST)
|
||||
return success(data=result, msg="MCP工具列表同步完成")
|
||||
except BusinessException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -249,8 +288,10 @@ async def test_tool_connection(
|
||||
# 普通连接测试
|
||||
result = await service.test_connection(tool_id, current_user.tenant_id)
|
||||
if result["success"] is False:
|
||||
raise HTTPException(status_code=400, detail=result["message"])
|
||||
raise BusinessException(result["message"], BizCode.SERVICE_UNAVAILABLE)
|
||||
return success(data=result, msg="连接测试完成")
|
||||
except BusinessException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ class Settings:
|
||||
|
||||
# File Upload
|
||||
MAX_FILE_SIZE: int = int(os.getenv("MAX_FILE_SIZE", "52428800"))
|
||||
MAX_FILE_COUNT: int = int(os.getenv("MAX_FILE_COUNT", "20"))
|
||||
FILE_PATH: str = os.getenv("FILE_PATH", "/files")
|
||||
FILE_URL_EXPIRES: int = int(os.getenv("FILE_URL_EXPIRES", "3600"))
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.core.memory.agent.utils.llm_tools import ReadState, WriteState
|
||||
from app.schemas.memory_agent_schema import AgentMemoryDataset
|
||||
|
||||
|
||||
def content_input_node(state: ReadState) -> ReadState:
|
||||
@@ -17,6 +18,9 @@ def content_input_node(state: ReadState) -> ReadState:
|
||||
|
||||
content = state['messages'][0].content if state.get('messages') else ''
|
||||
# Return content and maintain all state information
|
||||
for pronoun in AgentMemoryDataset.PRONOUN:
|
||||
content = content.replace(pronoun, AgentMemoryDataset.NAME)
|
||||
|
||||
return {"data": content}
|
||||
|
||||
|
||||
@@ -35,4 +39,7 @@ def content_input_write(state: WriteState) -> WriteState:
|
||||
|
||||
content = state['messages'][0].content if state.get('messages') else ''
|
||||
# Return content and maintain all state information
|
||||
for pronoun in AgentMemoryDataset.PRONOUN:
|
||||
content = content.replace(pronoun, AgentMemoryDataset.NAME)
|
||||
|
||||
return {"data": content}
|
||||
|
||||
@@ -69,11 +69,13 @@ class LabelPropagationEngine:
|
||||
connector: Neo4jConnector,
|
||||
config_id: Optional[str] = None,
|
||||
llm_model_id: Optional[str] = None,
|
||||
embedding_model_id: Optional[str] = None,
|
||||
):
|
||||
self.connector = connector
|
||||
self.repo = CommunityRepository(connector)
|
||||
self.config_id = config_id
|
||||
self.llm_model_id = llm_model_id
|
||||
self.embedding_model_id = embedding_model_id
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# 公开接口
|
||||
@@ -423,6 +425,12 @@ class LabelPropagationEngine:
|
||||
- name / summary:若有 llm_model_id 则调用 LLM 生成,否则用实体名称拼接兜底
|
||||
"""
|
||||
try:
|
||||
# 先检查属性是否已完整,完整则跳过,避免重复生成
|
||||
check_embedding = bool(self.embedding_model_id)
|
||||
if await self.repo.is_community_complete(community_id, end_user_id, check_embedding=check_embedding):
|
||||
logger.debug(f"[Clustering] 社区 {community_id} 属性已完整,跳过生成")
|
||||
return
|
||||
|
||||
members = await self.repo.get_community_members(community_id, end_user_id)
|
||||
if not members:
|
||||
return
|
||||
@@ -468,12 +476,28 @@ class LabelPropagationEngine:
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clustering] LLM 生成社区元数据失败,使用兜底值: {e}")
|
||||
|
||||
# 生成 summary_embedding
|
||||
summary_embedding: Optional[List[float]] = None
|
||||
if self.embedding_model_id and summary:
|
||||
try:
|
||||
from app.db import get_db_context
|
||||
from app.core.memory.utils.llm.llm_utils import MemoryClientFactory
|
||||
|
||||
with get_db_context() as db:
|
||||
embedder = MemoryClientFactory(db).get_embedder_client(self.embedding_model_id)
|
||||
vectors = await embedder.response([summary])
|
||||
if vectors:
|
||||
summary_embedding = vectors[0]
|
||||
except Exception as e:
|
||||
logger.warning(f"[Clustering] 社区 {community_id} 生成 summary_embedding 失败: {e}")
|
||||
|
||||
await self.repo.update_community_metadata(
|
||||
community_id=community_id,
|
||||
end_user_id=end_user_id,
|
||||
name=name,
|
||||
summary=summary,
|
||||
core_entities=core_entities,
|
||||
summary_embedding=summary_embedding,
|
||||
)
|
||||
logger.debug(f"[Clustering] 社区 {community_id} 元数据已更新: name={name}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -94,72 +94,16 @@ def knowledge_retrieval(
|
||||
db_knowledge = knowledge_repository.get_knowledge_by_id(db, knowledge_id=kb_id)
|
||||
if db_knowledge and db_knowledge.chunk_num > 0 and db_knowledge.status == 1:
|
||||
# Process shared knowledge base
|
||||
if db_knowledge.permission_id.lower() == knowledge_model.PermissionType.Share:
|
||||
knowledgeshare = knowledgeshare_repository.get_knowledgeshare_by_id(db=db,
|
||||
knowledgeshare_id=db_knowledge.id)
|
||||
if knowledgeshare:
|
||||
db_knowledge = knowledge_repository.get_knowledge_by_id(db,
|
||||
knowledge_id=knowledgeshare.source_kb_id)
|
||||
if not (db_knowledge and db_knowledge.chunk_num > 0 and db_knowledge.status == 1):
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
if str(db_knowledge.id) not in kb_ids:
|
||||
kb_ids.append(str(db_knowledge.id))
|
||||
if str(db_knowledge.workspace_id) not in workspace_ids:
|
||||
workspace_ids.append(str(db_knowledge.workspace_id))
|
||||
if not chat_model:
|
||||
chat_model = Base(
|
||||
key=db_knowledge.llm.api_keys[0].api_key,
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
base_url=db_knowledge.llm.api_keys[0].api_base
|
||||
)
|
||||
if not embedding_model:
|
||||
embedding_model = OpenAIEmbed(
|
||||
key=db_knowledge.embedding.api_keys[0].api_key,
|
||||
model_name=db_knowledge.embedding.api_keys[0].model_name,
|
||||
base_url=db_knowledge.embedding.api_keys[0].api_base
|
||||
)
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
# Retrieve according to the configured retrieval type
|
||||
match kb_config["retrieve_type"]:
|
||||
case "participle":
|
||||
rs = vector_service.search_by_full_text(
|
||||
query=query,
|
||||
top_k=kb_config["top_k"],
|
||||
score_threshold=kb_config["similarity_threshold"],
|
||||
file_names_filter=file_names_filter
|
||||
)
|
||||
case "semantic":
|
||||
rs = vector_service.search_by_vector(
|
||||
query=query,
|
||||
top_k=kb_config["top_k"],
|
||||
score_threshold=kb_config["vector_similarity_weight"],
|
||||
file_names_filter=file_names_filter
|
||||
)
|
||||
case _: # hybrid
|
||||
rs1 = vector_service.search_by_vector(
|
||||
query=query,
|
||||
top_k=kb_config["top_k"],
|
||||
score_threshold=kb_config["vector_similarity_weight"],
|
||||
file_names_filter=file_names_filter
|
||||
)
|
||||
rs2 = vector_service.search_by_full_text(
|
||||
query=query,
|
||||
top_k=kb_config["top_k"],
|
||||
score_threshold=kb_config["similarity_threshold"],
|
||||
file_names_filter=file_names_filter
|
||||
)
|
||||
|
||||
# Deduplication of merge results
|
||||
seen_ids = set()
|
||||
unique_rs = []
|
||||
for doc in rs1 + rs2:
|
||||
if doc.metadata["doc_id"] not in seen_ids:
|
||||
seen_ids.add(doc.metadata["doc_id"])
|
||||
unique_rs.append(doc)
|
||||
rs = unique_rs
|
||||
rs, chat_model, embedding_model = _retrieve_for_knowledge(
|
||||
db=db,
|
||||
db_knowledge=db_knowledge,
|
||||
kb_config={**kb_config, "query": query}, # 或改为单独参数
|
||||
file_names_filter=file_names_filter,
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
kb_ids=kb_ids,
|
||||
workspace_ids=workspace_ids,
|
||||
)
|
||||
|
||||
all_results.extend(rs)
|
||||
except Exception as e:
|
||||
@@ -199,6 +143,115 @@ def knowledge_retrieval(
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _retrieve_for_knowledge(
|
||||
db: Session,
|
||||
db_knowledge,
|
||||
kb_config: Dict[str, Any],
|
||||
file_names_filter: list[str],
|
||||
chat_model: Base | None,
|
||||
embedding_model: OpenAIEmbed | None,
|
||||
kb_ids: list[str],
|
||||
workspace_ids: list[str],
|
||||
) -> tuple[list[DocumentChunk], Base | None, OpenAIEmbed | None]:
|
||||
"""
|
||||
对单个知识库进行检索。
|
||||
- 处理共享知识库
|
||||
- 如果是 Folder,则递归检索其子知识库
|
||||
- 返回本知识库(含子库)的检索结果和可能更新后的 chat_model/embedding_model
|
||||
"""
|
||||
results: list[DocumentChunk] = []
|
||||
|
||||
# 处理共享知识库
|
||||
if db_knowledge.permission_id.lower() == knowledge_model.PermissionType.Share:
|
||||
knowledgeshare = knowledgeshare_repository.get_knowledgeshare_by_id(db=db, knowledgeshare_id=db_knowledge.id)
|
||||
if not knowledgeshare:
|
||||
return results, chat_model, embedding_model
|
||||
|
||||
db_knowledge = knowledge_repository.get_knowledge_by_id(db, knowledge_id=knowledgeshare.source_kb_id)
|
||||
if not (db_knowledge and db_knowledge.chunk_num > 0 and db_knowledge.status == 1):
|
||||
return results, chat_model, embedding_model
|
||||
|
||||
# Folder 类型:递归处理子知识库
|
||||
if db_knowledge.type == knowledge_model.KnowledgeType.FOLDER:
|
||||
children = knowledge_repository.get_knowledges_by_parent_id(db=db, parent_id=db_knowledge.id)
|
||||
for child in children:
|
||||
if not (child and child.chunk_num > 0 and child.status == 1):
|
||||
continue
|
||||
# 递归处理子知识库(子库如果还是 Folder,会继续往下)
|
||||
child_results, chat_model, embedding_model = _retrieve_for_knowledge(
|
||||
db=db,
|
||||
db_knowledge=child,
|
||||
kb_config=kb_config,
|
||||
file_names_filter=file_names_filter,
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
kb_ids=kb_ids,
|
||||
workspace_ids=workspace_ids,
|
||||
)
|
||||
results.extend(child_results)
|
||||
return results, chat_model, embedding_model
|
||||
|
||||
# 普通知识库,执行一次检索
|
||||
if str(db_knowledge.id) not in kb_ids:
|
||||
kb_ids.append(str(db_knowledge.id))
|
||||
if str(db_knowledge.workspace_id) not in workspace_ids:
|
||||
workspace_ids.append(str(db_knowledge.workspace_id))
|
||||
|
||||
if not chat_model:
|
||||
chat_model = Base(
|
||||
key=db_knowledge.llm.api_keys[0].api_key,
|
||||
model_name=db_knowledge.llm.api_keys[0].model_name,
|
||||
base_url=db_knowledge.llm.api_keys[0].api_base,
|
||||
)
|
||||
if not embedding_model:
|
||||
embedding_model = OpenAIEmbed(
|
||||
key=db_knowledge.embedding.api_keys[0].api_key,
|
||||
model_name=db_knowledge.embedding.api_keys[0].model_name,
|
||||
base_url=db_knowledge.embedding.api_keys[0].api_base,
|
||||
)
|
||||
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
|
||||
match kb_config["retrieve_type"]:
|
||||
case "participle":
|
||||
rs = vector_service.search_by_full_text(
|
||||
query=kb_config["query"], # 或者直接把 query 作为额外参数传进来
|
||||
top_k=kb_config["top_k"],
|
||||
score_threshold=kb_config["similarity_threshold"],
|
||||
file_names_filter=file_names_filter,
|
||||
)
|
||||
case "semantic":
|
||||
rs = vector_service.search_by_vector(
|
||||
query=kb_config["query"],
|
||||
top_k=kb_config["top_k"],
|
||||
score_threshold=kb_config["vector_similarity_weight"],
|
||||
file_names_filter=file_names_filter,
|
||||
)
|
||||
case _:
|
||||
rs1 = vector_service.search_by_vector(
|
||||
query=kb_config["query"],
|
||||
top_k=kb_config["top_k"],
|
||||
score_threshold=kb_config["vector_similarity_weight"],
|
||||
file_names_filter=file_names_filter,
|
||||
)
|
||||
rs2 = vector_service.search_by_full_text(
|
||||
query=kb_config["query"],
|
||||
top_k=kb_config["top_k"],
|
||||
score_threshold=kb_config["similarity_threshold"],
|
||||
file_names_filter=file_names_filter,
|
||||
)
|
||||
# 合并去重
|
||||
seen_ids = set()
|
||||
unique_rs = []
|
||||
for doc in rs1 + rs2:
|
||||
if doc.metadata["doc_id"] not in seen_ids:
|
||||
seen_ids.add(doc.metadata["doc_id"])
|
||||
unique_rs.append(doc)
|
||||
rs = unique_rs
|
||||
|
||||
results.extend(rs)
|
||||
return results, chat_model, embedding_model
|
||||
|
||||
|
||||
def rerank(db: Session, reranker_id: uuid, query: str, docs: list[DocumentChunk], top_k: int) -> list[DocumentChunk]:
|
||||
"""
|
||||
|
||||
@@ -7,7 +7,7 @@ file operations across different storage backends.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
|
||||
class StorageBackend(ABC):
|
||||
@@ -42,6 +42,26 @@ class StorageBackend(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def upload_stream(
|
||||
self,
|
||||
file_key: str,
|
||||
stream: AsyncIterator[bytes],
|
||||
content_type: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Upload a file from an async byte stream.
|
||||
|
||||
Args:
|
||||
file_key: Unique identifier for the file.
|
||||
stream: Async iterator yielding bytes chunks.
|
||||
content_type: Optional MIME type of the file.
|
||||
|
||||
Returns:
|
||||
Total bytes written.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def download(self, file_key: str) -> bytes:
|
||||
"""
|
||||
@@ -101,3 +121,18 @@ class StorageBackend(ABC):
|
||||
URL for accessing the file.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def get_permanent_url(self, file_key: str) -> Optional[str]:
|
||||
"""
|
||||
Get a permanent public URL for the file (no expiration).
|
||||
|
||||
Returns None by default; remote storage backends should override this
|
||||
if the bucket is configured for public read access.
|
||||
|
||||
Args:
|
||||
file_key: Unique identifier for the file in the storage system.
|
||||
|
||||
Returns:
|
||||
A permanent public URL, or None if not supported.
|
||||
"""
|
||||
return None
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
import aiofiles.os
|
||||
from typing import AsyncIterator
|
||||
|
||||
from app.core.storage.base import StorageBackend
|
||||
from app.core.storage_exceptions import (
|
||||
@@ -179,6 +180,36 @@ class LocalStorage(StorageBackend):
|
||||
full_path = self._get_full_path(file_key)
|
||||
return full_path.exists()
|
||||
|
||||
async def upload_stream(
|
||||
self,
|
||||
file_key: str,
|
||||
stream: AsyncIterator[bytes],
|
||||
content_type: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Upload a file from an async byte stream to the local file system.
|
||||
|
||||
Returns:
|
||||
Total bytes written.
|
||||
"""
|
||||
full_path = self._get_full_path(file_key)
|
||||
try:
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
total = 0
|
||||
async with aiofiles.open(full_path, "wb") as f:
|
||||
async for chunk in stream:
|
||||
await f.write(chunk)
|
||||
total += len(chunk)
|
||||
logger.info(f"File stream uploaded successfully: {file_key}")
|
||||
return total
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stream upload file {file_key}: {e}")
|
||||
raise StorageUploadError(
|
||||
message=f"Failed to stream upload file: {e}",
|
||||
file_key=file_key,
|
||||
cause=e,
|
||||
)
|
||||
|
||||
async def get_url(self, file_key: str, expires: int = 3600) -> str:
|
||||
"""
|
||||
Get an access URL for the file.
|
||||
|
||||
@@ -5,8 +5,9 @@ This module provides a storage backend that stores files on Aliyun Object
|
||||
Storage Service (OSS) using the oss2 SDK.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
import oss2
|
||||
from oss2.exceptions import NoSuchKey, OssError
|
||||
@@ -125,10 +126,39 @@ class OSSStorage(StorageBackend):
|
||||
cause=e,
|
||||
)
|
||||
|
||||
async def upload_stream(
|
||||
self,
|
||||
file_key: str,
|
||||
stream: AsyncIterator[bytes],
|
||||
content_type: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Upload from async stream to OSS. Returns total bytes written."""
|
||||
buf = io.BytesIO()
|
||||
try:
|
||||
async for chunk in stream:
|
||||
buf.write(chunk)
|
||||
content = buf.getvalue()
|
||||
headers = {"Content-Type": content_type} if content_type else None
|
||||
self.bucket.put_object(file_key, content, headers=headers)
|
||||
logger.info(f"File stream uploaded to OSS successfully: {file_key}")
|
||||
return len(content)
|
||||
except OssError as e:
|
||||
logger.error(f"OSS error stream uploading file {file_key}: {e}")
|
||||
raise StorageUploadError(
|
||||
message=f"Failed to stream upload file to OSS: {e.message}",
|
||||
file_key=file_key,
|
||||
cause=e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stream upload file to OSS {file_key}: {e}")
|
||||
raise StorageUploadError(
|
||||
message=f"Failed to stream upload file to OSS: {e}",
|
||||
file_key=file_key,
|
||||
cause=e,
|
||||
)
|
||||
|
||||
async def download(self, file_key: str) -> bytes:
|
||||
"""
|
||||
Download a file from OSS.
|
||||
|
||||
Args:
|
||||
file_key: Unique identifier for the file in the storage system.
|
||||
|
||||
@@ -231,3 +261,13 @@ class OSSStorage(StorageBackend):
|
||||
logger.error(f"Failed to generate presigned URL for {file_key}: {e}")
|
||||
# Return a basic URL format as fallback
|
||||
return f"https://{self.bucket_name}.{self.endpoint.replace('https://', '').replace('http://', '')}/{file_key}"
|
||||
|
||||
async def get_permanent_url(self, file_key: str) -> str:
|
||||
"""
|
||||
Get a permanent public URL for the file (requires bucket public read).
|
||||
|
||||
Returns:
|
||||
A permanent URL in the format: https://{bucket}.{endpoint}/{file_key}
|
||||
"""
|
||||
host = self.endpoint.replace("https://", "").replace("http://", "")
|
||||
return f"https://{self.bucket_name}.{host}/{file_key}"
|
||||
|
||||
@@ -5,8 +5,9 @@ This module provides a storage backend that stores files on AWS S3
|
||||
using the boto3 SDK.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, BotoCoreError
|
||||
@@ -174,6 +175,62 @@ class S3Storage(StorageBackend):
|
||||
cause=e,
|
||||
)
|
||||
|
||||
async def upload_stream(
|
||||
self,
|
||||
file_key: str,
|
||||
stream: AsyncIterator[bytes],
|
||||
content_type: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Upload from async stream to S3 via multipart upload. Returns total bytes written."""
|
||||
extra_args = {"ContentType": content_type} if content_type else {}
|
||||
mpu = self.client.create_multipart_upload(
|
||||
Bucket=self.bucket_name, Key=file_key, **extra_args
|
||||
)
|
||||
upload_id = mpu["UploadId"]
|
||||
parts = []
|
||||
part_number = 1
|
||||
buf = io.BytesIO()
|
||||
total = 0
|
||||
min_part_size = 5 * 1024 * 1024 # S3 最小分片 5MB
|
||||
try:
|
||||
async for chunk in stream:
|
||||
buf.write(chunk)
|
||||
total += len(chunk)
|
||||
if buf.tell() >= min_part_size:
|
||||
buf.seek(0)
|
||||
resp = self.client.upload_part(
|
||||
Bucket=self.bucket_name, Key=file_key,
|
||||
UploadId=upload_id, PartNumber=part_number, Body=buf.read()
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": resp["ETag"]})
|
||||
part_number += 1
|
||||
buf = io.BytesIO()
|
||||
# 上传剩余数据(最后一片可小于 5MB)
|
||||
remaining = buf.getvalue()
|
||||
if remaining:
|
||||
resp = self.client.upload_part(
|
||||
Bucket=self.bucket_name, Key=file_key,
|
||||
UploadId=upload_id, PartNumber=part_number, Body=remaining
|
||||
)
|
||||
parts.append({"PartNumber": part_number, "ETag": resp["ETag"]})
|
||||
self.client.complete_multipart_upload(
|
||||
Bucket=self.bucket_name, Key=file_key,
|
||||
UploadId=upload_id,
|
||||
MultipartUpload={"Parts": parts}
|
||||
)
|
||||
logger.info(f"File stream uploaded to S3 successfully: {file_key}")
|
||||
return total
|
||||
except Exception as e:
|
||||
self.client.abort_multipart_upload(
|
||||
Bucket=self.bucket_name, Key=file_key, UploadId=upload_id
|
||||
)
|
||||
logger.error(f"Failed to stream upload file to S3 {file_key}: {e}")
|
||||
raise StorageUploadError(
|
||||
message=f"Failed to stream upload file to S3: {e}",
|
||||
file_key=file_key,
|
||||
cause=e,
|
||||
)
|
||||
|
||||
async def download(self, file_key: str) -> bytes:
|
||||
"""
|
||||
Download a file from S3.
|
||||
@@ -321,3 +378,12 @@ class S3Storage(StorageBackend):
|
||||
logger.error(f"Failed to generate presigned URL for {file_key}: {e}")
|
||||
# Return a basic URL format as fallback
|
||||
return f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/{file_key}"
|
||||
|
||||
async def get_permanent_url(self, file_key: str) -> str:
|
||||
"""
|
||||
Get a permanent public URL for the file (requires bucket public read).
|
||||
|
||||
Returns:
|
||||
A permanent URL in the format: https://{bucket}.s3.{region}.amazonaws.com/{file_key}
|
||||
"""
|
||||
return f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/{file_key}"
|
||||
|
||||
@@ -195,6 +195,6 @@ class MCPToolManager:
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "连接失败"
|
||||
"error": "连接失败",
|
||||
"message": str(e)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class SimpleMCPClient:
|
||||
def __init__(self, server_url: str, connection_config: Dict[str, Any] = None):
|
||||
self.server_url = server_url
|
||||
self.connection_config = connection_config or {}
|
||||
self.timeout = self.connection_config.get("timeout", 30)
|
||||
self.timeout = self.connection_config.get("timeout", 10)
|
||||
|
||||
# 确定连接类型
|
||||
self.is_websocket = server_url.startswith(("ws://", "wss://"))
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import re
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, PrivateAttr
|
||||
|
||||
from app.core.logging_config import get_logger
|
||||
from app.core.workflow.engine.variable_pool import VariablePool
|
||||
@@ -52,10 +52,11 @@ class OutputContent(BaseModel):
|
||||
)
|
||||
)
|
||||
|
||||
_SCOPE: str | None = None
|
||||
_SCOPE: str | None = PrivateAttr(default=None)
|
||||
|
||||
def get_scope(self) -> str:
|
||||
self._SCOPE = SCOPE_PATTERN.findall(self.literal)[0]
|
||||
def get_scope(self) -> str | None:
|
||||
matches = SCOPE_PATTERN.findall(self.literal)
|
||||
self._SCOPE = matches[0] if matches else None
|
||||
return self._SCOPE
|
||||
|
||||
def depends_on_scope(self, scope: str) -> bool:
|
||||
@@ -68,6 +69,8 @@ class OutputContent(BaseModel):
|
||||
Returns:
|
||||
bool: True if this segment references the given scope.
|
||||
"""
|
||||
if not self.is_variable:
|
||||
return False
|
||||
if self._SCOPE:
|
||||
return self._SCOPE == scope
|
||||
return self.get_scope() == scope
|
||||
@@ -152,7 +155,7 @@ class StreamOutputConfig(BaseModel):
|
||||
"""
|
||||
|
||||
# Case 1: resolve control branch dependency
|
||||
if scope in self.control_nodes.keys():
|
||||
if scope in self.control_nodes:
|
||||
if status is None:
|
||||
raise RuntimeError("[Stream Output] Control node activation status not provided")
|
||||
if status in self.control_nodes[scope]:
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
from app.core.error_codes import BizCode
|
||||
from app.core.exceptions import BusinessException
|
||||
from app.core.models import RedBearRerank, RedBearModelConfig
|
||||
from app.core.rag.vdb.elasticsearch.elasticsearch_vector import ElasticSearchVectorFactory
|
||||
from app.core.rag.vdb.elasticsearch.elasticsearch_vector import ElasticSearchVectorFactory, ElasticSearchVector
|
||||
from app.core.workflow.engine.state_manager import WorkflowState
|
||||
from app.core.workflow.engine.variable_pool import VariablePool
|
||||
from app.core.workflow.nodes.base_node import BaseNode
|
||||
@@ -24,6 +24,7 @@ class KnowledgeRetrievalNode(BaseNode):
|
||||
def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]):
|
||||
super().__init__(node_config, workflow_config)
|
||||
self.typed_config: KnowledgeRetrievalNodeConfig | None = None
|
||||
self.vector_service: ElasticSearchVector | None = None
|
||||
|
||||
def _output_types(self) -> dict[str, VariableType]:
|
||||
return {
|
||||
@@ -163,6 +164,50 @@ class KnowledgeRetrievalNode(BaseNode):
|
||||
)
|
||||
return reranker
|
||||
|
||||
def knowledge_retrieval(self, db, query, rs, db_knowledge, kb_config):
|
||||
if db_knowledge.type == knowledge_model.KnowledgeType.FOLDER:
|
||||
children = knowledge_repository.get_knowledges_by_parent_id(db=db, parent_id=db_knowledge.id)
|
||||
for child in children:
|
||||
if not (child and child.chunk_num > 0 and child.status == 1):
|
||||
continue
|
||||
kb_config.kb_id = child.id
|
||||
self.knowledge_retrieval(db, query, rs, child, kb_config)
|
||||
return
|
||||
self.vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
indices = f"Vector_index_{kb_config.kb_id}_Node".lower()
|
||||
match kb_config.retrieve_type:
|
||||
case RetrieveType.PARTICIPLE:
|
||||
rs.extend(self.vector_service.search_by_full_text(query=query, top_k=kb_config.top_k,
|
||||
indices=indices,
|
||||
score_threshold=kb_config.similarity_threshold))
|
||||
case RetrieveType.SEMANTIC:
|
||||
rs.extend(self.vector_service.search_by_vector(query=query, top_k=kb_config.top_k,
|
||||
indices=indices,
|
||||
score_threshold=kb_config.vector_similarity_weight))
|
||||
case RetrieveType.HYBRID:
|
||||
rs1 = self.vector_service.search_by_vector(query=query, top_k=kb_config.top_k,
|
||||
indices=indices,
|
||||
score_threshold=kb_config.vector_similarity_weight)
|
||||
rs2 = self.vector_service.search_by_full_text(query=query, top_k=kb_config.top_k,
|
||||
indices=indices,
|
||||
score_threshold=kb_config.similarity_threshold)
|
||||
|
||||
# Deduplicate hybrid retrieval results
|
||||
unique_rs = self._deduplicate_docs(rs1, rs2)
|
||||
if not unique_rs:
|
||||
return
|
||||
if self.typed_config.reranker_id:
|
||||
self.vector_service.reranker = self.get_reranker_model()
|
||||
rs.extend(self.vector_service.rerank(query=query, docs=unique_rs, top_k=kb_config.top_k))
|
||||
else:
|
||||
rs.extend(sorted(
|
||||
unique_rs,
|
||||
key=lambda d: d.metadata.get("score", 0),
|
||||
reverse=True
|
||||
)[:kb_config.top_k])
|
||||
case _:
|
||||
raise RuntimeError("Unknown retrieval type")
|
||||
|
||||
async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> Any:
|
||||
"""
|
||||
Execute the knowledge retrieval workflow node.
|
||||
@@ -191,56 +236,19 @@ class KnowledgeRetrievalNode(BaseNode):
|
||||
query = self._render_template(self.typed_config.query, variable_pool)
|
||||
with get_db_read() as db:
|
||||
knowledge_bases = self.typed_config.knowledge_bases
|
||||
existing_ids = self._get_existing_kb_ids(db, [kb.kb_id for kb in knowledge_bases])
|
||||
|
||||
if not existing_ids:
|
||||
raise RuntimeError("Knowledge base retrieval failed: the knowledge base does not exist.")
|
||||
|
||||
rs = []
|
||||
for kb_config in knowledge_bases:
|
||||
db_knowledge = knowledge_repository.get_knowledge_by_id(db=db, knowledge_id=kb_config.kb_id)
|
||||
if not db_knowledge:
|
||||
raise RuntimeError("The knowledge base does not exist or access is denied.")
|
||||
self.knowledge_retrieval(db, query, rs, db_knowledge, kb_config)
|
||||
|
||||
vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge)
|
||||
indices = f"Vector_index_{kb_config.kb_id}_Node".lower()
|
||||
match kb_config.retrieve_type:
|
||||
case RetrieveType.PARTICIPLE:
|
||||
rs.extend(vector_service.search_by_full_text(query=query, top_k=kb_config.top_k,
|
||||
indices=indices,
|
||||
score_threshold=kb_config.similarity_threshold))
|
||||
case RetrieveType.SEMANTIC:
|
||||
rs.extend(vector_service.search_by_vector(query=query, top_k=kb_config.top_k,
|
||||
indices=indices,
|
||||
score_threshold=kb_config.vector_similarity_weight))
|
||||
case RetrieveType.HYBRID:
|
||||
rs1 = vector_service.search_by_vector(query=query, top_k=kb_config.top_k,
|
||||
indices=indices,
|
||||
score_threshold=kb_config.vector_similarity_weight)
|
||||
rs2 = vector_service.search_by_full_text(query=query, top_k=kb_config.top_k,
|
||||
indices=indices,
|
||||
score_threshold=kb_config.similarity_threshold)
|
||||
|
||||
# Deduplicate hy brid retrieval results
|
||||
unique_rs = self._deduplicate_docs(rs1, rs2)
|
||||
if not unique_rs:
|
||||
continue
|
||||
if self.typed_config.reranker_id:
|
||||
vector_service.reranker = self.get_reranker_model()
|
||||
rs.extend(vector_service.rerank(query=query, docs=unique_rs, top_k=kb_config.top_k))
|
||||
else:
|
||||
rs.extend(sorted(
|
||||
unique_rs,
|
||||
key=lambda d: d.metadata.get("score", 0),
|
||||
reverse=True
|
||||
)[:kb_config.top_k])
|
||||
case _:
|
||||
raise RuntimeError("Unknown retrieval type")
|
||||
if not rs:
|
||||
return []
|
||||
if self.typed_config.reranker_id:
|
||||
vector_service.reranker = self.get_reranker_model()
|
||||
final_rs = vector_service.rerank(query=query, docs=rs, top_k=self.typed_config.reranker_top_k)
|
||||
self.vector_service.reranker = self.get_reranker_model()
|
||||
final_rs = self.vector_service.rerank(query=query, docs=rs, top_k=self.typed_config.reranker_top_k)
|
||||
else:
|
||||
final_rs = sorted(
|
||||
rs,
|
||||
|
||||
@@ -27,7 +27,6 @@ class ToolNode(BaseNode):
|
||||
def _output_types(self) -> dict[str, VariableType]:
|
||||
return {
|
||||
"data": VariableType.STRING,
|
||||
"error_code": VariableType.STRING,
|
||||
"execution_time": VariableType.NUMBER
|
||||
}
|
||||
|
||||
@@ -48,10 +47,7 @@ class ToolNode(BaseNode):
|
||||
|
||||
if not tenant_id:
|
||||
logger.error(f"节点 {self.node_id} 缺少租户ID")
|
||||
return {
|
||||
"success": False,
|
||||
"data": "缺少租户ID"
|
||||
}
|
||||
raise ValueError("缺少租户ID")
|
||||
|
||||
# 渲染工具参数
|
||||
rendered_parameters = {}
|
||||
@@ -83,13 +79,8 @@ class ToolNode(BaseNode):
|
||||
logger.info(f"节点 {self.node_id} 工具执行成功")
|
||||
return {
|
||||
"data": result.data if isinstance(result.data, str) else json.dumps(result.data, ensure_ascii=False),
|
||||
"error_code": "",
|
||||
"execution_time": result.execution_time
|
||||
}
|
||||
else:
|
||||
logger.error(f"节点 {self.node_id} 工具执行失败: {result.error}")
|
||||
return {
|
||||
"data": result.error if isinstance(result.error, str) else json.dumps(result.error, ensure_ascii=False),
|
||||
"error_code": result.error_code,
|
||||
"execution_time": result.execution_time
|
||||
}
|
||||
raise ValueError(f"工具执行失败: {result.error if isinstance(result.error, str) else json.dumps(result.error, ensure_ascii=False)}")
|
||||
|
||||
@@ -16,7 +16,7 @@ engine = create_engine(
|
||||
pool_recycle=settings.DB_POOL_RECYCLE,
|
||||
pool_timeout=settings.DB_POOL_TIMEOUT,
|
||||
connect_args={
|
||||
"options": "-c timezone=Asia/Shanghai -c statement_timeout=60000"
|
||||
"options": "-c timezone=UTC -c statement_timeout=60000"
|
||||
},
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
@@ -506,10 +506,13 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
404: "errors.common.not_found",
|
||||
405: "errors.common.method_not_allowed",
|
||||
409: "errors.common.conflict",
|
||||
413: "errors.common.payload_too_large",
|
||||
422: "errors.common.validation_failed",
|
||||
429: "errors.common.too_many_requests",
|
||||
500: "errors.common.internal_error",
|
||||
502: "errors.common.bad_gateway",
|
||||
503: "errors.common.service_unavailable",
|
||||
504: "errors.common.gateway_timeout",
|
||||
}
|
||||
|
||||
# 如果有对应的翻译键,使用翻译
|
||||
@@ -534,7 +537,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=fail(code=exc.status_code, msg=translated_message, error=translated_message)
|
||||
content=fail(code=exc.status_code, msg=translated_message, error=exc.detail)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class AgentConfig(Base):
|
||||
variables = Column(JSON, default=list, nullable=True, comment="变量配置")
|
||||
tools = Column(JSON, default=list, nullable=True, comment="工具配置")
|
||||
skills = Column(JSON, default=dict, nullable=True, comment="技能配置")
|
||||
features = Column(JSON, default=dict, nullable=True, comment="功能特性配置")
|
||||
|
||||
# 多 Agent 相关字段
|
||||
agent_role = Column(String(20), comment="Agent 角色: master|sub|standalone")
|
||||
|
||||
@@ -12,7 +12,8 @@ class EndUser(Base):
|
||||
__tablename__ = "end_users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, nullable=False, index=True)
|
||||
app_id = Column(UUID(as_uuid=True), ForeignKey("apps.id"), nullable=False)
|
||||
app_id = Column(UUID(as_uuid=True), ForeignKey("apps.id"), nullable=True)
|
||||
workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False)
|
||||
# end_user_id = Column(String, nullable=False, index=True)
|
||||
other_id = Column(String, nullable=True) # Store original user_id
|
||||
other_name = Column(String, default="", nullable=False)
|
||||
@@ -61,4 +62,7 @@ class EndUser(Base):
|
||||
app = relationship(
|
||||
"App",
|
||||
back_populates="end_users"
|
||||
)
|
||||
)
|
||||
|
||||
# 与 WorkSpace 的反向关系
|
||||
workspace = relationship("Workspace", back_populates="end_users")
|
||||
@@ -110,7 +110,10 @@ class ToolConfig(Base):
|
||||
# 元数据
|
||||
version = Column(String(50), default="1.0.0")
|
||||
tags = Column(JSON, default=list) # 标签列表
|
||||
|
||||
|
||||
# 逻辑删除标志
|
||||
is_active = Column(Boolean, default=True, server_default='true', nullable=False, index=True, comment="是否可用,False表示已删除")
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=datetime.now, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||
|
||||
@@ -35,6 +35,7 @@ class WorkflowConfig(Base):
|
||||
|
||||
# 执行配置
|
||||
execution_config = Column(JSONB, nullable=False, default=dict)
|
||||
features = Column(JSONB, nullable=True, default=dict)
|
||||
|
||||
# 触发器配置(可选)
|
||||
triggers = Column(JSONB, default=list)
|
||||
|
||||
@@ -38,6 +38,7 @@ class Workspace(Base):
|
||||
members = relationship("WorkspaceMember", back_populates="workspace") # users collaborate through membership
|
||||
api_keys = relationship("ApiKey", back_populates="workspace", cascade="all, delete-orphan") # API Keys
|
||||
memory_increments = relationship("MemoryIncrement", back_populates="workspace")
|
||||
end_users = relationship("EndUser", back_populates="workspace", cascade="all, delete-orphan")
|
||||
|
||||
class WorkspaceMember(Base):
|
||||
__tablename__ = "workspace_members"
|
||||
|
||||
@@ -90,27 +90,27 @@ class ConversationRepository:
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID = None,
|
||||
limit: int = 10,
|
||||
is_activate: bool = True
|
||||
) -> list[Conversation]:
|
||||
is_activate: bool = True,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> tuple[list[Conversation], int]:
|
||||
"""
|
||||
Retrieve recent conversations for a specific user.
|
||||
Retrieve recent conversations for a specific user with pagination.
|
||||
|
||||
This method queries conversations associated with the given user ID,
|
||||
optionally scoped to a specific workspace. Results are ordered by the
|
||||
most recently updated conversations and limited to a fixed number.
|
||||
most recently updated conversations.
|
||||
|
||||
Args:
|
||||
user_id (uuid.UUID): Unique identifier of the user.
|
||||
workspace_id (uuid.UUID, optional): Workspace scope for the query.
|
||||
If provided, only conversations under this workspace will be returned.
|
||||
limit (int): Maximum number of conversations to return.
|
||||
Defaults to 10.
|
||||
is_activate (bool): Convsersation State limit
|
||||
is_activate (bool): Conversation State limit.
|
||||
page (int): Page number (1-based). Defaults to 1.
|
||||
page_size (int): Number of items per page. Defaults to 20.
|
||||
|
||||
Returns:
|
||||
list[Conversation]: A list of conversation entities ordered by
|
||||
last updated time (descending).
|
||||
tuple[list[Conversation], int]: A list of conversation entities and total count.
|
||||
"""
|
||||
logger.info(f"Fetching conversation by user_id: {user_id}")
|
||||
|
||||
@@ -122,18 +122,25 @@ class ConversationRepository:
|
||||
if workspace_id:
|
||||
stmt = stmt.where(Conversation.workspace_id == workspace_id)
|
||||
|
||||
stmt = stmt.order_by(desc(Conversation.updated_at))
|
||||
stmt = stmt.limit(limit)
|
||||
# Calculate total count
|
||||
total = int(self.db.execute(
|
||||
select(func.count()).select_from(stmt.subquery())
|
||||
).scalar_one())
|
||||
|
||||
convsersations = list(self.db.scalars(stmt).all())
|
||||
# Apply ordering and pagination
|
||||
stmt = stmt.order_by(desc(Conversation.updated_at))
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
conversations = list(self.db.scalars(stmt).all())
|
||||
logger.info(
|
||||
"Conversation fetched successfully",
|
||||
extra={
|
||||
"user_id": str(user_id),
|
||||
"workspace_id": str(workspace_id),
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
return convsersations
|
||||
return conversations, total
|
||||
|
||||
def list_conversations(
|
||||
self,
|
||||
|
||||
@@ -32,6 +32,21 @@ class EndUserRepository:
|
||||
db_logger.error(f"查询应用 {app_id} 下宿主时出错: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_end_users_by_workspace(self, workspace_id: uuid.UUID) -> List[EndUser]:
|
||||
"""获取指定 workspace 下的所有 end_user"""
|
||||
try:
|
||||
end_users = (
|
||||
self.db.query(EndUser)
|
||||
.filter(EndUser.workspace_id == workspace_id)
|
||||
.all()
|
||||
)
|
||||
db_logger.info(f"成功查询工作空间 {workspace_id} 下的 {len(end_users)} 个终端用户")
|
||||
return end_users
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
db_logger.error(f"查询工作空间 {workspace_id} 下终端用户时出错: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_end_user_by_id(self, end_user_id: uuid.UUID) -> Optional[EndUser]:
|
||||
"""根据 end_user_id 查询宿主"""
|
||||
try:
|
||||
@@ -51,8 +66,9 @@ class EndUserRepository:
|
||||
raise
|
||||
|
||||
def get_or_create_end_user(
|
||||
self,
|
||||
app_id: uuid.UUID,
|
||||
self,
|
||||
app_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
other_id: str,
|
||||
original_user_id: Optional[str] = None
|
||||
) -> EndUser:
|
||||
@@ -60,6 +76,7 @@ class EndUserRepository:
|
||||
|
||||
Args:
|
||||
app_id: 应用ID
|
||||
workspace_id: 工作空间ID
|
||||
other_id: 第三方ID
|
||||
original_user_id: 原始用户ID (存储到 other_id)
|
||||
"""
|
||||
@@ -68,26 +85,31 @@ class EndUserRepository:
|
||||
end_user = (
|
||||
self.db.query(EndUser)
|
||||
.filter(
|
||||
EndUser.app_id == app_id,
|
||||
EndUser.workspace_id == workspace_id,
|
||||
EndUser.other_id == other_id
|
||||
)
|
||||
.order_by(EndUser.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if end_user:
|
||||
db_logger.debug(f"找到现有终端用户: 应用ID {app_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
|
||||
|
||||
# 创建新用户
|
||||
end_user = EndUser(
|
||||
app_id=app_id,
|
||||
workspace_id=workspace_id,
|
||||
other_id=other_id
|
||||
)
|
||||
self.db.add(end_user)
|
||||
self.db.commit()
|
||||
self.db.refresh(end_user)
|
||||
|
||||
db_logger.info(f"创建新终端用户: (other_id: {other_id}) for app {app_id}")
|
||||
db_logger.info(f"创建新终端用户: (other_id: {other_id}) for workspace {workspace_id}")
|
||||
return end_user
|
||||
|
||||
except Exception as e:
|
||||
@@ -314,8 +336,7 @@ class EndUserRepository:
|
||||
try:
|
||||
end_users = (
|
||||
self.db.query(EndUser)
|
||||
.join(App, EndUser.app_id == App.id)
|
||||
.filter(App.workspace_id == workspace_id)
|
||||
.filter(EndUser.workspace_id == workspace_id)
|
||||
.all()
|
||||
)
|
||||
db_logger.info(f"成功查询工作空间 {workspace_id} 下的 {len(end_users)} 个终端用户")
|
||||
@@ -402,45 +423,79 @@ class EndUserRepository:
|
||||
db_logger.error(f"获取终端用户 {end_user_id} 的 memory_config_id 时出错: {str(e)}")
|
||||
raise
|
||||
|
||||
def batch_update_memory_config_id(
|
||||
self,
|
||||
app_id: uuid.UUID,
|
||||
memory_config_id: uuid.UUID
|
||||
# def batch_update_memory_config_id(
|
||||
# self,
|
||||
# app_id: uuid.UUID,
|
||||
# memory_config_id: uuid.UUID
|
||||
# ) -> int:
|
||||
# """批量更新应用下所有终端用户的 memory_config_id
|
||||
#
|
||||
# Args:
|
||||
# app_id: 应用ID
|
||||
# memory_config_id: 新的记忆配置ID
|
||||
#
|
||||
# Returns:
|
||||
# int: 更新的行数
|
||||
# """
|
||||
# try:
|
||||
# from sqlalchemy import update
|
||||
#
|
||||
# stmt = (
|
||||
# update(EndUser)
|
||||
# .where(EndUser.app_id == app_id)
|
||||
# .values(memory_config_id=memory_config_id)
|
||||
# )
|
||||
#
|
||||
# result = self.db.execute(stmt)
|
||||
# self.db.commit()
|
||||
#
|
||||
# updated_count = result.rowcount
|
||||
#
|
||||
# db_logger.info(
|
||||
# f"批量更新终端用户记忆配置: app_id={app_id}, "
|
||||
# f"memory_config_id={memory_config_id}, updated_count={updated_count}"
|
||||
# )
|
||||
#
|
||||
# return updated_count
|
||||
#
|
||||
# except Exception as e:
|
||||
# self.db.rollback()
|
||||
# db_logger.error(
|
||||
# f"批量更新终端用户记忆配置时出错: app_id={app_id}, "
|
||||
# f"memory_config_id={memory_config_id}, error={str(e)}"
|
||||
# )
|
||||
# raise
|
||||
|
||||
def batch_update_memory_config_id_by_workspace(
|
||||
self,
|
||||
workspace_id: uuid.UUID,
|
||||
memory_config_id: uuid.UUID
|
||||
) -> int:
|
||||
"""批量更新应用下所有终端用户的 memory_config_id
|
||||
|
||||
Args:
|
||||
app_id: 应用ID
|
||||
memory_config_id: 新的记忆配置ID
|
||||
|
||||
Returns:
|
||||
int: 更新的行数
|
||||
"""
|
||||
"""批量更新工作空间下所有终端用户的 memory_config_id"""
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
|
||||
stmt = (
|
||||
update(EndUser)
|
||||
.where(EndUser.app_id == app_id)
|
||||
.where(EndUser.workspace_id == workspace_id)
|
||||
.values(memory_config_id=memory_config_id)
|
||||
)
|
||||
|
||||
|
||||
result = self.db.execute(stmt)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
updated_count = result.rowcount
|
||||
|
||||
|
||||
db_logger.info(
|
||||
f"批量更新终端用户记忆配置: app_id={app_id}, "
|
||||
f"批量更新终端用户记忆配置: workspace_id={workspace_id}, "
|
||||
f"memory_config_id={memory_config_id}, updated_count={updated_count}"
|
||||
)
|
||||
|
||||
|
||||
return updated_count
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
db_logger.error(
|
||||
f"批量更新终端用户记忆配置时出错: app_id={app_id}, "
|
||||
f"批量更新终端用户记忆配置时出错: workspace_id={workspace_id}, "
|
||||
f"memory_config_id={memory_config_id}, error={str(e)}"
|
||||
)
|
||||
raise
|
||||
@@ -492,7 +547,7 @@ class EndUserRepository:
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
|
||||
|
||||
stmt = (
|
||||
update(EndUser)
|
||||
.where(EndUser.memory_config_id == memory_config_id)
|
||||
@@ -519,10 +574,16 @@ class EndUserRepository:
|
||||
)
|
||||
raise
|
||||
|
||||
def get_end_users_by_app_id(db: Session, app_id: uuid.UUID) -> List[EndUser]:
|
||||
"""根据应用ID查询宿主(返回 EndUser ORM 列表)"""
|
||||
# def get_end_users_by_app_id(db: Session, app_id: uuid.UUID) -> List[EndUser]:
|
||||
# """根据应用ID查询宿主(返回 EndUser ORM 列表)"""
|
||||
# repo = EndUserRepository(db)
|
||||
# end_users = repo.get_end_users_by_app_id(app_id)
|
||||
# return end_users
|
||||
|
||||
def get_end_users_by_workspace(db: Session, workspace_id: uuid.UUID) -> List[EndUser]:
|
||||
"""根据工作空间ID查询终端用户(返回 EndUser ORM 列表)"""
|
||||
repo = EndUserRepository(db)
|
||||
end_users = repo.get_end_users_by_app_id(app_id)
|
||||
end_users = repo.get_end_users_by_workspace(workspace_id)
|
||||
return end_users
|
||||
|
||||
def get_end_user_by_id(db: Session, end_user_id: uuid.UUID) -> Optional[EndUser]:
|
||||
|
||||
@@ -5,7 +5,7 @@ Implicit Emotions Storage Repository
|
||||
事务由调用方控制,仓储层只使用 flush/refresh
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Generator, Optional
|
||||
|
||||
|
||||
@@ -177,22 +177,21 @@ class ImplicitEmotionsStorageRepository:
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
CST = timezone(timedelta(hours=8))
|
||||
last_done = datetime.fromisoformat(raw)
|
||||
# last_done 写入时已是 CST naive,直接使用,无需转换
|
||||
if last_done.tzinfo is not None:
|
||||
last_done = last_done.astimezone(CST).replace(tzinfo=None)
|
||||
# last_done 写入时已是 UTC aware(+00:00),确保有 tzinfo
|
||||
if last_done.tzinfo is None:
|
||||
last_done = last_done.replace(tzinfo=timezone.utc)
|
||||
|
||||
if updated_at is None:
|
||||
yield end_user_id
|
||||
continue
|
||||
# updated_at 数据库存的是 UTC naive,转为 CST naive 再比较
|
||||
# updated_at 数据库存的是 UTC naive,补上 UTC tzinfo 再比较
|
||||
if updated_at.tzinfo is None:
|
||||
updated_at_cst = updated_at.replace(tzinfo=timezone.utc).astimezone(CST).replace(tzinfo=None)
|
||||
updated_at_utc = updated_at.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
updated_at_cst = updated_at.astimezone(CST).replace(tzinfo=None)
|
||||
updated_at_utc = updated_at.astimezone(timezone.utc)
|
||||
|
||||
if last_done > updated_at_cst:
|
||||
if last_done > updated_at_utc:
|
||||
yield end_user_id
|
||||
except Exception as e:
|
||||
logger.warning(f"解析 last_done 时间戳失败: end_user_id={end_user_id}, raw={raw}, error={e}")
|
||||
|
||||
@@ -111,6 +111,20 @@ def get_knowledge_by_id(db: Session, knowledge_id: uuid.UUID) -> Knowledge | Non
|
||||
raise
|
||||
|
||||
|
||||
def get_knowledges_by_parent_id(db: Session, parent_id: uuid.UUID) -> list[Knowledge]:
|
||||
db_logger.debug(f"Query knowledge bases based on parent ID: parent_id={parent_id}")
|
||||
try:
|
||||
knowledges = db.query(Knowledge).filter(Knowledge.parent_id == parent_id).all()
|
||||
if knowledges:
|
||||
db_logger.debug(f"Knowledge bases query successful: count={len(knowledges)} (parent_id: {parent_id})")
|
||||
else:
|
||||
db_logger.debug(f"No knowledge bases found for given parent: parent_id={parent_id}")
|
||||
return knowledges
|
||||
except Exception as e:
|
||||
db_logger.error(f"Failed to query the knowledge bases based on parent ID: parent_id={parent_id} - {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def get_knowledge_by_name(db: Session, name: str, workspace_id: uuid.UUID) -> Knowledge | None:
|
||||
db_logger.debug(f"Query knowledge base based on name and workspace_id: name={name}, workspace_id={workspace_id}")
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ from app.repositories.neo4j.cypher_queries import (
|
||||
CHECK_USER_HAS_COMMUNITIES,
|
||||
UPDATE_COMMUNITY_MEMBER_COUNT,
|
||||
UPDATE_COMMUNITY_METADATA,
|
||||
GET_INCOMPLETE_COMMUNITIES,
|
||||
GET_INCOMPLETE_COMMUNITIES_WITH_EMBEDDING,
|
||||
CHECK_COMMUNITY_IS_COMPLETE,
|
||||
CHECK_COMMUNITY_IS_COMPLETE_WITH_EMBEDDING,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -170,6 +174,31 @@ class CommunityRepository:
|
||||
logger.error(f"refresh_member_count failed: {e}")
|
||||
return 0
|
||||
|
||||
async def get_incomplete_communities(self, end_user_id: str, check_embedding: bool = False) -> List[str]:
|
||||
"""查询该用户下属性不完整的 Community 节点 ID 列表。
|
||||
|
||||
Args:
|
||||
end_user_id: 用户 ID
|
||||
check_embedding: 为 True 时额外检查 summary_embedding 是否缺失(仅当用户有 embedding 模型配置时传 True)
|
||||
"""
|
||||
try:
|
||||
query = GET_INCOMPLETE_COMMUNITIES_WITH_EMBEDDING if check_embedding else GET_INCOMPLETE_COMMUNITIES
|
||||
result = await self.connector.execute_query(query, end_user_id=end_user_id)
|
||||
return [row["community_id"] for row in result]
|
||||
except Exception as e:
|
||||
logger.error(f"get_incomplete_communities failed: {e}")
|
||||
return []
|
||||
|
||||
async def is_community_complete(self, community_id: str, end_user_id: str, check_embedding: bool = False) -> bool:
|
||||
"""检查单个社区节点的属性是否完整。"""
|
||||
try:
|
||||
query = CHECK_COMMUNITY_IS_COMPLETE_WITH_EMBEDDING if check_embedding else CHECK_COMMUNITY_IS_COMPLETE
|
||||
result = await self.connector.execute_query(query, community_id=community_id, end_user_id=end_user_id)
|
||||
return result[0]["is_complete"] if result else False
|
||||
except Exception as e:
|
||||
logger.error(f"is_community_complete failed: {e}")
|
||||
return False
|
||||
|
||||
async def update_community_metadata(
|
||||
self,
|
||||
community_id: str,
|
||||
@@ -177,8 +206,9 @@ class CommunityRepository:
|
||||
name: str,
|
||||
summary: str,
|
||||
core_entities: List[str],
|
||||
summary_embedding: Optional[List[float]] = None,
|
||||
) -> bool:
|
||||
"""更新社区的名称、摘要和核心实体列表。"""
|
||||
"""更新社区的名称、摘要、核心实体列表及 summary_embedding。"""
|
||||
try:
|
||||
result = await self.connector.execute_query(
|
||||
UPDATE_COMMUNITY_METADATA,
|
||||
@@ -187,6 +217,7 @@ class CommunityRepository:
|
||||
name=name,
|
||||
summary=summary,
|
||||
core_entities=core_entities,
|
||||
summary_embedding=summary_embedding,
|
||||
)
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
|
||||
@@ -1153,10 +1153,11 @@ RETURN c.community_id AS community_id, cnt AS member_count
|
||||
|
||||
UPDATE_COMMUNITY_METADATA = """
|
||||
MATCH (c:Community {community_id: $community_id, end_user_id: $end_user_id})
|
||||
SET c.name = $name,
|
||||
c.summary = $summary,
|
||||
c.core_entities = $core_entities,
|
||||
c.updated_at = datetime()
|
||||
SET c.name = $name,
|
||||
c.summary = $summary,
|
||||
c.core_entities = $core_entities,
|
||||
c.summary_embedding = $summary_embedding,
|
||||
c.updated_at = datetime()
|
||||
RETURN c.community_id AS community_id
|
||||
"""
|
||||
|
||||
@@ -1202,3 +1203,38 @@ RETURN
|
||||
properties(r) AS r_props,
|
||||
startNode(r) = e AS r_from_e
|
||||
"""
|
||||
|
||||
CHECK_COMMUNITY_IS_COMPLETE = """
|
||||
MATCH (c:Community {community_id: $community_id, end_user_id: $end_user_id})
|
||||
RETURN (
|
||||
c.name IS NOT NULL AND c.name <> '' AND
|
||||
c.summary IS NOT NULL AND c.summary <> '' AND
|
||||
c.core_entities IS NOT NULL
|
||||
) AS is_complete
|
||||
"""
|
||||
|
||||
CHECK_COMMUNITY_IS_COMPLETE_WITH_EMBEDDING = """
|
||||
MATCH (c:Community {community_id: $community_id, end_user_id: $end_user_id})
|
||||
RETURN (
|
||||
c.name IS NOT NULL AND c.name <> '' AND
|
||||
c.summary IS NOT NULL AND c.summary <> '' AND
|
||||
c.core_entities IS NOT NULL AND
|
||||
c.summary_embedding IS NOT NULL
|
||||
) AS is_complete
|
||||
"""
|
||||
|
||||
GET_INCOMPLETE_COMMUNITIES = """
|
||||
MATCH (c:Community {end_user_id: $end_user_id})
|
||||
WHERE c.name IS NULL OR c.summary IS NULL OR c.core_entities IS NULL
|
||||
OR c.name = '' OR c.summary = ''
|
||||
RETURN c.community_id AS community_id
|
||||
"""
|
||||
|
||||
GET_INCOMPLETE_COMMUNITIES_WITH_EMBEDDING = """
|
||||
MATCH (c:Community {end_user_id: $end_user_id})
|
||||
WHERE c.name IS NULL OR c.name = ''
|
||||
OR c.summary IS NULL OR c.summary = ''
|
||||
OR c.core_entities IS NULL
|
||||
OR (c.summary_embedding IS NULL AND c.summary IS NOT NULL AND c.summary <> '(empty)')
|
||||
RETURN c.community_id AS community_id
|
||||
"""
|
||||
|
||||
@@ -27,7 +27,7 @@ class ToolRepository:
|
||||
from app.models.app_model import App
|
||||
from app.models.workflow_model import WorkflowConfig
|
||||
from app.models.workspace_model import Workspace
|
||||
|
||||
|
||||
result = db.query(Workspace.tenant_id).join(
|
||||
App, App.workspace_id == Workspace.id
|
||||
).join(
|
||||
@@ -35,7 +35,7 @@ class ToolRepository:
|
||||
).filter(
|
||||
WorkflowConfig.id == workflow_id
|
||||
).first()
|
||||
|
||||
|
||||
return result[0] if result else None
|
||||
|
||||
@staticmethod
|
||||
@@ -67,18 +67,19 @@ class ToolRepository:
|
||||
|
||||
@staticmethod
|
||||
def find_by_tenant(
|
||||
db: Session,
|
||||
tenant_id: uuid.UUID,
|
||||
name: Optional[str] = None,
|
||||
tool_type: Optional[ToolType] = None,
|
||||
status: Optional[ToolStatus] = None,
|
||||
is_enabled: Optional[bool] = None
|
||||
db: Session,
|
||||
tenant_id: uuid.UUID,
|
||||
name: Optional[str] = None,
|
||||
tool_type: Optional[ToolType] = None,
|
||||
status: Optional[ToolStatus] = None,
|
||||
is_enabled: Optional[bool] = None
|
||||
) -> List[ToolConfig]:
|
||||
"""根据租户查找工具"""
|
||||
"""根据租户查找工具(只返回未删除的)"""
|
||||
query = db.query(ToolConfig).filter(
|
||||
ToolConfig.tenant_id == tenant_id
|
||||
ToolConfig.tenant_id == tenant_id,
|
||||
ToolConfig.is_active.is_(True)
|
||||
)
|
||||
|
||||
|
||||
if name:
|
||||
query = query.filter(ToolConfig.name.ilike(f"%{name}%"))
|
||||
if tool_type:
|
||||
@@ -91,8 +92,17 @@ class ToolRepository:
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
def find_by_id_and_tenant(db:Session, tool_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[ToolConfig]:
|
||||
"""根据ID和租户查找工具"""
|
||||
def find_by_id_and_tenant(db: Session, tool_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[ToolConfig]:
|
||||
"""根据ID和租户查找工具(只返回未删除的)"""
|
||||
return db.query(ToolConfig).filter(
|
||||
ToolConfig.id == tool_id,
|
||||
ToolConfig.tenant_id == tenant_id,
|
||||
ToolConfig.is_active.is_(True)
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
def find_by_id_and_tenant_all(db: Session, tool_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[ToolConfig]:
|
||||
"""根据ID和租户查找工具(返回所有工具包括删除的)"""
|
||||
return db.query(ToolConfig).filter(
|
||||
ToolConfig.id == tool_id,
|
||||
ToolConfig.tenant_id == tenant_id
|
||||
@@ -100,29 +110,26 @@ class ToolRepository:
|
||||
|
||||
@staticmethod
|
||||
def count_by_tenant(db: Session, tenant_id: uuid.UUID) -> int:
|
||||
"""统计租户工具数量"""
|
||||
"""统计租户工具数量(只统计未删除的)"""
|
||||
return db.query(ToolConfig).filter(
|
||||
ToolConfig.tenant_id == tenant_id
|
||||
ToolConfig.tenant_id == tenant_id,
|
||||
ToolConfig.is_active.is_(True)
|
||||
).count()
|
||||
|
||||
@staticmethod
|
||||
def get_status_statistics(db: Session, tenant_id: uuid.UUID) -> List[tuple]:
|
||||
"""获取状态统计"""
|
||||
return db.query(
|
||||
ToolConfig.status,
|
||||
func.count(ToolConfig.id).label('count')
|
||||
).filter(
|
||||
ToolConfig.tenant_id == tenant_id
|
||||
return db.query(ToolConfig.status, func.count(ToolConfig.id).label('count')).filter(
|
||||
ToolConfig.tenant_id == tenant_id,
|
||||
ToolConfig.is_active.is_(True)
|
||||
).group_by(ToolConfig.status).all()
|
||||
|
||||
@staticmethod
|
||||
def get_type_statistics(db: Session, tenant_id: uuid.UUID) -> List[tuple]:
|
||||
"""获取类型统计"""
|
||||
return db.query(
|
||||
ToolConfig.tool_type,
|
||||
func.count(ToolConfig.id).label('count')
|
||||
).filter(
|
||||
ToolConfig.tenant_id == tenant_id
|
||||
return db.query(ToolConfig.tool_type, func.count(ToolConfig.id).label('count')).filter(
|
||||
ToolConfig.tenant_id == tenant_id,
|
||||
ToolConfig.is_active.is_(True)
|
||||
).group_by(ToolConfig.tool_type).all()
|
||||
|
||||
@staticmethod
|
||||
@@ -130,6 +137,7 @@ class ToolRepository:
|
||||
"""统计租户启用的工具数量"""
|
||||
return db.query(ToolConfig).filter(
|
||||
ToolConfig.tenant_id == tenant_id,
|
||||
ToolConfig.is_active.is_(True),
|
||||
ToolConfig.is_enabled == True
|
||||
).count()
|
||||
|
||||
@@ -138,7 +146,8 @@ class ToolRepository:
|
||||
"""检查租户是否已有内置工具"""
|
||||
return db.query(ToolConfig).filter(
|
||||
ToolConfig.tenant_id == tenant_id,
|
||||
ToolConfig.tool_type == ToolType.BUILTIN.value
|
||||
ToolConfig.tool_type == ToolType.BUILTIN.value,
|
||||
ToolConfig.is_active.is_(True)
|
||||
).count() > 0
|
||||
|
||||
|
||||
@@ -194,10 +203,10 @@ class ToolExecutionRepository:
|
||||
|
||||
@staticmethod
|
||||
def find_by_tool_and_tenant(
|
||||
db: Session,
|
||||
tool_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int = 100
|
||||
db: Session,
|
||||
tool_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int = 100
|
||||
) -> List[ToolExecution]:
|
||||
"""根据工具和租户查找执行记录"""
|
||||
return db.query(ToolExecution).join(
|
||||
@@ -205,4 +214,4 @@ class ToolExecutionRepository:
|
||||
).filter(
|
||||
ToolConfig.id == tool_id,
|
||||
ToolConfig.tenant_id == tenant_id
|
||||
).order_by(ToolExecution.started_at.desc()).limit(limit).all()
|
||||
).order_by(ToolExecution.started_at.desc()).limit(limit).all()
|
||||
|
||||
@@ -43,6 +43,7 @@ class WorkflowConfigRepository:
|
||||
edges: list[dict[str, Any]],
|
||||
variables: list[dict[str, Any]] | None = None,
|
||||
execution_config: dict[str, Any] | None = None,
|
||||
features: dict[str, Any] | None = None,
|
||||
triggers: list[dict[str, Any]] | None = None
|
||||
) -> WorkflowConfig:
|
||||
"""创建或更新工作流配置
|
||||
@@ -53,6 +54,7 @@ class WorkflowConfigRepository:
|
||||
edges: 边列表
|
||||
variables: 变量列表
|
||||
execution_config: 执行配置
|
||||
features: 功能特性
|
||||
triggers: 触发器列表
|
||||
|
||||
Returns:
|
||||
@@ -82,6 +84,7 @@ class WorkflowConfigRepository:
|
||||
edges=edges,
|
||||
variables=variables or [],
|
||||
execution_config=execution_config or {},
|
||||
features=features or {},
|
||||
triggers=triggers or []
|
||||
)
|
||||
self.db.add(config)
|
||||
|
||||
@@ -125,6 +125,93 @@ class SkillConfig(BaseModel):
|
||||
all_skills: Optional[bool] = Field(default=False, description="是否允许访问所有技能")
|
||||
|
||||
|
||||
# ---------- App Features ----------
|
||||
|
||||
class FileUploadConfig(BaseModel):
|
||||
"""文件上传配置"""
|
||||
enabled: bool = Field(default=False)
|
||||
# 允许的传输方式:local_file / remote_url,默认两种都允许
|
||||
allowed_transfer_methods: List[str] = Field(
|
||||
default=["local_file", "remote_url"],
|
||||
description="允许的传输方式"
|
||||
)
|
||||
# 图片文件:PNG/JPG/JPEG/GIF/WEBP,最大 20MB
|
||||
image_enabled: bool = Field(default=False)
|
||||
image_max_size_mb: int = Field(default=20)
|
||||
image_allowed_extensions: List[str] = Field(
|
||||
default=["png", "jpg", "jpeg"]
|
||||
)
|
||||
# 语音文件:MP3/WAV/M4A/OGG/FLAC,最大 50MB
|
||||
audio_enabled: bool = Field(default=False)
|
||||
audio_max_size_mb: int = Field(default=50)
|
||||
audio_allowed_extensions: List[str] = Field(
|
||||
default=["mp3", "wav", "m4a"]
|
||||
)
|
||||
# 通用文件:PDF/DOCX/XLSX/TXT/CSV/JSON,最大 100MB
|
||||
document_enabled: bool = Field(default=False)
|
||||
document_max_size_mb: int = Field(default=50)
|
||||
document_allowed_extensions: List[str] = Field(
|
||||
default=["pdf", "docx", "doc", "xlsx", "xls", "txt", "csv", "json", "md"]
|
||||
)
|
||||
# 视频文件:MP4/MOV/AVI/WebM,最大 500MB
|
||||
video_enabled: bool = Field(default=False)
|
||||
video_max_size_mb: int = Field(default=50)
|
||||
video_allowed_extensions: List[str] = Field(
|
||||
default=["mp4"]
|
||||
)
|
||||
# 最大文件数量
|
||||
max_file_count: int = Field(default=5, ge=1)
|
||||
|
||||
@field_validator("max_file_count")
|
||||
@classmethod
|
||||
def validate_max_file_count(cls, v: int) -> int:
|
||||
from app.core.config import settings
|
||||
if v > settings.MAX_FILE_COUNT:
|
||||
raise ValueError(f"max_file_count 不能超过 {settings.MAX_FILE_COUNT}")
|
||||
return v
|
||||
|
||||
|
||||
class OpeningStatementConfig(BaseModel):
|
||||
"""对话开场白配置"""
|
||||
enabled: bool = Field(default=False)
|
||||
statement: Optional[str] = Field(default=None, description="开场白内容")
|
||||
suggested_questions: List[str] = Field(default_factory=list, description="预设问题列表")
|
||||
|
||||
|
||||
class SuggestedQuestionsConfig(BaseModel):
|
||||
"""下一步问题建议配置"""
|
||||
enabled: bool = Field(default=False)
|
||||
|
||||
|
||||
class TextToSpeechConfig(BaseModel):
|
||||
"""文字转语音配置"""
|
||||
enabled: bool = Field(default=False)
|
||||
voice: Optional[str] = Field(default=None, description="语音音色")
|
||||
language: Optional[str] = Field(default=None, description="语言")
|
||||
autoplay: bool = Field(default=False, description="是否自动播放")
|
||||
|
||||
|
||||
class CitationConfig(BaseModel):
|
||||
"""引用和归属配置"""
|
||||
enabled: bool = Field(default=False)
|
||||
|
||||
|
||||
class WebSearchConfig(BaseModel):
|
||||
"""联网搜索配置"""
|
||||
enabled: bool = Field(default=False)
|
||||
search_engine: Optional[str] = Field(default=None, description="搜索引擎")
|
||||
|
||||
|
||||
class AppFeatures(BaseModel):
|
||||
"""应用功能特性配置"""
|
||||
file_upload: FileUploadConfig = Field(default_factory=FileUploadConfig)
|
||||
opening_statement: OpeningStatementConfig = Field(default_factory=OpeningStatementConfig)
|
||||
suggested_questions_after_answer: SuggestedQuestionsConfig = Field(default_factory=SuggestedQuestionsConfig)
|
||||
text_to_speech: TextToSpeechConfig = Field(default_factory=TextToSpeechConfig)
|
||||
citation: CitationConfig = Field(default_factory=CitationConfig)
|
||||
web_search: WebSearchConfig = Field(default_factory=WebSearchConfig)
|
||||
|
||||
|
||||
class ToolOldConfig(BaseModel):
|
||||
"""工具配置"""
|
||||
enabled: bool = Field(default=False, description="是否启用该工具")
|
||||
@@ -201,6 +288,9 @@ class AgentConfigCreate(BaseModel):
|
||||
# 技能配置
|
||||
skills: Optional[SkillConfig] = Field(default=dict, description="关联的技能列表")
|
||||
|
||||
# 功能特性
|
||||
features: Optional[AppFeatures] = Field(default=None, description="功能特性配置")
|
||||
|
||||
|
||||
class AppCreate(BaseModel):
|
||||
name: str
|
||||
@@ -258,6 +348,9 @@ class AgentConfigUpdate(BaseModel):
|
||||
# 技能配置
|
||||
skills: Optional[SkillConfig] = Field(default=dict, description="关联的技能列表")
|
||||
|
||||
# 功能特性
|
||||
features: Optional[AppFeatures] = Field(default=None, description="功能特性配置")
|
||||
|
||||
|
||||
# ---------- Output Schemas ----------
|
||||
|
||||
@@ -283,6 +376,10 @@ class App(BaseModel):
|
||||
source_workspace_icon: Optional[str] = None # 共享来源工作空间图标
|
||||
source_app_version: Optional[str] = None # 应用版本号
|
||||
source_app_is_active: Optional[bool] = None # 应用是否生效
|
||||
share_id: Optional[uuid.UUID] = None # 分享记录ID(取消共享时使用)
|
||||
shared_by: Optional[uuid.UUID] = None # 分享者用户ID
|
||||
shared_by_name: Optional[str] = None # 分享者名称
|
||||
shared_at: Optional[datetime.datetime] = None # 分享时间
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
|
||||
@@ -294,6 +391,10 @@ class App(BaseModel):
|
||||
def _serialize_updated_at(self, dt: datetime.datetime):
|
||||
return int(dt.timestamp() * 1000) if dt else None
|
||||
|
||||
@field_serializer("shared_at", when_used="json")
|
||||
def _serialize_shared_at(self, dt: Optional[datetime.datetime]):
|
||||
return int(dt.timestamp() * 1000) if dt else None
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
"""Agent 配置输出 Schema"""
|
||||
@@ -323,6 +424,8 @@ class AgentConfig(BaseModel):
|
||||
|
||||
skills: Optional[SkillConfig] = {}
|
||||
|
||||
features: Optional[AppFeatures] = None
|
||||
|
||||
is_active: bool
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
@@ -359,6 +462,14 @@ class AgentConfig(BaseModel):
|
||||
return {}
|
||||
return v
|
||||
|
||||
@field_validator("features", mode="before")
|
||||
@classmethod
|
||||
def validate_features(cls, v):
|
||||
"""处理 None 值,返回默认 AppFeatures"""
|
||||
if v is None:
|
||||
return AppFeatures()
|
||||
return v
|
||||
|
||||
@field_serializer("created_at", when_used="json")
|
||||
def _serialize_created_at(self, dt: datetime.datetime):
|
||||
return int(dt.timestamp() * 1000) if dt else None
|
||||
@@ -422,6 +533,13 @@ class AppRelease(BaseModel):
|
||||
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 ----------
|
||||
|
||||
class AppShareCreate(BaseModel):
|
||||
@@ -500,12 +618,35 @@ class DraftRunRequest(BaseModel):
|
||||
files: Optional[List[FileInput]] = Field(default_factory=list, description="附件列表(支持多文件)")
|
||||
|
||||
|
||||
class SuggestedQuestion(BaseModel):
|
||||
"""建议问题"""
|
||||
content: str
|
||||
|
||||
|
||||
class CitationSource(BaseModel):
|
||||
"""引用来源"""
|
||||
title: str
|
||||
content: str
|
||||
score: Optional[float] = None
|
||||
kb_id: Optional[str] = None
|
||||
|
||||
|
||||
class DraftRunResponse(BaseModel):
|
||||
"""试运行响应(非流式)"""
|
||||
message: str = Field(..., description="AI 回复消息")
|
||||
conversation_id: Optional[str] = Field(default=None, description="会话ID(用于多轮对话)")
|
||||
usage: Optional[Dict[str, Any]] = Field(default=None, description="Token 使用情况")
|
||||
elapsed_time: Optional[float] = Field(default=None, description="耗时(秒)")
|
||||
suggested_questions: List[str] = Field(default_factory=list, description="下一步建议问题")
|
||||
citations: List[CitationSource] = Field(default_factory=list, description="引用来源")
|
||||
audio_url: Optional[str] = Field(default=None, description="TTS 语音URL")
|
||||
|
||||
|
||||
class OpeningResponse(BaseModel):
|
||||
"""应用开场白响应"""
|
||||
enabled: bool
|
||||
statement: Optional[str] = None
|
||||
suggested_questions: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DraftRunStreamChunk(BaseModel):
|
||||
|
||||
@@ -51,6 +51,10 @@ class Message(BaseModel):
|
||||
def _serialize_created_at(self, dt: datetime.datetime):
|
||||
return int(dt.timestamp() * 1000) if dt else None
|
||||
|
||||
@field_serializer("meta_data", when_used="json")
|
||||
def _serialize_meta_data(self, data: Optional[Dict[str, Any]]):
|
||||
return data or {}
|
||||
|
||||
|
||||
class Conversation(BaseModel):
|
||||
"""会话输出"""
|
||||
|
||||
@@ -8,7 +8,7 @@ class EndUser(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID = Field(description="终端用户ID")
|
||||
app_id: uuid.UUID = Field(description="应用ID")
|
||||
app_id: Optional[uuid.UUID] = Field(description="应用ID", default=None)
|
||||
# end_user_id: str = Field(description="终端用户ID")
|
||||
other_id: Optional[str] = Field(description="第三方ID", default=None)
|
||||
other_name: Optional[str] = Field(description="其他名称", default="")
|
||||
|
||||
@@ -26,5 +26,7 @@ class AgentMemory_Long_Term(ABC):
|
||||
STRATEGY_TIME = "time"
|
||||
DEFAULT_SCOPE = 6
|
||||
TIME_SCOPE=5
|
||||
|
||||
class AgentMemoryDataset(ABC):
|
||||
PRONOUN=['我','本人','在下','自己','咱','鄙人','吴','余']
|
||||
NAME='用户'
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ class ToolInfo(BaseModel):
|
||||
parameters: List[ToolParameter] = Field(default_factory=list, description="工具参数")
|
||||
config_data: Dict[str, Any] = Field(default_factory=dict, description="工具配置")
|
||||
status: ToolStatus = Field(ToolStatus.AVAILABLE, description="工具状态")
|
||||
is_active: bool = Field(True, description="是否可用(False 表示已删除)")
|
||||
tags: List[str] = Field(default_factory=list, description="工具标签")
|
||||
tenant_id: Optional[str] = Field(None, description="租户ID")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
@@ -212,6 +213,11 @@ class ToolUpdateRequest(BaseModel):
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ToolActiveUpdate(BaseModel):
|
||||
"""工具可用状态更新"""
|
||||
is_active: bool = Field(..., description="True=启用, False=禁用(逻辑删除)")
|
||||
|
||||
|
||||
class ToolExecuteRequest(BaseModel):
|
||||
"""执行工具请求"""
|
||||
tool_id: str
|
||||
|
||||
@@ -80,6 +80,7 @@ class WorkflowConfigCreate(BaseModel):
|
||||
variables: list[VariableDefinition] = Field(default_factory=list, description="变量列表")
|
||||
execution_config: ExecutionConfig = Field(default_factory=ExecutionConfig, description="执行配置")
|
||||
triggers: list[TriggerConfig] = Field(default_factory=list, description="触发器列表")
|
||||
features: dict = Field(default_factory=dict, description="功能特性配置")
|
||||
|
||||
|
||||
class WorkflowConfigUpdate(BaseModel):
|
||||
@@ -87,6 +88,7 @@ class WorkflowConfigUpdate(BaseModel):
|
||||
nodes: list[NodeDefinition] | None = None
|
||||
edges: list[EdgeDefinition] | None = None
|
||||
variables: list[VariableDefinition] | None = None
|
||||
features: dict | None = None
|
||||
execution_config: ExecutionConfig | None = None
|
||||
triggers: list[TriggerConfig] | None = None
|
||||
|
||||
@@ -102,6 +104,7 @@ class WorkflowConfig(BaseModel):
|
||||
variables: list[dict[str, Any]]
|
||||
execution_config: dict[str, Any]
|
||||
triggers: list[dict[str, Any]]
|
||||
features: dict | None
|
||||
is_active: bool
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
@@ -114,6 +117,10 @@ class WorkflowConfig(BaseModel):
|
||||
def _serialize_updated_at(self, dt: datetime.datetime):
|
||||
return int(dt.timestamp() * 1000) if dt else None
|
||||
|
||||
@field_serializer("features", when_used="json")
|
||||
def _serialize_features(self, features: dict | None):
|
||||
return features or {}
|
||||
|
||||
|
||||
# ==================== 工作流执行 ====================
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ class AgentConfigConverter:
|
||||
|
||||
if hasattr(config, "skills") and config.skills:
|
||||
result["skills"] = config.skills.model_dump()
|
||||
|
||||
if hasattr(config, "features") and config.features:
|
||||
result["features"] = config.features.model_dump()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.services.model_service import ModelApiKeyService
|
||||
from app.services.multi_agent_orchestrator import MultiAgentOrchestrator
|
||||
from app.services.multimodal_service import MultimodalService
|
||||
from app.services.workflow_service import WorkflowService
|
||||
from app.schemas import FileType
|
||||
|
||||
logger = get_business_logger()
|
||||
|
||||
@@ -49,12 +50,23 @@ class AppChatService:
|
||||
storage_type: Optional[str] = None,
|
||||
user_rag_memory_id: Optional[str] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
files: Optional[List[FileInput]] = None # 新增:多模态文件
|
||||
files: Optional[List[FileInput]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""聊天(非流式)"""
|
||||
start_time = time.time()
|
||||
config_id = None
|
||||
|
||||
# 应用 features 配置
|
||||
features_config: dict = config.features or {}
|
||||
if hasattr(features_config, 'model_dump'):
|
||||
features_config = features_config.model_dump()
|
||||
web_search_feature = features_config.get("web_search", {})
|
||||
if not (isinstance(web_search_feature, dict) and web_search_feature.get("enabled")):
|
||||
web_search = False
|
||||
|
||||
# 校验文件上传
|
||||
self.agent_service._validate_file_upload(features_config, files)
|
||||
|
||||
variables = self.agent_service.prepare_variables(variables, config.variables)
|
||||
|
||||
# 获取模型配置ID
|
||||
@@ -106,31 +118,54 @@ class AppChatService:
|
||||
|
||||
)
|
||||
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_obj.model_name,
|
||||
provider=api_key_obj.provider,
|
||||
api_key=api_key_obj.api_key,
|
||||
api_base=api_key_obj.api_base,
|
||||
capability=api_key_obj.capability,
|
||||
is_omni=api_key_obj.is_omni,
|
||||
model_type=ModelType.LLM
|
||||
)
|
||||
|
||||
# 加载历史消息
|
||||
messages = self.conversation_service.get_messages(
|
||||
conversation_id=conversation_id,
|
||||
limit=10
|
||||
)
|
||||
history = []
|
||||
memory_config = {"enabled": True, 'max_history': 10}
|
||||
if memory_config.get("enabled"):
|
||||
messages = self.conversation_service.get_messages(
|
||||
conversation_id=conversation_id,
|
||||
limit=memory_config.get("max_history", 10)
|
||||
)
|
||||
history = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in messages
|
||||
]
|
||||
for msg in messages:
|
||||
content = [{"type": "text", "text": msg.content}]
|
||||
|
||||
# 处理 meta_data 中的 files
|
||||
if msg.meta_data and msg.meta_data.get("files"):
|
||||
files = msg.meta_data.get("files", [])
|
||||
# 使用 MultimodalService 处理文件
|
||||
multimodal_service = MultimodalService(self.db, api_config=model_info)
|
||||
|
||||
# 将 files 转换为 FileInput 格式
|
||||
file_inputs = []
|
||||
for file in files:
|
||||
from app.schemas.app_schema import FileInput, TransferMethod
|
||||
file_input = FileInput(
|
||||
type=file.get("type"),
|
||||
transfer_method=TransferMethod.REMOTE_URL,
|
||||
url=file.get("url")
|
||||
)
|
||||
file_inputs.append(file_input)
|
||||
|
||||
history_processed_files = await multimodal_service.history_process_files(files=file_inputs)
|
||||
|
||||
content.extend(history_processed_files)
|
||||
|
||||
history.append({
|
||||
"role": msg.role,
|
||||
"content": content
|
||||
})
|
||||
|
||||
# 处理多模态文件
|
||||
processed_files = None
|
||||
if files:
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_obj.model_name,
|
||||
provider=api_key_obj.provider,
|
||||
api_key=api_key_obj.api_key,
|
||||
api_base=api_key_obj.api_base,
|
||||
capability=api_key_obj.capability,
|
||||
is_omni=api_key_obj.is_omni,
|
||||
model_type=ModelType.LLM
|
||||
)
|
||||
multimodal_service = MultimodalService(self.db, model_info)
|
||||
processed_files = await multimodal_service.process_files(user_id, files)
|
||||
logger.info(f"处理了 {len(processed_files)} 个文件")
|
||||
@@ -148,24 +183,61 @@ class AppChatService:
|
||||
files=processed_files # 传递处理后的文件
|
||||
)
|
||||
|
||||
# 保存消息
|
||||
message_id = self.conversation_service.save_conversation_messages(
|
||||
conversation_id=conversation_id,
|
||||
user_message=message,
|
||||
assistant_message=result["content"],
|
||||
meta_data={
|
||||
"usage": result.get("usage", {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ModelApiKeyService.record_api_key_usage(self.db, api_key_obj.id)
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# suggested_questions
|
||||
suggested_questions = []
|
||||
sq_config = features_config.get("suggested_questions_after_answer", {})
|
||||
if isinstance(sq_config, dict) and sq_config.get("enabled"):
|
||||
suggested_questions = await self.agent_service._generate_suggested_questions(
|
||||
features_config, result["content"],
|
||||
{"model_name": api_key_obj.model_name, "api_key": api_key_obj.api_key,
|
||||
"api_base": api_key_obj.api_base}, {}
|
||||
)
|
||||
|
||||
audio_url = await self.agent_service._generate_tts(
|
||||
features_config, result["content"],
|
||||
{"model_name": api_key_obj.model_name, "api_key": api_key_obj.api_key,
|
||||
"api_base": api_key_obj.api_base, "provider": api_key_obj.provider},
|
||||
tenant_id=tenant_id, workspace_id=workspace_id
|
||||
)
|
||||
|
||||
# 构建用户消息内容(含多模态文件)
|
||||
human_meta = {
|
||||
"files": []
|
||||
}
|
||||
assistant_meta = {
|
||||
"model": api_key_obj.model_name,
|
||||
"usage": result.get("usage", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}),
|
||||
"audio_url": None
|
||||
}
|
||||
if files:
|
||||
for f in files:
|
||||
# url = await MultimodalService(self.db).get_file_url(f)
|
||||
human_meta["files"].append({
|
||||
"type": f.type,
|
||||
"url": f.url
|
||||
})
|
||||
|
||||
# 保存消息
|
||||
if audio_url:
|
||||
assistant_meta["audio_url"] = audio_url
|
||||
self.conversation_service.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=message,
|
||||
meta_data=human_meta
|
||||
)
|
||||
ai_message = self.conversation_service.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=result["content"],
|
||||
meta_data=assistant_meta
|
||||
)
|
||||
message_id = ai_message.id
|
||||
|
||||
return {
|
||||
"conversation_id": conversation_id,
|
||||
"message_id": str(message_id),
|
||||
@@ -175,7 +247,10 @@ class AppChatService:
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
}),
|
||||
"elapsed_time": elapsed_time
|
||||
"elapsed_time": elapsed_time,
|
||||
"suggested_questions": suggested_questions,
|
||||
"citations": self.agent_service._filter_citations(features_config, result.get("citations", [])),
|
||||
"audio_url": audio_url,
|
||||
}
|
||||
|
||||
async def agnet_chat_stream(
|
||||
@@ -190,7 +265,7 @@ class AppChatService:
|
||||
storage_type: Optional[str] = None,
|
||||
user_rag_memory_id: Optional[str] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
files: Optional[List[FileInput]] = None # 新增:多模态文件
|
||||
files: Optional[List[FileInput]] = None
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""聊天(流式)"""
|
||||
|
||||
@@ -198,10 +273,19 @@ class AppChatService:
|
||||
start_time = time.time()
|
||||
config_id = None
|
||||
message_id = uuid.uuid4()
|
||||
yield f"event: start\ndata: {json.dumps({
|
||||
'conversation_id': str(conversation_id),
|
||||
"message_id": str(message_id)
|
||||
}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 应用 features 配置
|
||||
features_config: dict = config.features or {}
|
||||
if hasattr(features_config, 'model_dump'):
|
||||
features_config = features_config.model_dump()
|
||||
web_search_feature = features_config.get("web_search", {})
|
||||
if not (isinstance(web_search_feature, dict) and web_search_feature.get("enabled")):
|
||||
web_search = False
|
||||
|
||||
# 校验文件上传
|
||||
self.agent_service._validate_file_upload(features_config, files)
|
||||
|
||||
yield f"event: start\ndata: {json.dumps({'conversation_id': str(conversation_id), 'message_id': str(message_id)}, ensure_ascii=False)}\n\n"
|
||||
|
||||
variables = self.agent_service.prepare_variables(variables, config.variables)
|
||||
# 获取模型配置ID
|
||||
@@ -255,38 +339,75 @@ class AppChatService:
|
||||
streaming=True
|
||||
)
|
||||
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_obj.model_name,
|
||||
provider=api_key_obj.provider,
|
||||
api_key=api_key_obj.api_key,
|
||||
api_base=api_key_obj.api_base,
|
||||
capability=api_key_obj.capability,
|
||||
is_omni=api_key_obj.is_omni,
|
||||
model_type=ModelType.LLM
|
||||
)
|
||||
|
||||
# 加载历史消息
|
||||
messages = self.conversation_service.get_messages(
|
||||
conversation_id=conversation_id,
|
||||
limit=10
|
||||
)
|
||||
history = []
|
||||
memory_config = {"enabled": True, 'max_history': 10}
|
||||
if memory_config.get("enabled"):
|
||||
messages = self.conversation_service.get_messages(
|
||||
conversation_id=conversation_id,
|
||||
limit=memory_config.get("max_history", 10)
|
||||
)
|
||||
history = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in messages
|
||||
]
|
||||
for msg in messages:
|
||||
content = [{"type": "text", "text": msg.content}]
|
||||
|
||||
# 处理 meta_data 中的 files
|
||||
if msg.meta_data and msg.meta_data.get("files"):
|
||||
history_files = msg.meta_data.get("files", [])
|
||||
# 使用 MultimodalService 处理文件
|
||||
multimodal_service = MultimodalService(self.db, api_config=model_info)
|
||||
|
||||
# 将 files 转换为 FileInput 格式
|
||||
file_inputs = []
|
||||
for file in history_files:
|
||||
from app.schemas.app_schema import FileInput, TransferMethod
|
||||
file_input = FileInput(
|
||||
type=file.get("type"),
|
||||
transfer_method=TransferMethod.REMOTE_URL,
|
||||
url=file.get("url")
|
||||
)
|
||||
file_inputs.append(file_input)
|
||||
|
||||
history_processed_files = await multimodal_service.history_process_files(files=file_inputs)
|
||||
|
||||
content.extend(history_processed_files)
|
||||
|
||||
history.append({
|
||||
"role": msg.role,
|
||||
"content": content
|
||||
})
|
||||
|
||||
# 处理多模态文件
|
||||
processed_files = None
|
||||
if files:
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_obj.model_name,
|
||||
provider=api_key_obj.provider,
|
||||
api_key=api_key_obj.api_key,
|
||||
api_base=api_key_obj.api_base,
|
||||
capability=api_key_obj.capability,
|
||||
is_omni=api_key_obj.is_omni,
|
||||
model_type=ModelType.LLM
|
||||
)
|
||||
multimodal_service = MultimodalService(self.db, model_info)
|
||||
processed_files = await multimodal_service.process_files(user_id, files)
|
||||
logger.info(f"处理了 {len(processed_files)} 个文件")
|
||||
|
||||
# 流式调用 Agent(支持多模态)
|
||||
# 流式调用 Agent(支持多模态),同时并行启动 TTS
|
||||
full_content = ""
|
||||
total_tokens = 0
|
||||
|
||||
text_queue: asyncio.Queue = asyncio.Queue()
|
||||
api_key_config = {
|
||||
"model_name": api_key_obj.model_name,
|
||||
"api_key": api_key_obj.api_key,
|
||||
"api_base": api_key_obj.api_base,
|
||||
"provider": api_key_obj.provider,
|
||||
}
|
||||
stream_audio_url, tts_task = await self.agent_service._generate_tts_streaming(
|
||||
features_config, api_key_config,
|
||||
text_queue=text_queue,
|
||||
tenant_id=tenant_id, workspace_id=workspace_id
|
||||
)
|
||||
|
||||
async for chunk in agent.chat_stream(
|
||||
message=message,
|
||||
history=history,
|
||||
@@ -296,39 +417,67 @@ class AppChatService:
|
||||
user_rag_memory_id=user_rag_memory_id,
|
||||
config_id=config_id,
|
||||
memory_flag=memory_flag,
|
||||
files=processed_files # 传递处理后的文件
|
||||
files=processed_files
|
||||
):
|
||||
if isinstance(chunk, int):
|
||||
total_tokens = chunk
|
||||
else:
|
||||
full_content += chunk
|
||||
# 发送消息块事件
|
||||
yield f"event: message\ndata: {json.dumps({'content': chunk}, ensure_ascii=False)}\n\n"
|
||||
if tts_task is not None:
|
||||
await text_queue.put(chunk)
|
||||
|
||||
if tts_task is not None:
|
||||
await text_queue.put(None)
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
ModelApiKeyService.record_api_key_usage(self.db, api_key_obj.id)
|
||||
|
||||
# 发送结束事件(包含 suggested_questions、tts、citations)
|
||||
end_data: dict = {"elapsed_time": elapsed_time, "message_length": len(full_content), "error": None}
|
||||
sq_config = features_config.get("suggested_questions_after_answer", {})
|
||||
if isinstance(sq_config, dict) and sq_config.get("enabled"):
|
||||
end_data["suggested_questions"] = await self.agent_service._generate_suggested_questions(
|
||||
features_config, full_content,
|
||||
{"model_name": api_key_obj.model_name, "api_key": api_key_obj.api_key,
|
||||
"api_base": api_key_obj.api_base}, {}
|
||||
)
|
||||
end_data["audio_url"] = stream_audio_url
|
||||
end_data["citations"] = self.agent_service._filter_citations(features_config, [])
|
||||
|
||||
# 保存消息
|
||||
human_meta = {
|
||||
"files":[]
|
||||
}
|
||||
assistant_meta = {
|
||||
"model": api_key_obj.model_name,
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": total_tokens},
|
||||
"audio_url": None
|
||||
}
|
||||
|
||||
if files:
|
||||
for f in files:
|
||||
# url = await MultimodalService(self.db).get_file_url(f)
|
||||
human_meta["files"].append({
|
||||
"type": f.type,
|
||||
"url": f.url
|
||||
})
|
||||
|
||||
if stream_audio_url:
|
||||
assistant_meta["audio_url"] = stream_audio_url
|
||||
self.conversation_service.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=message
|
||||
content=message,
|
||||
meta_data=human_meta
|
||||
)
|
||||
|
||||
self.conversation_service.add_message(
|
||||
message_id=message_id,
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=full_content,
|
||||
meta_data={
|
||||
"model": api_key_obj.model_name,
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": total_tokens}
|
||||
}
|
||||
meta_data=assistant_meta
|
||||
)
|
||||
|
||||
ModelApiKeyService.record_api_key_usage(self.db, api_key_obj.id)
|
||||
|
||||
# 发送结束事件
|
||||
end_data = {"elapsed_time": elapsed_time, "message_length": len(full_content), "error": None}
|
||||
yield f"event: end\ndata: {json.dumps(end_data, ensure_ascii=False)}\n\n"
|
||||
|
||||
logger.info(
|
||||
@@ -442,7 +591,7 @@ class AppChatService:
|
||||
try:
|
||||
message_id = uuid.uuid4()
|
||||
# 发送开始事件
|
||||
yield f"event: start\ndata: {json.dumps({'conversation_id': str(conversation_id), "message_id": str(message_id)}, ensure_ascii=False)}\n\n"
|
||||
yield f"event: start\ndata: {json.dumps({'conversation_id': str(conversation_id), 'message_id': str(message_id)}, ensure_ascii=False)}\n\n"
|
||||
|
||||
full_content = ""
|
||||
total_tokens = 0
|
||||
@@ -534,6 +683,7 @@ class AppChatService:
|
||||
app_id: uuid.UUID,
|
||||
release_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
files: Optional[List[FileInput]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
web_search: bool = False,
|
||||
@@ -547,7 +697,8 @@ class AppChatService:
|
||||
variables=variables,
|
||||
conversation_id=str(conversation_id),
|
||||
stream=True,
|
||||
user_id=user_id
|
||||
user_id=user_id,
|
||||
files=files
|
||||
)
|
||||
return await self.workflow_service.run(
|
||||
app_id=app_id,
|
||||
|
||||
@@ -11,10 +11,12 @@ from app.core.error_codes import BizCode
|
||||
from app.core.exceptions import BusinessException, ResourceNotFoundException
|
||||
from app.models import AgentConfig, MultiAgentConfig
|
||||
from app.models.app_model import App, AppType
|
||||
from app.models.appshare_model import AppShare
|
||||
from app.models.app_release_model import AppRelease
|
||||
from app.models.knowledge_model import Knowledge
|
||||
from app.models.models_model import ModelConfig
|
||||
from app.models.tool_model import ToolConfig as ToolConfigModel
|
||||
from app.models.skill_model import Skill
|
||||
from app.models.workflow_model import WorkflowConfig
|
||||
from app.services.workflow_service import WorkflowService
|
||||
from app.core.workflow.adapters.memory_bear.memory_bear_adapter import MemoryBearAdapter
|
||||
@@ -83,7 +85,9 @@ class AppDslService:
|
||||
if "knowledge_retrieval" in cfg:
|
||||
enriched["knowledge_retrieval"] = self._enrich_knowledge_retrieval(cfg["knowledge_retrieval"])
|
||||
if "tools" in cfg:
|
||||
enriched["tools"] = self._enrich_tools(cfg["tools"])
|
||||
enriched["tools"] = self._enrich_tools(cfg.get("tools"))
|
||||
if "skills" in cfg:
|
||||
enriched["skills"] = self._enrich_skills(cfg.get("skills"))
|
||||
return enriched
|
||||
if app_type == AppType.MULTI_AGENT:
|
||||
enriched = {**cfg}
|
||||
@@ -107,6 +111,7 @@ class AppDslService:
|
||||
"variables": config.variables if config else [],
|
||||
"edges": config.edges if config else [],
|
||||
"nodes": config.nodes if config else [],
|
||||
"features": config.features if config else {},
|
||||
"execution_config": config.execution_config if config else {},
|
||||
"triggers": config.triggers if config else [],
|
||||
} if config else {}
|
||||
@@ -122,7 +127,8 @@ class AppDslService:
|
||||
"memory": config.memory if config else None,
|
||||
"variables": config.variables if config else [],
|
||||
"tools": self._enrich_tools(config.tools) if config else [],
|
||||
"skills": config.skills if config else {},
|
||||
"skills": self._enrich_skills(config.skills) if config else {},
|
||||
"features": config.features if config else {}
|
||||
} if config else {}
|
||||
dsl = {**meta, "app": app_meta, "agent_config": config_data}
|
||||
|
||||
@@ -184,6 +190,22 @@ class AppDslService:
|
||||
def _enrich_tools(self, tools: list) -> list:
|
||||
return [{**t, "_ref": self._tool_ref(t.get("tool_id"))} for t in (tools or [])]
|
||||
|
||||
def _skill_ref(self, skill_id) -> Optional[dict]:
|
||||
if not skill_id:
|
||||
return None
|
||||
s = self.db.query(Skill).filter(Skill.id == skill_id).first()
|
||||
return {"id": str(skill_id), "name": s.name} if s else {"id": str(skill_id)}
|
||||
|
||||
def _enrich_skills(self, skills: Optional[dict]) -> Optional[dict]:
|
||||
if not skills:
|
||||
return skills
|
||||
skill_ids = skills.get("skill_ids", [])
|
||||
enriched_ids = [
|
||||
{"id": sid, "_ref": self._skill_ref(sid)}
|
||||
for sid in (skill_ids or [])
|
||||
]
|
||||
return {**skills, "skill_ids": enriched_ids}
|
||||
|
||||
def _agent_ref(self, agent_id) -> Optional[dict]:
|
||||
if not agent_id:
|
||||
return None
|
||||
@@ -248,7 +270,8 @@ class AppDslService:
|
||||
memory=self._resolve_memory(cfg.get("memory"), workspace_id, warnings),
|
||||
variables=cfg.get("variables", []),
|
||||
tools=self._resolve_tools(cfg.get("tools", []), tenant_id, warnings),
|
||||
skills=cfg.get("skills", {}),
|
||||
skills=self._resolve_skills(cfg.get("skills", {}), tenant_id, warnings),
|
||||
features=cfg.get("features", {}),
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -289,6 +312,7 @@ class AppDslService:
|
||||
edges=[e.model_dump() for e in result.edges],
|
||||
variables=[v.model_dump() for v in result.variables],
|
||||
execution_config=wf.get("execution_config", {}),
|
||||
features=wf.get("features", {}),
|
||||
triggers=wf.get("triggers", []),
|
||||
validate=False,
|
||||
)
|
||||
@@ -298,11 +322,22 @@ class AppDslService:
|
||||
return new_app, warnings
|
||||
|
||||
def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str:
|
||||
"""生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用"""
|
||||
# 本空间自有应用名
|
||||
existing = {r[0] for r in self.db.query(App.name).filter(
|
||||
App.workspace_id == workspace_id,
|
||||
App.type == app_type,
|
||||
App.is_active.is_(True)
|
||||
).all()}
|
||||
# 共享到本空间的应用名
|
||||
shared_names = {r[0] for r in self.db.query(App.name).join(
|
||||
AppShare, AppShare.source_app_id == App.id
|
||||
).filter(
|
||||
AppShare.target_workspace_id == workspace_id,
|
||||
App.type == app_type,
|
||||
App.is_active.is_(True)
|
||||
).all()}
|
||||
existing |= shared_names
|
||||
if name not in existing:
|
||||
return name
|
||||
counter = 1
|
||||
@@ -432,6 +467,46 @@ class AppDslService:
|
||||
return {**memory, "memory_config_id": None, "enabled": False}
|
||||
return memory
|
||||
|
||||
def _resolve_skills(self, skills: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> dict:
|
||||
if not skills:
|
||||
return skills or {}
|
||||
resolved_ids = []
|
||||
for entry in (skills.get("skill_ids") or []):
|
||||
# entry 可能是 {"id": "...", "_ref": {...}} 或直接是字符串
|
||||
if isinstance(entry, dict):
|
||||
ref = entry.get("_ref") or ({"name": None, "id": entry.get("id")} if entry.get("id") else None)
|
||||
skill_id = self._resolve_skill(ref, tenant_id, warnings)
|
||||
else:
|
||||
skill_id = self._resolve_skill({"id": str(entry)}, tenant_id, warnings)
|
||||
if skill_id:
|
||||
resolved_ids.append(str(skill_id))
|
||||
return {**{k: v for k, v in skills.items() if k != "skill_ids"}, "skill_ids": resolved_ids}
|
||||
|
||||
def _resolve_skill(self, ref: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> Optional[str]:
|
||||
if not ref:
|
||||
return None
|
||||
# 先按 id 匹配
|
||||
if ref.get("id"):
|
||||
try:
|
||||
s = self.db.query(Skill).filter(
|
||||
Skill.id == uuid.UUID(str(ref["id"])),
|
||||
Skill.tenant_id == tenant_id
|
||||
).first()
|
||||
if s:
|
||||
return str(s.id)
|
||||
except Exception:
|
||||
pass
|
||||
# 再按名称匹配
|
||||
if ref.get("name"):
|
||||
s = self.db.query(Skill).filter(
|
||||
Skill.name == ref["name"],
|
||||
Skill.tenant_id == tenant_id
|
||||
).first()
|
||||
if s:
|
||||
return str(s.id)
|
||||
warnings.append(f"未找到技能: {ref}")
|
||||
return None
|
||||
|
||||
def _resolve_tools(self, tools: list, tenant_id: uuid.UUID, warnings: list) -> list:
|
||||
result = []
|
||||
for t in (tools or []):
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- 应用发布和版本管理
|
||||
- 应用回滚
|
||||
"""
|
||||
import copy
|
||||
import datetime
|
||||
import uuid
|
||||
from typing import Annotated, Any, Dict, List, Optional, Tuple
|
||||
@@ -80,6 +81,8 @@ class AppService:
|
||||
)
|
||||
raise BusinessException("应用不在指定工作空间中", BizCode.WORKSPACE_NO_ACCESS)
|
||||
|
||||
|
||||
|
||||
def _check_app_accessible(self, app: App, workspace_id: Optional[uuid.UUID]) -> bool:
|
||||
"""检查应用是否可访问(包括共享应用)
|
||||
|
||||
@@ -109,7 +112,7 @@ class AppService:
|
||||
|
||||
return share is not None
|
||||
|
||||
def _validate_app_accessible(self, app: App, workspace_id: Optional[uuid.UUID]) -> None:
|
||||
def _validate_app_accessible(self, app: App, workspace_id: Optional[uuid.UUID]) -> None:
|
||||
"""验证应用是否可访问(包括共享应用,用于只读操作)
|
||||
|
||||
Args:
|
||||
@@ -126,6 +129,28 @@ class AppService:
|
||||
)
|
||||
raise BusinessException("应用不可访问", BizCode.WORKSPACE_NO_ACCESS)
|
||||
|
||||
def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str:
|
||||
"""生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用"""
|
||||
existing = {r[0] for r in self.db.query(App.name).filter(
|
||||
App.workspace_id == workspace_id,
|
||||
App.type == app_type,
|
||||
App.is_active.is_(True)
|
||||
).all()}
|
||||
shared_names = {r[0] for r in self.db.query(App.name).join(
|
||||
AppShare, AppShare.source_app_id == App.id
|
||||
).filter(
|
||||
AppShare.target_workspace_id == workspace_id,
|
||||
App.type == app_type,
|
||||
App.is_active.is_(True)
|
||||
).all()}
|
||||
existing |= shared_names
|
||||
if name not in existing:
|
||||
return name
|
||||
counter = 1
|
||||
while f"{name}({counter})" in existing:
|
||||
counter += 1
|
||||
return f"{name}({counter})"
|
||||
|
||||
def _get_share_permission(self, app: App, workspace_id: Optional[uuid.UUID]) -> Optional[str]:
|
||||
"""获取共享应用的权限
|
||||
|
||||
@@ -148,11 +173,11 @@ class AppService:
|
||||
return share.permission if share else None
|
||||
|
||||
def _validate_app_writable(self, app: App, workspace_id: Optional[uuid.UUID]) -> None:
|
||||
"""Validate that the app config is writable (owner only).
|
||||
"""Validate that the app config is writable.
|
||||
|
||||
Shared apps (both readonly and editable) cannot modify config.
|
||||
- Own workspace app: allowed
|
||||
- Any shared app: denied
|
||||
- Shared app with editable permission: allowed
|
||||
- Shared app with readonly permission: denied
|
||||
|
||||
Raises:
|
||||
BusinessException: when app is not writable
|
||||
@@ -164,6 +189,11 @@ class AppService:
|
||||
if app.workspace_id == workspace_id:
|
||||
return
|
||||
|
||||
# Check share permission
|
||||
permission = self._get_share_permission(app, workspace_id)
|
||||
if permission == "editable":
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"应用写操作被拒",
|
||||
extra={"app_id": str(app.id), "workspace_id": str(workspace_id)}
|
||||
@@ -360,6 +390,7 @@ class AppService:
|
||||
variables=storage_data.get("variables", []),
|
||||
tools=storage_data.get("tools", []),
|
||||
skills=storage_data.get("skills", {}),
|
||||
features=storage_data.get("features", {}),
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -505,6 +536,10 @@ class AppService:
|
||||
source_workspace_icon = None
|
||||
source_app_version = None
|
||||
source_app_is_active = None
|
||||
share_id = None
|
||||
shared_by = None
|
||||
shared_by_name = None
|
||||
shared_at = None
|
||||
|
||||
if is_shared:
|
||||
# 查询共享权限和来源工作空间名称
|
||||
@@ -516,7 +551,12 @@ class AppService:
|
||||
)
|
||||
share = self.db.scalars(stmt).first()
|
||||
if share:
|
||||
share_id = share.id
|
||||
share_permission = share.permission
|
||||
shared_by = share.shared_by
|
||||
shared_at = share.created_at
|
||||
if share.shared_user:
|
||||
shared_by_name = share.shared_user.username
|
||||
if share.source_workspace:
|
||||
source_workspace_name = share.source_workspace.name
|
||||
source_workspace_icon = share.source_workspace.icon
|
||||
@@ -546,6 +586,10 @@ class AppService:
|
||||
"source_workspace_icon": source_workspace_icon,
|
||||
"source_app_version": source_app_version,
|
||||
"source_app_is_active": source_app_is_active,
|
||||
"share_id": share_id,
|
||||
"shared_by": shared_by,
|
||||
"shared_by_name": shared_by_name,
|
||||
"shared_at": shared_at,
|
||||
"created_at": app.created_at,
|
||||
"updated_at": app.updated_at
|
||||
}
|
||||
@@ -760,6 +804,7 @@ class AppService:
|
||||
# 确定新应用名称
|
||||
if not new_name:
|
||||
new_name = f"{source_app.name} - 副本"
|
||||
new_name = self._unique_app_name(new_name, target_workspace_id, source_app.type)
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
@@ -783,6 +828,17 @@ class AppService:
|
||||
self.db.add(new_app)
|
||||
self.db.flush()
|
||||
|
||||
# 判断是否跨工作空间复制(共享应用复制到自己的工作空间)
|
||||
is_cross_workspace = target_workspace_id != source_app.workspace_id
|
||||
|
||||
# 跨工作空间时,获取目标工作空间的 tenant_id 用于判断模型配置是否可用
|
||||
target_tenant_id = None
|
||||
if is_cross_workspace:
|
||||
target_ws = self.db.get(Workspace, target_workspace_id)
|
||||
if not target_ws:
|
||||
raise ResourceNotFoundException("工作空间", str(target_workspace_id))
|
||||
target_tenant_id = target_ws.tenant_id
|
||||
|
||||
# 如果是 agent 类型,复制 AgentConfig
|
||||
if source_app.type == AppType.AGENT:
|
||||
source_config = self.db.query(AgentConfig).filter(
|
||||
@@ -790,16 +846,43 @@ class AppService:
|
||||
).first()
|
||||
|
||||
if source_config:
|
||||
if is_cross_workspace:
|
||||
# 跨工作空间:model/tools/skills 属于 tenant 级别直接保留,
|
||||
# knowledge_bases 属于 workspace 级别需过滤,memory_config 需清空
|
||||
_, kb_ids = self._collect_resource_ids_from_config(
|
||||
None, source_config.knowledge_retrieval
|
||||
)
|
||||
_, available_kb_ids = self._preload_cross_workspace_resources(
|
||||
target_tenant_id, target_workspace_id, set(), kb_ids
|
||||
)
|
||||
new_model_config_id = source_config.default_model_config_id
|
||||
new_knowledge_retrieval = self._clean_knowledge_retrieval(
|
||||
source_config.knowledge_retrieval, available_kb_ids
|
||||
)
|
||||
new_tools = copy.deepcopy(source_config.tools) if source_config.tools else []
|
||||
new_memory = self._clean_memory_cross_workspace(
|
||||
source_config.memory, target_workspace_id
|
||||
)
|
||||
new_skills = copy.deepcopy(source_config.skills) if source_config.skills else {}
|
||||
else:
|
||||
new_model_config_id = source_config.default_model_config_id
|
||||
new_knowledge_retrieval = copy.deepcopy(source_config.knowledge_retrieval) if source_config.knowledge_retrieval else None
|
||||
new_tools = copy.deepcopy(source_config.tools) if source_config.tools else []
|
||||
new_memory = copy.deepcopy(source_config.memory) if source_config.memory else None
|
||||
new_skills = copy.deepcopy(source_config.skills) if source_config.skills else {}
|
||||
|
||||
new_config = AgentConfig(
|
||||
id=uuid.uuid4(),
|
||||
app_id=new_app.id,
|
||||
system_prompt=source_config.system_prompt,
|
||||
default_model_config_id=source_config.default_model_config_id,
|
||||
model_parameters=source_config.model_parameters.copy() if source_config.model_parameters else None,
|
||||
knowledge_retrieval=source_config.knowledge_retrieval.copy() if source_config.knowledge_retrieval else None,
|
||||
memory=source_config.memory.copy() if source_config.memory else None,
|
||||
variables=source_config.variables.copy() if source_config.variables else [],
|
||||
tools=source_config.tools.copy() if source_config.tools else [],
|
||||
default_model_config_id=new_model_config_id,
|
||||
model_parameters=copy.deepcopy(source_config.model_parameters) if source_config.model_parameters else None,
|
||||
knowledge_retrieval=new_knowledge_retrieval,
|
||||
memory=new_memory,
|
||||
variables=copy.deepcopy(source_config.variables) if source_config.variables else [],
|
||||
tools=new_tools,
|
||||
skills=new_skills,
|
||||
features=copy.deepcopy(source_config.features) if source_config.features else {},
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -815,11 +898,12 @@ class AppService:
|
||||
new_config = WorkflowConfig(
|
||||
id=uuid.uuid4(),
|
||||
app_id=new_app.id,
|
||||
nodes=source_config.nodes.copy() if source_config.nodes else [],
|
||||
edges=source_config.edges.copy() if source_config.edges else [],
|
||||
variables=source_config.variables.copy() if source_config.variables else [],
|
||||
execution_config=source_config.execution_config.copy() if source_config.execution_config else {},
|
||||
triggers=source_config.triggers.copy() if source_config.triggers else [],
|
||||
nodes=copy.deepcopy(source_config.nodes) if source_config.nodes else [],
|
||||
edges=copy.deepcopy(source_config.edges) if source_config.edges else [],
|
||||
variables=copy.deepcopy(source_config.variables) if source_config.variables else [],
|
||||
execution_config=copy.deepcopy(source_config.execution_config) if source_config.execution_config else {},
|
||||
features=copy.deepcopy(source_config.features) if source_config.features else {},
|
||||
triggers=copy.deepcopy(source_config.triggers) if source_config.triggers else [],
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -832,17 +916,19 @@ class AppService:
|
||||
).first()
|
||||
|
||||
if source_config:
|
||||
# multi_agent 的 model_config_id/sub_agents/routing_rules 均属于 tenant 级别直接保留
|
||||
# 跨空间时 master_agent_id(AppRelease)属于源空间,需清空
|
||||
new_config = MultiAgentConfig(
|
||||
id=uuid.uuid4(),
|
||||
app_id=new_app.id,
|
||||
master_agent_id=source_config.master_agent_id,
|
||||
master_agent_id=source_config.master_agent_id if not is_cross_workspace else None,
|
||||
master_agent_name=source_config.master_agent_name,
|
||||
default_model_config_id=source_config.default_model_config_id,
|
||||
model_parameters=source_config.model_parameters,
|
||||
model_parameters=copy.deepcopy(source_config.model_parameters) if source_config.model_parameters else None,
|
||||
orchestration_mode=source_config.orchestration_mode,
|
||||
sub_agents=source_config.sub_agents.copy() if source_config.sub_agents else [],
|
||||
routing_rules=source_config.routing_rules.copy() if source_config.routing_rules else None,
|
||||
execution_config=source_config.execution_config.copy() if source_config.execution_config else {},
|
||||
sub_agents=copy.deepcopy(source_config.sub_agents) if source_config.sub_agents else [],
|
||||
routing_rules=copy.deepcopy(source_config.routing_rules) if source_config.routing_rules else None,
|
||||
execution_config=copy.deepcopy(source_config.execution_config) if source_config.execution_config else {},
|
||||
aggregation_strategy=source_config.aggregation_strategy,
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
@@ -872,6 +958,148 @@ class AppService:
|
||||
)
|
||||
raise BusinessException(f"应用复制失败: {str(e)}", BizCode.INTERNAL_ERROR, cause=e)
|
||||
|
||||
def _preload_cross_workspace_resources(
|
||||
self,
|
||||
target_tenant_id: Optional[uuid.UUID],
|
||||
target_workspace_id: uuid.UUID,
|
||||
model_config_ids: set,
|
||||
kb_ids: set
|
||||
) -> tuple:
|
||||
"""Batch-load model configs and knowledge bases to avoid N+1 queries.
|
||||
|
||||
Returns:
|
||||
(available_model_ids, available_kb_ids): sets of IDs available in target workspace
|
||||
"""
|
||||
from app.models.models_model import ModelConfig as MC
|
||||
from app.models.knowledge_model import Knowledge
|
||||
from app.models.knowledgeshare_model import KnowledgeShare
|
||||
|
||||
# Batch check model configs by tenant
|
||||
available_model_ids: set = set()
|
||||
if model_config_ids and target_tenant_id:
|
||||
stmt = select(MC.id).where(
|
||||
MC.id.in_(model_config_ids),
|
||||
MC.tenant_id == target_tenant_id
|
||||
)
|
||||
available_model_ids = set(self.db.scalars(stmt).all())
|
||||
|
||||
# Batch check knowledge bases
|
||||
available_kb_ids: set = set()
|
||||
if kb_ids:
|
||||
kb_uuids = set()
|
||||
for kid in kb_ids:
|
||||
try:
|
||||
kb_uuids.add(uuid.UUID(str(kid)))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
if kb_uuids:
|
||||
# KBs in target workspace
|
||||
stmt = select(Knowledge.id).where(
|
||||
Knowledge.id.in_(kb_uuids),
|
||||
Knowledge.workspace_id == target_workspace_id
|
||||
)
|
||||
available_kb_ids.update(self.db.scalars(stmt).all())
|
||||
|
||||
# KBs shared to target workspace
|
||||
remaining = kb_uuids - available_kb_ids
|
||||
if remaining:
|
||||
stmt = select(KnowledgeShare.source_kb_id).where(
|
||||
KnowledgeShare.source_kb_id.in_(remaining),
|
||||
KnowledgeShare.target_workspace_id == target_workspace_id
|
||||
)
|
||||
available_kb_ids.update(self.db.scalars(stmt).all())
|
||||
|
||||
return available_model_ids, available_kb_ids
|
||||
|
||||
@staticmethod
|
||||
def _collect_resource_ids_from_config(
|
||||
model_config_id: Optional[uuid.UUID],
|
||||
knowledge_retrieval: Optional[dict]
|
||||
) -> tuple:
|
||||
"""Extract all model config IDs and knowledge base IDs from an app config."""
|
||||
model_ids: set = set()
|
||||
kb_ids: set = set()
|
||||
|
||||
if model_config_id:
|
||||
model_ids.add(model_config_id)
|
||||
|
||||
if knowledge_retrieval and isinstance(knowledge_retrieval, dict):
|
||||
if "knowledge_bases" in knowledge_retrieval:
|
||||
for kid in knowledge_retrieval.get("knowledge_bases", []):
|
||||
kb_ids.add(str(kid.get("kb_id")))
|
||||
|
||||
return model_ids, kb_ids
|
||||
|
||||
@staticmethod
|
||||
def _is_kb_available(kb_id: Optional[str], available_kb_ids: set) -> Optional[str]:
|
||||
if not kb_id:
|
||||
return None
|
||||
try:
|
||||
return kb_id if uuid.UUID(str(kb_id)) in available_kb_ids else None
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
def _clean_knowledge_retrieval(
|
||||
self,
|
||||
knowledge_retrieval: Optional[dict],
|
||||
available_kb_ids: set
|
||||
) -> Optional[dict]:
|
||||
"""Clean knowledge retrieval config, keeping only available KBs."""
|
||||
if not knowledge_retrieval:
|
||||
return None
|
||||
|
||||
cleaned = copy.deepcopy(knowledge_retrieval)
|
||||
|
||||
if "knowledge_bases" in cleaned and isinstance(cleaned["knowledge_bases"], list):
|
||||
cleaned["knowledge_bases"] = [
|
||||
kb for kb in cleaned["knowledge_bases"]
|
||||
if self._is_kb_available(kb.get("kb_id"), available_kb_ids)
|
||||
]
|
||||
|
||||
return cleaned
|
||||
|
||||
def _clean_memory_cross_workspace(
|
||||
self,
|
||||
memory: Optional[dict],
|
||||
target_workspace_id: uuid.UUID
|
||||
) -> Optional[dict]:
|
||||
"""Clear memory_config_id/memory_content if it doesn't belong to target workspace."""
|
||||
if not memory:
|
||||
return None
|
||||
|
||||
from app.models.memory_config_model import MemoryConfig
|
||||
|
||||
cleaned = copy.deepcopy(memory)
|
||||
# 兼容旧字段 memory_content 和新字段 memory_config_id
|
||||
mid = cleaned.get("memory_config_id") or cleaned.get("memory_content")
|
||||
if mid:
|
||||
try:
|
||||
mid_uuid = uuid.UUID(str(mid))
|
||||
except (ValueError, AttributeError):
|
||||
exists = self.db.query(MemoryConfig).filter(
|
||||
MemoryConfig.config_id_old == int(mid),
|
||||
MemoryConfig.workspace_id == target_workspace_id
|
||||
).first()
|
||||
if not exists:
|
||||
cleaned["memory_config_id"] = None
|
||||
cleaned.pop("memory_content", None)
|
||||
cleaned["enabled"] = False
|
||||
return cleaned
|
||||
|
||||
exists = self.db.query(
|
||||
self.db.query(MemoryConfig).filter(
|
||||
MemoryConfig.config_id == mid_uuid,
|
||||
MemoryConfig.workspace_id == target_workspace_id
|
||||
).exists()
|
||||
).scalar()
|
||||
if not exists:
|
||||
cleaned["memory_config_id"] = None
|
||||
cleaned.pop("memory_content", None)
|
||||
cleaned["enabled"] = False
|
||||
|
||||
return cleaned
|
||||
|
||||
def list_apps(
|
||||
self,
|
||||
*,
|
||||
@@ -1073,6 +1301,7 @@ class AppService:
|
||||
# if data.tools is not None:
|
||||
agent_cfg.tools = storage_data.get("tools", [])
|
||||
agent_cfg.skills = storage_data.get("skills", {})
|
||||
agent_cfg.features = storage_data.get("features", {})
|
||||
|
||||
agent_cfg.updated_at = now
|
||||
|
||||
@@ -1082,6 +1311,50 @@ class AppService:
|
||||
logger.info("Agent 配置更新成功", extra={"app_id": str(app_id)})
|
||||
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
|
||||
# 查出源应用真实的 WorkflowConfig id,供 workflow_executions 外键使用
|
||||
real_config = WorkflowConfigRepository(self.db).get_by_app_id(release.app_id)
|
||||
real_id = real_config.id if real_config else uuid.uuid4()
|
||||
wf_cfg = WorkflowConfigModel(
|
||||
id=real_id,
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -1113,6 +1386,15 @@ class AppService:
|
||||
# 只读操作,允许访问共享应用
|
||||
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(
|
||||
AgentConfig.app_id == app_id,
|
||||
AgentConfig.is_active.is_(True)
|
||||
@@ -1173,6 +1455,7 @@ class AppService:
|
||||
variables=[],
|
||||
tools=[],
|
||||
skills=[],
|
||||
features={},
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -1210,6 +1493,16 @@ class AppService:
|
||||
|
||||
# 只读操作,允许访问共享应用
|
||||
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)
|
||||
config = repo.get_by_app_id(app_id)
|
||||
if config:
|
||||
@@ -1264,6 +1557,7 @@ class AppService:
|
||||
variables=[var.model_dump() for var in data.variables] if data.variables else [],
|
||||
execution_config=data.execution_config.model_dump() if data.execution_config else {},
|
||||
triggers=[trigger.model_dump() for trigger in data.triggers] if data.triggers else [],
|
||||
features=data.features or {},
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now
|
||||
@@ -1277,6 +1571,7 @@ class AppService:
|
||||
workflow_cfg.variables = [var.model_dump() for var in data.variables] if data.variables else []
|
||||
workflow_cfg.execution_config = data.execution_config.model_dump() if data.execution_config else {}
|
||||
workflow_cfg.triggers = [trigger.model_dump() for trigger in data.triggers] if data.triggers else []
|
||||
workflow_cfg.features = data.features or {}
|
||||
workflow_cfg.updated_at = now
|
||||
|
||||
self.db.commit()
|
||||
@@ -1389,15 +1684,15 @@ class AppService:
|
||||
|
||||
return config.config_id
|
||||
|
||||
def _update_endusers_memory_config(
|
||||
def _update_endusers_memory_config_by_workspace(
|
||||
self,
|
||||
app_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
memory_config_id: uuid.UUID
|
||||
) -> int:
|
||||
"""批量更新应用下所有终端用户的 memory_config_id
|
||||
|
||||
Args:
|
||||
app_id: 应用ID
|
||||
workspace_id: 工作空间ID
|
||||
memory_config_id: 新的记忆配置ID
|
||||
|
||||
Returns:
|
||||
@@ -1406,8 +1701,8 @@ class AppService:
|
||||
from app.repositories.end_user_repository import EndUserRepository
|
||||
|
||||
repo = EndUserRepository(self.db)
|
||||
updated_count = repo.batch_update_memory_config_id(
|
||||
app_id=app_id,
|
||||
updated_count = repo.batch_update_memory_config_id_by_workspace(
|
||||
workspace_id=workspace_id,
|
||||
memory_config_id=memory_config_id
|
||||
)
|
||||
|
||||
@@ -1473,6 +1768,7 @@ class AppService:
|
||||
"variables": agent_cfg.variables or [],
|
||||
"tools": agent_cfg.tools or [],
|
||||
"skills": agent_cfg.skills or {},
|
||||
"features": agent_cfg.features or {}
|
||||
}
|
||||
# config = AgentConfigConverter.from_storage_format(agent_cfg)
|
||||
default_model_config_id = agent_cfg.default_model_config_id
|
||||
@@ -1529,7 +1825,8 @@ class AppService:
|
||||
"edges": workflow_cfg.edges,
|
||||
"variables": workflow_cfg.variables,
|
||||
"execution_config": workflow_cfg.execution_config,
|
||||
"triggers": workflow_cfg.triggers
|
||||
"triggers": workflow_cfg.triggers,
|
||||
"features": workflow_cfg.features or {}
|
||||
}
|
||||
|
||||
is_valid, errors = WorkflowValidator.validate_for_publish(config)
|
||||
@@ -1578,11 +1875,15 @@ class AppService:
|
||||
)
|
||||
|
||||
if memory_config_id:
|
||||
updated_count = self._update_endusers_memory_config(app_id, memory_config_id)
|
||||
logger.info(
|
||||
f"发布时更新终端用户记忆配置: app_id={app_id}, "
|
||||
f"memory_config_id={memory_config_id}, updated_count={updated_count}"
|
||||
)
|
||||
app = self.db.query(App).filter(App.id == app_id).first()
|
||||
if app:
|
||||
updated_count = self._update_endusers_memory_config_by_workspace(
|
||||
app.workspace_id, memory_config_id
|
||||
)
|
||||
logger.info(
|
||||
f"发布时更新终端用户记忆配置: app_id={app_id}, workspace_id={app.workspace_id}, "
|
||||
f"memory_config_id={memory_config_id}, updated_count={updated_count}"
|
||||
)
|
||||
|
||||
# 更新当前发布版本指针
|
||||
app.current_release_id = release.id
|
||||
@@ -1712,7 +2013,8 @@ class AppService:
|
||||
)
|
||||
|
||||
if memory_config_id:
|
||||
updated_count = self._update_endusers_memory_config(app_id, memory_config_id)
|
||||
|
||||
updated_count = self._update_endusers_memory_config_by_workspace(app.workspace_id, memory_config_id)
|
||||
logger.info(
|
||||
f"回滚时更新终端用户记忆配置: app_id={app_id}, version={version}, "
|
||||
f"memory_config_id={memory_config_id}, updated_count={updated_count}"
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.models.conversation_model import ConversationDetail
|
||||
from app.models.prompt_optimizer_model import RoleType
|
||||
from app.repositories.conversation_repository import ConversationRepository, MessageRepository
|
||||
from app.schemas.conversation_schema import ConversationOut
|
||||
from app.schemas.model_schema import ModelInfo
|
||||
from app.services import workspace_service
|
||||
from app.services.model_service import ModelConfigService
|
||||
|
||||
@@ -119,25 +120,27 @@ class ConversationService:
|
||||
|
||||
def get_user_conversations(
|
||||
self,
|
||||
user_id: uuid.UUID
|
||||
) -> list[Conversation]:
|
||||
user_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> tuple[list[Conversation], int]:
|
||||
"""
|
||||
Retrieve recent conversations for a specific user
|
||||
|
||||
This method delegates persistence logic to the repository layer and
|
||||
applies service-level defaults (e.g. recent conversation limit).
|
||||
Retrieve recent conversations for a specific user with pagination.
|
||||
|
||||
Args:
|
||||
user_id (uuid.UUID): Unique identifier of the user.
|
||||
page (int): Page number (1-based). Defaults to 1.
|
||||
page_size (int): Number of items per page. Defaults to 20.
|
||||
|
||||
Returns:
|
||||
list[Conversation]: A list of recent conversation entities.
|
||||
tuple[list[Conversation], int]: A list of recent conversation entities and total count.
|
||||
"""
|
||||
conversations = self.conversation_repo.get_conversation_by_user_id(
|
||||
conversations, total = self.conversation_repo.get_conversation_by_user_id(
|
||||
user_id,
|
||||
limit=10
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
return conversations
|
||||
return conversations, total
|
||||
|
||||
def list_conversations(
|
||||
self,
|
||||
@@ -267,10 +270,11 @@ class ConversationService:
|
||||
|
||||
return messages
|
||||
|
||||
def get_conversation_history(
|
||||
async def get_conversation_history(
|
||||
self,
|
||||
conversation_id: uuid.UUID,
|
||||
max_history: Optional[int] = None
|
||||
max_history: Optional[int] = None,
|
||||
api_config: Optional[ModelInfo] = None
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Retrieve historical conversation messages formatted as dictionaries.
|
||||
@@ -278,6 +282,7 @@ class ConversationService:
|
||||
Args:
|
||||
conversation_id (uuid.UUID): Conversation UUID.
|
||||
max_history (Optional[int]): Maximum number of messages to retrieve.
|
||||
api_config (Optional[ModelInfo]): Model API configuration for multimodal processing.
|
||||
|
||||
Returns:
|
||||
List[dict]: List of message dictionaries with keys 'role' and 'content'.
|
||||
@@ -288,13 +293,37 @@ class ConversationService:
|
||||
)
|
||||
|
||||
# 转换为字典格式
|
||||
history = [
|
||||
{
|
||||
history = []
|
||||
for msg in messages:
|
||||
content = [{"type": "text", "text": msg.content}]
|
||||
|
||||
# 处理 meta_data 中的 files
|
||||
if msg.meta_data and msg.meta_data.get("files"):
|
||||
files = msg.meta_data.get("files", [])
|
||||
if api_config:
|
||||
# 使用 MultimodalService 处理文件
|
||||
from app.services.multimodal_service import MultimodalService
|
||||
multimodal_service = MultimodalService(self.db, api_config=api_config)
|
||||
|
||||
# 将 files 转换为 FileInput 格式
|
||||
file_inputs = []
|
||||
for file in files:
|
||||
from app.schemas.app_schema import FileInput, TransferMethod
|
||||
file_input = FileInput(
|
||||
type=file.get("type"),
|
||||
transfer_method=TransferMethod.REMOTE_URL,
|
||||
url=file.get("url")
|
||||
)
|
||||
file_inputs.append(file_input)
|
||||
|
||||
processed_files = await multimodal_service.history_process_files(files=file_inputs)
|
||||
|
||||
content.extend(processed_files)
|
||||
|
||||
history.append({
|
||||
"role": msg.role,
|
||||
"content": msg.content
|
||||
}
|
||||
for msg in messages
|
||||
]
|
||||
"content": content
|
||||
})
|
||||
|
||||
return history
|
||||
|
||||
@@ -522,9 +551,18 @@ class ConversationService:
|
||||
type=ModelType(model_type)
|
||||
)
|
||||
|
||||
conversation_messages = self.get_conversation_history(
|
||||
conversation_messages = await self.get_conversation_history(
|
||||
conversation_id=conversation_id,
|
||||
max_history=20
|
||||
max_history=20,
|
||||
api_config=ModelInfo(
|
||||
model_name=model_name,
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
capability=api_config.capability,
|
||||
is_omni=api_config.is_omni,
|
||||
model_type=model_type
|
||||
)
|
||||
)
|
||||
if len(conversation_messages) == 0:
|
||||
return ConversationOut(
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
|
||||
from app.celery_app import celery_app
|
||||
from app.core.agent.agent_middleware import AgentMiddleware
|
||||
from app.core.agent.langchain_agent import LangChainAgent
|
||||
from app.core.config import settings
|
||||
from app.core.error_codes import BizCode
|
||||
from app.core.exceptions import BusinessException
|
||||
from app.core.logging_config import get_business_logger
|
||||
@@ -36,6 +37,7 @@ from app.services.model_parameter_merger import ModelParameterMerger
|
||||
from app.services.model_service import ModelApiKeyService
|
||||
from app.services.multimodal_service import MultimodalService
|
||||
from app.services.tool_service import ToolService
|
||||
from app.schemas import FileType
|
||||
|
||||
logger = get_business_logger()
|
||||
|
||||
@@ -98,7 +100,7 @@ def create_long_term_memory_tool(
|
||||
**重要:如果用户的问题可以直接回答,不要调用此工具。只在确实需要历史信息时才使用。**
|
||||
|
||||
Args:
|
||||
question: 需要检索的问题(保持原问题的核心语义,使用清晰的关键词)
|
||||
question: 需要检索的问题(保持原问题的核心语义,使用清晰的关键词,第三人称描述的偏好、行为通常指用户本人,比如(我,本人,在下,自己,咱,鄙人,吴,余)通指用户)
|
||||
|
||||
Returns:
|
||||
检索到的历史记忆内容
|
||||
@@ -262,9 +264,12 @@ class AgentRunService:
|
||||
|
||||
def load_tools_config(self, tools_config, web_search, tenant_id) -> list:
|
||||
"""加载工具配置"""
|
||||
if not tools_config:
|
||||
return []
|
||||
tools = []
|
||||
if web_search:
|
||||
search_tool = create_web_search_tool({})
|
||||
tools.append(search_tool)
|
||||
if not tools_config:
|
||||
return tools
|
||||
tool_service = ToolService(self.db)
|
||||
|
||||
if tools_config and isinstance(tools_config, list):
|
||||
@@ -273,24 +278,15 @@ class AgentRunService:
|
||||
# 根据工具名称查找工具实例
|
||||
tool_instance = tool_service.get_tool_instance(tool_config.get("tool_id", ""), tenant_id)
|
||||
if tool_instance:
|
||||
if tool_instance.name == "baidu_search_tool" and not web_search:
|
||||
continue
|
||||
# 转换为LangChain工具
|
||||
langchain_tool = tool_instance.to_langchain_tool(tool_config.get("operation", None))
|
||||
tools.append(langchain_tool)
|
||||
elif tools_config and isinstance(tools_config, dict):
|
||||
web_search_choice = tools_config.get("web_search", {})
|
||||
web_search_enable = web_search_choice.get("enabled", False)
|
||||
if web_search and web_search_enable:
|
||||
search_tool = create_web_search_tool({})
|
||||
tools.append(search_tool)
|
||||
|
||||
logger.debug(
|
||||
"已添加网络搜索工具",
|
||||
extra={
|
||||
"tool_count": len(tools)
|
||||
}
|
||||
)
|
||||
logger.debug(
|
||||
"已添加网络搜索工具",
|
||||
extra={
|
||||
"tool_count": len(tools)
|
||||
}
|
||||
)
|
||||
return tools
|
||||
|
||||
def load_skill_config(
|
||||
@@ -373,6 +369,86 @@ class AgentRunService:
|
||||
)
|
||||
return tools, bool(memory_config.get("enabled"))
|
||||
|
||||
@staticmethod
|
||||
def _validate_file_upload(
|
||||
features_config: Dict[str, Any],
|
||||
files: Optional[List[FileInput]]
|
||||
) -> None:
|
||||
"""校验上传文件是否符合 file_upload 配置"""
|
||||
if not files or not features_config:
|
||||
return
|
||||
fu = features_config.get("file_upload", {})
|
||||
if not (isinstance(fu, dict) and fu.get("enabled")):
|
||||
raise BusinessException("该应用未开启文件上传功能", BizCode.BAD_REQUEST)
|
||||
max_count = fu.get("max_file_count", 5)
|
||||
if len(files) > max_count:
|
||||
raise BusinessException(f"文件数量超过限制(最多 {max_count} 个)", 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"不支持的文件传输方式:{f.transfer_method.value},允许的方式:{', '.join(allowed_methods)}",
|
||||
BizCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
# 各类型对应的开关和大小限制配置键
|
||||
type_cfg = {
|
||||
"image": ("image_enabled", "image_max_size_mb", 20, "图片"),
|
||||
"audio": ("audio_enabled", "audio_max_size_mb", 50, "音频"),
|
||||
"document": ("document_enabled", "document_max_size_mb", 100, "文档"),
|
||||
"video": ("video_enabled", "video_max_size_mb", 500, "视频"),
|
||||
}
|
||||
|
||||
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"该应用未开启{label}文件上传", 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}文件大小超过限制(最大 {max_mb}MB,当前 {size_mb:.1f}MB)",
|
||||
BizCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inject_opening_statement(
|
||||
features_config: Dict[str, Any],
|
||||
system_prompt: str,
|
||||
is_new_conversation: bool
|
||||
) -> str:
|
||||
"""首轮对话时将开场白注入 system_prompt"""
|
||||
if not is_new_conversation:
|
||||
return system_prompt
|
||||
opening = features_config.get("opening_statement", {})
|
||||
if not (isinstance(opening, dict) and opening.get("enabled") and opening.get("statement")):
|
||||
return system_prompt
|
||||
statement = opening["statement"]
|
||||
return f"{system_prompt}\n\n[对话开场白]\n{statement}"
|
||||
|
||||
@staticmethod
|
||||
def _filter_citations(
|
||||
features_config: Dict[str, Any],
|
||||
citations: List[Any]
|
||||
) -> List[Any]:
|
||||
"""根据 citation 开关决定是否返回引用来源"""
|
||||
citation_cfg = features_config.get("citation", {})
|
||||
if isinstance(citation_cfg, dict) and citation_cfg.get("enabled"):
|
||||
return citations
|
||||
return []
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
@@ -415,6 +491,15 @@ class AgentRunService:
|
||||
skills_config: dict | None = agent_config.skills
|
||||
knowledge_retrieval_config: dict | None = agent_config.knowledge_retrieval
|
||||
memory_config: dict | None = agent_config.memory
|
||||
features_config: dict = agent_config.features or {}
|
||||
|
||||
# 从 features 中读取功能开关(优先级高于参数默认值)
|
||||
web_search_feature = features_config.get("web_search", {})
|
||||
if not isinstance(web_search_feature, dict) or not web_search_feature.get("enabled"):
|
||||
web_search = False
|
||||
|
||||
# file_upload 校验
|
||||
self._validate_file_upload(features_config, files)
|
||||
|
||||
try:
|
||||
# 1. 获取 API Key 配置
|
||||
@@ -449,6 +534,10 @@ class AgentRunService:
|
||||
# 3. 处理系统提示词(支持变量替换)
|
||||
system_prompt = system_prompt.get_text_content() or "你是一个专业的AI助手"
|
||||
|
||||
# opening_statement:首轮对话注入开场白
|
||||
is_new_conversation = not conversation_id
|
||||
system_prompt = self._inject_opening_statement(features_config, system_prompt, is_new_conversation)
|
||||
|
||||
# 4. 准备工具列表
|
||||
tools = []
|
||||
|
||||
@@ -490,27 +579,27 @@ class AgentRunService:
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_config["model_name"],
|
||||
provider=api_key_config["provider"],
|
||||
api_key=api_key_config["api_key"],
|
||||
api_base=api_key_config["api_base"],
|
||||
capability=api_key_config["capability"],
|
||||
is_omni=api_key_config["is_omni"],
|
||||
model_type=model_config.type
|
||||
)
|
||||
|
||||
# 6. 加载历史消息
|
||||
history = []
|
||||
if memory_config and memory_config.get("enabled"):
|
||||
history = await self._load_conversation_history(
|
||||
conversation_id=conversation_id,
|
||||
max_history=agent_config.memory.get("max_history", 10)
|
||||
)
|
||||
history = await self._load_conversation_history(
|
||||
conversation_id=conversation_id,
|
||||
api_config=model_info,
|
||||
max_history=10
|
||||
)
|
||||
|
||||
# 6. 处理多模态文件
|
||||
processed_files = None
|
||||
if files:
|
||||
# 获取 provider 信息
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_config["model_name"],
|
||||
provider=api_key_config["provider"],
|
||||
api_key=api_key_config["api_key"],
|
||||
api_base=api_key_config["api_base"],
|
||||
capability=api_key_config["capability"],
|
||||
is_omni=api_key_config["is_omni"],
|
||||
model_type=ModelType.LLM
|
||||
)
|
||||
provider = api_key_config.get("provider", "openai")
|
||||
multimodal_service = MultimodalService(self.db, model_info)
|
||||
processed_files = await multimodal_service.process_files(user_id, files)
|
||||
@@ -550,8 +639,14 @@ class AgentRunService:
|
||||
|
||||
ModelApiKeyService.record_api_key_usage(self.db, api_key_config.get("api_key_id"))
|
||||
|
||||
# 9. 保存会话消息
|
||||
if not sub_agent and memory_config and memory_config.get("enabled"):
|
||||
# 9. 生成 TTS audio_url(在保存消息前生成,以便一并存入 meta_data)
|
||||
audio_url = await self._generate_tts(
|
||||
features_config, result["content"], api_key_config,
|
||||
tenant_id=tenant_id, workspace_id=workspace_id
|
||||
) if not sub_agent else None
|
||||
|
||||
# 10. 保存会话消息
|
||||
if not sub_agent:
|
||||
await self._save_conversation_message(
|
||||
conversation_id=conversation_id,
|
||||
user_message=message,
|
||||
@@ -564,7 +659,9 @@ class AgentRunService:
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
})
|
||||
}
|
||||
},
|
||||
files=files,
|
||||
audio_url=audio_url
|
||||
)
|
||||
|
||||
response = {
|
||||
@@ -575,7 +672,12 @@ class AgentRunService:
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
}),
|
||||
"elapsed_time": elapsed_time
|
||||
"elapsed_time": elapsed_time,
|
||||
"suggested_questions": await self._generate_suggested_questions(
|
||||
features_config, result["content"], api_key_config, effective_params
|
||||
) if not sub_agent else [],
|
||||
"citations": self._filter_citations(features_config, result.get("citations", [])),
|
||||
"audio_url": audio_url,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -630,6 +732,15 @@ class AgentRunService:
|
||||
skills_config: dict | None = agent_config.skills
|
||||
knowledge_retrieval_config: dict | None = agent_config.knowledge_retrieval
|
||||
memory_config: dict | None = agent_config.memory
|
||||
features_config: dict = agent_config.features or {}
|
||||
|
||||
# 从 features 中读取功能开关
|
||||
web_search_feature = features_config.get("web_search", {})
|
||||
if not (isinstance(web_search_feature, dict) and web_search_feature.get("enabled")):
|
||||
web_search = False
|
||||
|
||||
# file_upload 校验
|
||||
self._validate_file_upload(features_config, files)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
@@ -659,6 +770,10 @@ class AgentRunService:
|
||||
# 3. 处理系统提示词(支持变量替换)
|
||||
system_prompt = system_prompt.get_text_content() or "你是一个专业的AI助手"
|
||||
|
||||
# opening_statement:首轮对话注入开场白
|
||||
is_new_conversation = not conversation_id
|
||||
system_prompt = self._inject_opening_statement(features_config, system_prompt, is_new_conversation)
|
||||
|
||||
# 4. 准备工具列表
|
||||
tools = []
|
||||
|
||||
@@ -702,27 +817,27 @@ class AgentRunService:
|
||||
sub_agent=sub_agent
|
||||
)
|
||||
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_config["model_name"],
|
||||
provider=api_key_config["provider"],
|
||||
api_key=api_key_config["api_key"],
|
||||
api_base=api_key_config["api_base"],
|
||||
capability=api_key_config["capability"],
|
||||
is_omni=api_key_config["is_omni"],
|
||||
model_type=model_config.type
|
||||
)
|
||||
|
||||
# 6. 加载历史消息
|
||||
history = []
|
||||
if memory_config and memory_config.get("enabled"):
|
||||
history = await self._load_conversation_history(
|
||||
conversation_id=conversation_id,
|
||||
max_history=memory_config.get("max_history", 10)
|
||||
)
|
||||
history = await self._load_conversation_history(
|
||||
conversation_id=conversation_id,
|
||||
api_config=model_info,
|
||||
max_history=memory_config.get("max_history", 10)
|
||||
)
|
||||
|
||||
# 6. 处理多模态文件
|
||||
processed_files = None
|
||||
if files:
|
||||
# 获取 provider 信息
|
||||
model_info = ModelInfo(
|
||||
model_name=api_key_config["model_name"],
|
||||
provider=api_key_config["provider"],
|
||||
api_key=api_key_config["api_key"],
|
||||
api_base=api_key_config["api_base"],
|
||||
capability=api_key_config["capability"],
|
||||
is_omni=api_key_config["is_omni"],
|
||||
model_type=ModelType.LLM
|
||||
)
|
||||
provider = api_key_config.get("provider", "openai")
|
||||
multimodal_service = MultimodalService(self.db, model_info)
|
||||
processed_files = await multimodal_service.process_files(user_id, files)
|
||||
@@ -741,9 +856,18 @@ class AgentRunService:
|
||||
# 兼容新旧字段名:优先使用 memory_config_id,回退到 memory_content
|
||||
config_id = memory_config_.get("memory_config_id") or memory_config_.get("memory_content", None)
|
||||
|
||||
# 9. 流式调用 Agent(支持多模态)
|
||||
# 9. 流式调用 Agent(支持多模态),同时并行启动 TTS
|
||||
full_content = ""
|
||||
total_tokens = 0
|
||||
|
||||
# 启动流式 TTS(文本边输出边合成)
|
||||
text_queue: asyncio.Queue = asyncio.Queue()
|
||||
stream_audio_url, tts_task = await self._generate_tts_streaming(
|
||||
features_config, api_key_config,
|
||||
text_queue=text_queue,
|
||||
tenant_id=tenant_id, workspace_id=workspace_id
|
||||
) if not sub_agent else (None, None)
|
||||
|
||||
async for chunk in agent.chat_stream(
|
||||
message=message,
|
||||
history=history,
|
||||
@@ -753,28 +877,28 @@ class AgentRunService:
|
||||
storage_type=storage_type,
|
||||
user_rag_memory_id=user_rag_memory_id,
|
||||
memory_flag=memory_flag,
|
||||
files=processed_files # 传递处理后的文件
|
||||
files=processed_files
|
||||
):
|
||||
if isinstance(chunk, int):
|
||||
total_tokens = chunk
|
||||
else:
|
||||
full_content += chunk
|
||||
# 发送消息块事件
|
||||
yield self._format_sse_event("message", {
|
||||
"content": chunk
|
||||
})
|
||||
yield self._format_sse_event("message", {"content": chunk})
|
||||
if tts_task is not None:
|
||||
await text_queue.put(chunk)
|
||||
|
||||
# 文本结束,通知 TTS
|
||||
if tts_task is not None:
|
||||
await text_queue.put(None)
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
ModelApiKeyService.record_api_key_usage(self.db, api_key_config.get("api_key_id"))
|
||||
|
||||
if sub_agent:
|
||||
yield self._format_sse_event("sub_usage", {
|
||||
"total_tokens": total_tokens
|
||||
})
|
||||
yield self._format_sse_event("sub_usage", {"total_tokens": total_tokens})
|
||||
|
||||
# 10. 保存会话消息
|
||||
if not sub_agent and memory_config and memory_config.get("enabled"):
|
||||
# 11. 保存会话消息
|
||||
if not sub_agent:
|
||||
await self._save_conversation_message(
|
||||
conversation_id=conversation_id,
|
||||
user_message=message,
|
||||
@@ -783,15 +907,24 @@ class AgentRunService:
|
||||
user_id=user_id,
|
||||
meta_data={
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": total_tokens}
|
||||
}
|
||||
},
|
||||
files=files,
|
||||
audio_url=stream_audio_url
|
||||
)
|
||||
|
||||
# 11. 发送结束事件
|
||||
yield self._format_sse_event("end", {
|
||||
# 12. 发送结束事件(包含 suggested_questions 和 tts)
|
||||
end_data: Dict[str, Any] = {
|
||||
"conversation_id": conversation_id,
|
||||
"elapsed_time": elapsed_time,
|
||||
"message_length": len(full_content)
|
||||
})
|
||||
}
|
||||
if not sub_agent:
|
||||
end_data["suggested_questions"] = await self._generate_suggested_questions(
|
||||
features_config, full_content, api_key_config, effective_params
|
||||
)
|
||||
end_data["audio_url"] = stream_audio_url
|
||||
end_data["citations"] = self._filter_citations(features_config, [])
|
||||
yield self._format_sse_event("end", end_data)
|
||||
|
||||
logger.info(
|
||||
"流式试运行完成",
|
||||
@@ -986,6 +1119,7 @@ class AgentRunService:
|
||||
async def _load_conversation_history(
|
||||
self,
|
||||
conversation_id: str,
|
||||
api_config: ModelInfo | None = None,
|
||||
max_history: int = 10
|
||||
) -> List[Dict[str, str]]:
|
||||
"""加载会话历史消息
|
||||
@@ -1000,9 +1134,11 @@ class AgentRunService:
|
||||
try:
|
||||
|
||||
conversation_service = ConversationService(self.db)
|
||||
history = conversation_service.get_conversation_history(
|
||||
# 获取 API 配置用于多模态处理
|
||||
history = await conversation_service.get_conversation_history(
|
||||
conversation_id=uuid.UUID(conversation_id),
|
||||
max_history=max_history
|
||||
max_history=max_history,
|
||||
api_config=api_config
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
@@ -1028,7 +1164,9 @@ class AgentRunService:
|
||||
assistant_message: str,
|
||||
meta_data: dict,
|
||||
app_id: Optional[uuid.UUID] = None,
|
||||
user_id: Optional[str] = None
|
||||
user_id: Optional[str] = None,
|
||||
files: Optional[List[FileInput]] = None,
|
||||
audio_url: Optional[str] = None
|
||||
) -> None:
|
||||
"""保存会话消息(会话已通过 _ensure_conversation 确保存在)
|
||||
|
||||
@@ -1047,13 +1185,26 @@ class AgentRunService:
|
||||
conv_uuid = uuid.UUID(conversation_id)
|
||||
|
||||
# 保存消息(会话已经存在)
|
||||
human_meta = {
|
||||
"files": []
|
||||
}
|
||||
if files:
|
||||
for f in files:
|
||||
# url = await MultimodalService(self.db).get_file_url(f)
|
||||
human_meta["files"].append({
|
||||
"type": f.type,
|
||||
"url": f.url
|
||||
})
|
||||
# 保存用户消息
|
||||
conversation_service.add_message(
|
||||
conversation_id=conv_uuid,
|
||||
role="user",
|
||||
content=user_message
|
||||
content=user_message,
|
||||
meta_data=human_meta
|
||||
)
|
||||
# 保存助手消息
|
||||
# 保存助手消息(含 audio_url)
|
||||
if audio_url:
|
||||
meta_data["audio_url"] = audio_url
|
||||
conversation_service.add_message(
|
||||
conversation_id=conv_uuid,
|
||||
role="assistant",
|
||||
@@ -1137,6 +1288,385 @@ class AgentRunService:
|
||||
logger.debug("获取配置快照失败(可能是多 Agent 应用)", exc_info=True, extra={"error": str(e)})
|
||||
return {}
|
||||
|
||||
async def _generate_suggested_questions(
|
||||
self,
|
||||
features_config: Dict[str, Any],
|
||||
assistant_message: str,
|
||||
api_key_config: Dict[str, Any],
|
||||
effective_params: Dict[str, Any]
|
||||
) -> List[str]:
|
||||
"""根据 suggested_questions_after_answer 配置生成下一步建议问题"""
|
||||
sq_config = features_config.get("suggested_questions_after_answer", {})
|
||||
if not isinstance(sq_config, dict) or not sq_config.get("enabled"):
|
||||
return []
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
llm = ChatOpenAI(
|
||||
model=api_key_config["model_name"],
|
||||
api_key=api_key_config["api_key"],
|
||||
base_url=api_key_config.get("api_base"),
|
||||
temperature=0.5,
|
||||
max_tokens=200,
|
||||
)
|
||||
prompt = (
|
||||
f"根据以下AI回复,生成3个用户可能继续追问的简短问题,每行一个,不加序号:\n\n{assistant_message}"
|
||||
)
|
||||
resp = await llm.ainvoke([HumanMessage(content=prompt)])
|
||||
lines = [l.strip() for l in resp.content.strip().split("\n") if l.strip()]
|
||||
return lines[:3]
|
||||
except Exception as e:
|
||||
logger.warning(f"生成建议问题失败: {e}")
|
||||
return []
|
||||
|
||||
async def _generate_tts(
|
||||
self,
|
||||
features_config: Dict[str, Any],
|
||||
text: str,
|
||||
api_key_config: Dict[str, Any],
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
workspace_id: Optional[uuid.UUID] = None,
|
||||
) -> Optional[str]:
|
||||
"""先注册文件元数据并返回 audio_url,再后台流式写入音频内容"""
|
||||
tts_config = features_config.get("text_to_speech", {})
|
||||
if not isinstance(tts_config, dict) or not tts_config.get("enabled"):
|
||||
return None
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
from app.models.file_metadata_model import FileMetadata
|
||||
from app.services.file_storage_service import FileStorageService, generate_file_key
|
||||
|
||||
provider = api_key_config.get("provider", "openai")
|
||||
api_key = api_key_config.get("api_key")
|
||||
api_base = api_key_config.get("api_base")
|
||||
voice = tts_config.get("voice")
|
||||
file_ext, content_type = ".mp3", "audio/mpeg"
|
||||
|
||||
file_id = uuid.uuid4()
|
||||
file_key = generate_file_key(tenant_id, workspace_id, file_id, file_ext)
|
||||
|
||||
# 先写入 pending 状态的元数据,立即返回 URL
|
||||
db_file = FileMetadata(
|
||||
id=file_id,
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=workspace_id,
|
||||
file_key=file_key,
|
||||
file_name=f"tts_{file_id}{file_ext}",
|
||||
file_ext=file_ext,
|
||||
file_size=0,
|
||||
content_type=content_type,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(db_file)
|
||||
self.db.commit()
|
||||
|
||||
server_url = settings.FILE_LOCAL_SERVER_URL
|
||||
audio_url = f"{server_url}/storage/permanent/{file_id}"
|
||||
|
||||
# 后台任务:流式生成并写入存储,完成后更新状态
|
||||
async def _stream_to_storage():
|
||||
try:
|
||||
storage_service = FileStorageService()
|
||||
if provider == "dashscope":
|
||||
stream = self._tts_dashscope_stream(
|
||||
api_key=api_key,
|
||||
text=text,
|
||||
voice=voice or "longxiaochun",
|
||||
tts_config=tts_config,
|
||||
)
|
||||
else:
|
||||
stream = self._tts_openai_stream(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
text=text,
|
||||
voice=voice or "alloy",
|
||||
)
|
||||
|
||||
total_size = await storage_service.upload_stream(
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=workspace_id,
|
||||
file_id=file_id,
|
||||
file_ext=file_ext,
|
||||
stream=stream,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# 更新元数据状态
|
||||
with get_db_context() as bg_db:
|
||||
record = bg_db.get(FileMetadata, file_id)
|
||||
if record:
|
||||
record.status = "completed"
|
||||
record.file_size = total_size
|
||||
bg_db.commit()
|
||||
logger.debug(f"TTS 流式写入完成,provider={provider}, file_key={file_key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"TTS 流式写入失败: {e}")
|
||||
with get_db_context() as bg_db:
|
||||
record = bg_db.get(FileMetadata, file_id)
|
||||
if record:
|
||||
record.status = "failed"
|
||||
bg_db.commit()
|
||||
|
||||
asyncio.create_task(_stream_to_storage())
|
||||
return audio_url
|
||||
|
||||
async def _generate_tts_streaming(
|
||||
self,
|
||||
features_config: Dict[str, Any],
|
||||
api_key_config: Dict[str, Any],
|
||||
text_queue: asyncio.Queue,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
workspace_id: Optional[uuid.UUID] = None,
|
||||
) -> tuple[Optional[str], Optional[asyncio.Task]]:
|
||||
"""文本流式输入并行合成音频。
|
||||
返回 (audio_url, task),audio_url 立即可用,task 完成后文件内容就绪。
|
||||
调用方向 text_queue put 文本 chunk,结束时 put None。
|
||||
"""
|
||||
tts_config = features_config.get("text_to_speech", {})
|
||||
if not isinstance(tts_config, dict) or not tts_config.get("enabled"):
|
||||
return None, None
|
||||
|
||||
from app.models.file_metadata_model import FileMetadata
|
||||
from app.services.file_storage_service import FileStorageService, generate_file_key
|
||||
|
||||
provider = api_key_config.get("provider", "openai")
|
||||
api_key = api_key_config.get("api_key")
|
||||
api_base = api_key_config.get("api_base")
|
||||
voice = tts_config.get("voice")
|
||||
file_ext, content_type = ".mp3", "audio/mpeg"
|
||||
|
||||
file_id = uuid.uuid4()
|
||||
file_key = generate_file_key(tenant_id, workspace_id, file_id, file_ext)
|
||||
|
||||
db_file = FileMetadata(
|
||||
id=file_id,
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=workspace_id,
|
||||
file_key=file_key,
|
||||
file_name=f"tts_{file_id}{file_ext}",
|
||||
file_ext=file_ext,
|
||||
file_size=0,
|
||||
content_type=content_type,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(db_file)
|
||||
self.db.commit()
|
||||
|
||||
server_url = settings.FILE_LOCAL_SERVER_URL
|
||||
audio_url = f"{server_url}/storage/permanent/{file_id}"
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
storage_service = FileStorageService()
|
||||
if provider == "dashscope":
|
||||
audio_stream = self._tts_dashscope_stream_from_queue(
|
||||
api_key=api_key,
|
||||
voice=voice or "longxiaochun",
|
||||
tts_config=tts_config,
|
||||
text_queue=text_queue,
|
||||
)
|
||||
else:
|
||||
audio_stream = self._tts_openai_stream_from_queue(
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
voice=voice or "alloy",
|
||||
text_queue=text_queue,
|
||||
)
|
||||
total_size = await storage_service.upload_stream(
|
||||
tenant_id=tenant_id,
|
||||
workspace_id=workspace_id,
|
||||
file_id=file_id,
|
||||
file_ext=file_ext,
|
||||
stream=audio_stream,
|
||||
content_type=content_type,
|
||||
)
|
||||
with get_db_context() as bg_db:
|
||||
record = bg_db.get(FileMetadata, file_id)
|
||||
if record:
|
||||
record.status = "completed"
|
||||
record.file_size = total_size
|
||||
bg_db.commit()
|
||||
logger.debug(f"TTS 流式合成完成,provider={provider}, file_key={file_key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"TTS 流式合成失败: {e}")
|
||||
with get_db_context() as bg_db:
|
||||
record = bg_db.get(FileMetadata, file_id)
|
||||
if record:
|
||||
record.status = "failed"
|
||||
bg_db.commit()
|
||||
|
||||
task = asyncio.create_task(_run())
|
||||
return audio_url, task
|
||||
|
||||
@staticmethod
|
||||
async def _tts_openai_stream_from_queue(
|
||||
api_key: str,
|
||||
api_base: Optional[str],
|
||||
voice: str,
|
||||
text_queue: asyncio.Queue,
|
||||
):
|
||||
"""OpenAI TTS:收集全部文本后流式合成(OpenAI 不支持增量输入)"""
|
||||
from openai import AsyncOpenAI
|
||||
# 收集全部文本(此时文本流已并行输出,等待时间短)
|
||||
parts = []
|
||||
while True:
|
||||
chunk = await text_queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
parts.append(chunk)
|
||||
full_text = "".join(parts)
|
||||
if not full_text.strip():
|
||||
return
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=api_base)
|
||||
async with client.audio.speech.with_streaming_response.create(
|
||||
model="tts-1",
|
||||
voice=voice,
|
||||
input=full_text[:4096],
|
||||
) as response:
|
||||
async for chunk in response.iter_bytes(chunk_size=4096):
|
||||
yield chunk
|
||||
|
||||
@staticmethod
|
||||
async def _tts_dashscope_stream_from_queue(
|
||||
api_key: str,
|
||||
voice: str,
|
||||
tts_config: Dict[str, Any],
|
||||
text_queue: asyncio.Queue,
|
||||
):
|
||||
"""DashScope TTS:文本流式输入,实现真正并行合成"""
|
||||
import dashscope
|
||||
from dashscope.audio.tts_v2 import SpeechSynthesizer, AudioFormat, ResultCallback
|
||||
|
||||
model = tts_config.get("model") or "cosyvoice-v2"
|
||||
is_v2 = model.endswith("-v2")
|
||||
if is_v2 and not voice.endswith("_v2"):
|
||||
voice = voice + "_v2"
|
||||
elif not is_v2 and voice.endswith("_v2"):
|
||||
voice = voice[:-3]
|
||||
|
||||
audio_queue: asyncio.Queue = asyncio.Queue()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
class _Callback(ResultCallback):
|
||||
def on_data(self, data: bytes):
|
||||
if data:
|
||||
loop.call_soon_threadsafe(audio_queue.put_nowait, data)
|
||||
def on_complete(self):
|
||||
loop.call_soon_threadsafe(audio_queue.put_nowait, None)
|
||||
def on_error(self, message):
|
||||
loop.call_soon_threadsafe(audio_queue.put_nowait, RuntimeError(str(message)))
|
||||
def on_open(self): pass
|
||||
def on_close(self): pass
|
||||
|
||||
dashscope.api_key = api_key
|
||||
synthesizer = SpeechSynthesizer(
|
||||
model=model,
|
||||
voice=voice,
|
||||
format=AudioFormat.MP3_22050HZ_MONO_256KBPS,
|
||||
callback=_Callback(),
|
||||
)
|
||||
|
||||
async def _feed_text():
|
||||
"""从 text_queue 取文本按句子切分后喂给 synthesizer"""
|
||||
import re
|
||||
buf = ""
|
||||
sentence_end = re.compile(r'[\u3002\uff01\uff1f\.!?\n]')
|
||||
while True:
|
||||
chunk = await text_queue.get()
|
||||
if chunk is None:
|
||||
if buf.strip():
|
||||
await asyncio.to_thread(synthesizer.streaming_call, buf)
|
||||
await asyncio.to_thread(synthesizer.streaming_complete)
|
||||
break
|
||||
buf += chunk
|
||||
# 按句子切分喂入
|
||||
while sentence_end.search(buf):
|
||||
m = sentence_end.search(buf)
|
||||
sentence = buf[:m.end()]
|
||||
buf = buf[m.end():]
|
||||
await asyncio.to_thread(synthesizer.streaming_call, sentence)
|
||||
|
||||
asyncio.create_task(_feed_text())
|
||||
|
||||
while True:
|
||||
item = await audio_queue.get()
|
||||
if item is None:
|
||||
break
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
yield item
|
||||
|
||||
@staticmethod
|
||||
async def _tts_openai_stream(
|
||||
api_key: str,
|
||||
api_base: Optional[str],
|
||||
text: str,
|
||||
voice: str,
|
||||
):
|
||||
"""OpenAI 兼容 TTS 流式生成,yield bytes chunks"""
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=api_base)
|
||||
async with client.audio.speech.with_streaming_response.create(
|
||||
model="tts-1",
|
||||
voice=voice,
|
||||
input=text[:4096],
|
||||
) as response:
|
||||
async for chunk in response.iter_bytes(chunk_size=4096):
|
||||
yield chunk
|
||||
|
||||
@staticmethod
|
||||
async def _tts_dashscope_stream(
|
||||
api_key: str,
|
||||
text: str,
|
||||
voice: str,
|
||||
tts_config: Dict[str, Any],
|
||||
):
|
||||
"""DashScope TTS 流式生成,yield bytes chunks"""
|
||||
import dashscope
|
||||
from dashscope.audio.tts_v2 import SpeechSynthesizer, AudioFormat, ResultCallback
|
||||
|
||||
model = tts_config.get("model") or "cosyvoice-v2"
|
||||
is_v2 = model.endswith("-v2")
|
||||
if is_v2 and not voice.endswith("_v2"):
|
||||
voice = voice + "_v2"
|
||||
elif not is_v2 and voice.endswith("_v2"):
|
||||
voice = voice[:-3]
|
||||
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
class _Callback(ResultCallback):
|
||||
def on_data(self, data: bytes):
|
||||
if data:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, data)
|
||||
def on_complete(self):
|
||||
loop.call_soon_threadsafe(queue.put_nowait, None)
|
||||
def on_error(self, message):
|
||||
loop.call_soon_threadsafe(queue.put_nowait, RuntimeError(str(message)))
|
||||
def on_open(self): pass
|
||||
def on_close(self): pass
|
||||
|
||||
def _sync_stream():
|
||||
dashscope.api_key = api_key
|
||||
synthesizer = SpeechSynthesizer(
|
||||
model=model,
|
||||
voice=voice,
|
||||
format=AudioFormat.MP3_22050HZ_MONO_256KBPS,
|
||||
callback=_Callback(),
|
||||
)
|
||||
synthesizer.streaming_call(text[:4096])
|
||||
synthesizer.streaming_complete()
|
||||
|
||||
asyncio.create_task(asyncio.to_thread(_sync_stream))
|
||||
while True:
|
||||
item = await queue.get()
|
||||
if item is None:
|
||||
break
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
yield item
|
||||
|
||||
def _replace_variables(
|
||||
self,
|
||||
text: str,
|
||||
@@ -1221,6 +1751,12 @@ class AgentRunService:
|
||||
}
|
||||
)
|
||||
|
||||
# 提前校验文件上传(与 run() 内部保持一致)
|
||||
features_config: dict = agent_config.features or {}
|
||||
if hasattr(features_config, 'model_dump'):
|
||||
features_config = features_config.model_dump()
|
||||
# self._validate_file_upload(features_config, files)
|
||||
|
||||
async def run_single_model(model_info):
|
||||
"""运行单个模型"""
|
||||
try:
|
||||
@@ -1271,6 +1807,9 @@ class AgentRunService:
|
||||
if elapsed > 0 and usage.get("completion_tokens") else None
|
||||
),
|
||||
"cost_estimate": self._estimate_cost(usage, model_info["model_config"]),
|
||||
"audio_url": result.get("audio_url"),
|
||||
"citations": result.get("citations", []),
|
||||
"suggested_questions": result.get("suggested_questions", []),
|
||||
"error": None
|
||||
}
|
||||
|
||||
@@ -1343,7 +1882,12 @@ class AgentRunService:
|
||||
)
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"results": [{
|
||||
**r,
|
||||
"audio_url": r.get("audio_url"),
|
||||
"citations": r.get("citations", []),
|
||||
"suggested_questions": r.get("suggested_questions", []),
|
||||
} for r in results],
|
||||
"total_elapsed_time": sum(r.get("elapsed_time", 0) for r in results),
|
||||
"successful_count": len(successful),
|
||||
"failed_count": len(failed),
|
||||
@@ -1434,6 +1978,12 @@ class AgentRunService:
|
||||
extra={"model_count": len(models), "parallel": parallel}
|
||||
)
|
||||
|
||||
# 提前校验文件上传
|
||||
# features_config: dict = agent_config.features or {}
|
||||
# if hasattr(features_config, 'model_dump'):
|
||||
# features_config = features_config.model_dump()
|
||||
# self._validate_file_upload(features_config, files)
|
||||
|
||||
# 发送开始事件
|
||||
yield self._format_sse_event("compare_start", {
|
||||
"conversation_id": conversation_id,
|
||||
@@ -1465,6 +2015,9 @@ class AgentRunService:
|
||||
start_time = time.time()
|
||||
full_content = ""
|
||||
returned_conversation_id = model_conversation_id
|
||||
audio_url = None
|
||||
citations = []
|
||||
suggested_questions = []
|
||||
|
||||
# 临时修改参数
|
||||
original_params = agent_config.model_parameters
|
||||
@@ -1518,6 +2071,12 @@ class AgentRunService:
|
||||
"content": chunk
|
||||
}))
|
||||
|
||||
# 从 end 事件中提取 features 输出字段
|
||||
if event_type == "end" and event_data:
|
||||
audio_url = event_data.get("audio_url")
|
||||
citations = event_data.get("citations", [])
|
||||
suggested_questions = event_data.get("suggested_questions", [])
|
||||
|
||||
if event_type == "error" and event_data:
|
||||
await event_queue.put(self._format_sse_event("model_error", {
|
||||
"model_index": idx,
|
||||
@@ -1543,6 +2102,9 @@ class AgentRunService:
|
||||
"parameters_used": model_info["parameters"],
|
||||
"message": full_content,
|
||||
"elapsed_time": elapsed,
|
||||
"audio_url": audio_url,
|
||||
"citations": citations,
|
||||
"suggested_questions": suggested_questions,
|
||||
"error": None
|
||||
}
|
||||
|
||||
@@ -1554,6 +2116,9 @@ class AgentRunService:
|
||||
"conversation_id": returned_conversation_id,
|
||||
"elapsed_time": elapsed,
|
||||
"message_length": len(full_content),
|
||||
"audio_url": audio_url,
|
||||
"citations": citations,
|
||||
"suggested_questions": suggested_questions,
|
||||
"timestamp": time.time()
|
||||
}))
|
||||
|
||||
@@ -1685,8 +2250,11 @@ class AgentRunService:
|
||||
"model_name": r["model_name"],
|
||||
"label": r["label"],
|
||||
"conversation_id": r.get("conversation_id"),
|
||||
"message": r.get("message"), # 包含完整消息
|
||||
"message": r.get("message"),
|
||||
"elapsed_time": r.get("elapsed_time", 0),
|
||||
"audio_url": r.get("audio_url"),
|
||||
"citations": r.get("citations", []),
|
||||
"suggested_questions": r.get("suggested_questions", []),
|
||||
"error": r.get("error")
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ and error handling.
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
from app.core.storage import StorageFactory, StorageBackend
|
||||
from app.core.storage_exceptions import (
|
||||
@@ -162,6 +162,31 @@ class FileStorageService:
|
||||
cause=e,
|
||||
)
|
||||
|
||||
async def upload_stream(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID | None,
|
||||
file_id: uuid.UUID,
|
||||
file_ext: str,
|
||||
stream: AsyncIterator[bytes],
|
||||
content_type: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Upload a file from an async byte stream.
|
||||
|
||||
Returns:
|
||||
Total bytes written.
|
||||
"""
|
||||
file_key = generate_file_key(tenant_id, workspace_id, file_id, file_ext)
|
||||
logger.info(f"Starting stream upload: file_key={file_key}, content_type={content_type}")
|
||||
try:
|
||||
total = await self.storage.upload_stream(file_key, stream, content_type)
|
||||
logger.info(f"Stream upload successful: file_key={file_key}, size={total} bytes")
|
||||
return total
|
||||
except Exception as e:
|
||||
logger.error(f"Stream upload failed: file_key={file_key}, error={str(e)}")
|
||||
raise
|
||||
|
||||
async def download_file(self, file_key: str) -> bytes:
|
||||
"""
|
||||
Download a file from storage.
|
||||
|
||||
@@ -68,14 +68,14 @@ def get_workspace_end_users(
|
||||
return []
|
||||
|
||||
# 提取所有 app_id
|
||||
app_ids = [app.id for app in apps_orm]
|
||||
# app_ids = [app.id for app in apps_orm]
|
||||
|
||||
# 批量查询所有 end_users(一次查询而非循环查询)
|
||||
# 按 created_at 降序排序,NULL 值排在最后;id 作为次级排序键保证确定性
|
||||
from app.models.end_user_model import EndUser as EndUserModel
|
||||
from sqlalchemy import desc, nullslast
|
||||
end_users_orm = db.query(EndUserModel).filter(
|
||||
EndUserModel.app_id.in_(app_ids)
|
||||
EndUserModel.workspace_id == workspace_id
|
||||
).order_by(
|
||||
nullslast(desc(EndUserModel.created_at)),
|
||||
desc(EndUserModel.id)
|
||||
|
||||
@@ -518,7 +518,7 @@ class MemoryForgetService:
|
||||
'total_nodes': result['total_nodes'] or 0,
|
||||
'nodes_with_activation': result['nodes_with_activation'] or 0,
|
||||
'nodes_without_activation': result['nodes_without_activation'] or 0,
|
||||
'average_activation_value': result['average_activation'],
|
||||
'average_activation_value': round(result['average_activation'], 2) if result['average_activation'] is not None else None,
|
||||
'low_activation_nodes': result['low_activation_nodes'] or 0,
|
||||
'forgetting_threshold': forgetting_threshold,
|
||||
'timestamp': int(datetime.now().timestamp() * 1000)
|
||||
@@ -619,7 +619,7 @@ class MemoryForgetService:
|
||||
recent_trends.append({
|
||||
'date': date_str,
|
||||
'merged_count': record.merged_count,
|
||||
'average_activation': record.average_activation_value,
|
||||
'average_activation': round(record.average_activation_value, 2) if record.average_activation_value is not None else None,
|
||||
'total_nodes': record.total_nodes,
|
||||
'execution_time': int(record.execution_time.timestamp() * 1000)
|
||||
})
|
||||
|
||||
@@ -5,12 +5,14 @@ from urllib.parse import urlparse, unquote
|
||||
|
||||
import json_repair
|
||||
from jinja2 import Template
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.error_codes import BizCode
|
||||
from app.core.exceptions import BusinessException
|
||||
from app.core.logging_config import get_business_logger
|
||||
from app.core.models import RedBearLLM, RedBearModelConfig
|
||||
from app.models import FileMetadata
|
||||
from app.models.memory_perceptual_model import PerceptualType, FileStorageService
|
||||
from app.models.prompt_optimizer_model import RoleType
|
||||
from app.repositories.memory_perceptual_repository import MemoryPerceptualRepository
|
||||
@@ -245,6 +247,18 @@ class MemoryPerceptualService:
|
||||
filename = os.path.basename(path)
|
||||
filename = unquote(filename)
|
||||
file_ext = os.path.splitext(filename)[1]
|
||||
try:
|
||||
file_id = uuid.UUID(filename)
|
||||
stmt = select(FileMetadata).where(
|
||||
FileMetadata.id == file_id
|
||||
)
|
||||
file = self.db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if file:
|
||||
filename = file.file_name
|
||||
file_ext = file.file_ext
|
||||
except ValueError:
|
||||
business_logger.debug(f"Remote file, file_id={filename}")
|
||||
if not file_ext:
|
||||
if file_type == FileType.AUDIO:
|
||||
file_ext = ".mp3"
|
||||
@@ -262,17 +276,17 @@ class MemoryPerceptualService:
|
||||
}
|
||||
if file_type in [FileType.IMAGE, FileType.VIDEO]:
|
||||
file_modalities = {
|
||||
"scene": content.get("scene")
|
||||
"scene": content.get("scene", [])
|
||||
}
|
||||
elif file_type in [FileType.DOCUMENT]:
|
||||
file_modalities = {
|
||||
"section_count": content.get("section_count"),
|
||||
"title": content.get("title"),
|
||||
"first_line": content.get("first_line")
|
||||
"section_count": content.get("section_count", 0),
|
||||
"title": content.get("title", ""),
|
||||
"first_line": content.get("first_line", "")
|
||||
}
|
||||
else:
|
||||
file_modalities = {
|
||||
"speaker_count": content.get("speaker_count")
|
||||
"speaker_count": content.get("speaker_count", 0)
|
||||
}
|
||||
self.repository.create_perceptual_memory(
|
||||
end_user_id=uuid.UUID(end_user_id),
|
||||
@@ -280,7 +294,7 @@ class MemoryPerceptualService:
|
||||
file_path=file_url,
|
||||
file_name=filename,
|
||||
file_ext=file_ext,
|
||||
summary=content.get('summary'),
|
||||
summary=content.get('summary', ""),
|
||||
meta_data={
|
||||
"content": file_content,
|
||||
"modalities": file_modalities
|
||||
|
||||
@@ -1638,6 +1638,7 @@ class MultiAgentOrchestrator:
|
||||
self.variables = config_data.get("variables", [])
|
||||
self.tools = config_data.get("tools", {})
|
||||
self.skills = config_data.get("skills", {})
|
||||
self.features = config_data.get("features", {})
|
||||
self.default_model_config_id = release.default_model_config_id
|
||||
|
||||
return AgentConfigProxy(release, app, config_data)
|
||||
|
||||
@@ -11,12 +11,18 @@
|
||||
import base64
|
||||
import io
|
||||
import uuid
|
||||
import zipfile
|
||||
import chardet
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import csv
|
||||
import json
|
||||
|
||||
import PyPDF2
|
||||
import httpx
|
||||
import magic
|
||||
import openpyxl
|
||||
from docx import Document
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -37,8 +43,14 @@ TEXT_MIME = ['text/plain', 'text/x-markdown']
|
||||
PDF_MIME = ['application/pdf']
|
||||
DOC_MIME = [
|
||||
'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',
|
||||
]
|
||||
CSV_MIME = ['text/csv', 'application/csv']
|
||||
JSON_MIME = ['application/json']
|
||||
|
||||
|
||||
class MultimodalFormatStrategy(ABC):
|
||||
@@ -48,22 +60,22 @@ class MultimodalFormatStrategy(ABC):
|
||||
self.file = file
|
||||
|
||||
@abstractmethod
|
||||
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
||||
async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||
"""格式化图片"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]:
|
||||
async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
|
||||
"""格式化文档"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def format_audio(self, file_type: str, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
||||
async def format_audio(self, file_type: str, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||
"""格式化音频"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def format_video(self, url: str) -> Dict[str, Any]:
|
||||
async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
|
||||
"""格式化视频"""
|
||||
pass
|
||||
|
||||
@@ -71,16 +83,16 @@ class MultimodalFormatStrategy(ABC):
|
||||
class DashScopeFormatStrategy(MultimodalFormatStrategy):
|
||||
"""通义千问策略"""
|
||||
|
||||
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
||||
async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||
"""通义千问图片格式:{"type": "image", "image": "url"}"""
|
||||
return {
|
||||
return True, {
|
||||
"type": "image",
|
||||
"image": url
|
||||
}
|
||||
|
||||
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]:
|
||||
async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
|
||||
"""通义千问文档格式"""
|
||||
return {
|
||||
return True, {
|
||||
"type": "text",
|
||||
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
|
||||
}
|
||||
@@ -91,26 +103,26 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
|
||||
url: str,
|
||||
content: bytes | None = None,
|
||||
transcription: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
) -> tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
通义千问音频格式
|
||||
- 原生支持: qwen-audio 系列
|
||||
- 其他模型: 需要转录为文本
|
||||
"""
|
||||
if transcription:
|
||||
return {
|
||||
return True, {
|
||||
"type": "text",
|
||||
"text": f"<audio url=\"{url}\">\ntext_transcription:{transcription}\n</audio>"
|
||||
}
|
||||
# 通义千问音频格式:{"type": "audio", "audio": "url"}
|
||||
return {
|
||||
return True, {
|
||||
"type": "audio",
|
||||
"audio": url
|
||||
}
|
||||
|
||||
async def format_video(self, url: str) -> Dict[str, Any]:
|
||||
async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
|
||||
"""通义千问视频格式(qwen-vl 系列原生支持)"""
|
||||
return {
|
||||
return True, {
|
||||
"type": "video",
|
||||
"video": url
|
||||
}
|
||||
@@ -119,7 +131,7 @@ class DashScopeFormatStrategy(MultimodalFormatStrategy):
|
||||
class BedrockFormatStrategy(MultimodalFormatStrategy):
|
||||
"""Bedrock/Anthropic 策略"""
|
||||
|
||||
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
||||
async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
Bedrock/Anthropic 格式: base64 编码
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}}
|
||||
@@ -142,7 +154,7 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
|
||||
|
||||
logger.info(f"图片编码完成: media_type={media_type}, size={len(base64_data)}")
|
||||
|
||||
return {
|
||||
return True, {
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
@@ -151,13 +163,13 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
|
||||
}
|
||||
}
|
||||
|
||||
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]:
|
||||
async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
|
||||
"""Bedrock/Anthropic 文档格式(需要 base64 编码)"""
|
||||
# Bedrock 文档需要 base64 编码
|
||||
text_bytes = text.encode('utf-8')
|
||||
base64_text = base64.b64encode(text_bytes).decode('utf-8')
|
||||
|
||||
return {
|
||||
return True, {
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
@@ -171,24 +183,24 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
|
||||
url: str,
|
||||
content: bytes | None = None,
|
||||
transcription: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
) -> tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
Bedrock/Anthropic 音频格式
|
||||
不支持原生音频,必须转录为文本
|
||||
"""
|
||||
if transcription:
|
||||
return {
|
||||
return True, {
|
||||
"type": "text",
|
||||
"text": f"[音频转录]\n{transcription}"
|
||||
}
|
||||
return {
|
||||
return False, {
|
||||
"type": "text",
|
||||
"text": "[音频文件:Bedrock 不支持原生音频,请启用音频转文本功能]"
|
||||
}
|
||||
|
||||
async def format_video(self, url: str) -> Dict[str, Any]:
|
||||
async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
|
||||
"""Bedrock/Anthropic 视频格式"""
|
||||
return {
|
||||
return False, {
|
||||
"type": "text",
|
||||
"text": f"<video url=\"{url}\">\n[视频文件,当前 provider 暂不支持]\n</video>"
|
||||
}
|
||||
@@ -197,18 +209,18 @@ class BedrockFormatStrategy(MultimodalFormatStrategy):
|
||||
class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
||||
"""OpenAI 策略"""
|
||||
|
||||
async def format_image(self, url: str, content: bytes | None = None) -> Dict[str, Any]:
|
||||
async def format_image(self, url: str, content: bytes | None = None) -> tuple[bool, Dict[str, Any]]:
|
||||
"""OpenAI 格式: {"type": "image_url", "image_url": {"url": "..."}}"""
|
||||
return {
|
||||
return True, {
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": url
|
||||
}
|
||||
}
|
||||
|
||||
async def format_document(self, file_name: str, text: str) -> Dict[str, Any]:
|
||||
async def format_document(self, file_name: str, text: str) -> tuple[bool, Dict[str, Any]]:
|
||||
"""OpenAI 文档格式"""
|
||||
return {
|
||||
return True, {
|
||||
"type": "text",
|
||||
"text": f"<document name=\"{file_name}\">\n{text}\n</document>"
|
||||
}
|
||||
@@ -219,14 +231,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
||||
url: str,
|
||||
content: bytes | None = None,
|
||||
transcription: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
) -> tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
OpenAI 音频格式
|
||||
- gpt-4o-audio 系列支持原生音频(需要 base64 编码)
|
||||
- 其他模型使用转录文本
|
||||
"""
|
||||
if transcription:
|
||||
return {
|
||||
return True, {
|
||||
"type": "text",
|
||||
"text": f"<audio url=\"{url}\">\n{transcription}\n</audio>"
|
||||
}
|
||||
@@ -255,7 +267,7 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
||||
# supported_ext = {"wav", "mp3", "mp4", "ogg", "flac", "webm", "m4a", "wave", "x-m4a"}
|
||||
file_ext = "wav" if not file_ext else file_ext
|
||||
|
||||
return {
|
||||
return True, {
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": f"data:;base64,{base64_audio}",
|
||||
@@ -264,14 +276,14 @@ class OpenAIFormatStrategy(MultimodalFormatStrategy):
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"下载音频失败: {e}")
|
||||
return {
|
||||
return False, {
|
||||
"type": "text",
|
||||
"text": f"[音频处理失败: {str(e)}]"
|
||||
}
|
||||
|
||||
async def format_video(self, url: str) -> Dict[str, Any]:
|
||||
async def format_video(self, url: str) -> tuple[bool, Dict[str, Any]]:
|
||||
"""OpenAI 视频格式"""
|
||||
return {
|
||||
return True, {
|
||||
"type": "video_url",
|
||||
"video_url": {
|
||||
"url": url
|
||||
@@ -366,21 +378,90 @@ class MultimodalService:
|
||||
file.url = await self.get_file_url(file)
|
||||
try:
|
||||
if file.type == FileType.IMAGE and "vision" in self.capability:
|
||||
content = await self._process_image(file, strategy)
|
||||
is_support, content = await self._process_image(file, strategy)
|
||||
result.append(content)
|
||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||
if is_support:
|
||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||
elif file.type == FileType.DOCUMENT:
|
||||
content = await self._process_document(file, strategy)
|
||||
is_support, content = await self._process_document(file, strategy)
|
||||
result.append(content)
|
||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||
if is_support:
|
||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||
elif file.type == FileType.AUDIO and "audio" in self.capability:
|
||||
content = await self._process_audio(file, strategy)
|
||||
is_support, content = await self._process_audio(file, strategy)
|
||||
result.append(content)
|
||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||
if is_support:
|
||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||
elif file.type == FileType.VIDEO and "video" in self.capability:
|
||||
content = await self._process_video(file, strategy)
|
||||
is_support, content = await self._process_video(file, strategy)
|
||||
result.append(content)
|
||||
if is_support:
|
||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||
else:
|
||||
logger.warning(f"不支持的文件类型: {file.type}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"处理文件失败",
|
||||
extra={
|
||||
"file_index": idx,
|
||||
"file_type": file.type,
|
||||
"error": str(e)
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
# 继续处理其他文件,不中断整个流程
|
||||
result.append({
|
||||
"type": "text",
|
||||
"text": f"[文件处理失败: {str(e)}]"
|
||||
})
|
||||
|
||||
logger.info(f"成功处理 {len(result)}/{len(files)} 个文件,provider={self.provider}")
|
||||
return result
|
||||
|
||||
async def history_process_files(
|
||||
self,
|
||||
files: Optional[List[FileInput]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
处理文件列表,返回 LLM 可用的格式
|
||||
|
||||
Args:
|
||||
files: 文件输入列表
|
||||
|
||||
Returns:
|
||||
List[Dict]: LLM 可用的内容格式列表(根据 provider 返回不同格式)
|
||||
"""
|
||||
if not files:
|
||||
return []
|
||||
|
||||
# 获取对应的策略
|
||||
# dashscope 的 omni 模型使用 OpenAI 兼容格式
|
||||
if self.provider == "dashscope" and self.is_omni:
|
||||
strategy_class = OpenAIFormatStrategy
|
||||
else:
|
||||
strategy_class = PROVIDER_STRATEGIES.get(self.provider)
|
||||
if not strategy_class:
|
||||
logger.warning(f"未找到 provider '{self.provider}' 的策略,使用默认策略")
|
||||
strategy_class = DashScopeFormatStrategy
|
||||
|
||||
result = []
|
||||
for idx, file in enumerate(files):
|
||||
strategy = strategy_class(file)
|
||||
if not file.url:
|
||||
file.url = await self.get_file_url(file)
|
||||
try:
|
||||
if file.type == FileType.IMAGE and "vision" in self.capability:
|
||||
is_support, content = await self._process_image(file, strategy)
|
||||
result.append(content)
|
||||
elif file.type == FileType.DOCUMENT:
|
||||
is_support, content = await self._process_document(file, strategy)
|
||||
result.append(content)
|
||||
elif file.type == FileType.AUDIO and "audio" in self.capability:
|
||||
is_support, content = await self._process_audio(file, strategy)
|
||||
result.append(content)
|
||||
elif file.type == FileType.VIDEO and "video" in self.capability:
|
||||
is_support, content = await self._process_video(file, strategy)
|
||||
result.append(content)
|
||||
self.write_perceptual_memory(end_user_id, file.type, file.url, content)
|
||||
else:
|
||||
logger.warning(f"不支持的文件类型: {file.type}")
|
||||
except Exception as e:
|
||||
@@ -413,7 +494,7 @@ class MultimodalService:
|
||||
if end_user_id and self.api_config:
|
||||
write_perceptual_memory.delay(end_user_id, self.api_config.model_dump(), file_type, file_url, file_message)
|
||||
|
||||
async def _process_image(self, file: FileInput, strategy) -> Dict[str, Any]:
|
||||
async def _process_image(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
处理图片文件
|
||||
|
||||
@@ -425,16 +506,16 @@ class MultimodalService:
|
||||
Dict: 根据 provider 返回不同格式的图片内容
|
||||
"""
|
||||
try:
|
||||
url = await self.get_file_url(file)
|
||||
return await strategy.format_image(url, content=file.get_content())
|
||||
# url = await self.get_file_url(file)
|
||||
return await strategy.format_image(file.url, content=file.get_content())
|
||||
except Exception as e:
|
||||
logger.error(f"处理图片失败: {e}", exc_info=True)
|
||||
return {
|
||||
return False, {
|
||||
"type": "text",
|
||||
"text": f"[图片处理失败: {str(e)}]"
|
||||
}
|
||||
|
||||
async def _process_document(self, file: FileInput, strategy) -> Dict[str, Any]:
|
||||
async def _process_document(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
处理文档文件(PDF、Word 等)
|
||||
|
||||
@@ -446,7 +527,7 @@ class MultimodalService:
|
||||
Dict: 根据 provider 返回不同格式的文档内容
|
||||
"""
|
||||
if file.transfer_method == TransferMethod.REMOTE_URL:
|
||||
return {
|
||||
return True, {
|
||||
"type": "text",
|
||||
"text": f"<document url=\"{file.url}\">\n{await self._extract_document_text(file)}\n</document>"
|
||||
}
|
||||
@@ -464,7 +545,7 @@ class MultimodalService:
|
||||
# 使用策略格式化文档
|
||||
return await strategy.format_document(file_name, text)
|
||||
|
||||
async def _process_audio(self, file: FileInput, strategy) -> Dict[str, Any]:
|
||||
async def _process_audio(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
处理音频文件
|
||||
|
||||
@@ -476,28 +557,28 @@ class MultimodalService:
|
||||
Dict: 根据 provider 返回不同格式的音频内容
|
||||
"""
|
||||
try:
|
||||
url = await self.get_file_url(file)
|
||||
# url = await self.get_file_url(file)
|
||||
|
||||
# 如果启用音频转文本且有 API Key
|
||||
transcription = None
|
||||
if self.enable_audio_transcription and self.audio_api_key:
|
||||
logger.info(f"开始音频转文本: {url}")
|
||||
logger.info(f"开始音频转文本: {file.url}")
|
||||
if self.provider == "dashscope":
|
||||
transcription = await AudioTranscriptionService.transcribe_dashscope(url, self.audio_api_key)
|
||||
transcription = await AudioTranscriptionService.transcribe_dashscope(file.url, self.audio_api_key)
|
||||
elif self.provider == "openai":
|
||||
transcription = await AudioTranscriptionService.transcribe_openai(url, self.audio_api_key)
|
||||
transcription = await AudioTranscriptionService.transcribe_openai(file.url, self.audio_api_key)
|
||||
else:
|
||||
logger.warning(f"Provider {self.provider} 不支持音频转文本")
|
||||
|
||||
return await strategy.format_audio(file.file_type, url, file.get_content(), transcription)
|
||||
return await strategy.format_audio(file.file_type, file.url, file.get_content(), transcription)
|
||||
except Exception as e:
|
||||
logger.error(f"处理音频失败: {e}", exc_info=True)
|
||||
return {
|
||||
return False, {
|
||||
"type": "text",
|
||||
"text": f"[音频处理失败: {str(e)}]"
|
||||
}
|
||||
|
||||
async def _process_video(self, file: FileInput, strategy) -> Dict[str, Any]:
|
||||
async def _process_video(self, file: FileInput, strategy) -> tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
处理视频文件
|
||||
|
||||
@@ -509,11 +590,11 @@ class MultimodalService:
|
||||
Dict: 根据 provider 返回不同格式的视频内容
|
||||
"""
|
||||
try:
|
||||
url = await self.get_file_url(file)
|
||||
return await strategy.format_video(url)
|
||||
# url = await self.get_file_url(file)
|
||||
return await strategy.format_video(file.url)
|
||||
except Exception as e:
|
||||
logger.error(f"处理视频失败: {e}", exc_info=True)
|
||||
return {
|
||||
return False, {
|
||||
"type": "text",
|
||||
"text": f"[视频处理失败: {str(e)}]"
|
||||
}
|
||||
@@ -572,11 +653,17 @@ class MultimodalService:
|
||||
file.set_content(file_content)
|
||||
file_mime_type = magic.from_buffer(file_content, mime=True)
|
||||
if file_mime_type in TEXT_MIME:
|
||||
return file_content.decode("utf-8")
|
||||
return self._decode_text_safe(file_content)
|
||||
elif file_mime_type in PDF_MIME:
|
||||
return await self._extract_pdf_text(file_content)
|
||||
elif file_mime_type in DOC_MIME:
|
||||
elif self._is_word_file(file_content, file_mime_type):
|
||||
return await self._extract_word_text(file_content)
|
||||
elif self._is_excel_file(file_content, file_mime_type):
|
||||
return await self._extract_xlsx_text(file_content)
|
||||
elif file_mime_type in CSV_MIME:
|
||||
return await self._extract_csv_text(file_content)
|
||||
elif file_mime_type in JSON_MIME:
|
||||
return await self._extract_json_text(file_content)
|
||||
else:
|
||||
return f"[Unsupported file type: {file_mime_type}]"
|
||||
except Exception as e:
|
||||
@@ -600,16 +687,155 @@ class MultimodalService:
|
||||
|
||||
@staticmethod
|
||||
async def _extract_word_text(file_content: bytes) -> str:
|
||||
"""提取 Word 文档文本"""
|
||||
"""提取 Word 文档文本(支持 .docx 和旧版 .doc)"""
|
||||
# 先尝试 docx(ZIP 格式)
|
||||
if file_content[:2] == b'PK':
|
||||
try:
|
||||
word_file = io.BytesIO(file_content)
|
||||
doc = Document(word_file)
|
||||
return '\n'.join(p.text for p in doc.paragraphs)
|
||||
except Exception as e:
|
||||
logger.error(f"提取 docx 文本失败: {e}")
|
||||
return f"[docx 提取失败: {str(e)}]"
|
||||
|
||||
# 旧版 .doc(OLE2 格式)
|
||||
try:
|
||||
# 使用 BytesIO 读取 Word 文档
|
||||
word_file = io.BytesIO(file_content)
|
||||
doc = Document(word_file)
|
||||
text_parts = [paragraph.text for paragraph in doc.paragraphs]
|
||||
return '\n'.join(text_parts)
|
||||
import olefile
|
||||
ole = olefile.OleFileIO(io.BytesIO(file_content))
|
||||
if not ole.exists('WordDocument'):
|
||||
return "[doc 提取失败: 未找到 WordDocument 流]"
|
||||
# 读取 WordDocument 流,提取可见 ASCII/Unicode 文本
|
||||
stream = ole.openstream('WordDocument').read()
|
||||
# Word Binary Format: 文本在流中以 UTF-16-LE 编码存储
|
||||
# 简单提取:过滤出可打印字符段
|
||||
try:
|
||||
text = stream.decode('utf-16-le', errors='ignore')
|
||||
except Exception:
|
||||
text = stream.decode('latin-1', errors='ignore')
|
||||
# 过滤控制字符,保留可打印内容
|
||||
import re
|
||||
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
|
||||
text = re.sub(r' +', ' ', text).strip()
|
||||
ole.close()
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.error(f"提取 Word 文本失败: {e}")
|
||||
return f"[Word 提取失败: {str(e)}]"
|
||||
logger.error(f"提取 doc 文本失败: {e}")
|
||||
return f"[doc 提取失败: {str(e)}]"
|
||||
|
||||
@staticmethod
|
||||
async def _extract_xlsx_text(file_content: bytes) -> str:
|
||||
"""提取 Excel 文本(支持 .xlsx 和旧版 .xls)"""
|
||||
# xlsx(ZIP 格式)
|
||||
if file_content[:2] == b'PK':
|
||||
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"提取 xlsx 文本失败: {e}")
|
||||
return f"[xlsx 提取失败: {str(e)}]"
|
||||
|
||||
# xls(OLE2/BIFF 格式)
|
||||
try:
|
||||
import xlrd
|
||||
wb = xlrd.open_workbook(file_contents=file_content)
|
||||
parts = []
|
||||
for sheet in wb.sheets():
|
||||
parts.append(f"[Sheet: {sheet.name}]")
|
||||
for row_idx in range(sheet.nrows):
|
||||
parts.append('\t'.join(str(sheet.cell_value(row_idx, col)) for col in range(sheet.ncols)))
|
||||
return '\n'.join(parts)
|
||||
except Exception as e:
|
||||
logger.error(f"提取 xls 文本失败: {e}")
|
||||
return f"[xls 提取失败: {str(e)}]"
|
||||
|
||||
async def _extract_csv_text(self, file_content: bytes) -> str:
|
||||
"""提取 CSV 文本"""
|
||||
try:
|
||||
text = self._decode_text_safe(file_content)
|
||||
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)}]"
|
||||
|
||||
async def _extract_json_text(self, file_content: bytes) -> str:
|
||||
"""提取 JSON 文本"""
|
||||
try:
|
||||
text = self._decode_text_safe(file_content)
|
||||
data = json.loads(text)
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"提取 JSON 文本失败: {e}")
|
||||
return f"[JSON 提取失败: {str(e)}]"
|
||||
|
||||
def _is_word_file(self, file_content: bytes, mime_type: str) -> bool:
|
||||
"""判断是不是 Word 文件(doc / docx),不依赖后缀"""
|
||||
# 旧版 .doc
|
||||
if mime_type == 'application/msword':
|
||||
return True
|
||||
|
||||
# 新版 .docx(ZIP 内部包含 word/document.xml)
|
||||
header = file_content[:4]
|
||||
if header == b'PK\x03\x04':
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_content)) as zf:
|
||||
return "word/document.xml" in zf.namelist()
|
||||
except:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def _is_excel_file(self, file_content: bytes, mime_type: str) -> bool:
|
||||
"""判断是不是 Excel 文件(xls / xlsx),不依赖后缀"""
|
||||
# 旧版 .xls
|
||||
if mime_type == 'application/vnd.ms-excel':
|
||||
return True
|
||||
|
||||
# 新版 .xlsx(ZIP 内部包含 xl/workbook.xml)
|
||||
header = file_content[:4]
|
||||
if header == b'PK\x03\x04':
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_content)) as zf:
|
||||
return "xl/workbook.xml" in zf.namelist()
|
||||
except:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _decode_text_safe(file_content: bytes) -> str:
|
||||
"""
|
||||
【万能文本解码】
|
||||
自动检测编码,支持 utf-8 / gbk / gb2312 / utf-8-sig / ascii 等
|
||||
永远不报错,永远不乱码
|
||||
"""
|
||||
if not file_content:
|
||||
return ""
|
||||
|
||||
# 1. 自动检测文件编码
|
||||
detect = chardet.detect(file_content)
|
||||
encoding = detect.get("encoding") or "utf-8"
|
||||
encoding = encoding.lower()
|
||||
|
||||
# 2. 兼容常见中文编码
|
||||
compatible_encodings = ["utf-8", "gbk", "gb18030", "gb2312", "ascii", "latin-1"]
|
||||
|
||||
# 3. 按优先级尝试解码
|
||||
for enc in [encoding] + compatible_encodings:
|
||||
if not enc:
|
||||
continue
|
||||
try:
|
||||
return file_content.decode(enc.strip())
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
|
||||
# 终极兜底
|
||||
return file_content.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def get_multimodal_service(db: Session) -> MultimodalService:
|
||||
|
||||
@@ -78,7 +78,7 @@ class ToolService:
|
||||
|
||||
def get_tool_info(self, tool_id: str, tenant_id: uuid.UUID) -> Optional[ToolInfo]:
|
||||
"""获取工具详情"""
|
||||
config = self.tool_repo.find_by_id_and_tenant(self.db, uuid.UUID(tool_id), tenant_id)
|
||||
config = self.tool_repo.find_by_id_and_tenant_all(self.db, uuid.UUID(tool_id), tenant_id)
|
||||
return self._config_to_info(config) if config else None
|
||||
|
||||
def _check_name_duplicate(self, name: str, tool_type: ToolType, tenant_id: uuid.UUID, exclude_id: Optional[uuid.UUID] = None):
|
||||
@@ -237,7 +237,7 @@ class ToolService:
|
||||
return False
|
||||
|
||||
def delete_tool(self, tool_id: str, tenant_id: uuid.UUID) -> bool:
|
||||
"""删除工具"""
|
||||
"""删除工具(逻辑删除)"""
|
||||
config = self._get_tool_config(tool_id, tenant_id)
|
||||
if not config:
|
||||
return False
|
||||
@@ -246,14 +246,7 @@ class ToolService:
|
||||
raise ValueError("内置工具不允许删除")
|
||||
|
||||
try:
|
||||
# 删除关联表记录
|
||||
if config.tool_type == ToolType.CUSTOM.value:
|
||||
self.db.query(CustomToolConfig).filter(CustomToolConfig.id == config.id).delete()
|
||||
elif config.tool_type == ToolType.MCP.value:
|
||||
self.db.query(MCPToolConfig).filter(MCPToolConfig.id == config.id).delete()
|
||||
|
||||
# 删除主表记录(ToolExecution会通过cascade自动删除)
|
||||
self.db.delete(config)
|
||||
config.is_active = False
|
||||
self._clear_tool_cache(tool_id)
|
||||
self.db.commit()
|
||||
return True
|
||||
@@ -262,6 +255,27 @@ class ToolService:
|
||||
logger.error(f"删除工具失败: {tool_id}, {e}")
|
||||
return False
|
||||
|
||||
def set_tool_active(self, tool_id: str, tenant_id: uuid.UUID, is_active: bool) -> bool:
|
||||
"""设置工具可用状态(启用/禁用)"""
|
||||
# 直接查询,包含 is_active=False 的记录
|
||||
config = self.db.query(ToolConfig).filter(
|
||||
ToolConfig.id == uuid.UUID(tool_id),
|
||||
ToolConfig.tenant_id == tenant_id
|
||||
).first()
|
||||
if not config:
|
||||
return False
|
||||
if config.tool_type == ToolType.BUILTIN.value:
|
||||
raise ValueError("内置工具不允许修改可用状态")
|
||||
try:
|
||||
config.is_active = is_active
|
||||
self._clear_tool_cache(tool_id)
|
||||
self.db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"设置工具状态失败: {tool_id}, {e}")
|
||||
return False
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
tool_id: str,
|
||||
@@ -378,7 +392,7 @@ class ToolService:
|
||||
Returns:
|
||||
方法列表或None
|
||||
"""
|
||||
config = self._get_tool_config(tool_id, tenant_id)
|
||||
config = self._get_tool_config_all(tool_id, tenant_id)
|
||||
if not config:
|
||||
return None
|
||||
|
||||
@@ -857,16 +871,20 @@ class ToolService:
|
||||
}
|
||||
|
||||
def _get_tool_config(self, tool_id: str, tenant_id: uuid.UUID) -> Optional[ToolConfig]:
|
||||
"""获取工具配置"""
|
||||
"""获取工具配置(仅返回 is_active=True)"""
|
||||
return self.tool_repo.find_by_id_and_tenant(self.db, uuid.UUID(tool_id), tenant_id)
|
||||
|
||||
def _get_tool_config_all(self, tool_id: str, tenant_id: uuid.UUID) -> Optional[ToolConfig]:
|
||||
"""获取工具配置(返回所有)"""
|
||||
return self.tool_repo.find_by_id_and_tenant_all(self.db, uuid.UUID(tool_id), tenant_id)
|
||||
|
||||
def get_tool_instance(self, tool_id: str, tenant_id: uuid.UUID) -> Optional[BaseTool]:
|
||||
"""获取工具实例"""
|
||||
"""获取工具实例(仅返回 is_active=True 的工具)"""
|
||||
if tool_id in self._tool_cache:
|
||||
return self._tool_cache[tool_id]
|
||||
|
||||
config = self._get_tool_config(tool_id, tenant_id)
|
||||
if not config:
|
||||
if not config or not config.is_active:
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -980,6 +998,7 @@ class ToolService:
|
||||
tags=config.tags or [],
|
||||
tenant_id=str(config.tenant_id) if config.tenant_id else None,
|
||||
config_data=config_data,
|
||||
is_active=config.is_active,
|
||||
created_at=config.created_at
|
||||
)
|
||||
|
||||
|
||||
@@ -1408,12 +1408,11 @@ async def analytics_memory_types(
|
||||
if end_user_id:
|
||||
try:
|
||||
conversation_repo = ConversationRepository(db)
|
||||
conversations = conversation_repo.get_conversation_by_user_id(
|
||||
conversations, total = conversation_repo.get_conversation_by_user_id(
|
||||
user_id=uuid.UUID(end_user_id),
|
||||
limit=100, # 获取更多会话以准确统计
|
||||
is_activate=True
|
||||
)
|
||||
work_count = len(conversations)
|
||||
work_count = total
|
||||
logger.debug(f"工作记忆数量(会话数): {work_count} (end_user_id={end_user_id})")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取会话数量失败,工作记忆数量设为0: {str(e)}")
|
||||
|
||||
@@ -55,6 +55,7 @@ class WorkflowService:
|
||||
edges: list[dict[str, Any]],
|
||||
variables: list[dict[str, Any]] | None = None,
|
||||
execution_config: dict[str, Any] | None = None,
|
||||
features: dict[str, Any] | None = None,
|
||||
triggers: list[dict[str, Any]] | None = None,
|
||||
validate: bool = True
|
||||
) -> WorkflowConfig:
|
||||
@@ -66,6 +67,7 @@ class WorkflowService:
|
||||
edges: 边列表
|
||||
variables: 变量列表
|
||||
execution_config: 执行配置
|
||||
features: 功能特性
|
||||
triggers: 触发器列表
|
||||
validate: 是否验证配置
|
||||
|
||||
@@ -81,6 +83,7 @@ class WorkflowService:
|
||||
"edges": edges,
|
||||
"variables": variables or [],
|
||||
"execution_config": execution_config or {},
|
||||
"features": features or {},
|
||||
"triggers": triggers or []
|
||||
}
|
||||
|
||||
@@ -101,6 +104,7 @@ class WorkflowService:
|
||||
edges=edges,
|
||||
variables=variables,
|
||||
execution_config=execution_config,
|
||||
features=features,
|
||||
triggers=triggers
|
||||
)
|
||||
|
||||
@@ -570,6 +574,9 @@ class WorkflowService:
|
||||
message=f"工作流配置不存在: app_id={app_id}"
|
||||
)
|
||||
|
||||
feature_configs = config.features or {}
|
||||
self._validate_file_upload(feature_configs, payload.files)
|
||||
|
||||
input_data = {
|
||||
"message": payload.message, "variables": payload.variables,
|
||||
"conversation_id": payload.conversation_id,
|
||||
@@ -633,30 +640,33 @@ class WorkflowService:
|
||||
final_messages = result.get("messages", [])[init_message_length:]
|
||||
human_message = ""
|
||||
assistant_message = ""
|
||||
human_meta = {
|
||||
"files": []
|
||||
}
|
||||
for message in final_messages:
|
||||
if message["role"] == "user":
|
||||
if isinstance(message["content"], str):
|
||||
human_message += message["content"]
|
||||
elif isinstance(message["content"], list):
|
||||
for file in message["content"]:
|
||||
if file.get("type") == FileType.IMAGE:
|
||||
human_message += f"})"
|
||||
else:
|
||||
human_message += f"[{file.get('type')}]({file.get('url', '')})"
|
||||
human_meta["files"].append({
|
||||
"type": file.get("type"),
|
||||
"url": file.get("url")
|
||||
})
|
||||
if message["role"] == "assistant":
|
||||
assistant_message = message["content"]
|
||||
self.conversation_service.add_message(
|
||||
conversation_id=conversation_id_uuid,
|
||||
role="user",
|
||||
content=human_message,
|
||||
meta_data=None
|
||||
meta_data=human_meta
|
||||
)
|
||||
self.conversation_service.add_message(
|
||||
message_id=message_id,
|
||||
conversation_id=conversation_id_uuid,
|
||||
role="assistant",
|
||||
content=assistant_message,
|
||||
meta_data={"usage": token_usage}
|
||||
meta_data={"usage": token_usage, "audio_url": None}
|
||||
)
|
||||
self.update_execution_status(
|
||||
execution.execution_id,
|
||||
@@ -737,6 +747,8 @@ class WorkflowService:
|
||||
code=BizCode.CONFIG_MISSING,
|
||||
message=f"工作流配置不存在: app_id={app_id}"
|
||||
)
|
||||
feature_configs = config.features or {}
|
||||
self._validate_file_upload(feature_configs, payload.files)
|
||||
|
||||
input_data = {
|
||||
"message": payload.message, "variables": payload.variables,
|
||||
@@ -797,30 +809,33 @@ class WorkflowService:
|
||||
final_messages = event.get("data", {}).get("messages", [])[init_message_length:]
|
||||
human_message = ""
|
||||
assistant_message = ""
|
||||
human_meta = {
|
||||
"files": []
|
||||
}
|
||||
for message in final_messages:
|
||||
if message["role"] == "user":
|
||||
if isinstance(message["content"], str):
|
||||
human_message += message["content"]
|
||||
elif isinstance(message["content"], list):
|
||||
for file in message["content"]:
|
||||
if file.get("type") == FileType.IMAGE:
|
||||
human_message += f"})"
|
||||
else:
|
||||
human_message += f"[{file.get('type')}]({file.get('url', '')})"
|
||||
human_meta["files"].append({
|
||||
"type": file.get("type"),
|
||||
"url": file.get("url")
|
||||
})
|
||||
if message["role"] == "assistant":
|
||||
assistant_message = message["content"]
|
||||
self.conversation_service.add_message(
|
||||
conversation_id=conversation_id_uuid,
|
||||
role="user",
|
||||
content=human_message,
|
||||
meta_data=None
|
||||
meta_data=human_meta
|
||||
)
|
||||
self.conversation_service.add_message(
|
||||
message_id=message_id,
|
||||
conversation_id=conversation_id_uuid,
|
||||
role="assistant",
|
||||
content=assistant_message,
|
||||
meta_data={"usage": token_usage}
|
||||
meta_data={"usage": token_usage, "audio_url": None}
|
||||
)
|
||||
self.update_execution_status(
|
||||
execution.execution_id,
|
||||
@@ -845,7 +860,10 @@ class WorkflowService:
|
||||
yield event
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"工作流流式执行失败: execution_id={execution.execution_id}, error={e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Workflow streaming execution failed: execution_id={execution.execution_id}, error={e}",
|
||||
exc_info=True
|
||||
)
|
||||
self.update_execution_status(
|
||||
execution.execution_id,
|
||||
"failed",
|
||||
@@ -868,6 +886,80 @@ class WorkflowService:
|
||||
return node.get("config", {}).get("variables", [])
|
||||
raise BusinessException("workflow config error - start node not found")
|
||||
|
||||
@staticmethod
|
||||
def is_memory_enable(config: dict) -> bool:
|
||||
nodes = config.get("nodes", [])
|
||||
for node in nodes:
|
||||
if node.get("type") in [NodeType.MEMORY_READ, NodeType.MEMORY_WRITE]:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _validate_file_upload(
|
||||
features_config: dict[str, Any],
|
||||
files: Optional[list[FileInput]]
|
||||
) -> None:
|
||||
"""校验上传文件是否符合 file_upload 配置"""
|
||||
if not files:
|
||||
return
|
||||
fu = features_config.get("file_upload")
|
||||
if fu is None:
|
||||
return
|
||||
if not (isinstance(fu, dict) and fu.get("enabled")):
|
||||
raise BusinessException(
|
||||
"The application does not have file upload functionality enabled",
|
||||
BizCode.BAD_REQUEST
|
||||
)
|
||||
max_count = fu.get("max_file_count", 5)
|
||||
if len(files) > max_count:
|
||||
raise BusinessException(
|
||||
f"File count exceeds limit (maximum {max_count} files)",
|
||||
BizCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
# 校验传输方式
|
||||
allowed_methods = fu.get("allowed_transfer_methods", ["local_file", "remote_url"])
|
||||
for f in files:
|
||||
if f.transfer_method.value not in allowed_methods:
|
||||
raise BusinessException(
|
||||
f"Unsupport file transfer method:{f.transfer_method.value},"
|
||||
f"allowed method:{', '.join(allowed_methods)}",
|
||||
BizCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
# 各类型对应的开关和大小限制配置键
|
||||
type_cfg = {
|
||||
"image": ("image_enabled", "image_max_size_mb", 20, "image"),
|
||||
"audio": ("audio_enabled", "audio_max_size_mb", 50, "audio"),
|
||||
"document": ("document_enabled", "document_max_size_mb", 100, "document"),
|
||||
"video": ("video_enabled", "video_max_size_mb", 500, "video"),
|
||||
}
|
||||
|
||||
for f in files:
|
||||
ftype = str(f.type) # 如 "image", "audio", "document", "video"
|
||||
cfg = type_cfg.get(ftype)
|
||||
if cfg is None:
|
||||
continue
|
||||
enabled_key, size_key, default_max_mb, label = cfg
|
||||
|
||||
# 校验类型开关
|
||||
if not fu.get(enabled_key):
|
||||
raise BusinessException(
|
||||
f"The application has not enabled {label} file upload",
|
||||
BizCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
# 校验文件大小(仅当内容已加载时)
|
||||
content = f.get_content()
|
||||
if content is not None:
|
||||
max_mb = fu.get(size_key, default_max_mb)
|
||||
size_mb = len(content) / (1024 * 1024)
|
||||
if size_mb > max_mb:
|
||||
raise BusinessException(
|
||||
f"{label} File size exceeds the limit (maximum {max_mb} MB, current {size_mb:.1f} MB)",
|
||||
BizCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
# ==================== 依赖注入函数 ====================
|
||||
|
||||
|
||||
@@ -1158,13 +1158,11 @@ def write_message_task(self, end_user_id: str, message: list[dict], config_id: s
|
||||
try:
|
||||
_r = get_sync_redis_client()
|
||||
if _r is not None:
|
||||
from datetime import timedelta as _td
|
||||
from datetime import timezone as _tz
|
||||
_CST = _tz(_td(hours=8))
|
||||
_now_cst = datetime.now(_CST).replace(tzinfo=None).isoformat()
|
||||
_now_utc = datetime.now(_tz.utc).isoformat()
|
||||
_r.set(
|
||||
f"write_message:last_done:{end_user_id}",
|
||||
_now_cst,
|
||||
_now_utc,
|
||||
ex=86400 * 30,
|
||||
)
|
||||
except Exception as _e:
|
||||
@@ -1294,9 +1292,9 @@ def write_total_memory_task(workspace_id: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
# 2. 查询所有app下的end_user_id(去重)
|
||||
app_ids = [app.id for app in apps]
|
||||
# app_ids = [app.id for app in apps]
|
||||
end_users = db.query(EndUser.id).filter(
|
||||
EndUser.app_id.in_(app_ids)
|
||||
EndUser.workspace_id == workspace_id
|
||||
).distinct().all()
|
||||
|
||||
# 3. 遍历所有end_user,查询每个宿主的记忆总量并累加
|
||||
@@ -1435,9 +1433,9 @@ def write_all_workspaces_memory_task(self) -> Dict[str, Any]:
|
||||
continue
|
||||
|
||||
# 2. 查询所有app下的end_user_id(去重)
|
||||
app_ids = [app.id for app in apps]
|
||||
# app_ids = [app.id for app in apps]
|
||||
end_users = db.query(EndUser.id).filter(
|
||||
EndUser.app_id.in_(app_ids)
|
||||
EndUser.workspace_id == workspace_id
|
||||
).distinct().all()
|
||||
|
||||
# 3. 遍历所有end_user,查询每个宿主的记忆总量并累加
|
||||
@@ -2677,13 +2675,15 @@ def write_perceptual_memory(
|
||||
time_limit=7200, # 2小时硬超时
|
||||
soft_time_limit=6900,
|
||||
)
|
||||
def init_community_clustering_for_users(self, end_user_ids: List[str]) -> Dict[str, Any]:
|
||||
def init_community_clustering_for_users(self, end_user_ids: List[str], workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""触发型任务:检查指定用户列表,对有 ExtractedEntity 但无 Community 节点的用户执行全量聚类。
|
||||
|
||||
由 /dashboard/end_users 接口触发,已有社区节点的用户直接跳过。
|
||||
任务完成且所有用户数据均完整时,写入 Redis 标记,避免下次重复投递。
|
||||
|
||||
Args:
|
||||
end_user_ids: 需要检查的用户 ID 列表
|
||||
workspace_id: 工作空间 ID,用于完成标记
|
||||
|
||||
Returns:
|
||||
包含任务执行结果的字典
|
||||
@@ -2709,6 +2709,7 @@ def init_community_clustering_for_users(self, end_user_ids: List[str]) -> Dict[s
|
||||
|
||||
# 批量预取所有用户的配置(内置兜底:用户配置不可用时自动回退到工作空间默认配置)
|
||||
user_llm_map: Dict[str, Optional[str]] = {}
|
||||
user_embedding_map: Dict[str, Optional[str]] = {}
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
from app.services.memory_agent_service import get_end_users_connected_configs_batch
|
||||
@@ -2720,21 +2721,54 @@ def init_community_clustering_for_users(self, end_user_ids: List[str]) -> Dict[s
|
||||
try:
|
||||
cfg = MemoryConfigService(db).load_memory_config(config_id=config_id)
|
||||
user_llm_map[uid] = str(cfg.llm_model_id) if cfg.llm_model_id else None
|
||||
user_embedding_map[uid] = str(cfg.embedding_model_id) if cfg.embedding_model_id else None
|
||||
except Exception as e:
|
||||
logger.warning(f"[CommunityCluster] 用户 {uid} 加载 LLM 配置失败,将使用 None: {e}")
|
||||
logger.warning(f"[CommunityCluster] 用户 {uid} 加载配置失败,将使用 None: {e}")
|
||||
user_llm_map[uid] = None
|
||||
user_embedding_map[uid] = None
|
||||
else:
|
||||
user_llm_map[uid] = None
|
||||
user_embedding_map[uid] = None
|
||||
except Exception as e:
|
||||
logger.warning(f"[CommunityCluster] 批量获取 LLM 配置失败,所有用户将使用 None: {e}")
|
||||
logger.warning(f"[CommunityCluster] 批量获取配置失败,所有用户将使用 None: {e}")
|
||||
|
||||
for end_user_id in end_user_ids:
|
||||
try:
|
||||
# 已有社区节点则跳过
|
||||
# 已有社区节点时,检查是否存在属性不完整的节点
|
||||
has_communities = await repo.has_communities(end_user_id)
|
||||
if has_communities:
|
||||
skipped += 1
|
||||
logger.debug(f"[CommunityCluster] 用户 {end_user_id} 已有社区节点,跳过")
|
||||
llm_model_id = user_llm_map.get(end_user_id)
|
||||
embedding_model_id = user_embedding_map.get(end_user_id)
|
||||
incomplete_ids = await repo.get_incomplete_communities(
|
||||
end_user_id, check_embedding=bool(embedding_model_id)
|
||||
)
|
||||
if not incomplete_ids:
|
||||
skipped += 1
|
||||
logger.debug(f"[CommunityCluster] 用户 {end_user_id} 社区节点均完整,跳过")
|
||||
continue
|
||||
|
||||
# 对不完整的社区节点逐一补全元数据
|
||||
engine = LabelPropagationEngine(
|
||||
connector=connector,
|
||||
llm_model_id=llm_model_id,
|
||||
embedding_model_id=embedding_model_id,
|
||||
)
|
||||
logger.info(
|
||||
f"[CommunityCluster] 用户 {end_user_id} 发现 {len(incomplete_ids)} 个属性不完整的社区,开始补全"
|
||||
)
|
||||
patch_ok = 0
|
||||
patch_fail = 0
|
||||
for cid in incomplete_ids:
|
||||
try:
|
||||
await engine._generate_community_metadata(cid, end_user_id)
|
||||
patch_ok += 1
|
||||
except Exception as patch_err:
|
||||
patch_fail += 1
|
||||
logger.error(f"[CommunityCluster] 社区 {cid} 元数据补全失败: {patch_err}")
|
||||
logger.info(
|
||||
f"[CommunityCluster] 用户 {end_user_id} 社区补全完成: 成功={patch_ok}, 失败={patch_fail}"
|
||||
)
|
||||
initialized += 1
|
||||
continue
|
||||
|
||||
# 检查是否有 ExtractedEntity 节点
|
||||
@@ -2744,11 +2778,13 @@ def init_community_clustering_for_users(self, end_user_ids: List[str]) -> Dict[s
|
||||
logger.debug(f"[CommunityCluster] 用户 {end_user_id} 无实体节点,跳过")
|
||||
continue
|
||||
|
||||
# 每个用户使用自己的 llm_model_id
|
||||
# 每个用户使用自己的 llm_model_id / embedding_model_id
|
||||
llm_model_id = user_llm_map.get(end_user_id)
|
||||
embedding_model_id = user_embedding_map.get(end_user_id)
|
||||
engine = LabelPropagationEngine(
|
||||
connector=connector,
|
||||
llm_model_id=llm_model_id,
|
||||
embedding_model_id=embedding_model_id,
|
||||
)
|
||||
|
||||
logger.info(f"[CommunityCluster] 用户 {end_user_id} 有 {len(entities)} 个实体,开始全量聚类,llm_model_id={llm_model_id}")
|
||||
|
||||
@@ -100,7 +100,8 @@ def agent_config_4_app_release(release: AppRelease) -> AgentConfig:
|
||||
memory=config_dict.get("memory"),
|
||||
variables=config_dict.get("variables", []),
|
||||
tools=config_dict.get("tools", []),
|
||||
skills=config_dict.get("skills", {})
|
||||
skills=config_dict.get("skills", {}),
|
||||
features=config_dict.get("features", {})
|
||||
)
|
||||
|
||||
return agent_config
|
||||
|
||||
@@ -1,4 +1,38 @@
|
||||
{
|
||||
"v0.2.8": {
|
||||
"introduction": {
|
||||
"codeName": "景玉",
|
||||
"releaseDate": "2026-3-20",
|
||||
"upgradePosition": "🐻 MemoryBear v0.2.8 社区版全面升级应用共享、多模态交互与平台基础设施,引入语音交互、感知记忆和云端存储,打造更强大的开放 AI 记忆平台",
|
||||
"coreUpgrades": [
|
||||
"1. 应用共享与发布<br>* 应用共享(Agent、工作流、Agent 集群):全类型应用共享至其他空间<br>* 分享应用默认开启记忆功能:发布分享后记忆默认开启,关闭时提醒<br>* 工作流记忆分享规则:按记忆配置自动控制分享页记忆开关<br>* 分享会话联网搜索修复:恢复分享应用的联网搜索能力",
|
||||
"2. 多模态与交互 💬<br>* 语音输入:模型接口和应用支持语音输入<br>* 语音回复:应用支持语音回复模态<br>* 多模态感知记忆:记忆系统支持视觉、音频、图片和文件的感知记忆<br>* 对话框文件展示:试运行和体验分享中正确展示上传文件",
|
||||
"3. 平台与基础设施 ⚙️<br>* i18n 国际化:全面多语言多地区支持<br>* 云端文件存储(OSS + S3):支持阿里云 OSS 和 S3 云端上传<br>* Flower 容器监控:Celery 异步任务监控与管理",
|
||||
"4. EndUser 身份迁移 🔐<br>* EndUser 从 app_id 迁移至 workspace_id:身份从应用级迁移至工作空间级",
|
||||
"5. 情景记忆 🧠<br>* 情景记忆聚类算法:基于社区图谱的聚类算法,支持老用户图谱生成",
|
||||
"6. 稳健性与缺陷修复 🔧<br>* MCP 服务删除后工具 404:修复删除 MCP 服务后接口报错<br>* 应用导出配置不一致:导出已保存配置而非画布状态<br>* 工作流节点 ID 重复:修复复制节点后 ID 冲突<br>* 条件分支连线错误:修复保存刷新后连线错乱<br>* 回复节点内容丢失:修复点击画布后内容消失<br>* 连接桩规则优化:禁止非法连接方向<br>* 知识库状态列宽度:锁定或自适应宽度<br>* 等待中文档预览:支持未完成解析文档预览<br>* 知识库关联修复:统一修复关联问题<br>* 多模态对话连续性:修复多模态内容后无法继续对话<br>* 时区统一:环境变量统一控制存储和任务时区<br>* 遗忘强度精度:修复小数显示过长",
|
||||
"<br>",
|
||||
"v0.2.8 社区版在应用共享和多模态交互方面实现重大升级,感知记忆扩展了平台的认知维度。后续将深化多智能体协作、情景记忆聚类,并持续优化平台稳定性与开放生态。",
|
||||
"MemoryBear —— 让 AI 拥有记忆 🐻✨"
|
||||
]
|
||||
},
|
||||
"introduction_en": {
|
||||
"codeName": "JingYu",
|
||||
"releaseDate": "2026-3-20",
|
||||
"upgradePosition": "🐻 MemoryBear v0.2.8 Community delivers multimodal interaction, perceptual memory, cloud storage, and workspace-level identity for a more capable open AI memory platform",
|
||||
"coreUpgrades": [
|
||||
"1. Application Sharing & Publishing<br>* Application Sharing (Agent, Workflow, Agent Cluster): Full sharing across all app types<br>* Memory Enabled by Default: Memory auto-enabled on shared apps with disable reminder<br>* Workflow Memory Sharing Rules: Auto-controlled based on memory configuration<br>* Shared Session Web Search Fix: Restored web search for shared apps",
|
||||
"2. Multimodal & Interaction 💬<br>* Voice Input: Model interfaces and apps support voice input<br>* Voice Reply: Apps support voice reply modality<br>* Multimodal Perceptual Memory: Memory system supports visual, audio, image, and file perception<br>* File Display in Chat: Uploaded files display correctly in dry-run and sharing",
|
||||
"3. Platform & Infrastructure ⚙️<br>* i18n Internationalization: Full multi-language multi-region support<br>* Cloud File Storage (OSS + S3): Alibaba Cloud OSS and S3 cloud uploads<br>* Flower Container Monitoring: Celery async task monitoring and management",
|
||||
"4. EndUser Identity Migration 🔐<br>* EndUser Migration from app_id to workspace_id: Identity migrated to workspace level",
|
||||
"5. Episodic Memory 🧠<br>* Episodic Memory Clustering: Community-graph-based clustering with legacy user support",
|
||||
"6. Robustness & Bug Fixes 🔧<br>* MCP Service Deletion 404: Fixed tool endpoint error after MCP removal<br>* App Export Config Mismatch: Exports saved config instead of canvas state<br>* Workflow Duplicate Node ID: Fixed ID conflict on node duplication<br>* Conditional Branch Wiring: Fixed wiring reset after save/refresh<br>* Reply Node Content Loss: Fixed content disappearing on canvas click<br>* Port Connection Rules: Prohibited invalid connection directions<br>* Knowledge Base Status Width: Locked or adaptive column width<br>* Pending Document Preview: Preview support for unparsed documents<br>* Knowledge Base Association Fixes: Consolidated association fixes<br>* Multimodal Conversation Continuity: Fixed single-round limit after multimodal input<br>* Timezone Unification: Env-var controlled unified timezone<br>* Forgetting Strength Precision: Fixed excessive decimal display",
|
||||
"<br>",
|
||||
"v0.2.8 Community delivers major upgrades in application sharing and multimodal interaction, with perceptual memory expanding the platform's cognitive dimensions. Multi-agent collaboration, episodic clustering, and continued platform stability improvements are ahead.",
|
||||
"MemoryBear — Give AI Memory 🐻✨"
|
||||
]
|
||||
}
|
||||
},
|
||||
"v0.2.7": {
|
||||
"introduction": {
|
||||
"codeName": "武陵",
|
||||
|
||||
50
api/migrations/versions/12114b3e953c_202603131647.py
Normal file
50
api/migrations/versions/12114b3e953c_202603131647.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""202603131647
|
||||
|
||||
Revision ID: 12114b3e953c
|
||||
Revises: cd3a402c2f6c
|
||||
Create Date: 2026-03-13 08:47:30.455956
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '12114b3e953c'
|
||||
down_revision: Union[str, None] = 'ef9d172cb753'
|
||||
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! ###
|
||||
conn = op.get_bind()
|
||||
print("Step 1: 添加 workspace_id 列...")
|
||||
op.add_column('end_users', sa.Column('workspace_id', sa.UUID(), nullable=True))
|
||||
print("Step 2: 回填 workspace_id...")
|
||||
conn.execute(text("""
|
||||
UPDATE end_users
|
||||
SET workspace_id = apps.workspace_id
|
||||
FROM apps
|
||||
WHERE end_users.app_id = apps.id
|
||||
"""))
|
||||
# Step 3: 设置 workspace_id 为 NOT NULL
|
||||
print("Step 3: 设置 workspace_id 为 NOT NULL...")
|
||||
op.alter_column('end_users', 'workspace_id', nullable=False)
|
||||
op.alter_column('end_users', 'app_id', existing_type=sa.UUID(), nullable=True)
|
||||
# Step 4: 添加外键约束
|
||||
print("Step 4: 添加外键约束...")
|
||||
op.create_foreign_key('fk_end_users_workspace_id','end_users', 'workspaces',
|
||||
['workspace_id'], ['id']
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint('fk_end_users_workspace_id', 'end_users', type_='foreignkey')
|
||||
op.alter_column('end_users', 'app_id', existing_type=sa.UUID(), nullable=False)
|
||||
op.drop_column('end_users', 'workspace_id')
|
||||
# ### end Alembic commands ###
|
||||
34
api/migrations/versions/818c6c535e14_202603161825.py
Normal file
34
api/migrations/versions/818c6c535e14_202603161825.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""202603161825
|
||||
|
||||
Revision ID: 818c6c535e14
|
||||
Revises: 12114b3e953c
|
||||
Create Date: 2026-03-16 18:33:41.883671
|
||||
|
||||
"""
|
||||
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 = '818c6c535e14'
|
||||
down_revision: Union[str, None] = '12114b3e953c'
|
||||
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('agent_configs', sa.Column('features', postgresql.JSON(astext_type=sa.Text()), nullable=True, comment='功能特性配置'))
|
||||
op.add_column('tool_configs', sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否可用,False表示已删除'))
|
||||
op.create_index(op.f('ix_tool_configs_is_active'), 'tool_configs', ['is_active'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_tool_configs_is_active'), table_name='tool_configs')
|
||||
op.drop_column('tool_configs', 'is_active')
|
||||
op.drop_column('agent_configs', 'features')
|
||||
# ### end Alembic commands ###
|
||||
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 ###
|
||||
@@ -46,6 +46,7 @@
|
||||
"lexical": "^0.39.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"mermaid": "^11.12.1",
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^15.0.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 13:59:45
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:07:54
|
||||
* @Last Modified time: 2026-03-18 20:01:29
|
||||
*/
|
||||
import { request } from '@/utils/request'
|
||||
import type { ApplicationModalData } from '@/views/ApplicationManagement/types'
|
||||
@@ -137,7 +137,7 @@ export const getExperienceConfig = (share_token: string) => {
|
||||
})
|
||||
}
|
||||
// Export application
|
||||
export const appExport = (app_id: string, appName: string, data?: { release_version: string }) => {
|
||||
export const appExport = (app_id: string, appName: string, data?: { release_id: string }) => {
|
||||
return request.getDownloadFile(`/apps/${app_id}/export`, `${appName}.yml`, data)
|
||||
}
|
||||
// Import application
|
||||
|
||||
@@ -52,6 +52,10 @@ export const getKnowledgeBaseTypeList = async (): Promise<string[]> => {
|
||||
// 如果不是数组,返回空数组
|
||||
return [];
|
||||
};
|
||||
// 获取文件地址
|
||||
export const getFileUrl = (fileId: string) => {
|
||||
return `${apiPrefix}/files/${fileId}`;
|
||||
};
|
||||
// 知识库文档解析类型
|
||||
export const getKnowledgeBaseDocumentParseTypeList = async () => {
|
||||
const response = await request.get(`${apiPrefix}/knowledges/parsertype`);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:00:06
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 10:48:41
|
||||
* @Last Modified time: 2026-03-19 18:35:10
|
||||
*/
|
||||
import { request } from '@/utils/request'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
@@ -218,8 +218,8 @@ export const getExplicitMemory = (end_user_id: string) => {
|
||||
export const getExplicitMemoryDetails = (data: { end_user_id: string, memory_id: string; }) => {
|
||||
return request.post(`/memory/explicit-memory/details`, data)
|
||||
}
|
||||
export const getConversations = (end_user_id: string) => {
|
||||
return request.get(`/memory/work/${end_user_id}/conversations`)
|
||||
export const getConversations = (end_user_id: string, page = 1, pagesize = 20) => {
|
||||
return request.get(`/memory/work/${end_user_id}/conversations`, { page, pagesize })
|
||||
}
|
||||
export const getConversationMessages = (end_user_id: string, conversation_id: string) => {
|
||||
return request.get(`/memory/work/${end_user_id}/messages`, { conversation_id })
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:11:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:11:14
|
||||
* @Last Modified time: 2026-03-17 18:39:09
|
||||
*/
|
||||
import { type FC, useRef, useState } from 'react'
|
||||
import RecordRTC from 'recordrtc'
|
||||
import { App } from 'antd'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import { request } from '@/utils/request'
|
||||
@@ -19,14 +21,20 @@ interface AudioRecorderProps {
|
||||
action?: string;
|
||||
/** Additional config passed to the upload request */
|
||||
requestConfig?: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
onRecordingComplete,
|
||||
className = '',
|
||||
action = fileUploadUrlWithoutApiPrefix,
|
||||
requestConfig = {}
|
||||
requestConfig = {},
|
||||
disabled = false,
|
||||
maxSize,
|
||||
}) => {
|
||||
const { message } = App.useApp()
|
||||
const { t } = useTranslation();
|
||||
// Whether the recorder is currently capturing audio
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
// Holds the RecordRTC instance across renders
|
||||
@@ -34,6 +42,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
|
||||
/** Request microphone access and start recording */
|
||||
const startRecording = async () => {
|
||||
if (disabled) return
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
recorderRef.current = new RecordRTC(stream, {
|
||||
@@ -49,10 +58,17 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
|
||||
/** Stop recording, upload the audio blob, then invoke the completion callback */
|
||||
const stopRecording = () => {
|
||||
if (disabled) return
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stopRecording(() => {
|
||||
const blob = recorderRef.current!.getBlob()
|
||||
const url = recorderRef.current!.toURL()
|
||||
|
||||
if (maxSize && blob.size > maxSize * 1024 * 1024) {
|
||||
message.error(t('common.fileSizeTip', { size: maxSize }));
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, `recording_${Date.now()}.webm`)
|
||||
request
|
||||
@@ -76,7 +92,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
// swap background image to reflect current state
|
||||
return (
|
||||
<div
|
||||
className={`rb:size-5.5 rb:cursor-pointer rb:bg-cover ${className} ${
|
||||
className={`rb:size-5.5 rb:bg-cover ${disabled ? 'rb:opacity-65 rb:cursor-not-allowed' : 'rb:cursor-pointer'} ${className} ${
|
||||
isRecording
|
||||
? `rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]`
|
||||
: `rb:bg-[url('@/assets/images/conversation/audio.svg')]`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:01:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-12 14:59:38
|
||||
* @Last Modified time: 2026-03-19 13:41:26
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,8 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
icon,
|
||||
checkedIcon,
|
||||
children,
|
||||
cicle = false
|
||||
cicle = false,
|
||||
disabled,
|
||||
}) => {
|
||||
// Listen to value changes and trigger side effects via onValueChange callback
|
||||
useEffect(() => {
|
||||
@@ -63,13 +64,14 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
align="center"
|
||||
justify={cicle ? 'center' : 'start'}
|
||||
gap={4}
|
||||
className={clsx("rb:flex rb:items-center rb:cursor-pointer rb:border rb:hover:bg-[#F6F6F6]", {
|
||||
className={clsx("rb:flex rb:items-center rb:cursor-pointer rb:px-2! rb:border rb:hover:bg-[#F6F6F6]", {
|
||||
'rb:size-7 rb:rounded-[14px] rb:border-[0.5px] rb:border-[#EBEBEB]': cicle,
|
||||
'rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6': !cicle,
|
||||
'rb:rounded-lg rb:text-[12px] rb:h-6': !cicle,
|
||||
// Checked state: blue background and border
|
||||
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[rgba(21,94,239,0.25)] rb:hover:bg-[rgba(21,94,239,0.06)] rb:text-[#155EEF]": checked,
|
||||
// Unchecked state: gray border and dark text
|
||||
"rb:border-[#DFE4ED] rb:text-[#212332]": !checked,
|
||||
"rb:opacity-65 rb:cursor-not-allowed!": disabled
|
||||
})}
|
||||
onClick={handleChange}
|
||||
>
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:17
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:05:52
|
||||
* @Last Modified time: 2026-03-19 19:45:40
|
||||
*/
|
||||
import { type FC, useRef, useEffect } from 'react'
|
||||
import { type FC, useRef, useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import Markdown from '@/components/Markdown'
|
||||
import type { ChatContentProps } from './types'
|
||||
import { Spin } from 'antd'
|
||||
import { Spin, Divider, Space, Image, Flex } from 'antd'
|
||||
import { SoundOutlined } from '@ant-design/icons'
|
||||
|
||||
|
||||
const getFileUrl = (file: any) => {
|
||||
return file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat Content Display Component
|
||||
@@ -28,15 +34,33 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
// Scroll container reference for controlling auto-scroll to bottom
|
||||
const scrollContainerRef = useRef<(HTMLDivElement | null)>(null)
|
||||
const prevDataLengthRef = useRef(data.length);
|
||||
const isScrolledToBottomRef = useRef(true); // Track if user is scrolled to bottom
|
||||
const isScrolledToBottomRef = useRef(true);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null)
|
||||
|
||||
const handlePlay = (index: number, audio_url: string) => {
|
||||
if (playingIndex === index) {
|
||||
audioRef.current?.pause()
|
||||
setPlayingIndex(null)
|
||||
return
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
const audio = new Audio(audio_url)
|
||||
audioRef.current = audio
|
||||
audio.play()
|
||||
setPlayingIndex(index)
|
||||
audio.onended = () => setPlayingIndex(null)
|
||||
}
|
||||
|
||||
// Track scroll position to determine if user is at bottom
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||
// Consider user is at bottom if within 20px of the bottom
|
||||
isScrolledToBottomRef.current = scrollHeight - scrollTop - clientHeight < 20;
|
||||
// Consider user is at bottom if within 100px of the bottom
|
||||
isScrolledToBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,11 +88,16 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
// Auto-scroll if data length changed OR user is currently at bottom
|
||||
if (data.length !== prevDataLengthRef.current || isScrolledToBottomRef.current) {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
isScrolledToBottomRef.current = true;
|
||||
}
|
||||
prevDataLengthRef.current = data.length;
|
||||
}
|
||||
}, 0);
|
||||
}, [data])
|
||||
|
||||
const handleDownload = (file: any) => {
|
||||
window.open(getFileUrl(file), '_blank')
|
||||
}
|
||||
return (
|
||||
<div ref={scrollContainerRef} className={clsx("rb:relative rb:overflow-y-auto", classNames)}>
|
||||
{data.length === 0
|
||||
@@ -89,6 +118,49 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
{labelFormat(item)}
|
||||
</div>
|
||||
}
|
||||
{item.meta_data?.files && item.meta_data?.files.length > 0 && <Flex gap={8} vertical align="end">
|
||||
{item.meta_data?.files?.map((file) => {
|
||||
if (file.type.includes('image')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className={`rb:inline-block rb:group rb:relative rb:rounded-lg ${contentClassNames}`}>
|
||||
<Image src={getFileUrl(file)} alt={file.name} className="rb:w-full rb:max-w-80 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('video')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<video src={getFileUrl(file)} controls className="rb:max-w-80 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('audio')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:inline-flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5 rb:gap-2">
|
||||
<audio src={getFileUrl(file)} controls className="rb:max-w-80" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:p-1! rb:cursor-pointer" onClick={() => handleDownload(file)}>
|
||||
{(file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv'))
|
||||
? <div
|
||||
className="rb:size-10 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/excel.svg')]"
|
||||
></div>
|
||||
:(file.type.includes('pdf'))
|
||||
? <div
|
||||
className="rb:size-10 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf.svg')]"
|
||||
></div>
|
||||
: (file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document'))
|
||||
? <div
|
||||
className="rb:size-10 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word.svg')]"
|
||||
></div>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>}
|
||||
{/* Message bubble */}
|
||||
<div className={clsx('rb:border rb:text-left rb:rounded-lg rb:mt-1.5 rb:leading-4.5 rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-130 rb:wrap-break-word', contentClassNames, {
|
||||
// Error message style (content is null and not assistant message)
|
||||
@@ -101,6 +173,19 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
{item.subContent && renderRuntime && renderRuntime(item, index)}
|
||||
{/* Render message content using Markdown component */}
|
||||
<Markdown content={renderRuntime ? item.content ?? '' : item.content ?? errorDesc ?? ''} />
|
||||
|
||||
{item.meta_data?.audio_url && <>
|
||||
<Divider className="rb:my-3!" />
|
||||
<Space size={12} className="rb:pb-2 rb:pl-1">
|
||||
{playingIndex !== index
|
||||
? <SoundOutlined className="rb:cursor-pointer rb:hover:text-[#155EEF]! rb:size-5.5" onClick={() => handlePlay(index, item.meta_data?.audio_url!)} />
|
||||
: <div
|
||||
className="rb:size-5.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]"
|
||||
onClick={() => handlePlay(index, item.meta_data?.audio_url!)}
|
||||
/>
|
||||
}
|
||||
</Space>
|
||||
</>}
|
||||
</div>
|
||||
{/* Bottom label (such as timestamp, username, etc.) */}
|
||||
{labelPosition === 'bottom' &&
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 13:36:20
|
||||
* @Last Modified time: 2026-03-19 18:44:51
|
||||
*/
|
||||
import { type FC, useEffect, useMemo } from 'react'
|
||||
import { Flex, Input, Form } from 'antd'
|
||||
import { Flex, Input, Form, Spin } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import SendIcon from '@/assets/images/conversation/send.svg'
|
||||
import SendDisabledIcon from '@/assets/images/conversation/sendDisabled.svg'
|
||||
@@ -69,6 +70,8 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
onSend(values.message)
|
||||
}
|
||||
|
||||
console.log('previewFileList', previewFileList)
|
||||
|
||||
return (
|
||||
<div className={`rb:absolute rb:bottom-3 rb:left-0 rb:right-0 rb:w-full ${className}`}>
|
||||
<Flex vertical justify="space-between" className="rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:min-h-30">
|
||||
@@ -76,57 +79,78 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
{previewFileList.map((file) => {
|
||||
if (file.type.includes('image')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<img src={file.url} alt={file.name} className="rb:size-12! rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
|
||||
<div key={file.url || file.uid} className={clsx("rb:inline-block rb:group rb:relative rb:rounded-lg", {
|
||||
'rb:border rb:border-[#FF5D34]': file.status === 'error'
|
||||
})}>
|
||||
<img src={file.url} alt={file.name} className="rb:size-12! rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('video')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:w-45 rb:h-16 rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<video src={file.url} controls className="rb:w-45 rb:h-16 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
|
||||
<div key={file.url || file.uid} className={clsx("rb:w-45 rb:h-16 rb:inline-block rb:group rb:relative rb:rounded-lg", {
|
||||
'rb:border rb:border-[#FF5D34]': file.status === 'error'
|
||||
})}>
|
||||
<video src={file.url} controls className="rb:w-45 rb:h-15.5 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('audio')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:w-45 rb:h-16 rb:inline-flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5 rb:gap-2">
|
||||
<audio src={file.url} controls className="rb:w-45 rb:h-16" />
|
||||
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
|
||||
<div key={file.url || file.uid} className={clsx("rb:w-45 rb:h-16 rb:inline-flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5 rb:gap-2", {
|
||||
'rb:border rb:border-[#FF5D34]': file.status === 'error'
|
||||
})}>
|
||||
<audio src={file.url} controls className="rb:w-45 rb:h-15.5" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
|
||||
<div key={file.url || file.uid} className={clsx("rb:w-45 rb:text-[12px] rb:gap-2.5 rb:flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5", {
|
||||
'rb:border rb:border-[#FF5D34]': file.status === 'error'
|
||||
})}>
|
||||
{file.type.includes('pdf')
|
||||
? <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/pdf.svg')]"
|
||||
></div>
|
||||
: (file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv'))
|
||||
? <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/excel_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/excel.svg')]"
|
||||
></div>
|
||||
: (file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document'))
|
||||
? <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/word.svg')]"
|
||||
></div>
|
||||
: null
|
||||
}
|
||||
<div className="rb:flex-1 rb:w-32.5">
|
||||
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
|
||||
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.type} · {file.size}</div>
|
||||
</div>
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:w-45 rb:text-[12px] rb:gap-2.5 rb:flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5">
|
||||
{(file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/word.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('pdf')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/pdf.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/excel_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/excel.svg')]"
|
||||
></div>}
|
||||
<div className="rb:flex-1 rb:w-32.5">
|
||||
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
|
||||
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.type} · {file.size}</div>
|
||||
</div>
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
)
|
||||
})}
|
||||
</Flex></div>}
|
||||
|
||||
213
web/src/components/Chat/ChatToolbar.tsx
Normal file
213
web/src/components/Chat/ChatToolbar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-17 14:22:25
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 18:59:37
|
||||
*/
|
||||
// Toolbar component for chat input area, supporting file upload, audio recording, and variable configuration
|
||||
import { useRef, forwardRef, useImperativeHandle, type ReactNode, useEffect } from 'react'
|
||||
import { Flex, Dropdown, Divider, App, Form, type MenuProps } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import type { VariableConfigModalRef } from '@/views/Workflow/types'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
|
||||
// Exposed methods via ref for parent components to access/set form state
|
||||
export interface ChatToolbarRef {
|
||||
getFiles: () => any[]
|
||||
getVariables: () => Variable[]
|
||||
setFiles: (files: any[]) => void
|
||||
setVariables: (variables: Variable[]) => void
|
||||
}
|
||||
|
||||
// Props for configuring toolbar features, upload settings, and event callbacks
|
||||
export interface ChatToolbarProps {
|
||||
features: FeaturesConfigForm
|
||||
extra?: ReactNode
|
||||
uploadAction?: string
|
||||
uploadRequestConfig?: {
|
||||
data?: Record<string, string | number | boolean>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
onFilesChange?: (files: any[]) => void
|
||||
onVariablesChange?: (variables: Variable[]) => void
|
||||
onRecordingComplete?: (file: any) => void;
|
||||
defaultValue?: { memory: boolean }
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
files: any[]
|
||||
variables: Variable[];
|
||||
memory?: boolean;
|
||||
}
|
||||
|
||||
const max_file_count = 1;
|
||||
const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
|
||||
features,
|
||||
extra,
|
||||
uploadAction,
|
||||
uploadRequestConfig,
|
||||
onFilesChange,
|
||||
onVariablesChange,
|
||||
onRecordingComplete,
|
||||
defaultValue,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
const queryValues = Form.useWatch([], form)
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultValue) return
|
||||
form.setFieldsValue(defaultValue)
|
||||
}, [defaultValue])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getFiles: () => form.getFieldValue('files') || [],
|
||||
getVariables: () => form.getFieldValue('variables') || [],
|
||||
setFiles: (files) => form.setFieldValue('files', files),
|
||||
setVariables: (variables) => {
|
||||
console.log('variables', variables)
|
||||
form.setFieldValue('variables', variables)
|
||||
},
|
||||
}))
|
||||
|
||||
const { file_upload } = features || {}
|
||||
|
||||
// Append newly uploaded file to the file list when upload is complete
|
||||
const fileChange = (file?: any) => {
|
||||
console.log('file', file)
|
||||
const lastFiles = form.getFieldValue('files') || [];
|
||||
const index = lastFiles.findIndex((item: any) => item.uid === file.uid)
|
||||
if (index > -1) {
|
||||
lastFiles[index] = file
|
||||
} else {
|
||||
lastFiles.push(file)
|
||||
}
|
||||
form.setFieldValue('files', [...lastFiles])
|
||||
onFilesChange?.([...lastFiles])
|
||||
|
||||
console.log('lastFiles', lastFiles)
|
||||
}
|
||||
|
||||
// Append recorded audio file to the file list and notify parent
|
||||
const handleRecordingComplete = (file: any) => {
|
||||
const files = [...(queryValues?.files || []), file]
|
||||
form.setFieldValue('files', files)
|
||||
onFilesChange?.(files)
|
||||
onRecordingComplete?.(file)
|
||||
}
|
||||
|
||||
// Merge a batch of files (e.g. from remote URL modal) into the file list
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list?.length) return
|
||||
const files = [...(queryValues?.files || []), ...list]
|
||||
form.setFieldValue('files', files)
|
||||
onFilesChange?.(files)
|
||||
}
|
||||
|
||||
// Persist variable values from the config modal and notify parent
|
||||
const handleVariablesSave = (values: Variable[]) => {
|
||||
form.setFieldValue('variables', values)
|
||||
onVariablesChange?.(values)
|
||||
}
|
||||
|
||||
// True when any required variable is missing a value, used to highlight the config button
|
||||
const isNeedVariableConfig = queryValues?.variables?.some(
|
||||
vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === '')
|
||||
)
|
||||
|
||||
// Build dropdown menu items based on allowed transfer methods
|
||||
const fileMenus: MenuProps['items'] = []
|
||||
const enabledTypes = ['image', 'document', 'video', 'audio'].filter(
|
||||
type => file_upload?.[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]
|
||||
)
|
||||
if (file_upload?.allowed_transfer_methods?.includes('remote_url') && enabledTypes.length > 0) {
|
||||
fileMenus.push({
|
||||
key: 'url',
|
||||
label: t('memoryConversation.addRemoteFile'),
|
||||
onClick: () => {
|
||||
if ((queryValues?.files?.length || 0) >= max_file_count) {
|
||||
messageApi.warning(t('common.fileNumTip', { num: max_file_count }))
|
||||
return
|
||||
}
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (file_upload?.allowed_transfer_methods?.includes('local_file') && enabledTypes.length > 0) {
|
||||
fileMenus.push({
|
||||
key: 'upload',
|
||||
label: (
|
||||
<UploadFiles
|
||||
action={uploadAction}
|
||||
onChange={fileChange}
|
||||
requestConfig={uploadRequestConfig}
|
||||
featureConfig={file_upload}
|
||||
disabled={(queryValues?.files?.length || 0) >= max_file_count}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form form={form} initialValues={{ files: [], variables: [] }}>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Form.Item name="files" noStyle hidden={!file_upload?.enabled || fileMenus.length === 0}>
|
||||
<Dropdown menu={{ items: fileMenus }}>
|
||||
<div className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]" />
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
{extra}
|
||||
<Form.Item name="variables" className="rb:mb-0!" hidden={queryValues?.variables?.length < 1}>
|
||||
<div
|
||||
className={clsx('rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]', {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={() => variableConfigModalRef.current?.handleOpen(queryValues.variables)}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t('memoryConversation.variableConfig')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
{file_upload?.audio_enabled && file_upload?.allowed_transfer_methods?.includes('local_file') && (
|
||||
<Flex align="center">
|
||||
<AudioRecorder
|
||||
disabled={(queryValues?.files?.length || 0) >= max_file_count}
|
||||
action={uploadAction}
|
||||
requestConfig={uploadRequestConfig}
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
maxSize={file_upload?.audio_max_size_mb}
|
||||
/>
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
featureConfig={file_upload}
|
||||
/>
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleVariablesSave}
|
||||
/>
|
||||
</Form>
|
||||
)
|
||||
})
|
||||
|
||||
export default ChatToolbar
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:45:54
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-12 13:57:51
|
||||
* @Last Modified time: 2026-03-18 20:47:42
|
||||
*/
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
@@ -22,8 +22,11 @@ export interface ChatItem {
|
||||
created_at?: number | string;
|
||||
status?: string;
|
||||
subContent?: Record<string, any>[];
|
||||
files?: any[];
|
||||
error?: string;
|
||||
meta_data?: {
|
||||
audio_url?: string;
|
||||
files?: any[];
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { useState, useEffect, type FC } from 'react';
|
||||
import { Spin, Alert, Button, Table } from 'antd';
|
||||
import { ReloadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2026-03-16 19:01:12
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-03-20 12:12:20
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback, type FC } from 'react';
|
||||
import { Spin, Alert, Button, Table, InputNumber, Image } from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import RbMarkdown from '../Markdown';
|
||||
import { cookieUtils } from '@/utils/request';
|
||||
import mammoth from 'mammoth';
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
// 设置 pdf.js worker - 使用 CDN 避免 Vite 打包动态 import 问题
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.worker.min.mjs';
|
||||
|
||||
interface DocumentPreviewProps {
|
||||
fileUrl: string;
|
||||
@@ -30,11 +49,30 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
const [htmlContent, setHtmlContent] = useState<string>('');
|
||||
const [excelData, setExcelData] = useState<{ sheetName: string; data: any[][] }[]>([]);
|
||||
|
||||
// PDF 状态
|
||||
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||
const [pdfCurrentPage, setPdfCurrentPage] = useState(1);
|
||||
const [pdfTotalPages, setPdfTotalPages] = useState(0);
|
||||
const [pdfScale, setPdfScale] = useState(1.5);
|
||||
const pdfCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pdfRenderingRef = useRef(false);
|
||||
|
||||
// PPT 状态
|
||||
const [pptSlides, setPptSlides] = useState<string[]>([]);
|
||||
const [pptCurrentPage, setPptCurrentPage] = useState(1);
|
||||
const [pptTotalPages, setPptTotalPages] = useState(0);
|
||||
|
||||
// 图片状态
|
||||
const [imageBlobUrl, setImageBlobUrl] = useState<string>('');
|
||||
|
||||
// 支持预览的文件类型
|
||||
const previewableTypes = ['.pdf', '.txt', '.md', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx'];
|
||||
// PPT 暂不支持
|
||||
const downloadOnlyTypes = ['.ppt', '.pptx'];
|
||||
|
||||
const previewableTypes = [
|
||||
'.pdf', '.txt', '.md', '.csv',
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp',
|
||||
'.doc', '.docx', '.xls', '.xlsx',
|
||||
'.ppt', '.pptx',
|
||||
];
|
||||
|
||||
const getFileExtension = () => {
|
||||
if (fileExt) {
|
||||
return fileExt.toLowerCase().startsWith('.') ? fileExt.toLowerCase() : `.${fileExt.toLowerCase()}`;
|
||||
@@ -43,7 +81,7 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
const match = name.match(/\.([^.]+)$/);
|
||||
return match ? `.${match[1].toLowerCase()}` : '';
|
||||
};
|
||||
|
||||
|
||||
const isTextFile = () => getFileExtension() === '.txt';
|
||||
const isMarkdownFile = () => getFileExtension() === '.md';
|
||||
const isImageFile = () => {
|
||||
@@ -52,9 +90,31 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
};
|
||||
const isPdfFile = () => getFileExtension() === '.pdf';
|
||||
const isWordFile = () => ['.doc', '.docx'].includes(getFileExtension());
|
||||
const isExcelFile = () => ['.xls', '.xlsx'].includes(getFileExtension());
|
||||
const isExcelFile = () => ['.xls', '.xlsx', '.csv'].includes(getFileExtension());
|
||||
const isPptFile = () => ['.ppt', '.pptx'].includes(getFileExtension());
|
||||
const isPreviewable = () => previewableTypes.includes(getFileExtension());
|
||||
const isDownloadOnly = () => downloadOnlyTypes.includes(getFileExtension());
|
||||
|
||||
const getRequestUrl = (url: string) => {
|
||||
if (url.includes('devapi.mem.redbearai.com')) {
|
||||
const parsed = new URL(url);
|
||||
return parsed.pathname;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const fetchFileBuffer = async (url: string): Promise<ArrayBuffer> => {
|
||||
const requestUrl = getRequestUrl(url);
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
@@ -65,73 +125,154 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
};
|
||||
|
||||
const handleError = (msg?: string) => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
if (msg) setErrorMessage(msg);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
// ========== PDF 渲染逻辑 ==========
|
||||
const renderPdfPage = useCallback(async (doc: pdfjsLib.PDFDocumentProxy, pageNum: number, scale: number) => {
|
||||
if (pdfRenderingRef.current || !pdfCanvasRef.current) return;
|
||||
pdfRenderingRef.current = true;
|
||||
try {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = pdfCanvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = viewport.width * dpr;
|
||||
canvas.height = viewport.height * dpr;
|
||||
canvas.style.width = `${viewport.width}px`;
|
||||
canvas.style.height = `${viewport.height}px`;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
} finally {
|
||||
pdfRenderingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPdfFile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
|
||||
if (isTextFile() || isMarkdownFile()) {
|
||||
loadTextFile();
|
||||
} else if (isWordFile()) {
|
||||
loadWordFile();
|
||||
} else if (isExcelFile()) {
|
||||
loadExcelFile();
|
||||
} else {
|
||||
const iframe = document.querySelector(`iframe[title="${fileName || '文档预览'}"]`) as HTMLIFrameElement;
|
||||
if (iframe) {
|
||||
iframe.src = iframe.src;
|
||||
}
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
setPdfDoc(doc);
|
||||
setPdfTotalPages(doc.numPages);
|
||||
setPdfCurrentPage(1);
|
||||
await renderPdfPage(doc, 1, pdfScale);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载 PDF 文件失败:', err);
|
||||
handleError(err.message || '加载 PDF 文件失败');
|
||||
}
|
||||
}, [fileUrl, pdfScale, renderPdfPage]);
|
||||
|
||||
const handlePdfPageChange = async (page: number) => {
|
||||
if (!pdfDoc || page < 1 || page > pdfTotalPages) return;
|
||||
setPdfCurrentPage(page);
|
||||
await renderPdfPage(pdfDoc, page, pdfScale);
|
||||
};
|
||||
|
||||
const handlePdfZoom = async (delta: number) => {
|
||||
const newScale = Math.max(0.5, Math.min(3, pdfScale + delta));
|
||||
setPdfScale(newScale);
|
||||
if (pdfDoc) {
|
||||
await renderPdfPage(pdfDoc, pdfCurrentPage, newScale);
|
||||
}
|
||||
};
|
||||
|
||||
// ========== PPT/PPTX 预览逻辑(转 PDF 后用 pdfjs 渲染每页为图片) ==========
|
||||
const loadPptFile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
// 尝试用 pdfjs 直接加载(某些服务端会返回转换后的 PDF)
|
||||
// 如果失败,则使用 Office Online Viewer 作为 fallback
|
||||
try {
|
||||
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
// 成功解析为 PDF,逐页渲染为图片
|
||||
const slides: string[] = [];
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) continue;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
slides.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
setPptSlides(slides);
|
||||
setPptTotalPages(slides.length);
|
||||
setPptCurrentPage(1);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
// 不是 PDF 格式,使用 Office Online Viewer
|
||||
setPptSlides([]);
|
||||
setPptTotalPages(0);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('加载 PPT 文件失败:', err);
|
||||
handleError(err.message || '加载 PPT 文件失败');
|
||||
}
|
||||
}, [fileUrl]);
|
||||
|
||||
// ========== 图片加载逻辑 ==========
|
||||
const loadImageFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
const ext = getFileExtension().replace('.', '');
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
};
|
||||
const blob = new Blob([arrayBuffer], { type: mimeMap[ext] || 'image/png' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setImageBlobUrl(url);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载图片文件失败:', err);
|
||||
handleError(err.message || '图片加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 文本/Word/Excel 加载逻辑 ==========
|
||||
const loadTextFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
let requestUrl = fileUrl;
|
||||
|
||||
if (fileUrl.includes('devapi.mem.redbearai.com')) {
|
||||
const url = new URL(fileUrl);
|
||||
requestUrl = url.pathname;
|
||||
}
|
||||
|
||||
const requestUrl = getRequestUrl(fileUrl);
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
|
||||
if (contentType.startsWith('image/')) {
|
||||
handleError('文件实际是图片类型,但被标记为文本文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (text.startsWith('\x89PNG') || text.startsWith('<27>PNG')) {
|
||||
handleError('文件内容是图片,但扩展名是文本');
|
||||
return;
|
||||
}
|
||||
|
||||
setTextContent(text);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
@@ -145,25 +286,20 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
let requestUrl = fileUrl;
|
||||
|
||||
if (fileUrl.includes('devapi.mem.redbearai.com')) {
|
||||
const url = new URL(fileUrl);
|
||||
requestUrl = url.pathname;
|
||||
// .doc 旧格式 mammoth 不支持,使用 Office Online Viewer
|
||||
if (getFileExtension() === '.doc') {
|
||||
setHtmlContent('');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
// 校验是否为有效的 docx(ZIP 格式,前两字节为 PK)
|
||||
const header = new Uint8Array(arrayBuffer.slice(0, 4));
|
||||
if (header[0] !== 0x50 || header[1] !== 0x4B) {
|
||||
// 不是 ZIP/docx 格式,可能是 HTML 错误页或 JSON 响应
|
||||
const text = new TextDecoder().decode(arrayBuffer.slice(0, 200));
|
||||
throw new Error(`文件内容不是有效的 docx 格式: ${text.substring(0, 100)}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||
setHtmlContent(result.value);
|
||||
setLoading(false);
|
||||
@@ -173,38 +309,105 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const [csvTruncated, setCsvTruncated] = useState(false);
|
||||
|
||||
const isCsvFile = () => getFileExtension() === '.csv';
|
||||
|
||||
// CSV 预览大小限制:1MB
|
||||
const CSV_PREVIEW_SIZE = 1 * 1024 * 1024;
|
||||
// 最大预览行数
|
||||
const MAX_PREVIEW_ROWS = 500;
|
||||
|
||||
const fetchFileBufferWithLimit = async (url: string, maxBytes?: number): Promise<ArrayBuffer> => {
|
||||
const requestUrl = getRequestUrl(url);
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
};
|
||||
if (maxBytes) {
|
||||
headers['Range'] = `bytes=0-${maxBytes - 1}`;
|
||||
}
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
if (!response.ok && response.status !== 206) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
};
|
||||
|
||||
const loadExcelFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
setCsvTruncated(false);
|
||||
try {
|
||||
let requestUrl = fileUrl;
|
||||
|
||||
if (fileUrl.includes('devapi.mem.redbearai.com')) {
|
||||
const url = new URL(fileUrl);
|
||||
requestUrl = url.pathname;
|
||||
// CSV 文件需要处理编码问题(可能是 GBK/GB2312),且大文件只取前 1MB
|
||||
if (isCsvFile()) {
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
let truncated = false;
|
||||
try {
|
||||
// 先尝试 Range 请求只取前 1MB
|
||||
arrayBuffer = await fetchFileBufferWithLimit(fileUrl, CSV_PREVIEW_SIZE);
|
||||
// 如果返回的数据刚好等于限制大小,说明可能被截断了
|
||||
if (arrayBuffer.byteLength >= CSV_PREVIEW_SIZE) {
|
||||
truncated = true;
|
||||
}
|
||||
} catch {
|
||||
// Range 请求不支持时,全量获取后截断
|
||||
const fullBuffer = await fetchFileBuffer(fileUrl);
|
||||
if (fullBuffer.byteLength > CSV_PREVIEW_SIZE) {
|
||||
arrayBuffer = fullBuffer.slice(0, CSV_PREVIEW_SIZE);
|
||||
truncated = true;
|
||||
} else {
|
||||
arrayBuffer = fullBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
let csvText: string;
|
||||
const utf8Text = new TextDecoder('utf-8').decode(arrayBuffer);
|
||||
if (utf8Text.includes('\uFFFD') || /[\x80-\xff]/.test(utf8Text.slice(0, 200))) {
|
||||
try {
|
||||
csvText = new TextDecoder('gbk').decode(arrayBuffer);
|
||||
} catch {
|
||||
csvText = utf8Text;
|
||||
}
|
||||
} else {
|
||||
csvText = utf8Text;
|
||||
}
|
||||
|
||||
// 如果被截断,去掉最后一行不完整的数据
|
||||
if (truncated) {
|
||||
const lastNewline = csvText.lastIndexOf('\n');
|
||||
if (lastNewline > 0) {
|
||||
csvText = csvText.substring(0, lastNewline);
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = XLSX.read(csvText, { type: 'string' });
|
||||
const sheets = workbook.SheetNames.map(sheetName => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
let data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
// 限制最大行数
|
||||
if (data.length > MAX_PREVIEW_ROWS + 1) {
|
||||
data = data.slice(0, MAX_PREVIEW_ROWS + 1); // +1 保留表头
|
||||
truncated = true;
|
||||
}
|
||||
return { sheetName, data };
|
||||
});
|
||||
setCsvTruncated(truncated);
|
||||
setExcelData(sheets);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
|
||||
const sheets = workbook.SheetNames.map(sheetName => {
|
||||
const sheets = workbook.SheetNames.map((sheetName: string) => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
return { sheetName, data };
|
||||
});
|
||||
|
||||
setExcelData(sheets);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
@@ -213,40 +416,72 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
if (isTextFile() || isMarkdownFile()) loadTextFile();
|
||||
else if (isWordFile()) loadWordFile();
|
||||
else if (isExcelFile()) loadExcelFile();
|
||||
else if (isPdfFile()) loadPdfFile();
|
||||
else if (isPptFile()) loadPptFile();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isTextFile() || isMarkdownFile()) {
|
||||
loadTextFile();
|
||||
} else if (isWordFile()) {
|
||||
loadWordFile();
|
||||
} else if (isExcelFile()) {
|
||||
loadExcelFile();
|
||||
}
|
||||
if (isTextFile() || isMarkdownFile()) loadTextFile();
|
||||
else if (isWordFile()) loadWordFile();
|
||||
else if (isExcelFile()) loadExcelFile();
|
||||
else if (isPdfFile()) loadPdfFile();
|
||||
else if (isPptFile()) loadPptFile();
|
||||
else if (isImageFile()) loadImageFile();
|
||||
}, [fileUrl]);
|
||||
|
||||
// PPT 文件只提供下载
|
||||
if (isDownloadOnly()) {
|
||||
return (
|
||||
<div className={`rb:relative rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded rb:border rb:border-gray-200 ${className}`} style={{ width, height }}>
|
||||
<Alert
|
||||
message="PowerPoint 文档预览"
|
||||
description={
|
||||
<div className="rb:text-center">
|
||||
<p className="rb:mb-4">PPT 文件暂不支持在线预览,请下载后查看</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
下载文件
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
// PDF 翻页/缩放后重新渲染
|
||||
useEffect(() => {
|
||||
if (pdfDoc && isPdfFile()) {
|
||||
renderPdfPage(pdfDoc, pdfCurrentPage, pdfScale);
|
||||
}
|
||||
}, [pdfCurrentPage, pdfScale, pdfDoc]);
|
||||
|
||||
// ========== 分页控制栏组件 ==========
|
||||
const PaginationBar = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
extraControls,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
extraControls?: React.ReactNode;
|
||||
}) => (
|
||||
<div className="rb:flex rb:items-center rb:justify-center rb:gap-3 rb:py-2 rb:px-4 rb:bg-white rb:border-t rb:border-gray-200 rb:select-none">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
/>
|
||||
<span className="rb:text-sm rb:text-gray-600 rb:flex rb:items-center rb:gap-1">
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={currentPage}
|
||||
onChange={(val) => val && onPageChange(val)}
|
||||
style={{ width: 56 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<span>/ {totalPages}</span>
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
/>
|
||||
{extraControls}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isPreviewable()) {
|
||||
return (
|
||||
@@ -260,13 +495,13 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rb:relative ${className}`} style={{ width, height }}>
|
||||
<div className={`rb:relative rb:flex rb:flex-col ${className}`} style={{ width, height }}>
|
||||
{loading && (
|
||||
<div className="rb:absolute rb:inset-0 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:z-10">
|
||||
<Spin size="large" tip="加载文档预览中..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="rb:absolute rb:inset-0 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:z-10">
|
||||
<Alert
|
||||
@@ -275,9 +510,7 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
<div>
|
||||
<p className="rb:mb-2">无法加载文档预览</p>
|
||||
{errorMessage && (
|
||||
<p className="rb:text-sm rb:text-red-600 rb:mb-3">
|
||||
错误详情:{errorMessage}
|
||||
</p>
|
||||
<p className="rb:text-sm rb:text-red-600 rb:mb-3">错误详情:{errorMessage}</p>
|
||||
)}
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:mb-3">可能的原因:</p>
|
||||
<ul className="rb:list-disc rb:pl-5 rb:text-sm rb:text-gray-600 rb:mb-3">
|
||||
@@ -287,12 +520,8 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
<li>网络连接问题</li>
|
||||
</ul>
|
||||
<div className="rb:mt-4 rb:flex rb:gap-2">
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRetry}>
|
||||
重试
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleDownload}>
|
||||
下载文件
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRetry}>重试</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleDownload}>下载文件</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -301,43 +530,63 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 图片预览 */}
|
||||
{isImageFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={fileName || '图片预览'}
|
||||
className="rb:max-w-full rb:max-h-full rb:object-contain"
|
||||
onError={() => handleError('图片加载失败')}
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
|
||||
<Image
|
||||
src={imageBlobUrl}
|
||||
alt={fileName || '图片预览'}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||
onError={() => handleError('图片渲染失败')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown 预览 */}
|
||||
{isMarkdownFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<RbMarkdown content={textContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文本预览 */}
|
||||
{isTextFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
<pre className="rb:whitespace-pre-wrap rb:text-sm rb:text-gray-800 rb:font-mono">
|
||||
{textContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Word 预览 */}
|
||||
{isWordFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<div
|
||||
className="rb:prose rb:max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
getFileExtension() === '.doc' ? (
|
||||
/* .doc 旧格式前端无法解析,提示下载 */
|
||||
<div className="rb:w-full rb:flex-1 rb:flex rb:items-center rb:justify-center rb:bg-gray-50">
|
||||
<div className="rb:text-center">
|
||||
<p className="rb:text-gray-600 rb:mb-4">.doc 格式暂不支持在线预览,请下载后查看</p>
|
||||
<Button icon={<DownloadOutlined />} type="primary" onClick={handleDownload}>下载文件</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<div
|
||||
className="rb:prose rb:max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Excel/CSV 预览 */}
|
||||
{isExcelFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
{csvTruncated && (
|
||||
<div className="rb:mb-3 rb:px-3 rb:py-2 rb:bg-yellow-50 rb:border rb:border-yellow-200 rb:rounded rb:text-sm rb:text-yellow-700">
|
||||
文件较大,仅预览前 {MAX_PREVIEW_ROWS} 行数据
|
||||
</div>
|
||||
)}
|
||||
{excelData.map((sheet, index) => (
|
||||
<div key={index} className="rb:mb-6">
|
||||
<h3 className="rb:text-lg rb:font-semibold rb:mb-3">{sheet.sheetName}</h3>
|
||||
@@ -354,6 +603,7 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
bordered
|
||||
virtual
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -361,17 +611,84 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF 预览 - 带分页和缩放 */}
|
||||
{isPdfFile() && !error && !loading && (
|
||||
<iframe
|
||||
src={fileUrl}
|
||||
width="100%"
|
||||
height="100%"
|
||||
title={fileName || 'PDF 预览'}
|
||||
className="rb:border-0"
|
||||
style={{ border: 'none' }}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
<>
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-100 rb:flex rb:justify-center rb:p-4">
|
||||
<canvas ref={pdfCanvasRef} className="rb:shadow-lg" />
|
||||
</div>
|
||||
{pdfTotalPages > 0 && (
|
||||
<PaginationBar
|
||||
currentPage={pdfCurrentPage}
|
||||
totalPages={pdfTotalPages}
|
||||
onPageChange={handlePdfPageChange}
|
||||
extraControls={
|
||||
<div className="rb:flex rb:items-center rb:gap-1 rb:ml-4">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ZoomOutOutlined />}
|
||||
disabled={pdfScale <= 0.5}
|
||||
onClick={() => handlePdfZoom(-0.25)}
|
||||
/>
|
||||
<span className="rb:text-sm rb:text-gray-600 rb:min-w-[48px] rb:text-center">
|
||||
{Math.round(pdfScale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ZoomInOutlined />}
|
||||
disabled={pdfScale >= 3}
|
||||
onClick={() => handlePdfZoom(0.25)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PPT/PPTX 预览 */}
|
||||
{isPptFile() && !error && !loading && (
|
||||
<>
|
||||
{pptSlides.length > 0 ? (
|
||||
/* 本地渲染模式(服务端返回了可解析的格式) */
|
||||
<>
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-100 rb:flex rb:justify-center rb:items-center rb:p-4">
|
||||
<img
|
||||
src={pptSlides[pptCurrentPage - 1]}
|
||||
alt={`Slide ${pptCurrentPage}`}
|
||||
className="rb:max-w-full rb:max-h-full rb:object-contain rb:shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<PaginationBar
|
||||
currentPage={pptCurrentPage}
|
||||
totalPages={pptTotalPages}
|
||||
onPageChange={(page) => {
|
||||
if (page >= 1 && page <= pptTotalPages) setPptCurrentPage(page);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* Office Online Viewer fallback */
|
||||
<div className="rb:w-full rb:flex-1 rb:flex rb:flex-col">
|
||||
<iframe
|
||||
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fileUrl)}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
title={fileName || 'PPT 预览'}
|
||||
className="rb:border-0 rb:flex-1"
|
||||
style={{ border: 'none' }}
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => handleError('PPT 在线预览加载失败')}
|
||||
/>
|
||||
<div className="rb:flex rb:items-center rb:justify-center rb:gap-3 rb:py-2 rb:px-4 rb:bg-white rb:border-t rb:border-gray-200">
|
||||
<span className="rb:text-sm rb:text-gray-500">使用 Office Online 预览</span>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={handleDownload}>
|
||||
下载文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -136,7 +136,7 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
|
||||
|
||||
/** Sync edit content when external content changes */
|
||||
useEffect(() => {
|
||||
setEditContent(content)
|
||||
setEditContent(prev => prev !== content ? content : prev)
|
||||
}, [content])
|
||||
|
||||
/** Handle textarea content changes and trigger callback */
|
||||
|
||||
@@ -449,6 +449,7 @@ export const en = {
|
||||
|
||||
fileSizeTip: 'File size cannot exceed {{size}}MB',
|
||||
fileAcceptTip: 'Unsupported file type:',
|
||||
fileNumTip: 'File count cannot exceed {{num}}',
|
||||
nextStep: 'Next Step',
|
||||
prevStep: 'Previous Step',
|
||||
exportSuccess: 'Export successful',
|
||||
@@ -459,6 +460,7 @@ export const en = {
|
||||
nameInvalid: 'Name cannot start or end with a space',
|
||||
notAllSpaces: 'Cannot be all spaces',
|
||||
view: 'View',
|
||||
callbackUrlInvalid: 'Please enter a valid URL',
|
||||
},
|
||||
model: {
|
||||
searchPlaceholder: 'search model…',
|
||||
@@ -1373,9 +1375,9 @@ export const en = {
|
||||
dify: 'Dify',
|
||||
pleaseUploadFile: 'Please upload file',
|
||||
setting: 'Settings',
|
||||
funConfig: 'Features',
|
||||
fileUpload: 'File Upload',
|
||||
fileUploadDesc: 'The chat input box supports file uploads. Types include images, documents, and other types',
|
||||
features: 'Conversation Features',
|
||||
file_upload: 'File Upload',
|
||||
file_upload_desc: 'The chat input box supports file uploads. Types include images, documents, and other types',
|
||||
settings: 'File Upload Settings',
|
||||
uploadType: 'Upload Type',
|
||||
local: 'Local Upload',
|
||||
@@ -1392,8 +1394,8 @@ export const en = {
|
||||
maxCount: 'Max Files',
|
||||
singleMaxSize: 'Max Size',
|
||||
unix: 'items',
|
||||
textTranfer: 'Text to Speech',
|
||||
textTranferDesc: 'Text can be converted to speech',
|
||||
text_to_speech: 'Text to Speech',
|
||||
text_to_speech_desc: 'Text can be converted to speech',
|
||||
|
||||
apps: 'My Apps',
|
||||
sharing: 'Sharing',
|
||||
@@ -1563,6 +1565,7 @@ export const en = {
|
||||
summary: 'Summary',
|
||||
core_entities: 'Core Entities',
|
||||
communityDetailEmptyDesc: 'Click on a community in the chart on the left to view details',
|
||||
communityLoadingTip: 'Generating community graph',
|
||||
},
|
||||
space: {
|
||||
createSpace: 'Create Space',
|
||||
@@ -1779,6 +1782,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
fileUrl: 'File URL',
|
||||
addRemoteFile: 'Add Remote File',
|
||||
variableConfig: 'Variable Configuration',
|
||||
memoryCancelTipTitle: 'Are you sure you want to disable conversation memory? Conversations will no longer be saved to the memory store.',
|
||||
memoryTipTitle: 'Are you sure you want to enable conversation memory? Conversations will be saved to the memory store.',
|
||||
},
|
||||
login: {
|
||||
title: 'Red Bear Memory Science',
|
||||
|
||||
@@ -756,9 +756,9 @@ export const zh = {
|
||||
dify: 'Dify',
|
||||
pleaseUploadFile: '请上传文件',
|
||||
setting: '设置',
|
||||
funConfig: '功能',
|
||||
fileUpload: '文件上传',
|
||||
fileUploadDesc: '聊天输入框支持上传文件。类型包括图片、文档以及其它类型',
|
||||
features: '对话功能',
|
||||
file_upload: '文件上传',
|
||||
file_upload_desc: '聊天输入框支持上传文件。类型包括图片、文档以及其它类型',
|
||||
settings: '文件上传设置',
|
||||
uploadType: '上传类型',
|
||||
local: '本地上传',
|
||||
@@ -775,8 +775,8 @@ export const zh = {
|
||||
maxCount: '最大文件数',
|
||||
singleMaxSize: '单文件最大大小',
|
||||
unix: '个',
|
||||
textTranfer: '文字转语音',
|
||||
textTranferDesc: '文本可以转换成语言',
|
||||
text_to_speech: '文字转语音',
|
||||
text_to_speech_desc: '文本可以转换成语音',
|
||||
|
||||
apps: '我的应用',
|
||||
sharing: '共享',
|
||||
@@ -1082,6 +1082,7 @@ export const zh = {
|
||||
|
||||
fileSizeTip: '文件大小不能超过 {{size}}MB',
|
||||
fileAcceptTip: '不支持的文件类型:',
|
||||
fileNumTip: '文件数量不能超过{{num}}个',
|
||||
nextStep: '下一步',
|
||||
prevStep: '上一步',
|
||||
exportSuccess: '导出成功',
|
||||
@@ -1092,6 +1093,7 @@ export const zh = {
|
||||
nameInvalid: '不能是空格开头或结尾',
|
||||
notAllSpaces: '不能是纯空格',
|
||||
view: '查看',
|
||||
callbackUrlInvalid: '请输入有效的 URL',
|
||||
},
|
||||
model: {
|
||||
searchPlaceholder: '搜索模型…',
|
||||
@@ -1561,6 +1563,7 @@ export const zh = {
|
||||
summary: '摘要',
|
||||
core_entities: '核心实体',
|
||||
communityDetailEmptyDesc: '点击左侧图表中的社区查看详情',
|
||||
communityLoadingTip: '社区图谱生成中',
|
||||
},
|
||||
space: {
|
||||
createSpace: '创建空间',
|
||||
@@ -1775,6 +1778,8 @@ export const zh = {
|
||||
fileUrl: '文件链接',
|
||||
addRemoteFile: '添加远程文件',
|
||||
variableConfig: '变量配置',
|
||||
memoryCancelTipTitle: '确定关闭对话记忆功能吗?关闭后对话将不会保存到记忆库中',
|
||||
memoryTipTitle: '确定打开对话记忆功能吗?打开后对话将会保存到记忆库中',
|
||||
},
|
||||
login: {
|
||||
title: '红熊记忆科学',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 18:19:24
|
||||
* @Last Modified time: 2026-03-18 14:32:40
|
||||
*/
|
||||
/**
|
||||
* Server-Sent Events (SSE) Stream Utility Module
|
||||
@@ -176,17 +176,23 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
|
||||
case 500:
|
||||
case 502:
|
||||
const errorData = await response.json();
|
||||
let errorInfo = errorData.error || i18n.t('common.serviceUpgrading')
|
||||
const errorInfo = errorData.error || i18n.t('common.serviceUpgrading');
|
||||
message.warning(errorInfo);
|
||||
throw errorInfo;
|
||||
throw new Error(errorData);
|
||||
case 400:
|
||||
const error = await response.json();
|
||||
message.warning(error.error);
|
||||
throw error.error || 'Bad Request';
|
||||
const error400 = error.error || 'Bad Request';
|
||||
message.warning(error400);
|
||||
throw new Error(error);
|
||||
case 403:
|
||||
const errors = await response.json();
|
||||
message.warning(i18n.t('common.permissionDenied'));
|
||||
throw new Error(errors);
|
||||
case 504:
|
||||
const errorJson = await response.json();
|
||||
message.warning(errorJson.error || i18n.t('common.serverError'));
|
||||
throw errorData.error;
|
||||
const errorMsg = errorJson.error || i18n.t('common.serverError');
|
||||
message.warning(errorMsg);
|
||||
throw new Error(errorJson);
|
||||
case 401:
|
||||
if (url?.includes('/public')) {
|
||||
return message.warning(i18n.t('common.publicApiCannotRefreshToken'));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 16:58:15
|
||||
* @Last Modified time: 2026-03-17 14:24:29
|
||||
*/
|
||||
import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import clsx from 'clsx'
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
AiPromptModalRef,
|
||||
Source,
|
||||
ChatVariableConfigModalRef,
|
||||
FunConfigForm
|
||||
FeaturesConfigForm
|
||||
} from './types'
|
||||
import type { Variable } from './components/VariableList/types'
|
||||
import type { KnowledgeConfig } from './components/Knowledge/types'
|
||||
@@ -42,7 +42,7 @@ import ToolList from './components/ToolList/ToolList'
|
||||
import SkillList from './components/Skill'
|
||||
import ChatVariableConfigModal from './components/ChatVariableConfigModal';
|
||||
import type { Skill } from '@/views/Skills/types'
|
||||
import FunConfig from './components/FunConfig'
|
||||
import FeaturesConfig from './components/FeaturesConfig'
|
||||
|
||||
/**
|
||||
* Description wrapper component
|
||||
@@ -129,7 +129,7 @@ const SelectWrapper: FC<{ title: string, desc: string, name: string | string[],
|
||||
* Agent configuration component
|
||||
* Manages single agent configuration including prompts, knowledge, memory, variables, and tools
|
||||
*/
|
||||
const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
const Agent = forwardRef<AgentRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams();
|
||||
const { message } = App.useApp()
|
||||
@@ -200,6 +200,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
...response,
|
||||
tools: allTools
|
||||
})
|
||||
onFeaturesLoad?.(response.features)
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
@@ -356,7 +357,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSave,
|
||||
funConfig: values?.funConfig
|
||||
features: values?.features
|
||||
}))
|
||||
|
||||
const aiPromptModalRef = useRef<AiPromptModalRef>(null)
|
||||
@@ -411,8 +412,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
setChatVariables(values?.variables || [])
|
||||
}, [values?.variables])
|
||||
|
||||
const handleSaveFunConfig = (value: FunConfigForm) => {
|
||||
form.setFieldValue('funConfig', value)
|
||||
const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
|
||||
form.setFieldValue('features', value)
|
||||
}
|
||||
console.log('agent', values)
|
||||
return (
|
||||
@@ -426,7 +427,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
{defaultModel?.name ? <div className="rb:w-4 rb:h-4 rb:bg-[url('@/assets/images/application/model.svg')] rb:group-hover:bg-[url('@/assets/images/application/model_hover.svg')]"></div> : null}
|
||||
{defaultModel?.name || t('application.chooseModel')}
|
||||
</Button>
|
||||
{/* <FunConfig value={values?.funConfig as FunConfigForm} refresh={handleSaveFunConfig} /> */}
|
||||
<FeaturesConfig value={values?.features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} />
|
||||
<Button type="primary" onClick={() => handleSave()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
@@ -435,7 +436,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
<Form form={form}>
|
||||
<Form.Item name="default_model_config_id" hidden noStyle></Form.Item>
|
||||
<Form.Item name="model_parameters" hidden noStyle></Form.Item>
|
||||
<Form.Item name="funConfig" hidden noStyle></Form.Item>
|
||||
<Form.Item name="features" hidden noStyle></Form.Item>
|
||||
<Space size={16} direction="vertical" style={{ width: '100%' }}>
|
||||
<Card title={t('application.promptConfiguration')}>
|
||||
<div className="rb:flex rb:items-center rb:justify-between rb:mb-2.75">
|
||||
@@ -512,7 +513,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
</div>
|
||||
<RbCard height="calc(100vh - 160px)" bodyClassName="rb:p-[0]! rb:h-full rb:overflow-hidden">
|
||||
<Chat
|
||||
data={data as Config}
|
||||
data={values as Config}
|
||||
chatList={chatList}
|
||||
updateChatList={setChatList}
|
||||
handleSave={handleSave}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:33
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 13:47:23
|
||||
* @Last Modified time: 2026-03-18 19:49:09
|
||||
*/
|
||||
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -19,7 +19,8 @@ import type {
|
||||
ChatData,
|
||||
SubAgentItem,
|
||||
ClusterRef,
|
||||
ModelConfigModalRef
|
||||
ModelConfigModalRef,
|
||||
FeaturesConfigForm
|
||||
} from './types'
|
||||
import Chat from './components/Chat'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
@@ -29,7 +30,7 @@ import RadioGroupCard from '@/components/RadioGroupCard'
|
||||
import { getModelListUrl } from '@/api/models'
|
||||
import ModelConfigModal from './components/ModelConfigModal'
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
|
||||
// import FeaturesConfig from './components/FeaturesConfig'
|
||||
|
||||
const tagColors = ['processing', 'warning', 'default']
|
||||
const MAX_LENGTH = 5;
|
||||
@@ -37,7 +38,7 @@ const MAX_LENGTH = 5;
|
||||
* Multi-agent cluster configuration component
|
||||
* Manages multi-agent orchestration, sub-agents, and collaboration modes
|
||||
*/
|
||||
const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
const Cluster = forwardRef<ClusterRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm()
|
||||
@@ -130,6 +131,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
} else {
|
||||
setSubAgents(sub_agents)
|
||||
}
|
||||
onFeaturesLoad?.(response.features)
|
||||
})
|
||||
}
|
||||
/**
|
||||
@@ -166,7 +168,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
}
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSave,
|
||||
funConfig: data?.funConfig
|
||||
features: data?.features
|
||||
}))
|
||||
|
||||
const modelConfigModalRef = useRef<ModelConfigModalRef>(null)
|
||||
@@ -185,16 +187,21 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
model_parameters: values
|
||||
})
|
||||
}
|
||||
// const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
|
||||
// form.setFieldValue('features', value)
|
||||
// }
|
||||
|
||||
return (
|
||||
<Row className="rb:h-[calc(100vh-64px)]">
|
||||
<Col span={12} className="rb:h-full rb:overflow-x-auto rb:border-r rb:border-[#DFE4ED] rb:p-[20px_16px_24px_16px]">
|
||||
<div className="rb:flex rb:items-center rb:justify-end rb:mb-5">
|
||||
<Flex gap={10} justify="end" align="center" className="rb:mb-5!">
|
||||
{/* <FeaturesConfig value={values?.features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} /> */}
|
||||
<Button type="primary" onClick={() => handleSave()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="features" hidden noStyle></Form.Item>
|
||||
<Space size={20} direction="vertical" style={{width: '100%'}}>
|
||||
<Card title={t('application.collaboration')}>
|
||||
<Form.Item
|
||||
@@ -288,6 +295,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -299,6 +307,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:41
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-11 17:44:24
|
||||
* @Last Modified time: 2026-03-18 20:57:24
|
||||
*/
|
||||
import { type FC, useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -70,7 +70,8 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
})
|
||||
}
|
||||
const handleExport = () => {
|
||||
appExport(data.id, data.name)
|
||||
if (!selectedVersion) return
|
||||
appExport(data.id, data.name, { release_id: selectedVersion.id})
|
||||
}
|
||||
return (
|
||||
<div className="rb:flex rb:h-[calc(100vh-64px)]">
|
||||
@@ -131,7 +132,7 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
{data?.type !== 'multi_agent' && <Button onClick={handleExport}>{t('common.export')}</Button>}
|
||||
{data.current_release_id !== selectedVersion.id && <Button onClick={handleRollback}>{t('application.willRollToThisVersion')}</Button>}
|
||||
<Button type="primary" ghost onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</Button>
|
||||
<Button type="primary" ghost onClick={() => appSharingModalRef.current?.handleOpen()}>{t('application.sharing')}</Button>
|
||||
{data?.type !== 'multi_agent' && <Button type="primary" ghost onClick={() => appSharingModalRef.current?.handleOpen()}>{t('application.sharing')}</Button>}
|
||||
</>}
|
||||
<Button type="primary" onClick={() => releaseModalRef.current?.handleOpen()}>{t('application.release')}</Button>
|
||||
</Space>
|
||||
|
||||
@@ -1,36 +1,31 @@
|
||||
import { type FC, useState, useRef, useEffect, useMemo } from 'react'
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:27:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 20:54:35
|
||||
*/
|
||||
import { type FC, useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { App, Flex, Dropdown, type MenuProps, Divider, Form, Space } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { App } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
|
||||
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
|
||||
import { draftRun } from '@/api/application';
|
||||
import { draftRun } from '@/api/application'
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
import Chat from '@/components/Chat'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import Runtime from '@/views/Workflow/components/Chat/Runtime';
|
||||
import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import Runtime from '@/views/Workflow/components/Chat/Runtime'
|
||||
import { nodeLibrary } from '@/views/Workflow/constant'
|
||||
// import ButtonCheckbox from '@/components/ButtonCheckbox';
|
||||
|
||||
// import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg'
|
||||
// import OnlineIcon from '@/assets/images/conversation/online.svg'
|
||||
// import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
|
||||
// import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
|
||||
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import type { VariableConfigModalRef, WorkflowConfig } from '@/views/Workflow/types'
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
import type { TestChatProps } from './type';
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import type { TestChatProps } from './type'
|
||||
import type { SSEMessage } from '@/utils/stream'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
|
||||
const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => {
|
||||
return {
|
||||
@@ -65,29 +60,25 @@ interface NodeData {
|
||||
elapsed_time?: string;
|
||||
error?: any;
|
||||
state: Record<string, any>;
|
||||
status?: 'completed' | 'failed'
|
||||
status?: 'completed' | 'failed';
|
||||
audio_url?: string;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
files: any[];
|
||||
variables: Variable[]
|
||||
}
|
||||
const TestChat: FC<TestChatProps> = ({
|
||||
application,
|
||||
config
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
|
||||
const [loading, setLoading] = useState(false) // Send button loading state
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history
|
||||
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
|
||||
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID
|
||||
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message
|
||||
const [form] = Form.useForm<FormData>()
|
||||
const queryValues = Form.useWatch([], form)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([])
|
||||
const [streamLoading, setStreamLoading] = useState(false)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
|
||||
useEffect(() => {
|
||||
getVariables()
|
||||
@@ -96,6 +87,8 @@ const TestChat: FC<TestChatProps> = ({
|
||||
const getVariables = () => {
|
||||
if (!application || !config) return
|
||||
|
||||
setFeatures(config?.features || {} as FeaturesConfigForm)
|
||||
|
||||
let initVariables: Variable[] = []
|
||||
|
||||
switch (application.type) {
|
||||
@@ -104,85 +97,37 @@ const TestChat: FC<TestChatProps> = ({
|
||||
const startNodes = nodes.filter(vo => vo.type === 'start')
|
||||
if (startNodes.length) {
|
||||
const curVariables = startNodes[0].config.variables as Variable[]
|
||||
|
||||
curVariables.forEach((vo) => {
|
||||
if (typeof vo.default !== 'undefined') {
|
||||
vo.value = vo.default
|
||||
}
|
||||
const lastVo = curVariables.find(item => item.name === vo.name)
|
||||
if (lastVo?.value) {
|
||||
vo.value = lastVo.value
|
||||
}
|
||||
})
|
||||
initVariables = curVariables
|
||||
}
|
||||
curVariables.forEach((vo) => {
|
||||
if (typeof vo.default !== 'undefined') {
|
||||
vo.value = vo.default
|
||||
}
|
||||
const lastVo = curVariables.find(item => item.name === vo.name)
|
||||
if (lastVo?.value) {
|
||||
vo.value = lastVo.value
|
||||
}
|
||||
})
|
||||
initVariables = curVariables
|
||||
}
|
||||
break
|
||||
case 'agent':
|
||||
initVariables = config.variables as Variable[]
|
||||
break
|
||||
}
|
||||
|
||||
form.setFieldValue('variables', [...initVariables])
|
||||
toolbarRef.current?.setVariables([...initVariables])
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(queryValues.variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
form.setFieldValue('variables', [...values])
|
||||
}
|
||||
/**
|
||||
* Handles file upload from local device
|
||||
*/
|
||||
const fileChange = (file?: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dropdown menu actions for file upload
|
||||
*/
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Adds files from remote URL modal
|
||||
*/
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list || list.length <= 0) return
|
||||
form.setFieldValue('files', [...(queryValues.files || []), ...(list || [])])
|
||||
}
|
||||
/**
|
||||
* Updates the entire file list (used when removing files)
|
||||
*/
|
||||
const updateFileList = (list?: any[]) => {
|
||||
form.setFieldValue('files', [...list || []])
|
||||
}
|
||||
const isNeedVariableConfig = useMemo(() => {
|
||||
return queryValues?.variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
|
||||
}, [queryValues?.variables])
|
||||
|
||||
const addUserMessage = (message: string, files: any[]) => {
|
||||
const newUserMessage: ChatItem = {
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
};
|
||||
setChatList(prev => [...prev, newUserMessage])
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
}])
|
||||
}
|
||||
|
||||
const addAssistantMessage = () => {
|
||||
const { type } = application || {}
|
||||
setChatList(prev => [...prev, {
|
||||
@@ -193,20 +138,22 @@ const TestChat: FC<TestChatProps> = ({
|
||||
}])
|
||||
}
|
||||
|
||||
const updateAssistantMessage = (content: string) => {
|
||||
const updateAssistantMessage = (content: string, audio_url?: string) => {
|
||||
setChatList(prev => {
|
||||
let newList = [...prev]
|
||||
const newList = [...prev]
|
||||
const lastMsg = newList[newList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content += content
|
||||
lastMsg.content += content;
|
||||
lastMsg.meta_data = {audio_url}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateErrorAssistantMessage = (message_length: number) => {
|
||||
if (message_length > 0) return
|
||||
setChatList(prev => {
|
||||
let newList = [...prev]
|
||||
const newList = [...prev]
|
||||
const lastMsg = newList[newList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = null
|
||||
@@ -214,34 +161,37 @@ const TestChat: FC<TestChatProps> = ({
|
||||
return newList
|
||||
})
|
||||
}
|
||||
const handleSend = () => {
|
||||
if (loading || !application || !message || !message?.trim()) return
|
||||
// Validate required variables before sending
|
||||
const { variables, files } = queryValues;
|
||||
|
||||
const buildVariableParams = (variables: Variable[]) => {
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables && variables.length > 0) {
|
||||
if (variables?.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value
|
||||
|
||||
params[vo.name] = vo.value ?? vo.defaultValue
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
}
|
||||
})
|
||||
|
||||
if (needRequired.length) {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
return { isCanSend, params }
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
if (loading || !application || !message || !message?.trim()) return
|
||||
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
|
||||
const variables = toolbarRef.current?.getVariables() || []
|
||||
const { isCanSend, params } = buildVariableParams(variables)
|
||||
if (!isCanSend) return
|
||||
|
||||
addUserMessage(message, files)
|
||||
setMessage(undefined)
|
||||
form.setFieldValue('files', [])
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
addAssistantMessage()
|
||||
setStreamLoading(true)
|
||||
setLoading(true)
|
||||
@@ -252,6 +202,7 @@ const TestChat: FC<TestChatProps> = ({
|
||||
handleStreamMessage
|
||||
)
|
||||
.catch(() => {
|
||||
updateErrorAssistantMessage(0)
|
||||
setLoading(false)
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -259,105 +210,77 @@ const TestChat: FC<TestChatProps> = ({
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.map(item => {
|
||||
const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number };
|
||||
|
||||
const { conversation_id, content, message_length, audio_url } = item.data as { conversation_id: string, content: string, message_length: number; audio_url?: string; };
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
setConversationId(conversation_id);
|
||||
}
|
||||
if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
|
||||
break
|
||||
case 'message':
|
||||
updateAssistantMessage(content)
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
setConversationId(conversation_id);
|
||||
}
|
||||
break;
|
||||
if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
|
||||
break
|
||||
case 'end':
|
||||
if (audio_url) {
|
||||
updateAssistantMessage(content, audio_url)
|
||||
}
|
||||
updateErrorAssistantMessage(message_length)
|
||||
setStreamLoading(false)
|
||||
break;
|
||||
break
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const handleWorkflowSend = () => {
|
||||
if (loading || !application || !message || !message?.trim()) return
|
||||
|
||||
// Validate required variables before sending
|
||||
const { variables, files } = queryValues;
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value ?? vo.defaultValue
|
||||
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
}
|
||||
})
|
||||
|
||||
if (needRequired.length) {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
return
|
||||
}
|
||||
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
|
||||
const variables = toolbarRef.current?.getVariables() || []
|
||||
const { isCanSend, params } = buildVariableParams(variables)
|
||||
if (!isCanSend) return
|
||||
|
||||
setLoading(true)
|
||||
addUserMessage(message, files)
|
||||
addAssistantMessage()
|
||||
form.setFieldsValue({
|
||||
files: [],
|
||||
})
|
||||
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
setMessage(undefined)
|
||||
setStreamLoading(true)
|
||||
|
||||
draftRun(
|
||||
application.id,
|
||||
formatParams(message, conversationId, files, params),
|
||||
handleWorkflowStreamMessage
|
||||
)
|
||||
.catch((error) => {
|
||||
console.log('draftRun error', error)
|
||||
const errorInfo = JSON.parse(error.message)
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
status: 'failed',
|
||||
content: null,
|
||||
subContent: error.error
|
||||
}
|
||||
newList[lastIndex] = { ...newList[lastIndex], status: 'failed', content: null, subContent: errorInfo.error }
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}).finally(() => {
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleWorkflowStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach(item => {
|
||||
const { content, conversation_id } = item.data as NodeData;
|
||||
|
||||
switch (item.event) {
|
||||
// Append streaming text chunks to assistant message
|
||||
// Append streaming text chunks to assistant message
|
||||
case 'message':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
content: newList[lastIndex].content + content
|
||||
}
|
||||
newList[lastIndex] = { ...newList[lastIndex], content: newList[lastIndex].content + content }
|
||||
}
|
||||
return newList
|
||||
})
|
||||
@@ -388,10 +311,10 @@ const TestChat: FC<TestChatProps> = ({
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addWorkflowNodeStartMessage = (data: NodeData) => {
|
||||
const { node_id } = data;
|
||||
const { nodes } = config as WorkflowConfig
|
||||
|
||||
const node = nodes.find(n => n.id === node_id);
|
||||
const { name, type } = node || {}
|
||||
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
|
||||
@@ -428,6 +351,7 @@ const TestChat: FC<TestChatProps> = ({
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateWorkflowNodeEndMessage = (data: NodeData) => {
|
||||
const { node_id, input, output, error, elapsed_time, status } = data;
|
||||
setChatList(prev => {
|
||||
@@ -456,10 +380,10 @@ const TestChat: FC<TestChatProps> = ({
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateWorkflowCycleMessage = (data: NodeData) => {
|
||||
const { node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = data;
|
||||
const { nodes } = config as WorkflowConfig
|
||||
|
||||
const node = nodes.find(n => n.id === node_id);
|
||||
const { name, type } = node || {}
|
||||
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
|
||||
@@ -500,22 +424,9 @@ const TestChat: FC<TestChatProps> = ({
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateWorkflowEndMessage = (data: NodeData) => {
|
||||
const { error, status } = data as {
|
||||
content: string;
|
||||
conversation_id: string | null;
|
||||
cycle_id: string;
|
||||
cycle_idx: number;
|
||||
node_id: string;
|
||||
node_name?: string;
|
||||
node_type?: string;
|
||||
input?: any;
|
||||
output?: any;
|
||||
elapsed_time?: string;
|
||||
error?: any;
|
||||
state: Record<string, any>;
|
||||
status?: 'completed' | 'failed'
|
||||
};
|
||||
const { error, status, audio_url } = data;
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
@@ -525,13 +436,13 @@ const TestChat: FC<TestChatProps> = ({
|
||||
status,
|
||||
error,
|
||||
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
|
||||
meta_data: { audio_url: audio_url }
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
console.log('queryValues', queryValues)
|
||||
return (
|
||||
<div className="rb:w-250 rb:p-3 rb:mx-auto">
|
||||
<RbCard
|
||||
@@ -543,97 +454,29 @@ const TestChat: FC<TestChatProps> = ({
|
||||
<Chat
|
||||
empty={<Empty url={ChatIcon} title={t('application.testChatEmpty')} isNeedSubTitle={false} size={[240, 200]} />}
|
||||
contentClassName={clsx(`rb:mx-[16px] rb:pt-[24px]`, {
|
||||
'rb:h-[calc(100%-140px)]': !queryValues?.files?.length,
|
||||
'rb:h-[calc(100%-208px)]': !!queryValues?.files?.length,
|
||||
'rb:h-[calc(100%-140px)]': !fileList.length,
|
||||
'rb:h-[calc(100%-208px)]': !!fileList.length,
|
||||
})}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={application?.type === 'workflow' ? handleWorkflowSend : handleSend}
|
||||
fileList={queryValues?.files || []}
|
||||
fileChange={updateFileList}
|
||||
fileList={fileList}
|
||||
fileChange={(list) => {
|
||||
setFileList(list || [])
|
||||
toolbarRef.current?.setFiles(list || [])
|
||||
}}
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
renderRuntime={application?.type === 'workflow' ? (item, index) => {
|
||||
return <Runtime item={item} index={index} />
|
||||
} : undefined}
|
||||
renderRuntime={application?.type === 'workflow' ? (item, index) => <Runtime item={item} index={index} /> : undefined}
|
||||
>
|
||||
<Form form={form}>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Space size={8} align="center">
|
||||
<Form.Item name="files" noStyle>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<Flex align="center" justify="center" className="rb:size-7 rb:cursor-pointer rb:rounded-[14px] rb:border rb:border-[#EBEBEB] rb:hover:bg-[#F6F6F6]">
|
||||
<div
|
||||
className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')]"
|
||||
></div>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
{/* <Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={OnlineIcon}
|
||||
checkedIcon={OnlineCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.web_search`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Tooltip title={t(`memoryConversation.memory`)}></Tooltip>
|
||||
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={MemoryFunctionIcon}
|
||||
checkedIcon={MemoryFunctionCheckedIcon}
|
||||
cicle={true}
|
||||
>
|
||||
</ButtonCheckbox>
|
||||
</Form.Item> */}
|
||||
<Form.Item name="variables" className="rb:mb-0!" hidden={!queryValues?.variables?.length}>
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t(`memoryConversation.variableConfig`)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={8} align="center">
|
||||
<AudioRecorder
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
/>
|
||||
<Divider type="vertical" className="rb:ml-0! rb:mr-2!" />
|
||||
</Space>
|
||||
</Flex>
|
||||
</Form>
|
||||
<ChatToolbar
|
||||
ref={toolbarRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
/>
|
||||
</Chat>
|
||||
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleSave}
|
||||
/>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</RbCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:19:13
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:26:57
|
||||
* @Last Modified time: 2026-03-18 16:03:46
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Checkbox, App, Form } from 'antd';
|
||||
@@ -78,7 +78,7 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
|
||||
*/
|
||||
const handleToggle = (id: string, isShared: boolean) => {
|
||||
if (isShared) return;
|
||||
const prev = form.getFieldValue('target_workspace_ids') as string[] ?? [];
|
||||
const prev: string[] = form.getFieldValue('target_workspace_ids') ?? [];
|
||||
form.setFieldValue(
|
||||
'target_workspace_ids',
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
@@ -135,10 +135,16 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
|
||||
|
||||
{/* Target space: scrollable list of workspaces with checkbox selection */}
|
||||
<Form.Item
|
||||
name="target_workspace_ids"
|
||||
label={t('application.selectTargetSpace')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
required
|
||||
>
|
||||
<Form.Item
|
||||
name="target_workspace_ids"
|
||||
noStyle
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:divide-y rb:divide-[#EBEBEB] rb:max-h-50 rb:overflow-y-auto">
|
||||
{spaceList.map(space => {
|
||||
const isShared = sharedIds.includes(space.id);
|
||||
@@ -146,11 +152,11 @@ const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({
|
||||
<div key={space.id} className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-3 rb:cursor-pointer" onClick={() => handleToggle(space.id, isShared)}>
|
||||
<Checkbox
|
||||
checked={isShared || selectedIds.includes(space.id)}
|
||||
disabled={isShared} // already-shared workspaces cannot be unselected
|
||||
disabled={isShared}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => handleToggle(space.id, isShared)}
|
||||
/>
|
||||
<span className="rb:flex-1 rb:text-sm">{space.name}</span>
|
||||
{/* Badge shown when the app is already shared with this workspace */}
|
||||
{isShared && (
|
||||
<span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 15:20:32
|
||||
* @Last Modified time: 2026-03-18 20:52:33
|
||||
*/
|
||||
/**
|
||||
* Chat debugging component for application testing
|
||||
@@ -12,25 +12,25 @@
|
||||
|
||||
import { type FC, useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd';
|
||||
import { App } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
|
||||
import type { ChatData, Config } from '../types'
|
||||
import type { ChatData, Config, FeaturesConfigForm } from '../types'
|
||||
import { runCompare, draftRun } from '@/api/application'
|
||||
import Empty from '@/components/Empty'
|
||||
import ChatContent from '@/components/Chat/ChatContent'
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import ChatInput from '@/components/Chat/ChatInput'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import ChatToolbar from '@/components/Chat/ChatToolbar'
|
||||
import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import type { Variable } from './VariableList/types'
|
||||
|
||||
|
||||
/**
|
||||
* Component props
|
||||
*/
|
||||
@@ -45,10 +45,12 @@ interface ChatProps {
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
/** Source type: multi-agent cluster or single agent */
|
||||
source?: 'multi_agent' | 'agent';
|
||||
chatVariables?: Variable[]; // Add chatVariables prop
|
||||
/** chatVariables prop */
|
||||
chatVariables?: Variable[];
|
||||
handleEditVariables?: () => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Chat debugging component
|
||||
* Allows testing application with different model configurations side-by-side
|
||||
@@ -58,18 +60,29 @@ const Chat: FC<ChatProps> = ({
|
||||
handleEditVariables
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isCluster, setIsCluster] = useState(source === 'multi_agent')
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [compareLoading, setCompareLoading] = useState(false)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
|
||||
useEffect(() => {
|
||||
setCompareLoading(false)
|
||||
setLoading(false)
|
||||
}, [chatList.map(item => item.label).join(',')])
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.features) setFeatures(data.features)
|
||||
}, [data?.features])
|
||||
|
||||
useEffect(() => {
|
||||
setIsCluster(source === 'multi_agent')
|
||||
setFileList([])
|
||||
toolbarRef.current?.setFiles([])
|
||||
setMessage(undefined)
|
||||
}, [source])
|
||||
|
||||
@@ -79,7 +92,9 @@ const Chat: FC<ChatProps> = ({
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
};
|
||||
updateChatList(prev => prev.map(item => ({
|
||||
...item,
|
||||
@@ -111,8 +126,8 @@ const Chat: FC<ChatProps> = ({
|
||||
}
|
||||
}
|
||||
/** Update assistant message with streaming content */
|
||||
const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string) => {
|
||||
if (!content || !model_config_id) return
|
||||
const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string, audio_url?: string) => {
|
||||
if ((!content && !audio_url) || !model_config_id) return
|
||||
updateChatList(prev => {
|
||||
const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id);
|
||||
if (targetIndex !== -1) {
|
||||
@@ -123,12 +138,13 @@ const Chat: FC<ChatProps> = ({
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
modelChatList[targetIndex] = {
|
||||
...modelChatList[targetIndex],
|
||||
conversation_id: conversation_id,
|
||||
conversation_id,
|
||||
list: [
|
||||
...curChatMsgList.slice(0, curChatMsgList.length - 1),
|
||||
{
|
||||
...lastMsg,
|
||||
content: lastMsg.content + content
|
||||
content: lastMsg.content + (content || ''),
|
||||
meta_data: { audio_url }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -146,8 +162,7 @@ const Chat: FC<ChatProps> = ({
|
||||
const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id);
|
||||
if (targetIndex > -1) {
|
||||
const modelChatList = [...prev]
|
||||
const curModelChat = modelChatList[targetIndex]
|
||||
const curChatMsgList = curModelChat.list || []
|
||||
const curChatMsgList = modelChatList[targetIndex].list || []
|
||||
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
modelChatList[targetIndex] = {
|
||||
@@ -169,13 +184,14 @@ const Chat: FC<ChatProps> = ({
|
||||
}
|
||||
/** Send message for agent comparison mode */
|
||||
const handleSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
if (loading || !id) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = msg
|
||||
if (!message?.trim()) return
|
||||
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
|
||||
// Validate required variables before sending
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
@@ -200,8 +216,9 @@ const Chat: FC<ChatProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
addUserMessage(message, fileList)
|
||||
addUserMessage(message, files)
|
||||
setMessage(message)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
addAssistantMessage()
|
||||
|
||||
@@ -209,13 +226,16 @@ const Chat: FC<ChatProps> = ({
|
||||
setCompareLoading(false)
|
||||
|
||||
data.map(item => {
|
||||
const { model_config_id, conversation_id, content, message_length } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number };
|
||||
|
||||
const { model_config_id, conversation_id, content, message_length, audio_url } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number; audio_url: string };
|
||||
|
||||
switch (item.event) {
|
||||
case 'model_message':
|
||||
updateAssistantMessage(content, model_config_id, conversation_id)
|
||||
updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
|
||||
break;
|
||||
case 'model_end':
|
||||
if (audio_url) {
|
||||
updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
|
||||
}
|
||||
updateErrorAssistantMessage(message_length, model_config_id)
|
||||
break;
|
||||
case 'compare_end':
|
||||
@@ -226,9 +246,9 @@ const Chat: FC<ChatProps> = ({
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
runCompare(data.app_id, {
|
||||
runCompare(id, {
|
||||
message,
|
||||
files: fileList.map(file => {
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
@@ -246,9 +266,9 @@ const Chat: FC<ChatProps> = ({
|
||||
conversation_id: item.conversation_id
|
||||
})),
|
||||
variables: params,
|
||||
"parallel": true,
|
||||
"stream": true,
|
||||
"timeout": 60,
|
||||
parallel: true,
|
||||
stream: true,
|
||||
timeout: 60,
|
||||
}, handleStreamMessage)
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
@@ -272,7 +292,7 @@ const Chat: FC<ChatProps> = ({
|
||||
const assistantMessage: ChatItem = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: Date.now(),
|
||||
created_at: Date.now()
|
||||
};
|
||||
updateChatList(prev => prev.map(item => ({
|
||||
...item,
|
||||
@@ -284,8 +304,7 @@ const Chat: FC<ChatProps> = ({
|
||||
if (!content) return
|
||||
updateChatList(prev => {
|
||||
const modelChatList = [...prev]
|
||||
const curModelChat = modelChatList[0]
|
||||
const curChatMsgList = curModelChat.list || []
|
||||
const curChatMsgList = modelChatList[0].list || []
|
||||
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
modelChatList[0] = {
|
||||
@@ -305,11 +324,9 @@ const Chat: FC<ChatProps> = ({
|
||||
/** Update cluster message when error occurs */
|
||||
const updateClusterErrorAssistantMessage = (message_length: number) => {
|
||||
if (message_length > 0) return
|
||||
|
||||
updateChatList(prev => {
|
||||
const modelChatList = [...prev]
|
||||
const curModelChat = modelChatList[0]
|
||||
const curChatMsgList = curModelChat.list || []
|
||||
const curChatMsgList = modelChatList[0].list || []
|
||||
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
modelChatList[0] = {
|
||||
@@ -326,17 +343,19 @@ const Chat: FC<ChatProps> = ({
|
||||
return [...modelChatList]
|
||||
})
|
||||
}
|
||||
/** Send message for cluster mode */
|
||||
|
||||
const handleClusterSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
if (loading || !id) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = msg
|
||||
if (!message || message.trim() === '') return
|
||||
addUserMessage(message, fileList)
|
||||
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
|
||||
addUserMessage(message, files)
|
||||
setMessage(undefined)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
addClusterAssistantMessage()
|
||||
|
||||
@@ -345,7 +364,7 @@ const Chat: FC<ChatProps> = ({
|
||||
|
||||
data.map(item => {
|
||||
const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number };
|
||||
|
||||
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
@@ -369,13 +388,12 @@ const Chat: FC<ChatProps> = ({
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
draftRun(
|
||||
data.app_id,
|
||||
draftRun(id,
|
||||
{
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
stream: true,
|
||||
files: fileList.map(file => {
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
@@ -410,36 +428,6 @@ const Chat: FC<ChatProps> = ({
|
||||
const handleDelete = (index: number) => {
|
||||
updateChatList(chatList.filter((_, voIndex) => voIndex !== index))
|
||||
}
|
||||
const handleMessageChange = (message: string) => {
|
||||
setMessage(message)
|
||||
}
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
setFileList([...fileList, {
|
||||
uid: file.file_id,
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list || list.length <= 0) return
|
||||
setFileList([...fileList, ...(list || [])])
|
||||
}
|
||||
const updateFileList = (list?: any[]) => {
|
||||
setFileList([...list || []])
|
||||
}
|
||||
const isNeedVariableConfig = chatVariables?.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
|
||||
|
||||
return (
|
||||
<div className="rb:relative rb:h-full rb:flex rb:flex-col">
|
||||
@@ -458,13 +446,10 @@ const Chat: FC<ChatProps> = ({
|
||||
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
|
||||
})}>
|
||||
{chat.label &&
|
||||
<div className={clsx(
|
||||
"rb:grid rb:bg-[#F0F3F8] rb:text-center rb:flex-[0_0_auto]",
|
||||
{
|
||||
'rb:rounded-tr-xl': index === chatList.length - 1,
|
||||
'rb:rounded-tl-xl': index === 0,
|
||||
}
|
||||
)}>
|
||||
<div className={clsx("rb:grid rb:bg-[#F0F3F8] rb:text-center rb:flex-[0_0_auto]", {
|
||||
'rb:rounded-tr-xl': index === chatList.length - 1,
|
||||
'rb:rounded-tl-xl': index === 0,
|
||||
})}>
|
||||
<div className='rb:relative rb:p-[10px_12px] rb:overflow-hidden'>
|
||||
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div>
|
||||
<div
|
||||
@@ -501,59 +486,37 @@ const Chat: FC<ChatProps> = ({
|
||||
message={message}
|
||||
className="rb:relative!"
|
||||
loading={loading}
|
||||
fileChange={updateFileList}
|
||||
fileChange={(list) => {
|
||||
setFileList(list || [])
|
||||
toolbarRef.current?.setFiles(list || [])
|
||||
}}
|
||||
fileList={fileList}
|
||||
onSend={isCluster ? handleClusterSend : handleSend}
|
||||
onChange={handleMessageChange}
|
||||
onChange={setMessage}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<ChatToolbar
|
||||
ref={toolbarRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
extra={
|
||||
chatVariables && chatVariables.length > 0 ? (
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
{chatVariables && chatVariables.length > 0 && (
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
className={clsx('rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]', {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': chatVariables.some(vo => vo.required && !vo.value),
|
||||
'rb:border-[#DFE4ED]': !chatVariables.some(vo => vo.required && !vo.value),
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t(`memoryConversation.variableConfig`)}
|
||||
{t('memoryConversation.variableConfig')}
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</ChatInput>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:12:59
|
||||
* @Last Modified time: 2026-03-19 17:13:54
|
||||
*/
|
||||
import { type FC, useRef, useMemo, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
@@ -18,10 +18,10 @@ import exportIcon from '@/assets/images/export_hover.svg'
|
||||
import deleteIcon from '@/assets/images/delete_hover.svg'
|
||||
import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
|
||||
import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal'
|
||||
import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FunConfigForm } from '../types'
|
||||
import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FeaturesConfigForm } from '../types'
|
||||
import { deleteApplication, appExport } from '@/api/application'
|
||||
import CopyModal from './CopyModal'
|
||||
import FunConfig from './FunConfig'
|
||||
import FeaturesConfig from './FeaturesConfig'
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
@@ -61,6 +61,10 @@ interface ConfigHeaderProps {
|
||||
workflowRef: React.RefObject<WorkflowRef>
|
||||
/** App component ref (Agent/Cluster/Workflow) */
|
||||
appRef?: React.RefObject<AgentRef | ClusterRef | WorkflowRef>
|
||||
/** Features config from parent state */
|
||||
features?: FeaturesConfigForm;
|
||||
/** Callback to update features in parent */
|
||||
onFeaturesChange?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +75,8 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
application, activeTab, handleChangeTab, refresh,
|
||||
workflowRef,
|
||||
appRef,
|
||||
features,
|
||||
onFeaturesChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -97,10 +103,16 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
applicationModalRef.current?.handleOpen(application)
|
||||
break;
|
||||
case 'copy':
|
||||
copyModalRef.current?.handleOpen()
|
||||
appRef?.current?.handleSave(false)
|
||||
.then(() => {
|
||||
copyModalRef.current?.handleOpen()
|
||||
})
|
||||
break;
|
||||
case 'export':
|
||||
appExport(application.id, application.name)
|
||||
appRef?.current?.handleSave(false)
|
||||
.then(() => {
|
||||
appExport(application.id, application.name)
|
||||
})
|
||||
break;
|
||||
case 'delete':
|
||||
handleDelete()
|
||||
@@ -167,14 +179,11 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
return items
|
||||
}, [t, handleClick, application])
|
||||
|
||||
const funConfig = useMemo(() => {
|
||||
return (appRef?.current?.funConfig || { file_type: [] }) as FunConfigForm
|
||||
}, [appRef])
|
||||
const handleSaveFunConfig = useCallback((value: FunConfigForm) => {
|
||||
appRef?.current?.handleSaveFunConfig?.(value)
|
||||
}, [appRef])
|
||||
|
||||
console.log('formatMenuItems', formatMenuItems)
|
||||
const handleSaveFeaturesConfig = useCallback((value: FeaturesConfigForm) => {
|
||||
appRef?.current?.handleSaveFeaturesConfig?.(value)
|
||||
onFeaturesChange?.(value)
|
||||
}, [appRef, onFeaturesChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header className="rb:w-full rb:h-16 rb:grid rb:grid-cols-3 rb:p-[16px_16px_16px_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8">
|
||||
@@ -203,9 +212,9 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
className={styles.tabs}
|
||||
/>
|
||||
</div>
|
||||
{application?.type === 'workflow'
|
||||
{application?.type === 'workflow' && source !== 'sharing' && activeTab === 'arrangement'
|
||||
? <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:gap-2.5">
|
||||
{/* <FunConfig value={funConfig} refresh={handleSaveFunConfig} /> */}
|
||||
<FeaturesConfig source={application?.type} value={features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} />
|
||||
<Button onClick={clear}>{t('workflow.clear')}</Button>
|
||||
<Button onClick={addvariable}>{t('workflow.addvariable')}</Button>
|
||||
<Button onClick={run}>{t('workflow.run')}</Button>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 15:38:14
|
||||
*/
|
||||
/**
|
||||
* Copy Application Modal
|
||||
* Allows users to duplicate an existing application with a new name
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Form, Button, Flex } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||
import FileUploadSettingModal from './FileUploadSettingModal'
|
||||
import type { Application } from '@/views/ApplicationManagement/types';
|
||||
|
||||
interface FeaturesConfigModalProps {
|
||||
refresh: (value: FeaturesConfigForm) => void;
|
||||
source?: Application['type'];
|
||||
}
|
||||
const max_file_count = 1;
|
||||
/**
|
||||
* Modal for copying applications
|
||||
*/
|
||||
const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigModalProps>(({
|
||||
refresh,
|
||||
source,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<FeaturesConfigForm>();
|
||||
const values = Form.useWatch([], form)
|
||||
const fileUploadSettingModalRef = useRef<any>(null)
|
||||
|
||||
/** Close modal and reset form */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
/** Open modal */
|
||||
const handleOpen = (initValue: FeaturesConfigForm) => {
|
||||
setVisible(true);
|
||||
console.log('initValue', initValue)
|
||||
form.setFieldsValue(initValue)
|
||||
};
|
||||
/** Copy application with new name */
|
||||
const handleSave = () => {
|
||||
setVisible(false);
|
||||
refresh(form.getFieldsValue())
|
||||
}
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
fileUploadSettingModalRef.current?.handleOpen(values?.file_upload)
|
||||
}
|
||||
|
||||
const handleSaveSettings = (settings: FeaturesConfigForm['file_upload']) => {
|
||||
form.setFieldValue('file_upload', { ...settings, enabled: values?.file_upload?.enabled ?? false })
|
||||
}
|
||||
|
||||
/** Expose methods to parent component */
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RbModal
|
||||
title={t('application.features')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.confirm')}
|
||||
onOk={handleSave}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
{source !== 'workflow' && <>
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t(`memoryConversation.web_search`)}
|
||||
name={['web_search', "enabled"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.text_to_speech')}
|
||||
name={['text_to_speech', "enabled"]}
|
||||
desc={t('application.text_to_speech_desc')}
|
||||
/>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.file_upload')}
|
||||
name={['file_upload', "enabled"]}
|
||||
desc={values?.file_upload?.enabled ? undefined : t('application.file_upload_desc')}
|
||||
/>
|
||||
{values?.file_upload?.enabled && (() => {
|
||||
const fu = values.file_upload
|
||||
const types = [
|
||||
{ type: 'image', enabled: fu.image_enabled, maxSize: fu.image_max_size_mb },
|
||||
{ type: 'audio', enabled: fu.audio_enabled, maxSize: fu.audio_max_size_mb },
|
||||
{ type: 'document', enabled: fu.document_enabled, maxSize: fu.document_max_size_mb },
|
||||
{ type: 'video', enabled: fu.video_enabled, maxSize: fu.video_max_size_mb },
|
||||
].filter(item => item.enabled)
|
||||
return types.length > 0 ? <>
|
||||
<Flex gap={12} className="rb:py-2!">
|
||||
<div className="rb:flex-1 rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:bg-white rb:text-[12px]">
|
||||
<div className="rb:grid rb:grid-cols-2 rb:gap-2 rb:text-[12px] rb:text-[#5B6167] rb:border-b rb:border-b-[#DFE4ED]">
|
||||
<div className="rb:px-3 rb:py-1">{t(`application.supportedTypes`)}</div>
|
||||
<div className="rb:px-3 rb:py-1">{t('application.singleMaxSize')}</div>
|
||||
</div>
|
||||
{types.map((item, index) => (
|
||||
<div key={item.type} className={clsx('rb:grid rb:grid-cols-2 rb:gap-2', {
|
||||
'rb:border-b rb:border-b-[#DFE4ED]': index !== types.length - 1
|
||||
})}>
|
||||
<div className="rb:px-3 rb:py-1">{t(`application.${item.type}`)}</div>
|
||||
<div className="rb:px-3 rb:py-1">{item.maxSize} MB</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:py-1">{t('application.maxCount')}</div>
|
||||
{max_file_count} {t('application.unix')}
|
||||
</div>
|
||||
</Flex>
|
||||
<Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
|
||||
</> : <Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
|
||||
})()}
|
||||
<Form.Item name="file_upload" hidden />
|
||||
</div>
|
||||
</Flex>
|
||||
</Form>
|
||||
</RbModal>
|
||||
|
||||
<FileUploadSettingModal
|
||||
ref={fileUploadSettingModalRef}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FeaturesConfigModal;
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-05
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 20:19:14
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, InputNumber, Flex, Switch, Row, Col, Radio } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import RbModal from '@/components/RbModal';
|
||||
import type { FeaturesConfigForm } from '../../types'
|
||||
|
||||
type FileUpload = Omit<FeaturesConfigForm['file_upload'], 'settings'>
|
||||
|
||||
interface FileUploadSettingModalRef {
|
||||
handleOpen: (values?: FileUpload) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
interface FileUploadSettingModalProps {
|
||||
onSave: (values: FileUpload) => void;
|
||||
}
|
||||
|
||||
const fileTypeOptions = [
|
||||
{
|
||||
type: 'document',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
|
||||
formats: [
|
||||
"pdf",
|
||||
"docx",
|
||||
"doc",
|
||||
"xlsx",
|
||||
"xls",
|
||||
"txt",
|
||||
"csv",
|
||||
"json",
|
||||
"md",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
|
||||
formats: [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg"
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
|
||||
formats: [
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
|
||||
formats: [
|
||||
"mp4",
|
||||
"mov",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultValues: FileUpload = {
|
||||
enabled: false,
|
||||
image_enabled: false,
|
||||
image_max_size_mb: 20,
|
||||
image_allowed_extensions: [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg"
|
||||
],
|
||||
audio_enabled: false,
|
||||
audio_max_size_mb: 50,
|
||||
audio_allowed_extensions: [
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
],
|
||||
document_enabled: false,
|
||||
document_max_size_mb: 100,
|
||||
document_allowed_extensions: [
|
||||
"pdf",
|
||||
"docx",
|
||||
"doc",
|
||||
"xlsx",
|
||||
"xls",
|
||||
"txt",
|
||||
"csv",
|
||||
"json",
|
||||
"md",
|
||||
],
|
||||
video_enabled: false,
|
||||
video_max_size_mb: 100,
|
||||
video_allowed_extensions: [
|
||||
"mp4",
|
||||
"mov",
|
||||
],
|
||||
max_file_count: 1,
|
||||
allowed_transfer_methods: 'both'
|
||||
}
|
||||
|
||||
const FileUploadSettingModal = forwardRef<FileUploadSettingModalRef, FileUploadSettingModalProps>(({
|
||||
onSave,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<FileUpload>();
|
||||
const values = Form.useWatch([], form)
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleOpen = (values?: FileUpload) => {
|
||||
setVisible(true);
|
||||
if (values) {
|
||||
const methods = values.allowed_transfer_methods || ['local_file', 'remote_url']
|
||||
const transferMethod = Array.isArray(methods)
|
||||
? methods.length === 2 ? 'both' : methods[0]
|
||||
: methods
|
||||
form.setFieldsValue({ ...values, allowed_transfer_methods: transferMethod as any })
|
||||
} else {
|
||||
form.setFieldsValue(defaultValues)
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const vals = await form.validateFields();
|
||||
const methodMap: Record<string, string[]> = {
|
||||
local_file: ['local_file'],
|
||||
remote_url: ['remote_url'],
|
||||
both: ['local_file', 'remote_url'],
|
||||
}
|
||||
onSave({ ...vals, allowed_transfer_methods: methodMap[vals.allowed_transfer_methods as unknown as string] ?? [] });
|
||||
handleClose();
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.settings')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
onOk={handleSave}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={defaultValues}>
|
||||
<Form.Item
|
||||
label={t('application.uploadType')}
|
||||
name="allowed_transfer_methods"
|
||||
>
|
||||
<Radio.Group block buttonStyle="solid">
|
||||
<Radio.Button value="local_file">{t('application.local')}</Radio.Button>
|
||||
<Radio.Button value="remote_url">URL</Radio.Button>
|
||||
<Radio.Button value="both">{t('application.both')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{/* <div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-1">{t('application.maxCount')}</div> */}
|
||||
<Form.Item label={t('application.maxCount')} name="max_file_count" hidden>
|
||||
<InputNumber min={1} max={20} precision={0} className="rb:w-full!" placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('application.supportedTypes')}>
|
||||
<Flex vertical gap={12}>
|
||||
{fileTypeOptions.map((option) => {
|
||||
const enabledKey = `${option.type}_enabled` as keyof FileUpload
|
||||
const sizeKey = `${option.type}_max_size_mb` as keyof FileUpload
|
||||
const isEnabled = values?.[enabledKey]
|
||||
return (
|
||||
<div
|
||||
key={option.type}
|
||||
className={clsx('rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3', {
|
||||
'rb:bg-[#f5f7fc]': isEnabled
|
||||
})}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col flex="36px" className="rb:self-center">{option.icon}</Col>
|
||||
<Col flex="1">
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex vertical>
|
||||
<div className="rb:font-medium">{t(`application.${option.type}`)}</div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">{option.formats.map(item => item.toUpperCase()).join(', ')}</div>
|
||||
</Flex>
|
||||
<Form.Item name={enabledKey} valuePropName="checked" noStyle>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Col>
|
||||
</Row>
|
||||
{isEnabled && (
|
||||
<Flex align="center" gap={12} className="rb:mt-3! rb:pt-3! rb:border-t rb:border-[#DFE4ED]">
|
||||
<div>{t('application.singleMaxSize')}: </div>
|
||||
<Form.Item name={sizeKey} noStyle>
|
||||
<InputNumber min={1} max={100} suffix="MB" className="rb:flex-1" />
|
||||
</Form.Item>
|
||||
<Form.Item name={`${option.type}_allowed_extensions`} hidden />
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileUploadSettingModal;
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:20:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 15:38:59
|
||||
*/
|
||||
import { type FC, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'antd';
|
||||
|
||||
import FeaturesConfigModal from './FeaturesConfigModal'
|
||||
import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
|
||||
import type { Application } from '@/views/ApplicationManagement/types';
|
||||
|
||||
/** Props for the FeaturesConfig component */
|
||||
interface FeaturesConfigProps {
|
||||
/** Current feature configuration values */
|
||||
value: FeaturesConfigForm;
|
||||
/** Callback to propagate updated config back to the parent */
|
||||
refresh: (value: FeaturesConfigForm) => void;
|
||||
source?: Application['type'];
|
||||
}
|
||||
|
||||
const FeaturesConfig: FC<FeaturesConfigProps> = ({
|
||||
value,
|
||||
refresh,
|
||||
source
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Ref used to imperatively open the config modal
|
||||
const funConfigModalRef = useRef<FeaturesConfigModalRef>(null)
|
||||
|
||||
/** Open the feature config modal pre-populated with the current values */
|
||||
const handleFeaturesConfig = () => {
|
||||
console.log('handleFeaturesConfig', value)
|
||||
funConfigModalRef.current?.handleOpen(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Button that triggers the feature configuration modal */}
|
||||
<Button onClick={handleFeaturesConfig}>{t('application.features')}</Button>
|
||||
|
||||
{/* Modal for editing feature settings; calls refresh on save */}
|
||||
<FeaturesConfigModal
|
||||
ref={funConfigModalRef}
|
||||
refresh={refresh}
|
||||
source={source}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeaturesConfig
|
||||
@@ -1,182 +0,0 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-05
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-11 15:42:13
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Radio, InputNumber, Flex, Switch, Row, Col } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import RbModal from '@/components/RbModal';
|
||||
import type { FunConfigForm } from '../../types'
|
||||
|
||||
interface FileUploadSettingModalRef {
|
||||
handleOpen: (values?: FileUploadSettings) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
interface FileUploadSettings extends Omit<FunConfigForm, 'enabled'> {}
|
||||
|
||||
interface FileUploadSettingModalProps {
|
||||
onSave: (values: FileUploadSettings) => void;
|
||||
}
|
||||
|
||||
const fileTypeOptions = [
|
||||
{
|
||||
type: 'document',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
|
||||
formats: 'TXT, MD, MDX, MARKDOWN, PDF, DOC, DOCX',
|
||||
defaultMaxCount: 1,
|
||||
defaultMaxSize: 2
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
|
||||
formats: 'JPG, JPEG, PNG, GIF, WEBP, SVG',
|
||||
defaultMaxCount: 1,
|
||||
defaultMaxSize: 2
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
|
||||
formats: 'MP3, M4A, WAV, AMR, MPGA',
|
||||
defaultMaxCount: 1,
|
||||
defaultMaxSize: 2
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
|
||||
formats: 'MP4, MOV, MPEG, WEBM',
|
||||
defaultMaxCount: 1,
|
||||
defaultMaxSize: 2
|
||||
},
|
||||
];
|
||||
|
||||
const FileUploadSettingModal = forwardRef<FileUploadSettingModalRef, FileUploadSettingModalProps>(({
|
||||
onSave,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const values = Form.useWatch([], form)
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleOpen = (values?: FileUploadSettings) => {
|
||||
setVisible(true);
|
||||
// if (values) {
|
||||
// form.setFieldsValue(values);
|
||||
// }
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
onSave(values);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.settings')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
onOk={handleSave}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
uploadType: 'both',
|
||||
fileTypes: fileTypeOptions.map(opt => ({
|
||||
type: opt.type,
|
||||
enabled: false,
|
||||
maxCount: opt.defaultMaxCount,
|
||||
maxSize: opt.defaultMaxSize
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('application.uploadType')}
|
||||
name="uploadType"
|
||||
>
|
||||
<Radio.Group block buttonStyle="solid">
|
||||
<Radio.Button value="local">{t('application.local')}</Radio.Button>
|
||||
<Radio.Button value="url">URL</Radio.Button>
|
||||
<Radio.Button value="both">{t('application.both')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-1">{t('application.maxCount')}</div>
|
||||
<Form.Item
|
||||
name="maxCount"
|
||||
label={t('application.maxCount')}
|
||||
>
|
||||
<InputNumber min={1} max={100} className="rb:w-full!" placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('application.supportedTypes')}>
|
||||
<Form.List name="fileTypes">
|
||||
{(fields) => (
|
||||
<Flex vertical gap={12}>
|
||||
{fields.map((field, index) => {
|
||||
const option = fileTypeOptions[index];
|
||||
const isEnabled = values?.fileTypes?.[index]?.enabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={clsx("rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3", {
|
||||
'rb:bg-[#f5f7fc]': isEnabled
|
||||
})}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col flex="36px" className="rb:self-center">
|
||||
{option.icon}
|
||||
</Col>
|
||||
<Col flex="1">
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex vertical>
|
||||
<div className="rb:font-medium">{t(`application.${option.type}`)}</div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">{option.formats}</div>
|
||||
</Flex>
|
||||
<Form.Item name={[field.name, 'enabled']} valuePropName="checked" noStyle>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Col>
|
||||
</Row>
|
||||
{isEnabled && (
|
||||
<Flex align="center" gap={12} className="rb:mt-3! rb:pt-3! rb:border-t rb:border-[#DFE4ED]">
|
||||
<div>{t('application.singleMaxSize')}: </div>
|
||||
<Form.Item name={[field.name, 'maxSize']} noStyle>
|
||||
<InputNumber min={1} max={500} suffix="MB" className="rb:flex-1" />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
)}
|
||||
<Form.Item name={[field.name, 'type']} hidden>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileUploadSettingModal;
|
||||
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:20:30
|
||||
*/
|
||||
/**
|
||||
* Copy Application Modal
|
||||
* Allows users to duplicate an existing application with a new name
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Form, Button, Flex } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { FunConfigModalRef } from '../../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import type { FunConfigForm } from '../../types'
|
||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||
import FileUploadSettingModal from './FileUploadSettingModal'
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface FunConfigModalProps {
|
||||
refresh: (value: FunConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for copying applications
|
||||
*/
|
||||
const FunConfigModal = forwardRef<FunConfigModalRef, FunConfigModalProps>(({
|
||||
refresh,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<FunConfigForm>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const values = Form.useWatch([], form)
|
||||
const fileUploadSettingModalRef = useRef<any>(null)
|
||||
|
||||
/** Close modal and reset form */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
};
|
||||
|
||||
/** Open modal */
|
||||
const handleOpen = (initValue: FunConfigForm) => {
|
||||
setVisible(true);
|
||||
form.setFieldsValue(initValue)
|
||||
};
|
||||
/** Copy application with new name */
|
||||
const handleSave = () => {
|
||||
setVisible(false);
|
||||
setLoading(true)
|
||||
const values = form.getFieldsValue()
|
||||
refresh(values)
|
||||
}
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
fileUploadSettingModalRef.current?.handleOpen(values)
|
||||
}
|
||||
|
||||
const handleSaveSettings = (settings: any) => {
|
||||
form.setFieldsValue(settings)
|
||||
}
|
||||
|
||||
/** Expose methods to parent component */
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<RbModal
|
||||
title={t('application.funConfig')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.copy')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t(`memoryConversation.web_search`)}
|
||||
name={['web_search', "enabled"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.textTranfer')}
|
||||
name={['textTranfer', "enabled"]}
|
||||
desc={t('application.textTranferDesc')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.fileUpload')}
|
||||
name={['fileUpload', "enabled"]}
|
||||
desc={values?.fileUpload?.enabled ? undefined : t('application.fileUploadDesc')}
|
||||
/>
|
||||
{values?.fileUpload?.enabled && values?.fileTypes?.length > 0 ? <>
|
||||
<div className="rb:grid rb:grid-cols-3 rb:gap-2 rb:text-[12px] rb:text-[#5B6167]">
|
||||
<div>{t(`application.supportedTypes`)}</div>
|
||||
<div>{t('application.maxCount')}</div>
|
||||
<div>{t('application.singleMaxSize')}</div>
|
||||
</div>
|
||||
{values?.fileTypes?.filter(item => item.enabled).map(item => (
|
||||
<div key={item.type} className="rb:grid rb:grid-cols-3 rb:gap-2">
|
||||
<div>{t(`application.${item.type}`)}</div>
|
||||
<div>{item.maxCount} {t('application.unix')}</div>
|
||||
<div>{item.maxSize} MB</div>
|
||||
</div>
|
||||
))}
|
||||
<Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
|
||||
</> : null}
|
||||
<FormItem name="fileTypes" noStyle hidden></FormItem>
|
||||
<FormItem name="uploadType" noStyle hidden></FormItem>
|
||||
</div>
|
||||
</Flex>
|
||||
</Form>
|
||||
</RbModal>
|
||||
|
||||
<FileUploadSettingModal
|
||||
ref={fileUploadSettingModalRef}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FunConfigModal;
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:20:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:20:21
|
||||
*/
|
||||
import { type FC, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'antd';
|
||||
|
||||
import FunConfigModal from './FunConfigModal'
|
||||
import type { FunConfigModalRef, FunConfigForm } from '../../types'
|
||||
|
||||
/** Props for the FunConfig component */
|
||||
interface FunConfigProps {
|
||||
/** Current feature configuration values */
|
||||
value: FunConfigForm;
|
||||
/** Callback to propagate updated config back to the parent */
|
||||
refresh: (value: FunConfigForm) => void;
|
||||
}
|
||||
|
||||
const FunConfig: FC<FunConfigProps> = ({
|
||||
value,
|
||||
refresh
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Ref used to imperatively open the config modal
|
||||
const funConfigModalRef = useRef<FunConfigModalRef>(null)
|
||||
|
||||
/** Open the feature config modal pre-populated with the current values */
|
||||
const handleFunConfig = () => {
|
||||
console.log('funConfig', value)
|
||||
funConfigModalRef.current?.handleOpen(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Button that triggers the feature configuration modal */}
|
||||
<Button onClick={handleFunConfig}>{t('application.funConfig')}</Button>
|
||||
|
||||
{/* Modal for editing feature settings; calls refresh on save */}
|
||||
<FunConfigModal
|
||||
ref={funConfigModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FunConfig
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:26:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:26:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 14:01:13
|
||||
*/
|
||||
/**
|
||||
* Tool List Component
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import Empty from '@/components/Empty'
|
||||
import ToolModal from './ToolModal'
|
||||
import { getToolMethods, getToolDetail } from '@/api/tools'
|
||||
import Tag from '@/components/Tag'
|
||||
|
||||
/**
|
||||
* Tool list management component
|
||||
@@ -42,23 +43,25 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
getToolMethods(item.tool_id)
|
||||
])
|
||||
|
||||
console.log('toolDetail', toolDetail)
|
||||
switch ((toolDetail as any).tool_type) {
|
||||
case 'mcp':
|
||||
const mcpFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: mcpFilterItem?.description,
|
||||
method_id: mcpFilterItem?.method_id,
|
||||
value: mcpFilterItem?.name,
|
||||
description: mcpFilterItem?.description,
|
||||
parameters: mcpFilterItem?.parameters
|
||||
}
|
||||
break
|
||||
case 'builtin':
|
||||
if ((methods as any[]).length > 1) {
|
||||
const builtinFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: builtinFilterItem?.description,
|
||||
method_id: builtinFilterItem?.method_id,
|
||||
value: builtinFilterItem?.name,
|
||||
@@ -68,17 +71,18 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: (methods as any[])[0]?.description,
|
||||
method_id: (methods as any[])[0]?.method_id,
|
||||
value: (methods as any[])[0]?.name,
|
||||
description: (methods as any[])[0]?.description,
|
||||
parameters: (methods as any[])[0]?.parameters
|
||||
}
|
||||
break
|
||||
default:
|
||||
const customFilterItem = (methods as any[]).find(vo => vo.method_id === item.operation)
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: customFilterItem?.name,
|
||||
method_id: customFilterItem?.method_id,
|
||||
value: customFilterItem?.name,
|
||||
@@ -103,7 +107,10 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
}
|
||||
/** Add new tool to list */
|
||||
const updateTools = (tool: ToolOption) => {
|
||||
const list = [...toolList, tool]
|
||||
const list = [...toolList, {
|
||||
...tool,
|
||||
is_active: true,
|
||||
}]
|
||||
setToolList(list)
|
||||
onChange && onChange(list)
|
||||
}
|
||||
@@ -127,6 +134,7 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
setToolList([...list])
|
||||
onChange && onChange(list)
|
||||
}
|
||||
console.log('toolList', toolList)
|
||||
return (
|
||||
<Card
|
||||
title={t('application.toolConfiguration')}
|
||||
@@ -143,8 +151,13 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<div key={index} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg">
|
||||
<div className="rb:font-medium rb:leading-4">
|
||||
{item.label}
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-4">
|
||||
{item.label}
|
||||
</div>
|
||||
<Tag color={item.is_active ? 'success' : 'error'} className="rb:mt-1">
|
||||
{item.is_active ? t('common.enable') : t('common.deleted')}
|
||||
</Tag>
|
||||
</div>
|
||||
<Space size={12}>
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:26:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:26:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-17 15:50:48
|
||||
*/
|
||||
/**
|
||||
* Type definitions for tool configuration in application settings
|
||||
@@ -32,6 +32,7 @@ export interface ToolOption {
|
||||
tool_id?: string;
|
||||
/** Whether tool is enabled */
|
||||
enabled?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,7 @@ const ApplicationConfig: React.FC = () => {
|
||||
// State
|
||||
const [application, setApplication] = useState<Application | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('arrangement');
|
||||
const [features, setFeatures] = useState<import('./types').FeaturesConfigForm | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(source === 'sharing' ? 'test' : 'arrangement')
|
||||
@@ -114,10 +115,12 @@ const ApplicationConfig: React.FC = () => {
|
||||
refresh={getApplicationInfo}
|
||||
appRef={application?.type === 'agent' ? agentRef : application?.type === 'multi_agent' ? clusterRef : application?.type === 'workflow' ? workflowRef : undefined}
|
||||
workflowRef={workflowRef}
|
||||
features={features}
|
||||
onFeaturesChange={setFeatures}
|
||||
/>
|
||||
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} onFeaturesLoad={setFeatures} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} onFeaturesLoad={setFeatures} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} onFeaturesLoad={setFeatures} />}
|
||||
{activeTab === 'api' && <Api application={application} />}
|
||||
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
|
||||
{activeTab === 'statistics' && <Statistics application={application} />}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:01:04
|
||||
* @Last Modified time: 2026-03-16 17:42:12
|
||||
*/
|
||||
import type { KnowledgeConfig } from './components/Knowledge/types'
|
||||
import type { Variable } from './components/VariableList/types'
|
||||
@@ -78,7 +78,7 @@ export interface Config extends MultiAgentConfig {
|
||||
updated_at: number;
|
||||
skills?: SkillConfigForm | null;
|
||||
|
||||
funConfig?: FunConfigForm;
|
||||
features?: FeaturesConfigForm;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,8 +129,8 @@ export interface AgentRef {
|
||||
* @param flag - Whether to show success message
|
||||
*/
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
funConfig: Config['funConfig'];
|
||||
handleSaveFunConfig?: (value: FunConfigForm) => void;
|
||||
features: Config['features'];
|
||||
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,8 +142,8 @@ export interface ClusterRef {
|
||||
* @param flag - Whether to show success message
|
||||
*/
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
funConfig: Config['funConfig'];
|
||||
handleSaveFunConfig?: (value: FunConfigForm) => void;
|
||||
features: Config['features'];
|
||||
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,8 +162,8 @@ export interface WorkflowRef {
|
||||
/** Add variable */
|
||||
addVariable: () => void;
|
||||
config: WorkflowConfig | null;
|
||||
funConfig: WorkflowConfig['funConfig'];
|
||||
handleSaveFunConfig?: (value: FunConfigForm) => void;
|
||||
features: WorkflowConfig['features'];
|
||||
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -416,17 +416,55 @@ export interface FileTypeConfig {
|
||||
maxCount: number;
|
||||
maxSize: number;
|
||||
}
|
||||
export interface FunConfigForm {
|
||||
enabled: boolean;
|
||||
fileTypes: FileTypeConfig[]
|
||||
uploadType: 'local' | 'url' | 'both';
|
||||
interface FileSetttings {
|
||||
image_enabled: boolean;
|
||||
image_max_size_mb: number;
|
||||
image_allowed_extensions: string[];
|
||||
audio_enabled: boolean;
|
||||
audio_max_size_mb: number;
|
||||
audio_allowed_extensions: string[];
|
||||
document_enabled: boolean;
|
||||
document_max_size_mb: number;
|
||||
document_allowed_extensions: string[];
|
||||
video_enabled: boolean;
|
||||
video_max_size_mb: number;
|
||||
video_allowed_extensions: string[];
|
||||
max_file_count: number;
|
||||
allowed_transfer_methods: string[] | string;
|
||||
}
|
||||
export type FeaturesConfigForm = {
|
||||
file_upload: FileSetttings & {
|
||||
enabled: boolean;
|
||||
settings?: FileSetttings
|
||||
};
|
||||
opening_statement: {
|
||||
enabled: boolean;
|
||||
statement: string | null;
|
||||
suggested_questions: string[];
|
||||
};
|
||||
suggested_questions_after_answer: {
|
||||
enabled: boolean;
|
||||
};
|
||||
text_to_speech: {
|
||||
enabled: boolean;
|
||||
voice: string | null;
|
||||
language: string | null;
|
||||
autoplay: boolean;
|
||||
};
|
||||
citation: {
|
||||
enabled: boolean;
|
||||
};
|
||||
web_search: {
|
||||
enabled: boolean;
|
||||
search_engine: string | null;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Function config modal ref methods
|
||||
*/
|
||||
export interface FunConfigModalRef {
|
||||
export interface FeaturesConfigModalRef {
|
||||
/** Open function config modal */
|
||||
handleOpen: (value: FunConfigForm) => void;
|
||||
handleOpen: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 17:36:16
|
||||
* @Last Modified time: 2026-03-18 16:15:43
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, type MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, App, Flex, Row, Col, Collapse } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import type { MySharedOutItem } from './types';
|
||||
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
|
||||
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
||||
|
||||
const MySharing: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,7 +21,8 @@ const MySharing: React.FC = () => {
|
||||
useEffect(() => { getList() }, [])
|
||||
|
||||
const getList = () => {
|
||||
mySharedOutList().then(res => setData(res as MySharedOutItem[]))
|
||||
mySharedOutList()
|
||||
.then(res => setData(res as MySharedOutItem[]))
|
||||
}
|
||||
|
||||
/** Group items by target_workspace_id */
|
||||
@@ -57,7 +59,8 @@ const MySharing: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelOne = (item: MySharedOutItem) => {
|
||||
const handleCancelOne = (item: MySharedOutItem, e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
modal.confirm({
|
||||
title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }),
|
||||
okText: t('common.confirm'),
|
||||
@@ -71,87 +74,94 @@ const MySharing: React.FC = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
/** Navigate to application configuration page */
|
||||
const handleEdit = (item: MySharedOutItem) => {
|
||||
let url = `/#/application/config/${item.source_app_id}`
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex vertical gap={12}>
|
||||
{grouped.map(({ workspace, items }) => (
|
||||
<Collapse
|
||||
key={workspace.target_workspace_id}
|
||||
defaultActiveKey={[workspace.target_workspace_id]}
|
||||
items={[{
|
||||
key: workspace.target_workspace_id,
|
||||
label: (
|
||||
<Flex align="center" gap={12}>
|
||||
{workspace.target_workspace_icon
|
||||
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
|
||||
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{workspace.target_workspace_name[0]}
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span className="rb:font-medium">{workspace.target_workspace_name}</span>
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div>
|
||||
</div>
|
||||
</Flex>
|
||||
),
|
||||
extra: (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
|
||||
>
|
||||
{t('application.allCancel')}
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={[12, 12]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} span={6} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative">
|
||||
<div
|
||||
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/close.svg')]"
|
||||
onClick={() => handleCancelOne(item)}
|
||||
/>
|
||||
<Flex gap={8} align="center">
|
||||
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{item.source_app_name[0]}
|
||||
<Flex vertical gap={12} className="rb:h-[calc(100vh-148px)]! rb:overflow-y-auto!">
|
||||
<BodyWrapper loading={false} empty={data.length === 0}>
|
||||
{grouped.map(({ workspace, items }) => (
|
||||
<Collapse
|
||||
key={workspace.target_workspace_id}
|
||||
defaultActiveKey={[workspace.target_workspace_id]}
|
||||
items={[{
|
||||
key: workspace.target_workspace_id,
|
||||
label: (
|
||||
<Flex align="center" gap={12}>
|
||||
{workspace.target_workspace_icon
|
||||
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
|
||||
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{workspace.target_workspace_name[0]}
|
||||
</div>
|
||||
<div className="rb:font-medium">{item.source_app_name}</div>
|
||||
</Flex>
|
||||
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.type')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent',
|
||||
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent',
|
||||
})}>
|
||||
{t(`application.${item.source_app_type}`)}
|
||||
</span>
|
||||
}
|
||||
<div>
|
||||
<span className="rb:font-medium">{workspace.target_workspace_name}</span>
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div>
|
||||
</div>
|
||||
</Flex>
|
||||
),
|
||||
extra: (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
|
||||
>
|
||||
{t('application.allCancel')}
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={[12, 12]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} span={6} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative rb:cursor-pointer" onClick={() => handleEdit(item)}>
|
||||
<div
|
||||
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]"
|
||||
onClick={(e) => handleCancelOne(item, e)}
|
||||
/>
|
||||
<Flex gap={8} align="center">
|
||||
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{item.source_app_name[0]}
|
||||
</div>
|
||||
<div className="rb:font-medium">{item.source_app_name}</div>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.version')}</span>
|
||||
<span>{item.source_app_version}</span>
|
||||
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.type')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent',
|
||||
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent',
|
||||
})}>
|
||||
{t(`application.${item.source_app_type}`)}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.version')}</span>
|
||||
<span>{item.source_app_version}</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.permission')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable',
|
||||
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly',
|
||||
})}>
|
||||
{t(`application.${item.permission}`)}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span>
|
||||
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.permission')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable',
|
||||
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly',
|
||||
})}>
|
||||
{t(`application.${item.permission}`)}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span>
|
||||
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</BodyWrapper>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-16 09:56:02
|
||||
* @Last Modified time: 2026-03-18 21:00:12
|
||||
*/
|
||||
/**
|
||||
* Application Management Page
|
||||
@@ -143,7 +143,7 @@ const ApplicationManagement: React.FC = () => {
|
||||
<Form.Item name="type" noStyle>
|
||||
<Select
|
||||
placeholder={t('application.applicationType')}
|
||||
options={types.map((type) => ({
|
||||
options={(activeTab === 'sharing' ? types.filter(type => type !== 'multi_agent') : types).map((type) => ({
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))}
|
||||
@@ -185,7 +185,8 @@ const ApplicationManagement: React.FC = () => {
|
||||
<PageScrollList<Application, Query>
|
||||
ref={scrollListRef}
|
||||
url={getApplicationListUrl}
|
||||
query={{ ...query, shared_only: activeTab === 'sharing' }}
|
||||
needLoading={false}
|
||||
query={{ ...query, shared_only: activeTab === 'sharing', include_shared: activeTab !== 'apps' }}
|
||||
renderItem={(item) => (
|
||||
<RbCard
|
||||
title={item.name}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-16 09:55:52
|
||||
* @Last Modified time: 2026-03-18 10:50:27
|
||||
*/
|
||||
/**
|
||||
* Type definitions for Application Management
|
||||
@@ -16,6 +16,7 @@ export interface Query {
|
||||
search: string;
|
||||
type?: string;
|
||||
shared_only?: boolean;
|
||||
include_shared?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 12:20:43
|
||||
* @Last Modified time: 2026-03-19 18:38:41
|
||||
*/
|
||||
/**
|
||||
* File Upload Component
|
||||
@@ -20,14 +20,15 @@
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle, useMemo } from 'react';
|
||||
import { Upload, Progress, App } from 'antd';
|
||||
import type { UploadProps, UploadFile } from 'antd';
|
||||
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||
import type { UploadProps as RcUploadProps, RcFile, UploadFileStatus } from 'antd/es/upload/interface';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
/** Upload API endpoint */
|
||||
@@ -48,14 +49,14 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
disabled?: boolean;
|
||||
/** File size limit in MB */
|
||||
fileSize?: number;
|
||||
/** Allowed file types ['doc', 'xls', 'ppt', 'pdf'] */
|
||||
fileType?: string[];
|
||||
/** Auto-upload on file selection, default is true */
|
||||
isAutoUpload?: boolean;
|
||||
/** Maximum number of files allowed */
|
||||
maxCount?: number;
|
||||
/** Custom file removal callback */
|
||||
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
|
||||
|
||||
featureConfig: FeaturesConfigForm['file_upload']
|
||||
}
|
||||
|
||||
const transform_file_type = {
|
||||
@@ -70,6 +71,12 @@ const transform_file_type = {
|
||||
|
||||
'application/vnd.ms-powerpoint': 'document/ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document/pptx',
|
||||
|
||||
'application/vnd.ms-excel': 'document/xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document/xlsx',
|
||||
'text/csv': 'document/csv',
|
||||
|
||||
'application/json': 'document/json'
|
||||
}
|
||||
// Mapping of file extensions to MIME types
|
||||
const ALL_FILE_TYPE: {
|
||||
@@ -87,6 +94,13 @@ const ALL_FILE_TYPE: {
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
|
||||
csv: 'text/csv',
|
||||
|
||||
json: 'application/json',
|
||||
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
@@ -130,11 +144,11 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
onChange,
|
||||
disabled = false,
|
||||
fileSize = 5,
|
||||
fileType = Object.entries(ALL_FILE_TYPE).map(([key]) => key),
|
||||
isAutoUpload = true,
|
||||
maxCount = 1,
|
||||
onRemove: customOnRemove,
|
||||
requestConfig,
|
||||
featureConfig,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -142,18 +156,37 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
const [fileList, setFileList] = useState<UploadFile[]>(propFileList);
|
||||
const [accept, setAccept] = useState<string | undefined>();
|
||||
|
||||
const fileType = useMemo(() => {
|
||||
let types: string[] = [];
|
||||
['image', 'document', 'video', 'audio'].forEach(type => {
|
||||
if (featureConfig[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) {
|
||||
types = types.concat(featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[])
|
||||
}
|
||||
})
|
||||
|
||||
return types
|
||||
}, [featureConfig])
|
||||
|
||||
/**
|
||||
* Validates file type and size before upload
|
||||
* @returns Upload.LIST_IGNORE to prevent upload, or true to proceed
|
||||
*/
|
||||
const beforeUpload: RcUploadProps['beforeUpload'] = (file) => {
|
||||
// Validate file size
|
||||
if (fileSize) {
|
||||
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(t('common.fileSizeTip', { size: fileSize }));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
// Determine file category and get max size from featureConfig
|
||||
const mimePrefix = file.type?.split('/')[0]
|
||||
const categoryMap: Record<string, keyof FeaturesConfigForm['file_upload']> = {
|
||||
image: 'image_max_size_mb',
|
||||
video: 'video_max_size_mb',
|
||||
audio: 'audio_max_size_mb',
|
||||
}
|
||||
const maxSizeKey = categoryMap[mimePrefix] ?? 'document_max_size_mb'
|
||||
const maxSize = (featureConfig[maxSizeKey] as number) ?? fileSize
|
||||
|
||||
const fileSizeMB = file.size / 1024 / 1024
|
||||
const isLtMaxSize = fileSizeMB < maxSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(t('common.fileSizeTip', { size: maxSize }));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
// Validate file type
|
||||
if (fileType && fileType.length > 0) {
|
||||
@@ -188,17 +221,29 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
*/
|
||||
const handleCustomRequest: RcUploadProps['customRequest'] = async (options) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await request.uploadFile(action, formData, requestConfig);
|
||||
|
||||
onSuccess?.({data: response});
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
if (typeof file === 'string') return;
|
||||
const rcFile = file as RcFile;
|
||||
const formData = new FormData();
|
||||
formData.append('file', rcFile);
|
||||
const fileVo: UploadFile = {
|
||||
uid: rcFile.uid,
|
||||
name: rcFile.name,
|
||||
status: 'uploading' as UploadFileStatus,
|
||||
percent: 0,
|
||||
type: rcFile.type,
|
||||
originFileObj: rcFile,
|
||||
thumbUrl: URL.createObjectURL(rcFile)
|
||||
}
|
||||
onChange?.(fileVo)
|
||||
request.uploadFile(action, formData, requestConfig)
|
||||
.then(res => {
|
||||
onSuccess?.({ data: res });
|
||||
})
|
||||
.catch((error) => {
|
||||
onError?.(error as Error);
|
||||
fileVo.status = 'error'
|
||||
onChange?.(fileVo)
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 17:47:09
|
||||
* @Last Modified time: 2026-03-19 20:32:32
|
||||
*/
|
||||
/**
|
||||
* Upload File List Modal Component
|
||||
@@ -18,25 +18,31 @@
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, Select, Button, Flex } from 'antd';
|
||||
import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
|
||||
import { Form, Input, Select,
|
||||
// Button,
|
||||
Flex
|
||||
} from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UploadFileListModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface UploadFileListModalProps {
|
||||
/** Callback to refresh parent component with new file list */
|
||||
refresh: (fileList?: any[]) => void;
|
||||
featureConfig: FeaturesConfigForm['file_upload']
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for adding remote files via URL
|
||||
*/
|
||||
const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListModalProps>(({
|
||||
refresh
|
||||
refresh,
|
||||
featureConfig
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -79,6 +85,20 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
handleOpen
|
||||
}));
|
||||
|
||||
const fileTypeOptions = useMemo(() => {
|
||||
const options = [];
|
||||
if (featureConfig?.image_enabled) {
|
||||
options.push({ label: t('memoryConversation.image'), value: 'image' });
|
||||
}
|
||||
if (featureConfig?.audio_enabled) {
|
||||
options.push({ label: t('memoryConversation.audio'), value: 'audio' });
|
||||
}
|
||||
if (featureConfig?.video_enabled) {
|
||||
options.push({ label: t('memoryConversation.video'), value: 'video' });
|
||||
}
|
||||
return options;
|
||||
}, [featureConfig, t])
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('memoryConversation.addRemoteFile')}
|
||||
@@ -88,9 +108,11 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form form={form} layout="vertical" initialValues={{ files: [{ type: undefined, url: undefined }] }}>
|
||||
<Form.List name="files">
|
||||
{(fields, { add, remove }) => (
|
||||
{(fields,
|
||||
// { add, remove }
|
||||
) => (
|
||||
<>
|
||||
{/* Render each file entry with type selector and URL input */}
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
@@ -98,38 +120,39 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'type']}
|
||||
initialValue="image"
|
||||
className="rb:mb-0!"
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseSelect') }
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder={t('memoryConversation.fileType')}
|
||||
options={[
|
||||
{ label: t('memoryConversation.image'), value: 'image' },
|
||||
{ label: t('memoryConversation.audio'), value: 'audio' },
|
||||
{ label: t('memoryConversation.video'), value: 'video' },
|
||||
]}
|
||||
className="rb:w-30"
|
||||
options={fileTypeOptions}
|
||||
className="rb:w-30!"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'url']}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
className="rb:mb-0!"
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ type: 'url', message: t('common.callbackUrlInvalid') },
|
||||
]}
|
||||
className="rb:mb-0! rb:flex-1!"
|
||||
>
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} className="rb:w-82.5!" />
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} />
|
||||
</FormItem>
|
||||
<div
|
||||
{/* <div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
|
||||
onClick={() => remove(name)}
|
||||
></div>
|
||||
></div> */}
|
||||
</Flex>
|
||||
))}
|
||||
<Form.Item noStyle>
|
||||
{/* <Form.Item noStyle>
|
||||
<Button type="dashed" onClick={() => add()} block>
|
||||
+ {t('common.add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form.Item> */}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:58:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 12:10:44
|
||||
* @Last Modified time: 2026-03-19 12:30:41
|
||||
*/
|
||||
/**
|
||||
* Conversation Page
|
||||
@@ -14,13 +14,12 @@ import { type FC, useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
import { Flex, Skeleton, Form, Dropdown, type MenuProps, App, Divider } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { Flex, Skeleton, App } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken, getExperienceConfig } from '@/api/application'
|
||||
import type { HistoryItem, QueryParams, UploadFileListModalRef } from './types'
|
||||
import type { HistoryItem } from './types'
|
||||
import Empty from '@/components/Empty'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import { randomString } from '@/utils/common'
|
||||
@@ -34,20 +33,14 @@ import OnlineIcon from '@/assets/images/conversation/online.svg'
|
||||
import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
|
||||
import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import UploadFiles from './components/FileUpload'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import UploadFileListModal from './components/UploadFileListModal'
|
||||
import type { VariableConfigModalRef } from '@/views/Workflow/types'
|
||||
import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal';
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
/**
|
||||
* Conversation component for shared applications
|
||||
*/
|
||||
const Conversation: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const { message: messageApi, modal } = App.useApp()
|
||||
const { token } = useParams()
|
||||
const location = useLocation()
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
@@ -63,35 +56,22 @@ const Conversation: FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
const [shareToken, setShareToken] = useState<string | null>(localStorage.getItem(`shareToken_${token}`))
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [webSearch, setWebSearch] = useState(false)
|
||||
const [isHasMemory, setIsHasMemory] = useState(false)
|
||||
const [memory, setMemory] = useState(true)
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
const [config, setConfig] = useState<Record<string, any>>({})
|
||||
|
||||
const [form] = Form.useForm<QueryParams>()
|
||||
const queryValues = Form.useWatch<QueryParams>([], form)
|
||||
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
|
||||
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
setVariables([...values])
|
||||
}
|
||||
useEffect(() => {
|
||||
const shareToken = localStorage.getItem(`shareToken_${token}`)
|
||||
setShareToken(shareToken)
|
||||
if (shareToken && shareToken !== '') return
|
||||
getShareToken(token as string, userId || randomString(12, false))
|
||||
.then(res => {
|
||||
const response = res as { access_token: string } || {}
|
||||
const response = res as { access_token: string } || {}
|
||||
localStorage.setItem(`shareToken_${token}`, response.access_token ?? '')
|
||||
setShareToken(response.access_token ?? '')
|
||||
})
|
||||
@@ -102,12 +82,16 @@ const Conversation: FC = () => {
|
||||
getHistory()
|
||||
}
|
||||
}, [token, shareToken, page, hasMore, historyList])
|
||||
|
||||
useEffect(() => {
|
||||
if (shareToken && token) {
|
||||
getExperienceConfig(token)
|
||||
.then(res => {
|
||||
const response = res as { variables: Variable[] }
|
||||
setVariables(response.variables || [])
|
||||
const response = res as { variables: Variable[]; features: FeaturesConfigForm; app_type: string; memory?: boolean; }
|
||||
toolbarRef.current?.setVariables(response.variables || [])
|
||||
setConfig(response)
|
||||
setFeatures(response.features)
|
||||
setIsHasMemory((response.app_type === 'workflow' && response.memory) || (response.app_type !== 'workflow'))
|
||||
})
|
||||
} else {
|
||||
setChatList([])
|
||||
@@ -118,7 +102,7 @@ const Conversation: FC = () => {
|
||||
const groupHistoryByDate = (items: HistoryItem[]): Record<string, HistoryItem[]> => {
|
||||
return items.reduce((groups: Record<string, HistoryItem[]>, item) => {
|
||||
const date = formatDateTime(item.created_at, 'YYYY-MM-DD')
|
||||
|
||||
|
||||
if (!groups[date]) {
|
||||
groups[date] = [];
|
||||
}
|
||||
@@ -129,9 +113,7 @@ const Conversation: FC = () => {
|
||||
|
||||
/** Fetch conversation history with pagination */
|
||||
const getHistory = (flag: boolean = false) => {
|
||||
if (!token || (pageLoading || !hasMore) && !flag) {
|
||||
return
|
||||
}
|
||||
if (!token || (pageLoading || !hasMore) && !flag) return
|
||||
setPageLoading(true);
|
||||
getConversationHistory(token, { page: flag ? 1 : page, pagesize: 20 })
|
||||
.then(res => {
|
||||
@@ -154,19 +136,14 @@ const Conversation: FC = () => {
|
||||
setHasMore(response.page.hasnext);
|
||||
setLoading(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setPageLoading(false);
|
||||
})
|
||||
.finally(() => setPageLoading(false))
|
||||
}
|
||||
/** Switch to different conversation or start new one */
|
||||
const handleChangeHistory = (id: string | null) => {
|
||||
if (id !== conversation_id) {
|
||||
setConversationId(id)
|
||||
}
|
||||
if (!id) {
|
||||
setMessage('')
|
||||
}
|
||||
if (id !== conversation_id) setConversationId(id)
|
||||
if (!id) setMessage('')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (conversation_id) {
|
||||
getConversationDetail(token as string, conversation_id)
|
||||
@@ -179,43 +156,40 @@ const Conversation: FC = () => {
|
||||
}
|
||||
}, [conversation_id])
|
||||
|
||||
/** Add user message to chat */
|
||||
const addUserMessage = (message: string = '', files?: any[]) => {
|
||||
const newUserMessage: ChatItem = {
|
||||
setChatList(prev => [...prev, {
|
||||
conversation_id,
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
};
|
||||
setChatList(prev => [...prev, newUserMessage])
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
}])
|
||||
}
|
||||
/** Add empty assistant message placeholder */
|
||||
|
||||
const addAssistantMessage = () => {
|
||||
const newAssistantMessage: ChatItem = {
|
||||
setChatList(prev => [...prev, {
|
||||
created_at: Date.now(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
}
|
||||
setChatList(prev => [...prev, newAssistantMessage])
|
||||
content: ''
|
||||
}])
|
||||
}
|
||||
/** Update assistant message with streaming content */
|
||||
const updateAssistantMessage = (content: string = '') => {
|
||||
if (!content) return
|
||||
if (streamLoading) {
|
||||
setStreamLoading(false)
|
||||
}
|
||||
|
||||
const updateAssistantMessage = (content: string = '', audio_url?: string) => {
|
||||
if (!content && !audio_url) return
|
||||
if (streamLoading) setStreamLoading(false)
|
||||
setChatList(prev => {
|
||||
const lastList = [...prev]
|
||||
const lastIndex = lastList.length - 1
|
||||
const lastMsg = lastList[lastIndex]
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
return [
|
||||
...lastList.slice(0, lastList.length - 1),
|
||||
...lastList.slice(0, lastIndex),
|
||||
{
|
||||
...lastMsg,
|
||||
content: lastMsg.content + content
|
||||
content: lastMsg.content + content,
|
||||
meta_data: { audio_url }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -223,22 +197,17 @@ const Conversation: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const isNeedVariableConfig = variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
|
||||
|
||||
/** Send message and handle streaming response */
|
||||
const handleSend = () => {
|
||||
if (!token || !shareToken) {
|
||||
return
|
||||
}
|
||||
const { files = [], ...rest } = queryValues || {}
|
||||
// Validate required variables before sending
|
||||
if (!token || !shareToken) return
|
||||
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
|
||||
const variables = toolbarRef.current?.getVariables() || []
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value ?? vo.defaultValue
|
||||
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
@@ -249,33 +218,34 @@ const Conversation: FC = () => {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
return
|
||||
}
|
||||
if (!isCanSend) return
|
||||
|
||||
setLoading(true)
|
||||
setStreamLoading(true)
|
||||
addUserMessage(message, files)
|
||||
addAssistantMessage()
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
|
||||
let currentConversationId: string | null = null
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach((item) => {
|
||||
switch(item.event) {
|
||||
const { content, conversation_id: curId, audio_url } = item.data as { content: string; conversation_id: string; audio_url?: string; }
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
case 'node_start':
|
||||
const { conversation_id: newId } = item.data as { conversation_id: string }
|
||||
const { conversation_id: newId } = item.data as { conversation_id: string }
|
||||
currentConversationId = newId
|
||||
break
|
||||
case 'message':
|
||||
const { content, conversation_id: curId } = item.data as { content: string; conversation_id: string; }
|
||||
updateAssistantMessage(content)
|
||||
|
||||
if (curId) {
|
||||
currentConversationId = curId;
|
||||
}
|
||||
updateAssistantMessage(content, audio_url)
|
||||
if (curId) currentConversationId = curId;
|
||||
break
|
||||
case 'end':
|
||||
case 'workflow_end':
|
||||
if (audio_url) {
|
||||
updateAssistantMessage(content, audio_url)
|
||||
}
|
||||
setLoading(false)
|
||||
if (currentConversationId && currentConversationId !== conversation_id) {
|
||||
setConversationId(currentConversationId)
|
||||
@@ -286,9 +256,9 @@ const Conversation: FC = () => {
|
||||
})
|
||||
};
|
||||
|
||||
form.setFieldValue('files', [])
|
||||
sendConversation({
|
||||
...rest,
|
||||
web_search: webSearch,
|
||||
memory,
|
||||
message: message || '',
|
||||
stream: true,
|
||||
conversation_id: conversation_id || null,
|
||||
@@ -315,32 +285,19 @@ const Conversation: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const fileChange = (file?: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), {
|
||||
uid: file.file_id,
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
const addFileList = (fileList?: any[]) => {
|
||||
if (!fileList || fileList.length <= 0) return
|
||||
form.setFieldValue('files', [...(queryValues.files || []), ...fileList])
|
||||
}
|
||||
const updateFileList = (fileList?: any[]) => {
|
||||
console.log('fileList', fileList)
|
||||
form.setFieldValue('files', [...(fileList || [])])
|
||||
const handleChangeMemory = (value: boolean) => {
|
||||
if (config.app_type === 'workflow') return;
|
||||
modal.confirm({
|
||||
title: value ? t('memoryConversation.memoryTipTitle') : t('memoryConversation.memoryCancelTipTitle'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
setMemory(value)
|
||||
},
|
||||
onCancel: () => {
|
||||
setMemory(!value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -349,8 +306,8 @@ const Conversation: FC = () => {
|
||||
<div className="rb:group rb:flex rb:items-center rb:justify-center rb:font-regular rb:cursor-pointer rb:mb-5 rb:border rb:border-[#DFE4ED] rb:hover:border-[#155EEF] rb:hover:text-[#155EEF] rb:rounded-lg rb:py-2.5"
|
||||
onClick={() => handleChangeHistory(null)}
|
||||
>
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:mr-2 rb:bg-cover rb:bg-[url('@/assets/images/conversation/conversation.svg')] rb:group-hover:bg-[url('@/assets/images/conversation/conversation_hover.svg')]"
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:mr-2 rb:bg-cover rb:bg-[url('@/assets/images/conversation/conversation.svg')] rb:group-hover:bg-[url('@/assets/images/conversation/conversation_hover.svg')]"
|
||||
></div>
|
||||
{t('memoryConversation.startANewConversation')}
|
||||
</div>
|
||||
@@ -365,7 +322,6 @@ const Conversation: FC = () => {
|
||||
next={getHistory}
|
||||
hasMore={hasMore}
|
||||
loader={<Skeleton active />}
|
||||
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
|
||||
scrollableTarget="scrollableDiv"
|
||||
>
|
||||
{Object.entries(groupHistoryList).map(([date, items]) => (
|
||||
@@ -374,8 +330,8 @@ const Conversation: FC = () => {
|
||||
{items.map(item => (
|
||||
<div key={item.updated_at} className="rb:mb-3">
|
||||
<div className={clsx("rb:p-[8px_13px] rb:rounded-lg rb:leading-5 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
|
||||
'rb:bg-[#FFFFFF] rb:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] rb:font-medium rb:hover:bg-[#FFFFFF]!': item.id === conversation_id,
|
||||
})}
|
||||
'rb:bg-[#FFFFFF] rb:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] rb:font-medium rb:hover:bg-[#FFFFFF]!': item.id === conversation_id,
|
||||
})}
|
||||
onClick={() => handleChangeHistory(item.id)}
|
||||
>
|
||||
{item.title}
|
||||
@@ -391,109 +347,63 @@ const Conversation: FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="rb:relative rb:h-screen rb:px-4 rb:flex-[1_1_auto]">
|
||||
<div className='rb:w-190 rb:h-screen rb:mx-auto rb:pt-10'>
|
||||
<Chat
|
||||
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320,180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
|
||||
contentClassName={!queryValues?.files?.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={handleSend}
|
||||
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
fileList={queryValues?.files || []}
|
||||
fileChange={updateFileList}
|
||||
>
|
||||
<Form form={form} initialValues={{ memory: false, web_search: false}}>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Form.Item name="files" noStyle>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
action={shareFileUploadUrlWithoutApiPrefix}
|
||||
onChange={fileChange}
|
||||
requestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||
<div className='rb:w-190 rb:h-screen rb:mx-auto rb:pt-10'>
|
||||
<Chat
|
||||
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320, 180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
|
||||
contentClassName={!fileList.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={handleSend}
|
||||
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
fileList={fileList}
|
||||
fileChange={(list) => {
|
||||
setFileList(list || [])
|
||||
toolbarRef.current?.setFiles(list || [])
|
||||
}}
|
||||
>
|
||||
<ChatToolbar
|
||||
ref={toolbarRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
uploadAction={shareFileUploadUrlWithoutApiPrefix}
|
||||
uploadRequestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}
|
||||
}}
|
||||
extra={
|
||||
<>
|
||||
{features?.web_search?.enabled &&
|
||||
<ButtonCheckbox
|
||||
icon={OnlineIcon}
|
||||
checkedIcon={OnlineCheckedIcon}
|
||||
checked={webSearch}
|
||||
onChange={setWebSearch}
|
||||
>
|
||||
{t(`memoryConversation.web_search`)}
|
||||
{t('memoryConversation.web_search')}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
|
||||
}
|
||||
{isHasMemory &&
|
||||
<ButtonCheckbox
|
||||
icon={MemoryFunctionIcon}
|
||||
checkedIcon={MemoryFunctionCheckedIcon}
|
||||
checked={memory}
|
||||
disabled={config.app_type === 'workflow'}
|
||||
onChange={handleChangeMemory}
|
||||
>
|
||||
{t(`memoryConversation.memory`)}
|
||||
{t('memoryConversation.memory')}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
{variables.length > 0 && (
|
||||
<Form.Item name="variables" className="rb:mb-0!">
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t(`memoryConversation.variableConfig`)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder
|
||||
action={shareFileUploadUrlWithoutApiPrefix}
|
||||
requestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}
|
||||
}}
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
/>
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Chat>
|
||||
}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Chat>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleSave}
|
||||
variables={variables}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
export default Conversation
|
||||
export default Conversation
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user