From 60a95f655661e5cd0d22464ed63d943054c00759 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 15:02:01 +0800 Subject: [PATCH 01/89] [changes] --- api/app/cache/__init__.py | 4 +--- api/app/cache/memory/__init__.py | 4 ---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/api/app/cache/__init__.py b/api/app/cache/__init__.py index 5300348c..ca6a8784 100644 --- a/api/app/cache/__init__.py +++ b/api/app/cache/__init__.py @@ -4,10 +4,8 @@ Cache 缓存模块 提供各种缓存功能的统一入口 注意:隐性记忆和情绪建议已迁移到数据库存储,不再使用Redis缓存 """ -from .memory import EmotionMemoryCache, ImplicitMemoryCache, InterestMemoryCache +from .memory import InterestMemoryCache __all__ = [ - "EmotionMemoryCache", - "ImplicitMemoryCache", "InterestMemoryCache", ] diff --git a/api/app/cache/memory/__init__.py b/api/app/cache/memory/__init__.py index 46ad0b73..7bc86068 100644 --- a/api/app/cache/memory/__init__.py +++ b/api/app/cache/memory/__init__.py @@ -4,12 +4,8 @@ Memory 缓存模块 提供记忆系统相关的缓存功能 注意:隐性记忆和情绪建议已迁移到数据库存储,不再使用Redis缓存 """ -from .emotion_memory import EmotionMemoryCache -from .implicit_memory import ImplicitMemoryCache from .interest_memory import InterestMemoryCache __all__ = [ - "EmotionMemoryCache", - "ImplicitMemoryCache", "InterestMemoryCache", ] From 2cb6aeb0224f8e4b10f299fc819649807f59374b Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 16:16:55 +0800 Subject: [PATCH 02/89] [fix] The interface returns "is_system_default" --- api/app/controllers/ontology_controller.py | 4 +- .../controllers/ontology_secondary_routes.py | 61 ++++++++----------- api/app/schemas/ontology_schemas.py | 1 + 3 files changed, 30 insertions(+), 36 deletions(-) diff --git a/api/app/controllers/ontology_controller.py b/api/app/controllers/ontology_controller.py index c892b013..74cad2db 100644 --- a/api/app/controllers/ontology_controller.py +++ b/api/app/controllers/ontology_controller.py @@ -612,7 +612,7 @@ async def get_scenes( workspace_id: Optional[str] = None, scene_name: Optional[str] = None, page: Optional[int] = None, - pagesize: Optional[int] = None, + page_size: Optional[int] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -652,7 +652,7 @@ async def get_scenes( - 不分页时,page 字段为 null """ from app.controllers.ontology_secondary_routes import scenes_handler - return await scenes_handler(workspace_id, scene_name, page, pagesize, db, current_user) + return await scenes_handler(workspace_id, scene_name, page, page_size, db, current_user) # ==================== 本体类型管理接口 ==================== diff --git a/api/app/controllers/ontology_secondary_routes.py b/api/app/controllers/ontology_secondary_routes.py index 607a0739..600aacc7 100644 --- a/api/app/controllers/ontology_secondary_routes.py +++ b/api/app/controllers/ontology_secondary_routes.py @@ -58,7 +58,7 @@ async def scenes_handler( workspace_id: Optional[str] = None, scene_name: Optional[str] = None, page: Optional[int] = None, - page_size: Optional[int] = None, + pagesize: Optional[int] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -71,14 +71,14 @@ async def scenes_handler( workspace_id: 工作空间ID(可选,默认当前用户工作空间) scene_name: 场景名称关键词(可选,支持模糊匹配) page: 页码(可选,从1开始,仅在全量查询时有效) - page_size: 每页数量(可选,仅在全量查询时有效) + pagesize: 每页数量(可选,仅在全量查询时有效) db: 数据库会话 current_user: 当前用户 """ operation = "search" if scene_name else "list" api_logger.info( f"Scene {operation} requested by user {current_user.id}, " - f"workspace_id={workspace_id}, keyword={scene_name}, page={page}, page_size={page_size}" + f"workspace_id={workspace_id}, keyword={scene_name}, page={page}, pagesize={pagesize}" ) try: @@ -105,13 +105,13 @@ async def scenes_handler( api_logger.warning(f"Invalid page number: {page}") return fail(BizCode.BAD_REQUEST, "请求参数无效", "页码必须大于0") - if page_size is not None and page_size < 1: - api_logger.warning(f"Invalid page_size: {page_size}") + if pagesize is not None and pagesize < 1: + api_logger.warning(f"Invalid pagesize: {pagesize}") return fail(BizCode.BAD_REQUEST, "请求参数无效", "每页数量必须大于0") - # 如果只提供了page或page_size中的一个,返回错误 - if (page is not None and page_size is None) or (page is None and page_size is not None): - api_logger.warning(f"Incomplete pagination params: page={page}, page_size={page_size}") + # 如果只提供了page或pagesize中的一个,返回错误 + if (page is not None and pagesize is None) or (page is None and pagesize is not None): + api_logger.warning(f"Incomplete pagination params: page={page}, pagesize={pagesize}") return fail(BizCode.BAD_REQUEST, "请求参数无效", "分页参数page和pagesize必须同时提供") # 模糊搜索场景(支持分页) @@ -119,17 +119,15 @@ async def scenes_handler( total = len(scenes) # 如果提供了分页参数,进行分页处理 - if page is not None and page_size is not None: - start_idx = (page - 1) * page_size - end_idx = start_idx + page_size + if page is not None and pagesize is not None: + start_idx = (page - 1) * pagesize + end_idx = start_idx + pagesize scenes = scenes[start_idx:end_idx] # 构建响应 items = [] for scene in scenes: - # 获取前3个class_name作为entity_type entity_type = [cls.class_name for cls in scene.classes[:3]] if scene.classes else None - # 动态计算 type_num type_num = len(scene.classes) if scene.classes else 0 items.append(SceneResponse( @@ -141,17 +139,16 @@ async def scenes_handler( workspace_id=scene.workspace_id, created_at=scene.created_at, updated_at=scene.updated_at, - classes_count=type_num + classes_count=type_num, + is_system_default=scene.is_system_default )) # 构建响应(包含分页信息) - if page is not None and page_size is not None: - # 计算是否有下一页 - hasnext = (page * page_size) < total - + if page is not None and pagesize is not None: + hasnext = (page * pagesize) < total pagination_info = PaginationInfo( page=page, - pagesize=page_size, + pagesize=pagesize, total=total, hasnext=hasnext ) @@ -165,28 +162,25 @@ async def scenes_handler( ) else: # 获取所有场景(支持分页) - # 验证分页参数 if page is not None and page < 1: api_logger.warning(f"Invalid page number: {page}") return fail(BizCode.BAD_REQUEST, "请求参数无效", "页码必须大于0") - if page_size is not None and page_size < 1: - api_logger.warning(f"Invalid page_size: {page_size}") + if pagesize is not None and pagesize < 1: + api_logger.warning(f"Invalid pagesize: {pagesize}") return fail(BizCode.BAD_REQUEST, "请求参数无效", "每页数量必须大于0") - # 如果只提供了page或page_size中的一个,返回错误 - if (page is not None and page_size is None) or (page is None and page_size is not None): - api_logger.warning(f"Incomplete pagination params: page={page}, page_size={page_size}") + # 如果只提供了page或pagesize中的一个,返回错误 + if (page is not None and pagesize is None) or (page is None and pagesize is not None): + api_logger.warning(f"Incomplete pagination params: page={page}, pagesize={pagesize}") return fail(BizCode.BAD_REQUEST, "请求参数无效", "分页参数page和pagesize必须同时提供") - scenes, total = service.list_scenes(ws_uuid, page, page_size) + scenes, total = service.list_scenes(ws_uuid, page, pagesize) # 构建响应 items = [] for scene in scenes: - # 获取前3个class_name作为entity_type entity_type = [cls.class_name for cls in scene.classes[:3]] if scene.classes else None - # 动态计算 type_num type_num = len(scene.classes) if scene.classes else 0 items.append(SceneResponse( @@ -198,17 +192,16 @@ async def scenes_handler( workspace_id=scene.workspace_id, created_at=scene.created_at, updated_at=scene.updated_at, - classes_count=type_num + classes_count=type_num, + is_system_default=scene.is_system_default )) # 构建响应(包含分页信息) - if page is not None and page_size is not None: - # 计算是否有下一页 - hasnext = (page * page_size) < total - + if page is not None and pagesize is not None: + hasnext = (page * pagesize) < total pagination_info = PaginationInfo( page=page, - pagesize=page_size, + pagesize=pagesize, total=total, hasnext=hasnext ) diff --git a/api/app/schemas/ontology_schemas.py b/api/app/schemas/ontology_schemas.py index 88ecd712..718c54eb 100644 --- a/api/app/schemas/ontology_schemas.py +++ b/api/app/schemas/ontology_schemas.py @@ -241,6 +241,7 @@ class SceneResponse(BaseModel): created_at: datetime.datetime = Field(..., description="创建时间(毫秒时间戳)") updated_at: datetime.datetime = Field(..., description="更新时间(毫秒时间戳)") classes_count: int = Field(0, description="类型数量") + is_system_default: bool = Field(False, description="是否为系统默认场景") @field_serializer("created_at", when_used="json") def _serialize_created_at(self, dt: datetime.datetime): From 6c2fc75199618f0e58911cce105ca793f66455f1 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 16:48:50 +0800 Subject: [PATCH 03/89] [fix] Memory configuration, addition of default identifiers for the ontology scene --- api/app/controllers/ontology_controller.py | 4 ++-- api/app/services/memory_storage_service.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/app/controllers/ontology_controller.py b/api/app/controllers/ontology_controller.py index 74cad2db..c892b013 100644 --- a/api/app/controllers/ontology_controller.py +++ b/api/app/controllers/ontology_controller.py @@ -612,7 +612,7 @@ async def get_scenes( workspace_id: Optional[str] = None, scene_name: Optional[str] = None, page: Optional[int] = None, - page_size: Optional[int] = None, + pagesize: Optional[int] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): @@ -652,7 +652,7 @@ async def get_scenes( - 不分页时,page 字段为 null """ from app.controllers.ontology_secondary_routes import scenes_handler - return await scenes_handler(workspace_id, scene_name, page, page_size, db, current_user) + return await scenes_handler(workspace_id, scene_name, page, pagesize, db, current_user) # ==================== 本体类型管理接口 ==================== diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 1083f750..beedaae9 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -211,6 +211,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) "apply_id": config.apply_id, "scene_id": str(config.scene_id) if config.scene_id else None, "scene_name": scene_name, # 新增:场景名称 + "is_system_default": config.is_default, # 是否为系统默认配置 "llm_id": config.llm_id, "embedding_id": config.embedding_id, "rerank_id": config.rerank_id, From 80fa88ac3781b559b0c08efda7f5a5eb2a707674 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 5 Mar 2026 17:05:48 +0800 Subject: [PATCH 04/89] fix(web): adjust variable validation timing during Agent debugging --- .../ApplicationConfig/components/Chat.tsx | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx index 8a74f25f..17af7613 100644 --- a/web/src/views/ApplicationConfig/components/Chat.tsx +++ b/web/src/views/ApplicationConfig/components/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:27:39 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-04 18:51:20 + * @Last Modified time: 2026-03-05 17:03:46 */ /** * Chat debugging component for application testing @@ -171,6 +171,29 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc .then(() => { const message = msg if (!message?.trim()) return + // Validate required variables before sending + let isCanSend = true + const params: Record = {} + if (chatVariables && chatVariables.length > 0) { + const needRequired: string[] = [] + chatVariables.forEach(vo => { + params[vo.name] = vo.value + + 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) + setCompareLoading(false) + return + } addUserMessage(message, fileList) setMessage(message) @@ -198,29 +221,6 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc }; setTimeout(() => { - // Validate required variables before sending - let isCanSend = true - const params: Record = {} - if (chatVariables && chatVariables.length > 0) { - const needRequired: string[] = [] - chatVariables.forEach(vo => { - params[vo.name] = vo.value - - 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) - setCompareLoading(false) - return - } runCompare(data.app_id, { message, files: fileList.map(file => { From 139ae3bcb4d99ad3bb169950a2f31af177729d70 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Thu, 5 Mar 2026 17:08:09 +0800 Subject: [PATCH 05/89] fix(tool and api key) 1. Tool name duplication check; 2. The default QPS value of API key is set to 100. --- api/app/schemas/api_key_schema.py | 2 +- api/app/services/tool_service.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/api/app/schemas/api_key_schema.py b/api/app/schemas/api_key_schema.py index 323c1a69..c7ca1e55 100644 --- a/api/app/schemas/api_key_schema.py +++ b/api/app/schemas/api_key_schema.py @@ -15,7 +15,7 @@ class ApiKeyCreate(BaseModel): type: ApiKeyType = Field(..., description="API Key 类型") scopes: List[str] = Field(default_factory=list, description="权限范围列表") resource_id: Optional[uuid.UUID] = Field(None, description="关联资源ID") - rate_limit: Optional[int] = Field(10, ge=1, le=1000, description="QPS限制(请求/秒)") + rate_limit: Optional[int] = Field(100, ge=1, le=1000, description="QPS限制(请求/秒)") daily_request_limit: Optional[int] = Field(10000, description="日请求限制", ge=1) quota_limit: Optional[int] = Field(None, description="配额限制(总请求数)", ge=1) expires_at: Optional[datetime.datetime] = Field(None, description="过期时间") diff --git a/api/app/services/tool_service.py b/api/app/services/tool_service.py index d2400ded..f6e2ccce 100644 --- a/api/app/services/tool_service.py +++ b/api/app/services/tool_service.py @@ -8,6 +8,8 @@ from datetime import datetime from sqlalchemy.orm import Session +from app.core.error_codes import BizCode +from app.core.exceptions import BusinessException from app.core.tools.mcp import MCPToolManager, SimpleMCPClient from app.repositories.tool_repository import ( ToolRepository, BuiltinToolRepository, CustomToolRepository, @@ -79,6 +81,18 @@ class ToolService: config = self.tool_repo.find_by_id_and_tenant(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): + """检查工具名称是否重复""" + query = self.db.query(ToolConfig).filter( + ToolConfig.name == name, + ToolConfig.tool_type == tool_type.value, + ToolConfig.tenant_id == tenant_id + ) + if exclude_id: + query = query.filter(ToolConfig.id != exclude_id) + if query.first(): + raise BusinessException(f"工具名称 '{name}' 已存在", BizCode.DUPLICATE_NAME) + def create_tool( self, name: str, @@ -92,6 +106,7 @@ class ToolService: """创建工具""" if tool_type == ToolType.BUILTIN: raise ValueError("内置工具不允许创建") + self._check_name_duplicate(name, tool_type, tenant_id) try: # 创建基础配置 @@ -141,6 +156,7 @@ class ToolService: raise ValueError("内置工具不允许修改名称、描述和图标") try: if name: + self._check_name_duplicate(name, config_obj.tool_type, tenant_id, exclude_id=config_obj.id) config_obj.name = name if description: config_obj.description = description From 8422a05d74d5ebab024eb2181005c88e5aa609fb Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 17:22:18 +0800 Subject: [PATCH 06/89] [add] Added checks for idempotency of the ontology project --- api/app/services/workspace_service.py | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 2f8cdc70..e93c0c5c 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -107,6 +107,7 @@ def get_user_workspaces(db: Session, user: User) -> List[Workspace]: for workspace in workspaces: if workspace.storage_type == 'neo4j': _ensure_default_memory_config(db, workspace) + _ensure_default_ontology_scenes(db, workspace) business_logger.info(f"用户 {user.username} 的工作空间数量: {len(workspaces)}") return workspaces @@ -1104,6 +1105,52 @@ def _fill_workspace_configs_model_defaults( ) +def _ensure_default_ontology_scenes(db: Session, workspace: Workspace) -> None: + """Ensure a workspace has default ontology scenes, creating them if missing. + + Checks whether any is_system_default scene exists for the workspace. + If not, runs the DefaultOntologyInitializer to create them. + + Args: + db: Database session + workspace: The workspace to check + """ + from app.models.ontology_scene import OntologyScene + + # 幂等检查:是否已存在系统默认场景 + existing = db.query(OntologyScene).filter( + OntologyScene.workspace_id == workspace.id, + OntologyScene.is_system_default.is_(True) + ).first() + + if existing: + return + + business_logger.info( + f"Workspace {workspace.id} missing default ontology scenes, creating them" + ) + + try: + initializer = DefaultOntologyInitializer(db) + success, error_msg = initializer.initialize_default_scenes( + workspace.id, language="zh" + ) + if success: + db.commit() + business_logger.info( + f"为工作空间 {workspace.id} 补建默认本体场景成功" + ) + else: + business_logger.warning( + f"为工作空间 {workspace.id} 补建默认本体场景失败: {error_msg}" + ) + except Exception as e: + db.rollback() + business_logger.error( + f"为工作空间 {workspace.id} 补建默认本体场景异常: {str(e)}" + ) + + def _create_default_memory_config( db: Session, workspace_id: uuid.UUID, From a2ed335e59cd14034947f1ff16959ddaf7faed3f Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 18:04:46 +0800 Subject: [PATCH 07/89] [add] Repeatability test --- api/app/controllers/ontology_controller.py | 24 ++++++++++++++----- .../controllers/ontology_secondary_routes.py | 21 ++++++++++++---- api/app/models/ontology_class.py | 5 +++- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/api/app/controllers/ontology_controller.py b/api/app/controllers/ontology_controller.py index c892b013..3d2a1bdb 100644 --- a/api/app/controllers/ontology_controller.py +++ b/api/app/controllers/ontology_controller.py @@ -25,7 +25,7 @@ from typing import Dict, Optional, List from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Form, Header -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, JSONResponse from sqlalchemy.orm import Session from app.core.config import settings @@ -289,7 +289,8 @@ async def extract_ontology( async def create_scene( request: SceneCreateRequest, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + x_language_type: Optional[str] = Header(None, alias="X-Language-Type") ): """创建本体场景 @@ -360,8 +361,18 @@ async def create_scene( return fail(BizCode.BAD_REQUEST, "请求参数无效", str(e)) except RuntimeError as e: - api_logger.error(f"Runtime error in scene creation: {str(e)}", exc_info=True) - return fail(BizCode.INTERNAL_ERROR, "场景创建失败", str(e)) + err_str = str(e) + if "UniqueViolation" in err_str or "uq_workspace_scene_name" in err_str: + api_logger.warning(f"Duplicate scene name '{request.scene_name}' in workspace {current_user.current_workspace_id}") + from app.core.language_utils import get_language_from_header + lang = get_language_from_header(x_language_type) + if lang == "en": + msg = fail(BizCode.BAD_REQUEST, "Scene name already exists", f"A scene named \"{request.scene_name}\" already exists in the current workspace. Please use a different name.") + else: + msg = fail(BizCode.BAD_REQUEST, "场景名称已存在", f"当前工作空间下已存在名为「{request.scene_name}」的场景,请使用其他名称") + return JSONResponse(status_code=400, content=msg) + api_logger.error(f"Runtime error in scene creation: {err_str}", exc_info=True) + return fail(BizCode.INTERNAL_ERROR, "场景创建失败", err_str) except Exception as e: api_logger.error(f"Unexpected error in scene creation: {str(e)}", exc_info=True) @@ -661,7 +672,8 @@ async def get_scenes( async def create_class( request: ClassCreateRequest, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + x_language_type: Optional[str] = Header(None, alias="X-Language-Type") ): """创建本体类型 @@ -676,7 +688,7 @@ async def create_class( ApiResponse: 包含创建的类型信息 """ from app.controllers.ontology_secondary_routes import create_class_handler - return await create_class_handler(request, db, current_user) + return await create_class_handler(request, db, current_user, x_language_type) @router.put("/class/{class_id}", response_model=ApiResponse) diff --git a/api/app/controllers/ontology_secondary_routes.py b/api/app/controllers/ontology_secondary_routes.py index 607a0739..a0609605 100644 --- a/api/app/controllers/ontology_secondary_routes.py +++ b/api/app/controllers/ontology_secondary_routes.py @@ -7,7 +7,7 @@ from uuid import UUID from typing import Optional -from fastapi import Depends +from fastapi import Depends, Header from sqlalchemy.orm import Session from app.core.error_codes import BizCode @@ -238,7 +238,8 @@ async def scenes_handler( async def create_class_handler( request: ClassCreateRequest, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + x_language_type: Optional[str] = None ): """创建本体类型(统一使用列表形式,支持单个或批量)""" @@ -334,8 +335,20 @@ async def create_class_handler( return fail(BizCode.BAD_REQUEST, "请求参数无效", str(e)) except RuntimeError as e: - api_logger.error(f"Runtime error in class creation: {str(e)}", exc_info=True) - return fail(BizCode.INTERNAL_ERROR, "类型创建失败", str(e)) + err_str = str(e) + if "UniqueViolation" in err_str or "uq_scene_class_name" in err_str: + api_logger.warning(f"Duplicate class name in scene {request.scene_id}") + from app.core.language_utils import get_language_from_header + from fastapi.responses import JSONResponse + lang = get_language_from_header(x_language_type) + class_name = request.classes[0].class_name if request.classes else "" + if lang == "en": + msg = fail(BizCode.BAD_REQUEST, "Class name already exists", f"A class named \"{class_name}\" already exists in this scene. Please use a different name.") + else: + msg = fail(BizCode.BAD_REQUEST, "类型名称已存在", f"当前场景下已存在名为「{class_name}」的类型,请使用其他名称") + return JSONResponse(status_code=400, content=msg) + api_logger.error(f"Runtime error in class creation: {err_str}", exc_info=True) + return fail(BizCode.INTERNAL_ERROR, "类型创建失败", err_str) except Exception as e: api_logger.error(f"Unexpected error in class creation: {str(e)}", exc_info=True) diff --git a/api/app/models/ontology_class.py b/api/app/models/ontology_class.py index a8468090..eb38d06f 100644 --- a/api/app/models/ontology_class.py +++ b/api/app/models/ontology_class.py @@ -9,7 +9,7 @@ Classes: import datetime import uuid -from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean +from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from app.db import Base @@ -18,6 +18,9 @@ from app.db import Base class OntologyClass(Base): """本体类型表 - 用于存储某个场景提取出来的本体类型信息""" __tablename__ = "ontology_class" + __table_args__ = ( + UniqueConstraint('scene_id', 'class_name', name='uq_scene_class_name'), + ) # 主键 class_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True, comment="类型ID") From 71fe35533dbc531d54a83c177b54dc4d79cc7f18 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 18:15:31 +0800 Subject: [PATCH 08/89] [add] Memory configuration adds uniqueness detection --- api/app/controllers/memory_storage_controller.py | 16 +++++++++++++++- api/app/models/memory_config_model.py | 5 ++++- api/app/services/memory_storage_service.py | 11 +++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index 826724c9..288b4265 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -2,7 +2,7 @@ from typing import Optional from uuid import UUID from fastapi import APIRouter, Depends, Query -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, JSONResponse from sqlalchemy.orm import Session from app.core.error_codes import BizCode @@ -85,6 +85,7 @@ def create_config( payload: ConfigParamsCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), + x_language_type: Optional[str] = Header(None, alias="X-Language-Type"), ) -> dict: workspace_id = current_user.current_workspace_id # 检查用户是否已选择工作空间 @@ -99,6 +100,19 @@ def create_config( svc = DataConfigService(db) result = svc.create(payload) return success(data=result, msg="创建成功") + except ValueError as e: + err_str = str(e) + if err_str.startswith("DUPLICATE_CONFIG_NAME:"): + config_name = err_str.split(":", 1)[1] + api_logger.warning(f"重复的配置名称 '{config_name}' 在工作空间 {workspace_id}") + lang = get_language_from_header(x_language_type) + if lang == "en": + msg = fail(BizCode.BAD_REQUEST, "Config name already exists", f"A config named \"{config_name}\" already exists in the current workspace. Please use a different name.") + else: + msg = fail(BizCode.BAD_REQUEST, "配置名称已存在", f"当前工作空间下已存在名为「{config_name}」的记忆配置,请使用其他名称") + return JSONResponse(status_code=400, content=msg) + api_logger.error(f"Create config failed: {err_str}") + return fail(BizCode.INTERNAL_ERROR, "创建配置失败", err_str) except Exception as e: api_logger.error(f"Create config failed: {str(e)}") return fail(BizCode.INTERNAL_ERROR, "创建配置失败", str(e)) diff --git a/api/app/models/memory_config_model.py b/api/app/models/memory_config_model.py index 1095a386..b4b441d5 100644 --- a/api/app/models/memory_config_model.py +++ b/api/app/models/memory_config_model.py @@ -1,6 +1,6 @@ import datetime -from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from app.db import Base @@ -9,6 +9,9 @@ from app.db import Base class MemoryConfig(Base): """记忆配置表 - 用于存储记忆系统的配置参数""" __tablename__ = "memory_config" + __table_args__ = ( + UniqueConstraint('workspace_id', 'config_name', name='uq_workspace_config_name'), + ) # 主键 config_id = Column(UUID(as_uuid=True), primary_key=True, comment="配置ID") diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 1083f750..6546a143 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -115,6 +115,17 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Create --- def create(self, params: ConfigParamsCreate) -> Dict[str, Any]: # 创建配置参数(仅名称与描述) + # 检查同一工作空间下是否已存在同名配置 + if params.workspace_id and params.config_name: + from app.models.memory_config_model import MemoryConfig + existing = ( + self.db.query(MemoryConfig) + .filter_by(workspace_id=params.workspace_id, config_name=params.config_name) + .first() + ) + if existing: + raise ValueError(f"DUPLICATE_CONFIG_NAME:{params.config_name}") + # 如果workspace_id存在且模型字段未全部指定,则自动获取 if params.workspace_id and not all([params.llm_id, params.embedding_id, params.rerank_id]): configs = self._get_workspace_configs(params.workspace_id) From a1fc0fd3949720bbb2a35f58c36eb05e14af3757 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 17:22:18 +0800 Subject: [PATCH 09/89] [add] Added checks for idempotency of the ontology project --- api/app/services/workspace_service.py | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 2f8cdc70..e93c0c5c 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -107,6 +107,7 @@ def get_user_workspaces(db: Session, user: User) -> List[Workspace]: for workspace in workspaces: if workspace.storage_type == 'neo4j': _ensure_default_memory_config(db, workspace) + _ensure_default_ontology_scenes(db, workspace) business_logger.info(f"用户 {user.username} 的工作空间数量: {len(workspaces)}") return workspaces @@ -1104,6 +1105,52 @@ def _fill_workspace_configs_model_defaults( ) +def _ensure_default_ontology_scenes(db: Session, workspace: Workspace) -> None: + """Ensure a workspace has default ontology scenes, creating them if missing. + + Checks whether any is_system_default scene exists for the workspace. + If not, runs the DefaultOntologyInitializer to create them. + + Args: + db: Database session + workspace: The workspace to check + """ + from app.models.ontology_scene import OntologyScene + + # 幂等检查:是否已存在系统默认场景 + existing = db.query(OntologyScene).filter( + OntologyScene.workspace_id == workspace.id, + OntologyScene.is_system_default.is_(True) + ).first() + + if existing: + return + + business_logger.info( + f"Workspace {workspace.id} missing default ontology scenes, creating them" + ) + + try: + initializer = DefaultOntologyInitializer(db) + success, error_msg = initializer.initialize_default_scenes( + workspace.id, language="zh" + ) + if success: + db.commit() + business_logger.info( + f"为工作空间 {workspace.id} 补建默认本体场景成功" + ) + else: + business_logger.warning( + f"为工作空间 {workspace.id} 补建默认本体场景失败: {error_msg}" + ) + except Exception as e: + db.rollback() + business_logger.error( + f"为工作空间 {workspace.id} 补建默认本体场景异常: {str(e)}" + ) + + def _create_default_memory_config( db: Session, workspace_id: uuid.UUID, From 418844310149d574c2265b896a71e3c02dc89c83 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 18:04:46 +0800 Subject: [PATCH 10/89] [add] Repeatability test --- api/app/controllers/ontology_controller.py | 24 ++++++++++++++----- .../controllers/ontology_secondary_routes.py | 21 ++++++++++++---- api/app/models/ontology_class.py | 5 +++- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/api/app/controllers/ontology_controller.py b/api/app/controllers/ontology_controller.py index c892b013..3d2a1bdb 100644 --- a/api/app/controllers/ontology_controller.py +++ b/api/app/controllers/ontology_controller.py @@ -25,7 +25,7 @@ from typing import Dict, Optional, List from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Form, Header -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, JSONResponse from sqlalchemy.orm import Session from app.core.config import settings @@ -289,7 +289,8 @@ async def extract_ontology( async def create_scene( request: SceneCreateRequest, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + x_language_type: Optional[str] = Header(None, alias="X-Language-Type") ): """创建本体场景 @@ -360,8 +361,18 @@ async def create_scene( return fail(BizCode.BAD_REQUEST, "请求参数无效", str(e)) except RuntimeError as e: - api_logger.error(f"Runtime error in scene creation: {str(e)}", exc_info=True) - return fail(BizCode.INTERNAL_ERROR, "场景创建失败", str(e)) + err_str = str(e) + if "UniqueViolation" in err_str or "uq_workspace_scene_name" in err_str: + api_logger.warning(f"Duplicate scene name '{request.scene_name}' in workspace {current_user.current_workspace_id}") + from app.core.language_utils import get_language_from_header + lang = get_language_from_header(x_language_type) + if lang == "en": + msg = fail(BizCode.BAD_REQUEST, "Scene name already exists", f"A scene named \"{request.scene_name}\" already exists in the current workspace. Please use a different name.") + else: + msg = fail(BizCode.BAD_REQUEST, "场景名称已存在", f"当前工作空间下已存在名为「{request.scene_name}」的场景,请使用其他名称") + return JSONResponse(status_code=400, content=msg) + api_logger.error(f"Runtime error in scene creation: {err_str}", exc_info=True) + return fail(BizCode.INTERNAL_ERROR, "场景创建失败", err_str) except Exception as e: api_logger.error(f"Unexpected error in scene creation: {str(e)}", exc_info=True) @@ -661,7 +672,8 @@ async def get_scenes( async def create_class( request: ClassCreateRequest, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + x_language_type: Optional[str] = Header(None, alias="X-Language-Type") ): """创建本体类型 @@ -676,7 +688,7 @@ async def create_class( ApiResponse: 包含创建的类型信息 """ from app.controllers.ontology_secondary_routes import create_class_handler - return await create_class_handler(request, db, current_user) + return await create_class_handler(request, db, current_user, x_language_type) @router.put("/class/{class_id}", response_model=ApiResponse) diff --git a/api/app/controllers/ontology_secondary_routes.py b/api/app/controllers/ontology_secondary_routes.py index 600aacc7..0d752006 100644 --- a/api/app/controllers/ontology_secondary_routes.py +++ b/api/app/controllers/ontology_secondary_routes.py @@ -7,7 +7,7 @@ from uuid import UUID from typing import Optional -from fastapi import Depends +from fastapi import Depends, Header from sqlalchemy.orm import Session from app.core.error_codes import BizCode @@ -231,7 +231,8 @@ async def scenes_handler( async def create_class_handler( request: ClassCreateRequest, db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + x_language_type: Optional[str] = None ): """创建本体类型(统一使用列表形式,支持单个或批量)""" @@ -327,8 +328,20 @@ async def create_class_handler( return fail(BizCode.BAD_REQUEST, "请求参数无效", str(e)) except RuntimeError as e: - api_logger.error(f"Runtime error in class creation: {str(e)}", exc_info=True) - return fail(BizCode.INTERNAL_ERROR, "类型创建失败", str(e)) + err_str = str(e) + if "UniqueViolation" in err_str or "uq_scene_class_name" in err_str: + api_logger.warning(f"Duplicate class name in scene {request.scene_id}") + from app.core.language_utils import get_language_from_header + from fastapi.responses import JSONResponse + lang = get_language_from_header(x_language_type) + class_name = request.classes[0].class_name if request.classes else "" + if lang == "en": + msg = fail(BizCode.BAD_REQUEST, "Class name already exists", f"A class named \"{class_name}\" already exists in this scene. Please use a different name.") + else: + msg = fail(BizCode.BAD_REQUEST, "类型名称已存在", f"当前场景下已存在名为「{class_name}」的类型,请使用其他名称") + return JSONResponse(status_code=400, content=msg) + api_logger.error(f"Runtime error in class creation: {err_str}", exc_info=True) + return fail(BizCode.INTERNAL_ERROR, "类型创建失败", err_str) except Exception as e: api_logger.error(f"Unexpected error in class creation: {str(e)}", exc_info=True) diff --git a/api/app/models/ontology_class.py b/api/app/models/ontology_class.py index a8468090..eb38d06f 100644 --- a/api/app/models/ontology_class.py +++ b/api/app/models/ontology_class.py @@ -9,7 +9,7 @@ Classes: import datetime import uuid -from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean +from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from app.db import Base @@ -18,6 +18,9 @@ from app.db import Base class OntologyClass(Base): """本体类型表 - 用于存储某个场景提取出来的本体类型信息""" __tablename__ = "ontology_class" + __table_args__ = ( + UniqueConstraint('scene_id', 'class_name', name='uq_scene_class_name'), + ) # 主键 class_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True, comment="类型ID") From 7afe5072961c085a647439b9a9a2a47b540e5ff9 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 18:15:31 +0800 Subject: [PATCH 11/89] [add] Memory configuration adds uniqueness detection --- api/app/controllers/memory_storage_controller.py | 16 +++++++++++++++- api/app/models/memory_config_model.py | 5 ++++- api/app/services/memory_storage_service.py | 11 +++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index 826724c9..288b4265 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -2,7 +2,7 @@ from typing import Optional from uuid import UUID from fastapi import APIRouter, Depends, Query -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, JSONResponse from sqlalchemy.orm import Session from app.core.error_codes import BizCode @@ -85,6 +85,7 @@ def create_config( payload: ConfigParamsCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), + x_language_type: Optional[str] = Header(None, alias="X-Language-Type"), ) -> dict: workspace_id = current_user.current_workspace_id # 检查用户是否已选择工作空间 @@ -99,6 +100,19 @@ def create_config( svc = DataConfigService(db) result = svc.create(payload) return success(data=result, msg="创建成功") + except ValueError as e: + err_str = str(e) + if err_str.startswith("DUPLICATE_CONFIG_NAME:"): + config_name = err_str.split(":", 1)[1] + api_logger.warning(f"重复的配置名称 '{config_name}' 在工作空间 {workspace_id}") + lang = get_language_from_header(x_language_type) + if lang == "en": + msg = fail(BizCode.BAD_REQUEST, "Config name already exists", f"A config named \"{config_name}\" already exists in the current workspace. Please use a different name.") + else: + msg = fail(BizCode.BAD_REQUEST, "配置名称已存在", f"当前工作空间下已存在名为「{config_name}」的记忆配置,请使用其他名称") + return JSONResponse(status_code=400, content=msg) + api_logger.error(f"Create config failed: {err_str}") + return fail(BizCode.INTERNAL_ERROR, "创建配置失败", err_str) except Exception as e: api_logger.error(f"Create config failed: {str(e)}") return fail(BizCode.INTERNAL_ERROR, "创建配置失败", str(e)) diff --git a/api/app/models/memory_config_model.py b/api/app/models/memory_config_model.py index 1095a386..b4b441d5 100644 --- a/api/app/models/memory_config_model.py +++ b/api/app/models/memory_config_model.py @@ -1,6 +1,6 @@ import datetime -from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from app.db import Base @@ -9,6 +9,9 @@ from app.db import Base class MemoryConfig(Base): """记忆配置表 - 用于存储记忆系统的配置参数""" __tablename__ = "memory_config" + __table_args__ = ( + UniqueConstraint('workspace_id', 'config_name', name='uq_workspace_config_name'), + ) # 主键 config_id = Column(UUID(as_uuid=True), primary_key=True, comment="配置ID") diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index beedaae9..71f4ff07 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -115,6 +115,17 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Create --- def create(self, params: ConfigParamsCreate) -> Dict[str, Any]: # 创建配置参数(仅名称与描述) + # 检查同一工作空间下是否已存在同名配置 + if params.workspace_id and params.config_name: + from app.models.memory_config_model import MemoryConfig + existing = ( + self.db.query(MemoryConfig) + .filter_by(workspace_id=params.workspace_id, config_name=params.config_name) + .first() + ) + if existing: + raise ValueError(f"DUPLICATE_CONFIG_NAME:{params.config_name}") + # 如果workspace_id存在且模型字段未全部指定,则自动获取 if params.workspace_id and not all([params.llm_id, params.embedding_id, params.rerank_id]): configs = self._get_workspace_configs(params.workspace_id) From d052c31ac509c23a988410e8186f6cec9a709a24 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 18:36:12 +0800 Subject: [PATCH 12/89] [changes] The pre-query at the service layer has been removed. The DB constraint ensures a unique single source of truth. --- api/app/controllers/memory_storage_controller.py | 16 ++++++---------- api/app/services/memory_storage_service.py | 11 ----------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index 288b4265..9708b3a5 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -100,20 +100,16 @@ def create_config( svc = DataConfigService(db) result = svc.create(payload) return success(data=result, msg="创建成功") - except ValueError as e: - err_str = str(e) - if err_str.startswith("DUPLICATE_CONFIG_NAME:"): - config_name = err_str.split(":", 1)[1] - api_logger.warning(f"重复的配置名称 '{config_name}' 在工作空间 {workspace_id}") + except Exception as e: + from sqlalchemy.exc import IntegrityError + if isinstance(e, IntegrityError) and "uq_workspace_config_name" in str(getattr(e, 'orig', '')): + api_logger.warning(f"重复的配置名称 '{payload.config_name}' 在工作空间 {workspace_id}") lang = get_language_from_header(x_language_type) if lang == "en": - msg = fail(BizCode.BAD_REQUEST, "Config name already exists", f"A config named \"{config_name}\" already exists in the current workspace. Please use a different name.") + msg = fail(BizCode.BAD_REQUEST, "Config name already exists", f"A config named \"{payload.config_name}\" already exists in the current workspace. Please use a different name.") else: - msg = fail(BizCode.BAD_REQUEST, "配置名称已存在", f"当前工作空间下已存在名为「{config_name}」的记忆配置,请使用其他名称") + msg = fail(BizCode.BAD_REQUEST, "配置名称已存在", f"当前工作空间下已存在名为「{payload.config_name}」的记忆配置,请使用其他名称") return JSONResponse(status_code=400, content=msg) - api_logger.error(f"Create config failed: {err_str}") - return fail(BizCode.INTERNAL_ERROR, "创建配置失败", err_str) - except Exception as e: api_logger.error(f"Create config failed: {str(e)}") return fail(BizCode.INTERNAL_ERROR, "创建配置失败", str(e)) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 71f4ff07..beedaae9 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -115,17 +115,6 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Create --- def create(self, params: ConfigParamsCreate) -> Dict[str, Any]: # 创建配置参数(仅名称与描述) - # 检查同一工作空间下是否已存在同名配置 - if params.workspace_id and params.config_name: - from app.models.memory_config_model import MemoryConfig - existing = ( - self.db.query(MemoryConfig) - .filter_by(workspace_id=params.workspace_id, config_name=params.config_name) - .first() - ) - if existing: - raise ValueError(f"DUPLICATE_CONFIG_NAME:{params.config_name}") - # 如果workspace_id存在且模型字段未全部指定,则自动获取 if params.workspace_id and not all([params.llm_id, params.embedding_id, params.rerank_id]): configs = self._get_workspace_configs(params.workspace_id) From c3707f543c079ac92f91f96c4ad4ed8ebf2f4802 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 5 Mar 2026 18:59:23 +0800 Subject: [PATCH 13/89] [changes] From the perspective of logical judgment, to determine the situation of duplicate names --- .../controllers/memory_storage_controller.py | 13 +++++++++++ .../controllers/ontology_secondary_routes.py | 23 +++++++++++++++---- api/app/models/memory_config_model.py | 5 +--- api/app/models/ontology_class.py | 5 +--- api/app/services/memory_storage_service.py | 11 +++++++++ 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index 9708b3a5..ee45fb83 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -100,6 +100,19 @@ def create_config( svc = DataConfigService(db) result = svc.create(payload) return success(data=result, msg="创建成功") + except ValueError as e: + err_str = str(e) + if err_str.startswith("DUPLICATE_CONFIG_NAME:"): + config_name = err_str.split(":", 1)[1] + api_logger.warning(f"重复的配置名称 '{config_name}' 在工作空间 {workspace_id}") + lang = get_language_from_header(x_language_type) + if lang == "en": + msg = fail(BizCode.BAD_REQUEST, "Config name already exists", f"A config named \"{config_name}\" already exists in the current workspace. Please use a different name.") + else: + msg = fail(BizCode.BAD_REQUEST, "配置名称已存在", f"当前工作空间下已存在名为「{config_name}」的记忆配置,请使用其他名称") + return JSONResponse(status_code=400, content=msg) + api_logger.error(f"Create config failed: {err_str}") + return fail(BizCode.INTERNAL_ERROR, "创建配置失败", err_str) except Exception as e: from sqlalchemy.exc import IntegrityError if isinstance(e, IntegrityError) and "uq_workspace_config_name" in str(getattr(e, 'orig', '')): diff --git a/api/app/controllers/ontology_secondary_routes.py b/api/app/controllers/ontology_secondary_routes.py index 0d752006..2aea77a4 100644 --- a/api/app/controllers/ontology_secondary_routes.py +++ b/api/app/controllers/ontology_secondary_routes.py @@ -265,8 +265,11 @@ async def create_class_handler( ] if count == 1: - # 单个创建 + # 单个创建 - 先检查重名 class_data = classes_data[0] + existing = OntologyClassRepository(db).get_by_name(class_data["class_name"], request.scene_id) + if existing: + raise ValueError(f"DUPLICATE_CLASS_NAME:{class_data['class_name']}") ontology_class = service.create_class( scene_id=request.scene_id, class_name=class_data["class_name"], @@ -324,9 +327,21 @@ async def create_class_handler( return success(data=response.model_dump(mode='json'), msg="批量创建完成") except ValueError as e: - api_logger.warning(f"Validation error in class creation: {str(e)}") - return fail(BizCode.BAD_REQUEST, "请求参数无效", str(e)) - + err_str = str(e) + if err_str.startswith("DUPLICATE_CLASS_NAME:"): + class_name = err_str.split(":", 1)[1] + api_logger.warning(f"Duplicate class name '{class_name}' in scene {request.scene_id}") + from app.core.language_utils import get_language_from_header + from fastapi.responses import JSONResponse + lang = get_language_from_header(x_language_type) + if lang == "en": + msg = fail(BizCode.BAD_REQUEST, "Class name already exists", f"A class named \"{class_name}\" already exists in this scene. Please use a different name.") + else: + msg = fail(BizCode.BAD_REQUEST, "类型名称已存在", f"当前场景下已存在名为「{class_name}」的类型,请使用其他名称") + return JSONResponse(status_code=400, content=msg) + api_logger.warning(f"Validation error in class creation: {err_str}") + return fail(BizCode.BAD_REQUEST, "请求参数无效", err_str) + except RuntimeError as e: err_str = str(e) if "UniqueViolation" in err_str or "uq_scene_class_name" in err_str: diff --git a/api/app/models/memory_config_model.py b/api/app/models/memory_config_model.py index b4b441d5..1095a386 100644 --- a/api/app/models/memory_config_model.py +++ b/api/app/models/memory_config_model.py @@ -1,6 +1,6 @@ import datetime -from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, UniqueConstraint +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String from sqlalchemy.dialects.postgresql import UUID from app.db import Base @@ -9,9 +9,6 @@ from app.db import Base class MemoryConfig(Base): """记忆配置表 - 用于存储记忆系统的配置参数""" __tablename__ = "memory_config" - __table_args__ = ( - UniqueConstraint('workspace_id', 'config_name', name='uq_workspace_config_name'), - ) # 主键 config_id = Column(UUID(as_uuid=True), primary_key=True, comment="配置ID") diff --git a/api/app/models/ontology_class.py b/api/app/models/ontology_class.py index eb38d06f..a8468090 100644 --- a/api/app/models/ontology_class.py +++ b/api/app/models/ontology_class.py @@ -9,7 +9,7 @@ Classes: import datetime import uuid -from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean, UniqueConstraint +from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from app.db import Base @@ -18,9 +18,6 @@ from app.db import Base class OntologyClass(Base): """本体类型表 - 用于存储某个场景提取出来的本体类型信息""" __tablename__ = "ontology_class" - __table_args__ = ( - UniqueConstraint('scene_id', 'class_name', name='uq_scene_class_name'), - ) # 主键 class_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True, comment="类型ID") diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index beedaae9..02fd1051 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -115,6 +115,17 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Create --- def create(self, params: ConfigParamsCreate) -> Dict[str, Any]: # 创建配置参数(仅名称与描述) + # 业务层检查同一工作空间下是否已存在同名配置 + if params.workspace_id and params.config_name: + from app.models.memory_config_model import MemoryConfig + existing = ( + self.db.query(MemoryConfig) + .filter_by(workspace_id=params.workspace_id, config_name=params.config_name) + .first() + ) + if existing: + raise ValueError(f"DUPLICATE_CONFIG_NAME:{params.config_name}") + # 如果workspace_id存在且模型字段未全部指定,则自动获取 if params.workspace_id and not all([params.llm_id, params.embedding_id, params.rerank_id]): configs = self._get_workspace_configs(params.workspace_id) From aaa04107810457e6ab4aef4ceff7e5e4a3daa828 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Fri, 6 Mar 2026 10:12:21 +0800 Subject: [PATCH 14/89] fix(db): fix database connection leak --- .../controllers/memory_agent_controller.py | 183 ++++++------- .../langgraph_graph/nodes/problem_nodes.py | 39 +-- .../langgraph_graph/nodes/retrieve_nodes.py | 78 +++--- .../langgraph_graph/nodes/summary_nodes.py | 178 ++++++------ .../nodes/verification_nodes.py | 75 ++--- .../agent/langgraph_graph/read_graph.py | 42 +-- .../memory/agent/utils/llm_client_pool.py | 56 ---- api/app/core/workflow/nodes/agent/node.py | 85 +++--- api/app/services/draft_run_service.py | 8 +- api/app/services/memory_agent_service.py | 257 +++++++++--------- api/app/services/memory_konwledges_server.py | 60 ++-- api/app/services/user_memory_service.py | 10 +- 12 files changed, 505 insertions(+), 566 deletions(-) delete mode 100644 api/app/core/memory/agent/utils/llm_client_pool.py diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index ccf93d68..e3d2bf92 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -1,28 +1,29 @@ from typing import List, Optional +from dotenv import load_dotenv +from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, Header +from sqlalchemy.orm import Session +from starlette.responses import StreamingResponse + from app.cache.memory.interest_memory import InterestMemoryCache from app.celery_app import celery_app from app.core.error_codes import BizCode from app.core.language_utils import get_language_from_header from app.core.logging_config import get_api_logger +from app.core.memory.agent.utils.redis_tool import store +from app.core.memory.agent.utils.session_tools import SessionService from app.core.rag.llm.cv_model import QWenCV from app.core.response_utils import fail, success from app.db import get_db from app.dependencies import cur_workspace_access_guard, get_current_user from app.models import ModelApiKey from app.models.user_model import User -from app.core.memory.agent.utils.session_tools import SessionService -from app.core.memory.agent.utils.redis_tool import store -from app.repositories import knowledge_repository, WorkspaceRepository +from app.repositories import knowledge_repository from app.schemas.memory_agent_schema import UserInput, Write_UserInput from app.schemas.response_schema import ApiResponse from app.services import task_service, workspace_service from app.services.memory_agent_service import MemoryAgentService from app.services.model_service import ModelConfigService -from dotenv import load_dotenv -from fastapi import APIRouter, Depends, File, Form, Query, UploadFile,Header -from sqlalchemy.orm import Session -from starlette.responses import StreamingResponse load_dotenv() api_logger = get_api_logger() @@ -37,7 +38,7 @@ router = APIRouter( @router.get("/health/status", response_model=ApiResponse) async def get_health_status( - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user) ): """ Get latest health status written by Celery periodic task @@ -55,8 +56,9 @@ async def get_health_status( @router.get("/download_log") async def download_log( - log_type: str = Query("file", regex="^(file|transmission)$", description="日志类型: file=完整文件, transmission=实时流式传输"), - current_user: User = Depends(get_current_user) + log_type: str = Query("file", regex="^(file|transmission)$", + description="日志类型: file=完整文件, transmission=实时流式传输"), + current_user: User = Depends(get_current_user) ): """ Download or stream agent service log file @@ -75,16 +77,16 @@ async def download_log( - transmission mode: StreamingResponse with SSE """ api_logger.info(f"Log download requested with log_type={log_type}") - + # Validate log_type parameter (FastAPI Query regex already validates, but explicit check for clarity) if log_type not in ["file", "transmission"]: api_logger.warning(f"Invalid log_type parameter: {log_type}") return fail( - BizCode.BAD_REQUEST, - "无效的log_type参数", + BizCode.BAD_REQUEST, + "无效的log_type参数", "log_type必须是'file'或'transmission'" ) - + # Route to appropriate mode if log_type == "file": # File mode: Return complete log file content @@ -119,10 +121,10 @@ async def download_log( @router.post("/writer_service", response_model=ApiResponse) @cur_workspace_access_guard() async def write_server( - user_input: Write_UserInput, - language_type: str = Header(default=None, alias="X-Language-Type"), - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + user_input: Write_UserInput, + language_type: str = Header(default=None, alias="X-Language-Type"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) ): """ Write service endpoint - processes write operations synchronously @@ -136,11 +138,11 @@ async def write_server( """ # 使用集中化的语言校验 language = get_language_from_header(language_type) - + config_id = user_input.config_id workspace_id = current_user.current_workspace_id api_logger.info(f"Write service: workspace_id={workspace_id}, config_id={config_id}, language_type={language}") - + # 获取 storage_type,如果为 None 则使用默认值 storage_type = workspace_service.get_workspace_storage_type( db=db, @@ -149,7 +151,7 @@ async def write_server( ) if storage_type is None: storage_type = 'neo4j' user_rag_memory_id = '' - + # 如果 storage_type 是 rag,必须确保有有效的 user_rag_memory_id if storage_type == 'rag': if workspace_id: @@ -161,13 +163,15 @@ async def write_server( if knowledge: user_rag_memory_id = str(knowledge.id) else: - api_logger.warning(f"未找到名为 'USER_RAG_MERORY' 的知识库,workspace_id: {workspace_id},将使用 neo4j 存储") + api_logger.warning( + f"未找到名为 'USER_RAG_MERORY' 的知识库,workspace_id: {workspace_id},将使用 neo4j 存储") storage_type = 'neo4j' else: api_logger.warning("workspace_id 为空,无法使用 rag 存储,将使用 neo4j 存储") storage_type = 'neo4j' - - api_logger.info(f"Write service requested for group {user_input.end_user_id}, storage_type: {storage_type}, user_rag_memory_id: {user_rag_memory_id}") + + api_logger.info( + f"Write service requested for group {user_input.end_user_id}, storage_type: {storage_type}, user_rag_memory_id: {user_rag_memory_id}") try: messages_list = memory_agent_service.get_messages_list(user_input) result = await memory_agent_service.write_memory( @@ -175,7 +179,7 @@ async def write_server( messages_list, config_id, db, - storage_type, + storage_type, user_rag_memory_id, language ) @@ -195,10 +199,10 @@ async def write_server( @router.post("/writer_service_async", response_model=ApiResponse) @cur_workspace_access_guard() async def write_server_async( - user_input: Write_UserInput, - language_type: str = Header(default=None, alias="X-Language-Type"), - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + user_input: Write_UserInput, + language_type: str = Header(default=None, alias="X-Language-Type"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) ): """ Async write service endpoint - enqueues write processing to Celery @@ -213,10 +217,11 @@ async def write_server_async( """ # 使用集中化的语言校验 language = get_language_from_header(language_type) - + config_id = user_input.config_id workspace_id = current_user.current_workspace_id - api_logger.info(f"Async write service: workspace_id={workspace_id}, config_id={config_id}, language_type={language}") + api_logger.info( + f"Async write service: workspace_id={workspace_id}, config_id={config_id}, language_type={language}") # 获取 storage_type,如果为 None 则使用默认值 storage_type = workspace_service.get_workspace_storage_type( @@ -244,7 +249,7 @@ async def write_server_async( args=[user_input.end_user_id, messages_list, config_id, storage_type, user_rag_memory_id, language] ) api_logger.info(f"Write task queued: {task.id}") - + return success(data={"task_id": task.id}, msg="写入任务已提交") except Exception as e: api_logger.error(f"Async write operation failed: {str(e)}") @@ -254,9 +259,9 @@ async def write_server_async( @router.post("/read_service", response_model=ApiResponse) @cur_workspace_access_guard() async def read_server( - user_input: UserInput, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + user_input: UserInput, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) ): """ Read service endpoint - processes read operations synchronously @@ -291,8 +296,9 @@ async def read_server( ) if knowledge: user_rag_memory_id = str(knowledge.id) - - api_logger.info(f"Read service: group={user_input.end_user_id}, storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}, workspace_id={workspace_id}") + + api_logger.info( + f"Read service: group={user_input.end_user_id}, storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}, workspace_id={workspace_id}") try: result = await memory_agent_service.read_memory( user_input.end_user_id, @@ -306,7 +312,8 @@ async def read_server( ) if str(user_input.search_switch) == "2": retrieve_info = result['answer'] - history = await SessionService(store).get_history(user_input.end_user_id, user_input.end_user_id, user_input.end_user_id) + history = await SessionService(store).get_history(user_input.end_user_id, user_input.end_user_id, + user_input.end_user_id) query = user_input.message # 调用 memory_agent_service 的方法生成最终答案 @@ -319,7 +326,7 @@ async def read_server( db=db ) if "信息不足,无法回答" in result['answer']: - result['answer']=retrieve_info + result['answer'] = retrieve_info return success(data=result, msg="回复对话消息成功") except BaseException as e: # Handle ExceptionGroup from TaskGroup (Python 3.11+) or BaseExceptionGroup @@ -335,9 +342,10 @@ async def read_server( @router.post("/file", response_model=ApiResponse) async def file_update( files: List[UploadFile] = File(..., description="要上传的文件"), - model_id:str = Form(..., description="模型ID"), + model_id: str = Form(..., description="模型ID"), metadata: Optional[str] = Form(None, description="文件元数据 (JSON格式)"), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), ): """ 文件上传接口 - 支持图片识别 @@ -350,9 +358,6 @@ async def file_update( Returns: 文件处理结果 """ - - db_gen = get_db() # get_db 通常是一个生成器 - db = next(db_gen) api_logger.info(f"File upload requested, file count: {len(files)}") config = ModelConfigService.get_model_by_id(db=db, model_id=model_id) apiConfig: ModelApiKey = config.api_keys[0] @@ -361,7 +366,7 @@ async def file_update( for file in files: api_logger.debug(f"Processing file: {file.filename}, content_type: {file.content_type}") content = await file.read() - + if file.content_type and file.content_type.startswith("image/"): vision_model = QWenCV( key=apiConfig.api_key, @@ -375,12 +380,12 @@ async def file_update( else: api_logger.warning(f"Unsupported file type: {file.content_type}") file_content.append(f"[不支持的文件类型: {file.content_type}]") - + result_text = ';'.join(file_content) api_logger.info(f"File processing completed, result length: {len(result_text)}") - + return success(data=result_text, msg="转换文本成功") - + except Exception as e: api_logger.error(f"File processing failed: {str(e)}", exc_info=True) return fail(BizCode.INTERNAL_ERROR, "转换文本失败", str(e)) @@ -430,8 +435,8 @@ async def read_server_async( @router.get("/read_result/", response_model=ApiResponse) async def get_read_task_result( - task_id: str, - current_user: User = Depends(get_current_user) + task_id: str, + current_user: User = Depends(get_current_user) ): """ Get the status and result of an async read task @@ -452,7 +457,7 @@ async def get_read_task_result( try: result = task_service.get_task_memory_read_result(task_id) status = result.get("status") - + if status == "SUCCESS": # 任务成功完成 task_result = result.get("result", {}) @@ -470,7 +475,7 @@ async def get_read_task_result( else: # 旧格式:直接返回结果 return success(data=task_result, msg="查询任务已完成") - + elif status == "FAILURE": # 任务失败 error_info = result.get("result", "Unknown error") @@ -479,7 +484,7 @@ async def get_read_task_result( else: error_msg = str(error_info) return fail(BizCode.INTERNAL_ERROR, "查询任务失败", error_msg) - + elif status in ["PENDING", "STARTED"]: # 任务进行中 return success( @@ -499,7 +504,7 @@ async def get_read_task_result( }, msg=f"任务状态: {status}" ) - + except Exception as e: api_logger.error(f"Read task status check failed: {str(e)}", exc_info=True) return fail(BizCode.INTERNAL_ERROR, "任务状态查询失败", str(e)) @@ -507,8 +512,8 @@ async def get_read_task_result( @router.get("/write_result/", response_model=ApiResponse) async def get_write_task_result( - task_id: str, - current_user: User = Depends(get_current_user) + task_id: str, + current_user: User = Depends(get_current_user) ): """ Get the status and result of an async write task @@ -529,7 +534,7 @@ async def get_write_task_result( try: result = task_service.get_task_memory_write_result(task_id) status = result.get("status") - + if status == "SUCCESS": # 任务成功完成 task_result = result.get("result", {}) @@ -547,7 +552,7 @@ async def get_write_task_result( else: # 旧格式:直接返回结果 return success(data=task_result, msg="写入任务已完成") - + elif status == "FAILURE": # 任务失败 error_info = result.get("result", "Unknown error") @@ -556,7 +561,7 @@ async def get_write_task_result( else: error_msg = str(error_info) return fail(BizCode.INTERNAL_ERROR, "写入任务失败", error_msg) - + elif status in ["PENDING", "STARTED"]: # 任务进行中 return success( @@ -576,7 +581,7 @@ async def get_write_task_result( }, msg=f"任务状态: {status}" ) - + except Exception as e: api_logger.error(f"Write task status check failed: {str(e)}", exc_info=True) return fail(BizCode.INTERNAL_ERROR, "任务状态查询失败", str(e)) @@ -584,9 +589,9 @@ async def get_write_task_result( @router.post("/status_type", response_model=ApiResponse) async def status_type( - user_input: Write_UserInput, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + user_input: Write_UserInput, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) ): """ Determine the type of user message (read or write) @@ -629,9 +634,10 @@ async def status_type( @router.get("/stats/types", response_model=ApiResponse) async def get_knowledge_type_stats_api( - end_user_id: Optional[str] = Query(None, description="用户ID(可选)"), - only_active: bool = Query(True, description="仅统计有效记录(status=1)"), - current_user: User = Depends(get_current_user) + end_user_id: Optional[str] = Query(None, description="用户ID(可选)"), + only_active: bool = Query(True, description="仅统计有效记录(status=1)"), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), ): """ 统计当前空间下各知识库类型的数量,包含 General | Web | Third-party | Folder。 @@ -640,14 +646,9 @@ async def get_knowledge_type_stats_api( - 知识库类型根据当前用户的 current_workspace_id 过滤 - 如果用户没有当前工作空间,对应的统计返回 0 """ - api_logger.info(f"Knowledge type stats requested for workspace_id: {current_user.current_workspace_id}, end_user_id: {end_user_id}") + api_logger.info( + f"Knowledge type stats requested for workspace_id: {current_user.current_workspace_id}, end_user_id: {end_user_id}") try: - from app.db import get_db - - # 获取数据库会话 - db_gen = get_db() - db = next(db_gen) - # 调用service层函数 result = await memory_agent_service.get_knowledge_type_stats( end_user_id=end_user_id, @@ -655,7 +656,7 @@ async def get_knowledge_type_stats_api( current_workspace_id=current_user.current_workspace_id, db=db ) - + return success(data=result, msg="获取知识库类型统计成功") except Exception as e: api_logger.error(f"Knowledge type stats failed: {str(e)}") @@ -664,11 +665,11 @@ async def get_knowledge_type_stats_api( @router.get("/analytics/interest_distribution/by_user", response_model=ApiResponse) async def get_interest_distribution_by_user_api( - end_user_id: str = Query(..., description="用户ID(必填)"), - limit: int = Query(5, le=5, description="返回兴趣标签数量限制,最多5个"), - language_type: str = Header(default=None, alias="X-Language-Type"), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + end_user_id: str = Query(..., description="用户ID(必填)"), + limit: int = Query(5, le=5, description="返回兴趣标签数量限制,最多5个"), + language_type: str = Header(default=None, alias="X-Language-Type"), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), ): """ 获取指定用户的兴趣分布标签 @@ -716,9 +717,9 @@ async def get_interest_distribution_by_user_api( @router.get("/analytics/user_profile", response_model=ApiResponse) async def get_user_profile_api( - end_user_id: Optional[str] = Query(None, description="用户ID(可选)"), - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + end_user_id: Optional[str] = Query(None, description="用户ID(可选)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) ): """ 获取用户详情,包含: @@ -756,17 +757,17 @@ async def get_user_profile_api( # ): # """ # Get parsed API documentation (Public endpoint - no authentication required) - + # Args: # file_path: Optional path to API docs file. If None, uses default path. - + # Returns: # Parsed API documentation including title, meta info, and sections # """ # api_logger.info(f"API docs requested, file_path: {file_path or 'default'}") # try: # result = await memory_agent_service.get_api_docs(file_path) - + # if result.get("success"): # return success(msg=result["msg"], data=result["data"]) # else: @@ -782,9 +783,9 @@ async def get_user_profile_api( @router.get("/end_user/{end_user_id}/connected_config", response_model=ApiResponse) async def get_end_user_connected_config( - end_user_id: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + end_user_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) ): """ 获取终端用户关联的记忆配置 @@ -803,9 +804,9 @@ async def get_end_user_connected_config( from app.services.memory_agent_service import ( get_end_user_connected_config as get_config, ) - + api_logger.info(f"Getting connected config for end_user: {end_user_id}") - + try: result = get_config(end_user_id, db) return success(data=result, msg="获取终端用户关联配置成功") @@ -814,4 +815,4 @@ async def get_end_user_connected_config( return fail(BizCode.NOT_FOUND, str(e)) except Exception as e: api_logger.error(f"Failed to get end user connected config: {str(e)}", exc_info=True) - return fail(BizCode.INTERNAL_ERROR, "获取终端用户关联配置失败", str(e)) \ No newline at end of file + return fail(BizCode.INTERNAL_ERROR, "获取终端用户关联配置失败", str(e)) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py index ac1fb9a6..c8cc0460 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py @@ -1,10 +1,10 @@ -import os import json +import os import time -from app.core.logging_config import get_agent_logger -from app.db import get_db +from app.core.logging_config import get_agent_logger from app.core.memory.agent.models.problem_models import ProblemExtensionResponse +from app.core.memory.agent.services.optimized_llm_service import LLMServiceMixin from app.core.memory.agent.utils.llm_tools import ( PROJECT_ROOT_, ReadState, @@ -12,10 +12,9 @@ from app.core.memory.agent.utils.llm_tools import ( from app.core.memory.agent.utils.redis_tool import store from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService -from app.core.memory.agent.services.optimized_llm_service import LLMServiceMixin +from app.db import get_db_context template_root = os.path.join(PROJECT_ROOT_, 'memory', 'agent', 'utils', 'prompt') -db_session = next(get_db()) logger = get_agent_logger(__name__) @@ -53,13 +52,14 @@ async def Split_The_Problem(state: ReadState) -> ReadState: try: # 使用优化的LLM服务 - structured = await problem_service.call_llm_structured( - state=state, - db_session=db_session, - system_prompt=system_prompt, - response_model=ProblemExtensionResponse, - fallback_value=[] - ) + with get_db_context() as db_session: + structured = await problem_service.call_llm_structured( + state=state, + db_session=db_session, + system_prompt=system_prompt, + response_model=ProblemExtensionResponse, + fallback_value=[] + ) # 添加更详细的日志记录 logger.info(f"Split_The_Problem: 开始处理问题分解,内容长度: {len(content)}") @@ -171,13 +171,14 @@ async def Problem_Extension(state: ReadState) -> ReadState: try: # 使用优化的LLM服务 - response_content = await problem_service.call_llm_structured( - state=state, - db_session=db_session, - system_prompt=system_prompt, - response_model=ProblemExtensionResponse, - fallback_value=[] - ) + with get_db_context() as db_session: + response_content = await problem_service.call_llm_structured( + state=state, + db_session=db_session, + system_prompt=system_prompt, + response_model=ProblemExtensionResponse, + fallback_value=[] + ) logger.info(f"Problem_Extension: 开始处理问题扩展,问题数量: {len(databasets)}") diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py index 1880357c..06539ad1 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py @@ -6,31 +6,26 @@ import os # ===== 第三方库 ===== from langchain.agents import create_agent from langchain_openai import ChatOpenAI + from app.core.logging_config import get_agent_logger -from app.db import get_db, get_db_context - -from app.schemas import model_schema -from app.services.memory_config_service import MemoryConfigService -from app.services.model_service import ModelConfigService - -from app.core.memory.agent.services.search_service import SearchService -from app.core.memory.agent.utils.llm_tools import ( - COUNTState, - ReadState, - deduplicate_entries, - merge_to_key_value_pairs, -) from app.core.memory.agent.langgraph_graph.tools.tool import ( create_hybrid_retrieval_tool_sync, create_time_retrieval_tool, extract_tool_message_content, ) - +from app.core.memory.agent.services.search_service import SearchService +from app.core.memory.agent.utils.llm_tools import ( + ReadState, + deduplicate_entries, + merge_to_key_value_pairs, +) from app.core.rag.nlp.search import knowledge_retrieval +from app.db import get_db_context +from app.schemas import model_schema +from app.services.memory_config_service import MemoryConfigService +from app.services.model_service import ModelConfigService logger = get_agent_logger(__name__) -db = next(get_db()) - async def rag_config(state): @@ -50,10 +45,12 @@ async def rag_config(state): "reranker_top_k": 10 } return kb_config -async def rag_knowledge(state,question): + + +async def rag_knowledge(state, question): kb_config = await rag_config(state) end_user_id = state.get('end_user_id', '') - user_rag_memory_id=state.get("user_rag_memory_id",'') + user_rag_memory_id = state.get("user_rag_memory_id", '') retrieve_chunks_result = knowledge_retrieval(question, kb_config, [str(end_user_id)]) try: retrieval_knowledge = [i.page_content for i in retrieve_chunks_result] @@ -61,13 +58,13 @@ async def rag_knowledge(state,question): cleaned_query = question raw_results = clean_content logger.info(f" Using RAG storage with memory_id={user_rag_memory_id}") - except Exception : - retrieval_knowledge=[] + except Exception: + retrieval_knowledge = [] clean_content = '' raw_results = '' cleaned_query = question logger.info(f"No content retrieved from knowledge base: {user_rag_memory_id}") - return retrieval_knowledge,clean_content,cleaned_query,raw_results + return retrieval_knowledge, clean_content, cleaned_query, raw_results async def llm_infomation(state: ReadState) -> ReadState: @@ -113,7 +110,7 @@ async def clean_databases(data) -> str: # 收集所有内容 content_list = [] - + # 处理重排序结果 reranked = results.get('reranked_results', {}) if reranked: @@ -141,7 +138,6 @@ async def clean_databases(data) -> str: elif isinstance(item, str): text_parts.append(item) - return '\n'.join(text_parts).strip() except Exception as e: @@ -150,23 +146,23 @@ async def clean_databases(data) -> str: async def retrieve_nodes(state: ReadState) -> ReadState: - ''' 模型信息 ''' - problem_extension=state.get('problem_extension', '')['context'] - storage_type=state.get('storage_type', '') - user_rag_memory_id=state.get('user_rag_memory_id', '') - end_user_id=state.get('end_user_id', '') + problem_extension = state.get('problem_extension', '')['context'] + storage_type = state.get('storage_type', '') + user_rag_memory_id = state.get('user_rag_memory_id', '') + end_user_id = state.get('end_user_id', '') memory_config = state.get('memory_config', None) - original=state.get('data', '') - problem_list=[] - for key,values in problem_extension.items(): + original = state.get('data', '') + problem_list = [] + for key, values in problem_extension.items(): for data in values: problem_list.append(data) logger.info(f"Retrieve: storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}") + # 创建异步任务处理单个问题 async def process_question_nodes(idx, question): try: @@ -244,7 +240,7 @@ async def retrieve_nodes(state: ReadState) -> ReadState: send_verify = [] for i, j in zip(keys, val, strict=False): - if j!=['']: + if j != ['']: send_verify.append({ "Query_small": i, "Answer_Small": j @@ -257,15 +253,13 @@ async def retrieve_nodes(state: ReadState) -> ReadState: } logger.info(f"Collected {len(intermediate_outputs)} intermediate outputs from search results") - return {'retrieve':dup_databases} - - + return {'retrieve': dup_databases} async def retrieve(state: ReadState) -> ReadState: # 从state中获取end_user_id import time - start=time.time() + start = time.time() problem_extension = state.get('problem_extension', '')['context'] storage_type = state.get('storage_type', '') user_rag_memory_id = state.get('user_rag_memory_id', '') @@ -283,6 +277,7 @@ async def retrieve(state: ReadState) -> ReadState: with get_db_context() as db: # 使用同步数据库上下文管理器 config_service = MemoryConfigService(db) return await llm_infomation(state) + llm_config = await get_llm_info() api_key_obj = llm_config.api_keys[0] api_key = api_key_obj.api_key @@ -296,11 +291,11 @@ async def retrieve(state: ReadState) -> ReadState: ) time_retrieval_tool = create_time_retrieval_tool(end_user_id) - search_params = { "end_user_id": end_user_id, "return_raw_results": True } - hybrid_retrieval=create_hybrid_retrieval_tool_sync(memory_config, **search_params) + search_params = {"end_user_id": end_user_id, "return_raw_results": True} + hybrid_retrieval = create_hybrid_retrieval_tool_sync(memory_config, **search_params) agent = create_agent( llm, - tools=[time_retrieval_tool,hybrid_retrieval], + tools=[time_retrieval_tool, hybrid_retrieval], system_prompt=f"我是检索专家,可以根据适合的工具进行检索。当前使用的end_user_id是: {end_user_id}" ) @@ -314,7 +309,8 @@ async def retrieve(state: ReadState) -> ReadState: async with SEMAPHORE: # 限制并发 try: if storage_type == "rag" and user_rag_memory_id: - retrieval_knowledge, clean_content, cleaned_query, raw_results = await rag_knowledge(state, question) + retrieval_knowledge, clean_content, cleaned_query, raw_results = await rag_knowledge(state, + question) else: cleaned_query = question # 使用 asyncio 在线程池中运行同步的 agent.invoke @@ -413,5 +409,3 @@ async def retrieve(state: ReadState) -> ReadState: # json.dump(dup_databases, f, indent=4) logger.info(f"Collected {len(intermediate_outputs)} intermediate outputs from search results") return {'retrieve': dup_databases} - - diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py index cf832add..87606bf8 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py @@ -1,5 +1,3 @@ - - import os import time @@ -18,22 +16,24 @@ from app.core.memory.agent.utils.redis_tool import store from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService from app.core.rag.nlp.search import knowledge_retrieval - -from app.db import get_db +from app.db import get_db_context template_root = os.path.join(PROJECT_ROOT_, 'memory', 'agent', 'utils', 'prompt') logger = get_agent_logger(__name__) -db_session = next(get_db()) + class SummaryNodeService(LLMServiceMixin): """总结节点服务类""" - + def __init__(self): super().__init__() self.template_service = TemplateService(template_root) + # 创建全局服务实例 summary_service = SummaryNodeService() + + async def rag_config(state): user_rag_memory_id = state.get('user_rag_memory_id', '') kb_config = { @@ -51,10 +51,12 @@ async def rag_config(state): "reranker_top_k": 10 } return kb_config -async def rag_knowledge(state,question): + + +async def rag_knowledge(state, question): kb_config = await rag_config(state) end_user_id = state.get('end_user_id', '') - user_rag_memory_id=state.get("user_rag_memory_id",'') + user_rag_memory_id = state.get("user_rag_memory_id", '') retrieve_chunks_result = knowledge_retrieval(question, kb_config, [str(end_user_id)]) try: retrieval_knowledge = [i.page_content for i in retrieve_chunks_result] @@ -62,25 +64,28 @@ async def rag_knowledge(state,question): cleaned_query = question raw_results = clean_content logger.info(f" Using RAG storage with memory_id={user_rag_memory_id}") - except Exception : - retrieval_knowledge=[] + except Exception: + retrieval_knowledge = [] clean_content = '' raw_results = '' cleaned_query = question logger.info(f"No content retrieved from knowledge base: {user_rag_memory_id}") - return retrieval_knowledge,clean_content,cleaned_query,raw_results + return retrieval_knowledge, clean_content, cleaned_query, raw_results + async def summary_history(state: ReadState) -> ReadState: end_user_id = state.get("end_user_id", '') history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) return history -async def summary_llm(state: ReadState, history, retrieve_info, template_name, operation_name, response_model,search_mode) -> str: + +async def summary_llm(state: ReadState, history, retrieve_info, template_name, operation_name, response_model, + search_mode) -> str: """ 增强的summary_llm函数,包含更好的错误处理和数据验证 """ data = state.get("data", '') - + # 构建系统提示词 if str(search_mode) == "0": system_prompt = await summary_service.template_service.render_template( @@ -99,18 +104,19 @@ async def summary_llm(state: ReadState, history, retrieve_info, template_name, o ) try: # 使用优化的LLM服务进行结构化输出 - structured = await summary_service.call_llm_structured( - state=state, - db_session=db_session, - system_prompt=system_prompt, - response_model=response_model, - fallback_value=None - ) + with get_db_context() as db_session: + structured = await summary_service.call_llm_structured( + state=state, + db_session=db_session, + system_prompt=system_prompt, + response_model=response_model, + fallback_value=None + ) # 验证结构化响应 if structured is None: logger.warning("LLM返回None,使用默认回答") return "信息不足,无法回答" - + # 根据操作类型提取答案 if operation_name == "summary": aimessages = getattr(structured, 'query_answer', None) or "信息不足,无法回答" @@ -121,16 +127,16 @@ async def summary_llm(state: ReadState, history, retrieve_info, template_name, o else: logger.warning("结构化响应缺少data字段") aimessages = "信息不足,无法回答" - + # 验证答案不为空 if not aimessages or aimessages.strip() == "": aimessages = "信息不足,无法回答" - + return aimessages - + except Exception as e: logger.error(f"结构化输出失败: {e}", exc_info=True) - + # 尝试非结构化输出作为fallback try: logger.info("尝试非结构化输出作为fallback") @@ -140,7 +146,7 @@ async def summary_llm(state: ReadState, history, retrieve_info, template_name, o system_prompt=system_prompt, fallback_message="信息不足,无法回答" ) - + if response and response.strip(): # 简单清理响应 cleaned_response = response.strip() @@ -148,16 +154,17 @@ async def summary_llm(state: ReadState, history, retrieve_info, template_name, o if cleaned_response.startswith('```'): lines = cleaned_response.split('\n') cleaned_response = '\n'.join(lines[1:-1]) - + return cleaned_response else: return "信息不足,无法回答" - + except Exception as fallback_error: logger.error(f"Fallback也失败: {fallback_error}") return "信息不足,无法回答" -async def summary_redis_save(state: ReadState,aimessages) -> ReadState: + +async def summary_redis_save(state: ReadState, aimessages) -> ReadState: data = state.get("data", '') end_user_id = state.get("end_user_id", '') await SessionService(store).save_session( @@ -169,10 +176,12 @@ async def summary_redis_save(state: ReadState,aimessages) -> ReadState: ) await SessionService(store).cleanup_duplicates() logger.info(f"sessionid: {aimessages} 写入成功") -async def summary_prompt(state: ReadState,aimessages,raw_results) -> ReadState: - storage_type=state.get("storage_type",'') - user_rag_memory_id=state.get("user_rag_memory_id",'') - data=state.get("data", '') + + +async def summary_prompt(state: ReadState, aimessages, raw_results) -> ReadState: + storage_type = state.get("storage_type", '') + user_rag_memory_id = state.get("user_rag_memory_id", '') + data = state.get("data", '') input_summary = { "status": "success", "summary_result": aimessages, @@ -189,14 +198,14 @@ async def summary_prompt(state: ReadState,aimessages,raw_results) -> ReadState: "user_rag_memory_id": user_rag_memory_id } } - retrieve={ + retrieve = { "status": "success", "summary_result": aimessages, "storage_type": storage_type, "user_rag_memory_id": user_rag_memory_id, "_intermediate": { "type": "retrieval_summary", - "title":"快速检索", + "title": "快速检索", "summary": aimessages, "query": data, "storage_type": storage_type, @@ -204,17 +213,18 @@ async def summary_prompt(state: ReadState,aimessages,raw_results) -> ReadState: } } - return input_summary,retrieve + return input_summary, retrieve + async def Input_Summary(state: ReadState) -> ReadState: - start=time.time() - storage_type=state.get("storage_type",'') + start = time.time() + storage_type = state.get("storage_type", '') memory_config = state.get('memory_config', None) - user_rag_memory_id=state.get("user_rag_memory_id",'') - data=state.get("data", '') - end_user_id=state.get("end_user_id", '') + user_rag_memory_id = state.get("user_rag_memory_id", '') + data = state.get("data", '') + end_user_id = state.get("end_user_id", '') logger.info(f"Input_Summary: storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}") - history = await summary_history( state) + history = await summary_history(state) search_params = { "end_user_id": end_user_id, "question": data, @@ -223,12 +233,13 @@ async def Input_Summary(state: ReadState) -> ReadState: } try: - if storage_type!="rag": - retrieve_info, question, raw_results = await SearchService().execute_hybrid_search(**search_params, memory_config=memory_config) + if storage_type != "rag": + retrieve_info, question, raw_results = await SearchService().execute_hybrid_search(**search_params, + memory_config=memory_config) else: retrieval_knowledge, retrieve_info, question, raw_results = await rag_knowledge(state, data) except Exception as e: - logger.error( f"Input_Summary: hybrid_search failed, using empty results: {e}", exc_info=True ) + logger.error(f"Input_Summary: hybrid_search failed, using empty results: {e}", exc_info=True) retrieve_info, question, raw_results = "", data, [] try: # aimessages=await summary_llm(state,history,retrieve_info,'Retrieve_Summary_prompt.jinja2', @@ -237,8 +248,8 @@ async def Input_Summary(state: ReadState) -> ReadState: summary_result = await summary_prompt(state, retrieve_info, retrieve_info) summary = summary_result[0] except Exception as e: - logger.error( f"Input_Summary failed: {e}", exc_info=True ) - summary= { + logger.error(f"Input_Summary failed: {e}", exc_info=True) + summary = { "status": "fail", "summary_result": "信息不足,无法回答", "storage_type": storage_type, @@ -251,30 +262,31 @@ async def Input_Summary(state: ReadState) -> ReadState: except Exception: duration = 0.0 log_time('检索', duration) - return {"summary":summary} + return {"summary": summary} -async def Retrieve_Summary(state: ReadState)-> ReadState: - retrieve=state.get("retrieve", '') - history = await summary_history( state) + +async def Retrieve_Summary(state: ReadState) -> ReadState: + retrieve = state.get("retrieve", '') + history = await summary_history(state) import json - with open("检索.json","w",encoding='utf-8') as f: + with open("检索.json", "w", encoding='utf-8') as f: f.write(json.dumps(retrieve, indent=4, ensure_ascii=False)) - retrieve=retrieve.get("Expansion_issue", []) - start=time.time() - retrieve_info_str=[] + retrieve = retrieve.get("Expansion_issue", []) + start = time.time() + retrieve_info_str = [] for data in retrieve: - if data=='': - retrieve_info_str='' + if data == '': + retrieve_info_str = '' else: for key, value in data.items(): - if key=='Answer_Small': + if key == 'Answer_Small': for i in value: retrieve_info_str.append(i) - retrieve_info_str=list(set(retrieve_info_str)) - retrieve_info_str='\n'.join(retrieve_info_str) + retrieve_info_str = list(set(retrieve_info_str)) + retrieve_info_str = '\n'.join(retrieve_info_str) - aimessages=await summary_llm(state,history,retrieve_info_str, - 'direct_summary_prompt.jinja2','retrieve_summary',RetrieveSummaryResponse,"1") + aimessages = await summary_llm(state, history, retrieve_info_str, + 'direct_summary_prompt.jinja2', 'retrieve_summary', RetrieveSummaryResponse, "1") if '信息不足,无法回答' not in str(aimessages) or str(aimessages) != "": await summary_redis_save(state, aimessages) if aimessages == '': @@ -286,33 +298,33 @@ async def Retrieve_Summary(state: ReadState)-> ReadState: except Exception: duration = 0.0 log_time('Retrieval summary', duration) - + # 修复协程调用 - 先await,然后访问返回值 summary_result = await summary_prompt(state, aimessages, retrieve_info_str) summary = summary_result[1] - return {"summary":summary} + return {"summary": summary} -async def Summary(state: ReadState)-> ReadState: - start=time.time() +async def Summary(state: ReadState) -> ReadState: + start = time.time() query = state.get("data", '') - verify=state.get("verify", '') - verify_expansion_issue=verify.get("verified_data", '') - retrieve_info_str='' + verify = state.get("verify", '') + verify_expansion_issue = verify.get("verified_data", '') + retrieve_info_str = '' for data in verify_expansion_issue: for key, value in data.items(): - if key=='answer_small': + if key == 'answer_small': for i in value: - retrieve_info_str+=i+'\n' - history=await summary_history(state) + retrieve_info_str += i + '\n' + history = await summary_history(state) data = { "query": query, "history": history, "retrieve_info": retrieve_info_str } - aimessages=await summary_llm(state,history,data, - 'summary_prompt.jinja2','summary',SummaryResponse,0) + aimessages = await summary_llm(state, history, data, + 'summary_prompt.jinja2', 'summary', SummaryResponse, 0) if '信息不足,无法回答' not in str(aimessages) or str(aimessages) != "": await summary_redis_save(state, aimessages) @@ -327,10 +339,12 @@ async def Summary(state: ReadState)-> ReadState: # 修复协程调用 - 先await,然后访问返回值 summary_result = await summary_prompt(state, aimessages, retrieve_info_str) summary = summary_result[1] - return {"summary":summary} -async def Summary_fails(state: ReadState)-> ReadState: - storage_type=state.get("storage_type", '') - user_rag_memory_id=state.get("user_rag_memory_id", '') + return {"summary": summary} + + +async def Summary_fails(state: ReadState) -> ReadState: + storage_type = state.get("storage_type", '') + user_rag_memory_id = state.get("user_rag_memory_id", '') history = await summary_history(state) query = state.get("data", '') verify = state.get("verify", '') @@ -346,12 +360,12 @@ async def Summary_fails(state: ReadState)-> ReadState: "history": history, "retrieve_info": retrieve_info_str } - aimessages = await summary_llm(state, history, data, - 'fail_summary_prompt.jinja2', 'summary', SummaryResponse, 0) - result= { + aimessages = await summary_llm(state, history, data, + 'fail_summary_prompt.jinja2', 'summary', SummaryResponse, 0) + result = { "status": "success", "summary_result": aimessages, "storage_type": storage_type, "user_rag_memory_id": user_rag_memory_id } - return {"summary":result} \ No newline at end of file + return {"summary": result} diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py index b809faf2..3f7b491e 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py @@ -1,8 +1,9 @@ +import asyncio import os -from app.core.logging_config import get_agent_logger -from app.db import get_db +from app.core.logging_config import get_agent_logger from app.core.memory.agent.models.verification_models import VerificationResult +from app.core.memory.agent.services.optimized_llm_service import LLMServiceMixin from app.core.memory.agent.utils.llm_tools import ( PROJECT_ROOT_, ReadState, @@ -10,28 +11,30 @@ from app.core.memory.agent.utils.llm_tools import ( from app.core.memory.agent.utils.redis_tool import store from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService -from app.core.memory.agent.services.optimized_llm_service import LLMServiceMixin +from app.db import get_db_context template_root = os.path.join(PROJECT_ROOT_, 'memory', 'agent', 'utils', 'prompt') -db_session = next(get_db()) logger = get_agent_logger(__name__) + class VerificationNodeService(LLMServiceMixin): """验证节点服务类""" - + def __init__(self): super().__init__() self.template_service = TemplateService(template_root) + # 创建全局服务实例 verification_service = VerificationNodeService() + async def Verify_prompt(state: ReadState, messages_deal: VerificationResult): """处理验证结果并生成输出格式""" storage_type = state.get('storage_type', '') user_rag_memory_id = state.get('user_rag_memory_id', '') data = state.get('data', '') - + # 将 VerificationItem 对象转换为字典列表 verified_data = [] if messages_deal.expansion_issue: @@ -40,7 +43,7 @@ async def Verify_prompt(state: ReadState, messages_deal: VerificationResult): verified_data.append(item.model_dump()) elif isinstance(item, dict): verified_data.append(item) - + Verify_result = { "status": messages_deal.split_result, "verified_data": verified_data, @@ -58,34 +61,37 @@ async def Verify_prompt(state: ReadState, messages_deal: VerificationResult): } } return Verify_result + + async def Verify(state: ReadState): logger.info("=== Verify 节点开始执行 ===") try: content = state.get('data', '') end_user_id = state.get('end_user_id', '') memory_config = state.get('memory_config', None) - + logger.info(f"Verify: content={content[:50] if content else 'empty'}..., end_user_id={end_user_id}") history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) logger.info(f"Verify: 获取历史记录完成,history length={len(history)}") retrieve = state.get("retrieve", {}) - logger.info(f"Verify: retrieve data type={type(retrieve)}, keys={retrieve.keys() if isinstance(retrieve, dict) else 'N/A'}") - + logger.info( + f"Verify: retrieve data type={type(retrieve)}, keys={retrieve.keys() if isinstance(retrieve, dict) else 'N/A'}") + retrieve_expansion = retrieve.get("Expansion_issue", []) if isinstance(retrieve, dict) else [] logger.info(f"Verify: Expansion_issue length={len(retrieve_expansion)}") - + messages = { "Query": content, "Expansion_issue": retrieve_expansion } logger.info("Verify: 开始渲染模板") - + # 生成 JSON schema 以指导 LLM 输出正确格式 json_schema = VerificationResult.model_json_schema() - + system_prompt = await verification_service.template_service.render_template( template_name='split_verify_prompt.jinja2', operation_name='split_verify_prompt', @@ -94,29 +100,30 @@ async def Verify(state: ReadState): json_schema=json_schema ) logger.info(f"Verify: 模板渲染完成,prompt length={len(system_prompt)}") - + # 使用优化的LLM服务,添加超时保护 logger.info("Verify: 开始调用 LLM") try: # 添加 asyncio.wait_for 超时包裹,防止无限等待 # 超时时间设置为 150 秒(比 LLM 配置的 120 秒稍长) - import asyncio - structured = await asyncio.wait_for( - verification_service.call_llm_structured( - state=state, - db_session=db_session, - system_prompt=system_prompt, - response_model=VerificationResult, - fallback_value={ - "query": content, - "history": history if isinstance(history, list) else [], - "expansion_issue": [], - "split_result": "failed", - "reason": "验证失败或超时" - } - ), - timeout=150.0 # 150秒超时 - ) + + with get_db_context() as db_session: + structured = await asyncio.wait_for( + verification_service.call_llm_structured( + state=state, + db_session=db_session, + system_prompt=system_prompt, + response_model=VerificationResult, + fallback_value={ + "query": content, + "history": history if isinstance(history, list) else [], + "expansion_issue": [], + "split_result": "failed", + "reason": "验证失败或超时" + } + ), + timeout=150.0 # 150秒超时 + ) logger.info(f"Verify: LLM 调用完成,result={structured}") except asyncio.TimeoutError: logger.error("Verify: LLM 调用超时(150秒),使用 fallback 值") @@ -127,11 +134,11 @@ async def Verify(state: ReadState): split_result="failed", reason="LLM调用超时" ) - + result = await Verify_prompt(state, structured) logger.info("=== Verify 节点执行完成 ===") return {"verify": result} - + except Exception as e: logger.error(f"Verify 节点执行失败: {e}", exc_info=True) # 返回失败的验证结果 @@ -152,4 +159,4 @@ async def Verify(state: ReadState): "user_rag_memory_id": state.get('user_rag_memory_id', '') } } - } \ No newline at end of file + } diff --git a/api/app/core/memory/agent/langgraph_graph/read_graph.py b/api/app/core/memory/agent/langgraph_graph/read_graph.py index 3476d0ec..cba1b230 100644 --- a/api/app/core/memory/agent/langgraph_graph/read_graph.py +++ b/api/app/core/memory/agent/langgraph_graph/read_graph.py @@ -5,7 +5,6 @@ from langchain_core.messages import HumanMessage from langgraph.constants import START, END from langgraph.graph import StateGraph - from app.db import get_db from app.services.memory_config_service import MemoryConfigService @@ -32,7 +31,6 @@ from app.core.memory.agent.langgraph_graph.routing.routers import ( ) - @asynccontextmanager async def make_read_graph(): """创建并返回 LangGraph 工作流""" @@ -49,7 +47,7 @@ async def make_read_graph(): workflow.add_node("Retrieve_Summary", Retrieve_Summary) workflow.add_node("Summary", Summary) workflow.add_node("Summary_fails", Summary_fails) - + # 添加边 workflow.add_edge(START, "content_input") workflow.add_conditional_edges("content_input", Split_continue) @@ -62,20 +60,20 @@ async def make_read_graph(): workflow.add_edge("Summary_fails", END) workflow.add_edge("Summary", END) - '''-----''' # workflow.add_edge("Retrieve", END) - + # 编译工作流 graph = workflow.compile() yield graph - + except Exception as e: print(f"创建工作流失败: {e}") raise finally: print("工作流创建完成") + async def main(): """主函数 - 运行工作流""" message = "昨天有什么好看的电影" @@ -92,17 +90,19 @@ async def main(): service_name="MemoryAgentService" ) import time - start=time.time() + start = time.time() try: async with make_read_graph() as graph: config = {"configurable": {"thread_id": end_user_id}} # 初始状态 - 包含所有必要字段 - initial_state = {"messages": [HumanMessage(content=message)] ,"search_switch":search_switch,"end_user_id":end_user_id - ,"storage_type":storage_type,"user_rag_memory_id":user_rag_memory_id,"memory_config":memory_config} + initial_state = {"messages": [HumanMessage(content=message)], "search_switch": search_switch, + "end_user_id": end_user_id + , "storage_type": storage_type, "user_rag_memory_id": user_rag_memory_id, + "memory_config": memory_config} # 获取节点更新信息 _intermediate_outputs = [] summary = '' - + async for update_event in graph.astream( initial_state, stream_mode="updates", @@ -110,7 +110,7 @@ async def main(): ): for node_name, node_data in update_event.items(): print(f"处理节点: {node_name}") - + # 处理不同Summary节点的返回结构 if 'Summary' in node_name: if 'InputSummary' in node_data and 'summary_result' in node_data['InputSummary']: @@ -125,23 +125,22 @@ async def main(): spit_data = node_data.get('spit_data', {}).get('_intermediate', None) if spit_data and spit_data != [] and spit_data != {}: _intermediate_outputs.append(spit_data) - + # Problem_Extension 节点 problem_extension = node_data.get('problem_extension', {}).get('_intermediate', None) if problem_extension and problem_extension != [] and problem_extension != {}: _intermediate_outputs.append(problem_extension) - + # Retrieve 节点 retrieve_node = node_data.get('retrieve', {}).get('_intermediate_outputs', None) if retrieve_node and retrieve_node != [] and retrieve_node != {}: _intermediate_outputs.extend(retrieve_node) - + # Verify 节点 verify_n = node_data.get('verify', {}).get('_intermediate', None) if verify_n and verify_n != [] and verify_n != {}: _intermediate_outputs.append(verify_n) - # Summary 节点 summary_n = node_data.get('summary', {}).get('_intermediate', None) if summary_n and summary_n != [] and summary_n != {}: @@ -161,17 +160,20 @@ async def main(): # print(f"=== 最终摘要 ===") print(summary) - + except Exception as e: import traceback traceback.print_exc() + finally: + db_session.close() - end=time.time() - print(100*'y') - print(f"总耗时: {end-start}s") - print(100*'y') + end = time.time() + print(100 * 'y') + print(f"总耗时: {end - start}s") + print(100 * 'y') if __name__ == "__main__": import asyncio + asyncio.run(main()) diff --git a/api/app/core/memory/agent/utils/llm_client_pool.py b/api/app/core/memory/agent/utils/llm_client_pool.py deleted file mode 100644 index fddd54f6..00000000 --- a/api/app/core/memory/agent/utils/llm_client_pool.py +++ /dev/null @@ -1,56 +0,0 @@ - -import asyncio -from typing import Dict, Optional -from app.core.memory.utils.llm.llm_utils import get_llm_client_fast -from app.db import get_db -from app.core.logging_config import get_agent_logger - -logger = get_agent_logger(__name__) - -class LLMClientPool: - """LLM客户端连接池""" - - def __init__(self, max_size: int = 5): - self.max_size = max_size - self.pools: Dict[str, asyncio.Queue] = {} - self.active_clients: Dict[str, int] = {} - - async def get_client(self, llm_model_id: str): - """获取LLM客户端""" - if llm_model_id not in self.pools: - self.pools[llm_model_id] = asyncio.Queue(maxsize=self.max_size) - self.active_clients[llm_model_id] = 0 - - pool = self.pools[llm_model_id] - - try: - # 尝试从池中获取客户端 - client = pool.get_nowait() - logger.debug(f"从池中获取LLM客户端: {llm_model_id}") - return client - except asyncio.QueueEmpty: - # 池为空,创建新客户端 - if self.active_clients[llm_model_id] < self.max_size: - db_session = next(get_db()) - client = get_llm_client_fast(llm_model_id, db_session) - self.active_clients[llm_model_id] += 1 - logger.debug(f"创建新LLM客户端: {llm_model_id}") - return client - else: - # 等待可用客户端 - logger.debug(f"等待LLM客户端可用: {llm_model_id}") - return await pool.get() - - async def return_client(self, llm_model_id: str, client): - """归还LLM客户端到池中""" - if llm_model_id in self.pools: - try: - self.pools[llm_model_id].put_nowait(client) - logger.debug(f"归还LLM客户端到池: {llm_model_id}") - except asyncio.QueueFull: - # 池已满,丢弃客户端 - self.active_clients[llm_model_id] -= 1 - logger.debug(f"池已满,丢弃LLM客户端: {llm_model_id}") - -# 全局客户端池 -llm_client_pool = LLMClientPool() diff --git a/api/app/core/workflow/nodes/agent/node.py b/api/app/core/workflow/nodes/agent/node.py index 3fbbbdbc..8959e27c 100644 --- a/api/app/core/workflow/nodes/agent/node.py +++ b/api/app/core/workflow/nodes/agent/node.py @@ -14,7 +14,7 @@ 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 from app.core.workflow.variable.base_variable import VariableType -from app.db import get_db +from app.db import get_db_context from app.models import AppRelease from app.services.draft_run_service import AgentRunService @@ -39,7 +39,7 @@ class AgentNode(BaseNode): def _output_types(self) -> dict[str, VariableType]: return {"output": VariableType.STRING} - def _prepare_agent(self, variable_pool: VariablePool) -> tuple[AgentRunService, AppRelease, str]: + def _prepare_agent(self, variable_pool: VariablePool) -> tuple[AppRelease, str]: """准备 Agent(公共逻辑) Args: @@ -57,17 +57,17 @@ class AgentNode(BaseNode): if not agent_id: raise ValueError(f"节点 {self.node_id} 缺少 agent_id 配置") - db = next(get_db()) - release = db.query(AppRelease).filter( - AppRelease.id == agent_id - ).first() + with get_db_context() as db: + release = db.query(AppRelease).filter( + AppRelease.id == agent_id + ).first() if not release: raise ValueError(f"Agent 不存在: {agent_id}") - draft_service = AgentRunService(db) + - return draft_service, release, message + return release, message async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> dict[str, Any]: """非流式执行 @@ -79,19 +79,21 @@ class AgentNode(BaseNode): Returns: 状态更新字典 """ - draft_service, release, message = self._prepare_agent(variable_pool) + release, message = self._prepare_agent(variable_pool) logger.info(f"节点 {self.node_id} 开始执行 Agent 调用(非流式)") - - # 执行 Agent(非流式) - result = await draft_service.run( - agent_config=release.config, - model_config=None, - message=message, - workspace_id=variable_pool.get_value("sys.workspace_id"), - user_id=state.get("user_id"), - variables=variable_pool.get_all_conversation_vars() - ) + with get_db_context() as db: + draft_service = AgentRunService(db) + + # 执行 Agent(非流式) + result = await draft_service.run( + agent_config=release.config, + model_config=None, + message=message, + workspace_id=variable_pool.get_value("sys.workspace_id"), + user_id=state.get("user_id"), + variables=variable_pool.get_all_conversation_vars() + ) response = result.get("response", "") @@ -118,34 +120,35 @@ class AgentNode(BaseNode): Yields: 流式事件字典 """ - draft_service, release, message = self._prepare_agent(variable_pool) + release, message = self._prepare_agent(variable_pool) logger.info(f"节点 {self.node_id} 开始执行 Agent 调用(流式)") # 累积完整响应 full_response = "" - + with get_db_context() as db: + draft_service = AgentRunService(db) # 执行 Agent(流式) - async for chunk in draft_service.run_stream( - agent_config=release.config, - model_config=None, - message=message, - workspace_id=variable_pool.get_value("sys.workspace_id"), - user_id=state.get("user_id"), - variables=variable_pool.get_all_conversation_vars() - ): - # 提取内容 - content = chunk.get("content", "") - full_response += content - - # 流式返回每个 chunk - yield { - "type": "chunk", - "node_id": self.node_id, - "content": content, - "full_content": full_response, - "meta_data": chunk.get("meta_data", {}) - } + async for chunk in draft_service.run_stream( + agent_config=release.config, + model_config=None, + message=message, + workspace_id=variable_pool.get_value("sys.workspace_id"), + user_id=state.get("user_id"), + variables=variable_pool.get_all_conversation_vars() + ): + # 提取内容 + content = chunk.get("content", "") + full_response += content + + # 流式返回每个 chunk + yield { + "type": "chunk", + "node_id": self.node_id, + "content": content, + "full_content": full_response, + "meta_data": chunk.get("meta_data", {}) + } logger.info(f"节点 {self.node_id} Agent 调用完成,输出长度: {len(full_response)}") diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index bb68c815..5026bf27 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -22,6 +22,7 @@ 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.rag.nlp.search import knowledge_retrieval +from app.db import get_db_context from app.models import AgentConfig, ModelConfig from app.repositories.tool_repository import ToolRepository from app.schemas.app_schema import FileInput @@ -103,9 +104,7 @@ def create_long_term_memory_tool( """ logger.info(f" 长期记忆工具被调用!question={question}, user={end_user_id}") try: - from app.db import get_db - db = next(get_db()) - try: + with get_db_context() as db: memory_content = asyncio.run( MemoryAgentService().read_memory( end_user_id=end_user_id, @@ -127,9 +126,6 @@ def create_long_term_memory_tool( logger.info(f"读取任务状态:{status}") if memory_content: memory_content = memory_content['answer'] - - finally: - db.close() logger.info(f'用户ID:Agent:{end_user_id}') logger.debug("调用长期记忆 API", extra={"question": question, "end_user_id": end_user_id}) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 16aee283..f272c541 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -13,7 +13,6 @@ TODO: Refactor get_end_user_connected_config """ import json import os -import re import time import uuid from typing import Any, AsyncGenerator, Dict, List, Optional @@ -35,12 +34,10 @@ from app.core.memory.agent.utils.messages_tools import ( reorder_output_results, ) from app.core.memory.agent.utils.type_classifier import status_typle -from app.core.memory.agent.utils.write_tools import write # 新增:直接导入 write 函数 -from app.core.memory.analytics.hot_memory_tags import get_hot_memory_tags, get_interest_distribution +from app.core.memory.analytics.hot_memory_tags import get_interest_distribution from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.models.knowledge_model import Knowledge, KnowledgeType -from app.repositories.memory_short_repository import ShortTermMemoryRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_agent_schema import Write_UserInput from app.schemas.memory_config_schema import ConfigurationError @@ -69,7 +66,8 @@ class MemoryAgentService: logger.info(f"Write operation successful for group {end_user_id} with config_id {config_id}") # 记录成功的操作 if audit_logger: - audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=True, + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, + success=True, duration=duration, details={"message_length": len(message)}) return context else: @@ -88,8 +86,6 @@ class MemoryAgentService: raise ValueError(f"写入失败: {messages}") - - def extract_tool_call_info(self, event: Dict) -> bool: """Extract tool call information from event""" last_message = event["messages"][-1] @@ -271,7 +267,8 @@ class MemoryAgentService: logger.info("Log streaming completed, cleaning up resources") # LogStreamer uses context manager for file handling, so cleanup is automatic - async def write_memory(self, end_user_id: str, messages: list[dict], config_id: Optional[uuid.UUID]|int, db: Session, storage_type: str, user_rag_memory_id: str, language: str = "zh") -> str: + async def write_memory(self, end_user_id: str, messages: list[dict], config_id: Optional[uuid.UUID] | int, + db: Session, storage_type: str, user_rag_memory_id: str, language: str = "zh") -> str: """ Process write operation with config_id @@ -300,7 +297,8 @@ class MemoryAgentService: config_id = connected_config.get("memory_config_id") logger.info(f"Resolved config from end_user: config_id={config_id}, workspace_id={workspace_id}") if config_id is None and workspace_id is None: - raise ValueError(f"No memory configuration found for end_user {end_user_id}. Please ensure the user has a connected memory configuration.") + raise ValueError( + f"No memory configuration found for end_user {end_user_id}. Please ensure the user has a connected memory configuration.") except Exception as e: if "No memory configuration found" in str(e): raise # Re-raise our specific error @@ -331,7 +329,8 @@ class MemoryAgentService: # Log failed operation if audit_logger: duration = time.time() - start_time - audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, + success=False, duration=duration, error=error_msg) raise ValueError(error_msg) @@ -351,9 +350,9 @@ class MemoryAgentService: langchain_messages.append(HumanMessage(content=msg['content'])) elif msg['role'] == 'assistant': langchain_messages.append(AIMessage(content=msg['content'])) - print(100*'-') + print(100 * '-') print(langchain_messages) - print(100*'-') + print(100 * '-') # 初始状态 - 包含所有必要字段 initial_state = { "messages": langchain_messages, @@ -375,29 +374,28 @@ class MemoryAgentService: contents = massages.get('write_result') # Convert messages back to string for logging message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, contents) + return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, + contents) except Exception as e: # Ensure proper error handling and logging error_msg = f"Write operation failed: {str(e)}" logger.error(error_msg) if audit_logger: duration = time.time() - start_time - audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, + success=False, duration=duration, error=error_msg) raise ValueError(error_msg) - - - async def read_memory( - self, - end_user_id: str, - message: str, - history: List[Dict], - search_switch: str, - config_id: Optional[uuid.UUID]|int, - db: Session, - storage_type: str, - user_rag_memory_id: str) -> Dict: + self, + end_user_id: str, + message: str, + history: List[Dict], + search_switch: str, + config_id: Optional[uuid.UUID] | int, + db: Session, + storage_type: str, + user_rag_memory_id: str) -> Dict: """ Process read operation with config_id @@ -425,7 +423,7 @@ class MemoryAgentService: import time start_time = time.time() - ori_message= message + ori_message = message # Resolve config_id and workspace_id # Always get workspace_id from end_user for fallback, even if config_id is provided @@ -437,7 +435,8 @@ class MemoryAgentService: config_id = connected_config.get("memory_config_id") logger.info(f"Resolved config from end_user: config_id={config_id}, workspace_id={workspace_id}") if config_id is None and workspace_id is None: - raise ValueError(f"No memory configuration found for end_user {end_user_id}. Please ensure the user has a connected memory configuration.") + raise ValueError( + f"No memory configuration found for end_user {end_user_id}. Please ensure the user has a connected memory configuration.") except Exception as e: if "No memory configuration found" in str(e): raise # Re-raise our specific error @@ -454,7 +453,6 @@ class MemoryAgentService: except ImportError: audit_logger = None - config_load_start = time.time() try: # Use a separate database session to avoid transaction failures @@ -562,34 +560,35 @@ class MemoryAgentService: from app.repositories.memory_short_repository import ( ShortTermMemoryRepository, ) - + retrieved_content = [] repo = ShortTermMemoryRepository(db) - + if str(search_switch) != "2": for intermediate in _intermediate_outputs: logger.debug(f"处理中间结果: {intermediate}") intermediate_type = intermediate.get('type', '') - + if intermediate_type == "search_result": query = intermediate.get('query', '') raw_results = intermediate.get('raw_results', {}) try: reranked_results = raw_results.get('reranked_results', []) - statements = [statement['statement'] for statement in reranked_results.get('statements', [])] + statements = [statement['statement'] for statement in + reranked_results.get('statements', [])] except Exception: statements = [] - + # 去重 statements = list(set(statements)) - + if query and statements: retrieved_content.append({query: statements}) - + # 如果 retrieved_content 为空,设置为空字符串 if retrieved_content == []: retrieved_content = '' - + # 只有当回答不是"信息不足"且不是快速检索时才保存 if '信息不足,无法回答。' != str(summary) and str(search_switch).strip() != "2": # 使用 upsert 方法 @@ -602,15 +601,17 @@ class MemoryAgentService: ) logger.info(f"成功保存短期记忆: end_user_id={end_user_id}, search_switch={search_switch}") else: - logger.debug(f"跳过保存短期记忆: summary={summary[:50] if summary else 'None'}, search_switch={search_switch}") - + logger.debug( + f"跳过保存短期记忆: summary={summary[:50] if summary else 'None'}, search_switch={search_switch}") + except Exception as save_error: # 保存失败不应该影响主流程,只记录错误 logger.error(f"保存短期记忆失败: {str(save_error)}", exc_info=True) # Log successful operation total_time = time.time() - start_time - logger.info(f"[PERF] read_memory completed successfully in {total_time:.4f}s (config: {config_load_time:.4f}s, graph: {graph_exec_time:.4f}s)") + logger.info( + f"[PERF] read_memory completed successfully in {total_time:.4f}s (config: {config_load_time:.4f}s, graph: {graph_exec_time:.4f}s)") if audit_logger: duration = time.time() - start_time audit_logger.log_operation( @@ -641,7 +642,6 @@ class MemoryAgentService: ) raise ValueError(error_msg) - def get_messages_list(self, user_input: Write_UserInput) -> list[dict]: """ Get standardized message list from user input. @@ -657,41 +657,43 @@ class MemoryAgentService: """ from app.core.logging_config import get_api_logger logger = get_api_logger() - + if len(user_input.messages) == 0: logger.error("Validation failed: Message list cannot be empty") raise ValueError("Message list cannot be empty") - + for idx, msg in enumerate(user_input.messages): if not isinstance(msg, dict): logger.error(f"Validation failed: Message {idx} is not a dict: {type(msg)}") - raise ValueError(f"Message format error: Message must be a dictionary. Error message index: {idx}, type: {type(msg)}") - + raise ValueError( + f"Message format error: Message must be a dictionary. Error message index: {idx}, type: {type(msg)}") + if 'role' not in msg: logger.error(f"Validation failed: Message {idx} missing 'role' field: {msg}") raise ValueError(f"Message format error: Message must contain 'role' field. Error message index: {idx}") - + if 'content' not in msg: logger.error(f"Validation failed: Message {idx} missing 'content' field: {msg}") - raise ValueError(f"Message format error: Message must contain 'content' field. Error message index: {idx}") - + raise ValueError( + f"Message format error: Message must contain 'content' field. Error message index: {idx}") + if msg['role'] not in ['user', 'assistant']: logger.error(f"Validation failed: Message {idx} invalid role: {msg['role']}") raise ValueError(f"Role must be 'user' or 'assistant', got: {msg['role']}. Message index: {idx}") - + if not msg['content'] or not msg['content'].strip(): logger.error(f"Validation failed: Message {idx} content is empty") raise ValueError(f"Message content cannot be empty. Message index: {idx}, role: {msg['role']}") - + logger.info(f"Validation successful: Structured message list, count: {len(user_input.messages)}") return user_input.messages async def classify_message_type( - self, - message: str, - config_id: UUID, - db: Session, - workspace_id: Optional[UUID] = None + self, + message: str, + config_id: UUID, + db: Session, + workspace_id: Optional[UUID] = None ) -> Dict: """ Determine the type of user message (read or write) @@ -719,14 +721,15 @@ class MemoryAgentService: status = await status_typle(message, memory_config.llm_model_id) logger.debug(f"Message type: {status}") return status + async def generate_summary_from_retrieve( - self, - end_user_id: str, - retrieve_info: str, - history: List[Dict], - query: str, - config_id: str, - db: Session + self, + end_user_id: str, + retrieve_info: str, + history: List[Dict], + query: str, + config_id: str, + db: Session ) -> str: """ 基于检索信息、历史对话和查询生成最终答案 @@ -761,9 +764,9 @@ class MemoryAgentService: if config_id is None: raise ValueError(f"Unable to determine memory configuration for end_user {end_user_id}: {e}") # If config_id was provided, continue without workspace_id fallback - + logger.info(f"Generating summary from retrieve info for query: {query[:50]}...") - + try: # 加载配置 config_service = MemoryConfigService(db) @@ -772,7 +775,7 @@ class MemoryAgentService: workspace_id=workspace_id, service_name="MemoryAgentService" ) - + # 导入必要的模块 from app.core.memory.agent.langgraph_graph.nodes.summary_nodes import ( summary_llm, @@ -780,13 +783,13 @@ class MemoryAgentService: from app.core.memory.agent.models.summary_models import ( RetrieveSummaryResponse, ) - + # 构建状态对象 state = { "data": query, "memory_config": memory_config } - + # 直接调用 summary_llm 函数 answer = await summary_llm( state=state, @@ -797,21 +800,20 @@ class MemoryAgentService: response_model=RetrieveSummaryResponse, search_mode="1" ) - + logger.info(f"Successfully generated summary: {answer[:100] if answer else 'None'}...") return answer if answer else "信息不足,无法回答。" - + except Exception as e: logger.error(f"生成摘要失败: {str(e)}", exc_info=True) return "信息不足,无法回答。" - async def get_knowledge_type_stats( - self, - end_user_id: Optional[str] = None, - only_active: bool = True, - current_workspace_id: Optional[uuid.UUID] = None, - db: Session = None + self, + db: Session, + end_user_id: Optional[str] = None, + only_active: bool = True, + current_workspace_id: Optional[uuid.UUID] = None ) -> Dict[str, Any]: """ 统计知识库类型分布,包含: @@ -837,11 +839,6 @@ class MemoryAgentService: # 1. 统计 PostgreSQL 中的知识库类型 try: - if db is None: - from app.db import get_db - db_gen = get_db() - db = next(db_gen) - # 初始化所有标准类型为 0 for kb_type in KnowledgeType: result[kb_type.value] = 0 @@ -881,21 +878,19 @@ class MemoryAgentService: # 3. 计算知识库类型总和(不包括 memory) result["total"] = ( - result.get("General", 0) + - result.get("Web", 0) + - result.get("Third-party", 0) + - result.get("Folder", 0) + result.get("General", 0) + + result.get("Web", 0) + + result.get("Third-party", 0) + + result.get("Folder", 0) ) return result - - async def get_interest_distribution_by_user( - self, - end_user_id: Optional[str] = None, - limit: int = 5, - language: str = "zh" + self, + end_user_id: Optional[str] = None, + limit: int = 5, + language: str = "zh" ) -> List[Dict[str, Any]]: """ 获取指定用户的兴趣分布标签。 @@ -921,13 +916,12 @@ class MemoryAgentService: logger.error(f"兴趣分布标签查询失败: {e}") raise Exception(f"兴趣分布标签查询失败: {e}") - async def get_user_profile( - self, - end_user_id: Optional[str] = None, - current_user_id: Optional[str] = None, - llm_id: Optional[str] = None, - db: Session = None + self, + end_user_id: Optional[str] = None, + current_user_id: Optional[str] = None, + llm_id: Optional[str] = None, + db: Session = None ) -> Dict[str, Any]: """ 获取用户详情,包含: @@ -1017,7 +1011,8 @@ class MemoryAgentService: # 定义标签提取的结构 class UserTags(BaseModel): - tags: list[str] = Field(..., description="3个描述用户特征的标签,如:产品设计师、旅行爱好者、摄影发烧友") + tags: list[str] = Field(..., + description="3个描述用户特征的标签,如:产品设计师、旅行爱好者、摄影发烧友") messages = [ { @@ -1160,7 +1155,6 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An ValueError: 当终端用户不存在或应用未发布时 """ import json as json_module - import uuid from sqlalchemy import select @@ -1192,14 +1186,14 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An # 3. 兼容旧数据:如果 memory_config_id 为空,从 AppRelease.config 获取并回填 memory_config_id_to_use = end_user.memory_config_id - + # 如果已有 memory_config_id,直接使用 # 如果新创建enduser,enduser.memory_config_id 必定为none # 那么使用从release中获取memory_config_id为预期行为,并且回填到 # end_user.memory_config_id if not memory_config_id_to_use: logger.info(f"end_user.memory_config_id is None, migrating from AppRelease.config") - + # 获取最新发布版本 stmt = ( select(AppRelease) @@ -1208,10 +1202,10 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An ) # TODO: change to current_release_id latest_release = db.scalars(stmt).first() - + if latest_release: config = latest_release.config or {} - + # 如果 config 是字符串,解析为字典 if isinstance(config, str): try: @@ -1219,22 +1213,22 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An except json_module.JSONDecodeError: logger.warning(f"Failed to parse config JSON for release {latest_release.id}") config = {} - + # 使用 MemoryConfigService 的提取方法 memory_config_service = MemoryConfigService(db) legacy_config_id, is_legacy_int = memory_config_service.extract_memory_config_id( app_type=app.type, config=config ) - + if legacy_config_id: # 验证提取的 config_id 是否存在于数据库中 from app.models.memory_config_model import MemoryConfig as MemoryConfigModel existing_config = db.get(MemoryConfigModel, legacy_config_id) - + if existing_config: memory_config_id_to_use = legacy_config_id - + # 回填到 end_user 表(lazy update) end_user.memory_config_id = memory_config_id_to_use db.commit() @@ -1268,7 +1262,8 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An "workspace_id": str(app.workspace_id) } - logger.info(f"Successfully retrieved connected config: memory_config_id={memory_config_id}, workspace_id={app.workspace_id}") + logger.info( + f"Successfully retrieved connected config: memory_config_id={memory_config_id}, workspace_id={app.workspace_id}") return result @@ -1312,7 +1307,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 1. 批量查询所有 end_user 及其 app_id 和 memory_config_id end_users = db.query(EndUser).filter(EndUser.id.in_(end_user_ids)).all() - + # 创建映射 - 保留 EndUser 对象引用以便回填 end_user_map = {str(eu.id): eu for eu in end_users} user_data = {str(eu.id): {"app_id": eu.app_id, "memory_config_id": eu.memory_config_id} for eu in end_users} @@ -1336,15 +1331,15 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 3. 对于没有 memory_config_id 的用户,尝试从 AppRelease.config 提取 users_needing_migration = [ - (end_user_id, data["app_id"]) - for end_user_id, data in user_data.items() + (end_user_id, data["app_id"]) + for end_user_id, data in user_data.items() if not data["memory_config_id"] ] - + if users_needing_migration: # 批量获取相关应用的最新发布版本 migration_app_ids = list(set(app_id for _, app_id in users_needing_migration)) - + # 查询每个应用的最新活跃发布版本 app_latest_releases = {} for app_id in migration_app_ids: @@ -1357,18 +1352,18 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) latest_release = db.scalars(stmt).first() if latest_release: app_latest_releases[app_id] = latest_release - + # 为每个需要迁移的用户提取 memory_config_id config_service = MemoryConfigService(db) users_to_backfill = [] # [(end_user, memory_config_id), ...] - + for end_user_id, app_id in users_needing_migration: latest_release = app_latest_releases.get(app_id) if not latest_release: continue - + config = latest_release.config or {} - + # 如果 config 是字符串,解析为字典 if isinstance(config, str): try: @@ -1376,21 +1371,21 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) except json_module.JSONDecodeError: logger.warning(f"Failed to parse config JSON for release {latest_release.id}") continue - + # 使用 MemoryConfigService 的提取方法 app = app_map.get(app_id) if not app: continue - + legacy_config_id, is_legacy_int = config_service.extract_memory_config_id( app_type=app.type, config=config ) - + if legacy_config_id: # 更新 user_data 中的 memory_config_id user_data[end_user_id]["memory_config_id"] = legacy_config_id - + # 记录需要回填的用户(稍后验证配置存在后再回填) end_user = end_user_map.get(end_user_id) if end_user: @@ -1399,7 +1394,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) logger.info( f"Legacy int config detected for end_user {end_user_id}, will use workspace default" ) - + # 验证提取的 config_id 是否存在于数据库中 if users_to_backfill: config_ids_to_validate = list(set(cid for _, cid in users_to_backfill)) @@ -1407,17 +1402,17 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) MemoryConfig.config_id.in_(config_ids_to_validate) ).all() valid_config_ids = {mc.config_id for mc in existing_configs} - + # 只回填存在的配置 valid_backfills = [ - (eu, cid) for eu, cid in users_to_backfill + (eu, cid) for eu, cid in users_to_backfill if cid in valid_config_ids ] invalid_backfills = [ - (eu, cid) for eu, cid in users_to_backfill + (eu, cid) for eu, cid in users_to_backfill if cid not in valid_config_ids ] - + if invalid_backfills: invalid_ids = [str(cid) for _, cid in invalid_backfills] logger.warning( @@ -1426,7 +1421,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 清除 user_data 中无效的 config_id for eu, cid in invalid_backfills: user_data[str(eu.id)]["memory_config_id"] = None - + # 批量回填 end_user.memory_config_id if valid_backfills: for end_user, memory_config_id in valid_backfills: @@ -1437,7 +1432,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 4. 收集需要查询的 memory_config_id 和需要回退的 workspace_id direct_config_ids = [] workspace_fallback_users = [] # [(end_user_id, workspace_id), ...] - + for end_user_id, data in user_data.items(): if data["memory_config_id"]: direct_config_ids.append(data["memory_config_id"]) @@ -1455,7 +1450,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 6. 获取工作空间默认配置(需要逐个查询,因为 get_workspace_default_config 有复杂逻辑) workspace_default_configs = {} unique_workspace_ids = list(set(ws_id for _, ws_id in workspace_fallback_users)) - + if unique_workspace_ids: config_service = MemoryConfigService(db) for workspace_id in unique_workspace_ids: @@ -1466,11 +1461,11 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 7. 构建最终结果 for end_user_id, data in user_data.items(): memory_config = None - + # 优先使用 end_user 直接分配的配置 if data["memory_config_id"]: memory_config = config_id_to_config.get(data["memory_config_id"]) - + # 回退到工作空间默认配置 if not memory_config: workspace_id = app_to_workspace.get(data["app_id"]) @@ -1486,4 +1481,4 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) result[end_user_id] = {"memory_config_id": None, "memory_config_name": None} logger.info(f"Successfully retrieved {len(result)} connected configs") - return result \ No newline at end of file + return result diff --git a/api/app/services/memory_konwledges_server.py b/api/app/services/memory_konwledges_server.py index 420f7ca1..b8961d33 100644 --- a/api/app/services/memory_konwledges_server.py +++ b/api/app/services/memory_konwledges_server.py @@ -1,45 +1,42 @@ # 修改 memory_konwledges_server.py 文件 -import asyncio import os -import re import uuid from pathlib import Path from typing import Optional -from pydantic import BaseModel, Field +from fastapi import HTTPException, status +from pydantic import BaseModel +from sqlalchemy.orm import Session +from app.celery_app import celery_app +from app.core.config import settings +from app.core.logging_config import get_api_logger from app.core.rag.models.chunk import DocumentChunk from app.core.rag.vdb.elasticsearch.elasticsearch_vector import ElasticSearchVectorFactory from app.core.response_utils import success -from app.db import get_db -from app.schemas import file_schema, document_schema -from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Query +from app.db import get_db_context from app.models.document_model import Document -import uuid -from sqlalchemy.orm import Session -from fastapi import HTTPException, status - -from app.core.config import settings from app.models.user_model import User +from app.schemas import file_schema, document_schema from app.schemas.file_schema import CustomTextFileCreate from app.services import document_service, file_service, knowledge_service -from app.celery_app import celery_app -from app.core.logging_config import get_api_logger -from app.schemas.file_schema import CustomTextFileCreate -from app.db import get_db + # 创建一个简单的用户类用于测试 api_logger = get_api_logger() + class ChunkCreate(BaseModel): content: str + + class SimpleUser: def __init__(self, user_id: str): # 确保ID是UUID类型 self.id = user_id self.username = user_id -'''解析''' + async def parse_document_by_id(document_id: uuid.UUID, db: Session, current_user: User): """ 解析指定文档 @@ -120,7 +117,7 @@ async def parse_document_by_id(document_id: uuid.UUID, db: Session, current_user api_logger.error(f"文档解析失败: document_id={document_id} - {str(e)}") raise -'''获取块ID''' + async def get_document_chunks( kb_id: uuid.UUID, document_id: uuid.UUID, @@ -198,7 +195,7 @@ async def get_document_chunks( return success(data=result, msg="文档块列表查询成功") -'''查找文档ID''' + def find_document_id_by_kb_and_filename( db: Session, kb_id: str, @@ -231,7 +228,7 @@ def find_document_id_by_kb_and_filename( except Exception as e: return None -'''获取知识库ID''' + def find_documents_by_kb_id( db: Session, kb_id: str, @@ -268,18 +265,14 @@ def find_documents_by_kb_id( except Exception as e: return [] -''''上传文件''' + async def memory_konwledges_up( kb_id: str, parent_id: str, create_data: file_schema.CustomTextFileCreate, - db: Session = Depends(get_db), - current_user: SimpleUser = None, # 修改为SimpleUser + db: Session, + current_user: SimpleUser, ): - # 如果没有提供current_user,则创建一个默认的 - if current_user is None: - current_user = SimpleUser("5d27df0b-7eec-4fa6-9f8b-0f9b7e852f60") - content_bytes = create_data.content.encode('utf-8') file_size = len(content_bytes) print(f"file size: {file_size} byte") @@ -350,8 +343,6 @@ async def memory_konwledges_up( return success(data=document_schema.Document.model_validate(db_document), msg="custom text upload successful") -'''添加新块''' - async def create_document_chunk( kb_id: uuid.UUID, @@ -417,7 +408,7 @@ async def create_document_chunk( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"查询文档块失败: {error_msg}" ) - + sort_id = sort_id + 1 # 5. 创建文档块 @@ -450,6 +441,7 @@ async def create_document_chunk( return success(data=chunk, msg="文档块创建成功") + async def write_rag(end_user_id, message, user_rag_memory_id): """ 将消息写入 RAG 知识库 @@ -483,15 +475,12 @@ async def write_rag(end_user_id, message, user_rag_memory_id): detail=f"知识库ID格式无效: {user_rag_memory_id}" ) - db_gen = get_db() - db = next(db_gen) - - try: + with get_db_context() as db: create_data = CustomTextFileCreate(title=end_user_id, content=message) current_user = SimpleUser(user_rag_memory_id) # 检查文档是否已存在 document = find_document_id_by_kb_and_filename(db=db, kb_id=user_rag_memory_id, file_name=f"{end_user_id}.txt") - print('======',document) + print('======', document) api_logger.info(f"查找文档结果: document_id={document}") if document is not None: # 文档已存在,直接添加新块 @@ -528,6 +517,3 @@ async def write_rag(end_user_id, message, user_rag_memory_id): else: api_logger.error(f"创建文档后无法找到文档ID: end_user_id={end_user_id}") return result - finally: - # 确保数据库会话被关闭 - db.close() \ No newline at end of file diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index db5051d2..8bacc112 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -21,8 +21,7 @@ from app.repositories.end_user_repository import EndUserRepository from app.repositories.neo4j.cypher_queries import Graph_Node_query from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_episodic_schema import EmotionSubject, EmotionType, type_mapping -from app.services.implicit_memory_service import ImplicitMemoryService -from app.services.memory_base_service import MemoryBaseService, MemoryTransService +from app.services.memory_base_service import MemoryBaseService from app.services.memory_config_service import MemoryConfigService from app.services.memory_perceptual_service import MemoryPerceptualService from app.services.memory_short_service import ShortService @@ -1167,7 +1166,6 @@ async def analytics_user_summary(end_user_id: Optional[str] = None, language: st from app.core.language_utils import validate_language from app.core.memory.utils.prompt.prompt_utils import render_user_summary_prompt - from app.db import get_db from app.repositories.end_user_repository import EndUserRepository # 验证语言参数 @@ -1178,8 +1176,7 @@ async def analytics_user_summary(end_user_id: Optional[str] = None, language: st if end_user_id: try: # 获取数据库会话并查询用户信息 - db = next(get_db()) - try: + with get_db_context() as db: repo = EndUserRepository(db) end_user = repo.get_by_id(uuid.UUID(end_user_id)) if end_user and end_user.other_name: @@ -1187,8 +1184,7 @@ async def analytics_user_summary(end_user_id: Optional[str] = None, language: st logger.info(f"使用 other_name 作为用户显示名称: {user_display_name}") else: logger.info(f"用户 {end_user_id} 的 other_name 为空,使用默认称呼: {user_display_name}") - finally: - db.close() + except Exception as e: logger.warning(f"获取用户 other_name 失败,使用默认称呼: {str(e)}") From 5c2e0af33e078ffda67fd28641d401f6e2286024 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Fri, 6 Mar 2026 10:23:21 +0800 Subject: [PATCH 15/89] fix(celery): resolve environment variable hijacking by Celery CLI - Rename CELERY_BROKER and CELERY_BACKEND to REDIS_DB_CELERY_BROKER and REDIS_DB_CELERY_BACKEND to avoid Celery CLI prefix matching hijacking - Build canonical broker and backend URLs and force them into os.environ to prevent override by stray environment variables - Add logging for Celery app initialization with sanitized connection details - Update celery_app.py to use pre-built URL variables instead of inline construction - Add documentation reference to celery-env-bug-report.md explaining the environment variable naming convention - Prevents Celery CLI's Click framework from intercepting broker/backend configuration through environment variables --- api/app/celery_app.py | 32 +++++++++++++++++++++++++++----- api/app/core/config.py | 6 ++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/api/app/celery_app.py b/api/app/celery_app.py index 863fc21c..c728abb2 100644 --- a/api/app/celery_app.py +++ b/api/app/celery_app.py @@ -1,27 +1,49 @@ import os import platform from datetime import timedelta -from celery.schedules import crontab from urllib.parse import quote from celery import Celery from celery.schedules import crontab from app.core.config import settings +from app.core.logging_config import get_logger + +logger = get_logger(__name__) # macOS fork() safety - must be set before any Celery initialization if platform.system() == 'Darwin': os.environ.setdefault('OBJC_DISABLE_INITIALIZE_FORK_SAFETY', 'YES') # 创建 Celery 应用实例 -# broker: 任务队列(使用 Redis DB 0) -# backend: 结果存储(使用 Redis DB 10) +# broker: 任务队列(使用 Redis DB,由 CELERY_BROKER_DB 指定) +# backend: 结果存储(使用 Redis DB,由 CELERY_BACKEND_DB 指定) +# NOTE: 不要在 .env 中设置 BROKER_URL / RESULT_BACKEND / CELERY_BROKER / CELERY_BACKEND, +# 这些名称会被 Celery CLI 的 Click 框架劫持,详见 docs/celery-env-bug-report.md + +# Build canonical broker/backend URLs and force them into os.environ so that +# Celery's Settings.broker_url property (which checks CELERY_BROKER_URL first) +# cannot be overridden by stray env vars. +# See: https://github.com/celery/celery/issues/4284 +_broker_url = f"redis://:{quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB_CELERY_BROKER}" +_backend_url = f"redis://:{quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB_CELERY_BACKEND}" +os.environ["CELERY_BROKER_URL"] = _broker_url +os.environ["CELERY_RESULT_BACKEND"] = _backend_url +os.environ.pop("BROKER_URL", None) + celery_app = Celery( "redbear_tasks", - broker=f"redis://:{quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BROKER}", - backend=f"redis://:{quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.CELERY_BACKEND}", + broker=_broker_url, + backend=_backend_url, ) +logger.info( + "Celery app initialized", + extra={ + "broker": _broker_url.replace(quote(settings.REDIS_PASSWORD), "***"), + "backend": _backend_url.replace(quote(settings.REDIS_PASSWORD), "***"), + }, +) # Default queue for unrouted tasks celery_app.conf.task_default_queue = 'memory_tasks' diff --git a/api/app/core/config.py b/api/app/core/config.py index 4afb1bc9..ba17da93 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -190,8 +190,10 @@ class Settings: LOG_FILE_MAX_SIZE_MB: int = int(os.getenv("LOG_FILE_MAX_SIZE_MB", "10")) # 10MB # Celery configuration (internal) - CELERY_BROKER: int = int(os.getenv("CELERY_BROKER", "1")) - CELERY_BACKEND: int = int(os.getenv("CELERY_BACKEND", "2")) + # NOTE: 变量名不以 CELERY_ 开头,避免被 Celery CLI 的前缀匹配机制劫持 + # 详见 docs/celery-env-bug-report.md + REDIS_DB_CELERY_BROKER: int = int(os.getenv("REDIS_DB_CELERY_BROKER", "1")) + REDIS_DB_CELERY_BACKEND: int = int(os.getenv("REDIS_DB_CELERY_BACKEND", "2")) # SMTP Email Configuration SMTP_SERVER: str = os.getenv("SMTP_SERVER", "smtp.gmail.com") From 47bf93d65ea627eac522ae97a088de5beb7ff672 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Fri, 6 Mar 2026 10:27:55 +0800 Subject: [PATCH 16/89] docs(config): update Celery environment variable naming convention - Replace BROKER_URL and RESULT_BACKEND with REDIS_DB_CELERY_BROKER and REDIS_DB_CELERY_BACKEND in README.md - Replace BROKER_URL and RESULT_BACKEND with REDIS_DB_CELERY_BROKER and REDIS_DB_CELERY_BACKEND in README_CN.md - Update api/env.example with new variable names and add deprecation notice - Add reference to celery-env-bug-report.md documentation explaining why old variable names are avoided - Prevents environment variable hijacking by Celery CLI when using standard naming conventions --- README.md | 4 ++-- README_CN.md | 4 ++-- api/env.example | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2f53a996..95d8d737 100644 --- a/README.md +++ b/README.md @@ -226,8 +226,8 @@ REDIS_PORT=6379 REDIS_DB=1 # Celery (Using Redis as broker) -BROKER_URL=redis://127.0.0.1:6379/0 -RESULT_BACKEND=redis://127.0.0.1:6379/0 +REDIS_DB_CELERY_BROKER=1 +REDIS_DB_CELERY_BACKEND=2 # JWT Secret Key (Formation method: openssl rand -hex 32) SECRET_KEY=your-secret-key-here diff --git a/README_CN.md b/README_CN.md index aed69b03..1472acac 100644 --- a/README_CN.md +++ b/README_CN.md @@ -201,8 +201,8 @@ REDIS_PORT=6379 REDIS_DB=1 # Celery (使用Redis作为broker) -BROKER_URL=redis://127.0.0.1:6379/0 -RESULT_BACKEND=redis://127.0.0.1:6379/0 +REDIS_DB_CELERY_BROKER=1 +REDIS_DB_CELERY_BACKEND=2 # JWT密钥 (生成方式: openssl rand -hex 32) SECRET_KEY=your-secret-key-here diff --git a/api/env.example b/api/env.example index 1dc4536c..bd7f3dae 100644 --- a/api/env.example +++ b/api/env.example @@ -29,10 +29,10 @@ REDIS_DB= REDIS_PASSWORD=password #celery -BROKER_URL= -RESULT_BACKEND= -CELERY_BROKER= -CELERY_BACKEND= +# NOTE: 不要使用 BROKER_URL / RESULT_BACKEND / CELERY_BROKER / CELERY_BACKEND, +# 这些名称会被 Celery CLI 劫持,详见 docs/celery-env-bug-report.md +REDIS_DB_CELERY_BROKER= +REDIS_DB_CELERY_BACKEND= # Memory Cache Regeneration Configuration # Interval in hours for regenerating memory insight and user summary cache From f16e3695404c35871907dd98dcc22c005a4be4f6 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Fri, 6 Mar 2026 10:37:00 +0800 Subject: [PATCH 17/89] fix(celery): remove legacy environment variables to prevent CLI hijacking - Remove BROKER_URL environment variable to prevent Celery CLI override - Remove RESULT_BACKEND environment variable to prevent Celery CLI override - Remove CELERY_BROKER environment variable to prevent Celery CLI override - Remove CELERY_BACKEND environment variable to prevent Celery CLI override - Add clarifying comments explaining the purpose of neutralizing legacy vars - Ensures canonical broker and backend URLs are not accidentally overridden by Celery's CLI/Click integration --- api/app/celery_app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/app/celery_app.py b/api/app/celery_app.py index c728abb2..0319e079 100644 --- a/api/app/celery_app.py +++ b/api/app/celery_app.py @@ -29,7 +29,12 @@ _broker_url = f"redis://:{quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}: _backend_url = f"redis://:{quote(settings.REDIS_PASSWORD)}@{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB_CELERY_BACKEND}" os.environ["CELERY_BROKER_URL"] = _broker_url os.environ["CELERY_RESULT_BACKEND"] = _backend_url +# Neutralize legacy Celery env vars that can be hijacked by Celery's CLI/Click +# integration and accidentally override our canonical URLs. os.environ.pop("BROKER_URL", None) +os.environ.pop("RESULT_BACKEND", None) +os.environ.pop("CELERY_BROKER", None) +os.environ.pop("CELERY_BACKEND", None) celery_app = Celery( "redbear_tasks", From f1c5f24f6bfc48a87a58ea3280fa7e79a6b5533d Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 6 Mar 2026 10:43:13 +0800 Subject: [PATCH 18/89] feat(model apikey): Add validation modification for adding the apikey to the muti_modal model --- api/app/schemas/model_schema.py | 4 +-- api/app/services/model_service.py | 43 ++++++++++++++----------------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/api/app/schemas/model_schema.py b/api/app/schemas/model_schema.py index f25d9408..ea4183a5 100644 --- a/api/app/schemas/model_schema.py +++ b/api/app/schemas/model_schema.py @@ -116,8 +116,8 @@ class ModelApiKeyBase(BaseModel): provider: ModelProvider = Field(..., description="API Key提供商") api_key: str = Field(..., description="API密钥", max_length=500) api_base: Optional[str] = Field(None, description="API基础URL", max_length=500) - capability: List[str] = Field(default_factory=list, description="模型能力列表") - is_omni: bool = Field(False, description="是否为Omni模型") + capability: Optional[List[str]] = Field(None, description="模型能力列表") + is_omni: Optional[bool] = Field(None, description="是否为Omni模型") config: Optional[Dict[str, Any]] = Field({}, description="API Key特定配置") is_active: bool = Field(True, description="是否激活") priority: str = Field("1", description="优先级", max_length=10) diff --git a/api/app/services/model_service.py b/api/app/services/model_service.py index 2337427a..cba25f32 100644 --- a/api/app/services/model_service.py +++ b/api/app/services/model_service.py @@ -116,27 +116,15 @@ class ModelConfigService: try: start_time = time.time() - # dashscope 的 omni 模型需要使用 compatible-mode - if provider.lower() == ModelProvider.DASHSCOPE and is_omni: - if not api_base: - api_base = "https://dashscope.aliyuncs.com/compatible-mode/v1" - model_config = RedBearModelConfig( - model_name=model_name, - provider=ModelProvider.OPENAI, - api_key=api_key, - base_url=api_base, - temperature=0.7, - max_tokens=100 - ) - else: - model_config = RedBearModelConfig( - model_name=model_name, - provider=provider, - api_key=api_key, - base_url=api_base, - temperature=0.7, - max_tokens=100 - ) + model_config = RedBearModelConfig( + model_name=model_name, + provider=provider, + api_key=api_key, + base_url=api_base, + is_omni=is_omni, + temperature=0.7, + max_tokens=100 + ) # 根据模型类型选择不同的验证方式 model_type_lower = model_type.lower() @@ -492,6 +480,9 @@ class ModelApiKeyService: model_config = ModelConfigRepository.get_by_id(db, model_config_id) if not model_config: continue + + data.is_omni = model_config.is_omni + data.capability = model_config.capability # 从ModelBase获取model_name model_name = model_config.model_base.name if model_config.model_base else model_config.name @@ -550,8 +541,8 @@ class ModelApiKeyService: provider=data.provider, api_key=data.api_key, api_base=data.api_base, - capability=data.capability if data.capability is not None else model_config.capability, - is_omni=data.is_omni if data.is_omni is not None else model_config.is_omni, + capability=data.capability, + is_omni=data.is_omni, config=data.config, is_active=data.is_active, priority=data.priority @@ -574,6 +565,10 @@ class ModelApiKeyService: model_config = ModelConfigRepository.get_by_id(db, model_config_id) if not model_config: raise BusinessException("模型配置不存在", BizCode.MODEL_NOT_FOUND) + if api_key_data.is_omni is None: + api_key_data.is_omni = model_config.is_omni + if api_key_data.capability is None: + api_key_data.capability = model_config.capability # 检查API Key是否已存在(包括软删除),需要考虑tenant_id existing_key = db.query(ModelApiKey).join( @@ -616,7 +611,7 @@ class ModelApiKeyService: api_base=api_key_data.api_base, model_type=model_config.type, test_message="Hello", - is_omni=model_config.is_omni + is_omni=api_key_data.is_omni ) if not validation_result["valid"]: raise BusinessException( From 3f052b77980c366e5a685bd66dab93e45d3f1e9f Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 10:45:12 +0800 Subject: [PATCH 19/89] feat(web): ontology add warning info --- web/src/i18n/en.ts | 2 ++ web/src/i18n/zh.ts | 2 ++ web/src/utils/request.ts | 6 +++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 43dea1c5..2c0dc94d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -441,6 +441,8 @@ export const en = { publicApiCannotRefreshToken: 'Public API cannot refresh token', refreshTokenNotExist: 'Refresh token does not exist', SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: 'This is a system preset scene and cannot be deleted', + SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: 'This scene is a system preset scene and cannot be deleted', + SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE: 'This scene is a system preset scene and cannot be modified', reset: 'Reset', refresh: 'Refresh', return: 'Return', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 62ce77e3..d6b8f859 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1021,6 +1021,8 @@ export const zh = { publicApiCannotRefreshToken: '公共接口不能刷新token', refreshTokenNotExist: '刷新token不存在', SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: '该场景为系统预设场景,不允许删除', + SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: '该场景为系统预设场景,不允许删除', + SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE: '该场景为系统预设场景,不允许修改', reset: '重置', refresh: '刷新', return: '返回', diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts index f58f5f65..03941960 100644 --- a/web/src/utils/request.ts +++ b/web/src/utils/request.ts @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-02 16:35:15 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-02 16:35:15 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-06 10:39:00 */ /** * HTTP Request Utility Module @@ -183,7 +183,7 @@ service.interceptors.response.use( msg = msg || i18n.t('common.serverError'); break; default: - if (msg === 'SYSTEM_DEFAULT_SCENE_CANNOT_DELETE') { + if (['SYSTEM_DEFAULT_SCENE_CANNOT_DELETE', 'SYSTEM_DEFAULT_CLASS_CANNOT_DELETE', 'SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE'].includes(msg)) { msg = i18n.t(`common.${msg}`) } else if (!msg && Array.isArray(error.response?.data?.detail)) { msg = error.response?.data?.detail?.map((item: { msg: string }) => item.msg).join(';') From bc4406cec67cdd16ae0cb4a294f7cbc2434dbdf5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 10:49:18 +0800 Subject: [PATCH 20/89] feat(web): ontology add warning info --- web/src/i18n/en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 2c0dc94d..58a9f086 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -441,7 +441,7 @@ export const en = { publicApiCannotRefreshToken: 'Public API cannot refresh token', refreshTokenNotExist: 'Refresh token does not exist', SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: 'This is a system preset scene and cannot be deleted', - SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: 'This scene is a system preset scene and cannot be deleted', + SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: 'This scene is a system preset class and cannot be deleted', SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE: 'This scene is a system preset scene and cannot be modified', reset: 'Reset', refresh: 'Refresh', From 1a08bea8649f01a0e8f3819b7e0b83b0b2a48701 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 10:50:16 +0800 Subject: [PATCH 21/89] fix(web): update i18n --- web/src/i18n/en.ts | 2 +- web/src/i18n/zh.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 58a9f086..b6c3efb7 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -441,7 +441,7 @@ export const en = { publicApiCannotRefreshToken: 'Public API cannot refresh token', refreshTokenNotExist: 'Refresh token does not exist', SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: 'This is a system preset scene and cannot be deleted', - SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: 'This scene is a system preset class and cannot be deleted', + SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: 'This class is a system preset class and cannot be deleted', SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE: 'This scene is a system preset scene and cannot be modified', reset: 'Reset', refresh: 'Refresh', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index d6b8f859..3cc6ec77 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1021,7 +1021,7 @@ export const zh = { publicApiCannotRefreshToken: '公共接口不能刷新token', refreshTokenNotExist: '刷新token不存在', SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: '该场景为系统预设场景,不允许删除', - SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: '该场景为系统预设场景,不允许删除', + SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: '该类型为系统预设类型,不允许删除', SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE: '该场景为系统预设场景,不允许修改', reset: '重置', refresh: '刷新', From 5c39d841eed08267b13b226480058ea33407a0a8 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 11:29:32 +0800 Subject: [PATCH 22/89] feat(web): default ontology hidden operate --- web/src/i18n/en.ts | 4 +--- web/src/i18n/zh.ts | 4 +--- .../views/Ontology/components/PageHeader.tsx | 4 ++-- web/src/views/Ontology/index.tsx | 8 ++++---- web/src/views/Ontology/pages/Detail.tsx | 18 +++++++++++------- web/src/views/Ontology/types.ts | 3 ++- 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index b6c3efb7..cd4b97e9 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -440,9 +440,6 @@ export const en = { logoutApiCannotRefreshToken: 'Logout API cannot refresh token', publicApiCannotRefreshToken: 'Public API cannot refresh token', refreshTokenNotExist: 'Refresh token does not exist', - SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: 'This is a system preset scene and cannot be deleted', - SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: 'This class is a system preset class and cannot be deleted', - SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE: 'This scene is a system preset scene and cannot be modified', reset: 'Reset', refresh: 'Refresh', return: 'Return', @@ -2619,6 +2616,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re updated_at: 'Updated At', entityTypes: 'Entity Types', + classSearchPlaceholder: 'Search types', addClass: 'Add Type', class_name: 'Type Name', class_description: 'Type Definition', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3cc6ec77..f3b2a906 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1020,9 +1020,6 @@ export const zh = { logoutApiCannotRefreshToken: '退出登录接口不能刷新token', publicApiCannotRefreshToken: '公共接口不能刷新token', refreshTokenNotExist: '刷新token不存在', - SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: '该场景为系统预设场景,不允许删除', - SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: '该类型为系统预设类型,不允许删除', - SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE: '该场景为系统预设场景,不允许修改', reset: '重置', refresh: '刷新', return: '返回', @@ -2620,6 +2617,7 @@ export const zh = { updated_at: '更新时间', entityTypes: '实体类型', + classSearchPlaceholder: '搜索类型', addClass: '添加类型', class_name: '类型名称', class_description: '类型定义', diff --git a/web/src/views/Ontology/components/PageHeader.tsx b/web/src/views/Ontology/components/PageHeader.tsx index 56fa8cfc..b46174b2 100644 --- a/web/src/views/Ontology/components/PageHeader.tsx +++ b/web/src/views/Ontology/components/PageHeader.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 14:10:24 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 18:02:13 + * @Last Modified time: 2026-03-06 11:25:59 */ import { type FC, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -17,7 +17,7 @@ const { Header } = Layout; */ interface ConfigHeaderProps { /** Page title/name */ - name?: string; + name?: string | ReactNode; /** Subtitle content displayed below the title */ subTitle?: ReactNode | string; /** Extra content displayed on the right side */ diff --git a/web/src/views/Ontology/index.tsx b/web/src/views/Ontology/index.tsx index eaf1188b..bee4ebe6 100644 --- a/web/src/views/Ontology/index.tsx +++ b/web/src/views/Ontology/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 14:10:15 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-05 16:28:53 + * @Last Modified time: 2026-03-06 10:56:44 */ import { type FC, useState, useRef, type MouseEvent } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -181,8 +181,8 @@ const Ontology: FC = () => { )} -
- +
+ {!item.is_system_default &&
handleEdit(item, e)} @@ -191,7 +191,7 @@ const Ontology: FC = () => { 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={(e) => handleDelete(item, e)} >
-
+ }
)} diff --git a/web/src/views/Ontology/pages/Detail.tsx b/web/src/views/Ontology/pages/Detail.tsx index 4426e96c..22e08244 100644 --- a/web/src/views/Ontology/pages/Detail.tsx +++ b/web/src/views/Ontology/pages/Detail.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 14:10:20 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 17:56:35 + * @Last Modified time: 2026-03-06 11:26:49 */ import { type FC, useEffect, useState, useRef } from 'react' import { useParams } from 'react-router-dom'; @@ -17,6 +17,7 @@ import OntologyClassModal from '../components/OntologyClassModal' import SearchInput from '@/components/SearchInput'; import OntologyClassExtractModal from '../components/OntologyClassExtractModal' import BodyWrapper from '@/components/Empty/BodyWrapper' +import Tag from '@/components/Tag' /** * Ontology detail page component @@ -99,19 +100,22 @@ const Detail: FC = () => { return ( <> + {data.scene_name} + {t('common.default')} +
} subTitle={
{data.scene_description}
} - extra={ + extra={data.is_system_default ? undefined : ( - } + )} />
setQuery({ class_name: value })} className="rb:w-full!" /> @@ -123,10 +127,10 @@ const Detail: FC = () => { handleDelete(item)} - >
} + >
)} className="rb:bg-transparent!" > diff --git a/web/src/views/Ontology/types.ts b/web/src/views/Ontology/types.ts index aad94ee0..c194f9ad 100644 --- a/web/src/views/Ontology/types.ts +++ b/web/src/views/Ontology/types.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 14:10:10 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-05 16:18:56 + * @Last Modified time: 2026-03-06 10:55:23 */ /** * Query parameters for ontology list pagination and filtering @@ -94,6 +94,7 @@ export interface OntologyClassData { scene_description: string; /** Array of class items */ items: OntologyClassItem[]; + is_system_default: boolean; } /** From d3399dfaf57113c8aac907655e49f1d35a6742f7 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 6 Mar 2026 11:49:02 +0800 Subject: [PATCH 23/89] [add] Default label for the entity type --- api/app/controllers/ontology_secondary_routes.py | 1 + api/app/repositories/ontology_scene_repository.py | 2 +- api/app/schemas/ontology_schemas.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api/app/controllers/ontology_secondary_routes.py b/api/app/controllers/ontology_secondary_routes.py index 2aea77a4..8720065b 100644 --- a/api/app/controllers/ontology_secondary_routes.py +++ b/api/app/controllers/ontology_secondary_routes.py @@ -636,6 +636,7 @@ async def classes_handler( scene_id=scene_uuid, scene_name=scene.scene_name, scene_description=scene.scene_description, + is_system_default=scene.is_system_default, items=items ) diff --git a/api/app/repositories/ontology_scene_repository.py b/api/app/repositories/ontology_scene_repository.py index 141b5d1c..0b357e41 100644 --- a/api/app/repositories/ontology_scene_repository.py +++ b/api/app/repositories/ontology_scene_repository.py @@ -374,7 +374,7 @@ class OntologySceneRepository: count = self.db.query(OntologyScene).filter( OntologyScene.scene_id == scene_id, - OntologyScene.workspace_id == workspace_id + (OntologyScene.workspace_id == workspace_id) | (OntologyScene.is_system_default == True) ).count() is_owner = count > 0 diff --git a/api/app/schemas/ontology_schemas.py b/api/app/schemas/ontology_schemas.py index 718c54eb..905e65fe 100644 --- a/api/app/schemas/ontology_schemas.py +++ b/api/app/schemas/ontology_schemas.py @@ -463,6 +463,7 @@ class ClassListResponse(BaseModel): scene_id: UUID = Field(..., description="所属场景ID") scene_name: str = Field(..., description="场景名称") scene_description: Optional[str] = Field(None, description="场景描述") + is_system_default: bool = Field(False, description="是否为系统默认场景") items: List[ClassResponse] = Field(..., description="类型列表") From 7611db19f3dd2b21f52e251099b363d020a4faef Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 12:06:32 +0800 Subject: [PATCH 24/89] fix(web): app upload jump add delay --- .../components/UploadWorkflowModal.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx index 503ea55a..56ff51eb 100644 --- a/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx +++ b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-28 14:08:14 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-02 17:39:49 + * @Last Modified time: 2026-03-06 12:05:46 */ /** * UploadWorkflowModal Component @@ -186,14 +186,16 @@ const UploadWorkflowModal = forwardRef { - switch(type) { - case 'detail': - // Open application detail page in new tab - window.open(`/#/application/config/${appId}`, '_blank'); - break; - } - refresh(); handleClose(); + refresh(); + setTimeout(() => { + switch (type) { + case 'detail': + // Open application detail page in new tab + window.open(`/#/application/config/${appId}`, '_blank'); + break; + } + }, 100) }; /** @@ -350,7 +352,7 @@ const UploadWorkflowModal = forwardRef handleJump('list')}> + , + {!item.model_id && } diff --git a/web/src/views/ModelManagement/types.ts b/web/src/views/ModelManagement/types.ts index 3233353b..d68e5521 100644 --- a/web/src/views/ModelManagement/types.ts +++ b/web/src/views/ModelManagement/types.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:50:18 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-04 11:39:20 + * @Last Modified time: 2026-03-06 12:26:11 */ /** * Type definitions for Model Management @@ -121,6 +121,7 @@ export interface ModelApiKey { * Model list item data structure */ export interface ModelListItem { + model_id?: string; /** Model name */ model_name?: string; /** Associated model config IDs */ From 0b2651f4edde3cadfe21c8a2505d63968e6b779d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 13:36:50 +0800 Subject: [PATCH 27/89] fix(web): chat file delete bugfix --- web/src/components/Chat/ChatInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 0e34561a..8c8dce1a 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-10 16:46:14 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-06 12:21:19 + * @Last Modified time: 2026-03-06 13:36:20 */ import { type FC, useEffect, useMemo } from 'react' import { Flex, Input, Form } from 'antd' @@ -50,7 +50,7 @@ const ChatInput: FC = ({ const handleDelete = (file: any) => { - fileChange?.(fileList?.filter(item => item.uid !== file.uid) || []) + fileChange?.(fileList?.filter(item => file.url ? item.url !== file.url : item.uid !== file.uid) || []) } // Convert file object to preview URL const previewFileList = useMemo(() => { From e833db954a7d397c45edf738f6cc17b4b35a53e5 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Fri, 6 Mar 2026 13:37:16 +0800 Subject: [PATCH 28/89] feat(chat): add message_id field to chat API response --- .../langgraph_graph/nodes/problem_nodes.py | 4 +- api/app/services/app_chat_service.py | 19 ++++++-- api/app/services/conversation_service.py | 8 +++- api/app/services/workflow_service.py | 46 +++++++++++-------- 4 files changed, 49 insertions(+), 28 deletions(-) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py index c8cc0460..784e5802 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py @@ -111,7 +111,7 @@ async def Split_The_Problem(state: ReadState) -> ReadState: "error_type": type(e).__name__, "error_message": str(e), "content_length": len(content), - "llm_model_id": memory_config.llm_model_id if memory_config else None + "llm_model_id": str(memory_config.llm_model_id) if memory_config else None } logger.error(f"Split_The_Problem error details: {error_details}") @@ -221,7 +221,7 @@ async def Problem_Extension(state: ReadState) -> ReadState: "error_type": type(e).__name__, "error_message": str(e), "questions_count": len(databasets), - "llm_model_id": memory_config.llm_model_id if memory_config else None + "llm_model_id": str(memory_config.llm_model_id) if memory_config else None } logger.error(f"Problem_Extension error details: {error_details}") diff --git a/api/app/services/app_chat_service.py b/api/app/services/app_chat_service.py index 5430d2f9..f3cdde2a 100644 --- a/api/app/services/app_chat_service.py +++ b/api/app/services/app_chat_service.py @@ -144,7 +144,7 @@ class AppChatService: ) # 保存消息 - self.conversation_service.save_conversation_messages( + message_id = self.conversation_service.save_conversation_messages( conversation_id=conversation_id, user_message=message, assistant_message=result["content"], @@ -163,6 +163,7 @@ class AppChatService: return { "conversation_id": conversation_id, + "message_id": str(message_id), "message": result["content"], "usage": result.get("usage", { "prompt_tokens": 0, @@ -191,7 +192,11 @@ class AppChatService: try: start_time = time.time() config_id = None - yield f"event: start\ndata: {json.dumps({'conversation_id': str(conversation_id)}, ensure_ascii=False)}\n\n" + 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" variables = self.agent_service.prepare_variables(variables, config.variables) # 获取模型配置ID @@ -296,6 +301,7 @@ class AppChatService: ) self.conversation_service.add_message( + message_id=message_id, conversation_id=conversation_id, role="assistant", content=full_content, @@ -373,7 +379,7 @@ class AppChatService: content=message ) - self.conversation_service.add_message( + ai_message = self.conversation_service.add_message( conversation_id=conversation_id, role="assistant", content=result.get("message", ""), @@ -391,6 +397,7 @@ class AppChatService: return { "conversation_id": conversation_id, "message": result.get("message", ""), + "message_id": str(ai_message.id), "usage": { "prompt_tokens": 0, "completion_tokens": 0, @@ -419,9 +426,9 @@ class AppChatService: variables = {} try: - + message_id = uuid.uuid4() # 发送开始事件 - yield f"event: start\ndata: {json.dumps({'conversation_id': str(conversation_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 @@ -429,6 +436,7 @@ class AppChatService: # 2. 创建编排器 orchestrator = MultiAgentOrchestrator(self.db, config) + # 3. 流式执行任务 async for event in orchestrator.execute_stream( message=message, @@ -472,6 +480,7 @@ class AppChatService: ) self.conversation_service.add_message( + message_id=message_id, conversation_id=conversation_id, role="assistant", content=full_content, diff --git a/api/app/services/conversation_service.py b/api/app/services/conversation_service.py index 553aefc4..aff5f533 100644 --- a/api/app/services/conversation_service.py +++ b/api/app/services/conversation_service.py @@ -178,7 +178,8 @@ class ConversationService: conversation_id: uuid.UUID, role: str, content: str, - meta_data: Optional[dict] = None + meta_data: Optional[dict] = None, + message_id: Optional[uuid.UUID] = None, ) -> Message: """ Add a message to a conversation using UnitOfWork. @@ -188,6 +189,7 @@ class ConversationService: role (str): Role of the message sender ('user' or 'assistant'). content (str): Message content. meta_data (Optional[dict]): Optional metadata. + message_id (Optional[uuid.UUID]): Optional custom message UUID. Returns: Message: Newly created Message instance. @@ -198,6 +200,7 @@ class ConversationService: ) message = Message( + id=message_id if message_id else uuid.uuid4(), conversation_id=conversation_id, role=role, content=content, @@ -317,7 +320,7 @@ class ConversationService: content=user_message ) - self.add_message( + ai_message = self.add_message( conversation_id=conversation_id, role="assistant", content=assistant_message, @@ -332,6 +335,7 @@ class ConversationService: "assistant_message_length": len(assistant_message) } ) + return ai_message.id def delete_conversation( self, diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index d13e3454..ce27e276 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -496,6 +496,7 @@ class WorkflowService: "event": "start", "data": { "conversation_id": payload.get("conversation_id"), + "message_id": payload.get("message_id") } } case "workflow_end": @@ -624,24 +625,28 @@ class WorkflowService: workspace_id=str(workspace_id), user_id=payload.user_id ) - # 更新执行结果 if result.get("status") == "completed": token_usage = result.get("token_usage", {}) or {} + + final_messages = result.get("messages", [])[init_message_length:] + for message in final_messages: + message_obj = self.conversation_service.add_message( + conversation_id=conversation_id_uuid, + role=message["role"], + content=message["content"], + meta_data=None if message["role"] == "user" else {"usage": token_usage} + ) + if message["role"] != "user": + result["message_id"] = str(message_obj.id) self.update_execution_status( execution.execution_id, "completed", output_data=result, token_usage=token_usage.get("total_tokens", None) ) - final_messages = result.get("messages", [])[init_message_length:] - for message in final_messages: - self.conversation_service.add_message( - conversation_id=conversation_id_uuid, - role=message["role"], - content=message["content"], - meta_data=None if message["role"] == "user" else {"usage": token_usage} - ) + logger.error(f"Workflow Run Failed, execution_id: {execution.execution_id}," + f" error: {result.get('error')}") logger.info(f"Workflow Run Success, " f"execution_id: {execution.execution_id}, message count: {len(final_messages)}") else: @@ -659,6 +664,7 @@ class WorkflowService: # "messages": result.get("messages"), "output": result.get("output"), # 最终输出(字符串) "message": result.get("output"), # 最终输出(字符串) + "message_id": result.get("message_id"), # "output_data": result.get("node_outputs", {}), # 所有节点输出(详细数据) "conversation_id": result.get("conversation_id"), # 所有节点输出(详细数据)payload., # 会话 ID "error_message": result.get("error"), @@ -756,7 +762,7 @@ class WorkflowService: input_data["conv_messages"] = last_state.get("messages") or [] break init_message_length = len(input_data.get("conv_messages", [])) - + message_id = uuid.uuid4() async for event in execute_workflow_stream( workflow_config=workflow_config_dict, input_data=input_data, @@ -765,24 +771,24 @@ class WorkflowService: user_id=payload.user_id, ): if event.get("event") == "workflow_end": - status = event.get("data", {}).get("status") token_usage = event.get("data", {}).get("token_usage", {}) or {} if status == "completed": + final_messages = event.get("data", {}).get("messages", [])[init_message_length:] + for message in final_messages: + self.conversation_service.add_message( + message_id=message_id if message["role"] != "user" else uuid.uuid4(), + conversation_id=conversation_id_uuid, + role=message["role"], + content=message["content"], + meta_data=None if message["role"] == "user" else {"usage": token_usage} + ) self.update_execution_status( execution.execution_id, "completed", output_data=event.get("data"), token_usage=token_usage.get("total_tokens", None) ) - final_messages = event.get("data", {}).get("messages", [])[init_message_length:] - for message in final_messages: - self.conversation_service.add_message( - conversation_id=conversation_id_uuid, - role=message["role"], - content=message["content"], - meta_data=None if message["role"] == "user" else {"usage": token_usage} - ) logger.info(f"Workflow Run Success, " f"execution_id: {execution.execution_id}, message count: {len(final_messages)}") elif status == "failed": @@ -793,6 +799,8 @@ class WorkflowService: ) else: logger.error(f"unexpect workflow run status, status: {status}") + elif event.get("event") == "workflow_start": + event["data"]["message_id"] = str(message_id) event = self._emit(public, event) if event: yield event From fed0ae8e9c3562400f94334bd8b3af9b50de749c Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 13:54:33 +0800 Subject: [PATCH 29/89] feat(web): change memory extraction pruning_scene control --- web/src/i18n/en.ts | 2 +- web/src/i18n/zh.ts | 2 +- .../views/MemoryExtractionEngine/constant.ts | 9 ++----- .../views/MemoryExtractionEngine/index.tsx | 27 ++++++++++++++----- web/src/views/MemoryManagement/index.tsx | 6 ++--- 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index cd4b97e9..9c76ba98 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1572,7 +1572,7 @@ export const en = { intelligentSemanticPruningFunction: 'Intelligent Semantic Pruning Function', intelligentSemanticPruningFunctionDesc: 'Whether to activate intelligent semantic pruning (true/false).', intelligentSemanticPruningScene: 'Intelligent Semantic Pruning Scene', - intelligentSemanticPruningSceneDesc: 'Select intelligent semantic pruning scene (education, online_service, outbound).', + intelligentSemanticPruningSceneDesc: 'Semantic pruning scenarios are consistent with ontology engineering scenarios', intelligentSemanticPruningThreshold: 'Intelligent Semantic Pruning Threshold', intelligentSemanticPruningThresholdDesc: 'Set intelligent semantic pruning threshold (0-0.9).', reflectionEngine: 'Self-Reflexion Engine', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index f3b2a906..12896307 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1571,7 +1571,7 @@ export const zh = { intelligentSemanticPruningFunction: '智能语义修剪功能', intelligentSemanticPruningFunctionDesc: '是否激活智能语义修剪(true/false)。', intelligentSemanticPruningScene: '智能语义修剪场景', - intelligentSemanticPruningSceneDesc: '选择智能语义修剪场景(education、online_service、outbound)。', + intelligentSemanticPruningSceneDesc: '语义剪枝场景与本体工程场景一致', intelligentSemanticPruningThreshold: '智能语义修剪阈值', intelligentSemanticPruningThresholdDesc: '设置智能语义修剪阈值(0-0.9)。', reflectionEngine: '自我反思引擎', diff --git a/web/src/views/MemoryExtractionEngine/constant.ts b/web/src/views/MemoryExtractionEngine/constant.ts index 8c2ceb80..68f9e8c6 100644 --- a/web/src/views/MemoryExtractionEngine/constant.ts +++ b/web/src/views/MemoryExtractionEngine/constant.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:30:06 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-04 10:09:45 + * @Last Modified time: 2026-03-06 13:49:00 */ /** * Memory Extraction Engine Configuration Constants @@ -140,13 +140,8 @@ export const configList: ConfigVo[] = [ { label: 'intelligentSemanticPruningScene', variableName: 'pruning_scene', - control: 'select', + control: 'text', type: 'enum', - options: [ - { label: 'education', value: 'education' }, - { label: 'online_service', value: 'online_service' }, - { label: 'outbound', value: 'outbound' }, - ], meaning: 'intelligentSemanticPruningSceneDesc', }, // Intelligent semantic pruning阈值 diff --git a/web/src/views/MemoryExtractionEngine/index.tsx b/web/src/views/MemoryExtractionEngine/index.tsx index c36a16f6..e5c8f477 100644 --- a/web/src/views/MemoryExtractionEngine/index.tsx +++ b/web/src/views/MemoryExtractionEngine/index.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 17:30:02 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 17:30:02 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-06 13:50:05 */ /** * Memory Extraction Engine Configuration Page @@ -13,7 +13,7 @@ import { type FC, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useParams } from 'react-router-dom' -import { Row, Col, Space, Select, InputNumber, Slider, App, Form } from 'antd' +import { Row, Col, Space, Select, InputNumber, Slider, App, Form, Input } from 'antd' import clsx from 'clsx' import Card from './components/Card' @@ -35,15 +35,15 @@ const keys = [ /** * Configuration description component */ -const ConfigDesc: FC<{ config: Variable, className?: string }> = ({config, className}) => { +const ConfigDesc: FC<{ config: Variable, className?: string; onlyMeaning?: boolean; }> = ({ config, className, onlyMeaning = false}) => { const { t } = useTranslation(); return (
- + {!onlyMeaning && {config.variableName && {t('memoryExtractionEngine.variableName')}: {config.variableName}} {config.control && {t('memoryExtractionEngine.control')}: {t(`memoryExtractionEngine.${config.control}`)}} {config.type && {t('memoryExtractionEngine.type')}: {config.type}} - + } {config.meaning &&
{t('memoryExtractionEngine.Meaning')}: {t(`memoryExtractionEngine.${config.meaning}`)}
}
) @@ -253,6 +253,21 @@ const MemoryExtractionEngine: FC = () => { } + {config.control === 'text' && + <> +
+ -{t(`memoryExtractionEngine.${config.label}`)} +
+
+ + + + +
+ + } ))} diff --git a/web/src/views/MemoryManagement/index.tsx b/web/src/views/MemoryManagement/index.tsx index 6ebb49c7..fdc272e5 100644 --- a/web/src/views/MemoryManagement/index.tsx +++ b/web/src/views/MemoryManagement/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:33:15 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-05 16:28:58 + * @Last Modified time: 2026-03-06 13:53:53 */ /** * Memory Management Page @@ -154,10 +154,10 @@ const MemoryManagement: React.FC = () => { className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]" onClick={() => handleEdit(item)} > -
handleDelete(item)} - >
+ >} From fc240849cf45d8d5491d41cb8afcdb0fedbbcb74 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 6 Mar 2026 14:12:03 +0800 Subject: [PATCH 30/89] [add] Semantic pruning is unified with the ontology engineering scenario. --- .../core/memory/agent/utils/get_dialogs.py | 4 +- api/app/core/memory/models/config_models.py | 15 +++++-- .../data_preprocessing/data_pruning.py | 27 ++++++++---- .../prompt/prompts/extracat_Pruning.jinja2 | 41 +++++++++++++++---- .../repositories/memory_config_repository.py | 1 + api/app/schemas/memory_config_schema.py | 1 + api/app/schemas/memory_storage_schema.py | 5 ++- api/app/services/memory_config_service.py | 32 +++++++++++++++ api/app/services/memory_storage_service.py | 33 +++++++++++++++ api/app/services/workspace_service.py | 11 ++++- 10 files changed, 147 insertions(+), 23 deletions(-) diff --git a/api/app/core/memory/agent/utils/get_dialogs.py b/api/app/core/memory/agent/utils/get_dialogs.py index 22555fff..ea44d0a5 100644 --- a/api/app/core/memory/agent/utils/get_dialogs.py +++ b/api/app/core/memory/agent/utils/get_dialogs.py @@ -82,7 +82,9 @@ async def get_chunked_dialogs( pruning_config = PruningConfig( pruning_switch=memory_config.pruning_enabled, pruning_scene=memory_config.pruning_scene or "education", - pruning_threshold=memory_config.pruning_threshold + pruning_threshold=memory_config.pruning_threshold, + scene_id=str(memory_config.scene_id) if memory_config.scene_id else None, + ontology_classes=memory_config.ontology_classes, ) logger.info(f"[剪枝] 加载配置: switch={pruning_config.pruning_switch}, scene={pruning_config.pruning_scene}, threshold={pruning_config.pruning_threshold}") diff --git a/api/app/core/memory/models/config_models.py b/api/app/core/memory/models/config_models.py index ca1780aa..c2d62ac1 100644 --- a/api/app/core/memory/models/config_models.py +++ b/api/app/core/memory/models/config_models.py @@ -10,7 +10,7 @@ Classes: TemporalSearchParams: Parameters for temporal search queries """ -from typing import Optional +from typing import Optional, List from pydantic import BaseModel, Field @@ -55,17 +55,26 @@ class PruningConfig(BaseModel): Attributes: pruning_switch: Enable or disable semantic pruning - pruning_scene: Scene type for pruning ('education', 'online_service', 'outbound') + pruning_scene: Scene name for pruning, either a built-in key + ('education', 'online_service', 'outbound') or a custom scene_name + from ontology_scene table pruning_threshold: Pruning ratio (0-0.9, max 0.9 to avoid complete removal) + scene_id: Optional ontology scene UUID, used to load custom ontology classes + ontology_classes: List of class_name strings from ontology_class table, + injected into the prompt when pruning_scene is not a built-in scene """ pruning_switch: bool = Field(False, description="Enable semantic pruning when True.") pruning_scene: str = Field( "education", - description="Scene for pruning: one of 'education', 'online_service', 'outbound'.", + description="Scene for pruning: built-in key or custom scene_name from ontology_scene.", ) pruning_threshold: float = Field( 0.5, ge=0.0, le=0.9, description="Pruning ratio within 0-0.9 (max 0.9 to avoid termination).") + scene_id: Optional[str] = Field(None, description="Ontology scene UUID (optional).") + ontology_classes: Optional[List[str]] = Field( + None, description="Class names from ontology_class table for custom scenes." + ) class TemporalSearchParams(BaseModel): diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py index 0a913633..5388b437 100644 --- a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py @@ -86,19 +86,26 @@ class SemanticPruner: self._detailed_prune_logging = True # 是否启用详细日志 self._max_debug_msgs_per_dialog = 20 # 每个对话最多记录前N条消息的详细日志 - # 加载场景特定配置 + # 加载场景特定配置(内置场景走专门规则,自定义场景 fallback 到通用规则) self.scene_config: ScenePatterns = SceneConfigRegistry.get_config( self.config.pruning_scene, fallback_to_generic=True ) - # 检查场景是否有专门支持 - is_supported = SceneConfigRegistry.is_scene_supported(self.config.pruning_scene) - if is_supported: - self._log(f"[剪枝-初始化] 场景={self.config.pruning_scene} 使用专门配置") + # 判断是否为内置专门场景 + self._is_builtin_scene = SceneConfigRegistry.is_scene_supported(self.config.pruning_scene) + + # 自定义场景的本体类型列表(用于注入提示词) + self._ontology_classes = config.ontology_classes or [] + + if self._is_builtin_scene: + self._log(f"[剪枝-初始化] 场景={self.config.pruning_scene} 使用内置专门配置") else: - self._log(f"[剪枝-初始化] 场景={self.config.pruning_scene} 未预定义,使用通用配置(保守策略)") - self._log(f"[剪枝-初始化] 支持的场景: {SceneConfigRegistry.get_all_scenes()}") + self._log(f"[剪枝-初始化] 场景={self.config.pruning_scene} 为自定义场景,使用通用规则 + 本体类型提示词注入") + if self._ontology_classes: + self._log(f"[剪枝-初始化] 注入本体类型: {self._ontology_classes}") + else: + self._log(f"[剪枝-初始化] 未找到本体类型,将使用通用提示词") # Load Jinja2 template self.template = prompt_env.get_template("extracat_Pruning.jinja2") @@ -424,12 +431,16 @@ class SemanticPruner: self._log(f"[剪枝-缓存] LRU缓存已满,删除最旧条目") rendered = self.template.render( - pruning_scene=self.config.pruning_scene, + pruning_scene=self.config.pruning_scene, + is_builtin_scene=self._is_builtin_scene, + ontology_classes=self._ontology_classes, dialog_text=dialog_text, language=self.language ) log_template_rendering("extracat_Pruning.jinja2", { "pruning_scene": self.config.pruning_scene, + "is_builtin_scene": self._is_builtin_scene, + "ontology_classes_count": len(self._ontology_classes), "language": self.language }) log_prompt_rendering("pruning-extract", rendered) diff --git a/api/app/core/memory/utils/prompt/prompts/extracat_Pruning.jinja2 b/api/app/core/memory/utils/prompt/prompts/extracat_Pruning.jinja2 index 8253924b..4eafbd64 100644 --- a/api/app/core/memory/utils/prompt/prompts/extracat_Pruning.jinja2 +++ b/api/app/core/memory/utils/prompt/prompts/extracat_Pruning.jinja2 @@ -1,6 +1,6 @@ {# 对话级抽取与相关性判定模板(用于剪枝加速) - 输入:pruning_scene, dialog_text + 输入:pruning_scene, is_builtin_scene, ontology_classes, dialog_text, language 输出:严格 JSON(不要包含任何多余文本),字段: - is_related: bool,是否与所选场景相关 - times: [string],从对话中抽取的时间相关文本(日期、时间、时间段、有效期等) @@ -16,7 +16,8 @@ - 仅输出上述键;避免多余解释或字段。 #} -{% set scene_instructions = { +{# ── 内置场景的固定说明 ── #} +{% set builtin_scene_instructions = { 'education': { 'zh': '教育场景:教学、课程、考试、作业、老师/学生互动、学习资源、学校管理等。', 'en': 'Education Scenario: Teaching, courses, exams, homework, teacher/student interaction, learning resources, school management, etc.' @@ -31,16 +32,39 @@ } } %} -{% set scene_key = pruning_scene %} -{% if scene_key not in scene_instructions %} -{% set scene_key = 'education' %} +{# ── 确定最终使用的场景说明 ── #} +{% if is_builtin_scene %} + {# 内置专门场景:使用固定说明 #} + {% set scene_key = pruning_scene %} + {% if scene_key not in builtin_scene_instructions %}{% set scene_key = 'education' %}{% endif %} + {% set instruction = builtin_scene_instructions[scene_key][language] if language in ['zh', 'en'] else builtin_scene_instructions[scene_key]['zh'] %} + {% set custom_types_str = '' %} +{% else %} + {# 自定义场景:使用场景名称 + 本体类型列表构建说明 #} + {% if ontology_classes and ontology_classes | length > 0 %} + {% if language == 'en' %} + {% set instruction = 'Custom scene "' ~ pruning_scene ~ '": The dialogue is related to this scene if it involves any of the following entity types: ' ~ ontology_classes | join(', ') ~ '.' %} + {% else %} + {% set instruction = '自定义场景「' ~ pruning_scene ~ '」:对话涉及以下任意实体类型时视为相关:' ~ ontology_classes | join('、') ~ '。' %} + {% endif %} + {% set custom_types_str = ontology_classes | join('、') %} + {% else %} + {# 无本体类型时退化为通用说明 #} + {% if language == 'en' %} + {% set instruction = 'Custom scene "' ~ pruning_scene ~ '": Determine whether the dialogue content is relevant to this scene based on overall context.' %} + {% else %} + {% set instruction = '自定义场景「' ~ pruning_scene ~ '」:根据对话整体内容判断是否与该场景相关。' %} + {% endif %} + {% set custom_types_str = '' %} + {% endif %} {% endif %} -{% set instruction = scene_instructions[scene_key][language] if language in ['zh', 'en'] else scene_instructions[scene_key]['zh'] %} - {% if language == "zh" %} 请在下方对话全文基础上,按该场景进行一次性抽取并判定相关性: 场景说明:{{ instruction }} +{% if not is_builtin_scene and custom_types_str %} +重要提示:只要对话中出现与上述实体类型({{ custom_types_str }})相关的内容,即判定为相关(is_related=true)。 +{% endif %} 对话全文: """ @@ -60,6 +84,9 @@ {% else %} Based on the full dialogue below, perform one-time extraction and relevance determination according to this scenario: Scenario Description: {{ instruction }} +{% if not is_builtin_scene and custom_types_str %} +Important: If the dialogue contains content related to any of the entity types above ({{ custom_types_str }}), mark it as relevant (is_related=true). +{% endif %} Full Dialogue: """ diff --git a/api/app/repositories/memory_config_repository.py b/api/app/repositories/memory_config_repository.py index 2dae51ef..22f13449 100644 --- a/api/app/repositories/memory_config_repository.py +++ b/api/app/repositories/memory_config_repository.py @@ -233,6 +233,7 @@ class MemoryConfigRepository: config_desc=params.config_desc, workspace_id=params.workspace_id, scene_id=params.scene_id, + pruning_scene=params.pruning_scene, llm_id=params.llm_id, embedding_id=params.embedding_id, rerank_id=params.rerank_id, diff --git a/api/app/schemas/memory_config_schema.py b/api/app/schemas/memory_config_schema.py index 0b63844b..0c359d70 100644 --- a/api/app/schemas/memory_config_schema.py +++ b/api/app/schemas/memory_config_schema.py @@ -417,6 +417,7 @@ class MemoryConfig: # Ontology scene association scene_id: Optional[UUID] = None + ontology_classes: Optional[list] = field(default=None) def __post_init__(self): """Validate configuration after initialization.""" diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index 776d2783..e396bbf6 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -232,14 +232,15 @@ class ConfigParamsCreate(BaseModel): # 创建配置参数模型(仅 body, # 本体场景关联(可选) scene_id: Optional[uuid.UUID] = Field(None, description="本体场景ID(UUID),关联ontology_scene表") + # 语义剪枝场景(由 service 层根据 scene_id 自动推导,值为关联场景的 scene_name,前端无需传入) + pruning_scene: Optional[str] = Field(None, description="语义剪枝场景,由 scene_id 对应的 scene_name 自动填充") + # 模型配置字段(可选,用于手动指定或自动填充) llm_id: Optional[str] = Field(None, description="LLM模型配置ID") embedding_id: Optional[str] = Field(None, description="嵌入模型配置ID") rerank_id: Optional[str] = Field(None, description="重排序模型配置ID") reflection_model_id: Optional[str] = Field(None, description="反思模型ID,默认与llm_id一致") emotion_model_id: Optional[str] = Field(None, description="情绪分析模型ID,默认与llm_id一致") - - class ConfigParamsDelete(BaseModel): # 删除配置参数模型(请求体) model_config = ConfigDict(populate_by_name=True, extra="forbid") # config_name: str = Field("配置名称", description="配置名称(字符串)") diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index ccfd5482..fca8b5b0 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -107,6 +107,37 @@ def _validate_config_id(config_id, db: Session = None): ) +# 专门场景的内置 key 列表(与 SceneConfigRegistry 保持一致) +_BUILTIN_PRUNING_SCENES = {"education", "online_service", "outbound"} + + +def _load_ontology_classes(db: Session, scene_id, pruning_scene: Optional[str]) -> Optional[list]: + """当 pruning_scene 不是内置场景时,从 ontology_class 表加载类型名称列表。 + + Args: + db: 数据库会话 + scene_id: 本体场景 UUID + pruning_scene: 语义剪枝场景名称 + + Returns: + class_name 字符串列表,或 None(内置场景 / 无数据时) + """ + if not scene_id: + return None + # 内置场景走 SceneConfigRegistry,不需要注入类型列表 + if pruning_scene in _BUILTIN_PRUNING_SCENES: + return None + try: + from app.repositories.ontology_class_repository import OntologyClassRepository + repo = OntologyClassRepository(db) + classes = repo.get_classes_by_scene(scene_id) + names = [c.class_name for c in classes if c.class_name] + return names if names else None + except Exception as e: + logger.warning(f"Failed to load ontology classes for scene_id={scene_id}: {e}") + return None + + class MemoryConfigService: """ Centralized service for memory configuration loading and validation. @@ -359,6 +390,7 @@ class MemoryConfigService: pruning_threshold=float(memory_config.pruning_threshold) if memory_config.pruning_threshold is not None else 0.5, # Ontology scene association scene_id=memory_config.scene_id, + ontology_classes=_load_ontology_classes(self.db, memory_config.scene_id, memory_config.pruning_scene), ) elapsed_ms = (time.time() - start_time) * 1000 diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 02fd1051..ff02a872 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -146,6 +146,10 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) if not params.emotion_model_id: params.emotion_model_id = params.llm_id + # 根据关联的本体场景推导 pruning_scene(语义剪枝场景与本体工程场景保持一致) + if params.scene_id and not getattr(params, 'pruning_scene', None): + params.pruning_scene = self._resolve_pruning_scene_from_scene_id(params.scene_id) + config = MemoryConfigRepository.create(self.db, params) self.db.commit() return {"affected": 1, "config_id": config.config_id} @@ -161,6 +165,22 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) finally: db_session.close() + def _resolve_pruning_scene_from_scene_id(self, scene_id) -> Optional[str]: + """根据本体场景ID获取对应的 scene_name,作为语义剪枝场景值 + + Args: + scene_id: 本体场景UUID + + Returns: + scene_name 字符串,查询失败时返回 None + """ + try: + from app.models.ontology_scene import OntologyScene + scene = self.db.query(OntologyScene).filter_by(scene_id=scene_id).first() + return scene.scene_name if scene else None + except Exception: + return None + # --- Delete --- def delete(self, key: ConfigParamsDelete) -> Dict[str, Any]: # 删除配置参数(按配置ID) success = MemoryConfigRepository.delete(self.db, key.config_id) @@ -196,6 +216,19 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) def get_all(self, workspace_id = None) -> List[Dict[str, Any]]: # 获取所有配置参数 results = MemoryConfigRepository.get_all(self.db, workspace_id) + # 检查并修正 pruning_scene 与 scene_name 不一致的记录 + needs_commit = False + for config, scene_name in results: + if scene_name and config.pruning_scene != scene_name: + logger.info( + f"修正 pruning_scene: config_id={config.config_id} " + f"'{config.pruning_scene}' -> '{scene_name}'" + ) + config.pruning_scene = scene_name + needs_commit = True + if needs_commit: + self.db.commit() + # 将 ORM 对象转换为字典列表 data_list = [] for config, scene_name in results: diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index e93c0c5c..74880410 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -152,6 +152,7 @@ def create_workspace( # Initialize default ontology scenes for the workspace (先创建本体场景) default_scene_id = None + default_scene_name = None try: initializer = DefaultOntologyInitializer(db) success, error_msg = initializer.initialize_default_scenes( @@ -163,7 +164,7 @@ def create_workspace( f"为工作空间 {db_workspace.id} 创建默认本体场景成功 (language={language})" ) - # 获取默认场景ID,优先使用"在线教育"场景,如果不存在则使用"情感陪伴"场景 + # 获取默认场景ID,优先使用"在线教育"场景,如果不存在则使用"情感陪伴"场景 from app.repositories.ontology_scene_repository import OntologySceneRepository from app.config.default_ontology_config import ( ONLINE_EDUCATION_SCENE, @@ -179,6 +180,7 @@ def create_workspace( if education_scene: default_scene_id = education_scene.scene_id + default_scene_name = education_scene.scene_name business_logger.info( f"获取到教育场景ID用于默认记忆配置: {default_scene_id} (scene_name={education_scene_name})" ) @@ -189,6 +191,7 @@ def create_workspace( if companion_scene: default_scene_id = companion_scene.scene_id + default_scene_name = companion_scene.scene_name business_logger.info( f"教育场景不存在,使用情感陪伴场景ID用于默认记忆配置: {default_scene_id} (scene_name={companion_scene_name})" ) @@ -219,6 +222,7 @@ def create_workspace( embedding_id=embedding, rerank_id=rerank, scene_id=default_scene_id, # 传入默认场景ID(优先教育场景,其次情感陪伴场景) + pruning_scene_name=default_scene_name, # 传入场景名称作为语义剪枝场景值 ) business_logger.info( f"为工作空间 {db_workspace.id} 创建默认记忆配置成功 (scene_id={default_scene_id})" @@ -1159,6 +1163,7 @@ def _create_default_memory_config( embedding_id: Optional[uuid.UUID] = None, rerank_id: Optional[uuid.UUID] = None, scene_id: Optional[uuid.UUID] = None, + pruning_scene_name: Optional[str] = None, ) -> None: """Create a default memory config for a newly created workspace. @@ -1170,6 +1175,7 @@ def _create_default_memory_config( embedding_id: Optional embedding model ID rerank_id: Optional rerank model ID scene_id: Optional ontology scene ID (默认关联教育场景) + pruning_scene_name: Optional pruning scene name,取自 ontology_scene.scene_name """ from app.models.memory_config_model import MemoryConfig @@ -1183,7 +1189,8 @@ def _create_default_memory_config( llm_id=str(llm_id) if llm_id else None, embedding_id=str(embedding_id) if embedding_id else None, rerank_id=str(rerank_id) if rerank_id else None, - scene_id=scene_id, # 关联本体场景ID + scene_id=scene_id, # 关联本体场景ID(默认为"在线教育"场景) + pruning_scene=pruning_scene_name, # 语义剪枝场景直接使用 scene_name state=True, # Active by default is_default=True, # Mark as workspace default ) From d0698090018efe683a2ab57ce0a145a32006a3a0 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 6 Mar 2026 14:35:16 +0800 Subject: [PATCH 31/89] [changes] AI review and correction of code --- .../extraction_engine/data_preprocessing/data_pruning.py | 2 +- .../memory/utils/prompt/prompts/extracat_Pruning.jinja2 | 7 ++++--- api/app/services/memory_config_service.py | 9 ++++++--- api/app/services/memory_storage_service.py | 3 ++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py index 5388b437..904b238f 100644 --- a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py @@ -96,7 +96,7 @@ class SemanticPruner: self._is_builtin_scene = SceneConfigRegistry.is_scene_supported(self.config.pruning_scene) # 自定义场景的本体类型列表(用于注入提示词) - self._ontology_classes = config.ontology_classes or [] + self._ontology_classes = getattr(self.config, "ontology_classes", None) or [] if self._is_builtin_scene: self._log(f"[剪枝-初始化] 场景={self.config.pruning_scene} 使用内置专门配置") diff --git a/api/app/core/memory/utils/prompt/prompts/extracat_Pruning.jinja2 b/api/app/core/memory/utils/prompt/prompts/extracat_Pruning.jinja2 index 4eafbd64..6b620df9 100644 --- a/api/app/core/memory/utils/prompt/prompts/extracat_Pruning.jinja2 +++ b/api/app/core/memory/utils/prompt/prompts/extracat_Pruning.jinja2 @@ -43,11 +43,12 @@ {# 自定义场景:使用场景名称 + 本体类型列表构建说明 #} {% if ontology_classes and ontology_classes | length > 0 %} {% if language == 'en' %} - {% set instruction = 'Custom scene "' ~ pruning_scene ~ '": The dialogue is related to this scene if it involves any of the following entity types: ' ~ ontology_classes | join(', ') ~ '.' %} + {% set custom_types_str = ontology_classes | join(', ') %} + {% set instruction = 'Custom scene "' ~ pruning_scene ~ '": The dialogue is related to this scene if it involves any of the following entity types: ' ~ custom_types_str ~ '.' %} {% else %} - {% set instruction = '自定义场景「' ~ pruning_scene ~ '」:对话涉及以下任意实体类型时视为相关:' ~ ontology_classes | join('、') ~ '。' %} + {% set custom_types_str = ontology_classes | join('、') %} + {% set instruction = '自定义场景「' ~ pruning_scene ~ '」:对话涉及以下任意实体类型时视为相关:' ~ custom_types_str ~ '。' %} {% endif %} - {% set custom_types_str = ontology_classes | join('、') %} {% else %} {# 无本体类型时退化为通用说明 #} {% if language == 'en' %} diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index fca8b5b0..00757f8c 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -107,8 +107,11 @@ def _validate_config_id(config_id, db: Session = None): ) -# 专门场景的内置 key 列表(与 SceneConfigRegistry 保持一致) -_BUILTIN_PRUNING_SCENES = {"education", "online_service", "outbound"} +# 专门场景的内置 key 集合,直接从 SceneConfigRegistry 派生,避免重复维护 +# 使用懒加载函数避免模块级循环导入 +def _get_builtin_pruning_scenes() -> set: + from app.core.memory.storage_services.extraction_engine.data_preprocessing.scene_config import SceneConfigRegistry + return set(SceneConfigRegistry.get_all_scenes()) def _load_ontology_classes(db: Session, scene_id, pruning_scene: Optional[str]) -> Optional[list]: @@ -125,7 +128,7 @@ def _load_ontology_classes(db: Session, scene_id, pruning_scene: Optional[str]) if not scene_id: return None # 内置场景走 SceneConfigRegistry,不需要注入类型列表 - if pruning_scene in _BUILTIN_PRUNING_SCENES: + if pruning_scene in _get_builtin_pruning_scenes(): return None try: from app.repositories.ontology_class_repository import OntologyClassRepository diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index ff02a872..a83d6830 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -178,7 +178,8 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) from app.models.ontology_scene import OntologyScene scene = self.db.query(OntologyScene).filter_by(scene_id=scene_id).first() return scene.scene_name if scene else None - except Exception: + except Exception as e: + logger.warning(f"_resolve_pruning_scene_from_scene_id failed for scene_id={scene_id}: {e}", exc_info=True) return None # --- Delete --- From af6fde414f85a4b5452243e80dd20652f2fd4d0a Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 6 Mar 2026 14:40:07 +0800 Subject: [PATCH 32/89] fix(agent and model): 1. From the model square to the model list, the added models are initially set to be inactive. When manually activating them, a mandatory API key configuration is required. 2. Copying of applications (agent, workflow, multi_agent) --- api/app/controllers/model_controller.py | 5 +++ api/app/schemas/model_schema.py | 1 + api/app/services/app_service.py | 46 ++++++++++++++++++++++++- api/app/services/model_service.py | 1 + 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/api/app/controllers/model_controller.py b/api/app/controllers/model_controller.py index 0de3d4fe..6204a745 100644 --- a/api/app/controllers/model_controller.py +++ b/api/app/controllers/model_controller.py @@ -371,6 +371,11 @@ def update_model( if model_data.type is not None or model_data.provider is not None: raise BusinessException("不允许更改模型类型和供应商", BizCode.INVALID_PARAMETER) + + if model_data.is_active: + active_keys = ModelApiKeyService.get_api_keys_by_model(db=db, model_config_id=model_id, is_active=model_data.is_active) + if not active_keys: + raise BusinessException("请先为该模型配置可用的 API Key", BizCode.INVALID_PARAMETER) try: api_logger.debug(f"开始更新模型配置: model_id={model_id}") diff --git a/api/app/schemas/model_schema.py b/api/app/schemas/model_schema.py index ea4183a5..4f3878ce 100644 --- a/api/app/schemas/model_schema.py +++ b/api/app/schemas/model_schema.py @@ -23,6 +23,7 @@ class ModelConfigBase(BaseModel): load_balance_strategy: Optional[str] = Field(LoadBalanceStrategy.NONE.value, description="负载均衡策略") capability: List[str] = Field(default_factory=list, description="模型能力列表") is_omni: bool = Field(False, description="是否为Omni模型") + model_id: Optional[uuid.UUID] = Field(None, description="基础模型ID") class ApiKeyCreateNested(BaseModel): diff --git a/api/app/services/app_service.py b/api/app/services/app_service.py index a248f869..5a799937 100644 --- a/api/app/services/app_service.py +++ b/api/app/services/app_service.py @@ -703,7 +703,7 @@ class AppService: self.db.flush() # 如果是 agent 类型,复制 AgentConfig - if source_app.type == "agent": + if source_app.type == AppType.AGENT: source_config = self.db.query(AgentConfig).filter( AgentConfig.app_id == source_app.id ).first() @@ -725,6 +725,50 @@ class AppService: ) self.db.add(new_config) + elif source_app.type == AppType.WORKFLOW: + source_config = self.db.query(WorkflowConfig).filter( + WorkflowConfig.app_id == source_app.id + ).first() + + if source_config: + 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 [], + is_active=True, + created_at=now, + updated_at=now, + ) + self.db.add(new_config) + + elif source_app.type == AppType.MULTI_AGENT: + source_config = self.db.query(MultiAgentConfig).filter( + MultiAgentConfig.app_id == source_app.id + ).first() + + if source_config: + new_config = MultiAgentConfig( + id=uuid.uuid4(), + app_id=new_app.id, + master_agent_id=source_config.master_agent_id, + master_agent_name=source_config.master_agent_name, + default_model_config_id=source_config.default_model_config_id, + model_parameters=source_config.model_parameters, + 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 {}, + aggregation_strategy=source_config.aggregation_strategy, + is_active=True, + created_at=now, + updated_at=now, + ) + self.db.add(new_config) + self.db.commit() self.db.refresh(new_app) diff --git a/api/app/services/model_service.py b/api/app/services/model_service.py index cba25f32..a7398504 100644 --- a/api/app/services/model_service.py +++ b/api/app/services/model_service.py @@ -780,6 +780,7 @@ class ModelBaseService: "description": model_base.description, "capability": model_base.capability, "is_omni": model_base.is_omni, + "is_active": False, "is_composite": False } model_config = ModelConfigRepository.create(db, model_config_data) From 0ea83b436462b2d298f0754f3621575e0094c831 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Fri, 6 Mar 2026 14:55:45 +0800 Subject: [PATCH 33/89] feat(web): enable MCP market configuration and service management - Add market configuration API endpoints for creating, updating, and retrieving market configs - Add market MCP listing and detail endpoints with support for activated services - Implement MarketConfigModal component for configuring market connections with URL and API key - Implement McpServiceModal component for viewing and managing MCP services from markets - Add infinite scroll pagination for market sources and MCP services - Add market connection status indicators (connected/disconnected/connecting states) - Add i18n translations for market configuration UI (en and zh) - Update Market component to display market sources with connection management - Add MarketQuery type for market-specific API queries - Refactor market data structure to match backend API response format --- web/src/api/tools.ts | 42 +- web/src/i18n/en.ts | 15 +- web/src/i18n/zh.ts | 15 +- web/src/views/ToolManagement/Market.tsx | 535 ++++++++++++------ .../components/MarketConfigModal.tsx | 97 ++-- .../components/McpServiceModal.tsx | 8 +- web/src/views/ToolManagement/index.tsx | 4 +- web/src/views/ToolManagement/types.ts | 6 + 8 files changed, 515 insertions(+), 207 deletions(-) diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index b14905f8..2aed3f80 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -1,5 +1,5 @@ import { request } from '@/utils/request' -import type { Query, CustomToolItem, ExecuteData, MCPToolItem, InnerToolItem } from '@/views/ToolManagement/types' +import type { Query, MarketQuery, CustomToolItem, ExecuteData, MCPToolItem, InnerToolItem } from '@/views/ToolManagement/types' // 工具列表 export const getTools = (data: Query) => { @@ -33,4 +33,44 @@ export const getToolDetail = (tool_id: string) => { } export const getToolMethods = (tool_id: string) => { return request.get(`/tools/${tool_id}/methods`) +} + +// MCP市场列表 +export const getMarketTools = (data: Query) => { + return request.get('/mcp_markets/mcp_markets', data) +} +// 市场配置创建 +export const createMarketConfig = (values: { + mcp_market_id: string; + token: string; + status: number; +}) => { + return request.post('/mcp_market_configs/mcp_market_config', values) +} +// 市场配置更新 +export const updateMarketConfig = (values: { + mcp_market_config_id: string; + token: string; + status: number; +}) => { + return request.put(`/mcp_market_configs/${values.mcp_market_config_id}`, values) +} +// 市场根据id获取配置 +export const getMarketConfig = (mcp_market_id: string) => { + return request.get(`/mcp_market_configs/mcp_market_id/${mcp_market_id}`) +} +// 市场MCP列表 +export const getMarketMCPs = (data: MarketQuery) => { + return request.get('/mcp_market_configs/mcp_servers', data) +} +// 根据配置ID serverId 获取MCP服务详情 +export const getMarketMCPDetail = (data:{ + mcp_market_config_id: string; + server_id: string; +}) => { + return request.get(`/mcp_market_configs/mcp_server`,data) +} +// 市场已激活MCP列表 +export const getMarketMCPsActivated = (data: MarketQuery) => { + return request.get('/mcp_market_configs/operational_mcp_servers', data) } \ No newline at end of file diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index b9ab09b0..5b9504b4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1948,7 +1948,20 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re path: 'Path', viewDetail: 'View Details', textLink: 'Test Connection', - noResult: 'Processing results will be displayed here' + noResult: 'Processing results will be displayed here', + + marketConfig: 'Configure {{name}}', + marketSaveAndConnect: 'Save & Connect', + marketUrl: 'Market URL', + marketUrlPlaceholder: 'Market URL', + marketCopy: 'Copy', + marketApiKeyOptional: 'Optional', + marketApiKeyExtra: 'Some markets require an API Key to access the full service list', + marketApiKeyPlaceholder: 'Enter API Key to access more services', + marketConnectionStatus: 'Connection Status', + marketConnected: '● Connected', + marketDisconnected: '○ Disconnected', + marketConnecting: 'Connecting to {{name}}...', }, workflow: { coreNode: 'Core Nodes', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 71a20207..6d836ce9 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1945,7 +1945,20 @@ export const zh = { path: '路径', viewDetail: '查看详情', textLink: '测试连接', - noResult: '处理结果将显示在这里' + noResult: '处理结果将显示在这里', + + marketConfig: '配置 {{name}}', + marketSaveAndConnect: '保存并连接', + marketUrl: '市场地址', + marketUrlPlaceholder: '市场地址', + marketCopy: '复制', + marketApiKeyOptional: '可选', + marketApiKeyExtra: '部分市场需要 API Key 才能获取完整的服务列表', + marketApiKeyPlaceholder: '输入 API Key 以获取更多服务', + marketConnectionStatus: '连接状态', + marketConnected: '● 已连接', + marketDisconnected: '○ 未连接', + marketConnecting: '正在连接 {{name}}...', }, workflow: { coreNode: '核心节点', diff --git a/web/src/views/ToolManagement/Market.tsx b/web/src/views/ToolManagement/Market.tsx index 59fbddcc..7a2df6df 100644 --- a/web/src/views/ToolManagement/Market.tsx +++ b/web/src/views/ToolManagement/Market.tsx @@ -1,122 +1,259 @@ -import React, { useState, useRef, type ReactNode } from 'react'; -import { Input, Button, Spin, App } from 'antd'; +import React, { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'; +import { Input, Button, App, Card, Space, Skeleton, Tag } from 'antd'; import { SearchOutlined, SettingOutlined, GlobalOutlined, SyncOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; +import InfiniteScroll from 'react-infinite-scroll-component'; import MarketConfigModal, { type MarketConfigModalRef } from './components/MarketConfigModal'; - +import McpServiceModal from './components/McpServiceModal'; +import type { McpServiceModalRef } from './types'; +import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated } from '@/api/tools'; interface MarketSource { id: string; name: string; category: string; - icon: string; + logo_url: string; url: string; - desc: string; - apiKey: string; + description: string; + api_key?: string; connected: boolean; - mcpCount: number; + mcp_count: number; + created_at?: number; + created_by?: string; } interface MarketMcp { id: string; name: string; - provider: string; - type: string; - desc: string; - downloads?: string; - stars?: string; - icon: string; - configTemplate: any; + chinese_name?: string; + description: string; + logo_url: string; + publisher: string; + categories?: string[]; + tags?: string[]; + view_count?: number; + activated?: boolean; + locales?: { + [lang: string]: { + name: string; + description: string; + }; + }; } interface MarketCategory { id: string; name: string; - icon: string; +} + +interface MarketApiResponse { + items: MarketSource[]; } const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { message } = App.useApp(); + + const getLocaleField = (mcp: MarketMcp, field: 'name' | 'description') => { + const lang = i18n.language?.startsWith('zh') ? 'zh' : 'en'; + return mcp.locales?.[lang]?.[field] || mcp[field] || ''; + }; const [loading, setLoading] = useState(false); const [selectedSource, setSelectedSource] = useState(null); const marketConfigModalRef = useRef(null); - const [marketSources, setMarketSources] = useState([ - { id: 'smithery', name: 'Smithery', category: 'official', icon: '🔧', url: 'https://mcp.smithery.ai', desc: '官方 MCP 服务市场,提供丰富的 MCP 服务', apiKey: '', connected: false, mcpCount: 2847 }, - { id: 'mcpmarket', name: 'MCP Market', category: 'official', icon: '🏪', url: 'https://mcpmarket.com', desc: '综合性 MCP 市场平台', apiKey: '', connected: false, mcpCount: 1523 }, - { id: 'glama', name: 'Glama.ai MCP', category: 'official', icon: '✨', url: 'https://glama.ai/mcp', desc: 'Glama AI 提供的 MCP 服务集合', apiKey: '', connected: false, mcpCount: 892 }, - { id: 'github-mcp', name: 'modelcontextprotocol/servers', category: 'official', icon: '🐙', url: 'https://github.com/modelcontextprotocol/servers', desc: 'GitHub 官方 MCP 服务器仓库', apiKey: '', connected: true, mcpCount: 156 }, - { id: 'aliyun-bailian', name: '阿里云百炼 MCP', category: 'china-cloud', icon: '☁️', url: 'https://bailian.console.aliyun.com/mcp', desc: '阿里云百炼平台 MCP 市场', apiKey: '', connected: false, mcpCount: 423 }, - { id: 'modelscope', name: '魔搭社区 MCP', category: 'china-cloud', icon: '🎭', url: 'https://modelscope.cn/mcp', desc: '阿里达摩院魔搭社区 MCP 市场', apiKey: '', connected: false, mcpCount: 312 }, - ]); - - const [categories] = useState([ - { id: 'official', name: '官方/综合', icon: '🌐' }, - { id: 'china-cloud', name: '国内云', icon: '☁️' }, - { id: 'community', name: '社区/垂直', icon: '👥' } - ]); - - const [mcpCache, setMcpCache] = useState>({ - 'github-mcp': [ - { id: 'gh-1', name: 'Fetch', provider: 'modelcontextprotocol', type: 'Hosted', desc: '使用浏览器模拟大型语言模型检索和处理网页内容', downloads: '203.7m', stars: '308.2k', icon: '🌐', configTemplate: {} }, - { id: 'gh-2', name: 'Filesystem', provider: 'modelcontextprotocol', type: 'Local', desc: '安全的文件系统操作,支持读写文件和目录管理', downloads: '156.2m', stars: '245.1k', icon: '📁', configTemplate: {} }, - { id: 'gh-3', name: 'GitHub', provider: 'modelcontextprotocol', type: 'Hosted', desc: 'GitHub API 集成,支持仓库、Issue、PR 等操作', downloads: '89.4m', stars: '178.3k', icon: '🐙', configTemplate: {} }, - ] - }); - + const mcpServiceModalRef = useRef(null); + const [marketSources, setMarketSources] = useState([]); + const [categories, setCategories] = useState([]); + const [mcpCache, setMcpCache] = useState>({}); + const [mcpTotal, setMcpTotal] = useState(0); const [searchKeyword, setSearchKeyword] = useState(''); + const [configIdMap, setConfigIdMap] = useState>({}); + const [hasMore, setHasMore] = useState(false); + const [activatedMcps, setActivatedMcps] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 20; - const handleSelectSource = (sourceId: string) => { - setSelectedSource(sourceId); - }; - - const handleRefresh = (sourceId: string) => { - setLoading(true); - setTimeout(() => { - // 模拟刷新数据 - const source = marketSources.find(s => s.id === sourceId); - if (source) { - message.success(`${source.name} 列表已刷新`); + // 获取市场数据 + useEffect(() => { + const fetchMarketData = async () => { + setLoading(true); + try { + const response = await getMarketTools({}) as MarketApiResponse; + if (response?.items && Array.isArray(response.items)) { + setMarketSources(response.items); + + // 根据 category 字段分组 + const categoryMap = new Map(); + response.items.forEach(item => { + if (item.category && !categoryMap.has(item.category)) { + categoryMap.set(item.category, { + id: item.category, + name: item.category + }); + } + }); + + setCategories(Array.from(categoryMap.values())); + } + } catch (error) { + console.error('获取市场数据失败:', error); + message.error('获取市场数据失败'); + } finally { + setLoading(false); } + }; + + fetchMarketData(); + }, [message]); + + const fetchMcpList = async (sourceId: string, page = 1, append = false) => { + setLoading(true); + try { + let configId = configIdMap[sourceId]; + + // 如果没有缓存 configId,先获取配置 + if (!configId) { + const config: any = await getMarketConfig(sourceId); + if (config?.id) { + configId = config.id; + setConfigIdMap(prev => ({ ...prev, [sourceId]: configId })); + } else { + return; + } + } + + // 第一次加载时获取已激活列表 + let activatedIds: string[] = activatedMcps; + if (page === 1 && !append) { + const activatedRes: any = await getMarketMCPsActivated({ mcp_market_config_id: configId }); + if (activatedRes && Array.isArray(activatedRes)) { + activatedIds = activatedRes.map((item: any) => item.id); + setActivatedMcps(activatedIds); + } + } + + const res: any = await getMarketMCPs({ mcp_market_config_id: configId, page, pagesize: pageSize }); + if (res?.items && Array.isArray(res.items)) { + // 标记已激活的 MCP + const mcpsWithActivated = res.items.map((item: MarketMcp) => ({ + ...item, + activated: activatedIds.includes(item.id) + })); + + setMcpCache(prev => ({ + ...prev, + [sourceId]: append ? [...(prev[sourceId] || []), ...mcpsWithActivated] : mcpsWithActivated + })); + } + if (res?.page) { + setMcpTotal(res.page.total || 0); + setHasMore(!!res.page.has_next); + setCurrentPage(res.page.page || page); + } + } catch (error) { + console.error('获取 MCP 列表失败:', error); + } finally { setLoading(false); - }, 600); + } }; - const handleOpenConfig = (sourceId: string) => { + const loadMore = useCallback(() => { + if (!selectedSource || loading) return; + fetchMcpList(selectedSource, currentPage + 1, true); + }, [selectedSource, currentPage, loading]); + + const handleSelectSource = async (sourceId: string) => { + setSelectedSource(sourceId); + setSearchKeyword(''); + setCurrentPage(1); + setHasMore(false); + setMcpTotal(0); + + // 如果缓存中已有数据,直接使用 + if (mcpCache[sourceId]) return; + + await fetchMcpList(sourceId, 1); + }; + + const handleRefresh = async (sourceId: string) => { + // 清除缓存,重新从第一页加载 + setMcpCache(prev => { + const next = { ...prev }; + delete next[sourceId]; + return next; + }); + setCurrentPage(1); + await fetchMcpList(sourceId, 1); const source = marketSources.find(s => s.id === sourceId); if (source) { + message.success(`${source.name} 列表已刷新`); + } + }; + + const handleOpenConfig = async (sourceId: string) => { + const source = marketSources.find(s => s.id === sourceId); + if (!source) return; + try { + const config: any = await getMarketConfig(sourceId); + marketConfigModalRef.current?.handleOpen({ + ...source, + connected: config?.status === 1, + token: config?.token || '', + configId: config?.id || '', + }); + } catch { marketConfigModalRef.current?.handleOpen(source); } }; - const handleConnect = (sourceId: string, apiKey: string) => { - // 更新市场源状态 + const handleOpenMcpServiceModal = async (mcp: MarketMcp) => { + if (!selectedSource || !configIdMap[selectedSource]) return; + try { + const detail: any = await getMarketMCPDetail({ + mcp_market_config_id: configIdMap[selectedSource], + server_id: mcp.id, + }); + const toolItem = { + name: detail.name, + description: detail.description, + config_data: { + server_url: detail.servers?.[0]?.url || '', + connection_config: { + auth_type: 'none', + timeout: 30, + headers: {}, + }, + }, + }; + mcpServiceModalRef.current?.handleOpen(toolItem as any); + } catch (error) { + console.error('获取 MCP 服务详情失败:', error); + } + }; + + const handleConnect = async (sourceId: string, configId: string) => { + // 更新市场源状态,缓存 configId setMarketSources(prev => prev.map(source => { if (source.id === sourceId) { - return { - ...source, - apiKey, - connected: true - }; + return { ...source, connected: true }; } return source; })); + setConfigIdMap(prev => ({ ...prev, [sourceId]: configId })); - // 模拟获取MCP列表 - setTimeout(() => { - const source = marketSources.find(s => s.id === sourceId); - if (source && !mcpCache[sourceId]) { - // 生成模拟数据 - const mockData: MarketMcp[] = [ - { id: `${sourceId}-1`, name: `${source.name} 服务 1`, provider: source.name, type: 'Hosted', desc: `来自 ${source.name} 的 MCP 服务`, downloads: '10.2m', stars: '23.4k', icon: '🔧', configTemplate: {} }, - { id: `${sourceId}-2`, name: `${source.name} 服务 2`, provider: source.name, type: 'Local', desc: `来自 ${source.name} 的本地 MCP 服务`, downloads: '8.5m', stars: '18.7k', icon: '⚙️', configTemplate: {} } - ]; - setMcpCache(prev => ({ - ...prev, - [sourceId]: mockData - })); + // 用 configId 获取第一页 MCP 列表 + try { + const res: any = await getMarketMCPs({ mcp_market_config_id: configId, page: 1, pagesize: pageSize }); + if (res?.items && Array.isArray(res.items)) { + setMcpCache(prev => ({ ...prev, [sourceId]: res.items })); } - message.success(`已连接 ${source?.name}`); - }, 800); + if (res?.page) { + setMcpTotal(res.page.total || 0); + setHasMore(!!res.page.has_next); + setCurrentPage(1); + } + } catch (error) { + console.error('获取 MCP 列表失败:', error); + } }; const renderSourceDetail = () => { @@ -134,38 +271,45 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => if (!source) return null; const mcpList = mcpCache[selectedSource] || []; - const filteredList = mcpList.filter(mcp => - mcp.name.toLowerCase().includes(searchKeyword.toLowerCase()) || - mcp.desc.toLowerCase().includes(searchKeyword.toLowerCase()) - ); + const filteredList = mcpList.filter(mcp => { + const name = getLocaleField(mcp, 'name'); + const desc = getLocaleField(mcp, 'description'); + return name.toLowerCase().includes(searchKeyword.toLowerCase()) || + desc.toLowerCase().includes(searchKeyword.toLowerCase()); + }); return ( <> -
-
-
- {source.icon} +
+
+
+ {source.logo_url ? ( + {source.name} { + e.currentTarget.style.display = 'none'; + const parent = e.currentTarget.parentElement; + if (parent) { + parent.innerHTML = '🏪'; + parent.style.fontSize = '48px'; + } + }} + /> + ) : ( + 🏪 + )}
-
-

{source.name}

-

{source.desc}

+
+

{source.name}

+ 可用 MCP 服务 ({mcpTotal}) + {/*

{source.description}

*/}
-
- - -
-
-
-
-

- 可用 MCP 服务 ({mcpList.length}) -

+
{source.connected && (
+ +
+
+
{mcpList.length > 0 ? ( - -
- {filteredList.map(mcp => ( +
+ } + scrollableTarget="mcpScrollableDiv" + > +
+ {filteredList.map(mcp => (
-
- {mcp.icon} +
+ {mcp.logo_url ? ( + {getLocaleField(mcp, { + e.currentTarget.style.display = 'none'; + const parent = e.currentTarget.parentElement; + if (parent) { + parent.innerHTML = '🔧'; + parent.style.fontSize = '24px'; + } + }} + /> + ) : ( + 🔧 + )}
- - {mcp.type} - + {mcp.categories?.[0] && ( + + {mcp.categories[0]} + + )}
-

{mcp.name}

- {mcp.provider && ( +

{getLocaleField(mcp, 'name')}

+ {mcp.publisher && (
- @ {mcp.provider} + {mcp.publisher.startsWith('@') ? mcp.publisher : `@${mcp.publisher}`}
)} -

{mcp.desc}

+

{getLocaleField(mcp, 'description')}

- {mcp.downloads && ( + {mcp.view_count != null && ( - {mcp.downloads} - - )} - {mcp.stars && ( - - ⭐ {mcp.stars} + {mcp.view_count.toLocaleString()} )}
-
-
))} -
- +
+
+
) : (
{source.connected ? '📭' : '🔌'}
@@ -254,50 +425,76 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => }; return ( -
+
{/* 左侧市场源列表 */} -
-
- MCP 市场 -
- {categories.map(cat => ( -
-
- {cat.icon} - {cat.name} -
-
- {marketSources - .filter(s => s.category === cat.id) - .map(source => ( -
handleSelectSource(source.id)} - > - {source.icon} - - {source.name} - - - {source.mcpCount} - - {source.connected && ( - - )} -
- ))} -
-
- ))} +
+ + {categories.map(cat => ( + + {cat.name} +
+ } + classNames={{ + body: "rb:p-[10px]!", + header: "rb:bg-[#F6F8FC]!" + }} + > + + {marketSources + .filter(s => s.category === cat.id) + .map(source => ( +
handleSelectSource(source.id)} + > +
+ {source.logo_url ? ( + {source.name} { + e.currentTarget.style.display = 'none'; + const parent = e.currentTarget.parentElement; + if (parent) { + parent.innerHTML = '🏪'; + parent.style.fontSize = '16px'; + } + }} + /> + ) : ( + 🏪 + )} +
+ + {source.name} + + + {source.mcp_count} + + {source.connected && ( + + )} +
+ ))} +
+ + ))} +
{/* 右侧内容区 */} -
+
{renderSourceDetail()}
@@ -308,6 +505,10 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => ref={marketConfigModalRef} onConnect={handleConnect} /> + {}} + />
); }; diff --git a/web/src/views/ToolManagement/components/MarketConfigModal.tsx b/web/src/views/ToolManagement/components/MarketConfigModal.tsx index d1d87563..2b4496fa 100644 --- a/web/src/views/ToolManagement/components/MarketConfigModal.tsx +++ b/web/src/views/ToolManagement/components/MarketConfigModal.tsx @@ -2,6 +2,7 @@ import { forwardRef, useImperativeHandle, useState } from 'react'; import { Form, Input, Button, App, Space } from 'antd'; import { useTranslation } from 'react-i18next'; import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; +import { createMarketConfig,updateMarketConfig } from '@/api/tools'; import RbModal from '@/components/RbModal'; const FormItem = Form.Item; @@ -9,15 +10,16 @@ const FormItem = Form.Item; interface MarketSource { id: string; name: string; - icon: string; + logo_url: string; url: string; - desc: string; - apiKey: string; + description: string; + token?: string; connected: boolean; + configId?: string; } interface MarketConfigModalProps { - onConnect: (sourceId: string, apiKey: string) => void; + onConnect: (sourceId: string, configId: string) => void; } export interface MarketConfigModalRef { @@ -47,8 +49,7 @@ const MarketConfigModal = forwardRef { setCurrentSource(source); form.setFieldsValue({ - url: source.url, - apiKey: source.apiKey, + token: source.token || '', }); setVisible(true); }; @@ -56,18 +57,36 @@ const MarketConfigModal = forwardRef { form .validateFields() - .then((values) => { + .then(async (values) => { if (!currentSource) return; setLoading(true); - - // 模拟连接延迟 - setTimeout(() => { - onConnect(currentSource.id, values.apiKey || ''); - message.success(`正在连接 ${currentSource.name}...`); - setLoading(false); + try { + let res: any; + if (currentSource.configId) { + // 更新配置 + res = await updateMarketConfig({ + mcp_market_config_id: currentSource.configId, + token: values.token || '', + status: 1, + }); + message.success(t('tool.marketConfigUpdated', { name: currentSource.name })); + } else { + // 创建配置 + res = await createMarketConfig({ + mcp_market_id: currentSource.id || '', + token: values.token || '', + status: 1, + }); + message.success(t('tool.marketConnecting', { name: currentSource.name })); + } + onConnect(currentSource.id, res.id || currentSource.configId); handleClose(); - }, 500); + } catch (error) { + console.error('保存配置失败:', error); + } finally { + setLoading(false); + } }) .catch((err) => { console.log('表单验证失败:', err); @@ -91,10 +110,10 @@ const MarketConfigModal = forwardRef {/* 市场源信息头部 */}
-
- {currentSource.icon} +
+ {currentSource.logo_url ? ( + {currentSource.name} { + e.currentTarget.style.display = 'none'; + const parent = e.currentTarget.parentElement; + if (parent) { + parent.innerHTML = '🏪'; + parent.style.fontSize = '32px'; + } + }} + /> + ) : ( + 🏪 + )}

{currentSource.name}

-

{currentSource.desc}

+

{currentSource.description}

@@ -115,39 +150,34 @@ const MarketConfigModal = forwardRef - {/* 市场地址 */} - + - {/* API Key */} - API Key (可选) + API Key ({t('tool.marketApiKeyOptional')}) } - extra="部分市场需要 API Key 才能获取完整的服务列表" + extra={{t('tool.marketApiKeyExtra')}} >
); }; diff --git a/web/src/views/ToolManagement/types.ts b/web/src/views/ToolManagement/types.ts index aa97db66..98976e28 100644 --- a/web/src/views/ToolManagement/types.ts +++ b/web/src/views/ToolManagement/types.ts @@ -136,4 +136,10 @@ export interface ExecuteData { export interface CustomToolModalRef { handleOpen: (data?: ToolItem) => void; handleClose: () => void; +} + +export interface MarketQuery { + mcp_market_config_id?: string; + page?: number; + pagesize?: number; } \ No newline at end of file From bccbeaabe497f0a2922c2e4f9f0f684040ff0933 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Fri, 6 Mar 2026 15:09:05 +0800 Subject: [PATCH 34/89] fix:tool market hidden --- web/src/views/ToolManagement/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/views/ToolManagement/index.tsx b/web/src/views/ToolManagement/index.tsx index c9383258..0f6ef872 100644 --- a/web/src/views/ToolManagement/index.tsx +++ b/web/src/views/ToolManagement/index.tsx @@ -4,7 +4,7 @@ * @Author: yujiangping * @Date: 2026-01-05 17:22:23 * @LastEditors: yujiangping - * @LastEditTime: 2026-03-04 15:12:48 + * @LastEditTime: 2026-03-06 15:08:38 */ import React, { useState } from 'react'; import { Tabs } from 'antd'; @@ -16,7 +16,7 @@ import Custom from './Custom'; import Market from './Market'; import Tag from '@/components/Tag' -const tabKeys = ['mcp', 'inner', 'custom', 'market'] +const tabKeys = ['mcp', 'inner', 'custom'] // , 'market' const ToolManagement: React.FC = () => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('mcp'); From 247db844a4885371b0e515da5623517b77838b74 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Fri, 6 Mar 2026 15:11:50 +0800 Subject: [PATCH 35/89] fix:market --- web/src/views/ToolManagement/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/views/ToolManagement/index.tsx b/web/src/views/ToolManagement/index.tsx index 12d40f46..e790c7ba 100644 --- a/web/src/views/ToolManagement/index.tsx +++ b/web/src/views/ToolManagement/index.tsx @@ -4,7 +4,7 @@ * @Author: yujiangping * @Date: 2026-01-05 17:22:23 * @LastEditors: yujiangping - * @LastEditTime: 2026-03-06 15:10:05 + * @LastEditTime: 2026-03-06 15:11:31 */ import React, { useState } from 'react'; import { Tabs } from 'antd'; @@ -16,7 +16,7 @@ import Custom from './Custom'; import Market from './Market'; import Tag from '@/components/Tag' -const tabKeys = ['mcp', 'inner', 'custom'] // , 'market' +const tabKeys = ['mcp', 'inner', 'custom', 'market'] // const ToolManagement: React.FC = () => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('mcp'); From 862bff51cbc10637f63059776d8dda82a8c0ea79 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 15:18:36 +0800 Subject: [PATCH 36/89] fix(web): i18n update --- web/src/i18n/zh.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 12896307..c4d2df71 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -96,7 +96,7 @@ export const zh = { createMemorySummary: '创建记忆摘要', memoryManagement: '记忆管理', spaceManagement: '空间管理', - memoryExtractionEngine: '记忆提取引擎', + memoryExtractionEngine: '记忆萃取引擎', forgettingEngine: '遗忘引擎', apiKeyManagement: 'API KEY管理', knowledgePrivate: '详情', @@ -1283,7 +1283,7 @@ export const zh = { createConfiguration: '创建配置', editConfiguration: '编辑配置', desc: '描述', - memoryExtractionEngine: '记忆提取引擎', + memoryExtractionEngine: '记忆萃取引擎', forgottenEngine: '遗忘引擎', active: '活跃', inactive: '不活跃', From 076ceee29d00eaece90e7720bce4b8d27e8bbe3a Mon Sep 17 00:00:00 2001 From: yujiangping Date: Fri, 6 Mar 2026 15:30:30 +0800 Subject: [PATCH 37/89] fix(web): filter vision models for image2text and cleanup tool management - Add vision capability filter for image2text model options in CreateModal - Filter model options to only include models with 'vision' capability when type is 'image2text' - Remove outdated file header comments from ToolManagement component - Comment out 'market' tab from tabKeys array in ToolManagement - Ensure image2text tool only displays compatible vision-capable models --- web/src/views/KnowledgeBase/components/CreateModal.tsx | 10 +++++++++- web/src/views/ToolManagement/index.tsx | 10 +--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/views/KnowledgeBase/components/CreateModal.tsx b/web/src/views/KnowledgeBase/components/CreateModal.tsx index d9727d18..35eb52d0 100644 --- a/web/src/views/KnowledgeBase/components/CreateModal.tsx +++ b/web/src/views/KnowledgeBase/components/CreateModal.tsx @@ -672,9 +672,17 @@ const CreateModal = forwardRef(({ {currentType !== 'Folder' && dynamicTypeList.map((tp) => { const fieldKey = typeToFieldKey(tp); // When tp is 'llm', merge llm and chat options - const options = tp.toLowerCase() === 'llm' || tp.toLowerCase() === 'image2text' + let options = tp.toLowerCase() === 'llm' || tp.toLowerCase() === 'image2text' ? [...(modelOptionsByType['llm'] || []), ...(modelOptionsByType['chat'] || [])] : modelOptionsByType[tp] || []; + + // When tp is 'image2text', filter to only include models with 'vision' capability + if (tp.toLowerCase() === 'image2text') { + options = options.filter((opt: any) => { + const model = models?.items?.find((m: any) => m.id === opt.value); + return model?.capability?.includes('vision'); + }); + } return ( { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState('mcp'); From ccc67df8df72f91022f97253c950f1d238bd8eb2 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Fri, 6 Mar 2026 15:44:37 +0800 Subject: [PATCH 38/89] feat(workflow): support multimodal context --- api/app/core/workflow/executor.py | 84 +++++++++++++++++------- api/app/core/workflow/nodes/base_node.py | 17 +++-- api/app/core/workflow/nodes/llm/node.py | 28 ++++++-- api/app/services/workflow_service.py | 71 +++++++++++++++----- 4 files changed, 148 insertions(+), 52 deletions(-) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index 78149e4c..ff979f2b 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -158,18 +158,36 @@ class WorkflowExecutor: full_content += self.variable_pool.get_value(f"{end_id}.output", default="", strict=False) # Append messages for user and assistant - result["messages"].extend( - [ - { - "role": "user", - "content": input_data.get("message", '') - }, - { - "role": "assistant", - "content": full_content - } - ] - ) + if input_data.get("files"): + result["messages"].extend( + [ + { + "role": "user", + "content": input_data.get("message", '') + }, + { + "role": "user", + "content": input_data.get("files") + }, + { + "role": "assistant", + "content": full_content + } + ] + ) + else: + result["messages"].extend( + [ + { + "role": "user", + "content": input_data.get("message", '') + }, + { + "role": "assistant", + "content": full_content + } + ] + ) # Calculate elapsed time end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() @@ -308,18 +326,36 @@ class WorkflowExecutor: elapsed_time = (end_time - start_time).total_seconds() # Append messages for user and assistant - result["messages"].extend( - [ - { - "role": "user", - "content": input_data.get("message", '') - }, - { - "role": "assistant", - "content": full_content - } - ] - ) + if input_data.get("files"): + result["messages"].extend( + [ + { + "role": "user", + "content": input_data.get("message", '') + }, + { + "role": "user", + "content": input_data.get("files") + }, + { + "role": "assistant", + "content": full_content + } + ] + ) + else: + result["messages"].extend( + [ + { + "role": "user", + "content": input_data.get("message", '') + }, + { + "role": "assistant", + "content": full_content + } + ] + ) logger.info( f"Workflow execution completed (streaming), " f"elapsed: {elapsed_time:.2f}ms, execution_id: {self.execution_context.execution_id}" diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index 3f30718c..dacbef85 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -617,10 +617,19 @@ class BaseNode(ABC): return variable_pool.has(selector) @staticmethod - async def process_message(provider: str, content: str | FileObject, enable_file=False) -> dict | str | None: + async def process_message(provider: str, content: str | dict | FileObject, enable_file=False) -> list | str | None: + if isinstance(content, dict): + content = FileObject( + type=content.get("type"), + url=content.get("url"), + transfer_method=content.get("transfer_method"), + origin_file_type=content.get("origin_file_type"), + file_id=content.get("file_id"), + is_file=True + ) if isinstance(content, str): if enable_file: - return {"text": content} + return [{"text": content}] return content elif isinstance(content, FileObject): @@ -639,8 +648,8 @@ class BaseNode(ABC): ) if message: - content.content_cache[provider] = message[0] - return message[0] + content.content_cache[provider] = message + return message return None raise TypeError(f'Unexpect input value type - {type(content)}') diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index c109d59b..4b63bc4e 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -151,23 +151,23 @@ class LLMNode(BaseNode): if role == "system": messages.append({ "role": "system", - "content": content + "content": await self.process_message(provider, content, self.typed_config.vision) }) elif role in ["user", "human"]: messages.append({ "role": "user", - "content": content + "content": await self.process_message(provider, content, self.typed_config.vision) }) elif role in ["ai", "assistant"]: messages.append({ "role": "assistant", - "content": content + "content": await self.process_message(provider, content, self.typed_config.vision) }) else: logger.warning(f"未知的消息角色: {role},默认使用 user") messages.append({ "role": "user", - "content": content + "content": await self.process_message(provider, content, self.typed_config.vision) }) if self.typed_config.vision_input and self.typed_config.vision: @@ -176,14 +176,28 @@ class LLMNode(BaseNode): for file in files.value: content = await self.process_message(provider, file.value, self.typed_config.vision) if content: - file_content.append(content) + file_content.extend(content) if messages and messages[-1]["role"] == 'user': - messages[-1]['content'] = [messages[-1]["content"]] + file_content + messages[-1]['content'] = messages[-1]["content"] + file_content else: messages.append({"role": "user", "content": file_content}) if self.typed_config.memory.enable: - messages = messages[:-1] + state["messages"][-self.typed_config.memory.window_size:] + messages[-1:] + history_message = [] + for message in state["messages"][-self.typed_config.memory.window_size:]: + if isinstance(message["content"], list): + file_content = [] + for file in message["content"]: + content = await self.process_message(provider, file, self.typed_config.vision) + if content: + file_content.extend(content) + history_message.append( + {"role": message["role"], "content": file_content} + ) + else: + message["content"] = await self.process_message(provider, message["content"], self.typed_config.vision) + history_message.append(message) + messages = messages[:-1] + history_message + messages[-1:] self.messages = messages else: # 使用简单的 prompt 格式(向后兼容) diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index ce27e276..bd940387 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -25,7 +25,7 @@ from app.repositories.workflow_repository import ( WorkflowExecutionRepository, WorkflowNodeExecutionRepository ) -from app.schemas import DraftRunRequest, FileInput +from app.schemas import DraftRunRequest, FileInput, FileType from app.services.conversation_service import ConversationService from app.services.multi_agent_service import convert_uuids_to_str from app.services.multimodal_service import MultimodalService @@ -601,6 +601,7 @@ class WorkflowService: try: files = await self._handle_file_input(payload.files) input_data["files"] = files + message_id = uuid.uuid4() # 更新状态为运行中 self.update_execution_status(execution.execution_id, "running") @@ -630,15 +631,32 @@ class WorkflowService: token_usage = result.get("token_usage", {}) or {} final_messages = result.get("messages", [])[init_message_length:] + human_message = "" + assistant_message = "" for message in final_messages: - message_obj = self.conversation_service.add_message( - conversation_id=conversation_id_uuid, - role=message["role"], - content=message["content"], - meta_data=None if message["role"] == "user" else {"usage": token_usage} - ) - if message["role"] != "user": - result["message_id"] = str(message_obj.id) + if message["role"] == "user": + if isinstance(message["content"], str): + human_message += message["content"] + elif isinstance(message["content"], dict): + if message["content"].get("type") == FileType.IMAGE: + human_message += f"![image]({message['content'].get('url', '')})" + else: + human_message += f"[{FileType}]({message['content'].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 + ) + self.conversation_service.add_message( + message_id=message_id, + conversation_id=conversation_id_uuid, + role="assistant", + content=assistant_message, + meta_data={"usage": token_usage} + ) self.update_execution_status( execution.execution_id, "completed", @@ -664,7 +682,7 @@ class WorkflowService: # "messages": result.get("messages"), "output": result.get("output"), # 最终输出(字符串) "message": result.get("output"), # 最终输出(字符串) - "message_id": result.get("message_id"), + "message_id": str(message_id), # "output_data": result.get("node_outputs", {}), # 所有节点输出(详细数据) "conversation_id": result.get("conversation_id"), # 所有节点输出(详细数据)payload., # 会话 ID "error_message": result.get("error"), @@ -775,14 +793,33 @@ class WorkflowService: token_usage = event.get("data", {}).get("token_usage", {}) or {} if status == "completed": final_messages = event.get("data", {}).get("messages", [])[init_message_length:] + human_message = "" + assistant_message = "" for message in final_messages: - self.conversation_service.add_message( - message_id=message_id if message["role"] != "user" else uuid.uuid4(), - conversation_id=conversation_id_uuid, - role=message["role"], - content=message["content"], - meta_data=None if message["role"] == "user" else {"usage": token_usage} - ) + 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"![image]({file.get('url', '')})" + else: + human_message += f"[{file.get("type")}]({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 + ) + self.conversation_service.add_message( + message_id=message_id, + conversation_id=conversation_id_uuid, + role="assistant", + content=assistant_message, + meta_data={"usage": token_usage} + ) self.update_execution_status( execution.execution_id, "completed", From 827ab27bef5e04d56ae92e8da5b26ad39e5dc2fa Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 16:12:55 +0800 Subject: [PATCH 39/89] fix(web): workflow upload bugfix --- .../components/UploadWorkflowModal.tsx | 14 ++++++++++---- web/src/views/Ontology/pages/Detail.tsx | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx index 56ff51eb..87c90061 100644 --- a/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx +++ b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx @@ -101,6 +101,7 @@ const UploadWorkflowModal = forwardRef { @@ -114,21 +115,24 @@ const UploadWorkflowModal = forwardRef setLoading(false)); break; case 1: // Step 2: Error/warning display if (firstFormData) { const { file, platform } = firstFormData; + const fileNameSplit = firstFormData.file[0].name.split('.') // Pre-fill form with file information form.setFieldsValue({ - name: file[0].name.split('.')[0], + name: fileNameSplit.slice(0, fileNameSplit.length - 1).join('.'), platform: platform, fileName: file[0].name, fileSize: file[0].size, @@ -175,7 +179,9 @@ const UploadWorkflowModal = forwardRef { {t('common.default')} } subTitle={
{data.scene_description}
} - extra={data.is_system_default ? undefined : ( + extra={!data.is_system_default ? undefined : ( )} From 391cd602a2feef16fd5cdf125412b2a4f344d91d Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 6 Mar 2026 16:32:33 +0800 Subject: [PATCH 40/89] fix(mcp): MCP tool binds the information of the tool marketplace --- api/app/controllers/tool_controller.py | 6 ++++++ api/app/models/tool_model.py | 20 +++++++++++++++++++- api/app/schemas/tool_schema.py | 8 ++++++++ api/app/services/tool_service.py | 16 ++++++++++++++-- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/api/app/controllers/tool_controller.py b/api/app/controllers/tool_controller.py index a3624ea4..ce5b15c0 100644 --- a/api/app/controllers/tool_controller.py +++ b/api/app/controllers/tool_controller.py @@ -97,6 +97,12 @@ async def create_tool( ): """创建工具""" try: + # 将 MCP 来源字段合并进 config + if request.tool_type == ToolType.MCP: + for key in ("source_channel", "market_id", "market_config_id", "mcp_service_id"): + val = getattr(request, key, None) + if val is not None: + request.config[key] = val tool_id = service.create_tool( name=request.name, tool_type=request.tool_type, diff --git a/api/app/models/tool_model.py b/api/app/models/tool_model.py index ccd28693..98448bc5 100644 --- a/api/app/models/tool_model.py +++ b/api/app/models/tool_model.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime from enum import StrEnum -from sqlalchemy import Column, String, Text, DateTime, JSON, ForeignKey, Integer, Float, Boolean +from sqlalchemy import Column, String, Text, DateTime, JSON, ForeignKey, Integer, Float, Boolean, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -163,6 +163,17 @@ class CustomToolConfig(Base): return f"" +class MCPSourceChannel(StrEnum): + """MCP来源渠道枚举""" + ALIYUN_BAILIAN = "aliyun_bailian" # 阿里云百炼 + MODELSCOPE = "modelscope" # ModelScope + TOKENFLUX = "tokenflux" # TokenFlux + LANGENG = "langeng" # 蓝耕科技 + AI_302 = "302ai" # 302.AI + MCP_ROUTER = "mcp_router" # MCP Router + SELF_HOSTED = "self_hosted" # 自建 + + class MCPToolConfig(Base): """MCP工具配置模型""" __tablename__ = "mcp_tool_configs" @@ -170,6 +181,13 @@ class MCPToolConfig(Base): id = Column(UUID(as_uuid=True), ForeignKey("tool_configs.id"), primary_key=True) server_url = Column(String(1000), nullable=False) # MCP服务器URL connection_config = Column(JSON, default=dict) # 连接配置(包含认证信息) + + # 来源渠道 + source_channel = Column(String(50), default=MCPSourceChannel.SELF_HOSTED, + server_default=text(f"'{MCPSourceChannel.SELF_HOSTED}'"), nullable=False, comment="来源渠道") + market_id = Column(UUID(as_uuid=True), nullable=True, comment="渠道市场id") + market_config_id = Column(UUID(as_uuid=True), nullable=True, comment="渠道市场配置id") + mcp_service_id = Column(String(255), nullable=True, comment="mcp服务id") # 服务状态 last_health_check = Column(DateTime) diff --git a/api/app/schemas/tool_schema.py b/api/app/schemas/tool_schema.py index 48afe2c3..2ba86c2c 100644 --- a/api/app/schemas/tool_schema.py +++ b/api/app/schemas/tool_schema.py @@ -155,6 +155,10 @@ class MCPToolConfigSchema(BaseModel): health_status: str = "unknown" error_message: Optional[str] = None available_tools: List[Dict[str, Dict[str, Any]]] = Field(default_factory=list, description="工具列表,格式: [{'tool_name': str, 'arguments': dict}]") + source_channel: Optional[str] = Field(None, description="来源渠道") + market_id: Optional[str] = Field(None, description="渠道市场id") + market_config_id: Optional[str] = Field(None, description="渠道市场配置id") + mcp_service_id: Optional[str] = Field(None, description="mcp服务id") class Config: from_attributes = True @@ -192,6 +196,10 @@ class ToolCreateRequest(BaseModel): tool_type: ToolType config: Dict[str, Any] = Field(default_factory=dict) tags: List[str] = Field(default_factory=list) + source_channel: Optional[str] = Field(None, description="来源渠道(仅MCP工具)") + market_id: Optional[str] = Field(None, description="渠道市场id(仅MCP工具)") + market_config_id: Optional[str] = Field(None, description="渠道市场配置id(仅MCP工具)") + mcp_service_id: Optional[str] = Field(None, description="mcp服务id(仅MCP工具)") class ToolUpdateRequest(BaseModel): diff --git a/api/app/services/tool_service.py b/api/app/services/tool_service.py index f6e2ccce..60ac1a38 100644 --- a/api/app/services/tool_service.py +++ b/api/app/services/tool_service.py @@ -85,7 +85,7 @@ class ToolService: """检查工具名称是否重复""" query = self.db.query(ToolConfig).filter( ToolConfig.name == name, - ToolConfig.tool_type == tool_type.value, + ToolConfig.tool_type == tool_type, ToolConfig.tenant_id == tenant_id ) if exclude_id: @@ -965,7 +965,11 @@ class ToolService: id=tool_config.id, server_url=config.get("server_url"), connection_config=config.get("connection_config", {}), - available_tools=config.get("available_tools", []) + available_tools=config.get("available_tools", []), + source_channel=config.get("source_channel", "self_hosted"), + market_id=config.get("market_id"), + market_config_id=config.get("market_config_id"), + mcp_service_id=config.get("mcp_service_id"), ) self.db.add(mcp_config) @@ -1018,6 +1022,14 @@ class ToolService: mcp_config.server_url = config.get("server_url") mcp_config.connection_config = config.get("connection_config", {}) mcp_config.available_tools = config.get("available_tools", []) + if config.get("source_channel") is not None: + mcp_config.source_channel = config.get("source_channel") + if config.get("market_id") is not None: + mcp_config.market_id = config.get("market_id") + if config.get("market_config_id") is not None: + mcp_config.market_config_id = config.get("market_config_id") + if config.get("mcp_service_id") is not None: + mcp_config.mcp_service_id = config.get("mcp_service_id") @staticmethod def _determine_initial_status(tool_info: Dict[str, Any]) -> str: From 8c3af7f4fffa9d16c17c3cd5dc98c3f314baf803 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Fri, 6 Mar 2026 16:35:24 +0800 Subject: [PATCH 41/89] fix(config): update default Redis DB numbers for Celery isolation - Change REDIS_DB_CELERY_BROKER default from 1 to 3 - Change REDIS_DB_CELERY_BACKEND default from 2 to 4 - Add documentation comments explaining DB isolation strategy - Prevent task interference when multiple developers share same Redis instance --- api/app/core/config.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/app/core/config.py b/api/app/core/config.py index ba17da93..bbe327b6 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -192,8 +192,10 @@ class Settings: # Celery configuration (internal) # NOTE: 变量名不以 CELERY_ 开头,避免被 Celery CLI 的前缀匹配机制劫持 # 详见 docs/celery-env-bug-report.md - REDIS_DB_CELERY_BROKER: int = int(os.getenv("REDIS_DB_CELERY_BROKER", "1")) - REDIS_DB_CELERY_BACKEND: int = int(os.getenv("REDIS_DB_CELERY_BACKEND", "2")) + # 默认使用 Redis DB 3 (broker) 和 DB 4 (backend),与业务缓存 (DB 1/2) 隔离 + # 多人共用同一 Redis 时,每位开发者应在 .env 中配置不同的 DB 编号避免任务互相干扰 + REDIS_DB_CELERY_BROKER: int = int(os.getenv("REDIS_DB_CELERY_BROKER", "3")) + REDIS_DB_CELERY_BACKEND: int = int(os.getenv("REDIS_DB_CELERY_BACKEND", "4")) # SMTP Email Configuration SMTP_SERVER: str = os.getenv("SMTP_SERVER", "smtp.gmail.com") From 2b0dedc81cf1326f8d2bd37087bb0aa45a2d5c2f Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 16:38:11 +0800 Subject: [PATCH 42/89] fix(web): model status bugfix --- web/src/views/ModelManagement/components/ModelListDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/views/ModelManagement/components/ModelListDetail.tsx b/web/src/views/ModelManagement/components/ModelListDetail.tsx index d42bc962..8d84ce4d 100644 --- a/web/src/views/ModelManagement/components/ModelListDetail.tsx +++ b/web/src/views/ModelManagement/components/ModelListDetail.tsx @@ -144,7 +144,7 @@ const ModelListDetail = forwardRef(({ {item.name[0]}
} - extra={ handleChange(item)} />} + extra={ handleChange(item)} />} bodyClassName="rb:relative rb:pb-[64px]! rb:h-[calc(100%-64px)]!" > From 41a0036bf6a64d8d691b97ecdb4912b74e662531 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Fri, 6 Mar 2026 16:52:27 +0800 Subject: [PATCH 43/89] chore(migrations): add MCP tool config source tracking fields - Add source_channel column to mcp_tool_configs with 'self_hosted' default - Add market_id column to track marketplace source reference - Add market_config_id column to store marketplace configuration reference - Add mcp_service_id column to identify MCP service instances - Enable tracking of tool origin and marketplace integration metadata --- .../versions/1ac07dc7366f_202603061644.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 api/migrations/versions/1ac07dc7366f_202603061644.py diff --git a/api/migrations/versions/1ac07dc7366f_202603061644.py b/api/migrations/versions/1ac07dc7366f_202603061644.py new file mode 100644 index 00000000..81266d78 --- /dev/null +++ b/api/migrations/versions/1ac07dc7366f_202603061644.py @@ -0,0 +1,36 @@ +"""202603061644 + +Revision ID: 1ac07dc7366f +Revises: 6a4641cf192b +Create Date: 2026-03-06 16:51:10.152305 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1ac07dc7366f' +down_revision: Union[str, None] = '6a4641cf192b' +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('mcp_tool_configs', sa.Column('source_channel', sa.String(length=50), server_default=sa.text("'self_hosted'"), nullable=False, comment='来源渠道')) + op.add_column('mcp_tool_configs', sa.Column('market_id', sa.UUID(), nullable=True, comment='渠道市场id')) + op.add_column('mcp_tool_configs', sa.Column('market_config_id', sa.UUID(), nullable=True, comment='渠道市场配置id')) + op.add_column('mcp_tool_configs', sa.Column('mcp_service_id', sa.String(length=255), nullable=True, comment='mcp服务id')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('mcp_tool_configs', 'mcp_service_id') + op.drop_column('mcp_tool_configs', 'market_config_id') + op.drop_column('mcp_tool_configs', 'market_id') + op.drop_column('mcp_tool_configs', 'source_channel') + # ### end Alembic commands ### From 9600d687faea95eed46ac4a2da8ace08a6d91e3b Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 6 Mar 2026 17:15:12 +0800 Subject: [PATCH 44/89] fix(mcp): Obtain the MCP tool information to complete the channel information --- api/app/services/tool_service.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/app/services/tool_service.py b/api/app/services/tool_service.py index 60ac1a38..4fe1e9e6 100644 --- a/api/app/services/tool_service.py +++ b/api/app/services/tool_service.py @@ -910,7 +910,11 @@ class ToolService: config_data.update({ "last_health_check": int(mcp_config.last_health_check.timestamp() * 1000) if mcp_config.last_health_check else None, "health_status": mcp_config.health_status, - "available_tools": available_tools_display + "available_tools": available_tools_display, + "source_channel": mcp_config.source_channel, + "market_id": mcp_config.market_id, + "market_config_id": mcp_config.market_config_id, + "mcp_service_id": mcp_config.mcp_service_id }) return ToolInfo( From 3c4dfb868f12f5c4006868fde883f587a9b0efb4 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 17:15:32 +0800 Subject: [PATCH 45/89] fix(web): knowledge-retrieval node's config ignore name & description key --- .../Workflow/components/Properties/index.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 76fc9ad0..bd5392cd 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -95,7 +95,7 @@ const Properties: FC = ({ initialValue[key] = config[key].defaultValue } }) - + form.setFieldsValue({ type, id: selectedNode.id, @@ -114,16 +114,16 @@ const Properties: FC = ({ */ const updateNodeLabel = (newLabel: string) => { if (selectedNode && form) { - const nodeData = selectedNode.data as NodeProperties; + const nodeData = selectedNode.getData() as NodeProperties; selectedNode.setAttrByPath('text/text', `${nodeData.icon} ${newLabel}`); - selectedNode.setData({ ...selectedNode.data, name: newLabel }); + selectedNode.setData({ ...selectedNode.getData(), name: newLabel }); } }; useEffect(() => { if (values && selectedNode) { const { id, knowledge_retrieval, group, group_variables, ...rest } = values - const { knowledge_bases = [], ...restKnowledgeConfig } = (knowledge_retrieval as any) || {} + const { knowledge_bases = [], name: _name, description: _description, ...restKnowledgeConfig } = (knowledge_retrieval as any) || {} let allRest = { ...rest, @@ -136,21 +136,23 @@ const Properties: FC = ({ })) } + const nodeData = selectedNode.getData() + Object.keys(values).forEach(key => { - if (selectedNode.data?.config?.[key]) { + if (nodeData?.config?.[key]) { // Create a deep copy to avoid reference sharing between nodes - if (!selectedNode.data.config[key]) { - selectedNode.data.config[key] = {}; + if (!nodeData.config[key]) { + nodeData.config[key] = {}; } - selectedNode.data.config[key] = { - ...selectedNode.data.config[key], + nodeData.config[key] = { + ...nodeData.config[key], defaultValue: values[key] }; } }) selectedNode?.setData({ - ...selectedNode.data, + ...nodeData, ...allRest, }) } From 63882e93914fb23ce31190be4f5c137974cb7f05 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 6 Mar 2026 17:16:00 +0800 Subject: [PATCH 46/89] [changes] Memory write completion active failure interest cache --- .../memory/agent/langgraph_graph/nodes/write_nodes.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py index ad0473fc..10fe96ba 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py @@ -1,3 +1,4 @@ +from app.cache.memory.interest_memory import InterestMemoryCache from app.core.memory.agent.utils.llm_tools import WriteState from app.core.memory.agent.utils.write_tools import write from app.core.logging_config import get_agent_logger @@ -40,6 +41,15 @@ async def write_node(state: WriteState) -> WriteState: ) logger.info(f"Write completed successfully! Config: {memory_config.config_name}") + # 写入 neo4j 成功后,删除该用户的兴趣分布缓存,确保下次请求重新生成 + for lang in ["zh", "en"]: + deleted = await InterestMemoryCache.delete_interest_distribution( + end_user_id=end_user_id, + language=lang, + ) + if deleted: + logger.info(f"Invalidated interest distribution cache: end_user_id={end_user_id}, language={lang}") + write_result = { "status": "success", "data": structured_messages, From f53633a8b80a608785ab2c083dcda533113bdd45 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Fri, 6 Mar 2026 16:58:36 +0800 Subject: [PATCH 47/89] fix(workflow): fix Dify compatibility issues --- .../core/workflow/adapters/dify/converter.py | 16 +++++------ .../workflow/adapters/dify/dify_adapter.py | 10 ++++++- api/app/core/workflow/engine/graph_builder.py | 2 +- api/app/core/workflow/nodes/base_config.py | 28 +++++++++---------- api/app/services/workflow_import_service.py | 2 +- 5 files changed, 33 insertions(+), 25 deletions(-) diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index 06c988d3..3c9348c7 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -129,11 +129,11 @@ class DifyConverter(BaseConverter): @staticmethod def _convert_file(var): - pass + return None @staticmethod def _convert_array_file(var): - pass + return [] @staticmethod def variable_type_map(source_type) -> VariableType | None: @@ -198,7 +198,7 @@ class DifyConverter(BaseConverter): "over-write": AssignmentOperator.COVER, "remove-last": AssignmentOperator.REMOVE_LAST, "remove-first": AssignmentOperator.REMOVE_FIRST, - + "set": AssignmentOperator.ASSIGN, } return operator_map.get(operator, operator) @@ -267,10 +267,10 @@ class DifyConverter(BaseConverter): type=var_type, required=var["required"], default=self.convert_variable_type( - var_type, var["default"] + var_type, var.get("default") ), description=var["label"], - max_length=var.get("max_length"), + max_length=var.get("max_length", 50), ) start_vars.append(var_def) result = StartNodeConfig.model_construct( @@ -333,7 +333,7 @@ class DifyConverter(BaseConverter): MessageConfig( role="user", content=self.trans_variable_format( - node_data["memory"].get("query_prompt_template", "{{#sys.query#}}") + node_data["memory"].get("query_prompt_template") or "{{#sys.query#}}" ) ) ) @@ -612,7 +612,7 @@ class DifyConverter(BaseConverter): ), headers=headers, params=params, - verify_ssl=node_data["ssl_verify"], + verify_ssl=node_data.get("ssl_verify", False), timeouts=HttpTimeOutConfig.model_construct( connect_timeout=node_data["timeout"]["max_connect_timeout"] or 5, read_timeout=node_data["timeout"]["max_read_timeout"] or 5, @@ -696,7 +696,7 @@ class DifyConverter(BaseConverter): group_variables = {} group_type = {} if not advanced_settings or not advanced_settings["group_enabled"]: - group_variables["output"] = [ + group_variables = [ self._process_list_variable_litearl(variable) for variable in node_data["variables"] ] diff --git a/api/app/core/workflow/adapters/dify/dify_adapter.py b/api/app/core/workflow/adapters/dify/dify_adapter.py index 6336b1f9..5b506d16 100644 --- a/api/app/core/workflow/adapters/dify/dify_adapter.py +++ b/api/app/core/workflow/adapters/dify/dify_adapter.py @@ -83,6 +83,12 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): require_fields = frozenset({'app', 'kind', 'version', 'workflow'}) if not all(field in self.config for field in require_fields): return False + if self.config.get("app",{}).get("mode") == "workflow": + self.errors.append(ExceptionDefineition( + type=ExceptionType.PLATFORM, + detail="workflow mode is not supported" + )) + return False for node in self.origin_nodes: if not self._valid_nodes(node): @@ -134,6 +140,8 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): for node in self.origin_nodes: if self.map_node_type(node["data"]["type"]) == NodeType.LLM: self.node_output_map[f"{node['id']}.text"] = f"{node['id']}.output" + elif self.map_node_type(node["data"]["type"]) == NodeType.KNOWLEDGE_RETRIEVAL: + self.node_output_map[f"{node['id']}.result"] = f"{node['id']}.output" def _convert_cycle_node_position(self, node_id: str, position: dict): for node in self.origin_nodes: @@ -184,7 +192,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): type=ExceptionType.NODE, node_id=node["id"], node_name=node["data"]["title"], - detail=f"node type {node_type} is unsupported", + detail=f"node type {node_type if node_type else 'notes'} is unsupported", )) return converter(node) except Exception as e: diff --git a/api/app/core/workflow/engine/graph_builder.py b/api/app/core/workflow/engine/graph_builder.py index 7b5c059c..5e4569ad 100644 --- a/api/app/core/workflow/engine/graph_builder.py +++ b/api/app/core/workflow/engine/graph_builder.py @@ -320,7 +320,7 @@ class GraphBuilder: # Used later to determine which branch to take based on the node's output # Assumes node output `node..output` matches the edge's label # For example, if node.123.output == 'CASE1', take the branch labeled 'CASE1' - related_edge[idx]['condition'] = f"node.{node_id}.output == '{related_edge[idx]['label']}'" + related_edge[idx]['condition'] = f"node['{node_id}']['output'] == '{related_edge[idx]['label']}'" if node_instance: # Wrap node's run method to avoid closure issues diff --git a/api/app/core/workflow/nodes/base_config.py b/api/app/core/workflow/nodes/base_config.py index 973e120d..4ae89376 100644 --- a/api/app/core/workflow/nodes/base_config.py +++ b/api/app/core/workflow/nodes/base_config.py @@ -85,20 +85,20 @@ class BaseNodeConfig(BaseModel): - tags: 节点标签(用于分类和搜索) """ - name: str | None = Field( - default=None, - description="节点名称(显示名称),如果不设置则使用节点 ID" - ) - - description: str | None = Field( - default=None, - description="节点描述,说明节点的作用" - ) - - tags: list[str] = Field( - default_factory=list, - description="节点标签,用于分类和搜索" - ) + # name: str | None = Field( + # default=None, + # description="节点名称(显示名称),如果不设置则使用节点 ID" + # ) + # + # description: str | None = Field( + # default=None, + # description="节点描述,说明节点的作用" + # ) + # + # tags: list[str] = Field( + # default_factory=list, + # description="节点标签,用于分类和搜索" + # ) class Config: """Pydantic 配置""" diff --git a/api/app/services/workflow_import_service.py b/api/app/services/workflow_import_service.py index 2e17f404..2b36c5ea 100644 --- a/api/app/services/workflow_import_service.py +++ b/api/app/services/workflow_import_service.py @@ -56,7 +56,7 @@ class WorkflowImportService: success=False, temp_id=None, workflow_id=None, - errors=[InvalidConfiguration()] + errors=[InvalidConfiguration()] + adapter.errors ) workflow_config = adapter.parse_workflow() From 05c9ed1450630f0e3d407a2720c53c0d06a72111 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Fri, 6 Mar 2026 17:26:03 +0800 Subject: [PATCH 48/89] fix(workflow): ensure file messages are written to messages in non-stream mode --- api/app/services/workflow_service.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index bd940387..eaf78b90 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -637,11 +637,12 @@ class WorkflowService: if message["role"] == "user": if isinstance(message["content"], str): human_message += message["content"] - elif isinstance(message["content"], dict): - if message["content"].get("type") == FileType.IMAGE: - human_message += f"![image]({message['content'].get('url', '')})" - else: - human_message += f"[{FileType}]({message['content'].get('url', '')})" + elif isinstance(message["content"], list): + for file in message["content"]: + if file.get("type") == FileType.IMAGE: + human_message += f"![image]({file.get('url', '')})" + else: + human_message += f"[{file.get('type')}]({file.get('url', '')})" if message["role"] == "assistant": assistant_message = message["content"] self.conversation_service.add_message( @@ -663,8 +664,7 @@ class WorkflowService: output_data=result, token_usage=token_usage.get("total_tokens", None) ) - logger.error(f"Workflow Run Failed, execution_id: {execution.execution_id}," - f" error: {result.get('error')}") + logger.info(f"Workflow Run Success, " f"execution_id: {execution.execution_id}, message count: {len(final_messages)}") else: @@ -673,6 +673,8 @@ class WorkflowService: "failed", error_message=result.get("error") ) + logger.error(f"Workflow Run Failed, execution_id: {execution.execution_id}," + f" error: {result.get('error')}") # 返回增强的响应结构 return { @@ -804,7 +806,7 @@ class WorkflowService: if file.get("type") == FileType.IMAGE: human_message += f"![image]({file.get('url', '')})" else: - human_message += f"[{file.get("type")}]({file.get('url', '')})" + human_message += f"[{file.get('type')}]({file.get('url', '')})" if message["role"] == "assistant": assistant_message = message["content"] self.conversation_service.add_message( From 479bba9a4eb854cc60a902767457a4870a8867ff Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 17:27:43 +0800 Subject: [PATCH 49/89] feat(web): http-request add headers variable --- .../components/Properties/hooks/useVariableList.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts index 4dca4854..779174ff 100644 --- a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts +++ b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts @@ -35,7 +35,8 @@ const NODE_VARIABLES = { ], 'http-request': [ { label: 'body', dataType: 'string', field: 'body' }, - { label: 'status_code', dataType: 'number', field: 'status_code' } + { label: 'status_code', dataType: 'number', field: 'status_code' }, + { label: 'headers', dataType: 'object', field: 'headers' }, ], 'question-classifier': [{ label: 'class_name', dataType: 'string', field: 'class_name' }], 'memory-read': [ @@ -390,11 +391,6 @@ export const useVariableList = ( addVariable(list, keys, `${pid}_item`, 'item', itemType, `${pid}.item`, pd); addVariable(list, keys, `${pid}_index`, 'index', 'number', `${pid}.index`, pd); } else if (pd.type === 'iteration' && !pd.config.input.defaultValue) { - let itemType = 'object'; - const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue); - if (iv?.dataType.startsWith('array[')) { - itemType = iv.dataType.replace(/^array\[(.+)\]$/, '$1'); - } addVariable(list, keys, `${pid}_item`, 'item', 'string', `${pid}.item`, pd); addVariable(list, keys, `${pid}_index`, 'index', 'number', `${pid}.index`, pd); } From b1368997c20bb6bb1dc80d10222d9857b3652592 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 6 Mar 2026 17:33:12 +0800 Subject: [PATCH 50/89] [changes] The enumeration check has been changed to a string. --- api/app/schemas/memory_storage_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index e396bbf6..046b79e7 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -275,8 +275,8 @@ class ConfigUpdateExtracted(BaseModel): # 更新记忆萃取引擎配置参数 # 剪枝配置:与 runtime.json 中 pruning 段对应 pruning_enabled: Optional[bool] = Field(None, description="是否启动智能语义剪枝") - pruning_scene: Optional[Literal["education", "online_service", "outbound"]] = Field( - None, description="智能剪枝场景:education/online_service/outbound" + pruning_scene: Optional[str] = Field( + None, description="智能剪枝场景:education/online_service/outbound 或本体工程自定义场景" ) pruning_threshold: Optional[float] = Field( None, ge=0.0, le=0.9, description="智能语义剪枝阈值(0-0.9)" From cde61cb6ac0fa035799fb00442f9542f7bcc2391 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 6 Mar 2026 17:33:12 +0800 Subject: [PATCH 51/89] [changes] The enumeration check has been changed to a string. --- api/app/schemas/memory_storage_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index e396bbf6..046b79e7 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -275,8 +275,8 @@ class ConfigUpdateExtracted(BaseModel): # 更新记忆萃取引擎配置参数 # 剪枝配置:与 runtime.json 中 pruning 段对应 pruning_enabled: Optional[bool] = Field(None, description="是否启动智能语义剪枝") - pruning_scene: Optional[Literal["education", "online_service", "outbound"]] = Field( - None, description="智能剪枝场景:education/online_service/outbound" + pruning_scene: Optional[str] = Field( + None, description="智能剪枝场景:education/online_service/outbound 或本体工程自定义场景" ) pruning_threshold: Optional[float] = Field( None, ge=0.0, le=0.9, description="智能语义剪枝阈值(0-0.9)" From 22382423ad68ee0ab6438d626f022556bc82a74f Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 18:30:40 +0800 Subject: [PATCH 52/89] fix(web): upload add loading --- web/src/i18n/en.ts | 1 + .../components/UploadWorkflowModal.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9c76ba98..ad9680d3 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1361,6 +1361,7 @@ export const en = { complex: 'Compatibility Analysis', sureInfo: 'Information Confirmation', completed: 'Import Completed', + baseInfo: 'Basic Information', workflowName: 'Workflow Name', fileName: 'File Name', fileSize: 'File Size', diff --git a/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx index 87c90061..e1353843 100644 --- a/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx +++ b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx @@ -142,6 +142,7 @@ const UploadWorkflowModal = forwardRef setLoading(false)); } break; default: @@ -243,7 +245,7 @@ const UploadWorkflowModal = forwardRef ]; } - }, [current]); + }, [current, loading]); return ( Date: Fri, 6 Mar 2026 18:32:24 +0800 Subject: [PATCH 53/89] [add] Recently, memory activities have adopted Redis caching. --- api/app/cache/memory/__init__.py | 2 + api/app/cache/memory/activity_stats_cache.py | 124 ++++++++++++++++++ .../controllers/memory_storage_controller.py | 7 +- .../core/memory/agent/utils/write_tools.py | 19 +++ api/app/services/memory_storage_service.py | 76 +++++++---- api/app/services/pilot_run_service.py | 19 +++ 6 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 api/app/cache/memory/activity_stats_cache.py diff --git a/api/app/cache/memory/__init__.py b/api/app/cache/memory/__init__.py index 9a7fd225..551062ac 100644 --- a/api/app/cache/memory/__init__.py +++ b/api/app/cache/memory/__init__.py @@ -4,7 +4,9 @@ Memory 缓存模块 提供记忆系统相关的缓存功能 """ from .interest_memory import InterestMemoryCache +from .activity_stats_cache import ActivityStatsCache __all__ = [ "InterestMemoryCache", + "ActivityStatsCache", ] diff --git a/api/app/cache/memory/activity_stats_cache.py b/api/app/cache/memory/activity_stats_cache.py new file mode 100644 index 00000000..6b162cdd --- /dev/null +++ b/api/app/cache/memory/activity_stats_cache.py @@ -0,0 +1,124 @@ +""" +Recent Activity Stats Cache + +记忆提取活动统计缓存模块 +用于缓存每次记忆提取流程的统计数据,按 workspace_id 存储,24小时后释放 +查询命令:cache:memory:activity_stats:by_workspace:7de31a97-40a6-4fc0-b8d3-15c89f523843 +""" +import json +import logging +from typing import Optional, Dict, Any +from datetime import datetime + +from app.aioRedis import aio_redis + +logger = logging.getLogger(__name__) + +# 缓存过期时间:24小时 +ACTIVITY_STATS_CACHE_EXPIRE = 86400 + + +class ActivityStatsCache: + """记忆提取活动统计缓存类""" + + PREFIX = "cache:memory:activity_stats" + + @classmethod + def _get_key(cls, workspace_id: str) -> str: + """生成 Redis key + + Args: + workspace_id: 工作空间ID + + Returns: + 完整的 Redis key + """ + return f"{cls.PREFIX}:by_workspace:{workspace_id}" + + @classmethod + async def set_activity_stats( + cls, + workspace_id: str, + stats: Dict[str, Any], + expire: int = ACTIVITY_STATS_CACHE_EXPIRE, + ) -> bool: + """设置记忆提取活动统计缓存 + + Args: + workspace_id: 工作空间ID + stats: 统计数据,格式: + { + "chunk_count": int, + "statements_count": int, + "triplet_entities_count": int, + "triplet_relations_count": int, + "temporal_count": int, + } + expire: 过期时间(秒),默认24小时 + + Returns: + 是否设置成功 + """ + try: + key = cls._get_key(workspace_id) + payload = { + "stats": stats, + "generated_at": datetime.now().isoformat(), + "workspace_id": workspace_id, + "cached": True, + } + value = json.dumps(payload, ensure_ascii=False) + await aio_redis.set(key, value, ex=expire) + logger.info(f"设置活动统计缓存成功: {key}, 过期时间: {expire}秒") + return True + except Exception as e: + logger.error(f"设置活动统计缓存失败: {e}", exc_info=True) + return False + + @classmethod + async def get_activity_stats( + cls, + workspace_id: str, + ) -> Optional[Dict[str, Any]]: + """获取记忆提取活动统计缓存 + + Args: + workspace_id: 工作空间ID + + Returns: + 统计数据字典,缓存不存在或已过期返回 None + """ + try: + key = cls._get_key(workspace_id) + value = await aio_redis.get(key) + if value: + payload = json.loads(value) + logger.info(f"命中活动统计缓存: {key}") + return payload + logger.info(f"活动统计缓存不存在或已过期: {key}") + return None + except Exception as e: + logger.error(f"获取活动统计缓存失败: {e}", exc_info=True) + return None + + @classmethod + async def delete_activity_stats( + cls, + workspace_id: str, + ) -> bool: + """删除记忆提取活动统计缓存 + + Args: + workspace_id: 工作空间ID + + Returns: + 是否删除成功 + """ + try: + key = cls._get_key(workspace_id) + result = await aio_redis.delete(key) + logger.info(f"删除活动统计缓存: {key}, 结果: {result}") + return result > 0 + except Exception as e: + logger.error(f"删除活动统计缓存失败: {e}", exc_info=True) + return False diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index ee45fb83..f43eb4cd 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -543,11 +543,12 @@ async def clear_hot_memory_tags_cache( @router.get("/analytics/recent_activity_stats", response_model=ApiResponse) async def get_recent_activity_stats_api( + workspace_id: Optional[str] = Query(None, description="工作空间ID,用于从 Redis 读取对应缓存"), current_user: User = Depends(get_current_user), - ) -> dict: - api_logger.info("Recent activity stats requested") +) -> dict: + api_logger.info(f"Recent activity stats requested: workspace_id={workspace_id}") try: - result = await analytics_recent_activity_stats() + result = await analytics_recent_activity_stats(workspace_id=workspace_id) return success(data=result, msg="查询成功") except Exception as e: api_logger.error(f"Recent activity stats failed: {str(e)}") diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index 93c6ef6f..22030278 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -225,5 +225,24 @@ async def write( with open(log_file, "a", encoding="utf-8") as f: f.write(f"=== Pipeline Run Completed: {timestamp} ===\n\n") + # 将提取统计写入 Redis,按 workspace_id 存储 + try: + from app.cache.memory.activity_stats_cache import ActivityStatsCache + + stats_to_cache = { + "chunk_count": len(all_chunk_nodes) if all_chunk_nodes else 0, + "statements_count": len(all_statement_nodes) if all_statement_nodes else 0, + "triplet_entities_count": len(all_entity_nodes) if all_entity_nodes else 0, + "triplet_relations_count": len(all_entity_entity_edges) if all_entity_entity_edges else 0, + "temporal_count": 0, + } + await ActivityStatsCache.set_activity_stats( + workspace_id=str(memory_config.workspace_id), + stats=stats_to_cache, + ) + logger.info(f"[WRITE] 活动统计已写入 Redis: workspace_id={memory_config.workspace_id}") + except Exception as cache_err: + logger.warning(f"[WRITE] 写入活动统计缓存失败(不影响主流程): {cache_err}", exc_info=True) + logger.info("=== Pipeline Complete ===") logger.info(f"Total execution time: {total_time:.2f} seconds") \ No newline at end of file diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index a83d6830..6e7c1ad4 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -783,8 +783,37 @@ async def analytics_hot_memory_tags( await connector.close() -async def analytics_recent_activity_stats() -> Dict[str, Any]: - stats, _msg = get_recent_activity_stats() +async def analytics_recent_activity_stats(workspace_id: Optional[str] = None) -> Dict[str, Any]: + """获取最近记忆提取活动统计。 + + 优先从 Redis 缓存读取(按 workspace_id),缓存不存在时降级到日志文件解析。 + + Args: + workspace_id: 工作空间ID,用于从 Redis 读取对应缓存 + + Returns: + 包含 total、stats、latest_relative、source 的统计字典 + """ + stats = None + source = "log" + + # 优先从 Redis 读取 + if workspace_id: + try: + from app.cache.memory.activity_stats_cache import ActivityStatsCache + cached = await ActivityStatsCache.get_activity_stats(workspace_id) + if cached: + stats = cached.get("stats", {}) + source = "redis" + logger.info(f"[ANALYTICS] 从 Redis 读取活动统计: workspace_id={workspace_id}") + except Exception as e: + logger.warning(f"[ANALYTICS] 读取 Redis 活动统计失败,降级到日志: {e}") + + # 降级:从日志文件解析 + if stats is None: + stats, _msg = get_recent_activity_stats() + source = "log" + total = ( stats.get("chunk_count", 0) + stats.get("statements_count", 0) @@ -792,26 +821,29 @@ async def analytics_recent_activity_stats() -> Dict[str, Any]: + stats.get("triplet_relations_count", 0) + stats.get("temporal_count", 0) ) - # 精简:仅提供“最新一次活动多久前” - latest_relative = None - try: - info = stats.get("log_path", "") - idx = info.rfind("最新:") - if idx != -1: - latest_path = info[idx + 3 :].strip() - if latest_path and os.path.exists(latest_path): - import time - diff = max(0.0, time.time() - os.path.getmtime(latest_path)) - m = int(diff // 60) - if m < 1: - latest_relative = "刚刚" - elif m < 60: - latest_relative = "一会前" - else: - latest_relative = "较早前" - except Exception: - pass - data = {"total": total, "stats": stats, "latest_relative": latest_relative} + # 计算"最新一次活动多久前"(仅日志来源时有效) + latest_relative = None + if source == "log": + try: + info = stats.get("log_path", "") + idx = info.rfind("最新:") + if idx != -1: + latest_path = info[idx + 3:].strip() + if latest_path and os.path.exists(latest_path): + import time + diff = max(0.0, time.time() - os.path.getmtime(latest_path)) + m = int(diff // 60) + if m < 1: + latest_relative = "刚刚" + elif m < 60: + latest_relative = "一会前" + else: + latest_relative = "较早前" + except Exception: + pass + + data = {"total": total, "stats": stats, "latest_relative": latest_relative, "source": source} return data + diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 4d9cbb5e..5d00d8a5 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -326,6 +326,25 @@ async def run_pilot_extraction( logger.info("Pilot run completed: Skipping Neo4j save") + # 将提取统计写入 Redis,按 workspace_id 存储 + try: + from app.cache.memory.activity_stats_cache import ActivityStatsCache + + stats_to_cache = { + "chunk_count": len(chunk_nodes) if chunk_nodes else 0, + "statements_count": len(statement_nodes) if statement_nodes else 0, + "triplet_entities_count": len(entity_nodes) if entity_nodes else 0, + "triplet_relations_count": len(entity_edges) if entity_edges else 0, + "temporal_count": 0, # temporal 数据在日志中,此处暂置0 + } + await ActivityStatsCache.set_activity_stats( + workspace_id=str(memory_config.workspace_id), + stats=stats_to_cache, + ) + logger.info(f"[PILOT_RUN] 活动统计已写入 Redis: workspace_id={memory_config.workspace_id}") + except Exception as cache_err: + logger.warning(f"[PILOT_RUN] 写入活动统计缓存失败(不影响主流程): {cache_err}", exc_info=True) + except Exception as e: logger.error(f"Pilot run failed: {e}", exc_info=True) raise From 9caa986c803811bf095677d01312f41901ee69e7 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 6 Mar 2026 18:38:23 +0800 Subject: [PATCH 54/89] [changes] Work space isolation --- api/app/controllers/memory_storage_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index f43eb4cd..d91dfc36 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -543,9 +543,9 @@ async def clear_hot_memory_tags_cache( @router.get("/analytics/recent_activity_stats", response_model=ApiResponse) async def get_recent_activity_stats_api( - workspace_id: Optional[str] = Query(None, description="工作空间ID,用于从 Redis 读取对应缓存"), current_user: User = Depends(get_current_user), ) -> dict: + workspace_id = str(current_user.current_workspace_id) if current_user.current_workspace_id else None api_logger.info(f"Recent activity stats requested: workspace_id={workspace_id}") try: result = await analytics_recent_activity_stats(workspace_id=workspace_id) From 834387e2541f0a319d69cc2d1647e8a65978e88c Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 6 Mar 2026 18:32:24 +0800 Subject: [PATCH 55/89] [add] Recently, memory activities have adopted Redis caching. --- api/app/cache/memory/__init__.py | 2 + api/app/cache/memory/activity_stats_cache.py | 124 ++++++++++++++++++ .../controllers/memory_storage_controller.py | 7 +- .../core/memory/agent/utils/write_tools.py | 19 +++ api/app/services/memory_storage_service.py | 76 +++++++---- api/app/services/pilot_run_service.py | 19 +++ 6 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 api/app/cache/memory/activity_stats_cache.py diff --git a/api/app/cache/memory/__init__.py b/api/app/cache/memory/__init__.py index 9a7fd225..551062ac 100644 --- a/api/app/cache/memory/__init__.py +++ b/api/app/cache/memory/__init__.py @@ -4,7 +4,9 @@ Memory 缓存模块 提供记忆系统相关的缓存功能 """ from .interest_memory import InterestMemoryCache +from .activity_stats_cache import ActivityStatsCache __all__ = [ "InterestMemoryCache", + "ActivityStatsCache", ] diff --git a/api/app/cache/memory/activity_stats_cache.py b/api/app/cache/memory/activity_stats_cache.py new file mode 100644 index 00000000..6b162cdd --- /dev/null +++ b/api/app/cache/memory/activity_stats_cache.py @@ -0,0 +1,124 @@ +""" +Recent Activity Stats Cache + +记忆提取活动统计缓存模块 +用于缓存每次记忆提取流程的统计数据,按 workspace_id 存储,24小时后释放 +查询命令:cache:memory:activity_stats:by_workspace:7de31a97-40a6-4fc0-b8d3-15c89f523843 +""" +import json +import logging +from typing import Optional, Dict, Any +from datetime import datetime + +from app.aioRedis import aio_redis + +logger = logging.getLogger(__name__) + +# 缓存过期时间:24小时 +ACTIVITY_STATS_CACHE_EXPIRE = 86400 + + +class ActivityStatsCache: + """记忆提取活动统计缓存类""" + + PREFIX = "cache:memory:activity_stats" + + @classmethod + def _get_key(cls, workspace_id: str) -> str: + """生成 Redis key + + Args: + workspace_id: 工作空间ID + + Returns: + 完整的 Redis key + """ + return f"{cls.PREFIX}:by_workspace:{workspace_id}" + + @classmethod + async def set_activity_stats( + cls, + workspace_id: str, + stats: Dict[str, Any], + expire: int = ACTIVITY_STATS_CACHE_EXPIRE, + ) -> bool: + """设置记忆提取活动统计缓存 + + Args: + workspace_id: 工作空间ID + stats: 统计数据,格式: + { + "chunk_count": int, + "statements_count": int, + "triplet_entities_count": int, + "triplet_relations_count": int, + "temporal_count": int, + } + expire: 过期时间(秒),默认24小时 + + Returns: + 是否设置成功 + """ + try: + key = cls._get_key(workspace_id) + payload = { + "stats": stats, + "generated_at": datetime.now().isoformat(), + "workspace_id": workspace_id, + "cached": True, + } + value = json.dumps(payload, ensure_ascii=False) + await aio_redis.set(key, value, ex=expire) + logger.info(f"设置活动统计缓存成功: {key}, 过期时间: {expire}秒") + return True + except Exception as e: + logger.error(f"设置活动统计缓存失败: {e}", exc_info=True) + return False + + @classmethod + async def get_activity_stats( + cls, + workspace_id: str, + ) -> Optional[Dict[str, Any]]: + """获取记忆提取活动统计缓存 + + Args: + workspace_id: 工作空间ID + + Returns: + 统计数据字典,缓存不存在或已过期返回 None + """ + try: + key = cls._get_key(workspace_id) + value = await aio_redis.get(key) + if value: + payload = json.loads(value) + logger.info(f"命中活动统计缓存: {key}") + return payload + logger.info(f"活动统计缓存不存在或已过期: {key}") + return None + except Exception as e: + logger.error(f"获取活动统计缓存失败: {e}", exc_info=True) + return None + + @classmethod + async def delete_activity_stats( + cls, + workspace_id: str, + ) -> bool: + """删除记忆提取活动统计缓存 + + Args: + workspace_id: 工作空间ID + + Returns: + 是否删除成功 + """ + try: + key = cls._get_key(workspace_id) + result = await aio_redis.delete(key) + logger.info(f"删除活动统计缓存: {key}, 结果: {result}") + return result > 0 + except Exception as e: + logger.error(f"删除活动统计缓存失败: {e}", exc_info=True) + return False diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index ee45fb83..f43eb4cd 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -543,11 +543,12 @@ async def clear_hot_memory_tags_cache( @router.get("/analytics/recent_activity_stats", response_model=ApiResponse) async def get_recent_activity_stats_api( + workspace_id: Optional[str] = Query(None, description="工作空间ID,用于从 Redis 读取对应缓存"), current_user: User = Depends(get_current_user), - ) -> dict: - api_logger.info("Recent activity stats requested") +) -> dict: + api_logger.info(f"Recent activity stats requested: workspace_id={workspace_id}") try: - result = await analytics_recent_activity_stats() + result = await analytics_recent_activity_stats(workspace_id=workspace_id) return success(data=result, msg="查询成功") except Exception as e: api_logger.error(f"Recent activity stats failed: {str(e)}") diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index 93c6ef6f..22030278 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -225,5 +225,24 @@ async def write( with open(log_file, "a", encoding="utf-8") as f: f.write(f"=== Pipeline Run Completed: {timestamp} ===\n\n") + # 将提取统计写入 Redis,按 workspace_id 存储 + try: + from app.cache.memory.activity_stats_cache import ActivityStatsCache + + stats_to_cache = { + "chunk_count": len(all_chunk_nodes) if all_chunk_nodes else 0, + "statements_count": len(all_statement_nodes) if all_statement_nodes else 0, + "triplet_entities_count": len(all_entity_nodes) if all_entity_nodes else 0, + "triplet_relations_count": len(all_entity_entity_edges) if all_entity_entity_edges else 0, + "temporal_count": 0, + } + await ActivityStatsCache.set_activity_stats( + workspace_id=str(memory_config.workspace_id), + stats=stats_to_cache, + ) + logger.info(f"[WRITE] 活动统计已写入 Redis: workspace_id={memory_config.workspace_id}") + except Exception as cache_err: + logger.warning(f"[WRITE] 写入活动统计缓存失败(不影响主流程): {cache_err}", exc_info=True) + logger.info("=== Pipeline Complete ===") logger.info(f"Total execution time: {total_time:.2f} seconds") \ No newline at end of file diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index a83d6830..6e7c1ad4 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -783,8 +783,37 @@ async def analytics_hot_memory_tags( await connector.close() -async def analytics_recent_activity_stats() -> Dict[str, Any]: - stats, _msg = get_recent_activity_stats() +async def analytics_recent_activity_stats(workspace_id: Optional[str] = None) -> Dict[str, Any]: + """获取最近记忆提取活动统计。 + + 优先从 Redis 缓存读取(按 workspace_id),缓存不存在时降级到日志文件解析。 + + Args: + workspace_id: 工作空间ID,用于从 Redis 读取对应缓存 + + Returns: + 包含 total、stats、latest_relative、source 的统计字典 + """ + stats = None + source = "log" + + # 优先从 Redis 读取 + if workspace_id: + try: + from app.cache.memory.activity_stats_cache import ActivityStatsCache + cached = await ActivityStatsCache.get_activity_stats(workspace_id) + if cached: + stats = cached.get("stats", {}) + source = "redis" + logger.info(f"[ANALYTICS] 从 Redis 读取活动统计: workspace_id={workspace_id}") + except Exception as e: + logger.warning(f"[ANALYTICS] 读取 Redis 活动统计失败,降级到日志: {e}") + + # 降级:从日志文件解析 + if stats is None: + stats, _msg = get_recent_activity_stats() + source = "log" + total = ( stats.get("chunk_count", 0) + stats.get("statements_count", 0) @@ -792,26 +821,29 @@ async def analytics_recent_activity_stats() -> Dict[str, Any]: + stats.get("triplet_relations_count", 0) + stats.get("temporal_count", 0) ) - # 精简:仅提供“最新一次活动多久前” - latest_relative = None - try: - info = stats.get("log_path", "") - idx = info.rfind("最新:") - if idx != -1: - latest_path = info[idx + 3 :].strip() - if latest_path and os.path.exists(latest_path): - import time - diff = max(0.0, time.time() - os.path.getmtime(latest_path)) - m = int(diff // 60) - if m < 1: - latest_relative = "刚刚" - elif m < 60: - latest_relative = "一会前" - else: - latest_relative = "较早前" - except Exception: - pass - data = {"total": total, "stats": stats, "latest_relative": latest_relative} + # 计算"最新一次活动多久前"(仅日志来源时有效) + latest_relative = None + if source == "log": + try: + info = stats.get("log_path", "") + idx = info.rfind("最新:") + if idx != -1: + latest_path = info[idx + 3:].strip() + if latest_path and os.path.exists(latest_path): + import time + diff = max(0.0, time.time() - os.path.getmtime(latest_path)) + m = int(diff // 60) + if m < 1: + latest_relative = "刚刚" + elif m < 60: + latest_relative = "一会前" + else: + latest_relative = "较早前" + except Exception: + pass + + data = {"total": total, "stats": stats, "latest_relative": latest_relative, "source": source} return data + diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 4d9cbb5e..5d00d8a5 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -326,6 +326,25 @@ async def run_pilot_extraction( logger.info("Pilot run completed: Skipping Neo4j save") + # 将提取统计写入 Redis,按 workspace_id 存储 + try: + from app.cache.memory.activity_stats_cache import ActivityStatsCache + + stats_to_cache = { + "chunk_count": len(chunk_nodes) if chunk_nodes else 0, + "statements_count": len(statement_nodes) if statement_nodes else 0, + "triplet_entities_count": len(entity_nodes) if entity_nodes else 0, + "triplet_relations_count": len(entity_edges) if entity_edges else 0, + "temporal_count": 0, # temporal 数据在日志中,此处暂置0 + } + await ActivityStatsCache.set_activity_stats( + workspace_id=str(memory_config.workspace_id), + stats=stats_to_cache, + ) + logger.info(f"[PILOT_RUN] 活动统计已写入 Redis: workspace_id={memory_config.workspace_id}") + except Exception as cache_err: + logger.warning(f"[PILOT_RUN] 写入活动统计缓存失败(不影响主流程): {cache_err}", exc_info=True) + except Exception as e: logger.error(f"Pilot run failed: {e}", exc_info=True) raise From aa6638424cbc521aeb8de4980e169253ca8b1170 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 6 Mar 2026 18:38:23 +0800 Subject: [PATCH 56/89] [changes] Work space isolation --- api/app/controllers/memory_storage_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index f43eb4cd..d91dfc36 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -543,9 +543,9 @@ async def clear_hot_memory_tags_cache( @router.get("/analytics/recent_activity_stats", response_model=ApiResponse) async def get_recent_activity_stats_api( - workspace_id: Optional[str] = Query(None, description="工作空间ID,用于从 Redis 读取对应缓存"), current_user: User = Depends(get_current_user), ) -> dict: + workspace_id = str(current_user.current_workspace_id) if current_user.current_workspace_id else None api_logger.info(f"Recent activity stats requested: workspace_id={workspace_id}") try: result = await analytics_recent_activity_stats(workspace_id=workspace_id) From 3e5f6176afb73c0072155ee9de09e37aeb98dec5 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Fri, 6 Mar 2026 19:29:31 +0800 Subject: [PATCH 57/89] feat: support model load balancing and add message_id to API responses --- api/app/core/workflow/nodes/base_node.py | 14 ++++++++++++-- api/app/core/workflow/nodes/llm/node.py | 6 ++++-- .../workflow/nodes/parameter_extractor/node.py | 4 +++- .../workflow/nodes/question_classifier/node.py | 4 +++- api/app/schemas/conversation_schema.py | 1 + 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index dacbef85..b84011d3 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -1,7 +1,7 @@ import asyncio import logging -import uuid from abc import ABC, abstractmethod +from datetime import datetime from functools import cached_property from typing import Any, AsyncGenerator @@ -13,6 +13,7 @@ from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes.enums import BRANCH_NODES from app.core.workflow.variable.base_variable import VariableType, FileObject from app.db import get_db_read +from app.models import ModelConfig, ModelApiKey, LoadBalanceStrategy from app.schemas import FileInput from app.services.multimodal_service import MultimodalService @@ -629,7 +630,7 @@ class BaseNode(ABC): ) if isinstance(content, str): if enable_file: - return [{"text": content}] + return [{"type": "text", "text": content}] return content elif isinstance(content, FileObject): @@ -667,3 +668,12 @@ class BaseNode(ABC): elif isinstance(content, str): return content return result + + def model_balance(self, model_config: ModelConfig) -> ModelApiKey: + api_keys = [key for key in model_config.api_keys if key.is_active] + if not api_keys: + raise ValueError("No active API keys available for model") + if model_config.load_balance_strategy == LoadBalanceStrategy.ROUND_ROBIN: + if model_config.load_balance_strategy == LoadBalanceStrategy.ROUND_ROBIN: + return min(api_keys, key=lambda x: (int(x.usage_count or "0"), x.last_used_at or datetime.min)) + return api_keys[0] diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index 4b63bc4e..92a0dff7 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -112,11 +112,12 @@ class LLMNode(BaseNode): raise BusinessException("模型配置缺少 API Key", BizCode.INVALID_PARAMETER) # 在 Session 关闭前提取所有需要的数据 - api_config = config.api_keys[0] + api_config = self.model_balance(config) model_name = api_config.model_name provider = api_config.provider api_key = api_config.api_key api_base = api_config.api_base + is_omni = api_config.is_omni model_type = config.type # 4. 创建 LLM 实例(使用已提取的数据) @@ -129,7 +130,8 @@ class LLMNode(BaseNode): provider=provider, api_key=api_key, base_url=api_base, - extra_params=extra_params + extra_params=extra_params, + is_omni=is_omni ), type=ModelType(model_type) ) diff --git a/api/app/core/workflow/nodes/parameter_extractor/node.py b/api/app/core/workflow/nodes/parameter_extractor/node.py index 4811c118..700ed85f 100644 --- a/api/app/core/workflow/nodes/parameter_extractor/node.py +++ b/api/app/core/workflow/nodes/parameter_extractor/node.py @@ -95,11 +95,12 @@ class ParameterExtractorNode(BaseNode): if not config.api_keys or len(config.api_keys) == 0: raise BusinessException("Model configuration is missing API Key", BizCode.INVALID_PARAMETER) - api_config = config.api_keys[0] + api_config = self.model_balance(config) model_name = api_config.model_name provider = api_config.provider api_key = api_config.api_key api_base = api_config.api_base + is_omni = api_config.is_omni model_type = config.type llm = RedBearLLM( @@ -108,6 +109,7 @@ class ParameterExtractorNode(BaseNode): provider=provider, api_key=api_key, base_url=api_base, + is_omni=is_omni ), type=ModelType(model_type) ) diff --git a/api/app/core/workflow/nodes/question_classifier/node.py b/api/app/core/workflow/nodes/question_classifier/node.py index e2fd97ae..5cebd886 100644 --- a/api/app/core/workflow/nodes/question_classifier/node.py +++ b/api/app/core/workflow/nodes/question_classifier/node.py @@ -56,11 +56,12 @@ class QuestionClassifierNode(BaseNode): if not config.api_keys or len(config.api_keys) == 0: raise BusinessException("模型配置缺少 API Key", BizCode.INVALID_PARAMETER) - api_config = config.api_keys[0] + api_config = self.model_balance(config) model_name = api_config.model_name provider = api_config.provider api_key = api_config.api_key base_url = api_config.api_base + is_omni = api_config.is_omni model_type = config.type return RedBearLLM( @@ -69,6 +70,7 @@ class QuestionClassifierNode(BaseNode): provider=provider, api_key=api_key, base_url=base_url, + is_omni=is_omni ), type=ModelType(model_type) ) diff --git a/api/app/schemas/conversation_schema.py b/api/app/schemas/conversation_schema.py index 0fcbc718..13766ef6 100644 --- a/api/app/schemas/conversation_schema.py +++ b/api/app/schemas/conversation_schema.py @@ -86,6 +86,7 @@ class ChatResponse(BaseModel): """聊天响应(非流式)""" conversation_id: uuid.UUID message: str + message_id: str usage: Optional[Dict[str, Any]] = None elapsed_time: Optional[float] = None From b756f0c86cccc1b01233678ae217a3da8649dd02 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Fri, 6 Mar 2026 19:29:31 +0800 Subject: [PATCH 58/89] feat: support model load balancing and add message_id to API responses --- api/app/core/workflow/nodes/base_node.py | 16 ++++++++++------ api/app/core/workflow/nodes/llm/node.py | 14 +++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index b84011d3..496454ba 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -618,7 +618,12 @@ class BaseNode(ABC): return variable_pool.has(selector) @staticmethod - async def process_message(provider: str, content: str | dict | FileObject, enable_file=False) -> list | str | None: + async def process_message( + provider: str, + is_omni: bool, + content: str | dict | FileObject, + enable_file=False + ) -> list | str | None: if isinstance(content, dict): content = FileObject( type=content.get("type"), @@ -637,7 +642,7 @@ class BaseNode(ABC): if content.content_cache.get(provider): return content.content_cache[provider] with get_db_read() as db: - multimodel_service = MultimodalService(db, provider) + multimodel_service = MultimodalService(db, provider, is_omni=is_omni) message = await multimodel_service.process_files( [FileInput.model_construct( type=content.type, @@ -647,7 +652,6 @@ class BaseNode(ABC): upload_file_id=content.file_id )] ) - if message: content.content_cache[provider] = message return message @@ -669,11 +673,11 @@ class BaseNode(ABC): return content return result - def model_balance(self, model_config: ModelConfig) -> ModelApiKey: + @staticmethod + def model_balance(model_config: ModelConfig) -> ModelApiKey: api_keys = [key for key in model_config.api_keys if key.is_active] if not api_keys: raise ValueError("No active API keys available for model") if model_config.load_balance_strategy == LoadBalanceStrategy.ROUND_ROBIN: - if model_config.load_balance_strategy == LoadBalanceStrategy.ROUND_ROBIN: - return min(api_keys, key=lambda x: (int(x.usage_count or "0"), x.last_used_at or datetime.min)) + return min(api_keys, key=lambda x: (int(x.usage_count or "0"), x.last_used_at or datetime.min)) return api_keys[0] diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index 92a0dff7..186c204f 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -153,30 +153,30 @@ class LLMNode(BaseNode): if role == "system": messages.append({ "role": "system", - "content": await self.process_message(provider, content, self.typed_config.vision) + "content": await self.process_message(provider, is_omni, content, self.typed_config.vision) }) elif role in ["user", "human"]: messages.append({ "role": "user", - "content": await self.process_message(provider, content, self.typed_config.vision) + "content": await self.process_message(provider, is_omni, content, self.typed_config.vision) }) elif role in ["ai", "assistant"]: messages.append({ "role": "assistant", - "content": await self.process_message(provider, content, self.typed_config.vision) + "content": await self.process_message(provider, is_omni, content, self.typed_config.vision) }) else: logger.warning(f"未知的消息角色: {role},默认使用 user") messages.append({ "role": "user", - "content": await self.process_message(provider, content, self.typed_config.vision) + "content": await self.process_message(provider, is_omni, content, self.typed_config.vision) }) if self.typed_config.vision_input and self.typed_config.vision: file_content = [] files = variable_pool.get_instance(self.typed_config.vision_input) for file in files.value: - content = await self.process_message(provider, file.value, self.typed_config.vision) + content = await self.process_message(provider, is_omni, file.value, self.typed_config.vision) if content: file_content.extend(content) if messages and messages[-1]["role"] == 'user': @@ -190,14 +190,14 @@ class LLMNode(BaseNode): if isinstance(message["content"], list): file_content = [] for file in message["content"]: - content = await self.process_message(provider, file, self.typed_config.vision) + content = await self.process_message(provider, is_omni, file, self.typed_config.vision) if content: file_content.extend(content) history_message.append( {"role": message["role"], "content": file_content} ) else: - message["content"] = await self.process_message(provider, message["content"], self.typed_config.vision) + message["content"] = await self.process_message(provider, is_omni, message["content"], self.typed_config.vision) history_message.append(message) messages = messages[:-1] + history_message + messages[-1:] self.messages = messages From 865ad31f2fda10ce0bf26da3b2afc54b8453b18f Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Mar 2026 19:44:34 +0800 Subject: [PATCH 59/89] fix(web): chat file delete bugfix --- web/src/components/Chat/ChatInput.tsx | 6 +++++- web/src/utils/request.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 8c8dce1a..508b0d0c 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -50,7 +50,11 @@ const ChatInput: FC = ({ const handleDelete = (file: any) => { - fileChange?.(fileList?.filter(item => file.url ? item.url !== file.url : item.uid !== file.uid) || []) + fileChange?.(fileList?.filter(item => { + return item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl + : item.url && file.url ? item.url !== file.url + : item.uid !== file.uid + }) || []) } // Convert file object to preview URL const previewFileList = useMemo(() => { diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts index 03941960..3f81d4ab 100644 --- a/web/src/utils/request.ts +++ b/web/src/utils/request.ts @@ -356,12 +356,11 @@ export const request = { * Get parent domain for cookie setting * @returns Parent domain or IP address */ +const isIp = (hostname: string) => /^\d+\.\d+\.\d+\.\d+$/.test(hostname) + const getParentDomain = () => { const hostname = window.location.hostname - // Check if it's an IP address - if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { - return hostname - } + if (isIp(hostname)) return hostname const parts = hostname.split('.') return parts.length > 2 ? `.${parts.slice(-2).join('.')}` : hostname } @@ -371,7 +370,10 @@ const getParentDomain = () => { */ export const cookieUtils = { set: (name: string, value: string, domain = getParentDomain()) => { - document.cookie = `${name}=${value}; domain=${domain}; path=/; secure; samesite=strict` + const ip = isIp(window.location.hostname) + const domainPart = ip ? '' : `; domain=${domain}` + const securePart = window.location.protocol === 'https:' ? '; secure' : '' + document.cookie = `${name}=${value}${domainPart}; path=/${securePart}; samesite=strict` }, get: (name: string) => { const value = `; ${document.cookie}` From 0a3acf446d5f86a3f41381cb4926183e4e6a8275 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Sat, 7 Mar 2026 04:19:35 +0200 Subject: [PATCH 60/89] fix(version): Version 0.2.6 Release Notes --- api/app/version_info.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/api/app/version_info.json b/api/app/version_info.json index 7d82eabc..bbaffc17 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -1,4 +1,36 @@ { + "v0.2.6": { + "introduction": { + "codeName": "听剑", + "releaseDate": "2026-3-6", + "upgradePosition": "🐻 多模态交互全面升级,记忆剪枝与工作流迁移双线并进,锋芒初露,兼收并蓄", + "coreUpgrades": [ + "1. 工作流与应用框架
* 工作流导入适配(Dify):支持 Dify 工作流定义无缝迁移
* 字段字数限制与校验规则:可配置字符限制与产品级校验
* 应用复制(Agent、工作流、集群):一键复制完整应用配置
* 对话变量(调试+分享):支持有状态多轮交互
* Chat 接口流式输出 message_id:流式响应包含消息追踪标识", + "2. 多模态与交互 💬
* 音频输入与输出:应用支持音频模态
* 文件类型输入支持:扩展支持语音、文件、视频上传", + "3. 模型与智能 🧠
* 模型视觉与 Omni 区分:精确区分视觉与 Omni 模型能力
* 教育记忆与陪伴玩具场景预设:垂直领域本体配置开箱即用
* 本体配置默认标识:支持基线配置标记
* 记忆配置默认标识:自动应用默认记忆设置", + "4. 记忆智能 🔬
* 记忆剪枝模块:智能裁剪冗余低价值记忆
* RAG 快速检索集成记忆:深度思考与正常回复双模式检索", + "5. 稳健性与缺陷修复 🔧
* 模型管理:修复自定义模型 API Key 批量配置错误
* 知识库管理:修复非源文档下载原始内容接口错误,更新分享停用提示文案
* 用户记忆:优化档案提取准确性(姓名、职业、兴趣分布)
* 长期记忆:修复情景记忆卡片重复和用户归属错误
* 工作空间首页:修复知识库数量、应用数量、总记忆容量、API 调用次数、知识库类型分布等数据不一致问题
* 基础设施:修正 Celery 环境变量配置,修复数据库连接池 idle-in-transaction 泄漏", + "
", + "v0.2.6 标志着 MemoryBear 在多模态交互、跨平台工作流迁移和智能记忆管理方面的重要突破。下一版本将聚焦 A2A 协议支持实现多智能体协作、多模态记忆能力扩展至语音与视觉领域,以及应用导入导出功能支持跨环境便携部署。", + "MemoryBear,让记忆有熊力 🐻✨" + ] + }, + "introduction_en": { + "codeName": "TingJian", + "releaseDate": "2026-3-6", + "upgradePosition": "🐻 Full multimodal interaction upgrade with memory pruning and workflow migration — sharpened edge, broader reach", + "coreUpgrades": [ + "1. Workflow & Application Framework
* Workflow Import Adaptation (Dify): Seamless Dify workflow migration
* Field Character Limits & Validation: Configurable limits with product-defined rules
* Application Cloning (Agent, Workflow, Cluster): One-click full config duplication
* Conversation Variables (Debug + Share): Stateful multi-turn interactions
* Streaming message_id in Chat API: Message tracking in streaming responses", + "2. Multimodal & Interaction 💬
* Audio Input & Output: Audio modality support for applications
* File Type Input Support: Voice, file, and video upload support", + "3. Model & Intelligence 🧠
* Model Vision & Omni Differentiation: Precise capability routing
* Education Memory & Companion Toy Presets: Domain-specific ontology configs
* Ontology Default Identifier: Baseline configuration flagging
* Memory Configuration Default Identifier: Auto-apply default settings", + "4. Memory Intelligence 🔬
* Memory Pruning Module: Intelligent trimming of redundant memories
* RAG Quick Retrieval with Memory: Deep think and normal reply dual-mode retrieval", + "5. Robustness & Bug Fixes 🔧
* Model Management: Fixed custom model API key batch configuration error
* Knowledge Base: Fixed download original content API error for non-source documents, updated share disable prompt text
* User Memory: Improved profile extraction accuracy (name, occupation, interests)
* Long-Term Memory: Fixed duplicate episodic memory cards and wrong user attribution
* Dashboard: Fixed data inconsistencies in knowledge count, app count, memory capacity, API calls, and knowledge type distribution
* Infrastructure: Corrected Celery environment variables, fixed database connection pool idle-in-transaction leak", + "
", + "v0.2.6 marks a significant milestone for MemoryBear in multimodal interaction, cross-platform workflow migration, and intelligent memory management. The next release will focus on A2A protocol support for multi-agent collaboration, multimodal memory extending extraction to voice and visual domains, and application import/export for portable cross-environment deployment.", + "MemoryBear, Memory with Bear Power 🐻✨" + ] + } + }, "v0.2.5": { "introduction": { "codeName": "行云", From 1029f94669a3e306e72c6c4c98eb8e0d1b2129bc Mon Sep 17 00:00:00 2001 From: zhaoying Date: Sat, 7 Mar 2026 10:33:32 +0800 Subject: [PATCH 61/89] fix(web): ontology class default tag bugfix --- web/src/views/Ontology/pages/Detail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/views/Ontology/pages/Detail.tsx b/web/src/views/Ontology/pages/Detail.tsx index 8758c2d7..25609083 100644 --- a/web/src/views/Ontology/pages/Detail.tsx +++ b/web/src/views/Ontology/pages/Detail.tsx @@ -102,10 +102,10 @@ const Detail: FC = () => { {data.scene_name} - {t('common.default')} + {data.is_system_default ? {t('common.default')} : undefined} } subTitle={
{data.scene_description}
} - extra={!data.is_system_default ? undefined : ( + extra={data.is_system_default ? undefined : ( )} From 2612abc9d07b6c152c12d9a3ec965af7cd80cd7a Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 7 Mar 2026 13:56:15 +0800 Subject: [PATCH 62/89] [add] Create a Celery task for checking the existence of the "implicit_emotions" data --- api/app/celery_app.py | 1 + .../memory_dashboard_controller.py | 11 + api/app/services/workspace_service.py | 237 +++++++++--------- api/app/tasks.py | 126 ++++++++++ api/docker-compose.yml | 14 ++ 5 files changed, 272 insertions(+), 117 deletions(-) diff --git a/api/app/celery_app.py b/api/app/celery_app.py index 0319e079..7ace1f9b 100644 --- a/api/app/celery_app.py +++ b/api/app/celery_app.py @@ -113,6 +113,7 @@ celery_app.conf.update( 'app.tasks.run_forgetting_cycle_task': {'queue': 'periodic_tasks'}, 'app.tasks.write_all_workspaces_memory_task': {'queue': 'periodic_tasks'}, 'app.tasks.update_implicit_emotions_storage': {'queue': 'periodic_tasks'}, + 'app.tasks.init_implicit_emotions_for_users': {'queue': 'ondemand_tasks'}, }, ) diff --git a/api/app/controllers/memory_dashboard_controller.py b/api/app/controllers/memory_dashboard_controller.py index 1b5b45fb..1c82b636 100644 --- a/api/app/controllers/memory_dashboard_controller.py +++ b/api/app/controllers/memory_dashboard_controller.py @@ -149,6 +149,17 @@ async def get_workspace_end_users( return {uid: {"total": 0} for uid in end_user_ids} + # 触发按需初始化:为 implicit_emotions_storage 中没有记录的用户异步生成数据 + try: + from app.celery_app import celery_app as _celery_app + _celery_app.send_task( + "app.tasks.init_implicit_emotions_for_users", + kwargs={"end_user_ids": end_user_ids}, + ) + api_logger.info(f"已触发隐性记忆按需初始化任务,候选用户数: {len(end_user_ids)}") + except Exception as e: + api_logger.warning(f"触发隐性记忆按需初始化任务失败(不影响主流程): {e}") + # 并发执行配置查询和记忆数量查询 memory_configs_map, memory_nums_map = await asyncio.gather( get_memory_configs(), diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 74880410..7861ef62 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -130,6 +130,7 @@ def _create_workspace_only( business_logger.error(f"创建工作空间失败: {workspace.name} - {str(e)}") raise + def create_workspace( db: Session, workspace: WorkspaceCreate, user: User, language: str = "zh" ) -> Workspace: @@ -966,6 +967,125 @@ def update_workspace_models_configs( raise BusinessException(f"更新模型配置失败: {str(e)}", BizCode.INTERNAL_ERROR) +def _fill_workspace_configs_model_defaults( + db: Session, + workspace: Workspace +) -> None: + """Fill empty model fields for all memory configs in a workspace. + + Updates llm_id, embedding_id, rerank_id, reflection_model_id, and emotion_model_id + if they are None, using the corresponding workspace default models. + + Args: + db: Database session + workspace: The workspace containing default model settings + """ + from app.models.memory_config_model import MemoryConfig + + # Get all configs for this workspace + configs = db.query(MemoryConfig).filter( + MemoryConfig.workspace_id == workspace.id + ).all() + + if not configs: + return + + # Map of memory_config field -> workspace field + model_field_mappings = [ + ("llm_id", "llm"), + ("embedding_id", "embedding"), + ("rerank_id", "rerank"), + ("reflection_model_id", "llm"), # reflection uses LLM + ("emotion_model_id", "llm"), # emotion uses LLM + ] + + configs_updated = 0 + + for memory_config in configs: + updated_fields = [] + + for config_field, workspace_field in model_field_mappings: + config_value = getattr(memory_config, config_field, None) + workspace_value = getattr(workspace, workspace_field, None) + + if not config_value and workspace_value: + setattr(memory_config, config_field, workspace_value) + updated_fields.append(config_field) + + if updated_fields: + configs_updated += 1 + business_logger.debug( + f"Updated memory config {memory_config.config_id} fields: {updated_fields}" + ) + + if configs_updated > 0: + try: + db.commit() + business_logger.info( + f"Updated {configs_updated} memory configs in workspace {workspace.id} with default models" + ) + except Exception as e: + db.rollback() + business_logger.error( + f"Failed to update memory configs in workspace {workspace.id}: {str(e)}" + ) + + +def _create_default_memory_config( + db: Session, + workspace_id: uuid.UUID, + workspace_name: str, + llm_id: Optional[uuid.UUID] = None, + embedding_id: Optional[uuid.UUID] = None, + rerank_id: Optional[uuid.UUID] = None, + scene_id: Optional[uuid.UUID] = None, + pruning_scene_name: Optional[str] = None, +) -> None: + """Create a default memory config for a newly created workspace. + + Args: + db: Database session + workspace_id: The workspace ID + workspace_name: The workspace name (used for config naming) + llm_id: Optional LLM model ID + embedding_id: Optional embedding model ID + rerank_id: Optional rerank model ID + scene_id: Optional ontology scene ID (默认关联教育场景) + pruning_scene_name: Optional pruning scene name,取自 ontology_scene.scene_name + """ + from app.models.memory_config_model import MemoryConfig + + config_id = uuid.uuid4() + + default_config = MemoryConfig( + config_id=config_id, + config_name=f"{workspace_name} 默认配置", + config_desc="工作空间创建时自动生成的默认记忆配置", + workspace_id=workspace_id, + llm_id=str(llm_id) if llm_id else None, + embedding_id=str(embedding_id) if embedding_id else None, + rerank_id=str(rerank_id) if rerank_id else None, + scene_id=scene_id, # 关联本体场景ID(默认为"在线教育"场景) + pruning_scene=pruning_scene_name, # 语义剪枝场景直接使用 scene_name + state=True, # Active by default + is_default=True, # Mark as workspace default + ) + + db.add(default_config) + db.flush() # 使用 flush 而不是 commit,让调用者统一提交 + + business_logger.info( + "Created default memory config for workspace", + extra={ + "workspace_id": str(workspace_id), + "config_id": str(config_id), + "config_name": default_config.config_name, + "scene_id": str(scene_id) if scene_id else None, + } + ) + +# ==================== 检查配置相关服务 ==================== + def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: """Ensure a workspace has a default memory config, creating one if missing. @@ -1045,70 +1165,6 @@ def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: _fill_workspace_configs_model_defaults(db, workspace) -def _fill_workspace_configs_model_defaults( - db: Session, - workspace: Workspace -) -> None: - """Fill empty model fields for all memory configs in a workspace. - - Updates llm_id, embedding_id, rerank_id, reflection_model_id, and emotion_model_id - if they are None, using the corresponding workspace default models. - - Args: - db: Database session - workspace: The workspace containing default model settings - """ - from app.models.memory_config_model import MemoryConfig - - # Get all configs for this workspace - configs = db.query(MemoryConfig).filter( - MemoryConfig.workspace_id == workspace.id - ).all() - - if not configs: - return - - # Map of memory_config field -> workspace field - model_field_mappings = [ - ("llm_id", "llm"), - ("embedding_id", "embedding"), - ("rerank_id", "rerank"), - ("reflection_model_id", "llm"), # reflection uses LLM - ("emotion_model_id", "llm"), # emotion uses LLM - ] - - configs_updated = 0 - - for memory_config in configs: - updated_fields = [] - - for config_field, workspace_field in model_field_mappings: - config_value = getattr(memory_config, config_field, None) - workspace_value = getattr(workspace, workspace_field, None) - - if not config_value and workspace_value: - setattr(memory_config, config_field, workspace_value) - updated_fields.append(config_field) - - if updated_fields: - configs_updated += 1 - business_logger.debug( - f"Updated memory config {memory_config.config_id} fields: {updated_fields}" - ) - - if configs_updated > 0: - try: - db.commit() - business_logger.info( - f"Updated {configs_updated} memory configs in workspace {workspace.id} with default models" - ) - except Exception as e: - db.rollback() - business_logger.error( - f"Failed to update memory configs in workspace {workspace.id}: {str(e)}" - ) - - def _ensure_default_ontology_scenes(db: Session, workspace: Workspace) -> None: """Ensure a workspace has default ontology scenes, creating them if missing. @@ -1154,56 +1210,3 @@ def _ensure_default_ontology_scenes(db: Session, workspace: Workspace) -> None: f"为工作空间 {workspace.id} 补建默认本体场景异常: {str(e)}" ) - -def _create_default_memory_config( - db: Session, - workspace_id: uuid.UUID, - workspace_name: str, - llm_id: Optional[uuid.UUID] = None, - embedding_id: Optional[uuid.UUID] = None, - rerank_id: Optional[uuid.UUID] = None, - scene_id: Optional[uuid.UUID] = None, - pruning_scene_name: Optional[str] = None, -) -> None: - """Create a default memory config for a newly created workspace. - - Args: - db: Database session - workspace_id: The workspace ID - workspace_name: The workspace name (used for config naming) - llm_id: Optional LLM model ID - embedding_id: Optional embedding model ID - rerank_id: Optional rerank model ID - scene_id: Optional ontology scene ID (默认关联教育场景) - pruning_scene_name: Optional pruning scene name,取自 ontology_scene.scene_name - """ - from app.models.memory_config_model import MemoryConfig - - config_id = uuid.uuid4() - - default_config = MemoryConfig( - config_id=config_id, - config_name=f"{workspace_name} 默认配置", - config_desc="工作空间创建时自动生成的默认记忆配置", - workspace_id=workspace_id, - llm_id=str(llm_id) if llm_id else None, - embedding_id=str(embedding_id) if embedding_id else None, - rerank_id=str(rerank_id) if rerank_id else None, - scene_id=scene_id, # 关联本体场景ID(默认为"在线教育"场景) - pruning_scene=pruning_scene_name, # 语义剪枝场景直接使用 scene_name - state=True, # Active by default - is_default=True, # Mark as workspace default - ) - - db.add(default_config) - db.flush() # 使用 flush 而不是 commit,让调用者统一提交 - - business_logger.info( - "Created default memory config for workspace", - extra={ - "workspace_id": str(workspace_id), - "config_id": str(config_id), - "config_name": default_config.config_name, - "scene_id": str(scene_id) if scene_id else None, - } - ) diff --git a/api/app/tasks.py b/api/app/tasks.py index a6ebbb8e..82904b21 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -2416,3 +2416,129 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: "elapsed_time": elapsed_time, "task_id": self.request.id } + + +# ============================================================================= + +@celery_app.task( + name="app.tasks.init_implicit_emotions_for_users", + bind=True, + ignore_result=True, + max_retries=0, + acks_late=False, + time_limit=3600, + soft_time_limit=3300, +) +def init_implicit_emotions_for_users(self, end_user_ids: List[str]) -> Dict[str, Any]: + """按需初始化:为指定用户列表中尚未生成隐性记忆/情绪数据的用户执行首次生成。 + + 由 /dashboard/end_users 接口触发,仅处理 implicit_emotions_storage 表中不存在记录的用户。 + + Args: + end_user_ids: 需要检查并初始化的用户ID列表 + + Returns: + 包含任务执行结果的字典 + """ + start_time = time.time() + + async def _run() -> Dict[str, Any]: + from app.core.logging_config import get_logger + from app.repositories.implicit_emotions_storage_repository import ImplicitEmotionsStorageRepository + from app.services.implicit_memory_service import ImplicitMemoryService + from app.services.emotion_analytics_service import EmotionAnalyticsService + + logger = get_logger(__name__) + logger.info(f"开始按需初始化隐性记忆/情绪数据,候选用户数: {len(end_user_ids)}") + + initialized = 0 + failed = 0 + skipped = 0 + + with get_db_context() as db: + repo = ImplicitEmotionsStorageRepository(db) + + for end_user_id in end_user_ids: + # 幂等检查:已有记录则跳过 + existing = repo.get_by_end_user_id(end_user_id) + if existing is not None: + skipped += 1 + continue + + logger.info(f"用户 {end_user_id} 无隐性记忆数据,开始初始化") + implicit_ok = False + emotion_ok = False + + try: + try: + implicit_service = ImplicitMemoryService(db=db, end_user_id=end_user_id) + profile_data = await implicit_service.generate_complete_profile(user_id=end_user_id) + await implicit_service.save_profile_cache( + end_user_id=end_user_id, + profile_data=profile_data, + db=db + ) + implicit_ok = True + except Exception as e: + logger.error(f"用户 {end_user_id} 隐性记忆初始化失败: {e}") + + try: + emotion_service = EmotionAnalyticsService() + suggestions_data = await emotion_service.generate_emotion_suggestions( + end_user_id=end_user_id, + db=db, + language="zh" + ) + await emotion_service.save_suggestions_cache( + end_user_id=end_user_id, + suggestions_data=suggestions_data, + db=db + ) + emotion_ok = True + except Exception as e: + logger.error(f"用户 {end_user_id} 情绪建议初始化失败: {e}") + + if implicit_ok or emotion_ok: + initialized += 1 + else: + failed += 1 + + except Exception as e: + failed += 1 + logger.error(f"用户 {end_user_id} 初始化异常: {e}") + + logger.info(f"按需初始化完成: 初始化={initialized}, 跳过={skipped}, 失败={failed}") + return { + "status": "SUCCESS", + "initialized": initialized, + "skipped": skipped, + "failed": failed, + } + + try: + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) + result["elapsed_time"] = time.time() - start_time + result["task_id"] = self.request.id + return result + except Exception as e: + return { + "status": "FAILURE", + "error": str(e), + "elapsed_time": time.time() - start_time, + "task_id": self.request.id, + } diff --git a/api/docker-compose.yml b/api/docker-compose.yml index 69763de2..1fcfc977 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -63,6 +63,20 @@ services: networks: - celery + # On-demand worker - API-triggered tasks (e.g. implicit emotions init) + worker-ondemand: + image: redbear-mem-open:latest + container_name: worker-ondemand + env_file: + - .env + volumes: + - ./files:/files + - /etc/localtime:/etc/localtime:ro + command: celery -A app.celery_worker.celery_app worker -E --loglevel=info --pool=prefork --concurrency=4 --queues=ondemand_tasks --max-tasks-per-child=50 -n ondemand_worker@%h + restart: unless-stopped + networks: + - celery + # Celery Beat - scheduler beat: image: redbear-mem-open:latest From d19fec2155122884efeece7950af8347051491d4 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Sat, 7 Mar 2026 14:40:43 +0800 Subject: [PATCH 63/89] fix(web): add notes node; jinja2 editor bugfix --- .../Workflow/components/Editor/plugin/InitialValuePlugin.tsx | 3 ++- web/src/views/Workflow/constant.ts | 4 ++++ web/src/views/Workflow/hooks/useWorkflowGraph.ts | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx index 4021a9ee..481c61c2 100644 --- a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx @@ -25,6 +25,7 @@ const InitialValuePlugin: React.FC = ({ value, options const textContent = root.getTextContent(); if (textContent !== prevValueRef.current) { isUserInputRef.current = true; + prevValueRef.current = textContent; } }); }); @@ -33,7 +34,7 @@ const InitialValuePlugin: React.FC = ({ value, options }, [editor]); useEffect(() => { - if ((value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) && !isUserInputRef.current) { + if (value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) { queueMicrotask(() => { editor.update(() => { const root = $getRoot(); diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index e7d2177a..0b2ec5ce 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -529,6 +529,10 @@ export const unknownNode = { type: 'unknown', icon: unknownIcon } +export const noteNode = { + type: 'notes', + icon: unknownIcon +} export const nodeWidth = 240; /** diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 2d8d1939..b262e47e 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -12,7 +12,7 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from ' import { register } from '@antv/x6-react-shape'; import type { PortMetadata } from '@antv/x6/lib/model/port'; -import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode } from '../constant'; +import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, noteNode } from '../constant'; import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types'; import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application' @@ -128,7 +128,7 @@ export const useWorkflowGraph = ({ if (nodes.length) { const nodeList = nodes.map(node => { const { id, type, name, position, config = {} } = node - let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode] }] + let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode, noteNode] }] .flatMap(category => category.nodes) .find(n => n.type === type) nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties From f01185a7fce9b4e808a368f3fd7c60c61534f9e1 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Sat, 7 Mar 2026 14:41:12 +0800 Subject: [PATCH 64/89] fix(workflow): fix compatibility issues when importing workflows from dify --- api/app/core/workflow/adapters/dify/converter.py | 8 ++++++-- api/app/core/workflow/adapters/dify/dify_adapter.py | 11 ++++++----- api/app/core/workflow/engine/graph_builder.py | 2 ++ api/app/core/workflow/nodes/enums.py | 1 + api/app/core/workflow/nodes/knowledge/node.py | 2 ++ api/app/core/workflow/validator.py | 2 +- 6 files changed, 18 insertions(+), 8 deletions(-) diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index 3c9348c7..32d420b5 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -185,6 +185,9 @@ class DifyConverter(BaseConverter): "not empty": ComparisonOperator.NOT_EMPTY, "start with": ComparisonOperator.START_WITH, "end with": ComparisonOperator.END_WITH, + "not contains": ComparisonOperator.NOT_CONTAINS, + "exists": ComparisonOperator.NOT_EMPTY, + "not exists": ComparisonOperator.EMPTY } return operator_map.get(operator, operator) @@ -364,7 +367,7 @@ class DifyConverter(BaseConverter): node_data = node["data"] cases = [] for case in node_data["cases"]: - case_id = case["id"] + case_id = case.get("id") or case.get("case_id") logical_operator = case["logical_operator"] conditions = [] for condition in case["conditions"]: @@ -540,7 +543,8 @@ class DifyConverter(BaseConverter): ] = self.trans_variable_format(content["value"]) else: if node_data["body"]["data"]: - body_content = node_data["body"]["data"][0]["value"] + body_content = (node_data["body"]["data"][0].get("value") or + self._process_list_variable_litearl(node_data["body"]["data"][0].get("file"))) else: body_content = "" diff --git a/api/app/core/workflow/adapters/dify/dify_adapter.py b/api/app/core/workflow/adapters/dify/dify_adapter.py index 5b506d16..895b3d37 100644 --- a/api/app/core/workflow/adapters/dify/dify_adapter.py +++ b/api/app/core/workflow/adapters/dify/dify_adapter.py @@ -44,7 +44,8 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): "parameter-extractor": NodeType.PARAMETER_EXTRACTOR, "question-classifier": NodeType.QUESTION_CLASSIFIER, "variable-aggregator": NodeType.VAR_AGGREGATOR, - "tool": NodeType.TOOL + "tool": NodeType.TOOL, + "": NodeType.NOTES } def __init__(self, config: dict[str, Any]): @@ -165,7 +166,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): return NodeDefinition( id=node["id"], type=self.map_node_type(node_data["type"]), - name=node_data.get("title"), + name=node_data.get("title") or "notes", cycle=node.get("parentId"), description=None, config=self._convert_node_config(node), @@ -209,16 +210,15 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): source = edge["source"] target = edge["target"] - edge_id = edge["id"] label = None if source in self.branch_node_cache: - case_id = "-".join(edge_id.split("-")[1:-2]) + case_id = edge["sourceHandle"] if case_id == "false": label = f'CASE{len(self.branch_node_cache[source])+1}' else: label = f'CASE{self.branch_node_cache[source].index(case_id) + 1}' if source in self.error_branch_node_cache: - case_id = "-".join(edge_id.split("-")[1:-2]) + case_id = edge["sourceHandle"] if case_id == "source": label = "SUCCESS" else: @@ -243,6 +243,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): name=variable["name"], default=variable["value"], type=self.variable_type_map(variable["value_type"]), + description=variable.get("description") ) except Exception as e: self.errors.append(ExceptionDefineition( diff --git a/api/app/core/workflow/engine/graph_builder.py b/api/app/core/workflow/engine/graph_builder.py index 5e4569ad..90668ad9 100644 --- a/api/app/core/workflow/engine/graph_builder.py +++ b/api/app/core/workflow/engine/graph_builder.py @@ -292,6 +292,8 @@ class GraphBuilder: """ for node in self.nodes: node_type = node.get("type") + if node_type == NodeType.NOTES: + continue node_id = node.get("id") cycle_node = node.get("cycle") if cycle_node: diff --git a/api/app/core/workflow/nodes/enums.py b/api/app/core/workflow/nodes/enums.py index ae9b81ff..43ab593b 100644 --- a/api/app/core/workflow/nodes/enums.py +++ b/api/app/core/workflow/nodes/enums.py @@ -25,6 +25,7 @@ class NodeType(StrEnum): MEMORY_WRITE = "memory-write" UNKNOWN = "unknown" + NOTES = "notes" BRANCH_NODES = [NodeType.IF_ELSE, NodeType.HTTP_REQUEST, NodeType.QUESTION_CLASSIFIER] diff --git a/api/app/core/workflow/nodes/knowledge/node.py b/api/app/core/workflow/nodes/knowledge/node.py index 17f55319..696298eb 100644 --- a/api/app/core/workflow/nodes/knowledge/node.py +++ b/api/app/core/workflow/nodes/knowledge/node.py @@ -180,6 +180,8 @@ class KnowledgeRetrievalNode(BaseNode): RuntimeError: If no valid knowledge base is found or access is denied. """ self.typed_config = KnowledgeRetrievalNodeConfig(**self.config) + if not self.typed_config.knowledge_bases: + return [] query = self._render_template(self.typed_config.query, variable_pool) with get_db_read() as db: knowledge_bases = self.typed_config.knowledge_bases diff --git a/api/app/core/workflow/validator.py b/api/app/core/workflow/validator.py index 47256b75..3b6e9036 100644 --- a/api/app/core/workflow/validator.py +++ b/api/app/core/workflow/validator.py @@ -138,7 +138,7 @@ class WorkflowValidator: errors.append("工作流必须至少有一个 end 节点") # 3. 验证节点 ID 唯一性 - node_ids = [n.get("id") for n in nodes] + node_ids = [n.get("id") for n in nodes if n.get("type") != NodeType.NOTES] if len(node_ids) != len(set(node_ids)): duplicates = [nid for nid in node_ids if node_ids.count(nid) > 1] errors.append(f"节点 ID 必须唯一,重复的 ID: {set(duplicates)}") From 7373f681725d34f6a35bab32cfc8979e5af6c2a8 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Sat, 7 Mar 2026 14:52:00 +0800 Subject: [PATCH 65/89] fix(web): jinja2 editor bugfix --- .../components/Editor/plugin/InitialValuePlugin.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx index 481c61c2..b263120a 100644 --- a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx @@ -35,6 +35,12 @@ const InitialValuePlugin: React.FC = ({ value, options useEffect(() => { if (value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) { + // Skip reset if the change was triggered by user input (avoid cursor jump) + if (isUserInputRef.current && enableLineNumbers === prevEnableLineNumbersRef.current) { + prevValueRef.current = value; + isUserInputRef.current = false; + return; + } queueMicrotask(() => { editor.update(() => { const root = $getRoot(); From 819d205166c4ae9595b5bd8a1e40e313e8080833 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Sat, 7 Mar 2026 15:23:56 +0800 Subject: [PATCH 66/89] fix(web): change mousewheel factor --- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index b262e47e..971d591a 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-28 17:59:34 + * @Last Modified time: 2026-03-07 15:23:39 */ import { useRef, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; @@ -715,6 +715,8 @@ export const useWorkflowGraph = ({ panning: isHandMode, mousewheel: { enabled: true, + factor: 0.1, + modifiers: null, }, connecting: { connector: { From c14f067afb5a50c26940b59d9f2d8ab78720042c Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 7 Mar 2026 16:23:59 +0800 Subject: [PATCH 67/89] [add] The "update-implicit-emotions-storage" task uses the timeline to filter the updated data users. --- .../implicit_emotions_storage_repository.py | 51 +++++++++ api/app/tasks.py | 107 ++++++++++-------- 2 files changed, 109 insertions(+), 49 deletions(-) diff --git a/api/app/repositories/implicit_emotions_storage_repository.py b/api/app/repositories/implicit_emotions_storage_repository.py index 97405ab6..d1edf0ec 100644 --- a/api/app/repositories/implicit_emotions_storage_repository.py +++ b/api/app/repositories/implicit_emotions_storage_repository.py @@ -111,6 +111,57 @@ class ImplicitEmotionsStorageRepository: logger.error(f"分批获取用户ID失败: offset={offset}, error={e}") break + def get_users_needing_refresh(self, redis_client, batch_size: int = 100) -> Generator[str, None, None]: + """分批次获取需要刷新隐性记忆/情绪数据的存量用户ID。 + + 筛选逻辑: + - 查询 implicit_emotions_storage 中所有用户的 end_user_id 和 updated_at + - 从 Redis 读取 write_message:last_done:{end_user_id} 的时间戳 + - 若 Redis 中无记录(该用户从未写入过记忆),跳过 + - 若 last_done > updated_at,说明上次刷新后又有新记忆写入,需要刷新 + - 若 last_done <= updated_at,说明已是最新,跳过 + + Args: + redis_client: 同步 redis.StrictRedis 实例(连接 CELERY_BACKEND DB) + batch_size: 每批次加载的数量 + + Yields: + 需要刷新的用户ID字符串 + """ + from datetime import timezone + offset = 0 + while True: + try: + stmt = ( + select(ImplicitEmotionsStorage.end_user_id, ImplicitEmotionsStorage.updated_at) + .order_by(ImplicitEmotionsStorage.end_user_id) + .limit(batch_size) + .offset(offset) + ) + batch = self.db.execute(stmt).all() + if not batch: + break + + for end_user_id, updated_at in batch: + raw = redis_client.get(f"write_message:last_done:{end_user_id}") + if raw is None: + # 该用户从未有过 write_message 成功记录,跳过 + continue + try: + last_done = datetime.fromisoformat(raw) + # 统一去掉时区信息做 naive 比较 + if last_done.tzinfo is not None: + last_done = last_done.astimezone(timezone.utc).replace(tzinfo=None) + if updated_at is None or last_done > updated_at: + yield end_user_id + except Exception as e: + logger.warning(f"解析 last_done 时间戳失败: end_user_id={end_user_id}, raw={raw}, error={e}") + + offset += batch_size + except Exception as e: + logger.error(f"get_users_needing_refresh 分批查询失败: offset={offset}, error={e}") + break + def get_new_user_ids_today(self, batch_size: int = 100) -> Generator[str, None, None]: """分批次获取当天新增的、尚未初始化隐性记忆和情绪建议数据的用户ID diff --git a/api/app/tasks.py b/api/app/tasks.py index 82904b21..d4afcc68 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -1090,6 +1090,25 @@ def write_message_task(self, end_user_id: str, message: list[dict], config_id: s logger.info( f"[CELERY WRITE] Task completed successfully - elapsed_time={elapsed_time:.2f}s, task_id={self.request.id}") + # 记录该用户最后一次 write_message 成功的时间,供 init_implicit_emotions_for_users 做时间轴筛选 + try: + import redis as _redis + from urllib.parse import quote as _quote + _r = _redis.StrictRedis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DB_CELERY_BACKEND, + password=settings.REDIS_PASSWORD, + decode_responses=True, + ) + _r.set( + f"write_message:last_done:{end_user_id}", + datetime.utcnow().isoformat(), + ex=86400 * 30, # 30天过期 + ) + except Exception as _e: + logger.warning(f"[CELERY WRITE] 写入 last_done 时间戳失败(不影响主流程): {_e}") + return { "status": "SUCCESS", "result": result, @@ -2167,18 +2186,27 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: with get_db_context() as db: try: - # 获取所有已存储数据的用户ID(分批次处理) repo = ImplicitEmotionsStorageRepository(db) - + # 先统计总数用于日志 from sqlalchemy import func total_users = db.execute( select(func.count()).select_from(ImplicitEmotionsStorage) ).scalar() or 0 - logger.info(f"找到 {total_users} 个需要更新的用户") + logger.info(f"表中存量用户总数: {total_users},开始时间轴筛选") - # 遍历每个用户并更新数据(分批次,避免一次性加载所有ID) - for end_user_id in repo.get_all_user_ids(batch_size=100): + # 构建 Redis 同步客户端,用于时间轴筛选 + import redis as _redis + _redis_client = _redis.StrictRedis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DB_CELERY_BACKEND, + password=settings.REDIS_PASSWORD, + decode_responses=True, + ) + + # 只处理 last_done > updated_at 的用户(有新记忆写入的用户) + for end_user_id in repo.get_users_needing_refresh(_redis_client, batch_size=100): logger.info(f"开始处理用户: {end_user_id}") user_start_time = time.time() @@ -2264,10 +2292,10 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: user_results.append(error_info) logger.error(f"处理用户 {end_user_id} 时出错: {str(e)}") - # ---- 处理增量用户(当天新增、尚未初始化的用户)---- + # ---- 当天新增用户兜底初始化 ---- new_users_initialized = 0 new_users_failed = 0 - logger.info("开始处理当天新增的增量用户初始化") + logger.info("开始处理当天新增用户的兜底初始化") for end_user_id in repo.get_new_user_ids_today(batch_size=100): logger.info(f"开始初始化新用户: {end_user_id}") @@ -2281,35 +2309,27 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: implicit_service = ImplicitMemoryService(db=db, end_user_id=end_user_id) profile_data = await implicit_service.generate_complete_profile(user_id=end_user_id) await implicit_service.save_profile_cache( - end_user_id=end_user_id, - profile_data=profile_data, - db=db + end_user_id=end_user_id, profile_data=profile_data, db=db ) implicit_success = True logger.info(f"成功初始化新用户 {end_user_id} 的隐性记忆画像") except Exception as e: - error_msg = f"隐性记忆初始化失败: {str(e)}" - errors.append(error_msg) - logger.error(f"新用户 {end_user_id} {error_msg}") + errors.append(f"隐性记忆初始化失败: {str(e)}") + logger.error(f"新用户 {end_user_id} 隐性记忆初始化失败: {e}") try: emotion_service = EmotionAnalyticsService() suggestions_data = await emotion_service.generate_emotion_suggestions( - end_user_id=end_user_id, - db=db, - language="zh" + end_user_id=end_user_id, db=db, language="zh" ) await emotion_service.save_suggestions_cache( - end_user_id=end_user_id, - suggestions_data=suggestions_data, - db=db + end_user_id=end_user_id, suggestions_data=suggestions_data, db=db ) emotion_success = True logger.info(f"成功初始化新用户 {end_user_id} 的情绪建议") except Exception as e: - error_msg = f"情绪建议初始化失败: {str(e)}" - errors.append(error_msg) - logger.error(f"新用户 {end_user_id} {error_msg}") + errors.append(f"情绪建议初始化失败: {str(e)}") + logger.error(f"新用户 {end_user_id} 情绪建议初始化失败: {e}") if implicit_success or emotion_success: new_users_initialized += 1 @@ -2319,7 +2339,7 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: user_elapsed = time.time() - user_start_time user_results.append({ "end_user_id": end_user_id, - "type": "init", + "type": "new_user_init", "implicit_success": implicit_success, "emotion_success": emotion_success, "errors": errors, @@ -2331,7 +2351,7 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: user_elapsed = time.time() - user_start_time user_results.append({ "end_user_id": end_user_id, - "type": "init", + "type": "new_user_init", "implicit_success": False, "emotion_success": False, "errors": [str(e)], @@ -2339,27 +2359,24 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: }) logger.error(f"初始化新用户 {end_user_id} 时出错: {str(e)}") - logger.info( - f"增量用户初始化完成: 成功={new_users_initialized}, 失败={new_users_failed}" - ) - # ---- 增量用户处理结束 ---- + logger.info(f"当天新增用户兜底初始化完成: 成功={new_users_initialized}, 失败={new_users_failed}") + # ---- 新增用户兜底初始化结束 ---- - # 记录总体统计信息 logger.info( f"隐性记忆和情绪数据更新定时任务完成: " f"存量用户总数={total_users}, " f"隐性记忆成功={successful_implicit}, " f"情绪建议成功={successful_emotion}, " f"存量失败={failed}, " - f"增量初始化成功={new_users_initialized}, " - f"增量初始化失败={new_users_failed}" + f"新增用户初始化成功={new_users_initialized}, " + f"新增用户初始化失败={new_users_failed}" ) return { "status": "SUCCESS", "message": ( f"存量用户 {total_users} 个,隐性记忆 {successful_implicit} 个成功,情绪建议 {successful_emotion} 个成功;" - f"增量新用户初始化 {new_users_initialized} 个成功,{new_users_failed} 个失败" + f"当天新增用户初始化 {new_users_initialized} 个成功,{new_users_failed} 个失败" ), "total_users": total_users, "successful_implicit": successful_implicit, @@ -2367,7 +2384,7 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: "failed": failed, "new_users_initialized": new_users_initialized, "new_users_failed": new_users_failed, - "user_results": user_results[:50] # 只保留前50个用户的详细结果 + "user_results": user_results[:50] } except Exception as e: @@ -2430,12 +2447,13 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: soft_time_limit=3300, ) def init_implicit_emotions_for_users(self, end_user_ids: List[str]) -> Dict[str, Any]: - """按需初始化:为指定用户列表中尚未生成隐性记忆/情绪数据的用户执行首次生成。 + """事件触发任务:对指定用户列表做存在性检查,无记录则执行首次初始化。 - 由 /dashboard/end_users 接口触发,仅处理 implicit_emotions_storage 表中不存在记录的用户。 + 由 /dashboard/end_users 接口触发,已有数据的用户直接跳过。 + 存量用户的数据刷新由定时任务 update_implicit_emotions_storage 负责。 Args: - end_user_ids: 需要检查并初始化的用户ID列表 + end_user_ids: 需要检查的用户ID列表 Returns: 包含任务执行结果的字典 @@ -2459,24 +2477,20 @@ def init_implicit_emotions_for_users(self, end_user_ids: List[str]) -> Dict[str, repo = ImplicitEmotionsStorageRepository(db) for end_user_id in end_user_ids: - # 幂等检查:已有记录则跳过 existing = repo.get_by_end_user_id(end_user_id) if existing is not None: skipped += 1 continue - logger.info(f"用户 {end_user_id} 无隐性记忆数据,开始初始化") + logger.info(f"用户 {end_user_id} 无记录,开始初始化") implicit_ok = False emotion_ok = False - try: try: implicit_service = ImplicitMemoryService(db=db, end_user_id=end_user_id) profile_data = await implicit_service.generate_complete_profile(user_id=end_user_id) await implicit_service.save_profile_cache( - end_user_id=end_user_id, - profile_data=profile_data, - db=db + end_user_id=end_user_id, profile_data=profile_data, db=db ) implicit_ok = True except Exception as e: @@ -2485,14 +2499,10 @@ def init_implicit_emotions_for_users(self, end_user_ids: List[str]) -> Dict[str, try: emotion_service = EmotionAnalyticsService() suggestions_data = await emotion_service.generate_emotion_suggestions( - end_user_id=end_user_id, - db=db, - language="zh" + end_user_id=end_user_id, db=db, language="zh" ) await emotion_service.save_suggestions_cache( - end_user_id=end_user_id, - suggestions_data=suggestions_data, - db=db + end_user_id=end_user_id, suggestions_data=suggestions_data, db=db ) emotion_ok = True except Exception as e: @@ -2502,7 +2512,6 @@ def init_implicit_emotions_for_users(self, end_user_ids: List[str]) -> Dict[str, initialized += 1 else: failed += 1 - except Exception as e: failed += 1 logger.error(f"用户 {end_user_id} 初始化异常: {e}") From cef14cda9e3819cd485458f4148a37f7582cb76b Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 7 Mar 2026 16:36:24 +0800 Subject: [PATCH 68/89] [add] Standardize time zones; Reuse a single Redis client; Use "mget" for batch writing requests --- .../implicit_emotions_storage_repository.py | 29 +++++++--- api/app/tasks.py | 58 +++++++++++-------- 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/api/app/repositories/implicit_emotions_storage_repository.py b/api/app/repositories/implicit_emotions_storage_repository.py index d1edf0ec..dfc7061b 100644 --- a/api/app/repositories/implicit_emotions_storage_repository.py +++ b/api/app/repositories/implicit_emotions_storage_repository.py @@ -142,17 +142,32 @@ class ImplicitEmotionsStorageRepository: if not batch: break - for end_user_id, updated_at in batch: - raw = redis_client.get(f"write_message:last_done:{end_user_id}") + # 批量获取当前批次所有用户的 last_done 时间戳(一次网络往返) + keys = [f"write_message:last_done:{end_user_id}" for end_user_id, _ in batch] + raw_values = redis_client.mget(keys) + + for (end_user_id, updated_at), raw in zip(batch, raw_values): if raw is None: - # 该用户从未有过 write_message 成功记录,跳过 continue try: + CST = timezone(timedelta(hours=8)) last_done = datetime.fromisoformat(raw) - # 统一去掉时区信息做 naive 比较 - if last_done.tzinfo is not None: - last_done = last_done.astimezone(timezone.utc).replace(tzinfo=None) - if updated_at is None or last_done > updated_at: + # 统一转为 CST naive 时间做比较 + if last_done.tzinfo is None: + last_done = last_done.replace(tzinfo=timezone.utc).astimezone(CST).replace(tzinfo=None) + else: + last_done = last_done.astimezone(CST).replace(tzinfo=None) + + if updated_at is None: + yield end_user_id + continue + # updated_at 同样转为 CST naive + if updated_at.tzinfo is None: + updated_at_cst = updated_at.replace(tzinfo=timezone.utc).astimezone(CST).replace(tzinfo=None) + else: + updated_at_cst = updated_at.astimezone(CST).replace(tzinfo=None) + + if last_done > updated_at_cst: yield end_user_id except Exception as e: logger.warning(f"解析 last_done 时间戳失败: end_user_id={end_user_id}, raw={raw}, error={e}") diff --git a/api/app/tasks.py b/api/app/tasks.py index d4afcc68..0c0fd01e 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -15,6 +15,29 @@ from uuid import UUID import redis import requests +# 模块级同步 Redis 客户端单例,供 Celery 任务共享使用(避免每次任务新建连接) +# 连接 CELERY_BACKEND DB,与 write_message:last_done 时间戳写入保持一致 +def _build_sync_redis_client(): + try: + return redis.StrictRedis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DB_CELERY_BACKEND, + password=settings.REDIS_PASSWORD, + decode_responses=True, + ) + except Exception: + return None + +_sync_redis_client: redis.StrictRedis = None + +def get_sync_redis_client() -> redis.StrictRedis: + """获取模块级同步 Redis 客户端(懒初始化单例)""" + global _sync_redis_client + if _sync_redis_client is None: + _sync_redis_client = _build_sync_redis_client() + return _sync_redis_client + # Import a unified Celery instance from app.celery_app import celery_app from app.core.config import settings @@ -1090,22 +1113,18 @@ def write_message_task(self, end_user_id: str, message: list[dict], config_id: s logger.info( f"[CELERY WRITE] Task completed successfully - elapsed_time={elapsed_time:.2f}s, task_id={self.request.id}") - # 记录该用户最后一次 write_message 成功的时间,供 init_implicit_emotions_for_users 做时间轴筛选 + # 记录该用户最后一次 write_message 成功的时间,供时间轴筛选使用 try: - import redis as _redis - from urllib.parse import quote as _quote - _r = _redis.StrictRedis( - host=settings.REDIS_HOST, - port=settings.REDIS_PORT, - db=settings.REDIS_DB_CELERY_BACKEND, - password=settings.REDIS_PASSWORD, - decode_responses=True, - ) - _r.set( - f"write_message:last_done:{end_user_id}", - datetime.utcnow().isoformat(), - ex=86400 * 30, # 30天过期 - ) + _r = get_sync_redis_client() + if _r is not None: + from datetime import timezone as _tz, timedelta as _td + _CST = _tz(timedelta(hours=8)) + _now_cst = datetime.now(_CST).replace(tzinfo=None).isoformat() + _r.set( + f"write_message:last_done:{end_user_id}", + _now_cst, + ex=86400 * 30, + ) except Exception as _e: logger.warning(f"[CELERY WRITE] 写入 last_done 时间戳失败(不影响主流程): {_e}") @@ -2196,14 +2215,7 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: logger.info(f"表中存量用户总数: {total_users},开始时间轴筛选") # 构建 Redis 同步客户端,用于时间轴筛选 - import redis as _redis - _redis_client = _redis.StrictRedis( - host=settings.REDIS_HOST, - port=settings.REDIS_PORT, - db=settings.REDIS_DB_CELERY_BACKEND, - password=settings.REDIS_PASSWORD, - decode_responses=True, - ) + _redis_client = get_sync_redis_client() # 只处理 last_done > updated_at 的用户(有新记忆写入的用户) for end_user_id in repo.get_users_needing_refresh(_redis_client, batch_size=100): From 8429279eeaa5796da0a108fc1716887531503b64 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 7 Mar 2026 16:55:06 +0800 Subject: [PATCH 69/89] [add] Verification of the existence of interest distribution --- api/app/celery_app.py | 1 + .../memory_dashboard_controller.py | 8 +- api/app/tasks.py | 107 ++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/api/app/celery_app.py b/api/app/celery_app.py index 7ace1f9b..02b7df77 100644 --- a/api/app/celery_app.py +++ b/api/app/celery_app.py @@ -114,6 +114,7 @@ celery_app.conf.update( 'app.tasks.write_all_workspaces_memory_task': {'queue': 'periodic_tasks'}, 'app.tasks.update_implicit_emotions_storage': {'queue': 'periodic_tasks'}, 'app.tasks.init_implicit_emotions_for_users': {'queue': 'ondemand_tasks'}, + 'app.tasks.init_interest_distribution_for_users': {'queue': 'ondemand_tasks'}, }, ) diff --git a/api/app/controllers/memory_dashboard_controller.py b/api/app/controllers/memory_dashboard_controller.py index 1c82b636..50e8ec8f 100644 --- a/api/app/controllers/memory_dashboard_controller.py +++ b/api/app/controllers/memory_dashboard_controller.py @@ -156,9 +156,13 @@ async def get_workspace_end_users( "app.tasks.init_implicit_emotions_for_users", kwargs={"end_user_ids": end_user_ids}, ) - api_logger.info(f"已触发隐性记忆按需初始化任务,候选用户数: {len(end_user_ids)}") + _celery_app.send_task( + "app.tasks.init_interest_distribution_for_users", + kwargs={"end_user_ids": end_user_ids}, + ) + api_logger.info(f"已触发按需初始化任务,候选用户数: {len(end_user_ids)}") except Exception as e: - api_logger.warning(f"触发隐性记忆按需初始化任务失败(不影响主流程): {e}") + api_logger.warning(f"触发按需初始化任务失败(不影响主流程): {e}") # 并发执行配置查询和记忆数量查询 memory_configs_map, memory_nums_map = await asyncio.gather( diff --git a/api/app/tasks.py b/api/app/tasks.py index 0c0fd01e..2387a9e4 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -2563,3 +2563,110 @@ def init_implicit_emotions_for_users(self, end_user_ids: List[str]) -> Dict[str, "elapsed_time": time.time() - start_time, "task_id": self.request.id, } + + +# ============================================================================= + +@celery_app.task( + name="app.tasks.init_interest_distribution_for_users", + bind=True, + ignore_result=True, + max_retries=0, + acks_late=False, + time_limit=3600, + soft_time_limit=3300, +) +def init_interest_distribution_for_users(self, end_user_ids: List[str]) -> Dict[str, Any]: + """事件触发任务:检查指定用户列表的兴趣分布缓存,无缓存则生成并写入 Redis。 + + 由 /dashboard/end_users 接口触发,已有缓存的用户直接跳过。 + 默认生成中文(zh)兴趣分布数据。 + + Args: + end_user_ids: 需要检查的用户ID列表 + + Returns: + 包含任务执行结果的字典 + """ + start_time = time.time() + + async def _run() -> Dict[str, Any]: + from app.core.logging_config import get_logger + from app.cache.memory.interest_memory import InterestMemoryCache, INTEREST_CACHE_EXPIRE + from app.services.memory_agent_service import MemoryAgentService + + logger = get_logger(__name__) + logger.info(f"开始按需初始化兴趣分布缓存,候选用户数: {len(end_user_ids)}") + + initialized = 0 + failed = 0 + skipped = 0 + language = "zh" + + service = MemoryAgentService() + + with get_db_context() as db: + for end_user_id in end_user_ids: + # 存在性检查:缓存有数据则跳过 + cached = await InterestMemoryCache.get_interest_distribution( + end_user_id=end_user_id, + language=language, + ) + if cached is not None: + skipped += 1 + continue + + logger.info(f"用户 {end_user_id} 无兴趣分布缓存,开始生成") + try: + result = await service.get_interest_distribution_by_user( + end_user_id=end_user_id, + limit=5, + language=language, + ) + await InterestMemoryCache.set_interest_distribution( + end_user_id=end_user_id, + language=language, + data=result, + expire=INTEREST_CACHE_EXPIRE, + ) + initialized += 1 + logger.info(f"用户 {end_user_id} 兴趣分布缓存生成成功") + except Exception as e: + failed += 1 + logger.error(f"用户 {end_user_id} 兴趣分布缓存生成失败: {e}") + + logger.info(f"兴趣分布按需初始化完成: 初始化={initialized}, 跳过={skipped}, 失败={failed}") + return { + "status": "SUCCESS", + "initialized": initialized, + "skipped": skipped, + "failed": failed, + } + + try: + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) + result["elapsed_time"] = time.time() - start_time + result["task_id"] = self.request.id + return result + except Exception as e: + return { + "status": "FAILURE", + "error": str(e), + "elapsed_time": time.time() - start_time, + "task_id": self.request.id, + } From 94a40e49a05f83dcf37468509cca625847976333 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 7 Mar 2026 17:07:38 +0800 Subject: [PATCH 70/89] [add] Throw out explicit error messages; Using the CST time zone --- .../implicit_emotions_storage_repository.py | 15 +++++++++------ api/app/tasks.py | 11 +++++++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/api/app/repositories/implicit_emotions_storage_repository.py b/api/app/repositories/implicit_emotions_storage_repository.py index dfc7061b..bb869934 100644 --- a/api/app/repositories/implicit_emotions_storage_repository.py +++ b/api/app/repositories/implicit_emotions_storage_repository.py @@ -122,12 +122,17 @@ class ImplicitEmotionsStorageRepository: - 若 last_done <= updated_at,说明已是最新,跳过 Args: - redis_client: 同步 redis.StrictRedis 实例(连接 CELERY_BACKEND DB) + redis_client: 同步 redis.StrictRedis 实例(连接 CELERY_BACKEND DB),为 None 时抛出 RuntimeError batch_size: 每批次加载的数量 + Raises: + RuntimeError: redis_client 为 None 时,调用方可捕获并回退到 get_all_user_ids + Yields: 需要刷新的用户ID字符串 """ + if redis_client is None: + raise RuntimeError("get_users_needing_refresh: redis_client 不可用,无法执行时间轴筛选") from datetime import timezone offset = 0 while True: @@ -152,16 +157,14 @@ class ImplicitEmotionsStorageRepository: try: CST = timezone(timedelta(hours=8)) last_done = datetime.fromisoformat(raw) - # 统一转为 CST naive 时间做比较 - if last_done.tzinfo is None: - last_done = last_done.replace(tzinfo=timezone.utc).astimezone(CST).replace(tzinfo=None) - else: + # last_done 写入时已是 CST naive,直接使用,无需转换 + if last_done.tzinfo is not None: last_done = last_done.astimezone(CST).replace(tzinfo=None) if updated_at is None: yield end_user_id continue - # updated_at 同样转为 CST naive + # updated_at 数据库存的是 UTC naive,转为 CST naive 再比较 if updated_at.tzinfo is None: updated_at_cst = updated_at.replace(tzinfo=timezone.utc).astimezone(CST).replace(tzinfo=None) else: diff --git a/api/app/tasks.py b/api/app/tasks.py index 2387a9e4..c5e8a105 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -1117,7 +1117,7 @@ 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 timezone as _tz, timedelta as _td + from datetime import timezone as _tz _CST = _tz(timedelta(hours=8)) _now_cst = datetime.now(_CST).replace(tzinfo=None).isoformat() _r.set( @@ -2218,7 +2218,14 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: _redis_client = get_sync_redis_client() # 只处理 last_done > updated_at 的用户(有新记忆写入的用户) - for end_user_id in repo.get_users_needing_refresh(_redis_client, batch_size=100): + # Redis 不可用时回退到全量处理 + try: + refresh_iter = repo.get_users_needing_refresh(_redis_client, batch_size=100) + except RuntimeError as e: + logger.warning(f"时间轴筛选不可用,回退到全量刷新: {e}") + refresh_iter = repo.get_all_user_ids(batch_size=100) + + for end_user_id in refresh_iter: logger.info(f"开始处理用户: {end_user_id}") user_start_time = time.time() From 8f789d47a2a932866723cf307c5b3a2e58f8c108 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 9 Mar 2026 02:38:35 +0800 Subject: [PATCH 71/89] feat(workflow): add support for notes nodes --- .../core/workflow/adapters/dify/converter.py | 71 ++++++++++++++----- .../workflow/adapters/dify/dify_adapter.py | 2 +- api/app/core/workflow/nodes/configs.py | 4 +- api/app/core/workflow/nodes/notes/__init__.py | 0 api/app/core/workflow/nodes/notes/config.py | 8 +++ 5 files changed, 65 insertions(+), 20 deletions(-) create mode 100644 api/app/core/workflow/nodes/notes/__init__.py create mode 100644 api/app/core/workflow/nodes/notes/config.py diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index 32d420b5..c7539ea2 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -8,34 +8,59 @@ from typing import Any from urllib.parse import quote from app.core.workflow.adapters.base_converter import BaseConverter -from app.core.workflow.adapters.errors import UnsupportVariableType, UnknowModelWarning, ExceptionDefineition, \ +from app.core.workflow.adapters.errors import ( + UnsupportVariableType, + UnknowModelWarning, + ExceptionDefineition, ExceptionType -from app.core.workflow.nodes.assigner import AssignerNodeConfig +) from app.core.workflow.nodes.assigner.config import AssignmentItem from app.core.workflow.nodes.base_config import VariableDefinition, BaseNodeConfig -from app.core.workflow.nodes.code import CodeNodeConfig from app.core.workflow.nodes.code.config import InputVariable, OutputVariable -from app.core.workflow.nodes.configs import StartNodeConfig, LLMNodeConfig -from app.core.workflow.nodes.cycle_graph import LoopNodeConfig, IterationNodeConfig -from app.core.workflow.nodes.cycle_graph.config import ConditionDetail as LoopConditionDetail, ConditionsConfig, \ +from app.core.workflow.nodes.configs import ( + StartNodeConfig, + LLMNodeConfig, + AssignerNodeConfig, + CodeNodeConfig, + LoopNodeConfig, + IterationNodeConfig, + EndNodeConfig, + HttpRequestNodeConfig, + IfElseNodeConfig, + JinjaRenderNodeConfig, + KnowledgeRetrievalNodeConfig, + NoteNodeConfig, + ParameterExtractorNodeConfig, + QuestionClassifierNodeConfig, + VariableAggregatorNodeConfig +) +from app.core.workflow.nodes.cycle_graph.config import ( + ConditionDetail as LoopConditionDetail, + ConditionsConfig, CycleVariable -from app.core.workflow.nodes.end import EndNodeConfig -from app.core.workflow.nodes.enums import ValueInputType, ComparisonOperator, AssignmentOperator, HttpAuthType, \ - HttpContentType, HttpErrorHandle -from app.core.workflow.nodes.http_request import HttpRequestNodeConfig -from app.core.workflow.nodes.http_request.config import HttpAuthConfig, HttpContentTypeConfig, HttpFormData, \ - HttpTimeOutConfig, HttpRetryConfig, HttpErrorDefaultTamplete, HttpErrorHandleConfig -from app.core.workflow.nodes.if_else import IfElseNodeConfig +) +from app.core.workflow.nodes.enums import ( + ValueInputType, + ComparisonOperator, + AssignmentOperator, + HttpAuthType, + HttpContentType, + HttpErrorHandle +) +from app.core.workflow.nodes.http_request.config import ( + HttpAuthConfig, + HttpContentTypeConfig, + HttpFormData, + HttpTimeOutConfig, + HttpRetryConfig, + HttpErrorDefaultTamplete, + HttpErrorHandleConfig +) from app.core.workflow.nodes.if_else.config import ConditionDetail, ConditionBranchConfig -from app.core.workflow.nodes.jinja_render import JinjaRenderNodeConfig from app.core.workflow.nodes.jinja_render.config import VariablesMappingConfig -from app.core.workflow.nodes.knowledge import KnowledgeRetrievalNodeConfig from app.core.workflow.nodes.llm.config import MemoryWindowSetting, MessageConfig -from app.core.workflow.nodes.parameter_extractor import ParameterExtractorNodeConfig from app.core.workflow.nodes.parameter_extractor.config import ParamsConfig -from app.core.workflow.nodes.question_classifier import QuestionClassifierNodeConfig from app.core.workflow.nodes.question_classifier.config import ClassifierConfig -from app.core.workflow.nodes.variable_aggregator import VariableAggregatorNodeConfig from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE @@ -63,6 +88,7 @@ class DifyConverter(BaseConverter): "question-classifier": self.convert_question_classifier_node_config, "variable-aggregator": self.convert_variable_aggregator_node_config, "tool": self.convert_tool_node_config, + "": self.convert_notes_config, "loop-start": lambda x: {}, "iteration-start": lambda x: {}, "loop-end": lambda x: {}, @@ -732,3 +758,12 @@ class DifyConverter(BaseConverter): detail=f"Please reconfigure the tool node.", )) return {} + + @staticmethod + def convert_notes_config(node: dict): + node_data = node["data"] + result = NoteNodeConfig.model_construct( + author=node_data.get("author", ""), + text=node_data.get("text", "") + ).model_dump() + return result diff --git a/api/app/core/workflow/adapters/dify/dify_adapter.py b/api/app/core/workflow/adapters/dify/dify_adapter.py index 895b3d37..a190aadd 100644 --- a/api/app/core/workflow/adapters/dify/dify_adapter.py +++ b/api/app/core/workflow/adapters/dify/dify_adapter.py @@ -193,7 +193,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): type=ExceptionType.NODE, node_id=node["id"], node_name=node["data"]["title"], - detail=f"node type {node_type if node_type else 'notes'} is unsupported", + detail=f"node type {node_type} is unsupported", )) return converter(node) except Exception as e: diff --git a/api/app/core/workflow/nodes/configs.py b/api/app/core/workflow/nodes/configs.py index e4e418fe..31dadc38 100644 --- a/api/app/core/workflow/nodes/configs.py +++ b/api/app/core/workflow/nodes/configs.py @@ -23,6 +23,7 @@ from app.core.workflow.nodes.question_classifier.config import QuestionClassifie from app.core.workflow.nodes.start.config import StartNodeConfig from app.core.workflow.nodes.tool.config import ToolNodeConfig from app.core.workflow.nodes.variable_aggregator.config import VariableAggregatorNodeConfig +from app.core.workflow.nodes.notes.config import NoteNodeConfig __all__ = [ # 基础类 @@ -47,5 +48,6 @@ __all__ = [ "ToolNodeConfig", "MemoryReadNodeConfig", "MemoryWriteNodeConfig", - "CodeNodeConfig" + "CodeNodeConfig", + "NoteNodeConfig" ] diff --git a/api/app/core/workflow/nodes/notes/__init__.py b/api/app/core/workflow/nodes/notes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/app/core/workflow/nodes/notes/config.py b/api/app/core/workflow/nodes/notes/config.py new file mode 100644 index 00000000..d7bfa383 --- /dev/null +++ b/api/app/core/workflow/nodes/notes/config.py @@ -0,0 +1,8 @@ +from pydantic import Field + +from app.core.workflow.nodes.base_config import BaseNodeConfig + + +class NoteNodeConfig(BaseNodeConfig): + author: str = Field(..., description="author") + text: str = Field(..., description="note context") From 966bd8528d4dafcb90c0d40618e2a97e8df291ae Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 9 Mar 2026 03:08:44 +0800 Subject: [PATCH 72/89] feat(workflow): simplify node converter registry --- .../core/workflow/adapters/dify/converter.py | 40 +++++++++---------- .../workflow/adapters/dify/dify_adapter.py | 22 +++++----- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index c7539ea2..023091a3 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -45,7 +45,8 @@ from app.core.workflow.nodes.enums import ( AssignmentOperator, HttpAuthType, HttpContentType, - HttpErrorHandle + HttpErrorHandle, + NodeType ) from app.core.workflow.nodes.http_request.config import ( HttpAuthConfig, @@ -73,25 +74,24 @@ class DifyConverter(BaseConverter): def __init__(self): self.CONFIG_CONVERT_MAP = { - "start": self.convert_start_node_config, - "llm": self.convert_llm_node_config, - "answer": self.convert_end_node_config, - "if-else": self.convert_if_else_node_config, - "loop": self.convert_loop_node_config, - "iteration": self.convert_iteration_node_config, - "assigner": self.convert_assigner_node_config, - "code": self.convert_code_node_config, - "http-request": self.convert_http_node_config, - "template-transform": self.convert_jinja_render_node_config, - "knowledge-retrieval": self.convert_knowledge_node_config, - "parameter-extractor": self.convert_parameter_extractor_node_config, - "question-classifier": self.convert_question_classifier_node_config, - "variable-aggregator": self.convert_variable_aggregator_node_config, - "tool": self.convert_tool_node_config, - "": self.convert_notes_config, - "loop-start": lambda x: {}, - "iteration-start": lambda x: {}, - "loop-end": lambda x: {}, + NodeType.START: self.convert_start_node_config, + NodeType.LLM: self.convert_llm_node_config, + NodeType.END: self.convert_end_node_config, + NodeType.IF_ELSE: self.convert_if_else_node_config, + NodeType.LOOP: self.convert_loop_node_config, + NodeType.ITERATION: self.convert_iteration_node_config, + NodeType.ASSIGNER: self.convert_assigner_node_config, + NodeType.CODE: self.convert_code_node_config, + NodeType.HTTP_REQUEST: self.convert_http_node_config, + NodeType.JINJARENDER: self.convert_jinja_render_node_config, + NodeType.KNOWLEDGE_RETRIEVAL: self.convert_knowledge_node_config, + NodeType.PARAMETER_EXTRACTOR: self.convert_parameter_extractor_node_config, + NodeType.QUESTION_CLASSIFIER: self.convert_question_classifier_node_config, + NodeType.VAR_AGGREGATOR: self.convert_variable_aggregator_node_config, + NodeType.TOOL: self.convert_tool_node_config, + NodeType.NOTES: self.convert_notes_config, + NodeType.CYCLE_START: lambda x: {}, + NodeType.BREAK: lambda x: {}, } def get_node_convert(self, node_type): diff --git a/api/app/core/workflow/adapters/dify/dify_adapter.py b/api/app/core/workflow/adapters/dify/dify_adapter.py index a190aadd..c9aa7ca9 100644 --- a/api/app/core/workflow/adapters/dify/dify_adapter.py +++ b/api/app/core/workflow/adapters/dify/dify_adapter.py @@ -50,7 +50,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): def __init__(self, config: dict[str, Any]): DifyConverter.__init__(self) - BasePlatformAdapter.__init__(self, config) + BasePlatformAdapter.__init__(self, config) def get_metadata(self) -> PlatformMetadata: return PlatformMetadata( @@ -84,7 +84,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): require_fields = frozenset({'app', 'kind', 'version', 'workflow'}) if not all(field in self.config for field in require_fields): return False - if self.config.get("app",{}).get("mode") == "workflow": + if self.config.get("app", {}).get("mode") == "workflow": self.errors.append(ExceptionDefineition( type=ExceptionType.PLATFORM, detail="workflow mode is not supported" @@ -163,13 +163,14 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): def _convert_node(self, node: dict[str, Any]) -> NodeDefinition | None: node_data = node["data"] try: + node_type = self.map_node_type(node_data["type"]) return NodeDefinition( id=node["id"], - type=self.map_node_type(node_data["type"]), + type=node_type, name=node_data.get("title") or "notes", cycle=node.get("parentId"), description=None, - config=self._convert_node_config(node), + config=self._convert_node_config(node_type, node), position={ "x": node["position"]["x"], "y": node["position"]["y"] @@ -183,17 +184,16 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): except Exception as e: logger.debug(f"convert node error - {e}", exc_info=True) - def _convert_node_config(self, node: dict): - node_data = node["data"] - node_type = node_data["type"] + def _convert_node_config(self, node_type: str, node: dict): try: + node_data = node["data"] converter = self.get_node_convert(node_type) - if node_type not in self.CONFIG_CONVERT_MAP: + if node_type == NodeType.UNKNOWN: self.errors.append(ExceptionDefineition( type=ExceptionType.NODE, node_id=node["id"], node_name=node["data"]["title"], - detail=f"node type {node_type} is unsupported", + detail=f"node type {node_data.get('type')} is unsupported", )) return converter(node) except Exception as e: @@ -214,7 +214,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): if source in self.branch_node_cache: case_id = edge["sourceHandle"] if case_id == "false": - label = f'CASE{len(self.branch_node_cache[source])+1}' + label = f'CASE{len(self.branch_node_cache[source]) + 1}' else: label = f'CASE{self.branch_node_cache[source].index(case_id) + 1}' if source in self.error_branch_node_cache: @@ -257,5 +257,3 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): def _convert_execution(self, execution: dict[str, Any]) -> ExecutionConfig: return ExecutionConfig() - - From 389dd8d4026b089408e0d50ca49988fd096ec929 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 9 Mar 2026 03:18:33 +0800 Subject: [PATCH 73/89] feat(workflow): support resizing comment nodes, add theme and author display toggle --- api/app/core/workflow/adapters/dify/converter.py | 6 +++++- api/app/core/workflow/adapters/dify/dify_adapter.py | 4 ++-- api/app/core/workflow/nodes/notes/config.py | 8 ++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index 023091a3..467beb07 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -764,6 +764,10 @@ class DifyConverter(BaseConverter): node_data = node["data"] result = NoteNodeConfig.model_construct( author=node_data.get("author", ""), - text=node_data.get("text", "") + text=node_data.get("text", ""), + width=node_data.get("width", 80), + height=node_data.get("height", 80), + theme=node_data.get("theme", "blue"), + show_author=node_data.get("showAuthor", True) ).model_dump() return result diff --git a/api/app/core/workflow/adapters/dify/dify_adapter.py b/api/app/core/workflow/adapters/dify/dify_adapter.py index c9aa7ca9..10397ad0 100644 --- a/api/app/core/workflow/adapters/dify/dify_adapter.py +++ b/api/app/core/workflow/adapters/dify/dify_adapter.py @@ -59,7 +59,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): support_node_types=list(self.NODE_TYPE_MAPPING.keys()) ) - def map_node_type(self, platform_node_type) -> str: + def map_node_type(self, platform_node_type) -> NodeType: return self.NODE_TYPE_MAPPING.get(platform_node_type, NodeType.UNKNOWN) @property @@ -184,7 +184,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): except Exception as e: logger.debug(f"convert node error - {e}", exc_info=True) - def _convert_node_config(self, node_type: str, node: dict): + def _convert_node_config(self, node_type: NodeType, node: dict): try: node_data = node["data"] converter = self.get_node_convert(node_type) diff --git a/api/app/core/workflow/nodes/notes/config.py b/api/app/core/workflow/nodes/notes/config.py index d7bfa383..42b4a1ab 100644 --- a/api/app/core/workflow/nodes/notes/config.py +++ b/api/app/core/workflow/nodes/notes/config.py @@ -4,5 +4,9 @@ from app.core.workflow.nodes.base_config import BaseNodeConfig class NoteNodeConfig(BaseNodeConfig): - author: str = Field(..., description="author") - text: str = Field(..., description="note context") + author: str = Field(default="", description="author") + text: str = Field(default="", description="note content") + width: int = Field(default=80) + height: int = Field(default=80) + theme: str = Field(default="blue") + show_author: bool = Field(default=True) From 476632294fe69899eea0a30a464cc7874d1c0f60 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 9 Mar 2026 14:02:23 +0800 Subject: [PATCH 74/89] [changes] Remove the "worker-ondemand" queue --- api/app/celery_app.py | 2 +- api/app/tasks.py | 2 ++ api/docker-compose.yml | 16 +--------------- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/api/app/celery_app.py b/api/app/celery_app.py index 7ace1f9b..e6b239dd 100644 --- a/api/app/celery_app.py +++ b/api/app/celery_app.py @@ -113,7 +113,7 @@ celery_app.conf.update( 'app.tasks.run_forgetting_cycle_task': {'queue': 'periodic_tasks'}, 'app.tasks.write_all_workspaces_memory_task': {'queue': 'periodic_tasks'}, 'app.tasks.update_implicit_emotions_storage': {'queue': 'periodic_tasks'}, - 'app.tasks.init_implicit_emotions_for_users': {'queue': 'ondemand_tasks'}, + 'app.tasks.init_implicit_emotions_for_users': {'queue': 'periodic_tasks'}, }, ) diff --git a/api/app/tasks.py b/api/app/tasks.py index 0c0fd01e..65a0a091 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -2457,6 +2457,8 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: acks_late=False, time_limit=3600, soft_time_limit=3300, + # 触发型任务标识,区别于 periodic_tasks 队列中的定时任务 + triggered=True, ) def init_implicit_emotions_for_users(self, end_user_ids: List[str]) -> Dict[str, Any]: """事件触发任务:对指定用户列表做存在性检查,无记录则执行首次初始化。 diff --git a/api/docker-compose.yml b/api/docker-compose.yml index 1fcfc977..5d358f2c 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -49,7 +49,7 @@ services: networks: - celery - # Periodic worker - Scheduled/beat tasks (prefork, low concurrency) + # Periodic worker - Scheduled/beat tasks + API-triggered tasks (prefork, low concurrency) worker-periodic: image: redbear-mem-open:latest container_name: worker-periodic @@ -63,20 +63,6 @@ services: networks: - celery - # On-demand worker - API-triggered tasks (e.g. implicit emotions init) - worker-ondemand: - image: redbear-mem-open:latest - container_name: worker-ondemand - env_file: - - .env - volumes: - - ./files:/files - - /etc/localtime:/etc/localtime:ro - command: celery -A app.celery_worker.celery_app worker -E --loglevel=info --pool=prefork --concurrency=4 --queues=ondemand_tasks --max-tasks-per-child=50 -n ondemand_worker@%h - restart: unless-stopped - networks: - - celery - # Celery Beat - scheduler beat: image: redbear-mem-open:latest From c8065b0c602242cb4233c85d67dd255b744170cc Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 9 Mar 2026 14:12:53 +0800 Subject: [PATCH 75/89] feat(implicit-emotions): add Redis resilience and connection pooling - Replace single Redis client with connection pool for better concurrency and auto-reconnection - Add graceful degradation when Redis is unavailable (None handling in get_users_needing_refresh) - Add RedisError exception handling with fallback to process all users on mget failures - Add type hints (Optional[redis.StrictRedis]) to Redis client parameters - Add health check and socket timeout configuration to connection pool - Add logging for Redis connection failures and degradation events - Reorganize imports alphabetically for consistency across both files - Update get_sync_redis_client to validate connection with ping() before returning --- .../implicit_emotions_storage_repository.py | 45 +++++++-- api/app/tasks.py | 92 +++++++++++++------ 2 files changed, 102 insertions(+), 35 deletions(-) diff --git a/api/app/repositories/implicit_emotions_storage_repository.py b/api/app/repositories/implicit_emotions_storage_repository.py index dfc7061b..58e98dfd 100644 --- a/api/app/repositories/implicit_emotions_storage_repository.py +++ b/api/app/repositories/implicit_emotions_storage_repository.py @@ -5,13 +5,15 @@ Implicit Emotions Storage Repository 事务由调用方控制,仓储层只使用 flush/refresh """ import logging -from datetime import datetime, date, timezone, timedelta -from typing import Optional, Generator -from sqlalchemy.orm import Session -from sqlalchemy import select, not_, exists +from datetime import date, datetime, timedelta, timezone +from typing import Generator, Optional + +import redis +from sqlalchemy import exists, not_, select +from sqlalchemy.orm import Session -from app.models.implicit_emotions_storage_model import ImplicitEmotionsStorage from app.models.end_user_model import EndUser +from app.models.implicit_emotions_storage_model import ImplicitEmotionsStorage logger = logging.getLogger(__name__) @@ -111,7 +113,7 @@ class ImplicitEmotionsStorageRepository: logger.error(f"分批获取用户ID失败: offset={offset}, error={e}") break - def get_users_needing_refresh(self, redis_client, batch_size: int = 100) -> Generator[str, None, None]: + def get_users_needing_refresh(self, redis_client: Optional[redis.StrictRedis], batch_size: int = 100) -> Generator[str, None, None]: """分批次获取需要刷新隐性记忆/情绪数据的存量用户ID。 筛选逻辑: @@ -120,15 +122,28 @@ class ImplicitEmotionsStorageRepository: - 若 Redis 中无记录(该用户从未写入过记忆),跳过 - 若 last_done > updated_at,说明上次刷新后又有新记忆写入,需要刷新 - 若 last_done <= updated_at,说明已是最新,跳过 + + 如果 redis_client 为 None,则降级为返回所有用户(禁用时间过滤)。 Args: - redis_client: 同步 redis.StrictRedis 实例(连接 CELERY_BACKEND DB) + redis_client: 同步 redis.StrictRedis 实例(连接 CELERY_BACKEND DB),如果为 None 则禁用时间过滤 batch_size: 每批次加载的数量 Yields: 需要刷新的用户ID字符串 """ from datetime import timezone + + from redis.exceptions import RedisError + + # 如果 Redis 不可用,降级为处理所有用户 + if redis_client is None: + logger.warning( + "Redis 客户端不可用,时间过滤已禁用,将处理所有存量用户" + ) + yield from self.get_all_user_ids(batch_size) + return + offset = 0 while True: try: @@ -144,7 +159,18 @@ class ImplicitEmotionsStorageRepository: # 批量获取当前批次所有用户的 last_done 时间戳(一次网络往返) keys = [f"write_message:last_done:{end_user_id}" for end_user_id, _ in batch] - raw_values = redis_client.mget(keys) + + try: + raw_values = redis_client.mget(keys) + except RedisError as e: + logger.error( + f"Redis mget 操作失败: {e},当前批次降级为处理所有用户", + extra={"offset": offset, "batch_size": len(batch)} + ) + # Redis 操作失败,降级为返回当前批次所有用户 + yield from (end_user_id for end_user_id, _ in batch) + offset += batch_size + continue for (end_user_id, updated_at), raw in zip(batch, raw_values): if raw is None: @@ -190,7 +216,8 @@ class ImplicitEmotionsStorageRepository: Yields: 用户ID字符串 """ - from sqlalchemy import cast, String as SAString + from sqlalchemy import String as SAString + from sqlalchemy import cast CST = timezone(timedelta(hours=8)) now_cst = datetime.now(CST) today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc).replace(tzinfo=None) diff --git a/api/app/tasks.py b/api/app/tasks.py index 65a0a091..5958d77d 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -1,5 +1,6 @@ import asyncio import json +import logging import os import re import shutil @@ -14,29 +15,62 @@ from uuid import UUID import redis import requests +from redis.exceptions import RedisError -# 模块级同步 Redis 客户端单例,供 Celery 任务共享使用(避免每次任务新建连接) +logger = logging.getLogger(__name__) + +# 模块级同步 Redis 连接池,供 Celery 任务共享使用 # 连接 CELERY_BACKEND DB,与 write_message:last_done 时间戳写入保持一致 -def _build_sync_redis_client(): +# 使用连接池而非单例客户端,提供更好的并发性能和自动重连 +_sync_redis_pool: redis.ConnectionPool = None + +def _get_or_create_redis_pool() -> redis.ConnectionPool: + """获取或创建 Redis 连接池(懒初始化)""" + global _sync_redis_pool + if _sync_redis_pool is None: + try: + _sync_redis_pool = redis.ConnectionPool( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DB_CELERY_BACKEND, + password=settings.REDIS_PASSWORD, + decode_responses=True, + max_connections=10, + socket_connect_timeout=5, + socket_timeout=5, + retry_on_timeout=True, + health_check_interval=30, + ) + logger.info("Redis connection pool created for Celery tasks") + except Exception as e: + logger.error(f"Failed to create Redis connection pool: {e}", exc_info=True) + return None + return _sync_redis_pool + +def get_sync_redis_client() -> Optional[redis.StrictRedis]: + """获取同步 Redis 客户端(使用连接池) + + 使用连接池提供的客户端,支持自动重连和健康检查。 + 如果 Redis 不可用,返回 None,调用方应优雅降级。 + + Returns: + redis.StrictRedis: Redis 客户端实例,如果连接失败则返回 None + """ try: - return redis.StrictRedis( - host=settings.REDIS_HOST, - port=settings.REDIS_PORT, - db=settings.REDIS_DB_CELERY_BACKEND, - password=settings.REDIS_PASSWORD, - decode_responses=True, - ) - except Exception: + pool = _get_or_create_redis_pool() + if pool is None: + return None + + client = redis.StrictRedis(connection_pool=pool) + # 验证连接可用性 + client.ping() + return client + except RedisError as e: + logger.error(f"Redis connection failed: {e}", exc_info=True) + return None + except Exception as e: + logger.error(f"Unexpected error getting Redis client: {e}", exc_info=True) return None - -_sync_redis_client: redis.StrictRedis = None - -def get_sync_redis_client() -> redis.StrictRedis: - """获取模块级同步 Redis 客户端(懒初始化单例)""" - global _sync_redis_client - if _sync_redis_client is None: - _sync_redis_client = _build_sync_redis_client() - return _sync_redis_client # Import a unified Celery instance from app.celery_app import celery_app @@ -1117,8 +1151,9 @@ 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 timezone as _tz, timedelta as _td - _CST = _tz(timedelta(hours=8)) + 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() _r.set( f"write_message:last_done:{end_user_id}", @@ -2187,12 +2222,15 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: start_time = time.time() async def _run() -> Dict[str, Any]: + from sqlalchemy import func, select + from app.core.logging_config import get_logger - from app.repositories.implicit_emotions_storage_repository import ImplicitEmotionsStorageRepository from app.models.implicit_emotions_storage_model import ImplicitEmotionsStorage - from sqlalchemy import select, func - from app.services.implicit_memory_service import ImplicitMemoryService + from app.repositories.implicit_emotions_storage_repository import ( + ImplicitEmotionsStorageRepository, + ) from app.services.emotion_analytics_service import EmotionAnalyticsService + from app.services.implicit_memory_service import ImplicitMemoryService logger = get_logger(__name__) logger.info("开始执行隐性记忆和情绪数据更新定时任务") @@ -2476,9 +2514,11 @@ def init_implicit_emotions_for_users(self, end_user_ids: List[str]) -> Dict[str, async def _run() -> Dict[str, Any]: from app.core.logging_config import get_logger - from app.repositories.implicit_emotions_storage_repository import ImplicitEmotionsStorageRepository - from app.services.implicit_memory_service import ImplicitMemoryService + from app.repositories.implicit_emotions_storage_repository import ( + ImplicitEmotionsStorageRepository, + ) from app.services.emotion_analytics_service import EmotionAnalyticsService + from app.services.implicit_memory_service import ImplicitMemoryService logger = get_logger(__name__) logger.info(f"开始按需初始化隐性记忆/情绪数据,候选用户数: {len(end_user_ids)}") From 50466124c820e469de7cc1b73f88d94fd2004b10 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 7 Mar 2026 16:55:06 +0800 Subject: [PATCH 76/89] [add] Verification of the existence of interest distribution --- api/app/celery_app.py | 1 + .../memory_dashboard_controller.py | 8 +- api/app/tasks.py | 107 ++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/api/app/celery_app.py b/api/app/celery_app.py index e6b239dd..cac4eff1 100644 --- a/api/app/celery_app.py +++ b/api/app/celery_app.py @@ -114,6 +114,7 @@ celery_app.conf.update( 'app.tasks.write_all_workspaces_memory_task': {'queue': 'periodic_tasks'}, 'app.tasks.update_implicit_emotions_storage': {'queue': 'periodic_tasks'}, 'app.tasks.init_implicit_emotions_for_users': {'queue': 'periodic_tasks'}, + 'app.tasks.init_interest_distribution_for_users': {'queue': 'periodic_tasks'}, }, ) diff --git a/api/app/controllers/memory_dashboard_controller.py b/api/app/controllers/memory_dashboard_controller.py index 1c82b636..50e8ec8f 100644 --- a/api/app/controllers/memory_dashboard_controller.py +++ b/api/app/controllers/memory_dashboard_controller.py @@ -156,9 +156,13 @@ async def get_workspace_end_users( "app.tasks.init_implicit_emotions_for_users", kwargs={"end_user_ids": end_user_ids}, ) - api_logger.info(f"已触发隐性记忆按需初始化任务,候选用户数: {len(end_user_ids)}") + _celery_app.send_task( + "app.tasks.init_interest_distribution_for_users", + kwargs={"end_user_ids": end_user_ids}, + ) + api_logger.info(f"已触发按需初始化任务,候选用户数: {len(end_user_ids)}") except Exception as e: - api_logger.warning(f"触发隐性记忆按需初始化任务失败(不影响主流程): {e}") + api_logger.warning(f"触发按需初始化任务失败(不影响主流程): {e}") # 并发执行配置查询和记忆数量查询 memory_configs_map, memory_nums_map = await asyncio.gather( diff --git a/api/app/tasks.py b/api/app/tasks.py index 5958d77d..05bc022d 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -2605,3 +2605,110 @@ def init_implicit_emotions_for_users(self, end_user_ids: List[str]) -> Dict[str, "elapsed_time": time.time() - start_time, "task_id": self.request.id, } + + +# ============================================================================= + +@celery_app.task( + name="app.tasks.init_interest_distribution_for_users", + bind=True, + ignore_result=True, + max_retries=0, + acks_late=False, + time_limit=3600, + soft_time_limit=3300, +) +def init_interest_distribution_for_users(self, end_user_ids: List[str]) -> Dict[str, Any]: + """事件触发任务:检查指定用户列表的兴趣分布缓存,无缓存则生成并写入 Redis。 + + 由 /dashboard/end_users 接口触发,已有缓存的用户直接跳过。 + 默认生成中文(zh)兴趣分布数据。 + + Args: + end_user_ids: 需要检查的用户ID列表 + + Returns: + 包含任务执行结果的字典 + """ + start_time = time.time() + + async def _run() -> Dict[str, Any]: + from app.core.logging_config import get_logger + from app.cache.memory.interest_memory import InterestMemoryCache, INTEREST_CACHE_EXPIRE + from app.services.memory_agent_service import MemoryAgentService + + logger = get_logger(__name__) + logger.info(f"开始按需初始化兴趣分布缓存,候选用户数: {len(end_user_ids)}") + + initialized = 0 + failed = 0 + skipped = 0 + language = "zh" + + service = MemoryAgentService() + + with get_db_context() as db: + for end_user_id in end_user_ids: + # 存在性检查:缓存有数据则跳过 + cached = await InterestMemoryCache.get_interest_distribution( + end_user_id=end_user_id, + language=language, + ) + if cached is not None: + skipped += 1 + continue + + logger.info(f"用户 {end_user_id} 无兴趣分布缓存,开始生成") + try: + result = await service.get_interest_distribution_by_user( + end_user_id=end_user_id, + limit=5, + language=language, + ) + await InterestMemoryCache.set_interest_distribution( + end_user_id=end_user_id, + language=language, + data=result, + expire=INTEREST_CACHE_EXPIRE, + ) + initialized += 1 + logger.info(f"用户 {end_user_id} 兴趣分布缓存生成成功") + except Exception as e: + failed += 1 + logger.error(f"用户 {end_user_id} 兴趣分布缓存生成失败: {e}") + + logger.info(f"兴趣分布按需初始化完成: 初始化={initialized}, 跳过={skipped}, 失败={failed}") + return { + "status": "SUCCESS", + "initialized": initialized, + "skipped": skipped, + "failed": failed, + } + + try: + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) + result["elapsed_time"] = time.time() - start_time + result["task_id"] = self.request.id + return result + except Exception as e: + return { + "status": "FAILURE", + "error": str(e), + "elapsed_time": time.time() - start_time, + "task_id": self.request.id, + } From 21ae448ed74f00d3d7bc0bcc41fd869c0c6d4f08 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 7 Mar 2026 17:07:38 +0800 Subject: [PATCH 77/89] [add] Throw out explicit error messages; Using the CST time zone --- .../implicit_emotions_storage_repository.py | 15 +++++++++------ api/app/tasks.py | 9 ++++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/api/app/repositories/implicit_emotions_storage_repository.py b/api/app/repositories/implicit_emotions_storage_repository.py index 58e98dfd..aa62e07d 100644 --- a/api/app/repositories/implicit_emotions_storage_repository.py +++ b/api/app/repositories/implicit_emotions_storage_repository.py @@ -126,12 +126,17 @@ class ImplicitEmotionsStorageRepository: 如果 redis_client 为 None,则降级为返回所有用户(禁用时间过滤)。 Args: - redis_client: 同步 redis.StrictRedis 实例(连接 CELERY_BACKEND DB),如果为 None 则禁用时间过滤 + redis_client: 同步 redis.StrictRedis 实例(连接 CELERY_BACKEND DB),为 None 时抛出 RuntimeError batch_size: 每批次加载的数量 + Raises: + RuntimeError: redis_client 为 None 时,调用方可捕获并回退到 get_all_user_ids + Yields: 需要刷新的用户ID字符串 """ + if redis_client is None: + raise RuntimeError("get_users_needing_refresh: redis_client 不可用,无法执行时间轴筛选") from datetime import timezone from redis.exceptions import RedisError @@ -178,16 +183,14 @@ class ImplicitEmotionsStorageRepository: try: CST = timezone(timedelta(hours=8)) last_done = datetime.fromisoformat(raw) - # 统一转为 CST naive 时间做比较 - if last_done.tzinfo is None: - last_done = last_done.replace(tzinfo=timezone.utc).astimezone(CST).replace(tzinfo=None) - else: + # last_done 写入时已是 CST naive,直接使用,无需转换 + if last_done.tzinfo is not None: last_done = last_done.astimezone(CST).replace(tzinfo=None) if updated_at is None: yield end_user_id continue - # updated_at 同样转为 CST naive + # updated_at 数据库存的是 UTC naive,转为 CST naive 再比较 if updated_at.tzinfo is None: updated_at_cst = updated_at.replace(tzinfo=timezone.utc).astimezone(CST).replace(tzinfo=None) else: diff --git a/api/app/tasks.py b/api/app/tasks.py index 05bc022d..101c66a5 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -2256,7 +2256,14 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: _redis_client = get_sync_redis_client() # 只处理 last_done > updated_at 的用户(有新记忆写入的用户) - for end_user_id in repo.get_users_needing_refresh(_redis_client, batch_size=100): + # Redis 不可用时回退到全量处理 + try: + refresh_iter = repo.get_users_needing_refresh(_redis_client, batch_size=100) + except RuntimeError as e: + logger.warning(f"时间轴筛选不可用,回退到全量刷新: {e}") + refresh_iter = repo.get_all_user_ids(batch_size=100) + + for end_user_id in refresh_iter: logger.info(f"开始处理用户: {end_user_id}") user_start_time = time.time() From 4c2b31f31f8aaf7cd98e99c29acc8179dd7c351f Mon Sep 17 00:00:00 2001 From: yujiangping Date: Mon, 9 Mar 2026 15:36:49 +0800 Subject: [PATCH 78/89] feat(web): add MCP market database tracking and refresh status messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add i18n translations for refresh success and failure messages in English and Chinese - Track MCP tools already stored in database with inDatabase flag in Market component - Display "已入库" (In Database) tag alongside activation status for MCPs - Import getTools API to fetch full tool list for database status comparison - Add market metadata fields (source_channel, market_id, market_config_id, mcp_service_id) to tool items when adding from market - Preserve market source information through McpServiceModal when saving tools - Update ToolItem type to include market tracking fields in config_data - Improve MCP card layout to properly display multiple status tags --- web/src/i18n/en.ts | 2 + web/src/i18n/zh.ts | 2 + web/src/views/ToolManagement/Market.tsx | 39 +++++++++++++++---- web/src/views/ToolManagement/Mcp.tsx | 1 - .../components/McpServiceModal.tsx | 13 +++++++ web/src/views/ToolManagement/types.ts | 4 ++ 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 19498811..3904b88b 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1806,6 +1806,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re error_desc: 'API is configured but connection error', testConnectionSuccess: 'Test Connection Successful', + refreshSuccess: 'Refresh Successful', + refreshFailed: 'Refresh Failed', serviceEndpoint: 'Service Endpoint URL', serviceEndpointPlaceholder: 'URL of the service endpoint', serviceEndpointExtra: 'Complete access address of the MCP service', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 664795fe..24e56d4e 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1803,6 +1803,8 @@ export const zh = { error_desc: 'API 已配置但链接异常', testConnectionSuccess: '测试连接成功', + refreshSuccess: '刷新成功', + refreshFailed: '刷新失败', serviceEndpoint: '服务端点 URL', serviceEndpointPlaceholder: '服务端点的 URL', serviceEndpointExtra: 'MCP服务的完整访问地址', diff --git a/web/src/views/ToolManagement/Market.tsx b/web/src/views/ToolManagement/Market.tsx index 7a2df6df..00dea715 100644 --- a/web/src/views/ToolManagement/Market.tsx +++ b/web/src/views/ToolManagement/Market.tsx @@ -6,7 +6,7 @@ import InfiniteScroll from 'react-infinite-scroll-component'; import MarketConfigModal, { type MarketConfigModalRef } from './components/MarketConfigModal'; import McpServiceModal from './components/McpServiceModal'; import type { McpServiceModalRef } from './types'; -import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated } from '@/api/tools'; +import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools'; interface MarketSource { id: string; name: string; @@ -32,6 +32,7 @@ interface MarketMcp { tags?: string[]; view_count?: number; activated?: boolean; + inDatabase?: boolean; locales?: { [lang: string]: { name: string; @@ -131,13 +132,27 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => } } + // 获取全量工具列表,用于标记已入库的 MCP + const allTools: any = await getTools({ tool_type: 'mcp' }); + const toolsList = Array.isArray(allTools) ? allTools : []; + const res: any = await getMarketMCPs({ mcp_market_config_id: configId, page, pagesize: pageSize }); if (res?.items && Array.isArray(res.items)) { - // 标记已激活的 MCP - const mcpsWithActivated = res.items.map((item: MarketMcp) => ({ - ...item, - activated: activatedIds.includes(item.id) - })); + // 标记已激活和已入库的 MCP + const mcpsWithActivated = res.items.map((item: MarketMcp) => { + // 检查是否已入库:market_id = sourceId, market_config_id = configId, mcp_service_id = item.id + const isInDatabase = toolsList.some((tool: any) => + tool.config_data?.market_id === sourceId && + tool.config_data?.market_config_id === configId && + tool.config_data?.mcp_service_id === item.id + ); + + return { + ...item, + activated: activatedIds.includes(item.id), + inDatabase: isInDatabase + }; + }); setMcpCache(prev => ({ ...prev, @@ -212,9 +227,14 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => mcp_market_config_id: configIdMap[selectedSource], server_id: mcp.id, }); + const source = marketSources.find(s => s.id === selectedSource); const toolItem = { name: detail.name, description: detail.description, + source_channel: source?.name || '', + market_id: selectedSource, + market_config_id: configIdMap[selectedSource], + mcp_service_id: mcp.id, config_data: { server_url: detail.servers?.[0]?.url || '', connection_config: { @@ -392,8 +412,11 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => )}
-
- {mcp.activated && 已激活} +
+
+ {mcp.activated && 已激活} + {mcp.inDatabase && 已入库} +
diff --git a/web/src/views/ToolManagement/Mcp.tsx b/web/src/views/ToolManagement/Mcp.tsx index 55cb73f5..90883b36 100644 --- a/web/src/views/ToolManagement/Mcp.tsx +++ b/web/src/views/ToolManagement/Mcp.tsx @@ -61,7 +61,6 @@ const Mcp: React.FC<{ getStatusTag: (status: string) => ReactNode }> = ({ getSta getData() }) }; - // 删除服务 const handleDeleteService = (item: ToolItem) => { if (!item.id) { diff --git a/web/src/views/ToolManagement/components/McpServiceModal.tsx b/web/src/views/ToolManagement/components/McpServiceModal.tsx index 0c9f6759..8bd44ccf 100644 --- a/web/src/views/ToolManagement/components/McpServiceModal.tsx +++ b/web/src/views/ToolManagement/components/McpServiceModal.tsx @@ -87,6 +87,10 @@ const McpServiceModal = forwardRef(({ name, description, icon, ...(config_data ? { config: { ...config_data } } : {}) }) + // 如果是从 Market 组件传来的数据(包含 market_id),保存完整的 data 用于后续提交 + if ((data as any).market_id) { + setEditVo(data) + } } else { form.resetFields(); } @@ -116,6 +120,15 @@ const McpServiceModal = forwardRef(({ } } } + + // 如果是从 Market 组件传来的数据,添加市场相关字段 + if ((editVo as any)?.market_id) { + (newService.config as any).source_channel = (editVo as any).source_channel; + (newService.config as any).market_id = (editVo as any).market_id; + (newService.config as any).market_config_id = (editVo as any).market_config_id; + (newService.config as any).mcp_service_id = (editVo as any).mcp_service_id; + } + const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService) request.then((res: any) => { message.success(t('common.saveSuccess')); diff --git a/web/src/views/ToolManagement/types.ts b/web/src/views/ToolManagement/types.ts index 98976e28..5508b200 100644 --- a/web/src/views/ToolManagement/types.ts +++ b/web/src/views/ToolManagement/types.ts @@ -75,6 +75,10 @@ export interface ToolItem { tool_class: string; schema_content: string; + source_channel?: string; + market_id?: string; + market_config_id?: string; + mcp_service_id?: string; }; status: 'available' | 'unavailable'; tags: string[]; From 5438d35f1710b077b69f2b58b8b46a8e9529400c Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 9 Mar 2026 16:19:55 +0800 Subject: [PATCH 79/89] [add] Specify the error types and clearly define the downgrade conditions --- .../implicit_emotions_storage_repository.py | 26 ++++++++----------- api/app/tasks.py | 3 ++- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/api/app/repositories/implicit_emotions_storage_repository.py b/api/app/repositories/implicit_emotions_storage_repository.py index aa62e07d..f0871b4b 100644 --- a/api/app/repositories/implicit_emotions_storage_repository.py +++ b/api/app/repositories/implicit_emotions_storage_repository.py @@ -8,6 +8,13 @@ import logging from datetime import date, datetime, timedelta, timezone from typing import Generator, Optional + +class TimeFilterUnavailableError(Exception): + """redis_client 不可用,无法执行时间轴筛选。 + + 调用方捕获此异常后可选择回退到 get_all_user_ids 进行全量处理。 + """ + import redis from sqlalchemy import exists, not_, select from sqlalchemy.orm import Session @@ -113,7 +120,7 @@ class ImplicitEmotionsStorageRepository: logger.error(f"分批获取用户ID失败: offset={offset}, error={e}") break - def get_users_needing_refresh(self, redis_client: Optional[redis.StrictRedis], batch_size: int = 100) -> Generator[str, None, None]: + def get_users_needing_refresh(self, redis_client: redis.StrictRedis, batch_size: int = 100) -> Generator[str, None, None]: """分批次获取需要刷新隐性记忆/情绪数据的存量用户ID。 筛选逻辑: @@ -123,32 +130,21 @@ class ImplicitEmotionsStorageRepository: - 若 last_done > updated_at,说明上次刷新后又有新记忆写入,需要刷新 - 若 last_done <= updated_at,说明已是最新,跳过 - 如果 redis_client 为 None,则降级为返回所有用户(禁用时间过滤)。 - Args: - redis_client: 同步 redis.StrictRedis 实例(连接 CELERY_BACKEND DB),为 None 时抛出 RuntimeError + redis_client: 同步 redis.StrictRedis 实例(连接 CELERY_BACKEND DB) batch_size: 每批次加载的数量 Raises: - RuntimeError: redis_client 为 None 时,调用方可捕获并回退到 get_all_user_ids + TimeFilterUnavailableError: redis_client 为 None 时抛出,调用方可捕获并回退到 get_all_user_ids Yields: 需要刷新的用户ID字符串 """ if redis_client is None: - raise RuntimeError("get_users_needing_refresh: redis_client 不可用,无法执行时间轴筛选") - from datetime import timezone + raise TimeFilterUnavailableError("redis_client 不可用,无法执行时间轴筛选") from redis.exceptions import RedisError - # 如果 Redis 不可用,降级为处理所有用户 - if redis_client is None: - logger.warning( - "Redis 客户端不可用,时间过滤已禁用,将处理所有存量用户" - ) - yield from self.get_all_user_ids(batch_size) - return - offset = 0 while True: try: diff --git a/api/app/tasks.py b/api/app/tasks.py index 101c66a5..6fd9c954 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -2228,6 +2228,7 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: from app.models.implicit_emotions_storage_model import ImplicitEmotionsStorage from app.repositories.implicit_emotions_storage_repository import ( ImplicitEmotionsStorageRepository, + TimeFilterUnavailableError, ) from app.services.emotion_analytics_service import EmotionAnalyticsService from app.services.implicit_memory_service import ImplicitMemoryService @@ -2259,7 +2260,7 @@ def update_implicit_emotions_storage(self) -> Dict[str, Any]: # Redis 不可用时回退到全量处理 try: refresh_iter = repo.get_users_needing_refresh(_redis_client, batch_size=100) - except RuntimeError as e: + except TimeFilterUnavailableError as e: logger.warning(f"时间轴筛选不可用,回退到全量刷新: {e}") refresh_iter = repo.get_all_user_ids(batch_size=100) From e1939ef47256ecf982def5717effd372b52abb94 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Mon, 9 Mar 2026 16:31:45 +0800 Subject: [PATCH 80/89] feat(web): internationalize MCP market UI strings - Add 19 new i18n keys for market-related UI text in English and Chinese - Replace hardcoded Chinese strings with i18n translations in Market.tsx - Update market refresh success message to use i18n key - Internationalize market selection, configuration, and service browsing UI - Support multi-language display for market status tags and action buttons --- web/src/i18n/en.ts | 17 ++++++++++++++++ web/src/i18n/zh.ts | 17 ++++++++++++++++ web/src/views/ToolManagement/Market.tsx | 26 ++++++++++++------------- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index cf14b37d..11be15b4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1809,6 +1809,23 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re testConnectionSuccess: 'Test Connection Successful', refreshSuccess: 'Refresh Successful', refreshFailed: 'Refresh Failed', + + // Market related + marketSelectTitle: 'Select an MCP Market', + marketSelectDesc: 'Choose a market source from the left, configure the connection to browse MCP services', + marketRefreshSuccess: 'List refreshed', + marketActivated: 'Activated', + marketInDatabase: 'In Database', + marketAdd: 'Add', + marketRefresh: 'Refresh', + marketConfig: 'Configure', + marketConfigConnection: 'Configure Connection', + marketNoServices: 'No MCP Services Available', + marketNotConnected: 'Not Connected to This Market', + marketNoServicesDesc: 'This market currently has no available services', + marketNotConnectedDesc: 'Click the "Configure" button in the upper right corner to set connection information', + marketSearchPlaceholder: 'Search services...', + marketVisit: 'Visit Market', serviceEndpoint: 'Service Endpoint URL', serviceEndpointPlaceholder: 'URL of the service endpoint', serviceEndpointExtra: 'Complete access address of the MCP service', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 8b598abe..94c145eb 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1805,6 +1805,23 @@ export const zh = { testConnectionSuccess: '测试连接成功', refreshSuccess: '刷新成功', refreshFailed: '刷新失败', + + // Market 相关 + marketSelectTitle: '选择一个 MCP 市场', + marketSelectDesc: '从左侧选择一个市场源,配置连接后即可浏览该市场的 MCP 服务', + marketRefreshSuccess: '列表已刷新', + marketActivated: '已激活', + marketInDatabase: '已入库', + marketAdd: '添加', + marketRefresh: '刷新', + marketConfig: '配置', + marketConfigConnection: '配置连接', + marketNoServices: '暂无可用的 MCP 服务', + marketNotConnected: '尚未连接此市场', + marketNoServicesDesc: '该市场暂时没有可用的服务', + marketNotConnectedDesc: '点击右上角"配置"按钮设置连接信息', + marketSearchPlaceholder: '搜索服务...', + marketVisit: '前往市场', serviceEndpoint: '服务端点 URL', serviceEndpointPlaceholder: '服务端点的 URL', serviceEndpointExtra: 'MCP服务的完整访问地址', diff --git a/web/src/views/ToolManagement/Market.tsx b/web/src/views/ToolManagement/Market.tsx index 00dea715..f6af8404 100644 --- a/web/src/views/ToolManagement/Market.tsx +++ b/web/src/views/ToolManagement/Market.tsx @@ -200,7 +200,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => await fetchMcpList(sourceId, 1); const source = marketSources.find(s => s.id === sourceId); if (source) { - message.success(`${source.name} 列表已刷新`); + message.success(`${source.name} ${t('tool.marketRefreshSuccess')}`); } }; @@ -281,8 +281,8 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => return (
🏪
-

选择一个 MCP 市场

-

从左侧选择一个市场源,配置连接后即可浏览该市场的 MCP 服务

+

{t('tool.marketSelectTitle')}

+

{t('tool.marketSelectDesc')}

); } @@ -333,13 +333,13 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
{source.connected && ( )} {mcpList.length > 0 && ( } - placeholder="搜索服务..." + placeholder={t('tool.marketSearchPlaceholder')} value={searchKeyword} onChange={(e) => setSearchKeyword(e.target.value)} style={{ width: 200 }} @@ -347,10 +347,10 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => )}
@@ -414,11 +414,11 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
- {mcp.activated && 已激活} - {mcp.inDatabase && 已入库} + {mcp.activated && {t('tool.marketActivated')}} + {mcp.inDatabase && {t('tool.marketInDatabase')}}
@@ -430,14 +430,14 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
{source.connected ? '📭' : '🔌'}

- {source.connected ? '暂无可用的 MCP 服务' : '尚未连接此市场'} + {source.connected ? t('tool.marketNoServices') : t('tool.marketNotConnected')}

- {source.connected ? '该市场暂时没有可用的服务' : '点击右上角"配置"按钮设置连接信息'} + {source.connected ? t('tool.marketNoServicesDesc') : t('tool.marketNotConnectedDesc')}

{!source.connected && ( )}
From 0f221b7ee6196c07d035d9dbe3f50f7ccb326232 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Mon, 9 Mar 2026 16:45:48 +0800 Subject: [PATCH 81/89] fix:loading --- .../KnowledgeBase/components/ShareModal.tsx | 27 +++++++----- .../components/ShareSpaceModal.tsx | 44 ++++++++++--------- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/web/src/views/KnowledgeBase/components/ShareModal.tsx b/web/src/views/KnowledgeBase/components/ShareModal.tsx index dc4a732a..2f2cbb7a 100644 --- a/web/src/views/KnowledgeBase/components/ShareModal.tsx +++ b/web/src/views/KnowledgeBase/components/ShareModal.tsx @@ -4,7 +4,7 @@ * @Author: yujiangping * @Date: 2025-11-10 18:52:55 * @LastEditors: yujiangping - * @LastEditTime: 2026-03-03 14:46:08 + * @LastEditTime: 2026-03-09 16:39:07 */ import { forwardRef, useImperativeHandle, useState, useRef } from 'react'; import { Switch } from 'antd'; @@ -58,16 +58,21 @@ const ShareModal = forwardRef(({ handleShare: } const handleShare = async() => { - const workspaceIds = spaceList - .map(item => item.target_kb?.workspace_id) - .filter(Boolean) - .join(','); - - console.log('Workspace IDs:', workspaceIds); - shareSpaceModalRef?.current?.handleOpen(kbId,knowledgeBase,workspaceIds); - - // Close modal after sharing - handleClose(); + setLoading(true); + try { + const workspaceIds = spaceList + .map(item => item.target_kb?.workspace_id) + .filter(Boolean) + .join(','); + + console.log('Workspace IDs:', workspaceIds); + shareSpaceModalRef?.current?.handleOpen(kbId,knowledgeBase,workspaceIds); + + // Close modal after sharing + handleClose(); + } finally { + setLoading(false); + } } const handleChange = (checked: boolean, item: any) => { // Toggle shared knowledge base status diff --git a/web/src/views/KnowledgeBase/components/ShareSpaceModal.tsx b/web/src/views/KnowledgeBase/components/ShareSpaceModal.tsx index db26fd93..f75a47d6 100644 --- a/web/src/views/KnowledgeBase/components/ShareSpaceModal.tsx +++ b/web/src/views/KnowledgeBase/components/ShareSpaceModal.tsx @@ -4,7 +4,7 @@ * @Author: yujiangping * @Date: 2025-11-10 18:52:55 * @LastEditors: yujiangping - * @LastEditTime: 2025-12-03 18:44:58 + * @LastEditTime: 2026-03-09 16:34:51 */ import { forwardRef, useImperativeHandle, useState } from 'react'; import { Switch } from 'antd'; @@ -50,34 +50,38 @@ const ShareModal = forwardRef(({ handleShare: setSpaceList(filteredItems as SpaceItem[]); } const handleShare = async() => { - // Get all data with checked = true const checkedItems = spaceList.filter(item => item.is_active); - debugger // Get currently selected item (corresponding to curIndex) const selectedItem = curIndex !== -1 ? spaceList[curIndex] : null; if(!selectedItem){ messageApi.error(t('knowledgeBase.selectSpace')); return; } - const payload = { - source_kb_id: kbId ?? '', - target_workspace_id: selectedItem?.id ?? '', - } - const respose = await shareKnowledgeBase(payload) - if(respose){ - messageApi.success(t('knowledgeBase.shareSuccess')); - }else{ - messageApi.error(t('knowledgeBase.shareFailed')); - } - // Call parent component's callback function with selected data - onShare?.({ - checkedItems, - selectedItem - }); - // Close modal after sharing - handleClose(); + setLoading(true); + try { + const payload = { + source_kb_id: kbId ?? '', + target_workspace_id: selectedItem?.id ?? '', + } + const respose = await shareKnowledgeBase(payload) + if(respose){ + messageApi.success(t('knowledgeBase.shareSuccess')); + }else{ + messageApi.error(t('knowledgeBase.shareFailed')); + } + // Call parent component's callback function with selected data + onShare?.({ + checkedItems, + selectedItem + }); + + // Close modal after sharing + handleClose(); + } finally { + setLoading(false); + } } const handleClick = (index: number, checked: boolean) => { if (!checked) return; From 33d12c43b27c4308ae04734b0f9f967298fb7387 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 9 Mar 2026 17:30:43 +0800 Subject: [PATCH 82/89] =?UTF-8?q?feat(web):=20=E6=B3=A8=E9=87=8A=E8=8A=82?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/i18n/en.ts | 7 + web/src/i18n/zh.ts | 7 + .../Workflow/components/CanvasToolbar.tsx | 10 +- .../NoteNode/NoteEditor/NoteFormatPlugin.tsx | 108 ++++++++++ .../NoteNode/NoteEditor/NoteLinkPopovers.tsx | 74 +++++++ .../Nodes/NoteNode/NoteEditor/index.tsx | 184 ++++++++++++++++++ .../Nodes/NoteNode/NoteNodeToolbar.tsx | 163 ++++++++++++++++ .../components/Nodes/NoteNode/index.tsx | 155 +++++++++++++++ web/src/views/Workflow/constant.ts | 81 +++++++- .../views/Workflow/hooks/useWorkflowGraph.ts | 77 +++++++- web/src/views/Workflow/index.tsx | 4 +- 11 files changed, 857 insertions(+), 13 deletions(-) create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/NoteFormatPlugin.tsx create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/NoteLinkPopovers.tsx create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/index.tsx create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/NoteNodeToolbar.tsx create mode 100644 web/src/views/Workflow/components/Nodes/NoteNode/index.tsx diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ad9680d3..6e9239d6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2008,6 +2008,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re self_optimization: 'Self Optimization', process_evolution: 'Process Evolution', unknown: 'Unknown Node', + notes: 'Sticky Note', clickToConfigure: 'Click to configure node parameters', nodeProperties: 'Node Properties', @@ -2195,6 +2196,12 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re output_variables: 'Output Variables', refreshTip: 'Sync function signature to code', }, + notes: { + showAuth: 'Show Author', + enterLink: 'Enter Link URL', + placeholder: 'Enter note...', + removeLink: 'Remove Link', + }, name: 'Key', type: 'Type', value: 'Value', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c4d2df71..45825a09 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2004,6 +2004,7 @@ export const zh = { self_optimization: '自我优化', process_evolution: '流程演化', unknown: '未知节点', + notes: '便签', clickToConfigure: '点击配置节点参数', nodeProperties: '节点属性', @@ -2194,6 +2195,12 @@ export const zh = { unknown: { replaceNodeType: '替换节点' }, + notes: { + showAuth: '显示作者', + enterLink: '输入链接 URL', + placeholder: '输入注释...', + removeLink: '取消链接', + }, name: '键', type: '类型', value: '值', diff --git a/web/src/views/Workflow/components/CanvasToolbar.tsx b/web/src/views/Workflow/components/CanvasToolbar.tsx index 8ca272e1..8bf2c641 100644 --- a/web/src/views/Workflow/components/CanvasToolbar.tsx +++ b/web/src/views/Workflow/components/CanvasToolbar.tsx @@ -1,8 +1,8 @@ import type { FC } from 'react'; -import { Select } from 'antd'; +import { Select, Divider } from 'antd'; // import { Node } from '@antv/x6'; import type { GraphRef } from '../types' -import { PlusOutlined, MinusOutlined } from '@ant-design/icons' +import { PlusOutlined, MinusOutlined, FileAddOutlined } from '@ant-design/icons' interface CanvasToolbarProps { miniMapRef: React.RefObject; @@ -14,6 +14,7 @@ interface CanvasToolbarProps { canRedo: boolean; onUndo: () => void; onRedo: () => void; + addNotes: () => void; } const CanvasToolbar: FC = ({ @@ -26,6 +27,7 @@ const CanvasToolbar: FC = ({ // canRedo, // onUndo, // onRedo, + addNotes, }) => { // 整理布局函数 /* @@ -152,7 +154,7 @@ const CanvasToolbar: FC = ({ {/* 小地图 */}
{/* 缩放控制按钮 */} -
+
graphRef.current?.zoom(-0.1)} /> setUrl(e.target.value)} + onKeyDown={e => e.stopPropagation()} + onPressEnter={confirm} + autoFocus + /> + + +
, + document.body + ); +}; diff --git a/web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/index.tsx b/web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/index.tsx new file mode 100644 index 00000000..06984d35 --- /dev/null +++ b/web/src/views/Workflow/components/Nodes/NoteNode/NoteEditor/index.tsx @@ -0,0 +1,184 @@ +import { type FC, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { ListPlugin } from '@lexical/react/LexicalListPlugin'; +import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; +import { ListNode, ListItemNode } from '@lexical/list'; +import { LinkNode } from '@lexical/link'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useEffect, useRef } from 'react'; +import NoteFormatPlugin from './NoteFormatPlugin'; +import type { FormatState } from './NoteFormatPlugin'; +import { LinkPopover, EditLinkPopover } from './NoteLinkPopovers'; + +const theme = { + paragraph: 'editor-paragraph', + text: { + bold: 'editor-text-bold', + italic: 'editor-text-italic', + strikethrough: 'note-text-strikethrough', + }, + list: { ul: 'note-list-ul', listitem: 'note-list-item' }, + link: 'note-link', +}; + +const NOTE_NODES = [ListNode, ListItemNode, LinkNode]; + +const NOTE_STYLES = ` + .editor-text-bold { font-weight: bold; } + .editor-text-italic { font-style: italic; } + .note-text-strikethrough { text-decoration: line-through; } + .note-list-ul { list-style-type: disc; padding-left: 1.2em; margin: 0; } + .note-list-item { margin: 2px 0; } + .note-link { color: #2563eb; text-decoration: underline; cursor: pointer; } +`; + +const NoteInitPlugin: FC<{ value: string }> = ({ value }) => { + const [editor] = useLexicalComposerContext(); + const initialized = useRef(false); + useEffect(() => { + if (initialized.current || !value) return; + initialized.current = true; + try { + const parsed = JSON.parse(value); + if (parsed?.root) { + const state = editor.parseEditorState(JSON.stringify(parsed)); + editor.setEditorState(state); + return; + } + } catch {} + }, [editor, value]); + return null; +}; + + +interface NoteEditorProps { + nodeId: string; + value: string; + fontSize?: number; + onChange: (val: string) => void; + onFormatChange?: (state: FormatState) => void; +} + +const NoteEditor: FC = ({ nodeId, value, fontSize = 12, onChange, onFormatChange }) => { + const { t } = useTranslation(); + const [linkState, setLinkState] = useState<{ url: string; rect: DOMRect } | null>(null); + const [editLinkRect, setEditLinkRect] = useState<{ url: string; rect: DOMRect } | null>(null); + const removingLink = useRef(false); + + useEffect(() => { + if (!linkState) return; + const handler = () => setLinkState(null); + window.addEventListener('mousedown', handler); + return () => window.removeEventListener('mousedown', handler); + }, [!!linkState]); + + useEffect(() => { + const handler = (e: Event) => { + const { id, url, rect: passedRect } = (e as CustomEvent).detail; + if (id !== nodeId) return; + if (passedRect) { + setEditLinkRect({ url: url || '', rect: passedRect }); + return; + } + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const r = sel.getRangeAt(0).getBoundingClientRect(); + if (r.width > 0 || r.height > 0) { setEditLinkRect({ url: url || '', rect: r }); return; } + } + const linkEl = document.querySelector(`[data-note-id="${nodeId}"] a.note-link`) as HTMLElement; + const rect = linkEl?.getBoundingClientRect() ?? new DOMRect(window.innerWidth / 2, 200, 0, 0); + setEditLinkRect({ url: url || '', rect }); + }; + window.addEventListener('note:edit-link', handler); + return () => window.removeEventListener('note:edit-link', handler); + }, [nodeId]); + + const handleFormatChange = useCallback((state: FormatState) => { + onFormatChange?.(state); + if (state.linkUrl) { + requestAnimationFrame(() => { + if (removingLink.current) { removingLink.current = false; return; } + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const rect = sel.getRangeAt(0).getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) { + setLinkState({ url: state.linkUrl!, rect }); + return; + } + } + // fallback: find the link element in the correct editor + const editorEl = document.querySelector(`[data-note-id="${nodeId}"] a.note-link`) as HTMLElement; + if (editorEl) { + setLinkState({ url: state.linkUrl!, rect: editorEl.getBoundingClientRect() }); + } + }); + } else { + setLinkState(null); + } + }, [onFormatChange]); + + return ( + <> + + +
+ + } + placeholder={ +
+ {t('workflow.config.notes.placeholder')} +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + onChange(JSON.stringify(editorState.toJSON()))} /> + + + {editLinkRect && ( + { + removingLink.current = true; + window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: url || null } })); + setEditLinkRect(null); + }} + /> + )} + {linkState && ( + { + removingLink.current = true; + const { rect, url } = linkState; + setLinkState(null); + setEditLinkRect({ url, rect }); + }} + onRemove={() => { + removingLink.current = true; + setLinkState(null); + window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: null } })); + }} + /> + )} +
+
+ + ); +}; + +export default NoteEditor; diff --git a/web/src/views/Workflow/components/Nodes/NoteNode/NoteNodeToolbar.tsx b/web/src/views/Workflow/components/Nodes/NoteNode/NoteNodeToolbar.tsx new file mode 100644 index 00000000..52c0eb22 --- /dev/null +++ b/web/src/views/Workflow/components/Nodes/NoteNode/NoteNodeToolbar.tsx @@ -0,0 +1,163 @@ +import { type FC } from 'react'; +import { Flex, Dropdown, type MenuProps, Switch, Button, Divider } from 'antd'; +import { UnorderedListOutlined, BoldOutlined, ItalicOutlined, StrikethroughOutlined, LinkOutlined, DashOutlined } from '@ant-design/icons'; +import { Node } from '@antv/x6'; +import { useTranslation } from 'react-i18next' + +import { THEME_MAP } from '../../../constant'; +const FONT_SIZES = [ + { label: '小', value: 12 }, + { label: '中', value: 14 }, + { label: '大', value: 16 }, +]; + +interface NoteNodeToolbarProps { + node: Node; + onFormat: (type: string, value?: unknown) => void; + toolConfig: Record; + nodeId: string; +} + +const NoteNodeToolbar: FC = ({ node, onFormat, toolConfig, nodeId }) => { + const data = node?.getData() || {}; + const { t } = useTranslation(); + + const colorItems: MenuProps['items'] = Object.entries(THEME_MAP).map(([key, theme]) => ({ + key, + label: ( +
onFormat('color', key)} + /> + ), + })); + + const fontSizeItems: MenuProps['items'] = FONT_SIZES.map(({ label, value }) => ({ + key: value, + label: onFormat('fontSize', value)}>{label}, + })); + + const currentFontSize = FONT_SIZES.find(f => f.value === toolConfig.fontSize)?.label ?? '小'; + + const handleClick: MenuProps['onClick'] = (e) => { + switch (e.key) { + case 'delete': + node.remove() + break; + case 'copy': + break; + } + } + const handleChange = (type: string) => { + let show_author = data.config.show_author.defaultValue + if(type === 'showAuth'){ + show_author = !show_author + } + node.setData({ + ...data, + config: { + ...data.config, + show_author: { + ...data.config.show_author, + defaultValue: show_author + } + } + }) + } + + return ( + e.stopPropagation()} + > + {/* Color picker */} + +
+ + + + + {/* Font size */} + + + Aa + {currentFontSize} + + + + + + {/* Bold */} +
From ac86bbd60c780f2c3ebc1d1a4e6be78297043b59 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 9 Mar 2026 17:35:56 +0800 Subject: [PATCH 83/89] =?UTF-8?q?feat(web):=20=E8=B0=83=E6=95=B4=E4=BE=BF?= =?UTF-8?q?=E7=AD=BE=E8=8A=82=E7=82=B9=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index e654e4e9..db792c59 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -1152,12 +1152,15 @@ export const useWorkflowGraph = ({ name: t('workflow.notes'), ...nodeConfig, }; - const area = graphRef.current.getGraphArea(); - const pos = graphRef.current.graphToLocal(area.center.x, area.bottom); + const container = graphRef.current.container; + const nodeW = graphNodeLibrary.notes?.width || nodeWidth; + const nodeH = graphNodeLibrary.notes?.height || 100; + const rect = container.getBoundingClientRect(); + const center = graphRef.current.clientToLocal(rect.left + rect.width / 2, rect.top + rect.height / 2); graphRef.current.addNode({ ...(graphNodeLibrary.notes || graphNodeLibrary.default), - x: pos.x - nodeWidth, - y: pos.y, + x: center.x - nodeW / 2, + y: center.y - nodeH / 2, id: cleanNodeData.id, data: { ...cleanNodeData }, }); From 9fe47e2fb25b4efaafa6c2d25b36655d06ab6250 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 9 Mar 2026 19:07:09 +0800 Subject: [PATCH 84/89] fix(memory_agent): handle draft run without current release - Add TODO comment to verify end_user sources (chat, draft, apikey) - Comment out release validation check to support draft run mode - Add TODO note explaining temporary fix for draft execution - Handle null current_release_id in result by returning None instead of failing - Improve import formatting for MemoryConfig model import statement - Allow configuration retrieval when app has no published release --- api/app/services/memory_agent_service.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index f272c541..a20b968a 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -1165,6 +1165,7 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An logger.info(f"Getting connected config for end_user: {end_user_id}") + # TODO: check sources for enduserid, should be one of these three: chat, draft, apikey # 1. 获取 end_user 及其 app_id end_user = db.query(EndUser).filter(EndUser.id == end_user_id).first() if not end_user: @@ -1179,10 +1180,10 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An if not app: logger.warning(f"App not found: {app_id}") raise ValueError(f"应用不存在: {app_id}") - - if not app.current_release_id: - logger.warning(f"No current release for app: {app_id}") - raise ValueError(f"应用未发布: {app_id}") + # TODO: temp fix for draft run + # if not app.current_release_id: + # logger.warning(f"No current release for app: {app_id}") + # raise ValueError(f"应用未发布: {app_id}") # 3. 兼容旧数据:如果 memory_config_id 为空,从 AppRelease.config 获取并回填 memory_config_id_to_use = end_user.memory_config_id @@ -1223,7 +1224,9 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An if legacy_config_id: # 验证提取的 config_id 是否存在于数据库中 - from app.models.memory_config_model import MemoryConfig as MemoryConfigModel + from app.models.memory_config_model import ( + MemoryConfig as MemoryConfigModel, + ) existing_config = db.get(MemoryConfigModel, legacy_config_id) if existing_config: @@ -1257,7 +1260,7 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An result = { "end_user_id": str(end_user_id), "app_id": str(app_id), - "release_id": str(app.current_release_id), + "release_id": str(app.current_release_id) if app.current_release_id else None, "memory_config_id": memory_config_id, "workspace_id": str(app.workspace_id) } From f405ac4d843cb3bd7016b2a73ba8a82c451aacea Mon Sep 17 00:00:00 2001 From: yujiangping Date: Mon, 9 Mar 2026 19:10:39 +0800 Subject: [PATCH 85/89] fix:next button --- .../[knowledgeBaseId]/CreateDataset.tsx | 18 ++++++++-- web/src/views/ToolManagement/Market.tsx | 33 ++++++++----------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx b/web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx index 760c4292..b336fe04 100644 --- a/web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx +++ b/web/src/views/KnowledgeBase/[knowledgeBaseId]/CreateDataset.tsx @@ -82,6 +82,7 @@ const CreateDataset = () => { const [form] = Form.useForm(); const [data, setData] = useState([]); const [rechunkFileIds, setRechunkFileIds] = useState(initialFileIds); + const [textFormValid, setTextFormValid] = useState(false); const [pollingLoading, setPollingLoading] = useState(false); const pollingTimerRef = useRef | null>(null); @@ -624,7 +625,16 @@ const CreateDataset = () => { )} {source && source === 'text' && (
-
+ { + // 检查表单字段是否都已填写 + const values = form.getFieldsValue(); + const isValid = !!(values.title?.trim() && values.content?.trim()); + setTextFormValid(isValid); + }} + > { diff --git a/web/src/views/ToolManagement/Market.tsx b/web/src/views/ToolManagement/Market.tsx index f6af8404..5297903e 100644 --- a/web/src/views/ToolManagement/Market.tsx +++ b/web/src/views/ToolManagement/Market.tsx @@ -6,7 +6,10 @@ import InfiniteScroll from 'react-infinite-scroll-component'; import MarketConfigModal, { type MarketConfigModalRef } from './components/MarketConfigModal'; import McpServiceModal from './components/McpServiceModal'; import type { McpServiceModalRef } from './types'; +import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png' +import Empty from '@/components/Empty/index' import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools'; +import BodyWrapper from '@/components/Empty/BodyWrapper'; interface MarketSource { id: string; name: string; @@ -280,9 +283,14 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => if (!selectedSource) { return (
-
🏪
-

{t('tool.marketSelectTitle')}

-

{t('tool.marketSelectDesc')}

+ +
); } @@ -356,7 +364,7 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
- {mcpList.length > 0 ? ( +
ReactNode }> = () =>
- ) : ( -
-
{source.connected ? '📭' : '🔌'}
-

- {source.connected ? t('tool.marketNoServices') : t('tool.marketNotConnected')} -

-

- {source.connected ? t('tool.marketNoServicesDesc') : t('tool.marketNotConnectedDesc')} -

- {!source.connected && ( - - )} -
- )} +
); From 7cdbbefc64f6d070bb61459a12748b751d45a854 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 10 Mar 2026 10:44:24 +0800 Subject: [PATCH 86/89] feat(workspace, app, agent): add duplicate name validation and restrict model/memory config on agent publish --- api/app/core/config.py | 3 +- api/app/repositories/app_repository.py | 23 +- api/app/repositories/workspace_repository.py | 111 ++++++--- api/app/services/app_service.py | 15 +- api/app/services/workspace_service.py | 241 ++++++++++--------- 5 files changed, 233 insertions(+), 160 deletions(-) diff --git a/api/app/core/config.py b/api/app/core/config.py index bbe327b6..3bc77376 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -1,7 +1,6 @@ -import json import os from pathlib import Path -from typing import Annotated, Any, Dict, Optional +from typing import Annotated, Optional from dotenv import load_dotenv from pydantic import Field, TypeAdapter diff --git a/api/app/repositories/app_repository.py b/api/app/repositories/app_repository.py index 0c7ba6a4..75a91fd6 100644 --- a/api/app/repositories/app_repository.py +++ b/api/app/repositories/app_repository.py @@ -1,10 +1,11 @@ -from sqlalchemy.orm import Session -from typing import List, Optional import uuid +from typing import List -from app.models.app_model import App +from sqlalchemy import select +from sqlalchemy.orm import Session from app.core.logging_config import get_db_logger +from app.models.app_model import App # 获取数据库专用日志器 db_logger = get_db_logger() @@ -35,11 +36,27 @@ class AppRepository: except Exception as e: raise + def get_apps_by_name(self, app_name: str, app_type: str, workspace_id: uuid.UUID) -> List[App]: + try: + stmt = select(App).where( + App.name == app_name, + App.workspace_id == workspace_id, + App.type == app_type, + App.is_active.is_(True), + ) + apps = self.db.execute(stmt).scalars().all() + return list(apps) + except Exception as e: + db_logger.error(f"查询名称 {app_name} 应用异常: {str(e)}") + raise + + def get_apps_by_workspace_id(db: Session, workspace_id: uuid.UUID) -> List[App]: """根据工作空间ID查询应用""" repo = AppRepository(db) return repo.get_apps_by_workspace_id(workspace_id) + def get_apps_by_id(db: Session, app_id: uuid.UUID) -> App: """根据工作空间ID查询应用""" repo = AppRepository(db) diff --git a/api/app/repositories/workspace_repository.py b/api/app/repositories/workspace_repository.py index 87b0e20f..68dbf13c 100644 --- a/api/app/repositories/workspace_repository.py +++ b/api/app/repositories/workspace_repository.py @@ -1,10 +1,13 @@ -from sqlalchemy.orm import Session, joinedload -from app.models.user_model import User -from typing import List, Optional import uuid -from app.models.workspace_model import Workspace, WorkspaceMember, WorkspaceRole -from app.schemas.workspace_schema import WorkspaceCreate, WorkspaceUpdate +from typing import List, Optional + +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import select + from app.core.logging_config import get_db_logger +from app.models.user_model import User +from app.models.workspace_model import Workspace, WorkspaceMember, WorkspaceRole +from app.schemas.workspace_schema import WorkspaceCreate # 获取数据库专用日志器 db_logger = get_db_logger() @@ -19,7 +22,7 @@ class WorkspaceRepository: def create_workspace(self, workspace_data: WorkspaceCreate, tenant_id: uuid.UUID) -> Workspace: """创建工作空间""" db_logger.debug(f"创建工作空间记录: name={workspace_data.name}, tenant_id={tenant_id}") - + try: db_workspace = Workspace( name=workspace_data.name, @@ -34,7 +37,8 @@ class WorkspaceRepository: ) self.db.add(db_workspace) self.db.flush() - db_logger.info(f"工作空间记录创建成功: {workspace_data.name} (ID: {db_workspace.id}), storage_type: {workspace_data.storage_type}") + db_logger.info( + f"工作空间记录创建成功: {workspace_data.name} (ID: {db_workspace.id}), storage_type: {workspace_data.storage_type}") return db_workspace except Exception as e: db_logger.error(f"创建工作空间记录失败: name={workspace_data.name} - {str(e)}") @@ -43,7 +47,7 @@ class WorkspaceRepository: def get_workspace_by_id(self, workspace_id: uuid.UUID) -> Optional[Workspace]: """根据ID获取工作空间""" db_logger.debug(f"根据ID查询工作空间: workspace_id={workspace_id}") - + try: workspace = self.db.query(Workspace).filter(Workspace.id == workspace_id).first() if workspace: @@ -65,7 +69,7 @@ class WorkspaceRepository: 包含 llm, embedding, rerank 的字典,如果工作空间不存在则返回 None """ db_logger.debug(f"查询工作空间模型配置: workspace_id={workspace_id}") - + try: workspace = self.db.query(Workspace).filter(Workspace.id == workspace_id).first() if workspace: @@ -89,7 +93,7 @@ class WorkspaceRepository: def get_workspaces_by_user(self, user_id: uuid.UUID) -> List[Workspace]: """获取用户参与的所有工作空间(包括用户创建的和作为成员的)""" db_logger.debug(f"查询用户参与的工作空间: user_id={user_id}") - + try: # 首先获取用户信息以获取 tenant_id from app.models.user_model import User @@ -97,7 +101,7 @@ class WorkspaceRepository: if not user: db_logger.warning(f"用户不存在: user_id={user_id}") return [] - + if user.is_superuser: # 超级用户获取对应tenantid所有工作空间 workspaces = ( @@ -109,7 +113,7 @@ class WorkspaceRepository: ) db_logger.debug(f"超用户查询所有工作空间: user_id={user_id}, 数量={len(workspaces)}") return workspaces - + # 获取用户作为成员的工作空间 member_workspaces = ( self.db.query(Workspace) @@ -120,7 +124,7 @@ class WorkspaceRepository: .order_by(Workspace.updated_at.desc()) .all() ) - + db_logger.debug(f"用户工作空间查询成功: user_id={user_id}, 数量={len(member_workspaces)}") return member_workspaces except Exception as e: @@ -130,7 +134,7 @@ class WorkspaceRepository: def get_workspaces_by_tenant(self, tenant_id: uuid.UUID) -> List[Workspace]: """获取租户的所有工作空间""" db_logger.debug(f"查询租户的工作空间: tenant_id={tenant_id}") - + try: workspaces = ( self.db.query(Workspace) @@ -144,14 +148,32 @@ class WorkspaceRepository: db_logger.error(f"查询租户工作空间失败: tenant_id={tenant_id} - {str(e)}") raise - def add_member(self, workspace_id: uuid.UUID, user_id: uuid.UUID, role: WorkspaceRole = WorkspaceRole.member) -> WorkspaceMember: + def get_workspaces_by_name(self, tenant_id: uuid.UUID, workspace_name: str) -> List[Workspace]: + try: + stmt = ( + select(Workspace) + .where( + Workspace.tenant_id == tenant_id, + Workspace.name == workspace_name, + Workspace.is_active.is_(True) + ) + ) + + workspaces = self.db.execute(stmt).scalars().all() + return list(workspaces) + except Exception as e: + db_logger.error(f"查询工作空间失败: workspace_name={workspace_name} - {str(e)}") + raise + + def add_member(self, workspace_id: uuid.UUID, user_id: uuid.UUID, + role: WorkspaceRole = WorkspaceRole.member) -> WorkspaceMember: """添加工作空间成员""" db_logger.debug(f"添加工作空间成员: user_id={user_id}, workspace_id={workspace_id}, role={role}") - + try: db_member = WorkspaceMember( - user_id=user_id, - workspace_id=workspace_id, + user_id=user_id, + workspace_id=workspace_id, role=role ) self.db.add(db_member) @@ -165,7 +187,7 @@ class WorkspaceRepository: def get_member(self, user_id: uuid.UUID, workspace_id: uuid.UUID) -> Optional[WorkspaceMember]: """获取工作空间成员""" db_logger.debug(f"查询工作空间成员: user_id={user_id}, workspace_id={workspace_id}") - + try: member = self.db.query(WorkspaceMember).filter( WorkspaceMember.user_id == user_id, @@ -173,7 +195,8 @@ class WorkspaceRepository: WorkspaceMember.is_active.is_(True), ).first() if member: - db_logger.debug(f"工作空间成员查询成功: user_id={user_id}, workspace_id={workspace_id}, role={member.role}") + db_logger.debug( + f"工作空间成员查询成功: user_id={user_id}, workspace_id={workspace_id}, role={member.role}") else: db_logger.debug(f"工作空间成员不存在: user_id={user_id}, workspace_id={workspace_id}") return member @@ -199,7 +222,7 @@ class WorkspaceRepository: except Exception as e: db_logger.error(f"查询成员列表失败: workspace_id={workspace_id} - {str(e)}") raise - + def get_member_by_id(self, member_id: uuid.UUID) -> WorkspaceMember: """按成员ID获取工作空间成员,并预加载 user 与 workspace 关系""" db_logger.debug(f"查询成员的工作空间: member_id={member_id}") @@ -214,7 +237,8 @@ class WorkspaceRepository: .first() ) if member: - db_logger.debug(f"成员查询成功: member_id={member_id}, workspace_id={member.workspace_id}, role={member.role}") + db_logger.debug( + f"成员查询成功: member_id={member_id}, workspace_id={member.workspace_id}, role={member.role}") else: db_logger.debug(f"成员不存在: member_id={member_id}") return member @@ -222,7 +246,8 @@ class WorkspaceRepository: db_logger.error(f"查询成员列表失败: member_id={member_id} - {str(e)}") raise - def update_member_role(self, workspace_id: uuid.UUID, user_id: uuid.UUID, role: WorkspaceRole) -> Optional[WorkspaceMember]: + def update_member_role(self, workspace_id: uuid.UUID, user_id: uuid.UUID, role: WorkspaceRole) -> Optional[ + WorkspaceMember]: try: member = self.db.query(WorkspaceMember).filter( WorkspaceMember.workspace_id == workspace_id, @@ -255,7 +280,7 @@ class WorkspaceRepository: except Exception as e: db_logger.error(f"删除成员失败: workspace_id={workspace_id}, user_id={user_id} - {str(e)}") raise - + def delete_member_by_id(self, member_id: uuid.UUID) -> Optional[WorkspaceMember]: try: member = self.db.query(WorkspaceMember).filter( @@ -271,7 +296,7 @@ class WorkspaceRepository: except Exception as e: db_logger.error(f"删除成员失败: id={member_id} - {str(e)}") raise - + def update_member_role_by_id(self, id: uuid.UUID, role: WorkspaceRole) -> Optional[WorkspaceMember]: try: member = self.db.query(WorkspaceMember).filter( @@ -288,12 +313,18 @@ class WorkspaceRepository: db_logger.error(f"更新成员角色失败: id={id} - {str(e)}") raise + # 保持向后兼容的函数 def get_workspace_by_id(db: Session, workspace_id: uuid.UUID) -> Workspace | None: repo = WorkspaceRepository(db) return repo.get_workspace_by_id(workspace_id) +def get_workspaces_by_name(db: Session, tenant_id: uuid.UUID, name: str) -> List[Workspace]: + repo = WorkspaceRepository(db) + return repo.get_workspaces_by_name(tenant_id, name) + + def get_workspaces_by_user(db: Session, user_id: uuid.UUID) -> List[Workspace]: repo = WorkspaceRepository(db) return repo.get_workspaces_by_user(user_id) @@ -315,7 +346,7 @@ def create_workspace(db: Session, workspace: WorkspaceCreate, tenant_id: uuid.UU def add_member_to_workspace( - db: Session, user_id: uuid.UUID, workspace_id: uuid.UUID, role: WorkspaceRole + db: Session, user_id: uuid.UUID, workspace_id: uuid.UUID, role: WorkspaceRole ) -> WorkspaceMember: repo = WorkspaceRepository(db) return repo.add_member(workspace_id, user_id, role) @@ -325,39 +356,43 @@ def get_members_by_workspace(db: Session, workspace_id: uuid.UUID) -> List[Works repo = WorkspaceRepository(db) return repo.get_members_by_workspace(workspace_id) + def get_member_by_id(db: Session, member_id: uuid.UUID) -> WorkspaceMember | None: repo = WorkspaceRepository(db) return repo.get_member_by_id(member_id) + def update_member_role_in_workspace( - db: Session, - user_id: uuid.UUID, - workspace_id: uuid.UUID, - role: WorkspaceRole, + db: Session, + user_id: uuid.UUID, + workspace_id: uuid.UUID, + role: WorkspaceRole, ) -> Optional[WorkspaceMember]: repo = WorkspaceRepository(db) return repo.update_member_role(workspace_id, user_id, role) + def remove_member_from_workspace( - db: Session, - user_id: uuid.UUID, - workspace_id: uuid.UUID, + db: Session, + user_id: uuid.UUID, + workspace_id: uuid.UUID, ) -> Optional[WorkspaceMember]: repo = WorkspaceRepository(db) return repo.deactivate_member(workspace_id, user_id) + def remove_member_from_workspace_by_id( - db: Session, - member_id: uuid.UUID, + db: Session, + member_id: uuid.UUID, ) -> Optional[WorkspaceMember]: repo = WorkspaceRepository(db) return repo.delete_member_by_id(member_id) def update_member_role_by_id( - db: Session, - id: uuid.UUID, - role: WorkspaceRole, + db: Session, + id: uuid.UUID, + role: WorkspaceRole, ) -> Optional[WorkspaceMember]: repo = WorkspaceRepository(db) return repo.update_member_role_by_id(id, role) diff --git a/api/app/services/app_service.py b/api/app/services/app_service.py index 5a799937..88582cee 100644 --- a/api/app/services/app_service.py +++ b/api/app/services/app_service.py @@ -33,7 +33,7 @@ from app.models import ( Workspace, ) from app.models.app_model import AppStatus, AppType -from app.repositories.app_repository import get_apps_by_id +from app.repositories.app_repository import get_apps_by_id, AppRepository from app.repositories.workflow_repository import WorkflowConfigRepository from app.schemas import app_schema from app.schemas.workflow_schema import WorkflowConfigUpdate @@ -59,6 +59,7 @@ class AppService: db: 数据库会话 """ self.db = db + self.app_repo = AppRepository(self.db) # ==================== 私有辅助方法 ==================== @@ -521,6 +522,9 @@ class AppService: "创建应用", extra={"app_name": data.name, "type": data.type, "workspace_id": str(workspace_id)} ) + apps = self.app_repo.get_apps_by_name(data.name, data.type, workspace_id) + if apps: + raise BusinessException(message="已存在同名应用", code=BizCode.RESOURCE_ALREADY_EXISTS) try: now = datetime.datetime.now() @@ -1368,6 +1372,15 @@ class AppService: if not agent_cfg: raise BusinessException("Agent 应用缺少配置,无法发布", BizCode.AGENT_CONFIG_MISSING) + miss_params = [] + if agent_cfg.default_model_config_id is None: + miss_params.append("model config") + + if agent_cfg.memory.get("enabled") and not agent_cfg.memory.get("memory_config_id"): + miss_params.append("memory config") + if miss_params: + raise BusinessException(f"{', '.join(miss_params)} is required") + config = { "system_prompt": agent_cfg.system_prompt, "model_parameters": model_parameters_to_dict(agent_cfg.model_parameters), diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 7861ef62..cefb8380 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -2,11 +2,11 @@ import datetime import hashlib import secrets import uuid -from os import getenv from typing import List, Optional from sqlalchemy.orm import Session +from app.config.default_ontology_initializer import DefaultOntologyInitializer from app.core.config import settings from app.core.error_codes import BizCode from app.core.exceptions import BusinessException, PermissionDeniedException @@ -30,17 +30,15 @@ from app.schemas.workspace_schema import ( WorkspaceModelsUpdate, WorkspaceUpdate, ) -from app.config.default_ontology_initializer import DefaultOntologyInitializer # 获取业务逻辑专用日志器 business_logger = get_business_logger() -from dotenv import load_dotenv -load_dotenv() + def switch_workspace( - db: Session, - workspace_id: uuid.UUID, - user: User, + db: Session, + workspace_id: uuid.UUID, + user: User, ): """切换工作空间""" business_logger.debug(f"用户 {user.username} 请求切换工作空间为 {workspace_id}") @@ -60,31 +58,32 @@ def switch_workspace( raise BusinessException(f"切换工作空间失败: {str(e)}", BizCode.INTERNAL_ERROR) -def delete_workspace_member( - db: Session, - workspace_id: uuid.UUID, - member_id: uuid.UUID, - user: User, - ): - """删除工作空间成员""" - business_logger.debug(f"用户 {user.username} 请求删除工作空间 {workspace_id} 的成员 {member_id}") - _check_workspace_admin_permission(db, workspace_id, user) - workspace_member = workspace_repository.get_member_by_id(db=db, member_id=member_id) - if not workspace_member: - raise BusinessException(f"工作空间成员 {member_id} 不存在", BizCode.WORKSPACE_NOT_FOUND) +def delete_workspace_member( + db: Session, + workspace_id: uuid.UUID, + member_id: uuid.UUID, + user: User, +): + """删除工作空间成员""" + business_logger.debug(f"用户 {user.username} 请求删除工作空间 {workspace_id} 的成员 {member_id}") + _check_workspace_admin_permission(db, workspace_id, user) + workspace_member = workspace_repository.get_member_by_id(db=db, member_id=member_id) + if not workspace_member: + raise BusinessException(f"工作空间成员 {member_id} 不存在", BizCode.WORKSPACE_NOT_FOUND) - if workspace_member.workspace_id != workspace_id: - raise BusinessException(f"工作空间成员 {member_id} 不存在于工作空间 {workspace_id}", BizCode.WORKSPACE_NOT_FOUND) + if workspace_member.workspace_id != workspace_id: + raise BusinessException(f"工作空间成员 {member_id} 不存在于工作空间 {workspace_id}", + BizCode.WORKSPACE_NOT_FOUND) - try: - workspace_member.is_active = False - workspace_member.user.current_workspace_id = None - db.commit() - business_logger.info(f"用户 {user.username} 成功删除工作空间 {workspace_id} 的成员 {member_id}") - except Exception as e: - db.rollback() - business_logger.error(f"删除工作空间成员失败 - 工作空间: {workspace_id}, 成员: {member_id}, 错误: {str(e)}") - raise BusinessException(f"删除工作空间成员失败: {str(e)}", BizCode.INTERNAL_ERROR) + try: + workspace_member.is_active = False + workspace_member.user.current_workspace_id = None + db.commit() + business_logger.info(f"用户 {user.username} 成功删除工作空间 {workspace_id} 的成员 {member_id}") + except Exception as e: + db.rollback() + business_logger.error(f"删除工作空间成员失败 - 工作空间: {workspace_id}, 成员: {member_id}, 错误: {str(e)}") + raise BusinessException(f"删除工作空间成员失败: {str(e)}", BizCode.INTERNAL_ERROR) def get_user_workspaces(db: Session, user: User) -> List[Workspace]: @@ -102,19 +101,19 @@ def get_user_workspaces(db: Session, user: User) -> List[Workspace]: """ business_logger.debug(f"获取用户工作空间列表: {user.username} (ID: {user.id})") workspaces = workspace_repository.get_workspaces_by_user(db=db, user_id=user.id) - + # Ensure each neo4j workspace has a default memory config for workspace in workspaces: if workspace.storage_type == 'neo4j': _ensure_default_memory_config(db, workspace) _ensure_default_ontology_scenes(db, workspace) - + business_logger.info(f"用户 {user.username} 的工作空间数量: {len(workspaces)}") return workspaces def _create_workspace_only( - db: Session, workspace: WorkspaceCreate, owner: User + db: Session, workspace: WorkspaceCreate, owner: User ) -> Workspace: business_logger.debug(f"创建工作空间: {workspace.name}, 创建者: {owner.username}") @@ -138,9 +137,14 @@ def create_workspace( f"创建工作空间: {workspace.name}, 创建者: {user.username}, " f"storage_type: {workspace.storage_type}" ) - llm=workspace.llm - embedding=workspace.embedding - rerank=workspace.rerank + if workspace_repository.get_workspaces_by_name(db=db, name=workspace.name, tenant_id=user.tenant_id): + raise BusinessException( + message="同名工作空间已存在", + code=BizCode.RESOURCE_ALREADY_EXISTS + ) + llm = workspace.llm + embedding = workspace.embedding + rerank = workspace.rerank try: # Create the workspace without adding any members business_logger.debug(f"创建工作空间: {workspace.name}") @@ -159,26 +163,26 @@ def create_workspace( success, error_msg = initializer.initialize_default_scenes( db_workspace.id, language=language ) - + if success: business_logger.info( f"为工作空间 {db_workspace.id} 创建默认本体场景成功 (language={language})" ) - - # 获取默认场景ID,优先使用"在线教育"场景,如果不存在则使用"情感陪伴"场景 + + # 获取默认场景ID,优先使用"在线教育"场景,如果不存在则使用"情感陪伴"场景 from app.repositories.ontology_scene_repository import OntologySceneRepository from app.config.default_ontology_config import ( - ONLINE_EDUCATION_SCENE, + ONLINE_EDUCATION_SCENE, EMOTIONAL_COMPANION_SCENE, get_scene_name ) - + scene_repo = OntologySceneRepository(db) - + # 优先尝试获取教育场景 education_scene_name = get_scene_name(ONLINE_EDUCATION_SCENE, language) education_scene = scene_repo.get_by_name(education_scene_name, db_workspace.id) - + if education_scene: default_scene_id = education_scene.scene_id default_scene_name = education_scene.scene_name @@ -189,7 +193,7 @@ def create_workspace( # 如果教育场景不存在,尝试获取情感陪伴场景 companion_scene_name = get_scene_name(EMOTIONAL_COMPANION_SCENE, language) companion_scene = scene_repo.get_by_name(companion_scene_name, db_workspace.id) - + if companion_scene: default_scene_id = companion_scene.scene_id default_scene_name = companion_scene.scene_name @@ -256,10 +260,10 @@ def create_workspace( avatar='', type=KnowledgeType.General, permission_id=PermissionType.Memory, - embedding_id=uuid.UUID(getenv('KB_embedding_id')) if None else embedding, - reranker_id=uuid.UUID(getenv('KB_reranker_id')) if None else rerank, - llm_id=uuid.UUID(getenv('KB_llm_id')) if None else llm, - image2text_id=uuid.UUID(getenv('KB_llm_id')) if None else llm, + embedding_id=embedding, + reranker_id=rerank, + llm_id=llm, + image2text_id=llm, parser_config={ "layout_recognize": "DeepDOC", "chunk_token_num": 256, @@ -294,7 +298,7 @@ def create_workspace( business_logger.info( f"工作空间 {db_workspace.id} 及相关资源创建完成并已提交" ) - + return db_workspace except Exception as e: @@ -304,11 +308,11 @@ def create_workspace( def update_workspace( - db: Session, workspace_id: uuid.UUID, workspace_in: WorkspaceUpdate, user: User + db: Session, workspace_id: uuid.UUID, workspace_in: WorkspaceUpdate, user: User ) -> Workspace: business_logger.info(f"更新工作空间: workspace_id={workspace_id}, 操作者: {user.username}") - db_workspace = _check_workspace_admin_permission(db,workspace_id,user) + db_workspace = _check_workspace_admin_permission(db, workspace_id, user) try: # 更新工作空间 business_logger.debug(f"执行工作空间更新: {db_workspace.name} (ID: {workspace_id})") @@ -328,7 +332,7 @@ def update_workspace( def get_workspace_members( - db: Session, workspace_id: uuid.UUID, user: User + db: Session, workspace_id: uuid.UUID, user: User ) -> List[WorkspaceMember]: """获取某工作空间的成员列表(关系序列化由模型关系支持)""" business_logger.info(f"获取工作空间成员: workspace_id={workspace_id}, 操作者: {user.username}") @@ -372,7 +376,6 @@ def get_workspace_members( return members - # ==================== 邀请相关服务方法 ==================== def _generate_invite_token() -> tuple[str, str]: @@ -465,13 +468,14 @@ def _check_workspace_admin_permission(db: Session, workspace_id: uuid.UUID, user def create_workspace_invite( - db: Session, - workspace_id: uuid.UUID, - invite_data: WorkspaceInviteCreate, - user: User + db: Session, + workspace_id: uuid.UUID, + invite_data: WorkspaceInviteCreate, + user: User ) -> WorkspaceInviteResponse: """创建工作空间邀请""" - business_logger.info(f"创建工作空间邀请: workspace_id={workspace_id}, email={invite_data.email}, 创建者: {user.username}") + business_logger.info( + f"创建工作空间邀请: workspace_id={workspace_id}, email={invite_data.email}, 创建者: {user.username}") try: # 检查权限 @@ -534,17 +538,18 @@ def create_workspace_invite( except Exception as e: db.rollback() - business_logger.error(f"创建工作空间邀请失败: workspace_id={workspace_id}, email={invite_data.email} - {str(e)}") + business_logger.error( + f"创建工作空间邀请失败: workspace_id={workspace_id}, email={invite_data.email} - {str(e)}") raise def get_workspace_invites( - db: Session, - workspace_id: uuid.UUID, - user: User, - status: Optional[InviteStatus] = None, - limit: int = 50, - offset: int = 0 + db: Session, + workspace_id: uuid.UUID, + user: User, + status: Optional[InviteStatus] = None, + limit: int = 50, + offset: int = 0 ) -> List[WorkspaceInviteResponse]: """获取工作空间邀请列表""" business_logger.info(f"获取工作空间邀请列表: workspace_id={workspace_id}, 操作者: {user.username}") @@ -605,9 +610,9 @@ def validate_invite_token(db: Session, token: str) -> InviteValidateResponse: def accept_workspace_invite( - db: Session, - accept_request: InviteAcceptRequest, - user: User + db: Session, + accept_request: InviteAcceptRequest, + user: User ) -> dict: """接受工作空间邀请""" business_logger.info(f"接受工作空间邀请: 用户 {user.username}") @@ -695,7 +700,8 @@ def accept_workspace_invite( # 获取工作空间信息 workspace = workspace_repository.get_workspace_by_id(db=db, workspace_id=invite.workspace_id) - business_logger.info(f"用户成功加入工作空间: user={user.username}, workspace={workspace.name}, role={workspace_role}") + business_logger.info( + f"用户成功加入工作空间: user={user.username}, workspace={workspace.name}, role={workspace_role}") return { "message": "Successfully joined the workspace", @@ -710,13 +716,14 @@ def accept_workspace_invite( def revoke_workspace_invite( - db: Session, - workspace_id: uuid.UUID, - invite_id: uuid.UUID, - user: User + db: Session, + workspace_id: uuid.UUID, + invite_id: uuid.UUID, + user: User ) -> dict: """撤销工作空间邀请""" - business_logger.info(f"撤销工作空间邀请: workspace_id={workspace_id}, invite_id={invite_id}, 操作者: {user.username}") + business_logger.info( + f"撤销工作空间邀请: workspace_id={workspace_id}, invite_id={invite_id}, 操作者: {user.username}") try: # 检查权限 @@ -745,13 +752,14 @@ def revoke_workspace_invite( def update_workspace_member_roles( - db: Session, - workspace_id: uuid.UUID, - updates: List[WorkspaceMemberUpdate], - user: User, + db: Session, + workspace_id: uuid.UUID, + updates: List[WorkspaceMemberUpdate], + user: User, ) -> List[WorkspaceMember]: """更新工作空间成员角色""" - business_logger.info(f"更新工作空间成员角色: workspace_id={workspace_id}, 操作者: {user.username}, 更新数量: {len(updates)}") + business_logger.info( + f"更新工作空间成员角色: workspace_id={workspace_id}, 操作者: {user.username}, 更新数量: {len(updates)}") # 检查管理员权限 _check_workspace_admin_permission(db, workspace_id, user) @@ -765,7 +773,8 @@ def update_workspace_member_roles( for upd in updates: # 检查成员是否存在 if upd.id not in member_map: - raise BusinessException(f"成员 {upd.id} 不存在于工作空间 {workspace_id}", BizCode.WORKSPACE_MEMBER_NOT_FOUND) + raise BusinessException(f"成员 {upd.id} 不存在于工作空间 {workspace_id}", + BizCode.WORKSPACE_MEMBER_NOT_FOUND) member = member_map[upd.id] @@ -917,10 +926,10 @@ def get_workspace_models_configs( def update_workspace_models_configs( - db: Session, - workspace_id: uuid.UUID, - models_update: WorkspaceModelsUpdate, - user: User, + db: Session, + workspace_id: uuid.UUID, + models_update: WorkspaceModelsUpdate, + user: User, ) -> Workspace: """更新工作空间的模型配置(llm, embedding, rerank) @@ -968,8 +977,8 @@ def update_workspace_models_configs( def _fill_workspace_configs_model_defaults( - db: Session, - workspace: Workspace + db: Session, + workspace: Workspace ) -> None: """Fill empty model fields for all memory configs in a workspace. @@ -981,43 +990,43 @@ def _fill_workspace_configs_model_defaults( workspace: The workspace containing default model settings """ from app.models.memory_config_model import MemoryConfig - + # Get all configs for this workspace configs = db.query(MemoryConfig).filter( MemoryConfig.workspace_id == workspace.id ).all() - + if not configs: return - + # Map of memory_config field -> workspace field model_field_mappings = [ ("llm_id", "llm"), ("embedding_id", "embedding"), ("rerank_id", "rerank"), ("reflection_model_id", "llm"), # reflection uses LLM - ("emotion_model_id", "llm"), # emotion uses LLM + ("emotion_model_id", "llm"), # emotion uses LLM ] - + configs_updated = 0 - + for memory_config in configs: updated_fields = [] - + for config_field, workspace_field in model_field_mappings: config_value = getattr(memory_config, config_field, None) workspace_value = getattr(workspace, workspace_field, None) - + if not config_value and workspace_value: setattr(memory_config, config_field, workspace_value) updated_fields.append(config_field) - + if updated_fields: configs_updated += 1 business_logger.debug( f"Updated memory config {memory_config.config_id} fields: {updated_fields}" ) - + if configs_updated > 0: try: db.commit() @@ -1032,14 +1041,14 @@ def _fill_workspace_configs_model_defaults( def _create_default_memory_config( - db: Session, - workspace_id: uuid.UUID, - workspace_name: str, - llm_id: Optional[uuid.UUID] = None, - embedding_id: Optional[uuid.UUID] = None, - rerank_id: Optional[uuid.UUID] = None, - scene_id: Optional[uuid.UUID] = None, - pruning_scene_name: Optional[str] = None, + db: Session, + workspace_id: uuid.UUID, + workspace_name: str, + llm_id: Optional[uuid.UUID] = None, + embedding_id: Optional[uuid.UUID] = None, + rerank_id: Optional[uuid.UUID] = None, + scene_id: Optional[uuid.UUID] = None, + pruning_scene_name: Optional[str] = None, ) -> None: """Create a default memory config for a newly created workspace. @@ -1054,9 +1063,9 @@ def _create_default_memory_config( pruning_scene_name: Optional pruning scene name,取自 ontology_scene.scene_name """ from app.models.memory_config_model import MemoryConfig - + config_id = uuid.uuid4() - + default_config = MemoryConfig( config_id=config_id, config_name=f"{workspace_name} 默认配置", @@ -1070,10 +1079,10 @@ def _create_default_memory_config( state=True, # Active by default is_default=True, # Mark as workspace default ) - + db.add(default_config) db.flush() # 使用 flush 而不是 commit,让调用者统一提交 - + business_logger.info( "Created default memory config for workspace", extra={ @@ -1084,6 +1093,7 @@ def _create_default_memory_config( } ) + # ==================== 检查配置相关服务 ==================== def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: @@ -1096,19 +1106,19 @@ def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: workspace: The workspace to check """ from app.models.memory_config_model import MemoryConfig - + # Check if default config exists for this workspace existing_default = db.query(MemoryConfig).filter( MemoryConfig.workspace_id == workspace.id, MemoryConfig.is_default == True ).first() - + if not existing_default: # No default config exists, create one business_logger.info( f"Workspace {workspace.id} missing default memory config, creating one" ) - + # 尝试获取默认场景ID,优先教育场景,其次情感陪伴场景 default_scene_id = None try: @@ -1118,7 +1128,7 @@ def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: EMOTIONAL_COMPANION_SCENE, get_scene_name ) - + scene_repo = OntologySceneRepository(db) # 尝试中文和英文场景名称 for language in ["zh", "en"]: @@ -1131,7 +1141,7 @@ def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: f"找到教育场景用于默认记忆配置: scene_id={default_scene_id}, scene_name={education_scene_name}" ) break - + # 如果教育场景不存在,尝试情感陪伴场景 companion_scene_name = get_scene_name(EMOTIONAL_COMPANION_SCENE, language) companion_scene = scene_repo.get_by_name(companion_scene_name, workspace.id) @@ -1145,7 +1155,7 @@ def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: business_logger.warning( f"获取默认场景失败,将创建不关联场景的记忆配置: {str(scene_error)}" ) - + try: _create_default_memory_config( db=db, @@ -1160,7 +1170,7 @@ def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: business_logger.error( f"Failed to create default memory config for workspace {workspace.id}: {str(e)}" ) - + # Fill empty model fields for ALL configs in this workspace _fill_workspace_configs_model_defaults(db, workspace) @@ -1209,4 +1219,3 @@ def _ensure_default_ontology_scenes(db: Session, workspace: Workspace) -> None: business_logger.error( f"为工作空间 {workspace.id} 补建默认本体场景异常: {str(e)}" ) - From beea826377fb602663487954c50dd5d5e1dcd52c Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 10 Mar 2026 11:17:52 +0800 Subject: [PATCH 87/89] feat(app): Application (agent, workflow) import/export --- api/app/controllers/app_controller.py | 57 ++++ api/app/services/app_dsl_service.py | 390 ++++++++++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 api/app/services/app_dsl_service.py diff --git a/api/app/controllers/app_controller.py b/api/app/controllers/app_controller.py index cdf94345..a4048bf4 100644 --- a/api/app/controllers/app_controller.py +++ b/api/app/controllers/app_controller.py @@ -1,10 +1,12 @@ import uuid +import io from typing import Optional, Annotated import yaml from fastapi import APIRouter, Depends, Path, Form, UploadFile, File from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session +from urllib.parse import quote from app.core.error_codes import BizCode from app.core.logging_config import get_business_logger @@ -25,6 +27,7 @@ from app.services.app_service import AppService from app.services.app_statistics_service import AppStatisticsService from app.services.workflow_import_service import WorkflowImportService from app.services.workflow_service import WorkflowService, get_workflow_service +from app.services.app_dsl_service import AppDslService router = APIRouter(prefix="/apps", tags=["Apps"]) logger = get_business_logger() @@ -1010,3 +1013,57 @@ def get_workspace_api_statistics( ) return success(data=result) + + +@router.get("/{app_id}/export", summary="导出应用配置为 YAML 文件") +@cur_workspace_access_guard() +async def export_app( + app_id: uuid.UUID, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], + release_version: Optional[str] = None +): + """导出 agent / multi_agent / workflow 应用配置为 YAML 文件流。 + release_version: 指定发布版本号,不传则导出当前草稿配置。 + """ + yaml_str, filename = AppDslService(db).export_dsl(app_id, release_version) + encoded = quote(filename, safe=".") + yaml_bytes = yaml_str.encode("utf-8") + file_stream = io.BytesIO(yaml_bytes) + file_stream.seek(0) + return StreamingResponse( + file_stream, + media_type="application/octet-stream; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename={encoded}", + "Content-Length": str(len(yaml_bytes))} + ) + + +@router.post("/import", summary="从 YAML 文件导入应用") +@cur_workspace_access_guard() +async def import_app( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """从 YAML 文件导入 agent / multi_agent / workflow 应用。 + 跨空间/跨租户导入时,模型/工具/知识库会按名称匹配,匹配不到则置空并返回 warnings。 + """ + if not file.filename.lower().endswith((".yaml", ".yml")): + return fail(msg="仅支持 YAML 文件", code=BizCode.BAD_REQUEST) + + raw = (await file.read()).decode("utf-8") + dsl = yaml.safe_load(raw) + if not dsl or "app" not in dsl: + return fail(msg="YAML 格式无效,缺少 app 字段", code=BizCode.BAD_REQUEST) + + new_app, warnings = AppDslService(db).import_dsl( + dsl=dsl, + workspace_id=current_user.current_workspace_id, + tenant_id=current_user.tenant_id, + user_id=current_user.id, + ) + return success( + data={"app": app_schema.App.model_validate(new_app), "warnings": warnings}, + msg="应用导入成功" + (",但部分资源需手动配置" if warnings else "") + ) diff --git a/api/app/services/app_dsl_service.py b/api/app/services/app_dsl_service.py new file mode 100644 index 00000000..7258fc2b --- /dev/null +++ b/api/app/services/app_dsl_service.py @@ -0,0 +1,390 @@ +"""应用 DSL 导入导出服务""" +import uuid +import datetime +from typing import Optional + +import yaml +from sqlalchemy.orm import Session + +from app.core.config import settings +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.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.workflow_model import WorkflowConfig +from app.services.workflow_service import WorkflowService + + +class AppDslService: + + def __init__(self, db: Session): + self.db = db + + # ==================== 导出 ==================== + + def export_dsl(self, app_id: uuid.UUID, release_version: Optional[str] = None) -> tuple[str, str]: + """构建应用 DSL yaml 字符串,返回 (yaml_str, filename)""" + app = self.db.query(App).filter(App.id == app_id, App.is_active.is_(True)).first() + if not app: + raise ResourceNotFoundException("应用", str(app_id)) + + meta = { + "version": settings.SYSTEM_VERSION, + "platform": "MemoryBear", + "exported_at": datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + } + app_meta = { + "name": app.name, + "description": app.description, + "icon": app.icon, + "icon_type": app.icon_type, + "type": app.type, + "tags": app.tags or [], + } + + if release_version is not None: + return self._export_release(app, release_version, meta, app_meta) + + return self._export_draft(app, meta, app_meta) + + def _export_release(self, app: App, release_version: str, meta: dict, app_meta: dict) -> tuple[str, str]: + release = self.db.query(AppRelease).filter( + AppRelease.app_id == app.id, + AppRelease.id == release_version, + AppRelease.is_active.is_(True) + ).first() + if not release: + raise ResourceNotFoundException("版本", str(release_version)) + + meta["release_version"] = release.version + meta["release_name"] = release.version_name + app_meta["name"] = release.name + app_meta["description"] = release.description + config_key = { + AppType.AGENT: "agent_config", + AppType.MULTI_AGENT: "multi_agent_config", + AppType.WORKFLOW: "workflow" + }.get(app.type, "config") + config_data = self._enrich_release_config(app.type, release.config or {}) + dsl = {**meta, "app": app_meta, config_key: config_data} + return yaml.dump(dsl, default_flow_style=False, allow_unicode=True), f"{release.name}_v{release.version_name}.yaml" + + def _enrich_release_config(self, app_type: str, cfg: dict) -> dict: + if app_type == AppType.AGENT: + enriched = {**cfg} + if "default_model_config_id" in cfg: + enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"]) + 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"]) + return enriched + if app_type == AppType.MULTI_AGENT: + enriched = {**cfg} + if "default_model_config_id" in cfg: + enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"]) + if "master_agent_id" in cfg: + enriched["master_agent_ref"] = self._release_ref(cfg["master_agent_id"]) + if "sub_agents" in cfg: + enriched["sub_agents"] = self._enrich_sub_agents(cfg["sub_agents"]) + if "routing_rules" in cfg: + enriched["routing_rules"] = [ + {**r, "_ref": self._agent_ref(r.get("target_agent_id"))} for r in (cfg["routing_rules"] or []) + ] + return enriched + return cfg + + def _export_draft(self, app: App, meta: dict, app_meta: dict) -> tuple[str, str]: + if app.type == AppType.WORKFLOW: + config = self.db.query(WorkflowConfig).filter(WorkflowConfig.app_id == app.id).first() + config_data = { + "variables": config.variables if config else [], + "edges": config.edges if config else [], + "nodes": config.nodes if config else [], + "execution_config": config.execution_config if config else {}, + "triggers": config.triggers if config else [], + } if config else {} + dsl = {**meta, "app": app_meta, "workflow": config_data} + + elif app.type == AppType.AGENT: + config = self.db.query(AgentConfig).filter(AgentConfig.app_id == app.id).first() + config_data = { + "system_prompt": config.system_prompt if config else None, + "model_parameters": self._to_dict(config.model_parameters) if config else None, + "default_model_config_ref": self._model_ref(config.default_model_config_id) if config else None, + "knowledge_retrieval": self._enrich_knowledge_retrieval(config.knowledge_retrieval) if config else None, + "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 {}, + } if config else {} + dsl = {**meta, "app": app_meta, "agent_config": config_data} + + elif app.type == AppType.MULTI_AGENT: + config = self.db.query(MultiAgentConfig).filter(MultiAgentConfig.app_id == app.id).first() + config_data = { + "orchestration_mode": config.orchestration_mode if config else None, + "master_agent_name": config.master_agent_name if config else None, + "model_parameters": self._to_dict(config.model_parameters) if config else None, + "default_model_config_ref": self._model_ref(config.default_model_config_id) if config else None, + "master_agent_ref": self._release_ref(config.master_agent_id) if config else None, + "sub_agents": self._enrich_sub_agents(config.sub_agents) if config else [], + "routing_rules": [ + {**r, "_ref": self._agent_ref(r.get("target_agent_id"))} for r in (config.routing_rules or []) + ] if config else [], + + "execution_config": config.execution_config if config else {}, + "aggregation_strategy": config.aggregation_strategy if config else "merge", + } if config else {} + dsl = {**meta, "app": app_meta, "multi_agent_config": config_data} + + else: + raise BusinessException(f"不支持的应用类型: {app.type}", BizCode.BAD_REQUEST) + + return yaml.dump(dsl, default_flow_style=False, allow_unicode=True), f"{app.name}.yaml" + + def _to_dict(self, value): + """将 Pydantic 对象转为普通 dict,供 yaml.dump 安全序列化""" + if value is None: + return None + if hasattr(value, "model_dump"): + return value.model_dump() + return value + + def _model_ref(self, model_config_id) -> Optional[dict]: + if not model_config_id: + return None + m = self.db.query(ModelConfig).filter(ModelConfig.id == model_config_id).first() + return {"id": str(model_config_id), "name": m.name, "provider": m.provider, "type": m.type} if m else {"id": str(model_config_id)} + + def _kb_ref(self, kb_id) -> Optional[dict]: + if not kb_id: + return None + kb = self.db.query(Knowledge).filter(Knowledge.id == kb_id).first() + return {"id": str(kb_id), "name": kb.name} if kb else {"id": str(kb_id)} + + def _tool_ref(self, tool_id) -> Optional[dict]: + if not tool_id: + return None + t = self.db.query(ToolConfigModel).filter(ToolConfigModel.id == tool_id).first() + return {"id": str(tool_id), "name": t.name, "tool_type": t.tool_type} if t else {"id": str(tool_id)} + + def _enrich_knowledge_retrieval(self, kr: Optional[dict]) -> Optional[dict]: + if not kr: + return kr + kbs = [{**kb, "_ref": self._kb_ref(kb.get("kb_id"))} for kb in kr.get("knowledge_bases", [])] + return {**kr, "knowledge_bases": kbs} + + def _enrich_tools(self, tools: list) -> list: + return [{**t, "_ref": self._tool_ref(t.get("tool_id"))} for t in (tools or [])] + + def _agent_ref(self, agent_id) -> Optional[dict]: + if not agent_id: + return None + a = self.db.query(App).filter(App.id == agent_id).first() + return {"id": str(agent_id), "name": a.name} if a else {"id": str(agent_id)} + + def _release_ref(self, release_id) -> Optional[dict]: + if not release_id: + return None + r = self.db.query(AppRelease).filter(AppRelease.id == release_id).first() + return {"id": str(release_id), "name": r.name, "version": r.version, "app_id": str(r.app_id)} if r else {"id": str(release_id)} + + def _enrich_sub_agents(self, sub_agents: list) -> list: + return [{**s, "_ref": self._agent_ref(s.get("agent_id"))} for s in (sub_agents or [])] + + # ==================== 导入 ==================== + + def import_dsl( + self, + dsl: dict, + workspace_id: uuid.UUID, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + ) -> tuple[App, list[str]]: + """解析 DSL,创建应用及配置,返回 (new_app, warnings)""" + app_meta = dsl.get("app", {}) + app_type = app_meta.get("type") + if app_type not in (AppType.AGENT, AppType.MULTI_AGENT, AppType.WORKFLOW): + raise BusinessException(f"不支持的应用类型: {app_type}", BizCode.BAD_REQUEST) + + warnings: list[str] = [] + now = datetime.datetime.now() + + new_app = App( + id=uuid.uuid4(), + workspace_id=workspace_id, + created_by=user_id, + name=app_meta.get("name", "导入应用"), + description=app_meta.get("description"), + icon=app_meta.get("icon"), + icon_type=app_meta.get("icon_type"), + type=app_type, + visibility="private", + status="draft", + tags=app_meta.get("tags", []), + is_active=True, + created_at=now, + updated_at=now, + ) + self.db.add(new_app) + self.db.flush() + + if app_type == AppType.AGENT: + cfg = dsl.get("agent_config") or {} + self.db.add(AgentConfig( + id=uuid.uuid4(), + app_id=new_app.id, + system_prompt=cfg.get("system_prompt"), + model_parameters=cfg.get("model_parameters"), + default_model_config_id=self._resolve_model(cfg.get("default_model_config_ref"), tenant_id, warnings), + knowledge_retrieval=self._resolve_knowledge_retrieval(cfg.get("knowledge_retrieval"), workspace_id, warnings), + memory=cfg.get("memory"), + variables=cfg.get("variables", []), + tools=self._resolve_tools(cfg.get("tools", []), tenant_id, warnings), + skills=cfg.get("skills", {}), + is_active=True, + created_at=now, + updated_at=now, + )) + + elif app_type == AppType.MULTI_AGENT: + cfg = dsl.get("multi_agent_config") or {} + self.db.add(MultiAgentConfig( + id=uuid.uuid4(), + app_id=new_app.id, + orchestration_mode=cfg.get("orchestration_mode", "collaboration"), + master_agent_name=cfg.get("master_agent_name"), + model_parameters=cfg.get("model_parameters"), + default_model_config_id=self._resolve_model(cfg.get("default_model_config_ref"), tenant_id, warnings), + master_agent_id=self._resolve_release(cfg.get("master_agent_ref"), warnings), + sub_agents=self._resolve_sub_agents(cfg.get("sub_agents", []), warnings), + routing_rules=self._resolve_routing_rules(cfg.get("routing_rules"), warnings), + execution_config=cfg.get("execution_config", {}), + aggregation_strategy=cfg.get("aggregation_strategy", "merge"), + is_active=True, + created_at=now, + updated_at=now, + )) + + elif app_type == AppType.WORKFLOW: + wf = dsl.get("workflow") or {} + WorkflowService(self.db).create_workflow_config( + app_id=new_app.id, + nodes=wf.get("nodes", []), + edges=wf.get("edges", []), + variables=wf.get("variables", []), + execution_config=wf.get("execution_config", {}), + triggers=wf.get("triggers", []), + validate=False, + ) + + self.db.commit() + self.db.refresh(new_app) + return new_app, warnings + + def _resolve_model(self, ref: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> Optional[uuid.UUID]: + if not ref: + return None + q = self.db.query(ModelConfig).filter( + ModelConfig.tenant_id == tenant_id, + ModelConfig.name == ref.get("name"), + ModelConfig.is_active.is_(True) + ) + if ref.get("provider"): + q = q.filter(ModelConfig.provider == ref["provider"]) + if ref.get("type"): + q = q.filter(ModelConfig.type == ref["type"]) + m = q.first() + if not m: + warnings.append(f"模型 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置") + return m.id if m else None + + def _resolve_kb(self, ref: Optional[dict], workspace_id: uuid.UUID, warnings: list) -> Optional[str]: + if not ref: + return None + kb = self.db.query(Knowledge).filter( + Knowledge.workspace_id == workspace_id, + Knowledge.name == ref.get("name") + ).first() + if not kb: + warnings.append(f"知识库 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置") + return str(kb.id) if kb else None + + def _resolve_tool(self, ref: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> Optional[str]: + if not ref: + return None + q = self.db.query(ToolConfigModel).filter( + ToolConfigModel.tenant_id == tenant_id, + ToolConfigModel.name == ref.get("name") + ) + if ref.get("tool_type"): + q = q.filter(ToolConfigModel.tool_type == ref["tool_type"]) + t = q.first() + if not t: + warnings.append(f"工具 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置") + return str(t.id) if t else None + + def _resolve_release(self, ref: Optional[dict], warnings: list) -> Optional[uuid.UUID]: + if not ref: + return None + r = self.db.query(AppRelease).filter( + AppRelease.app_id == ref.get("app_id"), + AppRelease.version == ref.get("version"), + AppRelease.is_active.is_(True) + ).first() + if not r: + warnings.append(f"主 Agent 发布版本 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置") + return r.id if r else None + + def _resolve_sub_agents(self, sub_agents: list, warnings: list) -> list: + result = [] + for s in (sub_agents or []): + ref = s.get("_ref") + entry = {k: v for k, v in s.items() if k != "_ref"} + if ref: + a = self.db.query(App).filter(App.name == ref.get("name"), App.is_active.is_(True)).first() + if not a: + warnings.append(f"子 Agent '{ref.get('name')}' 未匹配,已置空,请导入后手动配置") + entry["agent_id"] = str(a.id) if a else None + result.append(entry) + return result + + def _resolve_routing_rules(self, rules: Optional[list], warnings: list) -> Optional[list]: + if rules is None: + return None + result = [] + for r in rules: + ref = r.get("_ref") + entry = {k: v for k, v in r.items() if k != "_ref"} + if ref: + a = self.db.query(App).filter(App.name == ref.get("name"), App.is_active.is_(True)).first() + if not a: + warnings.append(f"路由目标 Agent '{ref.get('name')}' 未匹配,已置空,请导入后手动配置") + entry["target_agent_id"] = str(a.id) if a else None + result.append(entry) + return result + + def _resolve_knowledge_retrieval(self, kr: Optional[dict], workspace_id: uuid.UUID, warnings: list) -> Optional[dict]: + if not kr: + return kr + resolved_kbs = [] + for kb in kr.get("knowledge_bases", []): + ref = kb.get("_ref") or ({"name": kb.get("kb_id")} if kb.get("kb_id") else None) + entry = {k: v for k, v in kb.items() if k != "_ref"} + entry["kb_id"] = self._resolve_kb(ref, workspace_id, warnings) + resolved_kbs.append(entry) + return {k: v for k, v in kr.items() if k != "knowledge_bases"} | {"knowledge_bases": resolved_kbs} + + def _resolve_tools(self, tools: list, tenant_id: uuid.UUID, warnings: list) -> list: + result = [] + for t in (tools or []): + ref = t.get("_ref") or ({"name": t.get("tool_id")} if t.get("tool_id") else None) + entry = {k: v for k, v in t.items() if k != "_ref"} + entry["tool_id"] = self._resolve_tool(ref, tenant_id, warnings) + result.append(entry) + return result From 3237f4cd6eb7f527d2467baf63b49dca93f58937 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 10 Mar 2026 11:27:28 +0800 Subject: [PATCH 88/89] feat(app): Application (agent, workflow) import/export --- api/app/controllers/app_controller.py | 4 ++-- api/app/services/app_dsl_service.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/app/controllers/app_controller.py b/api/app/controllers/app_controller.py index a4048bf4..cadc7df8 100644 --- a/api/app/controllers/app_controller.py +++ b/api/app/controllers/app_controller.py @@ -1021,12 +1021,12 @@ async def export_app( app_id: uuid.UUID, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], - release_version: Optional[str] = None + release_id: Optional[uuid.UUID] = None ): """导出 agent / multi_agent / workflow 应用配置为 YAML 文件流。 release_version: 指定发布版本号,不传则导出当前草稿配置。 """ - yaml_str, filename = AppDslService(db).export_dsl(app_id, release_version) + yaml_str, filename = AppDslService(db).export_dsl(app_id, release_id) encoded = quote(filename, safe=".") yaml_bytes = yaml_str.encode("utf-8") file_stream = io.BytesIO(yaml_bytes) diff --git a/api/app/services/app_dsl_service.py b/api/app/services/app_dsl_service.py index 7258fc2b..c120d98b 100644 --- a/api/app/services/app_dsl_service.py +++ b/api/app/services/app_dsl_service.py @@ -26,7 +26,7 @@ class AppDslService: # ==================== 导出 ==================== - def export_dsl(self, app_id: uuid.UUID, release_version: Optional[str] = None) -> tuple[str, str]: + def export_dsl(self, app_id: uuid.UUID, release_id: Optional[uuid.UUID] = None) -> tuple[str, str]: """构建应用 DSL yaml 字符串,返回 (yaml_str, filename)""" app = self.db.query(App).filter(App.id == app_id, App.is_active.is_(True)).first() if not app: @@ -46,19 +46,19 @@ class AppDslService: "tags": app.tags or [], } - if release_version is not None: - return self._export_release(app, release_version, meta, app_meta) + if release_id is not None: + return self._export_release(app, release_id, meta, app_meta) return self._export_draft(app, meta, app_meta) - def _export_release(self, app: App, release_version: str, meta: dict, app_meta: dict) -> tuple[str, str]: + def _export_release(self, app: App, release_id: uuid.UUID, meta: dict, app_meta: dict) -> tuple[str, str]: release = self.db.query(AppRelease).filter( AppRelease.app_id == app.id, - AppRelease.id == release_version, + AppRelease.id == release_id, AppRelease.is_active.is_(True) ).first() if not release: - raise ResourceNotFoundException("版本", str(release_version)) + raise ResourceNotFoundException("版本", str(release_id)) meta["release_version"] = release.version meta["release_name"] = release.version_name From 3e5a7adfe4ea5845a89dcd2248ba05ff571f399b Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 10 Mar 2026 11:28:52 +0800 Subject: [PATCH 89/89] feat(app): Application (agent, workflow) import/export --- api/app/controllers/app_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/controllers/app_controller.py b/api/app/controllers/app_controller.py index cadc7df8..ee957f28 100644 --- a/api/app/controllers/app_controller.py +++ b/api/app/controllers/app_controller.py @@ -1024,7 +1024,7 @@ async def export_app( release_id: Optional[uuid.UUID] = None ): """导出 agent / multi_agent / workflow 应用配置为 YAML 文件流。 - release_version: 指定发布版本号,不传则导出当前草稿配置。 + release_id: 指定发布版本id,不传则导出当前草稿配置。 """ yaml_str, filename = AppDslService(db).export_dsl(app_id, release_id) encoded = quote(filename, safe=".")