From 7ce29019f7b42403d2f9d9122e48bfda252ad05e Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Wed, 1 Apr 2026 15:06:26 +0800 Subject: [PATCH 001/113] feat(memory): Add memory config API controller and end user info endpoints - Create new memory_config_api_controller.py for dedicated memory configuration management - Add /end_user/info GET endpoint to retrieve end user information (aliases, metadata) - Add /end_user/info/update POST endpoint to update end user details - Move /memory/configs endpoint from memory_api_controller to memory_config_api_controller - Extract _get_current_user helper function to build user context from API key auth - Support optional app_id parameter in end user creation with UUID validation - Update service controller imports with alphabetical ordering and multi-line formatting - Register memory_config_api_controller router in service module initialization - Refactor memory_api_controller imports for consistency and clarity --- api/app/controllers/service/__init__.py | 13 +- .../service/end_user_api_controller.py | 80 ++++++- .../service/memory_api_controller.py | 60 +----- .../service/memory_config_api_controller.py | 39 ++++ .../storage_services/search/__init__.py | 197 ++++++++---------- api/app/schemas/memory_api_schema.py | 2 + api/app/services/memory_api_service.py | 3 +- 7 files changed, 219 insertions(+), 175 deletions(-) create mode 100644 api/app/controllers/service/memory_config_api_controller.py diff --git a/api/app/controllers/service/__init__.py b/api/app/controllers/service/__init__.py index 96da0949..52d4b732 100644 --- a/api/app/controllers/service/__init__.py +++ b/api/app/controllers/service/__init__.py @@ -4,7 +4,17 @@ 认证方式: API Key """ from fastapi import APIRouter -from . import app_api_controller, rag_api_knowledge_controller, rag_api_document_controller, rag_api_file_controller, rag_api_chunk_controller, memory_api_controller, end_user_api_controller + +from . import ( + app_api_controller, + end_user_api_controller, + memory_api_controller, + memory_config_api_controller, + rag_api_chunk_controller, + rag_api_document_controller, + rag_api_file_controller, + rag_api_knowledge_controller, +) # 创建 V1 API 路由器 service_router = APIRouter() @@ -17,5 +27,6 @@ service_router.include_router(rag_api_file_controller.router) service_router.include_router(rag_api_chunk_controller.router) service_router.include_router(memory_api_controller.router) service_router.include_router(end_user_api_controller.router) +service_router.include_router(memory_config_api_controller.router) __all__ = ["service_router"] diff --git a/api/app/controllers/service/end_user_api_controller.py b/api/app/controllers/service/end_user_api_controller.py index 9d410bd2..658cef15 100644 --- a/api/app/controllers/service/end_user_api_controller.py +++ b/api/app/controllers/service/end_user_api_controller.py @@ -5,6 +5,7 @@ import uuid from fastapi import APIRouter, Body, Depends, Request from sqlalchemy.orm import Session +from app.controllers import user_memory_controllers from app.core.api_key_auth import require_api_key from app.core.error_codes import BizCode from app.core.exceptions import BusinessException @@ -13,13 +14,31 @@ from app.core.response_utils import success from app.db import get_db from app.repositories.end_user_repository import EndUserRepository from app.schemas.api_key_schema import ApiKeyAuth +from app.schemas.end_user_info_schema import EndUserInfoUpdate from app.schemas.memory_api_schema import CreateEndUserRequest, CreateEndUserResponse +from app.services import api_key_service from app.services.memory_config_service import MemoryConfigService router = APIRouter(prefix="/end_user", tags=["V1 - End User API"]) logger = get_business_logger() +def _get_current_user(api_key_auth: ApiKeyAuth, db: Session): + """Build a current_user object from API key auth + + Args: + api_key_auth: Validated API key auth info + db: Database session + + Returns: + User object with current_workspace_id set + """ + api_key = api_key_service.ApiKeyService.get_api_key(db, api_key_auth.api_key_id, api_key_auth.workspace_id) + current_user = api_key.creator + current_user.current_workspace_id = api_key_auth.workspace_id + return current_user + + @router.post("/create") @require_api_key(scopes=["memory"]) async def create_end_user( @@ -37,6 +56,7 @@ async def create_end_user( Optionally accepts a memory_config_id to connect the end user to a specific memory configuration. If not provided, falls back to the workspace default config. + Optionally accepts an app_id to bind the end user to a specific app. """ body = await request.json() payload = CreateEndUserRequest(**body) @@ -71,9 +91,20 @@ async def create_end_user( else: logger.warning(f"No default memory config found for workspace: {workspace_id}") + # Resolve app_id: explicit from payload, otherwise None + app_id = None + if payload.app_id: + try: + app_id = uuid.UUID(payload.app_id) + except ValueError: + raise BusinessException( + f"Invalid app_id format: {payload.app_id}", + BizCode.INVALID_PARAMETER + ) + end_user_repo = EndUserRepository(db) end_user = end_user_repo.get_or_create_end_user_with_config( - app_id=api_key_auth.resource_id, + app_id=app_id, workspace_id=workspace_id, other_id=payload.other_id, memory_config_id=memory_config_id, @@ -90,3 +121,50 @@ async def create_end_user( } return success(data=CreateEndUserResponse(**result).model_dump(), msg="End user created successfully") + + +@router.get("/info") +@require_api_key(scopes=["memory"]) +async def get_end_user_info( + request: Request, + end_user_id: str, + api_key_auth: ApiKeyAuth = None, + db: Session = Depends(get_db), +): + """ + Get end user info. + + Retrieves the info record (aliases, meta_data, etc.) for the specified end user. + Delegates to the manager-side controller for shared logic. + """ + current_user = _get_current_user(api_key_auth, db) + return await user_memory_controllers.get_end_user_info( + end_user_id=end_user_id, + current_user=current_user, + db=db, + ) + + +@router.post("/info/update") +@require_api_key(scopes=["memory"]) +async def update_end_user_info( + request: Request, + api_key_auth: ApiKeyAuth = None, + db: Session = Depends(get_db), + message: str = Body(None, description="Request body"), +): + """ + Update end user info. + + Updates the info record (other_name, aliases, meta_data) for the specified end user. + Delegates to the manager-side controller for shared logic. + """ + body = await request.json() + payload = EndUserInfoUpdate(**body) + + current_user = _get_current_user(api_key_auth, db) + return await user_memory_controllers.update_end_user_info( + info_update=payload, + current_user=current_user, + db=db, + ) diff --git a/api/app/controllers/service/memory_api_controller.py b/api/app/controllers/service/memory_api_controller.py index dc5e0408..d1229205 100644 --- a/api/app/controllers/service/memory_api_controller.py +++ b/api/app/controllers/service/memory_api_controller.py @@ -1,22 +1,20 @@ """Memory 服务接口 - 基于 API Key 认证""" +from fastapi import APIRouter, Body, Depends, Request +from sqlalchemy.orm import Session + from app.core.api_key_auth import require_api_key from app.core.logging_config import get_business_logger from app.core.response_utils import success from app.db import get_db from app.schemas.api_key_schema import ApiKeyAuth from app.schemas.memory_api_schema import ( - CreateEndUserRequest, - CreateEndUserResponse, - ListConfigsResponse, MemoryReadRequest, MemoryReadResponse, MemoryWriteRequest, MemoryWriteResponse, ) from app.services.memory_api_service import MemoryAPIService -from fastapi import APIRouter, Body, Depends, Request -from sqlalchemy.orm import Session router = APIRouter(prefix="/memory", tags=["V1 - Memory API"]) logger = get_business_logger() @@ -91,55 +89,3 @@ async def read_memory_api_service( logger.info(f"Memory read successful for end_user: {payload.end_user_id}") return success(data=MemoryReadResponse(**result).model_dump(), msg="Memory read successfully") - - -@router.get("/configs") -@require_api_key(scopes=["memory"]) -async def list_memory_configs( - request: Request, - api_key_auth: ApiKeyAuth = None, - db: Session = Depends(get_db), -): - """ - List all memory configs for the workspace. - - Returns all available memory configurations associated with the authorized workspace. - """ - logger.info(f"List configs request - workspace_id: {api_key_auth.workspace_id}") - - memory_api_service = MemoryAPIService(db) - - result = memory_api_service.list_memory_configs( - workspace_id=api_key_auth.workspace_id, - ) - - logger.info(f"Listed {result['total']} configs for workspace: {api_key_auth.workspace_id}") - return success(data=ListConfigsResponse(**result).model_dump(), msg="Configs listed successfully") - - -@router.post("/end_users") -@require_api_key(scopes=["memory"]) -async def create_end_user( - request: Request, - api_key_auth: ApiKeyAuth = None, - db: Session = Depends(get_db), -): - """ - Create an end user. - - Creates a new end user for the authorized workspace. - If an end user with the same other_id already exists, returns the existing one. - """ - body = await request.json() - payload = CreateEndUserRequest(**body) - logger.info(f"Create end user request - other_id: {payload.other_id}, workspace_id: {api_key_auth.workspace_id}") - - memory_api_service = MemoryAPIService(db) - - result = memory_api_service.create_end_user( - workspace_id=api_key_auth.workspace_id, - other_id=payload.other_id, - ) - - logger.info(f"End user ready: {result['id']}") - return success(data=CreateEndUserResponse(**result).model_dump(), msg="End user created successfully") diff --git a/api/app/controllers/service/memory_config_api_controller.py b/api/app/controllers/service/memory_config_api_controller.py new file mode 100644 index 00000000..bfc60510 --- /dev/null +++ b/api/app/controllers/service/memory_config_api_controller.py @@ -0,0 +1,39 @@ +"""Memory Config 服务接口 - 基于 API Key 认证""" + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from app.core.api_key_auth import require_api_key +from app.core.logging_config import get_business_logger +from app.core.response_utils import success +from app.db import get_db +from app.schemas.api_key_schema import ApiKeyAuth +from app.schemas.memory_api_schema import ListConfigsResponse +from app.services.memory_api_service import MemoryAPIService + +router = APIRouter(prefix="/memory_config", tags=["V1 - Memory Config API"]) +logger = get_business_logger() + + +@router.get("/configs") +@require_api_key(scopes=["memory"]) +async def list_memory_configs( + request: Request, + api_key_auth: ApiKeyAuth = None, + db: Session = Depends(get_db), +): + """ + List all memory configs for the workspace. + + Returns all available memory configurations associated with the authorized workspace. + """ + logger.info(f"List configs request - workspace_id: {api_key_auth.workspace_id}") + + memory_api_service = MemoryAPIService(db) + + result = memory_api_service.list_memory_configs( + workspace_id=api_key_auth.workspace_id, + ) + + logger.info(f"Listed {result['total']} configs for workspace: {api_key_auth.workspace_id}") + return success(data=ListConfigsResponse(**result).model_dump(), msg="Configs listed successfully") diff --git a/api/app/core/memory/storage_services/search/__init__.py b/api/app/core/memory/storage_services/search/__init__.py index c12c39b0..49154e19 100644 --- a/api/app/core/memory/storage_services/search/__init__.py +++ b/api/app/core/memory/storage_services/search/__init__.py @@ -4,11 +4,6 @@ 本模块提供统一的搜索服务接口,支持关键词搜索、语义搜索和混合搜索。 """ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from app.schemas.memory_config_schema import MemoryConfig - from app.core.memory.storage_services.search.hybrid_search import HybridSearchStrategy from app.core.memory.storage_services.search.keyword_search import KeywordSearchStrategy from app.core.memory.storage_services.search.search_strategy import ( @@ -29,115 +24,87 @@ __all__ = [ # ============================================================================ -# 向后兼容的函数式API +# 向后兼容的函数式API (DEPRECATED - 未被使用) # ============================================================================ -# 为了兼容旧代码,提供与 src/search.py 相同的函数式接口 +# 所有调用方均直接使用 app.core.memory.src.search.run_hybrid_search +# 保留注释以备参考 - -async def run_hybrid_search( - query_text: str, - search_type: str = "hybrid", - end_user_id: str | None = None, - apply_id: str | None = None, - user_id: str | None = None, - limit: int = 50, - include: list[str] | None = None, - alpha: float = 0.6, - use_forgetting_curve: bool = False, - memory_config: "MemoryConfig" = None, - **kwargs -) -> dict: - """运行混合搜索(向后兼容的函数式API) - - 这是一个向后兼容的包装函数,将旧的函数式API转换为新的基于类的API。 - - Args: - query_text: 查询文本 - search_type: 搜索类型("hybrid", "keyword", "semantic") - end_user_id: 组ID过滤 - apply_id: 应用ID过滤 - user_id: 用户ID过滤 - limit: 每个类别的最大结果数 - include: 要包含的搜索类别列表 - alpha: BM25分数权重(0.0-1.0) - use_forgetting_curve: 是否使用遗忘曲线 - memory_config: MemoryConfig object containing embedding_model_id - **kwargs: 其他参数 - - Returns: - dict: 搜索结果字典,格式与旧API兼容 - """ - from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient - from app.core.models.base import RedBearModelConfig - from app.db import get_db_context - from app.repositories.neo4j.neo4j_connector import Neo4jConnector - from app.services.memory_config_service import MemoryConfigService - - if not memory_config: - raise ValueError("memory_config is required for search") - - # 初始化客户端 - connector = Neo4jConnector() - with get_db_context() as db: - config_service = MemoryConfigService(db) - embedder_config_dict = config_service.get_embedder_config(str(memory_config.embedding_model_id)) - embedder_config = RedBearModelConfig(**embedder_config_dict) - embedder_client = OpenAIEmbedderClient(embedder_config) - - try: - # 根据搜索类型选择策略 - if search_type == "keyword": - strategy = KeywordSearchStrategy(connector=connector) - elif search_type == "semantic": - strategy = SemanticSearchStrategy( - connector=connector, - embedder_client=embedder_client - ) - else: # hybrid - strategy = HybridSearchStrategy( - connector=connector, - embedder_client=embedder_client, - alpha=alpha, - use_forgetting_curve=use_forgetting_curve - ) - - # 执行搜索 - result = await strategy.search( - query_text=query_text, - end_user_id=end_user_id, - limit=limit, - include=include, - alpha=alpha, - use_forgetting_curve=use_forgetting_curve, - **kwargs - ) - - # 转换为旧格式 - result_dict = result.to_dict() - - # 保存到文件(如果指定了output_path) - output_path = kwargs.get('output_path', 'search_results.json') - if output_path: - import json - import os - from datetime import datetime - - try: - # 确保目录存在 - out_dir = os.path.dirname(output_path) - if out_dir: - os.makedirs(out_dir, exist_ok=True) - - # 保存结果 - with open(output_path, "w", encoding="utf-8") as f: - json.dump(result_dict, f, ensure_ascii=False, indent=2, default=str) - print(f"Search results saved to {output_path}") - except Exception as e: - print(f"Error saving search results: {e}") - return result_dict - - finally: - await connector.close() - - -__all__.append("run_hybrid_search") +# async def run_hybrid_search( +# query_text: str, +# search_type: str = "hybrid", +# end_user_id: str | None = None, +# apply_id: str | None = None, +# user_id: str | None = None, +# limit: int = 50, +# include: list[str] | None = None, +# alpha: float = 0.6, +# use_forgetting_curve: bool = False, +# memory_config: "MemoryConfig" = None, +# **kwargs +# ) -> dict: +# """运行混合搜索(向后兼容的函数式API)""" +# from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient +# from app.core.models.base import RedBearModelConfig +# from app.db import get_db_context +# from app.repositories.neo4j.neo4j_connector import Neo4jConnector +# from app.services.memory_config_service import MemoryConfigService +# +# if not memory_config: +# raise ValueError("memory_config is required for search") +# +# connector = Neo4jConnector() +# with get_db_context() as db: +# config_service = MemoryConfigService(db) +# embedder_config_dict = config_service.get_embedder_config(str(memory_config.embedding_model_id)) +# embedder_config = RedBearModelConfig(**embedder_config_dict) +# embedder_client = OpenAIEmbedderClient(embedder_config) +# +# try: +# if search_type == "keyword": +# strategy = KeywordSearchStrategy(connector=connector) +# elif search_type == "semantic": +# strategy = SemanticSearchStrategy( +# connector=connector, +# embedder_client=embedder_client +# ) +# else: +# strategy = HybridSearchStrategy( +# connector=connector, +# embedder_client=embedder_client, +# alpha=alpha, +# use_forgetting_curve=use_forgetting_curve +# ) +# +# result = await strategy.search( +# query_text=query_text, +# end_user_id=end_user_id, +# limit=limit, +# include=include, +# alpha=alpha, +# use_forgetting_curve=use_forgetting_curve, +# **kwargs +# ) +# +# result_dict = result.to_dict() +# +# output_path = kwargs.get('output_path', 'search_results.json') +# if output_path: +# import json +# import os +# from datetime import datetime +# +# try: +# out_dir = os.path.dirname(output_path) +# if out_dir: +# os.makedirs(out_dir, exist_ok=True) +# with open(output_path, "w", encoding="utf-8") as f: +# json.dump(result_dict, f, ensure_ascii=False, indent=2, default=str) +# print(f"Search results saved to {output_path}") +# except Exception as e: +# print(f"Error saving search results: {e}") +# return result_dict +# +# finally: +# await connector.close() +# +# __all__.append("run_hybrid_search") diff --git a/api/app/schemas/memory_api_schema.py b/api/app/schemas/memory_api_schema.py index ff62355f..2229c540 100644 --- a/api/app/schemas/memory_api_schema.py +++ b/api/app/schemas/memory_api_schema.py @@ -141,10 +141,12 @@ class CreateEndUserRequest(BaseModel): other_id: External user identifier (required) other_name: Display name for the end user memory_config_id: Optional memory config ID. If not provided, uses workspace default. + app_id: Optional app ID to bind the end user to. """ other_id: str = Field(..., description="External user identifier (required)") other_name: Optional[str] = Field("", description="Display name") memory_config_id: Optional[str] = Field(None, description="Memory config ID. Falls back to workspace default if not provided.") + app_id: Optional[str] = Field(None, description="App ID to bind the end user to") @field_validator("other_id") @classmethod diff --git a/api/app/services/memory_api_service.py b/api/app/services/memory_api_service.py index f62f526c..bea313fc 100644 --- a/api/app/services/memory_api_service.py +++ b/api/app/services/memory_api_service.py @@ -8,6 +8,8 @@ This service validates inputs and delegates to MemoryAgentService for core memor import uuid from typing import Any, Dict, Optional +from sqlalchemy.orm import Session + from app.core.error_codes import BizCode from app.core.exceptions import BusinessException, ResourceNotFoundException from app.core.logging_config import get_logger @@ -15,7 +17,6 @@ from app.models.app_model import App from app.models.end_user_model import EndUser from app.schemas.memory_config_schema import ConfigurationError from app.services.memory_agent_service import MemoryAgentService -from sqlalchemy.orm import Session logger = get_logger(__name__) From 010eff17cfad8d7fa47568e493ef5932233f2950 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Thu, 2 Apr 2026 14:47:36 +0800 Subject: [PATCH 002/113] feat(memory): Refactor memory API to support async task-based and sync operations - Rename endpoints from write_api_service/read_api_service to write/read for clarity - Add async task-based endpoints (/write, /read) that dispatch to Celery with fair locking - Add task status polling endpoints (/write/status, /read/status) to check async operation results - Add synchronous endpoints (/write/sync, /read/sync) for blocking operations with direct results - Introduce TaskStatusResponse schema for task status polling responses - Add MemoryWriteSyncResponse and MemoryReadSyncResponse schemas for sync operations - Implement write_memory_sync and read_memory_sync methods in MemoryAPIService - Remove await from async service calls in task-based endpoints (now handled by Celery) - Add Query parameter import for task_id in status endpoints - Update docstrings to clarify async vs sync behavior and task polling workflow - Integrate task_service for retrieving Celery task results --- .../service/memory_api_controller.py | 160 ++++++++++++--- api/app/schemas/memory_api_schema.py | 43 +++- api/app/services/memory_api_service.py | 186 ++++++++++++++---- 3 files changed, 320 insertions(+), 69 deletions(-) diff --git a/api/app/controllers/service/memory_api_controller.py b/api/app/controllers/service/memory_api_controller.py index d1229205..34135fec 100644 --- a/api/app/controllers/service/memory_api_controller.py +++ b/api/app/controllers/service/memory_api_controller.py @@ -1,6 +1,6 @@ """Memory 服务接口 - 基于 API Key 认证""" -from fastapi import APIRouter, Body, Depends, Request +from fastapi import APIRouter, Body, Depends, Query, Request from sqlalchemy.orm import Session from app.core.api_key_auth import require_api_key @@ -11,8 +11,11 @@ from app.schemas.api_key_schema import ApiKeyAuth from app.schemas.memory_api_schema import ( MemoryReadRequest, MemoryReadResponse, + MemoryReadSyncResponse, MemoryWriteRequest, MemoryWriteResponse, + MemoryWriteSyncResponse, + TaskStatusResponse, ) from app.services.memory_api_service import MemoryAPIService @@ -26,26 +29,27 @@ async def get_memory_info(): return success(data={}, msg="Memory API - Coming Soon") -@router.post("/write_api_service") +@router.post("/write") @require_api_key(scopes=["memory"]) -async def write_memory_api_service( +async def write_memory( request: Request, api_key_auth: ApiKeyAuth = None, db: Session = Depends(get_db), message: str = Body(..., description="Message content"), ): """ - Write memory to storage. - - Stores memory content for the specified end user using the Memory API Service. + Submit a memory write task. + + Validates the end user, then dispatches the write to a Celery background task + with per-user fair locking. Returns a task_id for status polling. """ body = await request.json() payload = MemoryWriteRequest(**body) logger.info(f"Memory write request - end_user_id: {payload.end_user_id}, workspace_id: {api_key_auth.workspace_id}") - + memory_api_service = MemoryAPIService(db) - - result = await memory_api_service.write_memory( + + result = memory_api_service.write_memory( workspace_id=api_key_auth.workspace_id, end_user_id=payload.end_user_id, message=payload.message, @@ -53,31 +57,53 @@ async def write_memory_api_service( storage_type=payload.storage_type, user_rag_memory_id=payload.user_rag_memory_id, ) - - logger.info(f"Memory write successful for end_user: {payload.end_user_id}") - return success(data=MemoryWriteResponse(**result).model_dump(), msg="Memory written successfully") + + logger.info(f"Memory write task submitted: task_id={result['task_id']}, end_user_id: {payload.end_user_id}") + return success(data=MemoryWriteResponse(**result).model_dump(), msg="Memory write task submitted") -@router.post("/read_api_service") +@router.get("/write/status") @require_api_key(scopes=["memory"]) -async def read_memory_api_service( +async def get_write_task_status( + request: Request, + task_id: str = Query(..., description="Celery task ID"), + api_key_auth: ApiKeyAuth = None, + db: Session = Depends(get_db), +): + """ + Check the status of a memory write task. + + Returns the current status and result (if completed) of a previously submitted write task. + """ + logger.info(f"Write task status check - task_id: {task_id}") + + from app.services.task_service import get_task_memory_write_result + result = get_task_memory_write_result(task_id) + + return success(data=TaskStatusResponse(**result).model_dump(), msg="Task status retrieved") + + +@router.post("/read") +@require_api_key(scopes=["memory"]) +async def read_memory( request: Request, api_key_auth: ApiKeyAuth = None, db: Session = Depends(get_db), message: str = Body(..., description="Query message"), ): """ - Read memory from storage. - - Queries and retrieves memories for the specified end user with context-aware responses. + Submit a memory read task. + + Validates the end user, then dispatches the read to a Celery background task. + Returns a task_id for status polling. """ body = await request.json() payload = MemoryReadRequest(**body) logger.info(f"Memory read request - end_user_id: {payload.end_user_id}") - + memory_api_service = MemoryAPIService(db) - - result = await memory_api_service.read_memory( + + result = memory_api_service.read_memory( workspace_id=api_key_auth.workspace_id, end_user_id=payload.end_user_id, message=payload.message, @@ -86,6 +112,94 @@ async def read_memory_api_service( storage_type=payload.storage_type, user_rag_memory_id=payload.user_rag_memory_id, ) - - logger.info(f"Memory read successful for end_user: {payload.end_user_id}") - return success(data=MemoryReadResponse(**result).model_dump(), msg="Memory read successfully") + + logger.info(f"Memory read task submitted: task_id={result['task_id']}, end_user_id: {payload.end_user_id}") + return success(data=MemoryReadResponse(**result).model_dump(), msg="Memory read task submitted") + + +@router.get("/read/status") +@require_api_key(scopes=["memory"]) +async def get_read_task_status( + request: Request, + task_id: str = Query(..., description="Celery task ID"), + api_key_auth: ApiKeyAuth = None, + db: Session = Depends(get_db), +): + """ + Check the status of a memory read task. + + Returns the current status and result (if completed) of a previously submitted read task. + """ + logger.info(f"Read task status check - task_id: {task_id}") + + from app.services.task_service import get_task_memory_read_result + result = get_task_memory_read_result(task_id) + + return success(data=TaskStatusResponse(**result).model_dump(), msg="Task status retrieved") + + +@router.post("/write/sync") +@require_api_key(scopes=["memory"]) +async def write_memory_sync( + request: Request, + api_key_auth: ApiKeyAuth = None, + db: Session = Depends(get_db), + message: str = Body(..., description="Message content"), +): + """ + Write memory synchronously. + + Blocks until the write completes and returns the result directly. + For async processing with task polling, use /write instead. + """ + body = await request.json() + payload = MemoryWriteRequest(**body) + logger.info(f"Memory write (sync) request - end_user_id: {payload.end_user_id}") + + memory_api_service = MemoryAPIService(db) + + result = await memory_api_service.write_memory_sync( + workspace_id=api_key_auth.workspace_id, + end_user_id=payload.end_user_id, + message=payload.message, + config_id=payload.config_id, + storage_type=payload.storage_type, + user_rag_memory_id=payload.user_rag_memory_id, + ) + + logger.info(f"Memory write (sync) successful for end_user: {payload.end_user_id}") + return success(data=MemoryWriteSyncResponse(**result).model_dump(), msg="Memory written successfully") + + +@router.post("/read/sync") +@require_api_key(scopes=["memory"]) +async def read_memory_sync( + request: Request, + api_key_auth: ApiKeyAuth = None, + db: Session = Depends(get_db), + message: str = Body(..., description="Query message"), +): + """ + Read memory synchronously. + + Blocks until the read completes and returns the answer directly. + For async processing with task polling, use /read instead. + """ + body = await request.json() + payload = MemoryReadRequest(**body) + logger.info(f"Memory read (sync) request - end_user_id: {payload.end_user_id}") + + memory_api_service = MemoryAPIService(db) + + result = await memory_api_service.read_memory_sync( + workspace_id=api_key_auth.workspace_id, + end_user_id=payload.end_user_id, + message=payload.message, + search_switch=payload.search_switch, + config_id=payload.config_id, + storage_type=payload.storage_type, + user_rag_memory_id=payload.user_rag_memory_id, + ) + + logger.info(f"Memory read (sync) successful for end_user: {payload.end_user_id}") + return success(data=MemoryReadSyncResponse(**result).model_dump(), msg="Memory read successfully") diff --git a/api/app/schemas/memory_api_schema.py b/api/app/schemas/memory_api_schema.py index 2229c540..4523d02d 100644 --- a/api/app/schemas/memory_api_schema.py +++ b/api/app/schemas/memory_api_schema.py @@ -110,6 +110,30 @@ class MemoryReadRequest(BaseModel): class MemoryWriteResponse(BaseModel): """Response schema for memory write operation. + Attributes: + task_id: Celery task ID for status polling + status: Initial task status (PENDING) + end_user_id: End user ID the write was submitted for + """ + task_id: str = Field(..., description="Celery task ID for polling") + status: str = Field(..., description="Task status: PENDING") + end_user_id: str = Field(..., description="End user ID") + + +class TaskStatusResponse(BaseModel): + """Response schema for task status check. + + Attributes: + status: Task status (PENDING, STARTED, SUCCESS, FAILURE, SKIPPED) + result: Task result data (available when status is SUCCESS or FAILURE) + """ + status: str = Field(..., description="Task status") + result: Optional[Dict[str, Any]] = Field(None, description="Task result when completed") + + +class MemoryWriteSyncResponse(BaseModel): + """Response schema for synchronous memory write. + Attributes: status: Operation status (success or failed) end_user_id: End user ID that was written to @@ -118,8 +142,8 @@ class MemoryWriteResponse(BaseModel): end_user_id: str = Field(..., description="End user ID") -class MemoryReadResponse(BaseModel): - """Response schema for memory read operation. +class MemoryReadSyncResponse(BaseModel): + """Response schema for synchronous memory read. Attributes: answer: Generated answer from memory retrieval @@ -128,12 +152,25 @@ class MemoryReadResponse(BaseModel): """ answer: str = Field(..., description="Generated answer") intermediate_outputs: List[Dict[str, Any]] = Field( - default_factory=list, + default_factory=list, description="Intermediate retrieval outputs" ) end_user_id: str = Field(..., description="End user ID") +class MemoryReadResponse(BaseModel): + """Response schema for memory read operation. + + Attributes: + task_id: Celery task ID for status polling + status: Initial task status (PENDING) + end_user_id: End user ID the read was submitted for + """ + task_id: str = Field(..., description="Celery task ID for polling") + status: str = Field(..., description="Task status: PENDING") + end_user_id: str = Field(..., description="End user ID") + + class CreateEndUserRequest(BaseModel): """Request schema for creating an end user. diff --git a/api/app/services/memory_api_service.py b/api/app/services/memory_api_service.py index bea313fc..330b84ad 100644 --- a/api/app/services/memory_api_service.py +++ b/api/app/services/memory_api_service.py @@ -125,7 +125,7 @@ class MemoryAPIService: except Exception as e: logger.warning(f"Failed to update memory_config_id for end_user {end_user_id}: {e}") - async def write_memory( + def write_memory( self, workspace_id: uuid.UUID, end_user_id: str, @@ -134,27 +134,28 @@ class MemoryAPIService: storage_type: str = "neo4j", user_rag_memory_id: Optional[str] = None, ) -> Dict[str, Any]: - """Write memory with validation. - + """Submit a memory write task via Celery. + Validates end_user exists and belongs to workspace, updates the end user's - memory_config_id, then delegates to MemoryAgentService.write_memory. - + memory_config_id, then dispatches write_message_task to Celery for async + processing with per-user fair locking. + Args: workspace_id: Workspace ID for resource validation - end_user_id: End user identifier (used as end_user_id) + end_user_id: End user identifier message: Message content to store config_id: Memory configuration ID (required) storage_type: Storage backend (neo4j or rag) user_rag_memory_id: Optional RAG memory ID - + Returns: - Dict with status and end_user_id - + Dict with task_id, status, and end_user_id + Raises: ResourceNotFoundException: If end_user not found - BusinessException: If end_user not in authorized workspace or write fails + BusinessException: If validation fails """ - logger.info(f"Writing memory for end_user: {end_user_id}, workspace: {workspace_id}") + logger.info(f"Submitting memory write for end_user: {end_user_id}, workspace: {workspace_id}") # Validate end_user exists and belongs to workspace self.validate_end_user(end_user_id, workspace_id) @@ -162,9 +163,120 @@ class MemoryAPIService: # Update end user's memory_config_id self._update_end_user_config(end_user_id, config_id) + # Convert to message list format expected by write_message_task + messages = message if isinstance(message, list) else [{"role": "user", "content": message}] + + from app.tasks import write_message_task + task = write_message_task.delay( + end_user_id, + messages, + config_id, + storage_type, + user_rag_memory_id or "", + ) + + logger.info(f"Memory write task submitted: task_id={task.id}, end_user_id={end_user_id}") + + return { + "task_id": task.id, + "status": "PENDING", + "end_user_id": end_user_id, + } + + def read_memory( + self, + workspace_id: uuid.UUID, + end_user_id: str, + message: str, + search_switch: str = "0", + config_id: str = "", + storage_type: str = "neo4j", + user_rag_memory_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Submit a memory read task via Celery. + + Validates end_user exists and belongs to workspace, updates the end user's + memory_config_id, then dispatches read_message_task to Celery for async processing. + + Args: + workspace_id: Workspace ID for resource validation + end_user_id: End user identifier + message: Query message + search_switch: Search mode (0=deep search with verification, 1=deep search, 2=fast search) + config_id: Memory configuration ID (required) + storage_type: Storage backend (neo4j or rag) + user_rag_memory_id: Optional RAG memory ID + + Returns: + Dict with task_id, status, and end_user_id + + Raises: + ResourceNotFoundException: If end_user not found + BusinessException: If validation fails + """ + logger.info(f"Submitting memory read for end_user: {end_user_id}, workspace: {workspace_id}") + + # Validate end_user exists and belongs to workspace + self.validate_end_user(end_user_id, workspace_id) + + # Update end user's memory_config_id + self._update_end_user_config(end_user_id, config_id) + + from app.tasks import read_message_task + task = read_message_task.delay( + end_user_id, + message, + [], # history + search_switch, + config_id, + storage_type, + user_rag_memory_id or "", + ) + + logger.info(f"Memory read task submitted: task_id={task.id}, end_user_id={end_user_id}") + + return { + "task_id": task.id, + "status": "PENDING", + "end_user_id": end_user_id, + } + + async def write_memory_sync( + self, + workspace_id: uuid.UUID, + end_user_id: str, + message: str, + config_id: str, + storage_type: str = "neo4j", + user_rag_memory_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Write memory synchronously (inline, no Celery). + + Validates end_user, then calls MemoryAgentService.write_memory directly. + Blocks until the write completes. Use for cases where the caller needs + immediate confirmation. + + Args: + workspace_id: Workspace ID for resource validation + end_user_id: End user identifier + message: Message content to store + config_id: Memory configuration ID (required) + storage_type: Storage backend (neo4j or rag) + user_rag_memory_id: Optional RAG memory ID + + Returns: + Dict with status and end_user_id + + Raises: + ResourceNotFoundException: If end_user not found + BusinessException: If write fails + """ + logger.info(f"Writing memory (sync) for end_user: {end_user_id}, workspace: {workspace_id}") + + self.validate_end_user(end_user_id, workspace_id) + self._update_end_user_config(end_user_id, config_id) + try: - # Delegate to MemoryAgentService - # Convert string message to list[dict] format expected by MemoryAgentService messages = message if isinstance(message, list) else [{"role": "user", "content": message}] result = await MemoryAgentService().write_memory( end_user_id=end_user_id, @@ -175,11 +287,8 @@ class MemoryAPIService: user_rag_memory_id=user_rag_memory_id or "", ) - logger.info(f"Memory write successful for end_user: {end_user_id}") + logger.info(f"Memory write (sync) successful for end_user: {end_user_id}") - # result may be a string "success" or a dict with a "status" key - # Preserve the full dict so callers don't silently lose extra fields - # (e.g. error codes, metadata) returned by MemoryAgentService. if isinstance(result, dict): return { **result, @@ -193,20 +302,17 @@ class MemoryAPIService: except ConfigurationError as e: logger.error(f"Memory configuration error for end_user {end_user_id}: {e}") - raise BusinessException( - message=str(e), - code=BizCode.MEMORY_CONFIG_NOT_FOUND - ) + raise BusinessException(message=str(e), code=BizCode.MEMORY_CONFIG_NOT_FOUND) except BusinessException: raise except Exception as e: - logger.error(f"Memory write failed for end_user {end_user_id}: {e}") + logger.error(f"Memory write (sync) failed for end_user {end_user_id}: {e}") raise BusinessException( message=f"Memory write failed: {str(e)}", code=BizCode.MEMORY_WRITE_FAILED ) - async def read_memory( + async def read_memory_sync( self, workspace_id: uuid.UUID, end_user_id: str, @@ -216,37 +322,34 @@ class MemoryAPIService: storage_type: str = "neo4j", user_rag_memory_id: Optional[str] = None, ) -> Dict[str, Any]: - """Read memory with validation. - - Validates end_user exists and belongs to workspace, updates the end user's - memory_config_id, then delegates to MemoryAgentService.read_memory. - + """Read memory synchronously (inline, no Celery). + + Validates end_user, then calls MemoryAgentService.read_memory directly. + Blocks until the read completes. Use for cases where the caller needs + the answer immediately. + Args: workspace_id: Workspace ID for resource validation - end_user_id: End user identifier (used as end_user_id) + end_user_id: End user identifier message: Query message search_switch: Search mode (0=deep search with verification, 1=deep search, 2=fast search) config_id: Memory configuration ID (required) storage_type: Storage backend (neo4j or rag) user_rag_memory_id: Optional RAG memory ID - + Returns: Dict with answer, intermediate_outputs, and end_user_id - + Raises: ResourceNotFoundException: If end_user not found - BusinessException: If end_user not in authorized workspace or read fails + BusinessException: If read fails """ - logger.info(f"Reading memory for end_user: {end_user_id}, workspace: {workspace_id}") + logger.info(f"Reading memory (sync) for end_user: {end_user_id}, workspace: {workspace_id}") - # Validate end_user exists and belongs to workspace self.validate_end_user(end_user_id, workspace_id) - - # Update end user's memory_config_id self._update_end_user_config(end_user_id, config_id) try: - # Delegate to MemoryAgentService result = await MemoryAgentService().read_memory( end_user_id=end_user_id, message=message, @@ -258,7 +361,7 @@ class MemoryAPIService: user_rag_memory_id=user_rag_memory_id or "" ) - logger.info(f"Memory read successful for end_user: {end_user_id}") + logger.info(f"Memory read (sync) successful for end_user: {end_user_id}") return { "answer": result.get("answer", ""), @@ -268,14 +371,11 @@ class MemoryAPIService: except ConfigurationError as e: logger.error(f"Memory configuration error for end_user {end_user_id}: {e}") - raise BusinessException( - message=str(e), - code=BizCode.MEMORY_CONFIG_NOT_FOUND - ) + raise BusinessException(message=str(e), code=BizCode.MEMORY_CONFIG_NOT_FOUND) except BusinessException: raise except Exception as e: - logger.error(f"Memory read failed for end_user {end_user_id}: {e}") + logger.error(f"Memory read (sync) failed for end_user {end_user_id}: {e}") raise BusinessException( message=f"Memory read failed: {str(e)}", code=BizCode.MEMORY_READ_FAILED From 2f0bb793d8ea48cabea42e478c04b4ea71140e02 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Thu, 2 Apr 2026 14:49:46 +0800 Subject: [PATCH 003/113] feat(memory): Add task result sanitization for JSON serialization - Remove unused TaskStatusResponse import from memory_api_schema - Add _sanitize_task_result() helper function to convert non-serializable types (UUID, datetime) to strings - Update get_write_task_status endpoint to use sanitization instead of TaskStatusResponse validation - Update get_read_task_status endpoint to use sanitization instead of TaskStatusResponse validation - Ensures Celery task results are properly JSON-serializable before returning to clients --- .../service/memory_api_controller.py | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/api/app/controllers/service/memory_api_controller.py b/api/app/controllers/service/memory_api_controller.py index 34135fec..9acd865f 100644 --- a/api/app/controllers/service/memory_api_controller.py +++ b/api/app/controllers/service/memory_api_controller.py @@ -15,7 +15,6 @@ from app.schemas.memory_api_schema import ( MemoryWriteRequest, MemoryWriteResponse, MemoryWriteSyncResponse, - TaskStatusResponse, ) from app.services.memory_api_service import MemoryAPIService @@ -23,6 +22,34 @@ router = APIRouter(prefix="/memory", tags=["V1 - Memory API"]) logger = get_business_logger() +def _sanitize_task_result(result: dict) -> dict: + """Make Celery task result JSON-serializable. + + Converts UUID and other non-serializable values to strings. + + Args: + result: Raw task result dict from task_service + + Returns: + JSON-safe dict + """ + import uuid as _uuid + from datetime import datetime + + def _convert(obj): + if isinstance(obj, dict): + return {k: _convert(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_convert(i) for i in obj] + if isinstance(obj, _uuid.UUID): + return str(obj) + if isinstance(obj, datetime): + return obj.isoformat() + return obj + + return _convert(result) + + @router.get("") async def get_memory_info(): """获取记忆服务信息(占位)""" @@ -80,7 +107,7 @@ async def get_write_task_status( from app.services.task_service import get_task_memory_write_result result = get_task_memory_write_result(task_id) - return success(data=TaskStatusResponse(**result).model_dump(), msg="Task status retrieved") + return success(data=_sanitize_task_result(result), msg="Task status retrieved") @router.post("/read") @@ -135,7 +162,7 @@ async def get_read_task_status( from app.services.task_service import get_task_memory_read_result result = get_task_memory_read_result(task_id) - return success(data=TaskStatusResponse(**result).model_dump(), msg="Task status retrieved") + return success(data=_sanitize_task_result(result), msg="Task status retrieved") @router.post("/write/sync") From e1cf3bb3d20da491be31646aa25d3490a68e8c5b Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 10:21:35 +0800 Subject: [PATCH 004/113] fix(web): i18n update --- 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 d813f40f..e78964d5 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -116,7 +116,7 @@ export const en = { prompt: 'Prompt Engineering', skills: 'Skill Library', workbench: 'Workbench', - memoryRelated: 'Memory-Related', + memoryRelated: 'Memory Hub', advancedSettings: 'Advanced Settings', promptHistory: 'My history', platformManagement: 'Platform Management', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index fc846dcd..d206a1c6 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -116,7 +116,7 @@ export const zh = { prompt: '提示词工程', skills: '技能库', workbench: '工作台', - memoryRelated: '记忆相关', + memoryRelated: '记忆中枢', advancedSettings: '高级设置', promptHistory: '我的历史', platformManagement: '平台管理', From 2b52b32b96f2ee55f20c992978d52941442a38ec Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 11:36:14 +0800 Subject: [PATCH 005/113] fix(web): variable ui update --- web/src/i18n/en.ts | 3 + web/src/i18n/zh.ts | 3 + .../components/Editor/nodes/VariableNode.tsx | 16 +-- .../Editor/plugin/AutocompletePlugin.tsx | 83 ++++++------ .../components/Properties/VariableSelect.tsx | 124 +++++++++--------- .../Properties/hooks/useVariableList.ts | 4 +- .../Workflow/components/Properties/index.tsx | 8 +- 7 files changed, 121 insertions(+), 120 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e78964d5..59a303f1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2552,6 +2552,9 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re 'list-operator.input_list': 'Input list', }, checkListHasErrors: 'Please resolve all issues in the checklist before publishing', + variableSelect: { + empty: 'No variables available', + }, }, emotionEngine: { emotionEngineConfig: 'Emotion Engine Configuration', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index d206a1c6..1c3791d4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2516,6 +2516,9 @@ export const zh = { 'list-operator.input_list': '输入变量', }, checkListHasErrors: '发布前确认检查清单中所有问题均已解决', + variableSelect: { + empty: '暂无变量', + }, }, emotionEngine: { emotionEngineConfig: '情感引擎配置', diff --git a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx index 5688342c..72e73220 100644 --- a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx +++ b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx @@ -48,17 +48,13 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ return ( - {data.isContext ? ( - 📄 - ) : data.group !== 'CONVERSATION' && !data.value.includes('conv') ? ( - - ) : } + {!data.isContext && data.group !== 'CONVERSATION' && !data.value.includes('conv') + ?
+ : null + } {!data.isContext && data.group !== 'CONVERSATION' && ( <> {!data.value.includes('conv') && <> @@ -73,7 +69,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ )} )} - {data.label} + {data.label} ); }; diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index f9537032..9f718826 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -2,12 +2,13 @@ * @Author: ZhaoYing * @Date: 2025-12-23 16:22:51 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 16:51:04 + * @Last Modified time: 2026-04-13 11:12:18 */ import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; import { Space, Flex } from 'antd'; +import clsx from 'clsx'; import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; import type { NodeProperties } from '../../../types' @@ -284,23 +285,23 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { ref={popupRef} data-autocomplete-popup="true" onMouseDown={(e) => e.preventDefault()} - className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" + className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" style={{ top: popupPosition.top, left: popupPosition.left, }} > -
- - {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { - const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; - const nodeIcon = nodeOptions[0]?.nodeData?.icon; - return ( -
- {nodeName !== 'undefined' && - {nodeIcon &&
} + + {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { + const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; + return ( +
+ {nodeName !== 'undefined' && +
{nodeName} - } +
+ } + {nodeOptions.map((option) => { const globalIndex = flatOptions.indexOf(option); const isExpanded = expandedParent?.key === option.key; @@ -310,14 +311,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { key={option.key} ref={(el) => { if (el) itemRefs.current.set(option.key, el); }} data-selected={selectedIndex === globalIndex} - className="rb:pl-6! rb:pr-3! rb:py-2!" + className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { + 'rb:bg-[#F6F6F6]': selectedIndex === globalIndex || isExpanded, + 'rb:cursor-not-allowed rb:opacity-65': option.disabled, + 'rb:cursor-pointer': !option.disabled, + })} align="center" justify="space-between" - style={{ - cursor: option.disabled ? 'not-allowed' : 'pointer', - background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white', - opacity: option.disabled ? 0.5 : 1, - }} onClick={() => { if (option.disabled) return; insertMention(option); @@ -337,26 +337,27 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { } }} > - {option.label && - {option.isContext ? '📄' : `{x}`} - {option.label} - } - - {option.dataType && {option.dataType}} - {hasChildren && } + {option.label && +
+ {`{x}`} {option.label} +
+ } + + {option.dataType && {option.dataType}} + {hasChildren &&
}
); })} -
- ); - })} -
-
+
+
+ ); + })} +
{/* Child variables panel - floats to the left */} {expandedParent?.children?.length && (
= ({ options }) => { }} onMouseEnter={() => setExpandedParent(expandedParent)} > - {/* Header */} -
- +
+ {expandedParent.nodeData.name}.{expandedParent.label} {expandedParent.dataType} @@ -377,19 +377,18 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { !child.disabled && insertMention(child)} onMouseEnter={() => setSelectedIndex(childIndex)} > - {child.label} - {child.dataType && {child.dataType}} + {child.label} + {child.dataType && {child.dataType}} ); })} diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index b28d7b4f..c0207cb5 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:40:13 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-08 10:48:21 + * @Last Modified time: 2026-04-13 11:25:40 */ import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react' import { createPortal } from 'react-dom' @@ -190,20 +190,30 @@ const VariableSelect: FC = ({ {/* Trigger */}
setOpen(o => !o)} > {multiple ? ( selectedValues.length > 0 ? ( - + {selectedValues.map(v => { const s = suggestionMap.get(v); if (!s) return null; @@ -214,11 +224,11 @@ const VariableSelect: FC = ({ return ( - {!isConv && nd?.icon &&
} + {!isConv && nd?.icon &&
} {!isConv && nd?.name && {nd.name}{sep}} - + {parent ? <>{parent.label}{sep}{s.label} : s.label} = ({ ); })} - + ) : ( - {placeholder} + {placeholder} ) ) : selectedSuggestion ? (
- - {!isConversation && nodeData?.icon &&
} - {!isConversation && nodeData?.name && {nodeData.name}} - {!isConversation && nodeData?.name && {sep}} - + + {!isConversation && nodeData?.icon &&
} + {!isConversation && nodeData?.name && {nodeData.name}} + {!isConversation && nodeData?.name && {sep}} + {parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label} : selectedSuggestion.label} @@ -266,18 +278,19 @@ const VariableSelect: FC = ({ {open && createPortal(
-
- {Object.entries(filteredGroups).map(([nodeId, suggestions]) => { +
+ {Object.entries(filteredGroups).map(([nodeId, suggestions], index) => { const nd = suggestions[0].nodeData; return ( -
- - {nd.icon &&
} +
+
{nd.name} - +
{suggestions.map(s => { const isSelected = multiple ? selectedValues.includes(`{{${s.value}}}`) @@ -288,11 +301,9 @@ const VariableSelect: FC = ({ { if (el) itemRefs.current.set(s.key, el); }} - className={clsx("rb:pl-6! rb:pr-3! rb:py-1.25! rb:rounded-lg!", { - 'rb:bg-[#e6f4ff]': isSelected || isExpanded, - 'rb:bg-white rb:hover:bg-[#F6F6F6]!': !(isSelected || isExpanded), - 'rb:opacity-60': s.disabled, - 'rb:cursor-not-allowed': s.disabled, + className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { + 'rb:bg-[#F6F6F6]': isSelected || isExpanded, + 'rb:cursor-not-allowed rb:opacity-65': s.disabled, 'rb:cursor-pointer': !s.disabled, })} align="center" @@ -314,17 +325,16 @@ const VariableSelect: FC = ({ } }} > - +
{multiple && ( - + )} - {`{x}`} - {s.label} - - - {s.dataType && {s.dataType}} + {`{x}`} {s.label} +
- {hasChildren &&
} + + {s.dataType && {s.dataType}} + {hasChildren &&
}
); @@ -334,7 +344,7 @@ const VariableSelect: FC = ({ })} {Object.keys(filteredGroups).length === 0 && (
- {t('workflow.variableSelect.empty', '暂无变量')} + {t('workflow.variableSelect.empty')}
)}
@@ -346,18 +356,13 @@ const VariableSelect: FC = ({ {open && expandedParent?.children?.length && createPortal(
setExpandedParent(expandedParent)} > -
!expandedParent.disabled && handleSelect(expandedParent)} - > +
- - {expandedParent.nodeData.name}.{expandedParent.label} - + {expandedParent.nodeData.name}.{expandedParent.label} {expandedParent.dataType}
@@ -365,32 +370,27 @@ const VariableSelect: FC = ({ const isSelected = multiple ? selectedValues.includes(`{{${child.value}}}`) : `{{${child.value}}}` === value; - const hasGrandChildren = !!child.children?.length; return ( !child.disabled && handleSelect(child)} > - + {multiple && ( )} - {child.label} - - - {child.dataType && {child.dataType}} - {hasGrandChildren && } + {child.label} + + {child.dataType && {child.dataType}} + ); })} diff --git a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts index 3c4ea6f7..14dcced2 100644 --- a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts +++ b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-01-19 17:00:26 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-08 10:12:27 + * @Last Modified time: 2026-04-13 10:44:17 */ /** * useVariableList Hook @@ -414,7 +414,7 @@ export const useVariableList = ( const pd = parentLoop.getData(); const pid = pd.id; if (pd.type === 'loop') { - (pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'String', `${pid}.${cv.name}`, pd)); + (pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${pid}.${cv.name}`, pd)); } else if (pd.type === 'iteration' && pd.config.input.defaultValue) { let itemType = 'object'; const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue); diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index b5bc2d2e..f826edd9 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:39:59 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-10 17:24:19 + * @Last Modified time: 2026-04-13 10:44:19 */ import { type FC, useEffect, useState, useMemo } from "react"; import clsx from 'clsx' @@ -266,7 +266,7 @@ const Properties: FC = ({ key, label: cycleVar.name, type: 'variable', - dataType: cycleVar.type || 'String', + dataType: cycleVar.type || 'string', value: `${parentNodeId}.${cycleVar.name}`, nodeData: parentData, }); @@ -643,7 +643,7 @@ const Properties: FC = ({ key: contextKey, label: 'context', type: 'variable', - dataType: 'String', + dataType: 'string', value: `context`, nodeData: selectedNode.getData(), isContext: true, @@ -791,7 +791,7 @@ const Properties: FC = ({ key: `${selectedNode.id}_cycle_${cycleVar.name}`, label: cycleVar.name, type: 'variable', - dataType: cycleVar.type || 'String', + dataType: cycleVar.type || 'string', value: `${selectedNode.getData().id}.${cycleVar.name}`, nodeData: selectedNode.getData(), })); From 72be9f75f92ae6c2f59306bf9a4a9629e94c637a Mon Sep 17 00:00:00 2001 From: wxy Date: Mon, 13 Apr 2026 11:58:14 +0800 Subject: [PATCH 006/113] feat: Add quota check decorator and implement tenant-level API rate limiting - Add quota check decorator module quota_stub.py, providing community edition stub implementation - Add quota check decorators to multiple controllers - Implement tenant-level API call rate limiting - Remove redundant plan fields from tenant_model.py - Optimize user permission check logic with added error handling --- api/app/controllers/app_controller.py | 2 + api/app/controllers/file_controller.py | 2 + api/app/controllers/knowledge_controller.py | 2 + .../controllers/memory_storage_controller.py | 2 + api/app/controllers/model_controller.py | 6 +++ api/app/controllers/ontology_controller.py | 3 ++ .../service/end_user_api_controller.py | 2 + .../service/memory_api_controller.py | 2 + api/app/controllers/skill_controller.py | 2 + api/app/controllers/user_controller.py | 13 +++--- api/app/controllers/workspace_controller.py | 2 + api/app/core/api_key_auth.py | 32 ++++++++++++++ api/app/core/quota_stub.py | 44 +++++++++++++++++++ api/app/models/tenant_model.py | 7 +-- api/app/services/api_key_service.py | 29 ++++++++++++ 15 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 api/app/core/quota_stub.py diff --git a/api/app/controllers/app_controller.py b/api/app/controllers/app_controller.py index db3c7536..3b7240de 100644 --- a/api/app/controllers/app_controller.py +++ b/api/app/controllers/app_controller.py @@ -28,6 +28,7 @@ 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 +from app.core.quota_stub import check_app_quota router = APIRouter(prefix="/apps", tags=["Apps"]) logger = get_business_logger() @@ -35,6 +36,7 @@ logger = get_business_logger() @router.post("", summary="创建应用(可选创建 Agent 配置)") @cur_workspace_access_guard() +@check_app_quota def create_app( payload: app_schema.AppCreate, db: Session = Depends(get_db), diff --git a/api/app/controllers/file_controller.py b/api/app/controllers/file_controller.py index f7bd0e7a..6f8b1b97 100644 --- a/api/app/controllers/file_controller.py +++ b/api/app/controllers/file_controller.py @@ -19,6 +19,7 @@ from app.models.user_model import User from app.schemas import file_schema, document_schema from app.schemas.response_schema import ApiResponse from app.services import file_service, document_service +from app.core.quota_stub import check_knowledge_capacity_quota # Obtain a dedicated API logger @@ -131,6 +132,7 @@ async def create_folder( @router.post("/file", response_model=ApiResponse) +@check_knowledge_capacity_quota async def upload_file( kb_id: uuid.UUID, parent_id: uuid.UUID, diff --git a/api/app/controllers/knowledge_controller.py b/api/app/controllers/knowledge_controller.py index afda7cce..5cd87647 100644 --- a/api/app/controllers/knowledge_controller.py +++ b/api/app/controllers/knowledge_controller.py @@ -27,6 +27,7 @@ from app.schemas import knowledge_schema from app.schemas.response_schema import ApiResponse from app.services import knowledge_service, document_service from app.services.model_service import ModelConfigService +from app.core.quota_stub import check_knowledge_capacity_quota # Obtain a dedicated API logger api_logger = get_api_logger() @@ -179,6 +180,7 @@ async def get_knowledges( @router.post("/knowledge", response_model=ApiResponse) +@check_knowledge_capacity_quota async def create_knowledge( create_data: knowledge_schema.KnowledgeCreate, db: Session = Depends(get_db), diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index 76eed50f..545f8302 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -34,6 +34,7 @@ from app.services.memory_storage_service import ( search_entity, search_statement, ) +from app.core.quota_stub import check_memory_engine_quota from fastapi import APIRouter, Depends, Header from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session @@ -76,6 +77,7 @@ async def get_storage_info( @router.post("/create_config", response_model=ApiResponse) # 创建配置文件,其他参数默认 +@check_memory_engine_quota def create_config( payload: ConfigParamsCreate, current_user: User = Depends(get_current_user), diff --git a/api/app/controllers/model_controller.py b/api/app/controllers/model_controller.py index 71fd41ad..6105c3d8 100644 --- a/api/app/controllers/model_controller.py +++ b/api/app/controllers/model_controller.py @@ -15,6 +15,7 @@ from app.core.response_utils import success from app.schemas.response_schema import ApiResponse, PageData from app.services.model_service import ModelConfigService, ModelApiKeyService, ModelBaseService from app.core.logging_config import get_api_logger +from app.core.quota_stub import check_model_quota, check_model_activation_quota # 获取API专用日志器 api_logger = get_api_logger() @@ -236,6 +237,7 @@ def delete_model_base( @router.post("/model_plaza/{model_base_id}/add", response_model=ApiResponse) +@check_model_quota def add_model_from_plaza( model_base_id: uuid.UUID, db: Session = Depends(get_db), @@ -273,6 +275,7 @@ def get_model_by_id( @router.post("", response_model=ApiResponse) +@check_model_quota async def create_model( model_data: model_schema.ModelConfigCreate, db: Session = Depends(get_db), @@ -303,6 +306,7 @@ async def create_model( @router.post("/composite", response_model=ApiResponse) +@check_model_quota async def create_composite_model( model_data: model_schema.CompositeModelCreate, db: Session = Depends(get_db), @@ -329,6 +333,7 @@ async def create_composite_model( @router.put("/composite/{model_id}", response_model=ApiResponse) +@check_model_activation_quota async def update_composite_model( model_id: uuid.UUID, model_data: model_schema.CompositeModelCreate, @@ -370,6 +375,7 @@ def delete_composite_model( @router.put("/{model_id}", response_model=ApiResponse) +@check_model_activation_quota def update_model( model_id: uuid.UUID, model_data: model_schema.ModelConfigUpdate, diff --git a/api/app/controllers/ontology_controller.py b/api/app/controllers/ontology_controller.py index fe6b3598..83f75888 100644 --- a/api/app/controllers/ontology_controller.py +++ b/api/app/controllers/ontology_controller.py @@ -28,6 +28,8 @@ from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Form, H from fastapi.responses import StreamingResponse, JSONResponse from sqlalchemy.orm import Session +from app.core.quota_stub import check_ontology_project_quota + from app.core.config import settings from app.core.error_codes import BizCode from app.core.language_utils import get_language_from_header @@ -287,6 +289,7 @@ async def extract_ontology( # ==================== 本体场景管理接口 ==================== @router.post("/scene", response_model=ApiResponse) +@check_ontology_project_quota async def create_scene( request: SceneCreateRequest, db: Session = Depends(get_db), diff --git a/api/app/controllers/service/end_user_api_controller.py b/api/app/controllers/service/end_user_api_controller.py index df9996c2..92a9d7c8 100644 --- a/api/app/controllers/service/end_user_api_controller.py +++ b/api/app/controllers/service/end_user_api_controller.py @@ -9,6 +9,7 @@ from app.core.api_key_auth import require_api_key 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.quota_stub import check_end_user_quota from app.core.response_utils import success from app.db import get_db from app.repositories.end_user_repository import EndUserRepository @@ -22,6 +23,7 @@ logger = get_business_logger() @router.post("/create") @require_api_key(scopes=["memory"]) +@check_end_user_quota async def create_end_user( request: Request, api_key_auth: ApiKeyAuth = None, diff --git a/api/app/controllers/service/memory_api_controller.py b/api/app/controllers/service/memory_api_controller.py index dc5e0408..16f1e223 100644 --- a/api/app/controllers/service/memory_api_controller.py +++ b/api/app/controllers/service/memory_api_controller.py @@ -2,6 +2,7 @@ from app.core.api_key_auth import require_api_key from app.core.logging_config import get_business_logger +from app.core.quota_stub import check_end_user_quota from app.core.response_utils import success from app.db import get_db from app.schemas.api_key_schema import ApiKeyAuth @@ -119,6 +120,7 @@ async def list_memory_configs( @router.post("/end_users") @require_api_key(scopes=["memory"]) +@check_end_user_quota async def create_end_user( request: Request, api_key_auth: ApiKeyAuth = None, diff --git a/api/app/controllers/skill_controller.py b/api/app/controllers/skill_controller.py index 6e673679..4ee07c7d 100644 --- a/api/app/controllers/skill_controller.py +++ b/api/app/controllers/skill_controller.py @@ -11,11 +11,13 @@ from app.schemas import skill_schema from app.schemas.response_schema import PageData, PageMeta from app.services.skill_service import SkillService from app.core.response_utils import success +from app.core.quota_stub import check_skill_quota router = APIRouter(prefix="/skills", tags=["Skills"]) @router.post("", summary="创建技能") +@check_skill_quota def create_skill( data: skill_schema.SkillCreate, db: Session = Depends(get_db), diff --git a/api/app/controllers/user_controller.py b/api/app/controllers/user_controller.py index cc16a6b4..5a329165 100644 --- a/api/app/controllers/user_controller.py +++ b/api/app/controllers/user_controller.py @@ -114,11 +114,14 @@ def get_current_user_info( # 设置权限:如果用户来自 SSO Source,则使用该 Source 的 permissions;否则返回 "all" 表示拥有所有权限 if current_user.external_source: - from premium.sso.models import SSOSource - source = db.query(SSOSource).filter(SSOSource.source_code == current_user.external_source).first() - if source and source.permissions: - result_schema.permissions = source.permissions - else: + try: + from premium.sso.models import SSOSource + source = db.query(SSOSource).filter(SSOSource.source_code == current_user.external_source).first() + if source and source.permissions: + result_schema.permissions = source.permissions + else: + result_schema.permissions = [] + except ModuleNotFoundError: result_schema.permissions = [] else: result_schema.permissions = ["all"] diff --git a/api/app/controllers/workspace_controller.py b/api/app/controllers/workspace_controller.py index 6f4a4fa8..47068288 100644 --- a/api/app/controllers/workspace_controller.py +++ b/api/app/controllers/workspace_controller.py @@ -35,6 +35,7 @@ from app.schemas.workspace_schema import ( WorkspaceUpdate, ) from app.services import workspace_service +from app.core.quota_stub import check_workspace_quota # 获取API专用日志器 api_logger = get_api_logger() @@ -106,6 +107,7 @@ def get_workspaces( @router.post("", response_model=ApiResponse) +@check_workspace_quota def create_workspace( workspace: WorkspaceCreate, language_type: str = Header(default="zh", alias="X-Language-Type"), diff --git a/api/app/core/api_key_auth.py b/api/app/core/api_key_auth.py index 342405b8..91d6bd8a 100644 --- a/api/app/core/api_key_auth.py +++ b/api/app/core/api_key_auth.py @@ -96,6 +96,38 @@ def require_api_key( resource_id=api_key_obj.resource_id, ) + # ── Tenant 级别限速(来自套餐配额 api_ops_rate_limit)────────── + try: + from app.models.workspace_model import Workspace + from premium.platform_admin.package_plan_service import TenantSubscriptionService + + workspace = db.query(Workspace).filter( + Workspace.id == api_key_obj.workspace_id + ).first() + if workspace: + quota = TenantSubscriptionService(db).get_effective_quota(workspace.tenant_id) + tenant_qps_limit = quota.get("api_ops_rate_limit") if quota else None + if tenant_qps_limit: + rate_limiter = RateLimiterService() + tenant_ok, tenant_info = await rate_limiter.check_tenant_rate_limit( + workspace.tenant_id, tenant_qps_limit + ) + if not tenant_ok: + raise RateLimitException( + "租户 API 调用速率超限", + BizCode.API_KEY_QPS_LIMIT_EXCEEDED, + rate_headers={ + "X-RateLimit-Tenant-Limit": str(tenant_info["limit"]), + "X-RateLimit-Tenant-Remaining": str(tenant_info["remaining"]), + "X-RateLimit-Tenant-Reset": str(tenant_info["reset"]), + } + ) + except RateLimitException: + raise + except Exception as e: + logger.warning(f"Tenant 限速检查异常,跳过: {e}") + # ───────────────────────────────────────────────────────────── + rate_limiter = RateLimiterService() is_allowed, error_msg, rate_headers = await rate_limiter.check_all_limits(api_key_obj) if not is_allowed: diff --git a/api/app/core/quota_stub.py b/api/app/core/quota_stub.py new file mode 100644 index 00000000..b8f82e75 --- /dev/null +++ b/api/app/core/quota_stub.py @@ -0,0 +1,44 @@ +""" +配额检查 stub - 社区版使用,所有检查直接放行。 +企业版通过 premium.platform_admin.quota_decorator 提供真实实现。 +""" +from functools import wraps +from typing import Callable + + +def _noop_decorator(func: Callable) -> Callable: + """空装饰器,直接放行""" + return func + + +def _noop_check(*args, **kwargs): + """空检查函数,直接放行""" + pass + + +try: + from premium.platform_admin.quota_decorator import ( + check_workspace_quota, + check_skill_quota, + check_app_quota, + check_knowledge_capacity_quota, + check_memory_engine_quota, + check_end_user_quota, + check_ontology_project_quota, + check_model_quota, + check_model_activation_quota, + get_quota_usage, + _check_quota, + ) +except ModuleNotFoundError: + check_workspace_quota = _noop_decorator + check_skill_quota = _noop_decorator + check_app_quota = _noop_decorator + check_knowledge_capacity_quota = _noop_decorator + check_memory_engine_quota = _noop_decorator + check_end_user_quota = _noop_decorator + check_ontology_project_quota = _noop_decorator + check_model_quota = _noop_decorator + check_model_activation_quota = _noop_decorator + get_quota_usage = lambda db, tenant_id: {} + _check_quota = _noop_check diff --git a/api/app/models/tenant_model.py b/api/app/models/tenant_model.py index a92b5629..c3fd82df 100644 --- a/api/app/models/tenant_model.py +++ b/api/app/models/tenant_model.py @@ -29,11 +29,8 @@ class Tenants(Base): contact_email = Column(String(255), nullable=True) # 联系人邮箱 contact_phone = Column(String(50), nullable=True) # 联系人电话 - # 租户套餐信息 - plan = Column(String(50), nullable=True) # 套餐类型 - plan_expired_at = Column(DateTime, nullable=True) # 套餐到期时间 - api_ops_rate_limit = Column(String(100), nullable=True) # API 调用频率限制 - status = Column(String(50), nullable=True, default='active') # 租户状态 + # 租户套餐信息(只读,从 tenant_subscriptions 动态获取) + status = Column(String(50), nullable=True, default='active', server_default='active') # 租户状态 # Relationship to users - one tenant has many users users = relationship("User", back_populates="tenant") diff --git a/api/app/services/api_key_service.py b/api/app/services/api_key_service.py index a49e8fe0..07d55198 100644 --- a/api/app/services/api_key_service.py +++ b/api/app/services/api_key_service.py @@ -248,6 +248,35 @@ class RateLimiterService: def __init__(self): self.redis = aio_redis + async def check_tenant_rate_limit(self, tenant_id: uuid.UUID, limit: int) -> Tuple[bool, dict]: + """ + 按 tenant_id 做 1 秒滑动窗口限速,限制值来自套餐配额 api_ops_rate_limit + """ + now = time.time() + window_start = now - 1 # 1 秒窗口 + key = f"rate_limit:tenant_qps:{tenant_id}" + + async with self.redis.pipeline() as pipe: + # 清理 1 秒前的旧记录 + pipe.zremrangebyscore(key, 0, window_start) + # 加入当前请求(score=时间戳,member=时间戳+随机数保证唯一) + pipe.zadd(key, {f"{now}:{uuid.uuid4().hex}": now}) + # 统计窗口内请求数 + pipe.zcard(key) + # 设置 key 过期(2 秒后自动清理) + pipe.expire(key, 2) + results = await pipe.execute() + + current = results[2] + remaining = max(0, limit - current) + reset_time = int(now) + 1 + + return current <= limit, { + "limit": limit, + "remaining": remaining, + "reset": reset_time, + } + async def check_qps(self, api_key_id: uuid.UUID, limit: int) -> Tuple[bool, dict]: """ 检查QPS限制 From 520ee7c132b3ed4bad8bd3c619e8a4ffb7f12097 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 12:01:37 +0800 Subject: [PATCH 007/113] fix(web): sub node connected --- .../views/Workflow/hooks/useWorkflowGraph.ts | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index f385acf3..5d0bb9c6 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-04-07 23:17:50 + * @Last Modified time: 2026-04-13 12:00:09 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -1022,24 +1022,39 @@ export const useWorkflowGraph = ({ graphRef.current.on('node:removed', blankClick) // When edge connected, bring connected nodes' ports to front - graphRef.current.on('edge:connected', ({ isNew }) => { - // Bring edge to front first, then bring child nodes above edges - // Parent (loop/iteration) nodes stay behind to avoid covering edges - // Reset any port hover state left from dragging + graphRef.current.on('edge:connected', ({ isNew, edge }) => { if (isNew) { - graphRef.current?.getNodes().forEach(node => { - if (!node.getData()?.cycle) node.toFront(); - }); - graphRef.current?.getEdges().forEach(edge => { - const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); - const targetCell = graphRef.current?.getCellById(edge.getTargetCellId()); - if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) { - edge.toFront(); - } - }); - graphRef.current?.getNodes().forEach(node => { - if (node.getData()?.cycle) node.toFront(); - }); + const sourceCellId = edge.getSourceCellId() + const targetCellId = edge.getTargetCellId() + const sourceCell = graphRef.current?.getCellById(sourceCellId); + const targetCell = graphRef.current?.getCellById(targetCellId); + + sourceCell?.toFront(); + targetCell?.toFront() + if (['loop', 'iteration'].includes(sourceCell?.getData()?.type)) { + graphRef.current?.getEdges().forEach(edge => { + const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); + const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId()); + if (edgeSourceCell?.getData()?.cycle === sourceCellId || edgeTargetCell?.getData()?.cycle === sourceCellId) { + edge.toFront(); + } + }); + graphRef.current?.getNodes().forEach(node => { + if (node.getData()?.cycle === sourceCellId) node.toFront(); + }); + } + if (['loop', 'iteration'].includes(targetCell?.getData()?.type)) { + graphRef.current?.getEdges().forEach(edge => { + const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); + const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId()); + if (edgeSourceCell?.getData()?.cycle === targetCellId || edgeTargetCell?.getData()?.cycle === targetCellId) { + edge.toFront(); + } + }); + graphRef.current?.getNodes().forEach(node => { + if (node.getData()?.cycle === targetCellId) node.toFront(); + }); + } } }); From 988d101e935de0b6898612cbec23edc45f2d366d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 12:12:49 +0800 Subject: [PATCH 008/113] fix(web): tool checklist --- .../Workflow/components/CheckList/index.tsx | 77 ++++--------------- 1 file changed, 13 insertions(+), 64 deletions(-) diff --git a/web/src/views/Workflow/components/CheckList/index.tsx b/web/src/views/Workflow/components/CheckList/index.tsx index 636ae9a9..0256416a 100644 --- a/web/src/views/Workflow/components/CheckList/index.tsx +++ b/web/src/views/Workflow/components/CheckList/index.tsx @@ -79,7 +79,6 @@ const specialValidators: Record boolean> = { } function isEmpty(val: any): boolean { - console.log('validateNode isEmpty', val, val === undefined || val === null || val === '') if (val === undefined || val === null || val === '') return true if (Array.isArray(val)) return val.length === 0 return false @@ -98,7 +97,6 @@ function validateNode(type: string, config: Record): CheckError[] { const specialKey = `${type}.${field}` const specialValidator = specialValidators[specialKey] const isInvalid = specialValidator ? specialValidator(val) : isEmpty(val) - console.log('validateNode', val, specialKey, specialValidator, isEmpty(val)) if (isInvalid) errors.push({ key: specialKey, message: '' }) }) @@ -114,62 +112,6 @@ function validateNode(type: string, config: Record): CheckError[] { return errors } -export async function runCheckOnGraph( - graph: import('@antv/x6').Graph, - t: (key: string) => string -): Promise { - const nodes = graph.getNodes() - const edges = graph.getEdges() - const targetIds = new Set() - const childTargetIds = new Set() - edges.forEach(e => { - targetIds.add(e.getTargetCellId()) - const srcData = graph.getCellById(e.getSourceCellId())?.getData() - const tgtData = graph.getCellById(e.getTargetCellId())?.getData() - if (srcData?.cycle && tgtData?.cycle && srcData.cycle === tgtData.cycle) { - childTargetIds.add(e.getTargetCellId()) - } - }) - - const checked: NodeCheckResult[] = [] - for (const node of nodes) { - const data = node.getData() - if (!data || ['add-node', 'notes', 'cycle-start', 'break'].includes(data.type)) continue - - const errors: CheckError[] = [] - const isChildNode = !!data.cycle - const hasIncoming = isChildNode ? childTargetIds.has(node.id) : !['start', 'cycle-start'].includes(data.type) ? targetIds.has(node.id) : true - if (!hasIncoming) errors.push({ key: 'notConnected', message: t('workflow.notConnected') }) - - const configErrors = validateNode(data.type, data.config ?? {}) - configErrors.forEach(e => { - errors.push({ key: e.key, message: `${t(`workflow.checkListErrors.${e.key}`)} ${t('workflow.cannotBeEmpty')}`.trim() }) - }) - - if (data.type === 'tool') { - const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id - const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {} - if (toolId) { - try { - const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> - const operation = toolParameters?.operation - const method = operation ? methods.find(m => m.name === operation) : methods[0] - if (method) { - method.parameters - .filter(p => p.required && (toolParameters[p.name] === undefined || toolParameters[p.name] === null || toolParameters[p.name] === '')) - .forEach(p => errors.push({ key: 'tool.tool_parameters', message: `${p.name} ${t('workflow.cannotBeEmpty')}` })) - } - } catch { /* ignore */ } - } - } - - if (errors.length) { - checked.push({ id: node.id, name: data.name || t(`workflow.${data.type}`), type: data.type, icon: nodeIconMap[data.type] ?? '', errors }) - } - } - return checked -} - const CheckList: FC = ({ workflowRef, appId }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -222,7 +164,8 @@ const CheckList: FC = ({ workflowRef, appId }) => { if (data.type === 'tool') { const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {} - if (toolId) { + + if (typeof toolId === 'string') { try { const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> const operation = toolParameters?.operation @@ -251,21 +194,27 @@ const CheckList: FC = ({ workflowRef, appId }) => { return checked }, [workflowRef.current?.graphRef?.current, t]) + const scheduleCheckRef = useRef<() => void>() + const scheduleCheck = useCallback(() => { clearTimeout(timerRef.current) timerRef.current = setTimeout(async () => { setCheckResults(appId, await runCheck()) - }, 500) + }, 300) }, [runCheck]) + scheduleCheckRef.current = scheduleCheck + useEffect(() => { const graph = workflowRef.current?.graphRef?.current + console.log('graph') if (!graph) return - const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed'] - events.forEach(e => graph.on(e, scheduleCheck)) - scheduleCheck() + const handler = () => scheduleCheckRef.current?.() + const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed', 'edge:connected', 'edge:changed'] + events.forEach(e => graph.on(e, handler)) + scheduleCheckRef.current?.() return () => { - events.forEach(e => graph.off(e, scheduleCheck)) + events.forEach(e => graph.off(e, handler)) clearTimeout(timerRef.current) } }, [workflowRef.current?.graphRef?.current]) From efdee32f8566e9aadcaaa4a21347e62f9c6585f6 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 12:16:32 +0800 Subject: [PATCH 009/113] fix(web): update chat variable defaultValue validate rule --- .../components/AddChatVariable/ChatVariableModal.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx index 5666f3ab..5c80fab1 100644 --- a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx +++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-30 13:59:36 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-08 11:05:34 + * @Last Modified time: 2026-04-13 12:16:00 */ import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react'; import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd'; @@ -345,15 +345,16 @@ const ChatVariableModal = forwardRef { if (!value) return Promise.resolve(); try { JSON.parse(value); return Promise.resolve(); } catch { return Promise.reject(t('workflow.invalidJSON')); } } - } : {} - ]} + }] + : undefined + } > {type === 'number' ? From 62355186ef582b1dfcc4d7ad6c5f252296fd41da Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 13:38:10 +0800 Subject: [PATCH 010/113] fix(web): echarts grid --- web/src/views/UserMemoryDetail/components/InterestAreas.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/views/UserMemoryDetail/components/InterestAreas.tsx b/web/src/views/UserMemoryDetail/components/InterestAreas.tsx index 91554880..4d9be5b5 100644 --- a/web/src/views/UserMemoryDetail/components/InterestAreas.tsx +++ b/web/src/views/UserMemoryDetail/components/InterestAreas.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 18:32:53 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-16 14:27:12 + * @Last Modified time: 2026-04-13 13:37:43 */ import { useEffect, useState, forwardRef, useImperativeHandle, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -93,7 +93,7 @@ const InterestAreas = forwardRef<{ handleRefresh: () => void; }>((_props, ref) = ref={chartRef} option={{ color: Colors, - grid: { top: 8, left: 38, right: 8, bottom: 24 }, + grid: { top: 14, left: 38, right: 8, bottom: 24 }, xAxis: { type: 'category', data: keys.map(k => t(`implicitDetail.${k}`)), From 7a2a941ac4d94734cec11170de68b5a4ad240202 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 13 Apr 2026 13:47:59 +0800 Subject: [PATCH 011/113] refactor(neo4j): rename execute_query parameter from query to cypher Improves readability by making the parameter name explicitly reflect that it expects a Cypher query string rather than a generic query. --- api/app/repositories/neo4j/neo4j_connector.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/app/repositories/neo4j/neo4j_connector.py b/api/app/repositories/neo4j/neo4j_connector.py index ea8fa917..d20bf75f 100644 --- a/api/app/repositories/neo4j/neo4j_connector.py +++ b/api/app/repositories/neo4j/neo4j_connector.py @@ -77,11 +77,11 @@ class Neo4jConnector: """ await self.driver.close() - async def execute_query(self, query: str, json_format=False, **kwargs: Any) -> List[Dict[str, Any]]: + async def execute_query(self, cypher: str, json_format=False, **kwargs: Any) -> List[Dict[str, Any]]: """执行Cypher查询 Args: - query: Cypher查询语句 + cypher: Cypher查询语句 json_format: json格式化 **kwargs: 查询参数,将作为参数传递给Cypher查询 @@ -92,7 +92,7 @@ class Neo4jConnector: """ result = await self.driver.execute_query( - query, + cypher, database="neo4j", **kwargs ) From ac51ccaf1f23b75d79ae98172a323791b3d5c821 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 14:04:31 +0800 Subject: [PATCH 012/113] fix(web): ui fix --- .../Editor/plugin/AutocompletePlugin.tsx | 46 ++++++++++--------- .../components/Properties/VariableSelect.tsx | 2 +- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 9f718826..6d3b7a4f 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-23 16:22:51 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-13 11:12:18 + * @Last Modified time: 2026-04-13 14:00:07 */ import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; @@ -285,23 +285,24 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { ref={popupRef} data-autocomplete-popup="true" onMouseDown={(e) => e.preventDefault()} - className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" + className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" style={{ top: popupPosition.top, left: popupPosition.left, }} > - - {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { - const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; - return ( -
- {nodeName !== 'undefined' && -
- {nodeName} -
- } - +
+ + {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { + const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; + return ( +
+ {nodeName !== 'undefined' && +
+ {nodeName} +
+ } + {nodeOptions.map((option) => { const globalIndex = flatOptions.indexOf(option); const isExpanded = expandedParent?.key === option.key; @@ -344,20 +345,21 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { } {option.dataType && {option.dataType}} - {hasChildren &&
} + {hasChildren &&
}
); })} - -
- ); - })} -
+ +
+ ); + })} +
+
{/* Child variables panel - floats to the left */} {expandedParent?.children?.length && (
= ({ options }) => { onClick={() => !child.disabled && insertMention(child)} onMouseEnter={() => setSelectedIndex(childIndex)} > - {child.label} + + {`{x}`} {child.label} + {child.dataType && {child.dataType}} ); diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index c0207cb5..5523c06e 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -334,7 +334,7 @@ const VariableSelect: FC = ({ {s.dataType && {s.dataType}} - {hasChildren &&
} + {hasChildren &&
}
); From 5bb9ce9018eee47a8b8277c446d4f2d9090ffae4 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Mon, 13 Apr 2026 14:40:57 +0800 Subject: [PATCH 013/113] fix(user): add user retrieval regardless of active status and update DSL config enrichment Added `get_user_by_id_regardless_active` in user repository to support activation/deactivation workflows, updated `user_service` to use it, and refactored `_enrich_release_config` in `app_dsl_service` to accept `default_model_config_id` as a parameter instead of reading from config dict. --- api/app/repositories/user_repository.py | 4 ++++ api/app/services/app_dsl_service.py | 10 ++++------ api/app/services/user_service.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/api/app/repositories/user_repository.py b/api/app/repositories/user_repository.py index 2dd76b04..6874f9bf 100644 --- a/api/app/repositories/user_repository.py +++ b/api/app/repositories/user_repository.py @@ -297,6 +297,10 @@ def get_user_by_id(db: Session, user_id: uuid.UUID) -> Optional[User]: """根据ID获取用户""" return UserRepository(db).get_user_by_id(user_id) +def get_user_by_id_regardless_active(db: Session, user_id: uuid.UUID) -> Optional[User]: + """根据ID获取用户(不过滤 is_active,用于启用/禁用场景)""" + return db.query(User).filter(User.id == user_id).first() + def get_user_by_email(db: Session, email: str) -> Optional[User]: """根据邮箱获取用户""" return UserRepository(db).get_user_by_email(email) diff --git a/api/app/services/app_dsl_service.py b/api/app/services/app_dsl_service.py index 8c198be4..3a897109 100644 --- a/api/app/services/app_dsl_service.py +++ b/api/app/services/app_dsl_service.py @@ -73,15 +73,14 @@ class AppDslService: AppType.MULTI_AGENT: "multi_agent_config", AppType.WORKFLOW: "workflow" }.get(app.type, "config") - config_data = self._enrich_release_config(app.type, release.config or {}) + config_data = self._enrich_release_config(app.type, release.config or {}, release.default_model_config_id) 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: + def _enrich_release_config(self, app_type: str, cfg: dict, default_model_config_id=None) -> 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"]) + enriched["default_model_config_ref"] = self._model_ref(default_model_config_id) if "knowledge_retrieval" in cfg: enriched["knowledge_retrieval"] = self._enrich_knowledge_retrieval(cfg["knowledge_retrieval"]) if "tools" in cfg: @@ -91,8 +90,7 @@ class AppDslService: 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"]) + enriched["default_model_config_ref"] = self._model_ref(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: diff --git a/api/app/services/user_service.py b/api/app/services/user_service.py index 3122d282..43a58c5f 100644 --- a/api/app/services/user_service.py +++ b/api/app/services/user_service.py @@ -285,7 +285,7 @@ def activate_user(db: Session, user_id_to_activate: uuid.UUID, current_user: Use try: # 查找用户 business_logger.debug(f"查找待激活用户: {user_id_to_activate}") - db_user = user_repository.get_user_by_id(db, user_id=user_id_to_activate) + db_user = user_repository.get_user_by_id_regardless_active(db, user_id=user_id_to_activate) if not db_user: business_logger.warning(f"用户不存在: {user_id_to_activate}") raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND) From 2b067ce08a013654fb875b09fbbb1eb04617061b Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 15:35:45 +0800 Subject: [PATCH 014/113] fix(web): third variable --- .../OpenStatementSettingModal.tsx | 4 +-- .../AddChatVariable/ChatVariableModal.tsx | 4 +-- .../components/Nodes/ConditionNode.tsx | 2 ++ .../Properties/AssignmentList/index.tsx | 27 ++++++++++++++++--- .../components/Properties/CaseList/index.tsx | 4 ++- .../Properties/ConditionList/index.tsx | 4 ++- .../Properties/GroupVariableList/index.tsx | 8 ++++-- .../views/Workflow/hooks/useWorkflowGraph.ts | 3 ++- 8 files changed, 43 insertions(+), 13 deletions(-) diff --git a/web/src/views/ApplicationConfig/components/FeaturesConfig/OpenStatementSettingModal.tsx b/web/src/views/ApplicationConfig/components/FeaturesConfig/OpenStatementSettingModal.tsx index 91d0d19f..a46d973a 100644 --- a/web/src/views/ApplicationConfig/components/FeaturesConfig/OpenStatementSettingModal.tsx +++ b/web/src/views/ApplicationConfig/components/FeaturesConfig/OpenStatementSettingModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-03-05 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 16:58:10 + * @Last Modified time: 2026-04-13 15:13:36 */ import { forwardRef, useImperativeHandle, useState } from 'react'; import { Button, Form, Input, Flex, App } from 'antd'; @@ -36,8 +36,6 @@ const OpenStatementSettingModal = forwardRef(); - console.log('chatVariables', chatVariables) - const handleClose = () => { setVisible(false); form.resetFields(); diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx index 5c80fab1..e4f62432 100644 --- a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx +++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-30 13:59:36 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-13 12:16:00 + * @Last Modified time: 2026-04-13 15:26:33 */ import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react'; import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd'; @@ -136,7 +136,7 @@ const ChatVariableModal = forwardRef { const defaultValue = Array.isArray(values.defaultValue) ? values.defaultValue.filter((v: any) => v !== undefined && v !== null && v !== '') - : values.type.includes('object') + : values.type.includes('object') && values.defaultValue ? JSON.parse(values.defaultValue) : values.defaultValue; refresh({ ...values, defaultValue }, editIndex); diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index 996ae5dd..625a1b4d 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -31,6 +31,8 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { }; const labelRender = (value: string) => { const filterOption = variableList.find(vo => `{{${vo.value}}}` === value) + ?? variableList.flatMap(vo => vo.children ?? []).find(child => `{{${child.value}}}` === value) + ?? variableList.flatMap(vo => vo.children ?? []).flatMap((child: any) => child.children ?? []).find((grandchild: any) => `{{${grandchild.value}}}` === value) if (filterOption) { return ( diff --git a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx index 98f86ecf..e24d531d 100644 --- a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx +++ b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx @@ -30,6 +30,25 @@ const operationsObj = { ], } +const filterByDataType = (options: Suggestion[], dataType: string): Suggestion[] => + options.reduce((acc, vo) => { + if (vo.children?.length) { + const children = vo.children.reduce((cacc, child) => { + if (child.children?.length) { + const grandchildren = child.children.filter(gc => gc.dataType === dataType); + if (grandchildren.length) cacc.push({ ...child, children: grandchildren }); + } else if (child.dataType === dataType) { + cacc.push(child); + } + return cacc; + }, []); + if (children.length) acc.push({ ...vo, children }); + } else if (vo.dataType === dataType) { + acc.push(vo); + } + return acc; + }, []); + const AssignmentList: FC = ({ parentName, options = [], @@ -59,7 +78,9 @@ const AssignmentList: FC = ({ {fields.map(({ key, name, ...restField }) => { const variableSelector = form.getFieldValue([parentName, name, 'variable_selector']); - const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector); + const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector) + ?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === variableSelector) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === variableSelector); const dataType = selectedOption?.dataType; const operationOptions = dataType === 'number' ? operationsObj.number : operationsObj.default; @@ -119,7 +140,7 @@ const AssignmentList: FC = ({ {dataType === 'number' && operation === 'cover' ? vo.dataType === dataType) : options} + options={dataType ? filterByDataType(options, dataType) : options} size={size} className="rb:flex-1!" variant="filled" @@ -150,7 +171,7 @@ const AssignmentList: FC = ({ : vo.dataType === dataType) : options} + options={dataType ? filterByDataType(options, dataType) : options} size={size} className="rb:flex-1!" variant="filled" diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index 40353f64..f0a58517 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -329,7 +329,9 @@ const CaseList: FC = ({ const currentExpression = currentCase.expressions?.[conditionIndex] || {}; const currentOperator = currentExpression.operator; const leftFieldValue = currentExpression.left; - const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue); + const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue); const leftFieldType = leftFieldOption?.dataType; const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]' || leftFieldType === 'array[file]'; const operatorList = leftFieldType && operatorsObj[leftFieldType] diff --git a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx index ddf92971..3e9f3261 100644 --- a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx +++ b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx @@ -155,7 +155,9 @@ const ConditionList: FC = ({ const currentExpression = expressions[index] || {}; const currentOperator = currentExpression.operator; const leftFieldValue = currentExpression.left; - const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue); + const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue); const leftFieldType = leftFieldOption?.dataType; const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || ['array[object]', 'object'].includes(leftFieldType as string); const operatorList = leftFieldType && ['array[object]', 'object'].includes(leftFieldType) diff --git a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx index 0100707c..24cdc89a 100644 --- a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx +++ b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx @@ -62,14 +62,18 @@ const GroupVariableList: FC = ({ */ useEffect(() => { if (!isCanAdd && value[0]) { - const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0]); + const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0]) + ?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === value[0]) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === value[0]); if (firstVariable) { form.setFieldValue(['group_type', 'output'], firstVariable.dataType); } } else if (isCanAdd) { value.forEach((item: any, index: number) => { if (item?.value?.[0]) { - const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0]); + const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0]) + ?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === item.value[0]) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === item.value[0]); if (firstVariable) { form.setFieldValue(['group_type', index], firstVariable.dataType); } diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 5d0bb9c6..ac82b230 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-04-13 12:00:09 + * @Last Modified time: 2026-04-13 15:33:58 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -111,6 +111,7 @@ export const useWorkflowGraph = ({ graphRef.current.getNodes().forEach(node => { const data = node.getData() if (data?.type === 'if-else' || data?.type === 'question-classifier') { + console.log('chatVariables', chatVariables) node.setData({ ...data, chatVariables }, { silent: true }) } }) From ef8c7093b5aec0c87e49c72e561c98acc2c11c41 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 13 Apr 2026 18:32:43 +0800 Subject: [PATCH 015/113] refactor(memory): use MemorySummary node count for implicit memory metrics - Replace Statement-based implicit memory count (count/3) with actual MemorySummary node count filtered by DERIVED_FROM_STATEMENT relationship - Add minimum threshold of 5 MemorySummary nodes before reporting data - Add _build_empty_profile() to return structured empty profile when insufficient data exists, skipping unnecessary LLM calls --- api/app/services/implicit_memory_service.py | 61 +++++++++++++++++++++ api/app/services/user_memory_service.py | 22 ++++---- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/api/app/services/implicit_memory_service.py b/api/app/services/implicit_memory_service.py index 4bd11deb..10504fe7 100644 --- a/api/app/services/implicit_memory_service.py +++ b/api/app/services/implicit_memory_service.py @@ -379,12 +379,59 @@ class ImplicitMemoryService: raise + def _build_empty_profile(self) -> dict: + """构建 MemorySummary 不足时返回的固定空白画像数据""" + now_ms = int(datetime.now().timestamp() * 1000) + insufficient = "Insufficient data for analysis" + + def _empty_dimension(name: str) -> dict: + return { + "evidence": [insufficient], + "reasoning": f"No clear evidence found for {name} dimension", + "percentage": 0.0, + "dimension_name": name, + "confidence_level": 20, + } + + def _empty_category(name: str) -> dict: + return { + "evidence": [insufficient], + "percentage": 25.0, + "category_name": name, + "trending_direction": None, + } + + return { + "habits": [], + "portrait": { + "aesthetic": _empty_dimension("aesthetic"), + "creativity": _empty_dimension("creativity"), + "literature": _empty_dimension("literature"), + "technology": _empty_dimension("technology"), + "historical_trends": None, + "analysis_timestamp": now_ms, + "total_summaries_analyzed": 0, + }, + "preferences": [], + "interest_areas": { + "art": _empty_category("art"), + "tech": _empty_category("tech"), + "music": _empty_category("music"), + "lifestyle": _empty_category("lifestyle"), + "analysis_timestamp": now_ms, + "total_summaries_analyzed": 0, + }, + } + async def generate_complete_profile( self, user_id: str ) -> dict: """生成完整的用户画像(包含所有4个模块) + 需要该用户的 MemorySummary 节点数量 >= 5 才会真正调用 LLM 生成画像, + 否则返回固定的空白画像数据。 + Args: user_id: 用户ID @@ -394,6 +441,20 @@ class ImplicitMemoryService: logger.info(f"生成完整用户画像: user={user_id}") try: + # 前置检查:查询该用户有效的 MemorySummary 节点数量(排除孤立节点) + query = """ + MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) + WHERE n.end_user_id = $end_user_id + RETURN count(DISTINCT n) as count + """ + result = await self.neo4j_connector.execute_query(query, end_user_id=user_id) + memory_summary_count = result[0]["count"] if result and len(result) > 0 else 0 + logger.info(f"用户 MemorySummary 节点数量: {memory_summary_count} (user={user_id})") + + if memory_summary_count < 5: + logger.info(f"MemorySummary 数量不足 5(当前 {memory_summary_count}),返回空白画像: user={user_id}") + return self._build_empty_profile() + # 并行调用4个分析方法 preferences, portrait, interest_areas, habits = await asyncio.gather( self.get_preference_tags(user_id=user_id), diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index ab51d922..dcbedba6 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -1500,7 +1500,7 @@ async def analytics_memory_types( 2. 工作记忆 (WORKING_MEMORY) = 会话数量(通过 ConversationRepository.get_conversation_by_user_id 获取) 3. 短期记忆 (SHORT_TERM_MEMORY) = /short_term 接口返回的问答对数量 4. 显性记忆 (EXPLICIT_MEMORY) = 情景记忆 + 语义记忆(通过 MemoryBaseService.get_explicit_memory_count 获取) - 5. 隐性记忆 (IMPLICIT_MEMORY) = Statement 节点数量的三分之一 + 5. 隐性记忆 (IMPLICIT_MEMORY) = MemorySummary 节点数量(需 >= 5 才显示,否则为 0) 6. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取) 7. 情景记忆 (EPISODIC_MEMORY) = memory_summary(通过 MemoryBaseService.get_episodic_memory_count 获取) 8. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_memory_count 获取) @@ -1557,23 +1557,23 @@ async def analytics_memory_types( logger.warning(f"获取会话数量失败,工作记忆数量设为0: {str(e)}") work_count = 0 - # 获取隐性记忆数量(基于 Statement 节点数量的三分之一) + # 获取隐性记忆数量(基于有关联关系的 MemorySummary 节点数量,需 >= 5 才计入) implicit_count = 0 if end_user_id: try: - # 查询 Statement 节点数量 + # 只统计有 DERIVED_FROM_STATEMENT 关系的 MemorySummary 节点,排除孤立节点 query = """ - MATCH (n:Statement) + MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) WHERE n.end_user_id = $end_user_id - RETURN count(n) as count + RETURN count(DISTINCT n) as count """ result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id) - statement_count = result[0]["count"] if result and len(result) > 0 else 0 - # 取三分之一作为隐性记忆数量 - implicit_count = round(statement_count / 3) - logger.debug(f"隐性记忆数量(Statement数量的1/3): {implicit_count} (Statement总数={statement_count}, end_user_id={end_user_id})") + memory_summary_count = result[0]["count"] if result and len(result) > 0 else 0 + # 仅当 MemorySummary 节点数量 >= 5 时才显示数量,否则为 0 + implicit_count = memory_summary_count if memory_summary_count >= 5 else 0 + logger.debug(f"隐性记忆数量(有效MemorySummary节点数): {implicit_count} (有效MemorySummary总数={memory_summary_count}, end_user_id={end_user_id})") except Exception as e: - logger.warning(f"获取Statement数量失败,隐性记忆数量设为0: {str(e)}") + logger.warning(f"获取MemorySummary数量失败,隐性记忆数量设为0: {str(e)}") implicit_count = 0 # 原有的基于行为习惯的统计方式(已注释) @@ -1639,7 +1639,7 @@ async def analytics_memory_types( "WORKING_MEMORY": work_count, # 工作记忆(基于会话数量) "SHORT_TERM_MEMORY": short_term_count, # 短期记忆(基于问答对数量) "EXPLICIT_MEMORY": explicit_count, # 显性记忆(情景记忆 + 语义记忆) - "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(Statement数量的1/3) + "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(MemorySummary节点数,需>=5) "EMOTIONAL_MEMORY": emotion_count, # 情绪记忆(使用情绪标签统计) "EPISODIC_MEMORY": episodic_count, # 情景记忆 "FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值) From 095f4e3001529862e0bf2f9e074a3c5b1d3df619 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 18:33:45 +0800 Subject: [PATCH 016/113] feat(web): app import and Overwrite --- web/src/i18n/en.ts | 2 ++ web/src/i18n/zh.ts | 2 ++ .../components/ConfigHeader.tsx | 18 ++++++++++--- .../components/UploadModal.tsx | 27 ++++++++++++------- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c878476b..fc3a041d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1522,6 +1522,8 @@ export const en = { "version":"app_release_id" // string, optional, application version ID; specify a historical release version ID, or omit to use the currently active version; }`, + uploadCover: 'Import and Overwrite', + refresh: 'Refresh Current Page', }, userMemory: { userMemory: 'User Memory', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index da80fed2..01c766b8 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -857,6 +857,8 @@ export const zh = { "version":"app_release_id" //string,可选,应用版本ID;指定历史发布版本ID,不传则使用当前生效版本; }`, + uploadCover: '导入并覆盖', + refresh: '刷新当前页', }, table: { totalRecords: '共 {{total}} 条记录' diff --git a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx index d38a657a..a3a9bd7b 100644 --- a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx +++ b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:27:52 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 16:28:33 + * @Last Modified time: 2026-04-13 18:19:27 */ import { type FC, useRef, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; @@ -12,13 +12,14 @@ import { useTranslation } from 'react-i18next'; import clsx from 'clsx'; import styles from '../index.module.css' -import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types'; +import type { Application, ApplicationModalRef, UploadWorkflowModalRef } from '@/views/ApplicationManagement/types'; import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal' import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FeaturesConfigForm } from '../types' import { deleteApplication, appExport } from '@/api/application' import CopyModal from './CopyModal' import PageHeader from '@/components/Layout/PageHeader' import CheckList from '@/views/Workflow/components/CheckList' +import UploadModal from '@/views/ApplicationManagement/components/UploadModal' /** * Tab keys for application configuration @@ -77,6 +78,7 @@ const ConfigHeader: FC = ({ const { id, source } = useParams(); const applicationModalRef = useRef(null); const copyModalRef = useRef(null); + const uploadModalRef = useRef(null); /** * Format tab items for display @@ -111,6 +113,9 @@ const ConfigHeader: FC = ({ case 'delete': handleDelete() break; + case 'uploadCover': + uploadModalRef.current?.handleOpen() + break } } /** @@ -165,11 +170,11 @@ const ConfigHeader: FC = ({ * Format dropdown menu items */ const formatMenuItems = useMemo(() => { - const items = (application?.type !== 'multi_agent' ? ['edit', 'copy', 'export', 'delete'] : ['edit', 'copy', 'delete']).map(key => ({ + const items = (application?.type !== 'multi_agent' ? ['edit', 'copy', 'export', 'uploadCover', 'delete'] : ['edit', 'copy', 'delete']).map(key => ({ key, icon:
, danger: key === 'delete', - label: t(`common.${key}`), + label: key === 'uploadCover' ? t('application.uploadCover') : t(`common.${key}`), })) return items }, [t, handleClick, application]) @@ -261,6 +266,11 @@ const ConfigHeader: FC = ({ refresh={refresh} /> + ); }; diff --git a/web/src/views/ApplicationManagement/components/UploadModal.tsx b/web/src/views/ApplicationManagement/components/UploadModal.tsx index a7acc093..4211e72b 100644 --- a/web/src/views/ApplicationManagement/components/UploadModal.tsx +++ b/web/src/views/ApplicationManagement/components/UploadModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-28 14:08:14 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-12 17:19:46 + * @Last Modified time: 2026-04-13 18:17:32 */ /** * UploadModal Component @@ -28,6 +28,7 @@ import { appImport } from '@/api/application' interface UploadModalProps { /** Function to refresh the parent component after workflow import */ refresh: () => void; + id?: string; } @@ -46,10 +47,11 @@ const steps = [ * @param {React.Ref} ref - Ref for imperative methods */ const UploadModal = forwardRef(({ - refresh + refresh, + id }, ref) => { const { t } = useTranslation(); - + // State management const [visible, setVisible] = useState(false); // Modal visibility const [form] = Form.useForm<{ file: File[] }>(); // Form instance @@ -87,8 +89,8 @@ const UploadModal = forwardRef(({ */ const handleSave = () => { const values = form.getFieldsValue(); - - switch(current) { + + switch (current) { case 0: // Step 1: Upload file if (!values.file || values.file.length === 0) { message.warning(t('application.pleaseUploadFile')); @@ -96,6 +98,9 @@ const UploadModal = forwardRef(({ } const formData = new FormData(); formData.append('file', values.file[0]); + if (id) { + formData.append('app_id', id) + } setLoading(true) // Call import API @@ -134,8 +139,12 @@ const UploadModal = forwardRef(({ setTimeout(() => { switch (type) { case 'detail': - // Open application detail page in new tab - window.open(`/#/application/config/${appId}`, '_blank'); + if (id) { + window.location.reload(); + } else { + // Open application detail page in new tab + window.open(`/#/application/config/${appId}`, '_blank'); + } break; } }, 100) @@ -171,7 +180,7 @@ const UploadModal = forwardRef(({ loading={loading} onClick={() => handleJump('detail')} > - {t('application.gotoDetail')} + {id ? t('application.refresh') : t('application.gotoDetail')} ] default: @@ -244,7 +253,7 @@ const UploadModal = forwardRef(({ loading={loading} onClick={() => handleJump('detail')} > - {t('application.gotoDetail')} + {id ? t('application.refresh') : t('application.gotoDetail')} ]} /> From 10f1089198a52b377439c971cc8ef6f621a6c380 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Mon, 13 Apr 2026 18:38:12 +0800 Subject: [PATCH 017/113] feat(workflow): refactor iteration runtime to support independent subgraph per task feat(app): support file metadata in chat messages and DSL app overwrite - Extended chat message file objects with `name`, `size`, and `file_type` fields across app_chat_service and workflow_service - Added ability to overwrite existing app configurations via DSL import in app_dsl_service, including type validation and config update logic for AgentConfig, MultiAgentConfig, and WorkflowConfig --- api/app/controllers/app_controller.py | 10 +- .../workflow/nodes/cycle_graph/iteration.py | 172 ++++++++++++------ .../core/workflow/nodes/cycle_graph/node.py | 17 +- api/app/schemas/app_schema.py | 2 + api/app/services/app_chat_service.py | 30 ++- api/app/services/app_dsl_service.py | 137 +++++++++++--- api/app/services/draft_run_service.py | 15 +- api/app/services/workflow_service.py | 5 +- 8 files changed, 288 insertions(+), 100 deletions(-) diff --git a/api/app/controllers/app_controller.py b/api/app/controllers/app_controller.py index db3c7536..b4a209f8 100644 --- a/api/app/controllers/app_controller.py +++ b/api/app/controllers/app_controller.py @@ -1250,9 +1250,11 @@ async def export_app( async def import_app( file: UploadFile = File(...), db: Session = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + app_id: Optional[str] = Form(None), ): """从 YAML 文件导入 agent / multi_agent / workflow 应用。 + 传入 app_id 时覆盖该应用的配置(类型必须一致),否则创建新应用。 跨空间/跨租户导入时,模型/工具/知识库会按名称匹配,匹配不到则置空并返回 warnings。 """ if not file.filename.lower().endswith((".yaml", ".yml")): @@ -1263,13 +1265,15 @@ async def import_app( if not dsl or "app" not in dsl: return fail(msg="YAML 格式无效,缺少 app 字段", code=BizCode.BAD_REQUEST) - new_app, warnings = AppDslService(db).import_dsl( + target_app_id = uuid.UUID(app_id) if app_id else None + result_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, + app_id=target_app_id, ) return success( - data={"app": app_schema.App.model_validate(new_app), "warnings": warnings}, + data={"app": app_schema.App.model_validate(result_app), "warnings": warnings}, msg="应用导入成功" + (",但部分资源需手动配置" if warnings else "") ) diff --git a/api/app/core/workflow/nodes/cycle_graph/iteration.py b/api/app/core/workflow/nodes/cycle_graph/iteration.py index cf7ac976..7652e21f 100644 --- a/api/app/core/workflow/nodes/cycle_graph/iteration.py +++ b/api/app/core/workflow/nodes/cycle_graph/iteration.py @@ -28,86 +28,135 @@ class IterationRuntime: def __init__( self, - start_id: str, stream: bool, - graph: CompiledStateGraph, node_id: str, config: dict[str, Any], state: WorkflowState, variable_pool: VariablePool, - child_variable_pool: VariablePool, + cycle_nodes: list, + cycle_edges: list, ): """ Initialize the iteration runtime. Args: - graph: Compiled workflow graph capable of async invocation. - node_id: Unique identifier of the loop node. - config: Dictionary containing iteration node configuration. - state: Current workflow state at the point of iteration. + stream: Whether to run in streaming mode. When True, each iteration + uses graph.astream and emits cycle_item events in real time. + When False, graph.ainvoke is used instead. + node_id: The unique identifier of the iteration node in the workflow. + Also used as the variable namespace for item/index inside + the subgraph (e.g. {{ node_id.item }}). + config: Raw configuration dict for the iteration node, parsed into + IterationNodeConfig. Controls input/output variable selectors, + parallel execution settings, and output flattening. + state: The parent workflow state at the point the iteration node is + entered. Each task receives a copy of this state as its + starting point. + variable_pool: The parent VariablePool containing all variables available + at the time the iteration node executes, including sys.*, + conv.*, and outputs from upstream nodes. Used as the source + for deep-copying into each task's independent child pool. + cycle_nodes: List of node config dicts belonging to this iteration's + subgraph (i.e. nodes whose cycle field equals node_id). + Passed to GraphBuilder when constructing each task's subgraph. + cycle_edges: List of edge config dicts connecting nodes within the subgraph. + Passed to GraphBuilder alongside cycle_nodes. """ - self.start_id = start_id self.stream = stream - self.graph = graph self.state = state self.node_id = node_id self.typed_config = IterationNodeConfig(**config) self.looping = True self.variable_pool = variable_pool - self.child_variable_pool = child_variable_pool + self.cycle_nodes = cycle_nodes + self.cycle_edges = cycle_edges self.event_write = get_stream_writer() - self.checkpoint = RunnableConfig( - configurable={ - "thread_id": uuid.uuid4() - } - ) self.output_value = None self.result: list = [] - async def _init_iteration_state(self, item, idx): + def _build_child_graph(self) -> tuple[CompiledStateGraph, VariablePool, str]: """ - Initialize a per-iteration copy of the workflow state. + Build an independent compiled subgraph for a single iteration task. - Args: - item: Current element from the input array for this iteration. - idx: Index of the element in the input array. + Each call creates a brand-new VariablePool by deep-copying the parent pool, + then passes it to GraphBuilder. GraphBuilder binds this pool to every node's + execution closure at build time, so the pool and the subgraph always reference + the same object. This is the key design invariant: item/index written into the + pool after build will be visible to all nodes inside the subgraph. Returns: - A copy of the workflow state with iteration-specific variables set. + graph: The compiled LangGraph subgraph ready for invocation. + child_pool: The VariablePool bound to this subgraph's node closures. + Callers must write item/index into this pool before invoking + the graph, and read output from it after invocation. + start_node_id: The ID of the CYCLE_START node inside the subgraph, + used to set the initial activation signal in workflow state. """ - loopstate = WorkflowState( - **self.state + from app.core.workflow.engine.graph_builder import GraphBuilder + child_pool = VariablePool() + child_pool.copy(self.variable_pool) + builder = GraphBuilder( + {"nodes": self.cycle_nodes, "edges": self.cycle_edges}, + stream=self.stream, + variable_pool=child_pool, + cycle=self.node_id, ) - self.child_variable_pool.copy(self.variable_pool) - await self.child_variable_pool.new(self.node_id, "item", item, VariableType.type_map(item), mut=True) - await self.child_variable_pool.new(self.node_id, "index", item, VariableType.type_map(item), mut=True) - loopstate["node_outputs"][self.node_id] = { - "item": item, - "index": idx, - } + graph = builder.build() + return graph, builder.variable_pool, builder.start_node_id + + async def _init_iteration_state(self, item, idx, child_pool: VariablePool, start_id: str): + """ + Initialize the workflow state for a single iteration. + + Writes the current item and its index into child_pool under the iteration + node's namespace (e.g. iteration_xxx.item, iteration_xxx.index), making them + accessible to downstream nodes inside the subgraph via variable selectors. + + Also prepares a copy of the parent workflow state with: + - node_outputs[node_id] set to {item, index} so the state snapshot is consistent + with the pool values. + - looping flag set to 1 (active) to signal the subgraph is inside a cycle. + - activate[start_id] set to True to trigger the CYCLE_START node. + + Args: + item: The current element from the input array. + idx: The zero-based index of this element in the input array. + child_pool: The VariablePool bound to this iteration's subgraph. + Must be the same object returned by _build_child_graph. + start_id: The ID of the CYCLE_START node inside the subgraph. + + Returns: + A WorkflowState instance ready to be passed to graph.ainvoke or graph.astream. + """ + loopstate = WorkflowState(**self.state) + await child_pool.new(self.node_id, "item", item, VariableType.type_map(item), mut=True) + await child_pool.new(self.node_id, "index", idx, VariableType.type_map(idx), mut=True) + loopstate["node_outputs"][self.node_id] = {"item": item, "index": idx} loopstate["looping"] = 1 - loopstate["activate"][self.start_id] = True + loopstate["activate"][start_id] = True return loopstate - def merge_conv_vars(self): - self.variable_pool.variables["conv"].update( - self.child_variable_pool.variables["conv"] - ) + def _merge_conv_vars(self, child_pool: VariablePool): + self.variable_pool.variables["conv"].update(child_pool.variables["conv"]) async def run_task(self, item, idx): """ Execute a single iteration asynchronously. + Each task builds its own subgraph so the variable pool closure is independent. - Args: - item: The input element for this iteration. - idx: The index of this iteration. + Returns: + Tuple of (idx, output, result, child_pool, stopped) """ + graph, child_pool, start_id = self._build_child_graph() + checkpoint = RunnableConfig(configurable={"thread_id": uuid.uuid4()}) + init_state = await self._init_iteration_state(item, idx, child_pool, start_id) + if self.stream: - async for event in self.graph.astream( - await self._init_iteration_state(item, idx), + async for event in graph.astream( + init_state, stream_mode=["debug"], - config=self.checkpoint + config=checkpoint ): if isinstance(event, tuple) and len(event) == 2: mode, data = event @@ -117,7 +166,6 @@ class IterationRuntime: event_type = data.get("type") payload = data.get("payload", {}) node_name = payload.get("name") - if node_name and node_name.startswith("nop"): continue if event_type == "task_result": @@ -140,17 +188,13 @@ class IterationRuntime: "token_usage": result.get("node_outputs", {}).get(node_name, {}).get("token_usage") } }) - result = self.graph.get_state(config=self.checkpoint).values + result = graph.get_state(config=checkpoint).values else: - result = await self.graph.ainvoke(await self._init_iteration_state(item, idx)) - output = self.child_variable_pool.get_value(self.output_value) - if isinstance(output, list) and self.typed_config.flatten: - self.result.extend(output) - else: - self.result.append(output) - if result["looping"] == 2: - self.looping = False - return result + result = await graph.ainvoke(init_state) + + output = child_pool.get_value(self.output_value) + stopped = result["looping"] == 2 + return idx, output, result, child_pool, stopped def _create_iteration_tasks(self, array_obj, idx): """ @@ -196,16 +240,32 @@ class IterationRuntime: tasks = self._create_iteration_tasks(array_obj, idx) logger.info(f"Iteration node {self.node_id}: running, concurrency {len(tasks)}") idx += self.typed_config.parallel_count - child_state.extend(await asyncio.gather(*tasks)) - self.merge_conv_vars() + batch = await asyncio.gather(*tasks) + # Sort by idx to preserve order, then collect results + batch_sorted = sorted(batch, key=lambda x: x[0]) + for _, output, result, child_pool, stopped in batch_sorted: + if isinstance(output, list) and self.typed_config.flatten: + self.result.extend(output) + else: + self.result.append(output) + child_state.append(result) + if stopped: + self.looping = False + self._merge_conv_vars(batch_sorted[-1][3]) else: # Execute iterations sequentially while idx < len(array_obj) and self.looping: logger.info(f"Iteration node {self.node_id}: running") item = array_obj[idx] - result = await self.run_task(item, idx) - self.merge_conv_vars() + _, output, result, child_pool, stopped = await self.run_task(item, idx) + if isinstance(output, list) and self.typed_config.flatten: + self.result.extend(output) + else: + self.result.append(output) + self._merge_conv_vars(child_pool) child_state.append(result) + if stopped: + self.looping = False idx += 1 logger.info(f"Iteration node {self.node_id}: execution completed") return { diff --git a/api/app/core/workflow/nodes/cycle_graph/node.py b/api/app/core/workflow/nodes/cycle_graph/node.py index 68c83025..002c34df 100644 --- a/api/app/core/workflow/nodes/cycle_graph/node.py +++ b/api/app/core/workflow/nodes/cycle_graph/node.py @@ -123,7 +123,7 @@ class CycleGraphNode(BaseNode): return cycle_nodes, cycle_edges - def build_graph(self): + def build_graph(self, variable_pool: VariablePool): """ Build and compile the internal subgraph for this cycle node. @@ -135,6 +135,7 @@ class CycleGraphNode(BaseNode): from app.core.workflow.engine.graph_builder import GraphBuilder self.child_variable_pool = VariablePool() + self.child_variable_pool.copy(variable_pool) builder = GraphBuilder( { "nodes": self.cycle_nodes, @@ -165,8 +166,8 @@ class CycleGraphNode(BaseNode): Raises: RuntimeError: If the node type is unsupported. """ - self.build_graph() if self.node_type == NodeType.LOOP: + self.build_graph(variable_pool) return await LoopRuntime( start_id=self.start_node_id, stream=False, @@ -179,20 +180,19 @@ class CycleGraphNode(BaseNode): ).run() if self.node_type == NodeType.ITERATION: return await IterationRuntime( - start_id=self.start_node_id, stream=False, - graph=self.graph, node_id=self.node_id, config=self.config, state=state, variable_pool=variable_pool, - child_variable_pool=self.child_variable_pool + cycle_nodes=self.cycle_nodes, + cycle_edges=self.cycle_edges, ).run() raise RuntimeError("Unknown cycle node type") async def execute_stream(self, state: WorkflowState, variable_pool: VariablePool): - self.build_graph() if self.node_type == NodeType.LOOP: + self.build_graph(variable_pool) yield { "__final__": True, "result": await LoopRuntime( @@ -211,14 +211,13 @@ class CycleGraphNode(BaseNode): yield { "__final__": True, "result": await IterationRuntime( - start_id=self.start_node_id, stream=True, - graph=self.graph, node_id=self.node_id, config=self.config, state=state, variable_pool=variable_pool, - child_variable_pool=self.child_variable_pool + cycle_nodes=self.cycle_nodes, + cycle_edges=self.cycle_edges, ).run() } return diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 5f73cde1..1ed98f68 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -44,6 +44,8 @@ class FileInput(BaseModel): upload_file_id: Optional[uuid.UUID] = Field(None, description="已上传文件ID(local_file时必填)") url: Optional[str] = Field(None, description="远程URL(remote_url时必填)") file_type: Optional[str] = Field(None, description="具体文件格式(如image/jpg、audio/wav、document/docx、video/mp4)") + name: Optional[str] = Field(None, description="文件名") + size: Optional[int] = Field(None, description="文件大小(字节)") _content = None diff --git a/api/app/services/app_chat_service.py b/api/app/services/app_chat_service.py index ec0c4b79..94362176 100644 --- a/api/app/services/app_chat_service.py +++ b/api/app/services/app_chat_service.py @@ -26,6 +26,7 @@ from app.services.model_service import ModelApiKeyService from app.services.multi_agent_orchestrator import MultiAgentOrchestrator from app.services.multimodal_service import MultimodalService from app.services.workflow_service import WorkflowService +from app.models.file_metadata_model import FileMetadata logger = get_business_logger() @@ -219,10 +220,21 @@ class AppChatService: } if files: for f in files: - # url = await MultimodalService(self.db).get_file_url(f) + name, size = f.name, f.size + if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size): + meta = self.db.query(FileMetadata).filter( + FileMetadata.id == f.upload_file_id, + FileMetadata.status == "completed" + ).first() + if meta: + name = name or meta.file_name + size = size or meta.file_size human_meta["files"].append({ "type": f.type, - "url": f.url + "url": f.url, + "name": name, + "size": size, + "file_type": f.file_type, }) if processed_files: @@ -510,9 +522,21 @@ class AppChatService: if files: for f in files: + name, size = f.name, f.size + if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size): + meta = self.db.query(FileMetadata).filter( + FileMetadata.id == f.upload_file_id, + FileMetadata.status == "completed" + ).first() + if meta: + name = name or meta.file_name + size = size or meta.file_size human_meta["files"].append({ "type": f.type, - "url": f.url + "url": f.url, + "name": name, + "size": size, + "file_type": f.file_type, }) if processed_files: human_meta["history_files"] = { diff --git a/api/app/services/app_dsl_service.py b/api/app/services/app_dsl_service.py index 8c198be4..612b93fe 100644 --- a/api/app/services/app_dsl_service.py +++ b/api/app/services/app_dsl_service.py @@ -229,8 +229,11 @@ class AppDslService: workspace_id: uuid.UUID, tenant_id: uuid.UUID, user_id: uuid.UUID, + app_id: Optional[uuid.UUID] = None, ) -> tuple[App, list[str]]: - """解析 DSL,创建应用及配置,返回 (new_app, warnings)""" + """解析 DSL,创建或覆盖应用配置,返回 (app, warnings)。 + app_id 不为空时:校验类型一致后覆盖配置;为空时创建新应用。 + """ app_meta = dsl.get("app", {}) app_type = app_meta.get("type") if app_type not in (AppType.AGENT, AppType.MULTI_AGENT, AppType.WORKFLOW): @@ -239,6 +242,9 @@ class AppDslService: warnings: list[str] = [] now = datetime.datetime.now() + if app_id is not None: + return self._overwrite_dsl(dsl, app_id, app_type, workspace_id, tenant_id, warnings, now) + new_app = App( id=uuid.uuid4(), workspace_id=workspace_id, @@ -258,11 +264,57 @@ class AppDslService: self.db.add(new_app) self.db.flush() + self._write_config(new_app.id, app_type, dsl, workspace_id, tenant_id, warnings, now, create=True) + + self.db.commit() + self.db.refresh(new_app) + return new_app, warnings + + def _overwrite_dsl( + self, + dsl: dict, + app_id: uuid.UUID, + app_type: str, + workspace_id: uuid.UUID, + tenant_id: uuid.UUID, + warnings: list, + now: datetime.datetime, + ) -> tuple[type(App), list]: + """覆盖已有应用的配置,类型不一致时抛出异常""" + app = self.db.query(App).filter( + App.id == app_id, + App.workspace_id == workspace_id, + App.is_active.is_(True) + ).first() + if not app: + raise ResourceNotFoundException("应用", str(app_id)) + if app.type != app_type: + raise BusinessException( + f"YAML 类型 '{app_type}' 与应用类型 '{app.type}' 不一致,无法导入", + BizCode.BAD_REQUEST + ) + + self._write_config(app_id, app_type, dsl, workspace_id, tenant_id, warnings, now, create=False) + + self.db.commit() + self.db.refresh(app) + return app, warnings + + def _write_config( + self, + app_id: uuid.UUID, + app_type: str, + dsl: dict, + workspace_id: uuid.UUID, + tenant_id: uuid.UUID, + warnings: list, + now: datetime.datetime, + create: bool, + ) -> None: + """写入(新建或覆盖)应用配置""" if app_type == AppType.AGENT: cfg = dsl.get("agent_config") or {} - self.db.add(AgentConfig( - id=uuid.uuid4(), - app_id=new_app.id, + fields = dict( 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), @@ -272,16 +324,21 @@ class AppDslService: tools=self._resolve_tools(cfg.get("tools", []), tenant_id, warnings), skills=self._resolve_skills(cfg.get("skills", {}), tenant_id, warnings), features=cfg.get("features", {}), - is_active=True, - created_at=now, updated_at=now, - )) + ) + if create: + self.db.add(AgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields)) + else: + existing = self.db.query(AgentConfig).filter(AgentConfig.app_id == app_id).first() + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + else: + self.db.add(AgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields)) 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, + fields = dict( orchestration_mode=cfg.get("orchestration_mode", "collaboration"), master_agent_name=cfg.get("master_agent_name"), model_parameters=cfg.get("model_parameters"), @@ -291,10 +348,17 @@ class AppDslService: 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, - )) + ) + if create: + self.db.add(MultiAgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields)) + else: + existing = self.db.query(MultiAgentConfig).filter(MultiAgentConfig.app_id == app_id).first() + if existing: + for k, v in fields.items(): + setattr(existing, k, v) + else: + self.db.add(MultiAgentConfig(id=uuid.uuid4(), app_id=app_id, is_active=True, created_at=now, **fields)) elif app_type == AppType.WORKFLOW: adapter = MemoryBearAdapter(dsl) @@ -306,20 +370,39 @@ class AppDslService: for w in result.warnings: warnings.append(f"[节点警告] {w.node_name or w.node_id}: {w.detail}") wf = dsl.get("workflow") or {} - WorkflowService(self.db).create_workflow_config( - app_id=new_app.id, - nodes=[n.model_dump() for n in result.nodes], - edges=[e.model_dump() for e in result.edges], - variables=[v.model_dump() for v in result.variables], - execution_config=wf.get("execution_config", {}), - features=wf.get("features", {}), - triggers=wf.get("triggers", []), - validate=False, - ) - - self.db.commit() - self.db.refresh(new_app) - return new_app, warnings + wf_service = WorkflowService(self.db) + if create: + wf_service.create_workflow_config( + app_id=app_id, + nodes=[n.model_dump() for n in result.nodes], + edges=[e.model_dump() for e in result.edges], + variables=[v.model_dump() for v in result.variables], + execution_config=wf.get("execution_config", {}), + features=wf.get("features", {}), + triggers=wf.get("triggers", []), + validate=False, + ) + else: + existing = self.db.query(WorkflowConfig).filter(WorkflowConfig.app_id == app_id).first() + if existing: + existing.nodes = [n.model_dump() for n in result.nodes] + existing.edges = [e.model_dump() for e in result.edges] + existing.variables = [v.model_dump() for v in result.variables] + existing.execution_config = wf.get("execution_config", {}) + existing.features = wf.get("features", {}) + existing.triggers = wf.get("triggers", []) + existing.updated_at = now + else: + wf_service.create_workflow_config( + app_id=app_id, + nodes=[n.model_dump() for n in result.nodes], + edges=[e.model_dump() for e in result.edges], + variables=[v.model_dump() for v in result.variables], + execution_config=wf.get("execution_config", {}), + features=wf.get("features", {}), + triggers=wf.get("triggers", []), + validate=False, + ) def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str: """生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用""" diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 5c10e4f8..52c3d7f3 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -1299,10 +1299,23 @@ class AgentRunService: "history_files": {} } if files: + from app.models.file_metadata_model import FileMetadata for f in files: + name, size = f.name, f.size + if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size): + meta = self.db.query(FileMetadata).filter( + FileMetadata.id == f.upload_file_id, + FileMetadata.status == "completed" + ).first() + if meta: + name = name or meta.file_name + size = size or meta.file_size human_meta["files"].append({ "type": f.type, - "url": f.url + "url": f.url, + "file_type": f.file_type, + "name": name, + "size": size }) # 保存 history_files,包含 provider 和 is_omni 信息 diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index b771c639..0d282d78 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -957,7 +957,10 @@ class WorkflowService: for file in message["content"]: human_meta["files"].append({ "type": file.get("type"), - "url": file.get("url") + "url": file.get("url"), + "file_type": file.get("origin_file_type"), + "name": file.get("name"), + "size": file.get("size") }) if message["role"] == "assistant": assistant_message = message["content"] From 9470dd2f1e882d218da46d09b4cb717a30591a62 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 13 Apr 2026 18:47:56 +0800 Subject: [PATCH 018/113] refactor(memory): extract shared MemorySummary count query and replace magic number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move duplicated Neo4j MemorySummary count query into MemoryBaseService.get_valid_memory_summary_count() - Introduce MIN_MEMORY_SUMMARY_COUNT constant to replace hardcoded 5 - Fix import ordering in implicit_emotions_storage_repository - Use UTC consistently for date calculations (remove CST offset, datetime.now → datetime.utcnow) --- .../implicit_emotions_storage_repository.py | 20 +++++----- api/app/services/implicit_memory_service.py | 17 ++++----- api/app/services/memory_base_service.py | 38 +++++++++++++++++++ api/app/services/user_memory_service.py | 20 +++------- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/api/app/repositories/implicit_emotions_storage_repository.py b/api/app/repositories/implicit_emotions_storage_repository.py index b6c40b40..b665924d 100644 --- a/api/app/repositories/implicit_emotions_storage_repository.py +++ b/api/app/repositories/implicit_emotions_storage_repository.py @@ -5,16 +5,9 @@ Implicit Emotions Storage Repository 事务由调用方控制,仓储层只使用 flush/refresh """ import logging -from datetime import date, datetime, timezone +from datetime import 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 @@ -25,6 +18,13 @@ from app.models.implicit_emotions_storage_model import ImplicitEmotionsStorage logger = logging.getLogger(__name__) +class TimeFilterUnavailableError(Exception): + """redis_client 不可用,无法执行时间轴筛选。 + + 调用方捕获此异常后可选择回退到 get_all_user_ids 进行全量处理。 + """ + + class ImplicitEmotionsStorageRepository: """隐性记忆和情绪存储仓储类""" @@ -216,9 +216,7 @@ class ImplicitEmotionsStorageRepository: """ 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) + today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) tomorrow_start = today_start + timedelta(days=1) offset = 0 while True: diff --git a/api/app/services/implicit_memory_service.py b/api/app/services/implicit_memory_service.py index 10504fe7..7a186f33 100644 --- a/api/app/services/implicit_memory_service.py +++ b/api/app/services/implicit_memory_service.py @@ -34,6 +34,7 @@ from app.schemas.implicit_memory_schema import ( UserMemorySummary, ) from app.schemas.memory_config_schema import MemoryConfig +from app.services.memory_base_service import MIN_MEMORY_SUMMARY_COUNT from sqlalchemy.orm import Session logger = logging.getLogger(__name__) @@ -381,7 +382,7 @@ class ImplicitMemoryService: def _build_empty_profile(self) -> dict: """构建 MemorySummary 不足时返回的固定空白画像数据""" - now_ms = int(datetime.now().timestamp() * 1000) + now_ms = int(datetime.utcnow().timestamp() * 1000) insufficient = "Insufficient data for analysis" def _empty_dimension(name: str) -> dict: @@ -442,17 +443,13 @@ class ImplicitMemoryService: try: # 前置检查:查询该用户有效的 MemorySummary 节点数量(排除孤立节点) - query = """ - MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) - WHERE n.end_user_id = $end_user_id - RETURN count(DISTINCT n) as count - """ - result = await self.neo4j_connector.execute_query(query, end_user_id=user_id) - memory_summary_count = result[0]["count"] if result and len(result) > 0 else 0 + from app.services.memory_base_service import MemoryBaseService + base_service = MemoryBaseService() + memory_summary_count = await base_service.get_valid_memory_summary_count(user_id) logger.info(f"用户 MemorySummary 节点数量: {memory_summary_count} (user={user_id})") - if memory_summary_count < 5: - logger.info(f"MemorySummary 数量不足 5(当前 {memory_summary_count}),返回空白画像: user={user_id}") + if memory_summary_count < MIN_MEMORY_SUMMARY_COUNT: + logger.info(f"MemorySummary 数量不足 {MIN_MEMORY_SUMMARY_COUNT}(当前 {memory_summary_count}),返回空白画像: user={user_id}") return self._build_empty_profile() # 并行调用4个分析方法 diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py index bc647752..e615af8b 100644 --- a/api/app/services/memory_base_service.py +++ b/api/app/services/memory_base_service.py @@ -265,12 +265,50 @@ async def Translation_English(modid, text, fields=None): # 其他类型(数字、布尔值、None等):原样返回 else: return text +# 隐性记忆画像生成所需的最低 MemorySummary 节点数量 +MIN_MEMORY_SUMMARY_COUNT = 5 + + class MemoryBaseService: """记忆服务基类,提供共享的辅助方法""" def __init__(self): self.neo4j_connector = Neo4jConnector() + async def get_valid_memory_summary_count( + self, + end_user_id: str + ) -> int: + """获取用户有效的 MemorySummary 节点数量(排除孤立节点)。 + + 只统计存在 DERIVED_FROM_STATEMENT 关系的 MemorySummary 节点。 + + Args: + end_user_id: 终端用户ID + + Returns: + 有效 MemorySummary 节点数量 + """ + try: + query = """ + MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) + WHERE n.end_user_id = $end_user_id + RETURN count(DISTINCT n) as count + """ + result = await self.neo4j_connector.execute_query( + query, end_user_id=end_user_id + ) + count = result[0]["count"] if result and len(result) > 0 else 0 + logger.debug( + f"有效 MemorySummary 节点数量: {count} (end_user_id={end_user_id})" + ) + return count + except Exception as e: + logger.error( + f"获取有效 MemorySummary 数量失败: {str(e)}", exc_info=True + ) + return 0 + @staticmethod def parse_timestamp(timestamp_value) -> Optional[int]: """ diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index dcbedba6..cc18447e 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -21,7 +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.memory_base_service import MemoryBaseService +from app.services.memory_base_service import MemoryBaseService, MIN_MEMORY_SUMMARY_COUNT from app.services.memory_config_service import MemoryConfigService from app.services.memory_perceptual_service import MemoryPerceptualService from app.services.memory_short_service import ShortService @@ -1500,7 +1500,7 @@ async def analytics_memory_types( 2. 工作记忆 (WORKING_MEMORY) = 会话数量(通过 ConversationRepository.get_conversation_by_user_id 获取) 3. 短期记忆 (SHORT_TERM_MEMORY) = /short_term 接口返回的问答对数量 4. 显性记忆 (EXPLICIT_MEMORY) = 情景记忆 + 语义记忆(通过 MemoryBaseService.get_explicit_memory_count 获取) - 5. 隐性记忆 (IMPLICIT_MEMORY) = MemorySummary 节点数量(需 >= 5 才显示,否则为 0) + 5. 隐性记忆 (IMPLICIT_MEMORY) = MemorySummary 节点数量(需 >= MIN_MEMORY_SUMMARY_COUNT 才显示,否则为 0) 6. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取) 7. 情景记忆 (EPISODIC_MEMORY) = memory_summary(通过 MemoryBaseService.get_episodic_memory_count 获取) 8. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_memory_count 获取) @@ -1557,20 +1557,12 @@ async def analytics_memory_types( logger.warning(f"获取会话数量失败,工作记忆数量设为0: {str(e)}") work_count = 0 - # 获取隐性记忆数量(基于有关联关系的 MemorySummary 节点数量,需 >= 5 才计入) + # 获取隐性记忆数量(基于有关联关系的 MemorySummary 节点数量,需 >= MIN_MEMORY_SUMMARY_COUNT 才计入) implicit_count = 0 if end_user_id: try: - # 只统计有 DERIVED_FROM_STATEMENT 关系的 MemorySummary 节点,排除孤立节点 - query = """ - MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) - WHERE n.end_user_id = $end_user_id - RETURN count(DISTINCT n) as count - """ - result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id) - memory_summary_count = result[0]["count"] if result and len(result) > 0 else 0 - # 仅当 MemorySummary 节点数量 >= 5 时才显示数量,否则为 0 - implicit_count = memory_summary_count if memory_summary_count >= 5 else 0 + memory_summary_count = await base_service.get_valid_memory_summary_count(end_user_id) + implicit_count = memory_summary_count if memory_summary_count >= MIN_MEMORY_SUMMARY_COUNT else 0 logger.debug(f"隐性记忆数量(有效MemorySummary节点数): {implicit_count} (有效MemorySummary总数={memory_summary_count}, end_user_id={end_user_id})") except Exception as e: logger.warning(f"获取MemorySummary数量失败,隐性记忆数量设为0: {str(e)}") @@ -1639,7 +1631,7 @@ async def analytics_memory_types( "WORKING_MEMORY": work_count, # 工作记忆(基于会话数量) "SHORT_TERM_MEMORY": short_term_count, # 短期记忆(基于问答对数量) "EXPLICIT_MEMORY": explicit_count, # 显性记忆(情景记忆 + 语义记忆) - "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(MemorySummary节点数,需>=5) + "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(MemorySummary节点数,需>=MIN_MEMORY_SUMMARY_COUNT) "EMOTIONAL_MEMORY": emotion_count, # 情绪记忆(使用情绪标签统计) "EPISODIC_MEMORY": episodic_count, # 情景记忆 "FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值) From 7ca80b5d0120a19b01ceeb47b0885aa154af5fed Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Mon, 13 Apr 2026 18:52:43 +0800 Subject: [PATCH 019/113] perf(app): optimize FileMetadata queries by batching lookups Multiple services were performing individual database queries for FileMetadata when resolving missing file names/sizes. This change batches the queries using `in_()` to reduce database round trips and improve performance. --- api/app/services/app_chat_service.py | 30 ++++++++++++++++++++------- api/app/services/app_dsl_service.py | 2 +- api/app/services/draft_run_service.py | 15 ++++++++++---- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/api/app/services/app_chat_service.py b/api/app/services/app_chat_service.py index 94362176..2d10ed44 100644 --- a/api/app/services/app_chat_service.py +++ b/api/app/services/app_chat_service.py @@ -219,13 +219,20 @@ class AppChatService: "reasoning_content": result.get("reasoning_content") } if files: + local_ids = [f.upload_file_id for f in files + if f.transfer_method.value == "local_file" and f.upload_file_id + and (not f.name or not f.size)] + meta_map = {} + if local_ids: + rows = self.db.query(FileMetadata).filter( + FileMetadata.id.in_(local_ids), + FileMetadata.status == "completed" + ).all() + meta_map = {str(r.id): r for r in rows} for f in files: name, size = f.name, f.size if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size): - meta = self.db.query(FileMetadata).filter( - FileMetadata.id == f.upload_file_id, - FileMetadata.status == "completed" - ).first() + meta = meta_map.get(str(f.upload_file_id)) if meta: name = name or meta.file_name size = size or meta.file_size @@ -521,13 +528,20 @@ class AppChatService: } if files: + local_ids = [f.upload_file_id for f in files + if f.transfer_method.value == "local_file" and f.upload_file_id + and (not f.name or not f.size)] + meta_map = {} + if local_ids: + rows = self.db.query(FileMetadata).filter( + FileMetadata.id.in_(local_ids), + FileMetadata.status == "completed" + ).all() + meta_map = {str(r.id): r for r in rows} for f in files: name, size = f.name, f.size if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size): - meta = self.db.query(FileMetadata).filter( - FileMetadata.id == f.upload_file_id, - FileMetadata.status == "completed" - ).first() + meta = meta_map.get(str(f.upload_file_id)) if meta: name = name or meta.file_name size = size or meta.file_size diff --git a/api/app/services/app_dsl_service.py b/api/app/services/app_dsl_service.py index 612b93fe..16aa8a3a 100644 --- a/api/app/services/app_dsl_service.py +++ b/api/app/services/app_dsl_service.py @@ -279,7 +279,7 @@ class AppDslService: tenant_id: uuid.UUID, warnings: list, now: datetime.datetime, - ) -> tuple[type(App), list]: + ) -> tuple[App, list[str]]: """覆盖已有应用的配置,类型不一致时抛出异常""" app = self.db.query(App).filter( App.id == app_id, diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 52c3d7f3..b47bd4cd 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -1300,13 +1300,20 @@ class AgentRunService: } if files: from app.models.file_metadata_model import FileMetadata + local_ids = [f.upload_file_id for f in files + if f.transfer_method.value == "local_file" and f.upload_file_id + and (not f.name or not f.size)] + meta_map = {} + if local_ids: + rows = self.db.query(FileMetadata).filter( + FileMetadata.id.in_(local_ids), + FileMetadata.status == "completed" + ).all() + meta_map = {str(r.id): r for r in rows} for f in files: name, size = f.name, f.size if f.transfer_method.value == "local_file" and f.upload_file_id and (not name or not size): - meta = self.db.query(FileMetadata).filter( - FileMetadata.id == f.upload_file_id, - FileMetadata.status == "completed" - ).first() + meta = meta_map.get(str(f.upload_file_id)) if meta: name = name or meta.file_name size = size or meta.file_size From 52e726eabcc6b879e33b0037f7e871a3f2dc2ecb Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 18:53:49 +0800 Subject: [PATCH 020/113] ci: add release notification workflow for merged PRs - Add GitHub Actions workflow to notify on merged release branch PRs - Implement HEAD sync check to ensure branch is up-to-date before notification - Fetch commit messages from merged PR for AI summarization - Integrate Alibaba Qwen AI to generate Chinese release summaries for QA team - Send formatted Markdown notifications to WeChat webhook with PR details and AI summary - Workflow triggers only on final PR merge to release branches to avoid duplicate notifications --- .github/workflows/release-notify.yml | 107 +++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/workflows/release-notify.yml diff --git a/.github/workflows/release-notify.yml b/.github/workflows/release-notify.yml new file mode 100644 index 00000000..6b86db96 --- /dev/null +++ b/.github/workflows/release-notify.yml @@ -0,0 +1,107 @@ +name: Release Notify (Ali AI Final) + +on: + pull_request: + types: [closed] + +jobs: + notify: + if: > + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.base.ref, 'release') + runs-on: ubuntu-latest + + steps: + # 防止 GitHub HEAD 未同步 + - name: Wait for ref sync + run: sleep 3 + + # 1️⃣ 获取分支 HEAD + - name: Get HEAD + id: head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + HEAD_SHA=$(curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/$REPO/git/ref/heads/$BASE_REF" \ + | jq -r '.object.sha') + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + + # 2️⃣ 判断是否最终PR + - name: Check Latest + id: check + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + HEAD_SHA: ${{ steps.head.outputs.head_sha }} + run: | + if [ "$MERGE_SHA" = "$HEAD_SHA" ]; then + echo "ok=true" >> $GITHUB_OUTPUT + else + echo "ok=false" >> $GITHUB_OUTPUT + fi + + # 3️⃣ 获取 commits + - name: Get Commits + if: steps.check.outputs.ok == 'true' + id: commits + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMITS_URL: ${{ github.event.pull_request.commits_url }} + run: | + curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "$COMMITS_URL" \ + | jq -r '.[].commit.message' | head -n 20 > commits.txt + + # 4️⃣ 阿里 AI 总结(通义千问) + - name: AI Summary (Qwen) + if: steps.check.outputs.ok == 'true' + id: ai + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + run: | + COMMIT_MESSAGES=$(cat commits.txt) + + jq -n --arg msgs "请用中文总结以下代码提交,输出3-5条,面向测试人员: + $COMMIT_MESSAGES" \ + '{"model": "qwen-plus", "input": {"prompt": $msgs}}' > ai_payload.json + + SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H "Content-Type: application/json" \ + -d @ai_payload.json | jq -r '.output.text') + + echo "summary<> $GITHUB_OUTPUT + echo "$SUMMARY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # 5️⃣ 企业微信通知(Markdown) + - name: Notify WeChat + if: steps.check.outputs.ok == 'true' + env: + WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} + BRANCH: ${{ github.event.pull_request.base.ref }} + AUTHOR: ${{ github.event.pull_request.user.login }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + AI_SUMMARY: ${{ steps.ai.outputs.summary }} + run: | + jq -n \ + --arg branch "$BRANCH" \ + --arg author "$AUTHOR" \ + --arg title "$PR_TITLE" \ + --arg url "$PR_URL" \ + --arg summary "$AI_SUMMARY" \ + '{ + msgtype: "markdown", + markdown: { + content: "## 🚀 Release 发布通知\n> 📦 **分支**: \($branch)\n> 👤 **提交人**: \($author)\n> 📝 **标题**: \($title)\n\n### 🧠 AI变更摘要\n\($summary)\n\n---\n🔗 [查看PR详情](\($url))" + } + }' > wechat_payload.json + + curl -s "$WECHAT_WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d @wechat_payload.json From 2450fe3afea44aca3d78d8334edb0b50a4fdf4b6 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Mon, 13 Apr 2026 19:00:36 +0800 Subject: [PATCH 021/113] refactor(workflow): move _merge_conv_vars call inside iteration loop for consistent state updates --- api/app/core/workflow/nodes/cycle_graph/iteration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/core/workflow/nodes/cycle_graph/iteration.py b/api/app/core/workflow/nodes/cycle_graph/iteration.py index 7652e21f..1633b9c7 100644 --- a/api/app/core/workflow/nodes/cycle_graph/iteration.py +++ b/api/app/core/workflow/nodes/cycle_graph/iteration.py @@ -249,9 +249,9 @@ class IterationRuntime: else: self.result.append(output) child_state.append(result) + self._merge_conv_vars(child_pool) if stopped: self.looping = False - self._merge_conv_vars(batch_sorted[-1][3]) else: # Execute iterations sequentially while idx < len(array_obj) and self.looping: From a3f0415cd3e028f1b156d71cc167a44f46937e2c Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:02:28 +0800 Subject: [PATCH 022/113] ci(workflow): add release notification workflow for WeChat - Add new GitHub Actions workflow to notify WeChat on release branch merges - Implement HEAD sync check to prevent race conditions with GitHub API - Add commit validation to ensure PR is the latest merge to release branch - Fetch PR commits and generate AI summary using Alibaba Qwen API - Send formatted Markdown notification to WeChat webhook with release details - Include branch, author, PR title, and AI-generated change summary in notification --- .github/workflows/release-notify-wechat.yml | 107 ++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/workflows/release-notify-wechat.yml diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml new file mode 100644 index 00000000..6b86db96 --- /dev/null +++ b/.github/workflows/release-notify-wechat.yml @@ -0,0 +1,107 @@ +name: Release Notify (Ali AI Final) + +on: + pull_request: + types: [closed] + +jobs: + notify: + if: > + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.base.ref, 'release') + runs-on: ubuntu-latest + + steps: + # 防止 GitHub HEAD 未同步 + - name: Wait for ref sync + run: sleep 3 + + # 1️⃣ 获取分支 HEAD + - name: Get HEAD + id: head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + HEAD_SHA=$(curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/$REPO/git/ref/heads/$BASE_REF" \ + | jq -r '.object.sha') + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + + # 2️⃣ 判断是否最终PR + - name: Check Latest + id: check + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + HEAD_SHA: ${{ steps.head.outputs.head_sha }} + run: | + if [ "$MERGE_SHA" = "$HEAD_SHA" ]; then + echo "ok=true" >> $GITHUB_OUTPUT + else + echo "ok=false" >> $GITHUB_OUTPUT + fi + + # 3️⃣ 获取 commits + - name: Get Commits + if: steps.check.outputs.ok == 'true' + id: commits + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMITS_URL: ${{ github.event.pull_request.commits_url }} + run: | + curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "$COMMITS_URL" \ + | jq -r '.[].commit.message' | head -n 20 > commits.txt + + # 4️⃣ 阿里 AI 总结(通义千问) + - name: AI Summary (Qwen) + if: steps.check.outputs.ok == 'true' + id: ai + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + run: | + COMMIT_MESSAGES=$(cat commits.txt) + + jq -n --arg msgs "请用中文总结以下代码提交,输出3-5条,面向测试人员: + $COMMIT_MESSAGES" \ + '{"model": "qwen-plus", "input": {"prompt": $msgs}}' > ai_payload.json + + SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H "Content-Type: application/json" \ + -d @ai_payload.json | jq -r '.output.text') + + echo "summary<> $GITHUB_OUTPUT + echo "$SUMMARY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # 5️⃣ 企业微信通知(Markdown) + - name: Notify WeChat + if: steps.check.outputs.ok == 'true' + env: + WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} + BRANCH: ${{ github.event.pull_request.base.ref }} + AUTHOR: ${{ github.event.pull_request.user.login }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + AI_SUMMARY: ${{ steps.ai.outputs.summary }} + run: | + jq -n \ + --arg branch "$BRANCH" \ + --arg author "$AUTHOR" \ + --arg title "$PR_TITLE" \ + --arg url "$PR_URL" \ + --arg summary "$AI_SUMMARY" \ + '{ + msgtype: "markdown", + markdown: { + content: "## 🚀 Release 发布通知\n> 📦 **分支**: \($branch)\n> 👤 **提交人**: \($author)\n> 📝 **标题**: \($title)\n\n### 🧠 AI变更摘要\n\($summary)\n\n---\n🔗 [查看PR详情](\($url))" + } + }' > wechat_payload.json + + curl -s "$WECHAT_WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d @wechat_payload.json From 70d4e79de1546b1cb36e856575f4270fa34eb356 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 19:05:32 +0800 Subject: [PATCH 023/113] fix(web): breadcrumb ui --- web/src/components/Header/index.module.css | 8 +++++ web/src/components/Header/index.tsx | 37 +++++++++++----------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/web/src/components/Header/index.module.css b/web/src/components/Header/index.module.css index d39c91ec..525a2432 100644 --- a/web/src/components/Header/index.module.css +++ b/web/src/components/Header/index.module.css @@ -12,6 +12,14 @@ font-weight: 500; font-style: normal; } +.breadcrumbTitle { + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} .header :global(.ant-breadcrumb) { line-height: 31px; } diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index 49988223..f2eff014 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -14,7 +14,7 @@ */ import { type FC, useRef, useState } from 'react'; -import { Layout, Dropdown, Breadcrumb, Flex } from 'antd'; +import { Layout, Dropdown, Breadcrumb, Flex, Tooltip } from 'antd'; import type { MenuProps, BreadcrumbProps } from 'antd'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; @@ -136,27 +136,28 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { */ const formatBreadcrumbNames = () => { return breadcrumbs.filter(item => item.type !== 'group').map((menu, index) => { + const label = menu.i18nKey ? t(menu.i18nKey) : menu.label; + const isLast = index === breadcrumbs.length - 1; const item: any = { - title: menu.i18nKey ? t(menu.i18nKey) : menu.label, + title: ( + + {label} + + ), }; - // If it's the last item, don't set path - if (index === breadcrumbs.length - 1) { - return item; + if (!isLast) { + if ((menu as any).onClick) { + item.onClick = (e: React.MouseEvent) => { + e.preventDefault(); + (menu as any).onClick(e); + }; + item.href = '#'; + } else if (menu.path && menu.path !== '#') { + item.path = menu.path; + } } - - // If has custom onClick, use onClick and set href to '#' to show pointer cursor - if ((menu as any).onClick) { - item.onClick = (e: React.MouseEvent) => { - e.preventDefault(); - (menu as any).onClick(e); - }; - item.href = '#'; - } else if (menu.path && menu.path !== '#') { - // Only set path when path is not '#' - item.path = menu.path; - } - + return item; }); } From 60124e323222b95fd48cdf0255d2879eb57b3235 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:06:18 +0800 Subject: [PATCH 024/113] ci(workflow): simplify WeChat notification payload generation - Rename workflow from "Release Notify (Ali AI Final)" to "Release Notify Workflow" for clarity - Replace jq multi-line argument construction with printf for better readability - Simplify payload generation by building content string separately before passing to jq - Reduce complexity of nested jq arguments while maintaining identical output format --- .github/workflows/release-notify-wechat.yml | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 6b86db96..7b3378b0 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -1,4 +1,4 @@ -name: Release Notify (Ali AI Final) +name: Release Notify Workflow on: pull_request: @@ -89,18 +89,11 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | - jq -n \ - --arg branch "$BRANCH" \ - --arg author "$AUTHOR" \ - --arg title "$PR_TITLE" \ - --arg url "$PR_URL" \ - --arg summary "$AI_SUMMARY" \ - '{ - msgtype: "markdown", - markdown: { - content: "## 🚀 Release 发布通知\n> 📦 **分支**: \($branch)\n> 👤 **提交人**: \($author)\n> 📝 **标题**: \($title)\n\n### 🧠 AI变更摘要\n\($summary)\n\n---\n🔗 [查看PR详情](\($url))" - } - }' > wechat_payload.json + CONTENT=$(printf '## 🚀 Release 发布通知\n> 📦 **分支**: %s\n> 👤 **提交人**: %s\n> 📝 **标题**: %s\n\n### 🧠 AI变更摘要\n%s\n\n---\n🔗 [查看PR详情](%s)' \ + "$BRANCH" "$AUTHOR" "$PR_TITLE" "$AI_SUMMARY" "$PR_URL") + + jq -n --arg content "$CONTENT" \ + '{"msgtype": "markdown", "markdown": {"content": $content}}' > wechat_payload.json curl -s "$WECHAT_WEBHOOK" \ -H 'Content-Type: application/json' \ From 1ff3748935b31ebc9a1ae3978ddbeaf3d1de1301 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:11:15 +0800 Subject: [PATCH 025/113] ci: remove release notification workflow - Delete release-notify.yml GitHub Actions workflow - Remove AI-powered release summary generation via Qwen API - Remove WeChat enterprise notification integration - Simplify CI/CD pipeline by consolidating notification logic --- .github/workflows/release-notify.yml | 107 --------------------------- 1 file changed, 107 deletions(-) delete mode 100644 .github/workflows/release-notify.yml diff --git a/.github/workflows/release-notify.yml b/.github/workflows/release-notify.yml deleted file mode 100644 index 6b86db96..00000000 --- a/.github/workflows/release-notify.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Release Notify (Ali AI Final) - -on: - pull_request: - types: [closed] - -jobs: - notify: - if: > - github.event.pull_request.merged == true && - startsWith(github.event.pull_request.base.ref, 'release') - runs-on: ubuntu-latest - - steps: - # 防止 GitHub HEAD 未同步 - - name: Wait for ref sync - run: sleep 3 - - # 1️⃣ 获取分支 HEAD - - name: Get HEAD - id: head - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - BASE_REF: ${{ github.event.pull_request.base.ref }} - run: | - HEAD_SHA=$(curl -s \ - -H "Authorization: Bearer $GH_TOKEN" \ - "https://api.github.com/repos/$REPO/git/ref/heads/$BASE_REF" \ - | jq -r '.object.sha') - echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT - - # 2️⃣ 判断是否最终PR - - name: Check Latest - id: check - env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - HEAD_SHA: ${{ steps.head.outputs.head_sha }} - run: | - if [ "$MERGE_SHA" = "$HEAD_SHA" ]; then - echo "ok=true" >> $GITHUB_OUTPUT - else - echo "ok=false" >> $GITHUB_OUTPUT - fi - - # 3️⃣ 获取 commits - - name: Get Commits - if: steps.check.outputs.ok == 'true' - id: commits - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMMITS_URL: ${{ github.event.pull_request.commits_url }} - run: | - curl -s \ - -H "Authorization: Bearer $GH_TOKEN" \ - "$COMMITS_URL" \ - | jq -r '.[].commit.message' | head -n 20 > commits.txt - - # 4️⃣ 阿里 AI 总结(通义千问) - - name: AI Summary (Qwen) - if: steps.check.outputs.ok == 'true' - id: ai - env: - DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - run: | - COMMIT_MESSAGES=$(cat commits.txt) - - jq -n --arg msgs "请用中文总结以下代码提交,输出3-5条,面向测试人员: - $COMMIT_MESSAGES" \ - '{"model": "qwen-plus", "input": {"prompt": $msgs}}' > ai_payload.json - - SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ - -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ - -H "Content-Type: application/json" \ - -d @ai_payload.json | jq -r '.output.text') - - echo "summary<> $GITHUB_OUTPUT - echo "$SUMMARY" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - # 5️⃣ 企业微信通知(Markdown) - - name: Notify WeChat - if: steps.check.outputs.ok == 'true' - env: - WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} - BRANCH: ${{ github.event.pull_request.base.ref }} - AUTHOR: ${{ github.event.pull_request.user.login }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_URL: ${{ github.event.pull_request.html_url }} - AI_SUMMARY: ${{ steps.ai.outputs.summary }} - run: | - jq -n \ - --arg branch "$BRANCH" \ - --arg author "$AUTHOR" \ - --arg title "$PR_TITLE" \ - --arg url "$PR_URL" \ - --arg summary "$AI_SUMMARY" \ - '{ - msgtype: "markdown", - markdown: { - content: "## 🚀 Release 发布通知\n> 📦 **分支**: \($branch)\n> 👤 **提交人**: \($author)\n> 📝 **标题**: \($title)\n\n### 🧠 AI变更摘要\n\($summary)\n\n---\n🔗 [查看PR详情](\($url))" - } - }' > wechat_payload.json - - curl -s "$WECHAT_WEBHOOK" \ - -H 'Content-Type: application/json' \ - -d @wechat_payload.json From 77ed9faea102c365241221b24208f916362dba44 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:13:23 +0800 Subject: [PATCH 026/113] chore(.gitignore): add redbear-mem-benchmark to ignored paths - Add redbear-mem-benchmark directory to .gitignore - Prevents benchmark artifacts from being tracked in version control - Aligns with existing pattern of ignoring redbear-mem-metrics directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0ec6822c..a1896da7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ time.log celerybeat-schedule.db search_results.json redbear-mem-metrics/ +redbear-mem-benchmark/ pitch-deck/ api/migrations/versions From 7a4a02b2bb722c2e3b522d8bc7e0984abdf10c55 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:15:54 +0800 Subject: [PATCH 027/113] ci: add WeChat release notification workflow - Add GitHub Actions workflow to notify WeChat on release branch merges - Implement multi-step pipeline: sync ref, verify latest PR, fetch commits - Integrate Aliyun Qwen AI for automated Chinese commit message summarization - Send formatted Markdown notifications to WeChat webhook with release details - Include branch, author, PR title, AI summary, and PR link in notifications --- .github/workflows/release-notify-wechat.yml | 100 ++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/release-notify-wechat.yml diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml new file mode 100644 index 00000000..7b3378b0 --- /dev/null +++ b/.github/workflows/release-notify-wechat.yml @@ -0,0 +1,100 @@ +name: Release Notify Workflow + +on: + pull_request: + types: [closed] + +jobs: + notify: + if: > + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.base.ref, 'release') + runs-on: ubuntu-latest + + steps: + # 防止 GitHub HEAD 未同步 + - name: Wait for ref sync + run: sleep 3 + + # 1️⃣ 获取分支 HEAD + - name: Get HEAD + id: head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + HEAD_SHA=$(curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/$REPO/git/ref/heads/$BASE_REF" \ + | jq -r '.object.sha') + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + + # 2️⃣ 判断是否最终PR + - name: Check Latest + id: check + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + HEAD_SHA: ${{ steps.head.outputs.head_sha }} + run: | + if [ "$MERGE_SHA" = "$HEAD_SHA" ]; then + echo "ok=true" >> $GITHUB_OUTPUT + else + echo "ok=false" >> $GITHUB_OUTPUT + fi + + # 3️⃣ 获取 commits + - name: Get Commits + if: steps.check.outputs.ok == 'true' + id: commits + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMITS_URL: ${{ github.event.pull_request.commits_url }} + run: | + curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "$COMMITS_URL" \ + | jq -r '.[].commit.message' | head -n 20 > commits.txt + + # 4️⃣ 阿里 AI 总结(通义千问) + - name: AI Summary (Qwen) + if: steps.check.outputs.ok == 'true' + id: ai + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + run: | + COMMIT_MESSAGES=$(cat commits.txt) + + jq -n --arg msgs "请用中文总结以下代码提交,输出3-5条,面向测试人员: + $COMMIT_MESSAGES" \ + '{"model": "qwen-plus", "input": {"prompt": $msgs}}' > ai_payload.json + + SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H "Content-Type: application/json" \ + -d @ai_payload.json | jq -r '.output.text') + + echo "summary<> $GITHUB_OUTPUT + echo "$SUMMARY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # 5️⃣ 企业微信通知(Markdown) + - name: Notify WeChat + if: steps.check.outputs.ok == 'true' + env: + WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} + BRANCH: ${{ github.event.pull_request.base.ref }} + AUTHOR: ${{ github.event.pull_request.user.login }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + AI_SUMMARY: ${{ steps.ai.outputs.summary }} + run: | + CONTENT=$(printf '## 🚀 Release 发布通知\n> 📦 **分支**: %s\n> 👤 **提交人**: %s\n> 📝 **标题**: %s\n\n### 🧠 AI变更摘要\n%s\n\n---\n🔗 [查看PR详情](%s)' \ + "$BRANCH" "$AUTHOR" "$PR_TITLE" "$AI_SUMMARY" "$PR_URL") + + jq -n --arg content "$CONTENT" \ + '{"msgtype": "markdown", "markdown": {"content": $content}}' > wechat_payload.json + + curl -s "$WECHAT_WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d @wechat_payload.json From 8495aa5dde784b423f5fbe9522be34a9df910e38 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:18:11 +0800 Subject: [PATCH 028/113] ci(wechat-notify): replace shell string formatting with Python - Replace printf and jq command chain with Python script for payload generation - Improve readability by using Python string concatenation instead of nested printf format specifiers - Ensure proper JSON encoding with ensure_ascii=False to preserve Chinese characters - Simplify environment variable interpolation using os.environ dictionary access --- .github/workflows/release-notify-wechat.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 7b3378b0..ae5f3f6e 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -89,11 +89,22 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | - CONTENT=$(printf '## 🚀 Release 发布通知\n> 📦 **分支**: %s\n> 👤 **提交人**: %s\n> 📝 **标题**: %s\n\n### 🧠 AI变更摘要\n%s\n\n---\n🔗 [查看PR详情](%s)' \ - "$BRANCH" "$AUTHOR" "$PR_TITLE" "$AI_SUMMARY" "$PR_URL") - - jq -n --arg content "$CONTENT" \ - '{"msgtype": "markdown", "markdown": {"content": $content}}' > wechat_payload.json + python3 -c " + import json, os + content = ( + '## 🚀 Release 发布通知\n' + '> 📦 **分支**: ' + os.environ['BRANCH'] + '\n' + '> 👤 **提交人**: ' + os.environ['AUTHOR'] + '\n' + '> 📝 **标题**: ' + os.environ['PR_TITLE'] + '\n\n' + '### 🧠 AI变更摘要\n' + + os.environ['AI_SUMMARY'] + '\n\n' + '---\n' + '🔗 [查看PR详情](' + os.environ['PR_URL'] + ')' + ) + payload = {'msgtype': 'markdown', 'markdown': {'content': content}} + with open('wechat_payload.json', 'w') as f: + json.dump(payload, f, ensure_ascii=False) + " curl -s "$WECHAT_WEBHOOK" \ -H 'Content-Type: application/json' \ From b20971dc9565fb91925dbc4f461f607fb112f3ca Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:20:53 +0800 Subject: [PATCH 029/113] ci(wechat-notify): extract payload building logic to Python script - Create new `.github/scripts/build_wechat_payload.py` to handle WeChat payload generation - Replace inline Python string concatenation with dedicated script for better maintainability - Add checkout step to access the script during workflow execution - Simplify workflow by delegating payload construction to external script - Improve code readability and reusability for future notification enhancements --- .github/scripts/build_wechat_payload.py | 24 +++++++++++++++++++++ .github/workflows/release-notify-wechat.yml | 21 +++++------------- 2 files changed, 29 insertions(+), 16 deletions(-) create mode 100644 .github/scripts/build_wechat_payload.py diff --git a/.github/scripts/build_wechat_payload.py b/.github/scripts/build_wechat_payload.py new file mode 100644 index 00000000..5e292ee9 --- /dev/null +++ b/.github/scripts/build_wechat_payload.py @@ -0,0 +1,24 @@ +import json +import os + +branch = os.environ.get("BRANCH", "") +author = os.environ.get("AUTHOR", "") +pr_title = os.environ.get("PR_TITLE", "") +pr_url = os.environ.get("PR_URL", "") +ai_summary = os.environ.get("AI_SUMMARY", "") + +content = ( + "## 🚀 Release 发布通知\n" + f"> 📦 **分支**: {branch}\n" + f"> 👤 **提交人**: {author}\n" + f"> 📝 **标题**: {pr_title}\n\n" + "### 🧠 AI变更摘要\n" + f"{ai_summary}\n\n" + "---\n" + f"🔗 [查看PR详情]({pr_url})" +) + +payload = {"msgtype": "markdown", "markdown": {"content": content}} + +with open("wechat_payload.json", "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index ae5f3f6e..b6c8a04d 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -79,6 +79,10 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT # 5️⃣ 企业微信通知(Markdown) + - name: Checkout for script + if: steps.check.outputs.ok == 'true' + uses: actions/checkout@v4 + - name: Notify WeChat if: steps.check.outputs.ok == 'true' env: @@ -89,22 +93,7 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | - python3 -c " - import json, os - content = ( - '## 🚀 Release 发布通知\n' - '> 📦 **分支**: ' + os.environ['BRANCH'] + '\n' - '> 👤 **提交人**: ' + os.environ['AUTHOR'] + '\n' - '> 📝 **标题**: ' + os.environ['PR_TITLE'] + '\n\n' - '### 🧠 AI变更摘要\n' - + os.environ['AI_SUMMARY'] + '\n\n' - '---\n' - '🔗 [查看PR详情](' + os.environ['PR_URL'] + ')' - ) - payload = {'msgtype': 'markdown', 'markdown': {'content': content}} - with open('wechat_payload.json', 'w') as f: - json.dump(payload, f, ensure_ascii=False) - " + python3 .github/scripts/build_wechat_payload.py curl -s "$WECHAT_WEBHOOK" \ -H 'Content-Type: application/json' \ From 99559621c5aabc5861c3ef5d1487e5a3ec43d45e Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:24:50 +0800 Subject: [PATCH 030/113] ci(wechat-notify): inline payload building logic into workflow - Remove build_wechat_payload.py script and consolidate payload construction directly in workflow - Eliminate intermediate environment variables and file I/O operations for cleaner execution - Inline AI summary payload generation into curl request - Inline WeChat notification payload generation into curl request - Remove unnecessary checkout step since script is no longer needed - Simplify workflow by reducing file dependencies and improving readability --- .github/scripts/build_wechat_payload.py | 24 -------- .github/workflows/release-notify-wechat.yml | 62 +++++++-------------- 2 files changed, 21 insertions(+), 65 deletions(-) delete mode 100644 .github/scripts/build_wechat_payload.py diff --git a/.github/scripts/build_wechat_payload.py b/.github/scripts/build_wechat_payload.py deleted file mode 100644 index 5e292ee9..00000000 --- a/.github/scripts/build_wechat_payload.py +++ /dev/null @@ -1,24 +0,0 @@ -import json -import os - -branch = os.environ.get("BRANCH", "") -author = os.environ.get("AUTHOR", "") -pr_title = os.environ.get("PR_TITLE", "") -pr_url = os.environ.get("PR_URL", "") -ai_summary = os.environ.get("AI_SUMMARY", "") - -content = ( - "## 🚀 Release 发布通知\n" - f"> 📦 **分支**: {branch}\n" - f"> 👤 **提交人**: {author}\n" - f"> 📝 **标题**: {pr_title}\n\n" - "### 🧠 AI变更摘要\n" - f"{ai_summary}\n\n" - "---\n" - f"🔗 [查看PR详情]({pr_url})" -) - -payload = {"msgtype": "markdown", "markdown": {"content": content}} - -with open("wechat_payload.json", "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index b6c8a04d..4264570f 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -13,31 +13,23 @@ jobs: steps: # 防止 GitHub HEAD 未同步 - - name: Wait for ref sync - run: sleep 3 + - run: sleep 3 # 1️⃣ 获取分支 HEAD - name: Get HEAD id: head - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - BASE_REF: ${{ github.event.pull_request.base.ref }} run: | HEAD_SHA=$(curl -s \ - -H "Authorization: Bearer $GH_TOKEN" \ - "https://api.github.com/repos/$REPO/git/ref/heads/$BASE_REF" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + https://api.github.com/repos/${{ github.repository }}/git/ref/heads/${{ github.event.pull_request.base.ref }} \ | jq -r '.object.sha') echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT # 2️⃣ 判断是否最终PR - name: Check Latest id: check - env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - HEAD_SHA: ${{ steps.head.outputs.head_sha }} run: | - if [ "$MERGE_SHA" = "$HEAD_SHA" ]; then + if [ "${{ github.event.pull_request.merge_commit_sha }}" = "${{ steps.head.outputs.head_sha }}" ]; then echo "ok=true" >> $GITHUB_OUTPUT else echo "ok=false" >> $GITHUB_OUTPUT @@ -47,54 +39,42 @@ jobs: - name: Get Commits if: steps.check.outputs.ok == 'true' id: commits - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMMITS_URL: ${{ github.event.pull_request.commits_url }} run: | curl -s \ - -H "Authorization: Bearer $GH_TOKEN" \ - "$COMMITS_URL" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + ${{ github.event.pull_request.commits_url }} \ | jq -r '.[].commit.message' | head -n 20 > commits.txt # 4️⃣ 阿里 AI 总结(通义千问) - name: AI Summary (Qwen) if: steps.check.outputs.ok == 'true' id: ai - env: - DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | - COMMIT_MESSAGES=$(cat commits.txt) - - jq -n --arg msgs "请用中文总结以下代码提交,输出3-5条,面向测试人员: - $COMMIT_MESSAGES" \ - '{"model": "qwen-plus", "input": {"prompt": $msgs}}' > ai_payload.json + CONTENT=$(cat commits.txt | sed ':a;N;$!ba;s/\n/\\n/g') SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ - -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H "Authorization: Bearer ${{ secrets.DASHSCOPE_API_KEY }}" \ -H "Content-Type: application/json" \ - -d @ai_payload.json | jq -r '.output.text') + -d "{ + \"model\": \"qwen-plus\", + \"input\": { + \"prompt\": \"请用中文总结以下代码提交,输出3-5条,面向测试人员:\\n$CONTENT\" + } + }" | jq -r '.output.text') echo "summary<> $GITHUB_OUTPUT echo "$SUMMARY" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT # 5️⃣ 企业微信通知(Markdown) - - name: Checkout for script - if: steps.check.outputs.ok == 'true' - uses: actions/checkout@v4 - - name: Notify WeChat if: steps.check.outputs.ok == 'true' - env: - WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} - BRANCH: ${{ github.event.pull_request.base.ref }} - AUTHOR: ${{ github.event.pull_request.user.login }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_URL: ${{ github.event.pull_request.html_url }} - AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | - python3 .github/scripts/build_wechat_payload.py - - curl -s "$WECHAT_WEBHOOK" \ + curl '${{ secrets.WECHAT_WEBHOOK }}' \ -H 'Content-Type: application/json' \ - -d @wechat_payload.json + -d "{ + \"msgtype\": \"markdown\", + \"markdown\": { + \"content\": \"## 🚀 Release 发布通知\n> 📦 **分支**: ${{ github.event.pull_request.base.ref }}\n> 👤 **提交人**: ${{ github.event.pull_request.user.login }}\n> 📝 **标题**: ${{ github.event.pull_request.title }}\n\n### 🧠 AI变更摘要\n${{ steps.ai.outputs.summary }}\n\n---\n🔗 [查看PR详情](${{ github.event.pull_request.html_url }})\" + } + }" From 90365cd026dee4de15ccedae5b63ece2bbd97354 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:28:10 +0800 Subject: [PATCH 031/113] ci(wechat-notify): refactor payload building to Python script - Extract WeChat notification payload construction from inline curl command - Move environment variables to explicit env section for clarity - Build JSON payload using Python for better string handling and readability - Write payload to temporary file and pass to curl via -d @wechat.json - Improves maintainability and reduces shell string escaping complexity --- .github/workflows/release-notify-wechat.yml | 33 ++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 4264570f..0c1ad7ca 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -69,12 +69,31 @@ jobs: # 5️⃣ 企业微信通知(Markdown) - name: Notify WeChat if: steps.check.outputs.ok == 'true' + env: + WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} + BRANCH: ${{ github.event.pull_request.base.ref }} + AUTHOR: ${{ github.event.pull_request.user.login }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | - curl '${{ secrets.WECHAT_WEBHOOK }}' \ + python3 << 'PYEOF' + import json, os + content = ( + "## 🚀 Release 发布通知\n" + "> 📦 **分支**: " + os.environ["BRANCH"] + "\n" + "> 👤 **提交人**: " + os.environ["AUTHOR"] + "\n" + "> 📝 **标题**: " + os.environ["PR_TITLE"] + "\n\n" + "### 🧠 AI变更摘要\n" + + os.environ["AI_SUMMARY"] + "\n\n" + "---\n" + "🔗 [查看PR详情](" + os.environ["PR_URL"] + ")" + ) + payload = {"msgtype": "markdown", "markdown": {"content": content}} + with open("wechat.json", "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + PYEOF + + curl -s "$WECHAT_WEBHOOK" \ -H 'Content-Type: application/json' \ - -d "{ - \"msgtype\": \"markdown\", - \"markdown\": { - \"content\": \"## 🚀 Release 发布通知\n> 📦 **分支**: ${{ github.event.pull_request.base.ref }}\n> 👤 **提交人**: ${{ github.event.pull_request.user.login }}\n> 📝 **标题**: ${{ github.event.pull_request.title }}\n\n### 🧠 AI变更摘要\n${{ steps.ai.outputs.summary }}\n\n---\n🔗 [查看PR详情](${{ github.event.pull_request.html_url }})\" - } - }" + -d @wechat.json From bafcb5c5453fcd92a9cb9c6a3d0c3f8629b84338 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:30:15 +0800 Subject: [PATCH 032/113] ci(wechat-notify): replace curl with urllib for webhook request - Replace curl command with Python urllib.request for direct HTTP POST - Remove intermediate wechat.json file write, send payload directly - Add urllib.request import to Python script - Simplify workflow by eliminating file I/O and shell command dependency - Improves reliability by keeping notification logic entirely within Python --- .github/workflows/release-notify-wechat.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 0c1ad7ca..52fd0e44 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -78,7 +78,7 @@ jobs: AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | python3 << 'PYEOF' - import json, os + import json, os, urllib.request content = ( "## 🚀 Release 发布通知\n" "> 📦 **分支**: " + os.environ["BRANCH"] + "\n" @@ -90,10 +90,12 @@ jobs: "🔗 [查看PR详情](" + os.environ["PR_URL"] + ")" ) payload = {"msgtype": "markdown", "markdown": {"content": content}} - with open("wechat.json", "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False) + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + os.environ["WECHAT_WEBHOOK"], + data=data, + headers={"Content-Type": "application/json"} + ) + resp = urllib.request.urlopen(req) + print(resp.read().decode()) PYEOF - - curl -s "$WECHAT_WEBHOOK" \ - -H 'Content-Type: application/json' \ - -d @wechat.json From c614bb5be7402051ef8035179ac9502373f4e741 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:33:30 +0800 Subject: [PATCH 033/113] ci(wechat-notify): refine AI prompt for commit summarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update prompt instruction to request numbered list format - Remove title and preamble from AI output for cleaner formatting - Improve clarity by specifying "要点" (key points) in prompt - Enhance consistency of release notification messages --- .github/workflows/release-notify-wechat.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 52fd0e44..6894db15 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -58,7 +58,7 @@ jobs: -d "{ \"model\": \"qwen-plus\", \"input\": { - \"prompt\": \"请用中文总结以下代码提交,输出3-5条,面向测试人员:\\n$CONTENT\" + \"prompt\": \"请用中文总结以下代码提交,输出3-5条要点,面向测试人员。直接输出编号列表,不要输出标题或前言:\\n$CONTENT\" } }" | jq -r '.output.text') From 0d16e168e75ebe611ec6baab938ea4f7ef222881 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:36:27 +0800 Subject: [PATCH 034/113] ci(wechat-notify): refactor AI summary generation to Python - Replace curl with urllib.request for API calls to improve portability - Move API key to environment variable for better security practices - Inline Python script using heredoc for cleaner workflow definition - Add intermediate file (ai_summary.txt) to separate concerns between API call and output handling - Simplify JSON payload construction using Python's json module - Improve error handling with fallback message for failed AI generation --- .github/workflows/release-notify-wechat.yml | 38 +++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 6894db15..d8f4e5aa 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -49,19 +49,37 @@ jobs: - name: AI Summary (Qwen) if: steps.check.outputs.ok == 'true' id: ai + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | - CONTENT=$(cat commits.txt | sed ':a;N;$!ba;s/\n/\\n/g') + python3 << 'PYEOF' + import json, os, urllib.request - SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ - -H "Authorization: Bearer ${{ secrets.DASHSCOPE_API_KEY }}" \ - -H "Content-Type: application/json" \ - -d "{ - \"model\": \"qwen-plus\", - \"input\": { - \"prompt\": \"请用中文总结以下代码提交,输出3-5条要点,面向测试人员。直接输出编号列表,不要输出标题或前言:\\n$CONTENT\" - } - }" | jq -r '.output.text') + with open("commits.txt", "r") as f: + commits = f.read().strip() + prompt = "请用中文总结以下代码提交,输出3-5条要点,面向测试人员。直接输出编号列表,不要输出标题或前言:\n" + commits + payload = {"model": "qwen-plus", "input": {"prompt": prompt}} + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + + req = urllib.request.Request( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation", + data=data, + headers={ + "Authorization": "Bearer " + os.environ["DASHSCOPE_API_KEY"], + "Content-Type": "application/json" + } + ) + resp = urllib.request.urlopen(req) + result = json.loads(resp.read().decode()) + summary = result.get("output", {}).get("text", "AI 摘要生成失败") + print(summary) + + with open("ai_summary.txt", "w", encoding="utf-8") as f: + f.write(summary) + PYEOF + + SUMMARY=$(cat ai_summary.txt) echo "summary<> $GITHUB_OUTPUT echo "$SUMMARY" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT From 06075ffef504c0b58e286b5cf434cefc7633b68f Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 09:57:36 +0800 Subject: [PATCH 035/113] fix(web): calculate using the filtered breadcrumbs length --- web/src/components/Header/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index f2eff014..d85a84b0 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -135,9 +135,10 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { * - Disables navigation for the last breadcrumb item */ const formatBreadcrumbNames = () => { - return breadcrumbs.filter(item => item.type !== 'group').map((menu, index) => { + const filtered = breadcrumbs.filter(item => item.type !== 'group'); + return filtered.map((menu, index) => { const label = menu.i18nKey ? t(menu.i18nKey) : menu.label; - const isLast = index === breadcrumbs.length - 1; + const isLast = index === filtered.length - 1; const item: any = { title: ( From 2d9986f9025dd577108dc1a4f7572e7e2a75fe89 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 10:03:46 +0800 Subject: [PATCH 036/113] fix(web): header user name --- web/src/components/Header/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index d85a84b0..23a89894 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -77,7 +77,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { { key: '1', icon: - {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(0, 2) : user.username?.[0]} + {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(-2) : user.username[0]} , label: (<>
{user.username}
@@ -182,7 +182,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { > - {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(user.username.length, -2) : user.username[0]} + {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(-2) : user.username[0]} {user.username}
Date: Tue, 14 Apr 2026 10:20:50 +0800 Subject: [PATCH 037/113] feat(web): chat add file info --- web/src/components/Chat/ChatContent.tsx | 5 +++-- web/src/views/Conversation/index.tsx | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index 5c722e45..f28b5dce 100644 --- a/web/src/components/Chat/ChatContent.tsx +++ b/web/src/components/Chat/ChatContent.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-10 16:46:17 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-10 18:46:57 + * @Last Modified time: 2026-04-14 10:13:56 */ import { type FC, useRef, useEffect, useState } from 'react' import clsx from 'clsx' @@ -174,6 +174,7 @@ const ChatContent: FC = ({ ) } + const documentType = (file.file_type || file.type)?.split('/') return ( = ({ >
{file.name}
-
{file.type?.split('/')[file.type?.split('/').length - 1]} · {file.size}
+
{documentType?.[documentType.length - 1]} · {file.size}
) diff --git a/web/src/views/Conversation/index.tsx b/web/src/views/Conversation/index.tsx index 8432b1c2..778279d3 100644 --- a/web/src/views/Conversation/index.tsx +++ b/web/src/views/Conversation/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:58:03 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 21:21:52 + * @Last Modified time: 2026-04-13 18:32:58 */ /** * Conversation Page @@ -397,7 +397,10 @@ const Conversation: FC = () => { return { type: file.type, transfer_method: 'local_file', - upload_file_id: file.response.data.file_id + upload_file_id: file.response.data.file_id, + file_type: file.response.data.file_type, + size: file.response.data.file_size, + name: file.response.data.file_name } } }), @@ -444,7 +447,7 @@ const Conversation: FC = () => { }) } - console.log('chatList', chatList, streamLoadingRef.current) + console.log('chatList', fileList, streamLoadingRef.current) return ( From 7f8765b815b30e3cafaf75daba9c9c455c6ba445 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 14:51:02 +0800 Subject: [PATCH 038/113] feat(web): package --- web/src/api/package.ts | 14 +++ web/src/i18n/en.ts | 64 +++++++++++++ web/src/i18n/zh.ts | 64 +++++++++++++ web/src/routes/index.tsx | 5 +- web/src/routes/routes.json | 3 +- web/src/store/menu.json | 16 +--- web/src/utils/request.ts | 7 +- web/src/views/Package/constant.ts | 34 +++++++ web/src/views/Package/index.tsx | 145 ++++++++++++++++++++++++++++++ web/src/views/Package/types.ts | 61 +++++++++++++ 10 files changed, 392 insertions(+), 21 deletions(-) create mode 100644 web/src/api/package.ts create mode 100644 web/src/views/Package/constant.ts create mode 100644 web/src/views/Package/index.tsx create mode 100644 web/src/views/Package/types.ts diff --git a/web/src/api/package.ts b/web/src/api/package.ts new file mode 100644 index 00000000..da52d355 --- /dev/null +++ b/web/src/api/package.ts @@ -0,0 +1,14 @@ +import { request } from '@/utils/request' + +import type { Package } from '@/views/Package/types' + +export const SYS_API_PREFIX = '/sys'; +// 套餐列表 +export const getPackageListUrl = `${SYS_API_PREFIX}/package-plans` +export const getPackageList = (query: { category: Package['category']; status: boolean; }) => { + return request.get(getPackageListUrl, query) +} +// 获取套餐详情 +export const getPackageDetail = (package_plan_id: string) => { + return request.get(`${SYS_API_PREFIX}/package-plans/${package_plan_id}`) +} \ No newline at end of file diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index fc3a041d..6bcc5034 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -3016,5 +3016,69 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re apply: 'Apply', tools: 'Tools', }, + package: { + package: 'Package Management', + saas_personal: 'SaaS Personal', + commercial_deployment: 'Commercial Deployment', + noCommercialPackages: 'No commercial deployment packages available', + + addPackage: 'Add Plan', + packageName: 'Plan Name', + packageNameZh: 'Plan Name (中文)', + packageNameEn: 'Plan Name (English)', + packageNamePlaceholder: '中文, 例如:记忆体验版', + packageNamePlaceholderEn: 'English, e.g. Memory Trial Plan', + packageCategory: 'Package Category', + price: 'Price', + pricePlaceholder: 'e.g. 0, 19, 299 or Contact Us', + billingPeriod: 'Billing Period', + monthly: 'Monthly', + yearly: 'Yearly', + permanent_free: 'Permanent Free', + local_deployment: 'Local Deployment', + coreValue: 'Core Value', + coreValueZh: 'Core Value (中文)', + coreValueEn: 'Core Value (English)', + coreValuePlaceholder: '中文, 一句话描述核心价值', + coreValuePlaceholderEn: 'EngLish, describe the core value in one sentence', + tech_support: 'Technical Support', + tech_support_zh: 'Technical Support (中文)', + tech_support_en: 'Technical Support (English)', + technicalSupportPlaceholder: '中文, 例如:社群交流、工单支持', + technicalSupportPlaceholderEn: 'English, e.g. Community support, ticket support', + sla: 'SLA & Compliance', + slaZh: 'SLA & Compliance (中文)', + slaEn: 'SLA & Compliance (English)', + slaPlaceholder: '中文, 例如:无、验证力加强+审计日志', + slaPlaceholderEn: 'English, e.g. None, dedicated compute pool + audit logs', + customPage: 'Chat Page Customization', + customPageZh: 'Chat Page Customization (中文)', + customPageEn: 'Chat Page Customization (English)', + customPagePlaceholder: '中文, 例如:LOGO定制', + customPagePlaceholderEn: 'English, e.g. Logo customization', + primaryColor: 'Primary Color', + status: 'Status', + active: 'Active', + inactive: 'Inactive', + api_ops_rate_limit: 'API OPS Rate Limit', + ops: 'req/s', + pcs: 'pcs', + GB: 'GB', + tier_level: 'Tier Level', + numberPlaceholder: 'e.g. 10', + + packageDetail: 'Package Detail', + basicInfo: 'Basic Info', + featureConfig: 'Billing Unit Quota', + workspace_quota: 'Workspace Quota', + skill_quota: 'Skill Library Quota', + app_quota: 'App Quota', + knowledge_capacity_quota: 'Knowledge Base Capacity', + memory_engine_quota: 'Memory Engine Quota', + end_user_quota: 'Memorable End Users', + ontology_project_quota: 'Ontology Project', + model_quota: 'Model Quota', + editPackage: 'Edit Package', + }, }, }; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 01c766b8..fff8c1af 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2980,5 +2980,69 @@ export const zh = { apply: '应用', tools: '工具', }, + package: { + package: '套餐管理', + saas_personal: 'SaaS 个人版', + commercial_deployment: '商业化部署', + noCommercialPackages: '暂无商业化部署套餐', + + addPackage: '添加套餐', + packageName: '套餐名称', + packageNameZh: '套餐名称 (中文)', + packageNameEn: '套餐名称 (English)', + packageNamePlaceholder: '中文, 例如:记忆体验版', + packageNamePlaceholderEn: 'English, e.g. Memory Trial Plan', + packageCategory: '套餐分类', + price: '价格', + pricePlaceholder: '例如: 0, 19, 299 或联系我们', + billingPeriod: '计费周期', + monthly: '月', + yearly: '年', + permanent_free: '永久免费', + local_deployment: '本地化部署', + coreValue: '核心价值', + coreValueZh: '核心价值 (中文)', + coreValueEn: '核心价值 (English)', + coreValuePlaceholder: '中文, 一句话描述核心价值', + coreValuePlaceholderEn: 'EngLish, describe the core value in one sentence', + tech_support: '技术支持', + tech_support_zh: '技术支持 (中文)', + tech_support_en: '技术支持 (English)', + technicalSupportPlaceholder: '中文, 例如:社群交流、工单支持', + technicalSupportPlaceholderEn: 'English, e.g. Community support, ticket support', + sla: 'SLA与合规', + slaZh: 'SLA与合规 (中文)', + slaEn: 'SLA与合规 (English)', + slaPlaceholder: '中文, 例如:无、验证力加强+审计日志', + slaPlaceholderEn: 'English, e.g. None, dedicated compute pool + audit logs', + customPage: '对应页面个性化配置', + customPageZh: '对应页面个性化配置 (中文)', + customPageEn: '对应页面个性化配置 (English)', + customPagePlaceholder: '中文, 例如:LOGO定制', + customPagePlaceholderEn: 'English, e.g. Logo customization', + primaryColor: '主题色', + status: '状态', + active: '启用', + inactive: '停用', + api_ops_rate_limit: 'API OPS 频次', + ops: '次/秒', + pcs: '个', + GB: 'GB', + tier_level: '层级', + numberPlaceholder: '如: 10', + + packageDetail: '套餐详情', + basicInfo: '基础信息', + featureConfig: '计费单元配额', + workspace_quota: '空间数量', + skill_quota: '技能库数量', + app_quota: '应用数量', + knowledge_capacity_quota: '知识库容量', + memory_engine_quota: '记忆引擎数量', + end_user_quota: '可记忆终端用户数', + ontology_project_quota: '本体工程', + model_quota: '可负载模型数量', + editPackage: '编辑套餐', + }, }, } \ No newline at end of file diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index 92f7a5cf..7b940068 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 16:33:11 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-04 18:11:34 + * @Last Modified time: 2026-04-13 16:53:15 */ /** * Route Configuration @@ -76,13 +76,12 @@ const componentMap: Record>> = SpaceManagement: lazy(() => import('@/views/SpaceManagement')), ApiKeyManagement: lazy(() => import('@/views/ApiKeyManagement')), EmotionEngine: lazy(() => import('@/views/EmotionEngine')), - StatementDetail: lazy(() => import('@/views/UserMemoryDetail/pages/StatementDetail')), ForgetDetail: lazy(() => import('@/views/UserMemoryDetail/pages/ForgetDetail')), MemoryNodeDetail: lazy(() => import('@/views/UserMemoryDetail/pages/index')), SelfReflectionEngine: lazy(() => import('@/views/SelfReflectionEngine')), OrderPayment: lazy(() => import('@/views/OrderPayment')), OrderHistory: lazy(() => import('@/views/OrderHistory')), - Pricing: lazy(() => import('@/views/Pricing')), + Package: lazy(() => import('@/views/Package')), ToolManagement: lazy(() => import('@/views/ToolManagement')), SpaceConfig: lazy(() => import('@/views/SpaceConfig')), Ontology: lazy(() => import('@/views/Ontology')), diff --git a/web/src/routes/routes.json b/web/src/routes/routes.json index 5ff1f90c..422387a7 100644 --- a/web/src/routes/routes.json +++ b/web/src/routes/routes.json @@ -7,7 +7,7 @@ { "path": "/model", "element": "ModelManagement" }, { "path": "/space", "element": "SpaceManagement" }, { "path": "/tool", "element": "ToolManagement" }, - { "path": "/pricing", "element": "Pricing" }, + { "path": "/pricing", "element": "Package" }, { "path": "/order-pay", "element": "OrderPayment" }, { "path": "/orders", "element": "OrderHistory" }, { "path": "/skills", "element": "Skills" }, @@ -48,7 +48,6 @@ { "path": "/application/config/:id", "element": "ApplicationConfig" }, { "path": "/application/config/:id/:source", "element": "ApplicationConfig" }, { "path": "/user-memory/neo4j/:id", "element": "Neo4jUserMemoryDetail" }, - { "path": "/statement/:id", "element": "StatementDetail" }, { "path": "/user-memory/detail/:id/:type", "element": "MemoryNodeDetail" }, { "path": "/ontology/:id", "element": "OntologyDetail" } ] diff --git a/web/src/store/menu.json b/web/src/store/menu.json index 8d30dcc4..90b546ca 100644 --- a/web/src/store/menu.json +++ b/web/src/store/menu.json @@ -421,21 +421,7 @@ "display": false, "level": 3, "sort": 0, - "subs": [ - { - "id": 2211, - "parent": 221, - "code": "statementDetail", - "label": "记忆详情", - "i18nKey": "menu.statementDetail", - "path": "/statement/:id", - "enable": true, - "display": false, - "level": 4, - "sort": 0, - "subs": null - } - ] + "subs": [] }, { "id": 222, diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts index 80c12f85..318738dd 100644 --- a/web/src/utils/request.ts +++ b/web/src/utils/request.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 16:35:15 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-06 10:39:00 + * @Last Modified time: 2026-04-14 14:43:54 */ /** * HTTP Request Utility Module @@ -23,6 +23,7 @@ import { clearAuthData } from './auth'; import { message } from 'antd'; import { refreshTokenUrl, refreshToken, loginUrl, logoutUrl } from '@/api/user' import i18n from '@/i18n' +import { SYS_API_PREFIX } from '@/api/package' /** * Standard API response structure @@ -74,6 +75,10 @@ let requests: RequestQueueItem[] = []; // Request interceptor service.interceptors.request.use( (config) => { + console.log('config', config, config.url?.startsWith(SYS_API_PREFIX)) + if (config.url?.startsWith(SYS_API_PREFIX)) { + config.baseURL = ''; + } if (!config.headers.Authorization) { const token = cookieUtils.get('authToken'); if (token) { diff --git a/web/src/views/Package/constant.ts b/web/src/views/Package/constant.ts new file mode 100644 index 00000000..e4b04719 --- /dev/null +++ b/web/src/views/Package/constant.ts @@ -0,0 +1,34 @@ +export const billingUnits = [ + { + key: 'workspace_quota', + unit: '个', placeholder: '如: 10', + }, + { + key: 'skill_quota', + unit: '个', placeholder: '如: 10', + }, + { + key: 'app_quota', + unit: '个', placeholder: '如: 10', + }, + { + key: 'knowledge_capacity_quota', + unit: 'GB', placeholder: '如: 10', + }, + { + key: 'memory_engine_quota', + unit: '个', placeholder: '如: 10', + }, + { + key: 'end_user_quota', + unit: '个', placeholder: '如: 10', + }, + { + key: 'ontology_project_quota', + unit: '个', placeholder: '如: 10', + }, + { + key: 'model_quota', + unit: '次/秒', placeholder: '如: 10', + }, +] \ No newline at end of file diff --git a/web/src/views/Package/index.tsx b/web/src/views/Package/index.tsx new file mode 100644 index 00000000..0e8a347f --- /dev/null +++ b/web/src/views/Package/index.tsx @@ -0,0 +1,145 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-25 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-04-14 14:48:13 + */ +/** + * Package Component + * + * Package management page with: + * - Tabs for SaaS Personal and Commercial Deployment + * - Package cards showing features and pricing + * - Edit and delete actions + * + * @component + */ + +import { useMemo, useState, useEffect, type FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { Flex, Row, Col, type SegmentedProps } from 'antd'; +import clsx from 'clsx'; + +import type { Package } from './types' +import { getPackageList } from '@/api/package'; +import PageTabs from '@/components/PageTabs' +import { billingUnits } from './constant' +import RbCard from '@/components/RbCard/Card' +import BodyWrapper from '@/components/Empty/BodyWrapper' +import { useI18n } from '@/store/locale' +import RbButton from '@/components/RbButton' + +const Package: FC = () => { + const { t } = useTranslation(); + const { language } = useI18n() + const navigate = useNavigate(); + const [data, setData] = useState([]) + + const [activeTab, setActiveTab] = useState('saas_personal'); + const formatTabItems = useMemo(() => { + return ['saas_personal', 'commercial_deployment'].map(value => ({ + value, + label: t(`package.${value}`), + })) + }, [t]) + /** Handle tab change */ + const handleChangeTab = (value: SegmentedProps['value']) => { + setActiveTab(value as string); + } + const getList = () => { + getPackageList({ category: activeTab as Package['category'], status: true }) + .then(res => { + setData(res as Package[] || []) + }) + } + + useEffect(() => { + getList() + }, [activeTab]) + + const getKeyWithLanguage = (key: string) => { + return (language === 'en' ? `${key}_en` : key) as keyof Package + } + /** Navigate to order history */ + const goToHistory = () => { + navigate('/orders'); + } + return ( + <> + + + +
+ {t('pricing.orderHistory')} +
+
+ + + {data.map((pkg) => ( + + + +
+ {/* Header */} +
+

+ {String(pkg[getKeyWithLanguage('name')] ?? '')} +

+

{String(pkg[getKeyWithLanguage('core_value')] ?? '')}

+
+ {pkg.billing_cycle !== 'permanent_free' && <>¥{pkg.price}} + {pkg.billing_cycle && {pkg.billing_cycle !== 'permanent_free' && '/'}{t(`package.${pkg.billing_cycle}`)}} +
+
+ + {/* Features */} +
+ {billingUnits.map(({ key, unit }) => { + if (typeof pkg.quotas[key as keyof Package['quotas']] === 'number') { + return ( +
+ {t(`package.${key}`)} + {pkg.quotas[key as keyof Package['quotas']]}{unit} +
+ ) + } + })} + {pkg.tech_support && +
+ {t(`package.tech_support`)} + {String(pkg[getKeyWithLanguage('tech_support')] ?? '')} +
+ } + {pkg.api_ops_rate_limit && +
+ {t(`package.api_ops_rate_limit`)} + {pkg.api_ops_rate_limit}(次/秒) +
+ } +
+
+
+ +
+ + ))} +
+
+ + ); +}; + +export default Package; diff --git a/web/src/views/Package/types.ts b/web/src/views/Package/types.ts new file mode 100644 index 00000000..6517f63a --- /dev/null +++ b/web/src/views/Package/types.ts @@ -0,0 +1,61 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-04-14 11:35:01 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-04-14 14:28:46 + */ +export interface Package { + id: string; + // 名称 + name: string; + name_en: string; + // 类型 + category: "saas_personal" | "commercial_deployment"; + tier_level: number; + // 版本 + version: string; + // 状态 + status: boolean; + // 价格 + price: string; + // 计费周期 + billing_cycle: "monthly" | "yearly" | "permanent_free" | "local_deployment"; + // 核心价值 + core_value: string; + core_value_en: string; + // 技术支持 + tech_support: string; + tech_support_en: string; + // SLA与合规 + sla_compliance: string; + sla_compliance_en: string; + // 对话页面个性化配置 + page_customization: string; + page_customization_en: string; + // API OPS 频次(次/秒) + api_ops_rate_limit: number; + // 主题色 + theme_color: string; + quotas: { + // 空间数量 + workspace_quota: number; + // 技能库数量 + skill_quota: number; + // 应用数量 + app_quota: number; + // 知识库容量 + knowledge_capacity_quota: string; + // 记忆引擎数量 + memory_engine_quota: number; + // 可记忆终端用户数 + end_user_quota: number; + // 本体工程 + ontology_project_quota: number; + // 可负载模型数量 + model_quota: number; + }, + created_at: number; + updated_at: number; + created_by: string; + updated_by: string | null; +} From e539b3eeb7541291c45535e27623eadfe51b13b7 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 14:59:32 +0800 Subject: [PATCH 039/113] fix(web): i18n --- web/src/views/Package/constant.ts | 22 ++++++++++++++-------- web/src/views/Package/index.tsx | 16 ++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/web/src/views/Package/constant.ts b/web/src/views/Package/constant.ts index e4b04719..8d3b0d48 100644 --- a/web/src/views/Package/constant.ts +++ b/web/src/views/Package/constant.ts @@ -1,34 +1,40 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-04-14 11:43:57 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-04-14 14:55:20 + */ export const billingUnits = [ { key: 'workspace_quota', - unit: '个', placeholder: '如: 10', + unit: 'pcs', placeholder: 'numberPlaceholder', }, { key: 'skill_quota', - unit: '个', placeholder: '如: 10', + unit: 'pcs', placeholder: 'numberPlaceholder', }, { key: 'app_quota', - unit: '个', placeholder: '如: 10', + unit: 'pcs', placeholder: 'numberPlaceholder', }, { key: 'knowledge_capacity_quota', - unit: 'GB', placeholder: '如: 10', + unit: 'GB', placeholder: 'numberPlaceholder', }, { key: 'memory_engine_quota', - unit: '个', placeholder: '如: 10', + unit: 'pcs', placeholder: 'numberPlaceholder', }, { key: 'end_user_quota', - unit: '个', placeholder: '如: 10', + unit: 'pcs', placeholder: 'numberPlaceholder', }, { key: 'ontology_project_quota', - unit: '个', placeholder: '如: 10', + unit: 'pcs', placeholder: 'numberPlaceholder', }, { key: 'model_quota', - unit: '次/秒', placeholder: '如: 10', + unit: 'ops', placeholder: 'numberPlaceholder', }, ] \ No newline at end of file diff --git a/web/src/views/Package/index.tsx b/web/src/views/Package/index.tsx index 0e8a347f..64ce0c04 100644 --- a/web/src/views/Package/index.tsx +++ b/web/src/views/Package/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-25 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-14 14:48:13 + * @Last Modified time: 2026-04-14 14:59:11 */ /** * Package Component @@ -112,23 +112,23 @@ const Package: FC = () => { return (
{t(`package.${key}`)} - {pkg.quotas[key as keyof Package['quotas']]}{unit} + {pkg.quotas[key as keyof Package['quotas']]}{t(`package.${unit}`)}
) } })} + {pkg.api_ops_rate_limit && +
+ {t(`package.api_ops_rate_limit`)} + {pkg.api_ops_rate_limit}{t('package.ops')} +
+ } {pkg.tech_support &&
{t(`package.tech_support`)} {String(pkg[getKeyWithLanguage('tech_support')] ?? '')}
} - {pkg.api_ops_rate_limit && -
- {t(`package.api_ops_rate_limit`)} - {pkg.api_ops_rate_limit}(次/秒) -
- }
From b5ec5c2ceaa1c5fa00152564f6510576f2b01b2f Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 15:08:07 +0800 Subject: [PATCH 040/113] fix(web): change http body key name --- .../components/Properties/HttpRequest/EditableTable.tsx | 4 ++-- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx index e0a27b47..e4b2cc29 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx @@ -85,9 +85,9 @@ const EditableTable: FC = ({ return [ { title: t('workflow.config.name'), - dataIndex: 'name', + dataIndex: 'key', render: (_: any, __: TableRow, index: number) => ( - + ).map(([key, value]) => ({ key, value })) : group_variables } else if (type === 'http-request' && (key === 'headers' || key === 'params') && config[key] && typeof config[key] === 'object' && !Array.isArray(config[key]) && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { - nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([name, value]) => ({ name, value })) + nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([key, value]) => ({ key, value })) } else if (type === 'code' && key === 'code' && config[key] && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { try { nodeLibraryConfig.config[key].defaultValue = decodeURIComponent(atob(config[key] as string)) @@ -1259,7 +1259,7 @@ export const useWorkflowGraph = ({ itemConfig[key] = {} if (value.length > 0) { value.forEach((vo: any) => { - itemConfig[key][vo.name] = vo.value + itemConfig[key][vo.key] = vo.value }) } } else if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') { From 09650082103b1915369677603d4044313bcf9d95 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 14 Apr 2026 15:53:16 +0800 Subject: [PATCH 041/113] fix(http-request): support array and file variables in form-data files upload - Updated form-data handling to accept both single FileVariable and ArrayVariable containing FileVariable for file uploads - Fixed HTTP client redirect handling by enabling follow_redirects=True when downloading remote files - Adjusted config validation to correctly require list type for form-data fields instead of HttpFormData class --- .../workflow/nodes/http_request/config.py | 4 ++-- .../core/workflow/nodes/http_request/node.py | 19 ++++++++++++------- .../workflow/variable/variable_objects.py | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/api/app/core/workflow/nodes/http_request/config.py b/api/app/core/workflow/nodes/http_request/config.py index e1b84f0c..3473f666 100644 --- a/api/app/core/workflow/nodes/http_request/config.py +++ b/api/app/core/workflow/nodes/http_request/config.py @@ -72,8 +72,8 @@ class HttpContentTypeConfig(BaseModel): @classmethod def validate_data(cls, v, info): content_type = info.data.get("content_type") - if content_type == HttpContentType.FROM_DATA and not isinstance(v, HttpFormData): - raise ValueError("When content_type is 'form-data', data must be of type HttpFormData") + if content_type == HttpContentType.FROM_DATA and not isinstance(v, list): + raise ValueError("When content_type is 'form-data', data must be a list of HttpFormData") elif content_type in [HttpContentType.JSON] and not isinstance(v, str): raise ValueError("When content_type is JSON, data must be of type str") elif content_type in [HttpContentType.WWW_FORM] and not isinstance(v, dict): diff --git a/api/app/core/workflow/nodes/http_request/node.py b/api/app/core/workflow/nodes/http_request/node.py index 086bee4a..783c230b 100644 --- a/api/app/core/workflow/nodes/http_request/node.py +++ b/api/app/core/workflow/nodes/http_request/node.py @@ -260,17 +260,22 @@ class HttpRequestNode(BaseNode): )) case HttpContentType.FROM_DATA: data = {} - content["files"] = {} + files = [] for item in self.typed_config.body.data: + key = self._render_template(item.key, variable_pool) if item.type == "text": - data[self._render_template(item.key, variable_pool)] = self._render_template(item.value, - variable_pool) + data[key] = self._render_template(item.value, variable_pool) elif item.type == "file": - content["files"][self._render_template(item.key, variable_pool)] = ( - uuid.uuid4().hex, - await variable_pool.get_instance(item.value).get_content() - ) + file_instance = variable_pool.get_instance(item.value) + if isinstance(file_instance, ArrayVariable): + for v in file_instance.value: + if isinstance(v, FileVariable): + files.append((key, (uuid.uuid4().hex, await v.get_content()))) + elif isinstance(file_instance, FileVariable): + files.append((key, (uuid.uuid4().hex, await file_instance.get_content()))) content["data"] = data + if files: + content["files"] = files case HttpContentType.BINARY: content["files"] = [] file_instence = variable_pool.get_instance(self.typed_config.body.data) diff --git a/api/app/core/workflow/variable/variable_objects.py b/api/app/core/workflow/variable/variable_objects.py index 94f87287..2b849c94 100644 --- a/api/app/core/workflow/variable/variable_objects.py +++ b/api/app/core/workflow/variable/variable_objects.py @@ -84,7 +84,7 @@ class FileVariable(BaseVariable): total_bytes = 0 chunks = [] - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(follow_redirects=True) as client: async with client.stream("GET", self.value.url) as resp: resp.raise_for_status() async for chunk in resp.aiter_bytes(8192): From fa1e5ee43c962d66c6b20e4d12765fae996dc2c5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 16:06:03 +0800 Subject: [PATCH 042/113] fix(web): adjust the value of End User Name --- web/src/views/UserMemoryDetail/Neo4j.tsx | 10 ++++++---- .../UserMemoryDetail/components/EndUserProfile.tsx | 7 ++++--- web/src/views/UserMemoryDetail/types.ts | 3 ++- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/web/src/views/UserMemoryDetail/Neo4j.tsx b/web/src/views/UserMemoryDetail/Neo4j.tsx index 51be7c8d..3fdaaed3 100644 --- a/web/src/views/UserMemoryDetail/Neo4j.tsx +++ b/web/src/views/UserMemoryDetail/Neo4j.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:26 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 18:59:53 + * @Last Modified time: 2026-04-14 16:04:08 */ /** * Neo4j User Memory Detail View @@ -22,7 +22,7 @@ import InterestDistribution from './components/InterestDistribution' import NodeStatistics from './components/NodeStatistics' import RelationshipNetwork from './components/RelationshipNetwork' import MemoryInsight from './components/MemoryInsight' -import type { EndUserProfileRef, MemoryInsightRef, AboutMeRef } from './types' +import type { EndUserProfileRef, MemoryInsightRef, AboutMeRef, EndUser } from './types' import { analyticsRefresh, } from '@/api/memory' @@ -39,8 +39,10 @@ const Neo4j: FC = () => { const [selectedKey, setSelectedKey] = useState(null) /** Update displayed name */ - const handleNameUpdate = (data: { other_name?: string; id: string }) => { - setName(data.other_name && data.other_name !== '' ? data.other_name : data.id) + const handleNameUpdate = (data?: EndUser) => { + if (!data) return + let name = data.other_name && data.other_name !== '' ? data.other_name : data.id || data.end_user_id + setName(name) } /** Navigate back */ diff --git a/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx b/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx index c689bf72..4dee9d4f 100644 --- a/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx +++ b/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 18:33:30 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-27 11:11:09 + * @Last Modified time: 2026-04-14 16:03:41 */ /** * End User Profile Component @@ -27,11 +27,11 @@ import type { EndUser, EndUserProfileModalRef, EndUserProfileRef } from '../type * Component props */ interface EndUserProfileProps { - onDataLoaded?: (data: { other_name?: string; id: string }) => void; + onDataLoaded?: (data?: EndUser) => void; className?: string; } -const EndUserProfile = forwardRef(({ className }, ref) => { +const EndUserProfile = forwardRef(({ className, onDataLoaded }, ref) => { const { t } = useTranslation() const { id } = useParams() const endUserProfileModalRef = useRef(null) @@ -51,6 +51,7 @@ const EndUserProfile = forwardRef(({ cla const userData = res as EndUser setData(userData) setLoading(false) + onDataLoaded?.(userData as EndUser) }) .finally(() => { setLoading(false) diff --git a/web/src/views/UserMemoryDetail/types.ts b/web/src/views/UserMemoryDetail/types.ts index 9e56bb5d..d8bc6f23 100644 --- a/web/src/views/UserMemoryDetail/types.ts +++ b/web/src/views/UserMemoryDetail/types.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:15 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-24 17:58:54 + * @Last Modified time: 2026-04-14 16:03:16 */ /** * User Memory Detail Types @@ -172,6 +172,7 @@ export interface EndUser { other_name: string; aliases: string | null; meta_data: Record; + id?: string; end_user_info_id: string; end_user_id: string; created_at: string; From e3265e4ba37982afabc8234060348412beedc994 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 14 Apr 2026 16:14:01 +0800 Subject: [PATCH 043/113] fix(http-request,embedding,naive): tighten form-data validation, reduce truncation length to 8000, and disable chunking for Excel The form-data validation now ensures all items in the list are of type HttpFormData. Truncation length for embedding inputs is reduced from 8191 to 8000 to accommodate tokenizer differences and avoid overflow. Excel parsing now disables chunking by setting chunk_token_num to 0, aligning with intended behavior for structured file ingestion. --- api/app/core/rag/app/naive.py | 2 +- api/app/core/rag/llm/embedding_model.py | 10 +++++++--- api/app/core/workflow/nodes/http_request/config.py | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/api/app/core/rag/app/naive.py b/api/app/core/rag/app/naive.py index 72272347..93b96843 100644 --- a/api/app/core/rag/app/naive.py +++ b/api/app/core/rag/app/naive.py @@ -675,7 +675,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, parser_config["chunk_token_num"] = 0 else: sections = [(_, "") for _ in excel_parser(binary) if _] - parser_config["chunk_token_num"] = 12800 + parser_config["chunk_token_num"] = 0 elif re.search(r"\.(txt|py|js|java|c|cpp|h|php|go|ts|sh|cs|kt|sql)$", filename, re.IGNORECASE): callback(0.1, "Start to parse.") diff --git a/api/app/core/rag/llm/embedding_model.py b/api/app/core/rag/llm/embedding_model.py index 22e35a15..59210054 100644 --- a/api/app/core/rag/llm/embedding_model.py +++ b/api/app/core/rag/llm/embedding_model.py @@ -50,7 +50,9 @@ class OpenAIEmbed(Base): def encode(self, texts: list): # OpenAI requires batch size <=16 batch_size = 16 - texts = [truncate(t, 8191) for t in texts] + # Use 8000 instead of 8191 to leave safety margin for tokenizer differences + # between cl100k_base (used by truncate) and the actual embedding model + texts = [truncate(t, 8000) for t in texts] ress = [] total_tokens = 0 for i in range(0, len(texts), batch_size): @@ -63,7 +65,7 @@ class OpenAIEmbed(Base): return np.array(ress), total_tokens def encode_queries(self, text): - res = self.client.embeddings.create(input=[truncate(text, 8191)], model=self.model_name, encoding_format="float",extra_body={"drop_params": True}) + res = self.client.embeddings.create(input=[truncate(text, 8000)], model=self.model_name, encoding_format="float",extra_body={"drop_params": True}) return np.array(res.data[0].embedding), self.total_token_count(res) @@ -79,6 +81,7 @@ class LocalAIEmbed(Base): def encode(self, texts: list): batch_size = 16 + texts = [truncate(t, 8000) for t in texts] ress = [] for i in range(0, len(texts), batch_size): res = self.client.embeddings.create(input=texts[i : i + batch_size], model=self.model_name) @@ -173,6 +176,7 @@ class XinferenceEmbed(Base): def encode(self, texts: list): batch_size = 16 + texts = [truncate(t, 8000) for t in texts] ress = [] total_tokens = 0 for i in range(0, len(texts), batch_size): @@ -188,7 +192,7 @@ class XinferenceEmbed(Base): def encode_queries(self, text): res = None try: - res = self.client.embeddings.create(input=[text], model=self.model_name) + res = self.client.embeddings.create(input=[truncate(text, 8000)], model=self.model_name) return np.array(res.data[0].embedding), self.total_token_count(res) except Exception as _e: log_exception(_e, res) diff --git a/api/app/core/workflow/nodes/http_request/config.py b/api/app/core/workflow/nodes/http_request/config.py index 3473f666..72474436 100644 --- a/api/app/core/workflow/nodes/http_request/config.py +++ b/api/app/core/workflow/nodes/http_request/config.py @@ -72,7 +72,8 @@ class HttpContentTypeConfig(BaseModel): @classmethod def validate_data(cls, v, info): content_type = info.data.get("content_type") - if content_type == HttpContentType.FROM_DATA and not isinstance(v, list): + if content_type == HttpContentType.FROM_DATA and ( + not isinstance(v, list) or not all(isinstance(item, HttpFormData) for item in v)): raise ValueError("When content_type is 'form-data', data must be a list of HttpFormData") elif content_type in [HttpContentType.JSON] and not isinstance(v, str): raise ValueError("When content_type is JSON, data must be of type str") From 4f0e5d08662c46c7e50862b2dc8b792bfd4f0d0f Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Tue, 14 Apr 2026 16:24:20 +0800 Subject: [PATCH 044/113] ci(wechat-notify): add Sourcery summary extraction with Qwen fallback - Extract Sourcery AI summary from PR body as primary source - Add fallback to Qwen AI summarization when Sourcery summary unavailable - Refactor notification payload to conditionally use Sourcery or Qwen summary - Update step conditions to skip Qwen processing when Sourcery summary found - Improve code formatting and indentation consistency in Python scripts - Reduce redundant file I/O by writing directly to GITHUB_OUTPUT --- .github/workflows/release-notify-wechat.yml | 88 +++++++++++++++------ 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index d8f4e5aa..bc67518b 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -35,20 +35,52 @@ jobs: echo "ok=false" >> $GITHUB_OUTPUT fi - # 3️⃣ 获取 commits - - name: Get Commits + # 3️⃣ 尝试从 PR body 提取 Sourcery 摘要 + - name: Extract Sourcery Summary if: steps.check.outputs.ok == 'true' - id: commits + id: sourcery + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + python3 << 'PYEOF' + import os, re + + body = os.environ.get("PR_BODY", "") or "" + match = re.search( + r"## Summary by Sourcery\s*\n(.*?)(?=\n## |\Z)", + body, + re.DOTALL + ) + + if match: + summary = match.group(1).strip() + found = "true" + else: + summary = "" + found = "false" + + with open("sourcery_summary.txt", "w", encoding="utf-8") as f: + f.write(summary) + + with open(os.environ["GITHUB_OUTPUT"], "a") as gh: + gh.write(f"found={found}\n") + gh.write("summary< commits.txt - # 4️⃣ 阿里 AI 总结(通义千问) - - name: AI Summary (Qwen) - if: steps.check.outputs.ok == 'true' - id: ai + - name: AI Summary (Qwen Fallback) + if: steps.check.outputs.ok == 'true' && steps.sourcery.outputs.found == 'false' + id: qwen env: DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | @@ -56,34 +88,30 @@ jobs: import json, os, urllib.request with open("commits.txt", "r") as f: - commits = f.read().strip() + commits = f.read().strip() prompt = "请用中文总结以下代码提交,输出3-5条要点,面向测试人员。直接输出编号列表,不要输出标题或前言:\n" + commits payload = {"model": "qwen-plus", "input": {"prompt": prompt}} data = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( - "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation", - data=data, - headers={ - "Authorization": "Bearer " + os.environ["DASHSCOPE_API_KEY"], - "Content-Type": "application/json" - } + "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation", + data=data, + headers={ + "Authorization": "Bearer " + os.environ["DASHSCOPE_API_KEY"], + "Content-Type": "application/json" + } ) resp = urllib.request.urlopen(req) result = json.loads(resp.read().decode()) summary = result.get("output", {}).get("text", "AI 摘要生成失败") - print(summary) - with open("ai_summary.txt", "w", encoding="utf-8") as f: - f.write(summary) + with open(os.environ["GITHUB_OUTPUT"], "a") as gh: + gh.write("summary<> $GITHUB_OUTPUT - echo "$SUMMARY" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - # 5️⃣ 企业微信通知(Markdown) - name: Notify WeChat if: steps.check.outputs.ok == 'true' @@ -93,17 +121,27 @@ jobs: AUTHOR: ${{ github.event.pull_request.user.login }} PR_TITLE: ${{ github.event.pull_request.title }} PR_URL: ${{ github.event.pull_request.html_url }} - AI_SUMMARY: ${{ steps.ai.outputs.summary }} + SOURCERY_FOUND: ${{ steps.sourcery.outputs.found }} + SOURCERY_SUMMARY: ${{ steps.sourcery.outputs.summary }} + QWEN_SUMMARY: ${{ steps.qwen.outputs.summary }} run: | python3 << 'PYEOF' import json, os, urllib.request + + if os.environ.get("SOURCERY_FOUND") == "true": + label = "Summary by Sourcery" + summary = os.environ.get("SOURCERY_SUMMARY", "") + else: + label = "AI变更摘要" + summary = os.environ.get("QWEN_SUMMARY", "AI 摘要生成失败") + content = ( "## 🚀 Release 发布通知\n" "> 📦 **分支**: " + os.environ["BRANCH"] + "\n" "> 👤 **提交人**: " + os.environ["AUTHOR"] + "\n" "> 📝 **标题**: " + os.environ["PR_TITLE"] + "\n\n" - "### 🧠 AI变更摘要\n" + - os.environ["AI_SUMMARY"] + "\n\n" + "### 🧠 " + label + "\n" + + summary + "\n\n" "---\n" "🔗 [查看PR详情](" + os.environ["PR_URL"] + ")" ) From 3c2a78a449c77892c6cda50752ce758c6431474d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 16:35:19 +0800 Subject: [PATCH 045/113] fix(web): Hide error message when workflow node error message equals empty string --- .../views/Workflow/components/Chat/Runtime.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/web/src/views/Workflow/components/Chat/Runtime.tsx b/web/src/views/Workflow/components/Chat/Runtime.tsx index 68bdc452..4a5be793 100644 --- a/web/src/views/Workflow/components/Chat/Runtime.tsx +++ b/web/src/views/Workflow/components/Chat/Runtime.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-24 17:57:08 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 14:05:50 + * @Last Modified time: 2026-04-14 16:33:33 */ /* * Runtime Component @@ -161,8 +161,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({ children: ( {/* Display error message for failed nodes */} - - {item.error && + {vo.content?.error && vo.content?.error !== '' && @@ -219,11 +218,11 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
} - /** Copy value to clipboard and show success message */ - const handleCopy = (value: string) => { - copy(value) - message.success(t('common.copySuccess')) - } + /** Copy value to clipboard and show success message */ + const handleCopy = (value: string) => { + copy(value) + message.success(t('common.copySuccess')) + } return (
= ({
) :
- {item.error && + {item.error && item.error !== '' && } {renderChild(item.subContent)} From 1294aabbcc6260c5b3fe1a2a253371c75e8aa43b Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 16:38:59 +0800 Subject: [PATCH 046/113] feat(web): update document title --- web/src/App.tsx | 55 ++++++++++++++++++++-- web/src/hooks/useBreadcrumbManager.ts | 10 ++-- web/src/routes/routes.json | 1 - web/src/store/menu.json | 15 +++--- web/src/views/ApplicationConfig/index.tsx | 11 ++++- web/src/views/MemoryManagement/index.tsx | 7 +-- web/src/views/Ontology/pages/Detail.tsx | 6 ++- web/src/views/Skills/pages/SkillConfig.tsx | 3 +- web/src/views/UserMemoryDetail/Neo4j.tsx | 17 ++++--- web/src/views/UserMemoryDetail/Rag.tsx | 6 ++- 10 files changed, 101 insertions(+), 30 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index a10f9409..1af38372 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,7 +16,7 @@ import { ConfigProvider, App as AntdApp } from 'antd'; -import { useTranslation } from 'react-i18next'; +import i18n from 'i18next'; import { lightTheme } from './styles/antdThemeConfig.ts' import router from './routes'; @@ -29,11 +29,58 @@ import 'dayjs/plugin/utc' import { cookieUtils } from './utils/request'; import { useUser } from '@/store/user'; +import menuJson from '@/store/menu.json'; + +type MenuEntry = { path: string; i18nKey: string }; + +function flattenMenuEntries(list: any[]): MenuEntry[] { + const result: MenuEntry[] = []; + for (const item of list) { + if (item.path && item.i18nKey && item.type !== 'group') result.push({ path: item.path, i18nKey: item.i18nKey }); + if (item.subs?.length) result.push(...flattenMenuEntries(item.subs)); + } + return result; +} + +const menuEntries: MenuEntry[] = flattenMenuEntries([...menuJson.manage, ...menuJson.space]); + +function pathMatches(pattern: string, path: string): boolean { + if (pattern === path) return true; + if (pattern.includes(':')) { + return new RegExp('^' + pattern.replace(/:[\w-]+/g, '[^/]+') + '$').test(path); + } + return false; +} + +function getPageTitle(pathname: string): string { + const appName = i18n.t('memoryBear'); + const entry = menuEntries.find(e => pathMatches(e.path, pathname)); + if (!entry) return appName; + return `${i18n.t(entry.i18nKey)} - ${appName}`; +} + +const SKIP_TITLE_PATTERNS = [ + '/user-memory/detail/:id/:type', + '/forgetting-engine/:id', + '/memory-extraction-engine/:id', + '/emotion-engine/:id', + '/reflection-engine/:id', +]; + + + function App() { - const { t } = useTranslation(); const { locale, language, timeZone } = useI18n() const { checkJump } = useUser(); + useEffect(() => { + const unsubscribe = router.subscribe(({ location }) => { + if (SKIP_TITLE_PATTERNS.some(p => pathMatches(p, location.pathname))) return; + document.title = getPageTitle(location.pathname); + }); + return () => unsubscribe(); + }, []) + useEffect(() => { const authToken = cookieUtils.get('authToken') if (!authToken && !window.location.hash.includes('#/login') && !window.location.hash.includes('#/conversation/') && !window.location.hash.includes('#/jump') && !window.location.hash.includes('#/invite-register')) { @@ -44,7 +91,9 @@ function App() { }, []) useEffect(() => { - document.title = t('memoryBear') + if (!SKIP_TITLE_PATTERNS.some(p => pathMatches(p, router.state.location.pathname))) { + document.title = getPageTitle(router.state.location.pathname) + } dayjs.locale(language) localStorage.setItem('language', language) }, [language]) diff --git a/web/src/hooks/useBreadcrumbManager.ts b/web/src/hooks/useBreadcrumbManager.ts index 161fbb65..1b7cf4b2 100644 --- a/web/src/hooks/useBreadcrumbManager.ts +++ b/web/src/hooks/useBreadcrumbManager.ts @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-02 16:24:44 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-02 16:24:44 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-04-14 15:52:57 */ /** * useBreadcrumbManager Hook @@ -18,6 +18,7 @@ import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next' import { useMenu } from '@/store/menu'; import type { MenuItem } from '@/store/menu'; @@ -53,6 +54,7 @@ export interface BreadcrumbOptions { export const useBreadcrumbManager = (options?: BreadcrumbOptions) => { const { allBreadcrumbs, setCustomBreadcrumbs } = useMenu(); const navigate = useNavigate(); + const { t } = useTranslation() /** Update breadcrumbs based on current path and type */ const updateBreadcrumbs = useCallback((breadcrumbPath: BreadcrumbPath) => { @@ -336,8 +338,8 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => { /** Use different keys based on breadcrumb type to implement independent breadcrumb paths */ const breadcrumbKey = breadcrumbType === 'list' ? 'space' : 'space-detail'; - - + const lastMenu = customBreadcrumbs[customBreadcrumbs.length - 1] + document.title = `${lastMenu.i18nKey ? t(lastMenu.i18nKey) : lastMenu.label} - ${t('memoryBear') }`; setCustomBreadcrumbs(customBreadcrumbs, breadcrumbKey); }, [setCustomBreadcrumbs, navigate, options?.breadcrumbType, options?.onKnowledgeBaseMenuClick, options?.onKnowledgeBaseFolderClick]); diff --git a/web/src/routes/routes.json b/web/src/routes/routes.json index 5ff1f90c..f6dc631d 100644 --- a/web/src/routes/routes.json +++ b/web/src/routes/routes.json @@ -48,7 +48,6 @@ { "path": "/application/config/:id", "element": "ApplicationConfig" }, { "path": "/application/config/:id/:source", "element": "ApplicationConfig" }, { "path": "/user-memory/neo4j/:id", "element": "Neo4jUserMemoryDetail" }, - { "path": "/statement/:id", "element": "StatementDetail" }, { "path": "/user-memory/detail/:id/:type", "element": "MemoryNodeDetail" }, { "path": "/ontology/:id", "element": "OntologyDetail" } ] diff --git a/web/src/store/menu.json b/web/src/store/menu.json index 8d30dcc4..ec80a384 100644 --- a/web/src/store/menu.json +++ b/web/src/store/menu.json @@ -6,7 +6,7 @@ "code": "workbench", "label": "workbench", "i18nKey": "menu.workbench", - "path": "/", + "path": null, "enable": true, "display": true, "level": 1, @@ -174,7 +174,7 @@ "code": "workbench", "label": "workbench", "i18nKey": "menu.workbench", - "path": "/", + "path": null, "enable": true, "display": true, "level": 1, @@ -425,15 +425,14 @@ { "id": 2211, "parent": 221, - "code": "statementDetail", + "code": "userMemoryDetail", "label": "记忆详情", - "i18nKey": "menu.statementDetail", - "path": "/statement/:id", + "i18nKey": "menu.userMemoryDetail", + "path": "/user-memory/detail/:id/:type", "enable": true, "display": false, - "level": 4, - "sort": 0, - "subs": null + "level": 3, + "sort": 0 } ] }, diff --git a/web/src/views/ApplicationConfig/index.tsx b/web/src/views/ApplicationConfig/index.tsx index a9e1df5d..fa6f9939 100644 --- a/web/src/views/ApplicationConfig/index.tsx +++ b/web/src/views/ApplicationConfig/index.tsx @@ -2,11 +2,12 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:29:37 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 15:37:18 + * @Last Modified time: 2026-04-14 10:01:05 */ import React, { useEffect, useState, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { Flex } from 'antd' +import { useTranslation } from 'react-i18next' import ConfigHeader from './components/ConfigHeader' import type { AgentRef, ClusterRef, WorkflowRef, Config } from './types' @@ -30,6 +31,7 @@ import Logs from './Logs'; const ApplicationConfig: React.FC = () => { // Hooks const { id, source } = useParams(); + const { t } = useTranslation() // Refs for different application types const agentRef = useRef(null) @@ -95,6 +97,13 @@ const ApplicationConfig: React.FC = () => { getApplicationInfo() }, [id]) + useEffect(() => { + if (application?.name) { + const appName = t('memoryBear'); + document.title = `${application.name} - ${appName}`; + } + }, [application?.name]) + /** * Fetch application information */ diff --git a/web/src/views/MemoryManagement/index.tsx b/web/src/views/MemoryManagement/index.tsx index 1fb945b0..5bbbb8bd 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-26 14:56:00 + * @Last Modified time: 2026-04-14 16:17:29 */ /** * Memory Management Page @@ -74,7 +74,8 @@ const MemoryManagement: React.FC = () => { }; /** Navigate to engine configuration page */ - const handleClick = (id: number, type: string) => { + const handleClick = (id: number, type: string, config_name: string) => { + document.title = `${config_name} - ${t('memoryBear')}`; switch (type) { case 'memoryExtractionEngine': navigate(`/memory-extraction-engine/${id}`) @@ -130,7 +131,7 @@ const MemoryManagement: React.FC = () => { align="center" justify="space-between" className="rb:cursor-pointer rb:bg-[#F6F6F6] rb:h-8 rb:rounded-lg rb:font-medium rb:leading-5 rb:pl-2! rb:pr-1! rb:hover:shadow-[0px_2px_8px_0px_rgba(23,23,25,0.16)]" - onClick={() => handleClick(item.config_id, key)} + onClick={() => handleClick(item.config_id, key, item.config_name)} > {t(`memory.${key}`)}
{ getData() }, [id, query]) + useEffect(() => { + document.title = `${data.scene_name} - ${t('memoryBear')}`; + }, [data.scene_name]) + /** * Fetch ontology class list data */ diff --git a/web/src/views/Skills/pages/SkillConfig.tsx b/web/src/views/Skills/pages/SkillConfig.tsx index 84c82378..91cd710a 100644 --- a/web/src/views/Skills/pages/SkillConfig.tsx +++ b/web/src/views/Skills/pages/SkillConfig.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-05 10:44:08 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-05 10:56:28 + * @Last Modified time: 2026-04-14 16:27:08 */ import { type FC, useEffect, useRef, useState } from "react"; import { useTranslation } from 'react-i18next'; @@ -71,6 +71,7 @@ const SkillConfig: FC = () => { .then(res => { form.setFieldsValue(res as SkillFormData) setData(res as SkillFormData) + document.title = `${(res as SkillFormData).name} - ${t('memoryBear')}`; }) .finally(() => { setLoading(false) diff --git a/web/src/views/UserMemoryDetail/Neo4j.tsx b/web/src/views/UserMemoryDetail/Neo4j.tsx index 51be7c8d..67ddc065 100644 --- a/web/src/views/UserMemoryDetail/Neo4j.tsx +++ b/web/src/views/UserMemoryDetail/Neo4j.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:26 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 18:59:53 + * @Last Modified time: 2026-04-14 16:38:21 */ /** * Neo4j User Memory Detail View @@ -22,7 +22,7 @@ import InterestDistribution from './components/InterestDistribution' import NodeStatistics from './components/NodeStatistics' import RelationshipNetwork from './components/RelationshipNetwork' import MemoryInsight from './components/MemoryInsight' -import type { EndUserProfileRef, MemoryInsightRef, AboutMeRef } from './types' +import type { EndUserProfileRef, MemoryInsightRef, AboutMeRef, EndUser } from './types' import { analyticsRefresh, } from '@/api/memory' @@ -39,8 +39,11 @@ const Neo4j: FC = () => { const [selectedKey, setSelectedKey] = useState(null) /** Update displayed name */ - const handleNameUpdate = (data: { other_name?: string; id: string }) => { - setName(data.other_name && data.other_name !== '' ? data.other_name : data.id) + const handleNameUpdate = (data?: EndUser) => { + if (!data) return + let name = data.other_name && data.other_name !== '' ? data.other_name : data.id || data.end_user_id + setName(name) + document.title = `${name} - ${t('memoryBear')}`; } /** Navigate back */ @@ -78,7 +81,7 @@ const Neo4j: FC = () => { - {
- {
- + diff --git a/web/src/views/UserMemoryDetail/Rag.tsx b/web/src/views/UserMemoryDetail/Rag.tsx index ff9069c7..a1da95d4 100644 --- a/web/src/views/UserMemoryDetail/Rag.tsx +++ b/web/src/views/UserMemoryDetail/Rag.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:11 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-31 15:29:45 + * @Last Modified time: 2026-04-14 15:56:15 */ /** * RAG User Memory Detail View @@ -97,6 +97,10 @@ const Rag: FC = () => { } const name = loading.detail ? '' : data?.name && data?.name !== '' ? data.name : id + useEffect(() => { + document.title = `${name} - ${t('memoryBear')}`; + }, [name]) + const [refreshLoading, setRefreshLoading] = useState(false) const handleRefresh = () => { if (refreshLoading || !id) return From 5e6490213d6b6b72624f6a986e3c7012d2d0b7fc Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 17:03:22 +0800 Subject: [PATCH 047/113] fix(web): document title support i18n --- web/src/hooks/useBreadcrumbManager.ts | 6 ++++-- web/src/views/ApplicationConfig/index.tsx | 6 ++++-- web/src/views/EmotionEngine/index.tsx | 8 +++++++- web/src/views/ForgettingEngine/index.tsx | 9 ++++++++- .../views/MemoryExtractionEngine/components/Result.tsx | 1 + web/src/views/MemoryExtractionEngine/index.tsx | 8 +++++++- web/src/views/Ontology/pages/Detail.tsx | 6 ++++-- web/src/views/SelfReflectionEngine/index.tsx | 7 ++++++- web/src/views/Skills/pages/SkillConfig.tsx | 10 ++++++++-- web/src/views/UserMemoryDetail/Neo4j.tsx | 10 +++++++--- web/src/views/UserMemoryDetail/Rag.tsx | 6 ++++-- 11 files changed, 60 insertions(+), 17 deletions(-) diff --git a/web/src/hooks/useBreadcrumbManager.ts b/web/src/hooks/useBreadcrumbManager.ts index 1b7cf4b2..e2567cfd 100644 --- a/web/src/hooks/useBreadcrumbManager.ts +++ b/web/src/hooks/useBreadcrumbManager.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 16:24:44 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-14 15:52:57 + * @Last Modified time: 2026-04-14 16:52:43 */ /** * useBreadcrumbManager Hook @@ -21,6 +21,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next' import { useMenu } from '@/store/menu'; import type { MenuItem } from '@/store/menu'; +import { useI18n } from '@/store/locale' /** Breadcrumb item interface */ export interface BreadcrumbItem { @@ -55,6 +56,7 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => { const { allBreadcrumbs, setCustomBreadcrumbs } = useMenu(); const navigate = useNavigate(); const { t } = useTranslation() + const { language } = useI18n() /** Update breadcrumbs based on current path and type */ const updateBreadcrumbs = useCallback((breadcrumbPath: BreadcrumbPath) => { @@ -341,7 +343,7 @@ export const useBreadcrumbManager = (options?: BreadcrumbOptions) => { const lastMenu = customBreadcrumbs[customBreadcrumbs.length - 1] document.title = `${lastMenu.i18nKey ? t(lastMenu.i18nKey) : lastMenu.label} - ${t('memoryBear') }`; setCustomBreadcrumbs(customBreadcrumbs, breadcrumbKey); - }, [setCustomBreadcrumbs, navigate, options?.breadcrumbType, options?.onKnowledgeBaseMenuClick, options?.onKnowledgeBaseFolderClick]); + }, [setCustomBreadcrumbs, navigate, options?.breadcrumbType, options?.onKnowledgeBaseMenuClick, options?.onKnowledgeBaseFolderClick, language]); return { updateBreadcrumbs, diff --git a/web/src/views/ApplicationConfig/index.tsx b/web/src/views/ApplicationConfig/index.tsx index fa6f9939..5b9bcb85 100644 --- a/web/src/views/ApplicationConfig/index.tsx +++ b/web/src/views/ApplicationConfig/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:29:37 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-14 10:01:05 + * @Last Modified time: 2026-04-14 16:53:27 */ import React, { useEffect, useState, useRef } from 'react'; import { useParams } from 'react-router-dom'; @@ -22,6 +22,7 @@ import Statistics from './Statistics' import TestChat from './TestChat' import type { WorkflowConfig } from '@/views/Workflow/types'; import Logs from './Logs'; +import { useI18n } from '@/store/locale' /** * Application configuration page component @@ -32,6 +33,7 @@ const ApplicationConfig: React.FC = () => { // Hooks const { id, source } = useParams(); const { t } = useTranslation() + const { language } = useI18n() // Refs for different application types const agentRef = useRef(null) @@ -102,7 +104,7 @@ const ApplicationConfig: React.FC = () => { const appName = t('memoryBear'); document.title = `${application.name} - ${appName}`; } - }, [application?.name]) + }, [application?.name, language]) /** * Fetch application information diff --git a/web/src/views/EmotionEngine/index.tsx b/web/src/views/EmotionEngine/index.tsx index 8c0188da..28933c32 100644 --- a/web/src/views/EmotionEngine/index.tsx +++ b/web/src/views/EmotionEngine/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:56:54 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 15:43:29 + * @Last Modified time: 2026-04-14 16:59:16 */ /** * Emotion Engine Configuration Page @@ -25,6 +25,7 @@ import DescWrapper from '@/components/FormItem/DescWrapper' import RbSlider from '@/components/RbSlider'; import RbAlert from '@/components/RbAlert'; import ModelSelect from '@/components/ModelSelect'; +import { useI18n } from '@/store/locale' /** * Configuration field definitions @@ -69,9 +70,14 @@ const EmotionEngine: React.FC = () => { const [form] = Form.useForm(); const { message: messageApi } = App.useApp(); const [loading, setLoading] = useState(false) + const { language } = useI18n() const values = Form.useWatch([], form); + useEffect(() => { + document.title = [document.title.split(' - ')[0], t('memoryBear')].join(' - ') + }, [language]) + useEffect(() => { getConfigData() }, [id]) diff --git a/web/src/views/ForgettingEngine/index.tsx b/web/src/views/ForgettingEngine/index.tsx index 0b15867e..bcd7dbc1 100644 --- a/web/src/views/ForgettingEngine/index.tsx +++ b/web/src/views/ForgettingEngine/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:00:12 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 15:47:37 + * @Last Modified time: 2026-04-14 16:54:38 */ /** * Forgetting Engine Configuration Page @@ -22,6 +22,7 @@ import type { ConfigForm } from './types' import SwitchFormItem from '@/components/FormItem/SwitchFormItem' import RbSlider from '@/components/RbSlider'; import DescWrapper from '@/components/FormItem/DescWrapper' +import { useI18n } from '@/store/locale' /** * Configuration field definitions @@ -109,9 +110,14 @@ const ForgettingEngine: React.FC = () => { const [form] = Form.useForm(); const { message: messageApi } = App.useApp(); const [loading, setLoading] = useState(false) + const { language } = useI18n() const values = Form.useWatch([], form); + useEffect(() => { + document.title = [document.title.split(' - ')[0], t('memoryBear')].join(' - ') + }, [language]) + useEffect(() => { getConfigData() }, []) @@ -182,6 +188,7 @@ const ForgettingEngine: React.FC = () => { if (config.type === 'button') { return ( {t(`forgettingEngine.type`)}: {config.type}} diff --git a/web/src/views/MemoryExtractionEngine/components/Result.tsx b/web/src/views/MemoryExtractionEngine/components/Result.tsx index 4d07aae9..2fa8788f 100644 --- a/web/src/views/MemoryExtractionEngine/components/Result.tsx +++ b/web/src/views/MemoryExtractionEngine/components/Result.tsx @@ -328,6 +328,7 @@ const Result: FC = ({ loading, handleSave }) => { {['processData', 'finalResult'].map(tab => (
{ const { t } = useTranslation(); const { message } = App.useApp(); const { id } = useParams() + const { language } = useI18n() const [expandedKeys, setExpandedKeys] = useState(keys) const [form] = Form.useForm() const values = Form.useWatch([], form) const [loading, setLoading] = useState(false) const [iterationPeriodDisabled, setIterationPeriodDisabled] = useState(false) + useEffect(() => { + document.title = [document.title.split(' - ')[0], t('memoryBear')].join(' - ') + }, [language]) + useEffect(() => { if (values?.reflexion_range === 'database') { form.setFieldValue('iteration_period', 24) diff --git a/web/src/views/Ontology/pages/Detail.tsx b/web/src/views/Ontology/pages/Detail.tsx index 80ce3a0f..b6ee5953 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-04-14 15:54:05 + * @Last Modified time: 2026-04-14 16:55:26 */ import { type FC, useEffect, useState, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom'; @@ -18,6 +18,7 @@ import SearchInput from '@/components/SearchInput'; import OntologyClassExtractModal from '../components/OntologyClassExtractModal' import BodyWrapper from '@/components/Empty/BodyWrapper' import Tag from '@/components/Tag' +import { useI18n } from '@/store/locale' /** * Ontology detail page component @@ -29,6 +30,7 @@ const Detail: FC = () => { const navigate = useNavigate() const { id } = useParams() const { modal, message } = App.useApp() + const { language } = useI18n() // Refs const ontologyClassModalRef = useRef(null) @@ -48,7 +50,7 @@ const Detail: FC = () => { useEffect(() => { document.title = `${data.scene_name} - ${t('memoryBear')}`; - }, [data.scene_name]) + }, [data.scene_name, language]) /** * Fetch ontology class list data diff --git a/web/src/views/SelfReflectionEngine/index.tsx b/web/src/views/SelfReflectionEngine/index.tsx index bd1470c1..ef5e412c 100644 --- a/web/src/views/SelfReflectionEngine/index.tsx +++ b/web/src/views/SelfReflectionEngine/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:46:47 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 18:57:08 + * @Last Modified time: 2026-04-14 16:59:56 */ /** * Self Reflection Engine Configuration Page @@ -99,6 +99,10 @@ const SelfReflectionEngine: React.FC = () => { const values = Form.useWatch([], form); + useEffect(() => { + document.title = [document.title.split(' - ')[0], t('memoryBear')].join(' - ') + }, [language]) + useEffect(() => { getConfigData() }, [id]) @@ -242,6 +246,7 @@ const SelfReflectionEngine: React.FC = () => { return ( diff --git a/web/src/views/Skills/pages/SkillConfig.tsx b/web/src/views/Skills/pages/SkillConfig.tsx index 91cd710a..440356fd 100644 --- a/web/src/views/Skills/pages/SkillConfig.tsx +++ b/web/src/views/Skills/pages/SkillConfig.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-05 10:44:08 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-14 16:27:08 + * @Last Modified time: 2026-04-14 16:57:52 */ import { type FC, useEffect, useRef, useState } from "react"; import { useTranslation } from 'react-i18next'; @@ -17,6 +17,7 @@ import type { SkillFormData } from '../types' import { getSkillDetail, createSkill, updateSkill } from '@/api/skill' import { stringRegExp } from '@/utils/validator'; import PageHeader from '@/components/Layout/PageHeader' +import { useI18n } from '@/store/locale' /** * Skill Configuration Page Component @@ -43,6 +44,7 @@ const SkillConfig: FC = () => { const [loading, setLoading] = useState(false) const [form] = Form.useForm(); const [data, setData] = useState(null) + const { language } = useI18n() /** * Effect: Load skill data if editing existing skill @@ -71,12 +73,16 @@ const SkillConfig: FC = () => { .then(res => { form.setFieldsValue(res as SkillFormData) setData(res as SkillFormData) - document.title = `${(res as SkillFormData).name} - ${t('memoryBear')}`; }) .finally(() => { setLoading(false) }) } + + useEffect(() => { + if (!data) return; + document.title = `${data?.name} - ${t('memoryBear')}`; + }, [language, data?.name]) const aiPromptModalRef = useRef(null) diff --git a/web/src/views/UserMemoryDetail/Neo4j.tsx b/web/src/views/UserMemoryDetail/Neo4j.tsx index 67ddc065..4de310b3 100644 --- a/web/src/views/UserMemoryDetail/Neo4j.tsx +++ b/web/src/views/UserMemoryDetail/Neo4j.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:26 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-14 16:38:21 + * @Last Modified time: 2026-04-14 16:57:59 */ /** * Neo4j User Memory Detail View @@ -10,7 +10,7 @@ * Shows profile, interests, node statistics, relationships, and insights */ -import { type FC, useRef, useState, type MouseEvent } from 'react' +import { type FC, useRef, useState, type MouseEvent, useEffect } from 'react' import clsx from 'clsx' import { useParams, useNavigate } from 'react-router-dom' import { Flex, Popover } from 'antd' @@ -26,6 +26,7 @@ import type { EndUserProfileRef, MemoryInsightRef, AboutMeRef, EndUser } from '. import { analyticsRefresh, } from '@/api/memory' +import { useI18n } from '@/store/locale' const Neo4j: FC = () => { const { id } = useParams() @@ -33,6 +34,7 @@ const Neo4j: FC = () => { const navigate = useNavigate(); const [loading, setLoading] = useState(false) const [name, setName] = useState('') + const { language } = useI18n() const ref = useRef(null) const memoryInsightRef = useRef(null) const aboutMeRef = useRef(null) @@ -43,8 +45,10 @@ const Neo4j: FC = () => { if (!data) return let name = data.other_name && data.other_name !== '' ? data.other_name : data.id || data.end_user_id setName(name) - document.title = `${name} - ${t('memoryBear')}`; } + useEffect(() => { + document.title = `${name} - ${t('memoryBear')}`; + }, [name, language]) /** Navigate back */ const goBack = () => { diff --git a/web/src/views/UserMemoryDetail/Rag.tsx b/web/src/views/UserMemoryDetail/Rag.tsx index a1da95d4..07fb0af6 100644 --- a/web/src/views/UserMemoryDetail/Rag.tsx +++ b/web/src/views/UserMemoryDetail/Rag.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:11 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-14 15:56:15 + * @Last Modified time: 2026-04-14 16:56:36 */ /** * RAG User Memory Detail View @@ -26,6 +26,7 @@ import { } from '@/api/memory' import Empty from '@/components/Empty' import ConversationMemory from './components/ConversationMemory' +import { useI18n } from '@/store/locale' /** * Title component props @@ -45,6 +46,7 @@ const Title: FC = ({ title, iconClassName }) => ( const Rag: FC = () => { const { t } = useTranslation() const { id } = useParams() + const { language } = useI18n() const [data, setData] = useState(null) const [summary, setSummary] = useState('') const [loading, setLoading] = useState>({ @@ -99,7 +101,7 @@ const Rag: FC = () => { useEffect(() => { document.title = `${name} - ${t('memoryBear')}`; - }, [name]) + }, [name, language]) const [refreshLoading, setRefreshLoading] = useState(false) const handleRefresh = () => { From 75e95bab01239a465cb4cb123e07d9debda73b68 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 14 Apr 2026 17:10:52 +0800 Subject: [PATCH 048/113] refactor(rag): simplify Excel parsing logic and remove redundant chunk_token_num assignment --- api/app/core/rag/app/naive.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/app/core/rag/app/naive.py b/api/app/core/rag/app/naive.py index 93b96843..312216dd 100644 --- a/api/app/core/rag/app/naive.py +++ b/api/app/core/rag/app/naive.py @@ -672,10 +672,15 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, excel_parser = ExcelParser() if parser_config.get("html4excel") and parser_config.get("html4excel").lower() == "true": sections = [(_, "") for _ in excel_parser.html(binary, 12) if _] - parser_config["chunk_token_num"] = 0 else: sections = [(_, "") for _ in excel_parser(binary) if _] - parser_config["chunk_token_num"] = 0 + callback(0.8, "Finish parsing.") + # Excel 每行直接作为一个 chunk,不经过 naive_merge 避免被 delimiter 拆分 + chunks = [s for s, _ in sections] + res.extend(tokenize_chunks(chunks, doc, is_english, None)) + res.extend(embed_res) + res.extend(url_res) + return res elif re.search(r"\.(txt|py|js|java|c|cpp|h|php|go|ts|sh|cs|kt|sql)$", filename, re.IGNORECASE): callback(0.1, "Start to parse.") From 811193dd756295c9ac89d3d54c3b03dac131c840 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Tue, 14 Apr 2026 17:28:24 +0800 Subject: [PATCH 049/113] fix(memory): make PgSQL the single source of truth for user entity aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip alias merging for user entities during dedup (_merge_attribute and _merge_entities_with_aliases) to prevent dirty data from overwriting PgSQL authoritative aliases - Add PgSQL→Neo4j alias sync after Neo4j write in write_tools to ensure Neo4j user entities always reflect the PgSQL source - Remove deduped_aliases (Neo4j history) from alias sync in extraction_orchestrator, only append newly extracted aliases to PgSQL - Guard Neo4j MERGE cypher to preserve existing aliases for user entities (name IN ['用户','我','User','I']) - Fix emotion_analytics_service query to use ExtractedEntity label and entity_type property --- .../core/memory/agent/utils/write_tools.py | 29 ++++- .../deduplication/deduped_and_disamb.py | 109 +++++------------- .../extraction_orchestrator.py | 44 ++----- api/app/repositories/neo4j/cypher_queries.py | 2 + api/app/services/emotion_analytics_service.py | 4 +- 5 files changed, 71 insertions(+), 117 deletions(-) diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index bae4643e..5e51beba 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -191,15 +191,34 @@ async def write( if success: logger.info("Successfully saved all data to Neo4j") - # 使用 Celery 异步任务触发聚类(不阻塞主流程) if all_entity_nodes: + end_user_id = all_entity_nodes[0].end_user_id + + # Neo4j 写入完成后,用 PgSQL 权威 aliases 覆盖 Neo4j 用户实体 + try: + from app.repositories.end_user_info_repository import EndUserInfoRepository + if end_user_id: + with get_db_context() as db_session: + info = EndUserInfoRepository(db_session).get_by_end_user_id(uuid.UUID(end_user_id)) + pg_aliases = info.aliases if info and info.aliases else [] + if pg_aliases: + await neo4j_connector.execute_query( + """ + MATCH (e:ExtractedEntity) + WHERE e.end_user_id = $end_user_id AND e.name IN ['用户', '我', 'User', 'I'] + SET e.aliases = $aliases + """, + end_user_id=end_user_id, aliases=pg_aliases, + ) + logger.info(f"[AliasSync] Neo4j 用户实体 aliases 已用 PgSQL 权威源覆盖: {pg_aliases}") + except Exception as sync_err: + logger.warning(f"[AliasSync] PgSQL→Neo4j aliases 同步失败(不影响主流程): {sync_err}") + + # 使用 Celery 异步任务触发聚类(不阻塞主流程) try: from app.tasks import run_incremental_clustering - end_user_id = all_entity_nodes[0].end_user_id new_entity_ids = [e.id for e in all_entity_nodes] - - # 异步提交 Celery 任务 task = run_incremental_clustering.apply_async( kwargs={ "end_user_id": end_user_id, @@ -207,7 +226,6 @@ async def write( "llm_model_id": str(memory_config.llm_model_id) if memory_config.llm_model_id else None, "embedding_model_id": str(memory_config.embedding_model_id) if memory_config.embedding_model_id else None, }, - # 设置任务优先级(低优先级,不影响主业务) priority=3, ) logger.info( @@ -215,7 +233,6 @@ async def write( f"task_id={task.id}, end_user_id={end_user_id}, entity_count={len(new_entity_ids)}" ) except Exception as e: - # 聚类任务提交失败不影响主流程 logger.error(f"[Clustering] 提交聚类任务失败(不影响主流程): {e}", exc_info=True) break diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py index 7e0976fe..8f659a27 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py @@ -82,51 +82,35 @@ def _merge_attribute(canonical: ExtractedEntityNode, ent: ExtractedEntityNode): canonical.connect_strength = next(iter(pair)) # 别名合并(去重保序,使用标准化工具) + # 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,去重合并时不修改 try: canonical_name = (getattr(canonical, "name", "") or "").strip() - incoming_name = (getattr(ent, "name", "") or "").strip() - - # 收集所有需要合并的别名 - all_aliases = [] - - # 1. 添加canonical现有的别名 - existing = getattr(canonical, "aliases", []) or [] - all_aliases.extend(existing) - - # 2. 添加incoming实体的名称(如果不同于canonical的名称) - if incoming_name and incoming_name != canonical_name: - all_aliases.append(incoming_name) - - # 3. 添加incoming实体的所有别名 - incoming = getattr(ent, "aliases", []) or [] - all_aliases.extend(incoming) - - # 4. 标准化并去重(优先使用alias_utils工具函数) - try: - from app.core.memory.utils.alias_utils import normalize_aliases - canonical.aliases = normalize_aliases(canonical_name, all_aliases) - except Exception: - # 如果导入失败,使用增强的去重逻辑 - seen_normalized = set() - unique_aliases = [] + if canonical_name.lower() not in _USER_PLACEHOLDER_NAMES: + incoming_name = (getattr(ent, "name", "") or "").strip() - for alias in all_aliases: - if not alias: - continue - - alias_stripped = str(alias).strip() - if not alias_stripped or alias_stripped == canonical_name: - continue - - # 标准化:转小写用于去重判断 - alias_normalized = alias_stripped.lower() - - if alias_normalized not in seen_normalized: - seen_normalized.add(alias_normalized) - unique_aliases.append(alias_stripped) + # 收集所有需要合并的别名 + all_aliases = list(getattr(canonical, "aliases", []) or []) + if incoming_name and incoming_name != canonical_name: + all_aliases.append(incoming_name) + all_aliases.extend(getattr(ent, "aliases", []) or []) - # 排序并赋值 - canonical.aliases = sorted(unique_aliases) + try: + from app.core.memory.utils.alias_utils import normalize_aliases + canonical.aliases = normalize_aliases(canonical_name, all_aliases) + except Exception: + seen_normalized = set() + unique_aliases = [] + for alias in all_aliases: + if not alias: + continue + alias_stripped = str(alias).strip() + if not alias_stripped or alias_stripped == canonical_name: + continue + alias_normalized = alias_stripped.lower() + if alias_normalized not in seen_normalized: + seen_normalized.add(alias_normalized) + unique_aliases.append(alias_stripped) + canonical.aliases = sorted(unique_aliases) except Exception: pass @@ -733,66 +717,37 @@ def fuzzy_match( def _merge_entities_with_aliases(canonical: ExtractedEntityNode, losing: ExtractedEntityNode): - """ 模糊匹配中的实体合并。 + """模糊匹配中的实体合并(别名部分)。 - 合并策略: - 1. 保留canonical的主名称不变 - 2. 将losing的主名称添加为alias(如果不同) - 3. 合并两个实体的所有aliases - 4. 自动去重(case-insensitive)并排序 - - Args: - canonical: 规范实体(保留) - losing: 被合并实体(删除) - - Note: - 使用alias_utils.normalize_aliases进行标准化去重 + 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,跳过合并。 """ - # 获取规范实体的名称 canonical_name = (getattr(canonical, "name", "") or "").strip() + if canonical_name.lower() in _USER_PLACEHOLDER_NAMES: + return + losing_name = (getattr(losing, "name", "") or "").strip() - # 收集所有需要合并的别名 - all_aliases = [] - - # 1. 添加canonical现有的别名 - current_aliases = getattr(canonical, "aliases", []) or [] - all_aliases.extend(current_aliases) - - # 2. 添加losing实体的名称(如果不同于canonical的名称) + all_aliases = list(getattr(canonical, "aliases", []) or []) if losing_name and losing_name != canonical_name: all_aliases.append(losing_name) + all_aliases.extend(getattr(losing, "aliases", []) or []) - # 3. 添加losing实体的所有别名 - losing_aliases = getattr(losing, "aliases", []) or [] - all_aliases.extend(losing_aliases) - - # 4. 标准化并去重(使用标准化后的字符串进行去重) try: from app.core.memory.utils.alias_utils import normalize_aliases canonical.aliases = normalize_aliases(canonical_name, all_aliases) except Exception: - # 如果导入失败,使用增强的去重逻辑 - # 使用标准化后的字符串作为key进行去重 seen_normalized = set() unique_aliases = [] - for alias in all_aliases: if not alias: continue - alias_stripped = str(alias).strip() if not alias_stripped or alias_stripped == canonical_name: continue - - # 标准化:转小写用于去重判断 alias_normalized = alias_stripped.lower() - if alias_normalized not in seen_normalized: seen_normalized.add(alias_normalized) unique_aliases.append(alias_stripped) - - # 排序并赋值 canonical.aliases = sorted(unique_aliases) # ========== 主循环:遍历所有实体对进行模糊匹配 ========== diff --git a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py index 5636dcb5..75fc87d2 100644 --- a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py +++ b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py @@ -1391,18 +1391,18 @@ class ExtractionOrchestrator: """ 将本轮提取的用户别名同步到 end_user 和 end_user_info 表。 - 注意:此方法在 Neo4j 写入之前调用,因此不能依赖 Neo4j 作为别名的权威数据源。 - 改为直接使用内存中去重后的 entity_nodes 的 aliases,与 PgSQL 已有的 aliases 合并。 + PgSQL end_user_info.aliases 是用户别名的唯一权威源。 + 此方法仅将本轮 LLM 从对话中新提取的别名增量追加到 PgSQL, + 不再从 Neo4j 二层去重合并历史别名,避免脏数据反向污染 PgSQL。 策略: - 1. 从内存中的 entity_nodes 提取本轮用户别名(current_aliases) - 2. 从去重后的 entity_nodes 中提取完整别名(含 Neo4j 二层去重合并的历史别名) - 3. 从 PgSQL end_user_info 读取已有的 aliases(db_aliases) - 4. 合并 db_aliases + deduped_aliases + current_aliases,去重保序 - 5. 写回 PgSQL + 1. 从本轮对话原始发言中提取用户别名(current_aliases) + 2. 从 PgSQL end_user_info 读取已有的 aliases(db_aliases) + 3. 合并 db_aliases + current_aliases,去重保序 + 4. 写回 PgSQL Args: - entity_nodes: 去重后的实体节点列表(内存中,含二层去重合并结果) + entity_nodes: 去重后的实体节点列表(内存中) dialog_data_list: 对话数据列表 """ try: @@ -1418,11 +1418,6 @@ class ExtractionOrchestrator: # 1. 提取本轮对话的用户别名(保持 LLM 提取的原始顺序,不排序) current_aliases = self._extract_current_aliases(entity_nodes, dialog_data_list) - # 1.5 从去重后的 entity_nodes 中提取完整别名 - # 二层去重会将 Neo4j 中已有的历史别名合并到 entity_nodes 中, - # 这里提取出来确保 PgSQL 与 Neo4j 的别名保持同步 - deduped_aliases = self._extract_deduped_entity_aliases(entity_nodes) - # 1.6 从 Neo4j 查询已有的 AI 助手别名,作为额外的排除源 # (防止 LLM 未提取出 AI 助手实体时,AI 别名泄漏到用户别名中) neo4j_assistant_aliases = await self._fetch_neo4j_assistant_aliases(end_user_id) @@ -1434,19 +1429,12 @@ class ExtractionOrchestrator: ] if len(current_aliases) < before_count: logger.info(f"通过 Neo4j AI 助手别名排除了 {before_count - len(current_aliases)} 个误归属别名") - # 同样过滤 deduped_aliases - deduped_aliases = [ - a for a in deduped_aliases - if a.strip().lower() not in neo4j_assistant_aliases - ] - if not current_aliases and not deduped_aliases: + if not current_aliases: logger.debug(f"本轮未提取到用户别名,跳过同步: end_user_id={end_user_id}") return logger.info(f"本轮对话提取的 aliases: {current_aliases}") - if deduped_aliases: - logger.info(f"去重后实体的完整 aliases(含历史): {deduped_aliases}") # 2. 同步到数据库 end_user_uuid = uuid.UUID(end_user_id) @@ -1457,21 +1445,15 @@ class ExtractionOrchestrator: logger.warning(f"未找到 end_user_id={end_user_id} 的用户记录") return - # 3. 从 PgSQL 读取已有 aliases 并与本轮合并 + # 3. 从 PgSQL 读取已有 aliases 并与本轮新增合并 info = EndUserInfoRepository(db).get_by_end_user_id(end_user_uuid) db_aliases = (info.aliases if info and info.aliases else []) # 过滤掉占位名称 db_aliases = [a for a in db_aliases if a.strip().lower() not in self.USER_PLACEHOLDER_NAMES] - # 合并:已有 + 去重后完整别名 + 本轮新增,去重保序 + # 合并:PgSQL 已有 + 本轮新增,去重保序(不再合并 Neo4j 历史别名) merged_aliases = list(db_aliases) seen_lower = {a.strip().lower() for a in merged_aliases} - # 先合并去重后实体的完整别名(含 Neo4j 历史别名) - for alias in deduped_aliases: - if alias.strip().lower() not in seen_lower: - merged_aliases.append(alias) - seen_lower.add(alias.strip().lower()) - # 再合并本轮新提取的别名 for alias in current_aliases: if alias.strip().lower() not in seen_lower: merged_aliases.append(alias) @@ -1505,9 +1487,7 @@ class ExtractionOrchestrator: info.aliases = merged_aliases logger.info(f"同步合并后 aliases 到 end_user_info: {merged_aliases}") else: - first_alias = current_aliases[0].strip() if current_aliases else ( - deduped_aliases[0].strip() if deduped_aliases else "" - ) + first_alias = current_aliases[0].strip() if current_aliases else "" # 确保 first_alias 不是占位名称 if first_alias and first_alias.lower() not in self.USER_PLACEHOLDER_NAMES: db.add(EndUserInfo( diff --git a/api/app/repositories/neo4j/cypher_queries.py b/api/app/repositories/neo4j/cypher_queries.py index 4b5273ac..daf04bcb 100644 --- a/api/app/repositories/neo4j/cypher_queries.py +++ b/api/app/repositories/neo4j/cypher_queries.py @@ -93,6 +93,8 @@ SET e.name = CASE WHEN entity.name IS NOT NULL AND entity.name <> '' THEN entity END, e.statement_id = CASE WHEN entity.statement_id IS NOT NULL AND entity.statement_id <> '' THEN entity.statement_id ELSE e.statement_id END, e.aliases = CASE + // 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,知识抽取完全不写入 + WHEN entity.name IN ['用户', '我', 'User', 'I'] THEN e.aliases WHEN entity.aliases IS NOT NULL AND size(entity.aliases) > 0 THEN CASE WHEN e.aliases IS NULL THEN entity.aliases diff --git a/api/app/services/emotion_analytics_service.py b/api/app/services/emotion_analytics_service.py index c226348e..9a215cd6 100644 --- a/api/app/services/emotion_analytics_service.py +++ b/api/app/services/emotion_analytics_service.py @@ -679,9 +679,9 @@ class EmotionAnalyticsService: # 查询用户的实体和标签 query = """ - MATCH (e:Entity) + MATCH (e:ExtractedEntity) WHERE e.end_user_id = $end_user_id - RETURN e.name as name, e.type as type + RETURN e.name as name, e.entity_type as type ORDER BY e.created_at DESC LIMIT 20 """ From 47c242e5135880c3a0e54ed2af2455b42f6323ff Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 17:43:58 +0800 Subject: [PATCH 050/113] fix(web): Compatible with Windows whitespace --- .../components/Properties/HttpRequest/index.tsx | 7 +++---- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 13 +++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx index b032016b..4cf7c150 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-09 18:35:43 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-02 17:17:06 + * @Last Modified time: 2026-04-14 17:36:53 */ import { type FC, useMemo, useRef, useState } from "react"; import { useTranslation } from 'react-i18next' @@ -35,9 +35,8 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an form.setFieldsValue({ auth }) } - const handleChangeBodyContentType = (e: any) => { - const value = e.target.value || e.target.value - form.setFieldValue(['body', 'data'], ['form-data', 'x-www-form-urlencoded'].includes(value) ? [{}] : undefined) + const handleChangeBodyContentType = () => { + form.setFieldValue(['body', 'data'], undefined) } // Handle error handling method change and update node ports accordingly diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 3280562a..535a4eb0 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-04-14 15:05:33 + * @Last Modified time: 2026-04-14 17:43:14 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -1200,9 +1200,6 @@ export const useWorkflowGraph = ({ }) || []; const edges = graphRef.current?.getEdges() || [] - - console.log('config', config) - const params = { ...config, features: featuresRef.current, @@ -1262,6 +1259,14 @@ export const useWorkflowGraph = ({ itemConfig[key][vo.key] = vo.value }) } + } else if (data.type === 'http-request' && key === 'body' && data.config[key] && 'defaultValue' in data.config[key]) { + const value = data.config[key].defaultValue + itemConfig[key] = value + if (value.content_type === 'json' && value.data && value.data !== '') { + itemConfig[key].data = value.data.replace(/\u00a0/g, ' ') + } else { + itemConfig[key].data = value.data + } } else if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') { itemConfig[key] = data.config[key].defaultValue } else if (key === 'knowledge_retrieval' && data.config[key] && 'defaultValue' in data.config[key]) { From 441b21774d8cdfb4aaf19033593a777ee257ff00 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 14 Apr 2026 17:56:30 +0800 Subject: [PATCH 051/113] fix(rag): replace semicolon separators with newlines in Excel parser output --- api/app/core/rag/deepdoc/parser/excel_parser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/app/core/rag/deepdoc/parser/excel_parser.py b/api/app/core/rag/deepdoc/parser/excel_parser.py index d66a21a8..c3999be9 100644 --- a/api/app/core/rag/deepdoc/parser/excel_parser.py +++ b/api/app/core/rag/deepdoc/parser/excel_parser.py @@ -232,14 +232,14 @@ class RAGExcelParser: t = str(ti[i].value) if i < len(ti) else "" t += (":" if t else "") + str(c.value) fields.append(t) - line = "; ".join(fields) + line = "\n".join(fields) if sheetname.lower().find("sheet") < 0: - line += " ——" + sheetname + line += "\n——" + sheetname res.append(line) else: # 只有表头的情况 if header_fields: - line = "; ".join(header_fields) + line = "\n".join(header_fields) if sheetname.lower().find("sheet") < 0: line += " ——" + sheetname res.append(line) From 3e48d620b2853b44d3b386ef50ebfbe1c62379bc Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 17:59:24 +0800 Subject: [PATCH 052/113] feat(web): table support pagesize --- web/src/components/Table/index.tsx | 6 +++--- web/src/views/Index/index.tsx | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/components/Table/index.tsx b/web/src/components/Table/index.tsx index bb79b4bc..d6cb3c68 100644 --- a/web/src/components/Table/index.tsx +++ b/web/src/components/Table/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:29:46 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 14:52:23 + * @Last Modified time: 2026-04-14 17:55:15 */ /** * RbTable Component @@ -27,7 +27,7 @@ import { useTranslation } from 'react-i18next'; import { request } from '@/utils/request'; import Empty from '@/components/Empty'; -interface TablePaginationConfig { pagesize: number; page: number; } +interface TablePaginationConfig { pagesize?: number; page?: number; } /** Props interface for Table component */ interface TableComponentProps, Q = Record> extends Omit, 'pagination'> { @@ -102,7 +102,7 @@ const RbTable = forwardRef(, Q = Record { rowKey="id" bordered={false} scrollY="100%" + pagination={{pagesize: 10}} />
From 49e0801d1582c08bc7d746edd20d8314abc8bf29 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Tue, 14 Apr 2026 18:06:56 +0800 Subject: [PATCH 053/113] refactor(memory): unify user placeholder names and harden alias sync logic - Replace hardcoded user placeholder name lists in write_tools and user_memory_service with shared _USER_PLACEHOLDER_NAMES constant - Filter user placeholder names during alias merging in _merge_attribute to prevent cross-role alias contamination on non-user entities - Use toLower() in Cypher query for case-insensitive name matching - Change PgSQL->Neo4j alias sync condition from 'if pg_aliases' to 'if info is not None' so empty aliases correctly clear stale data --- api/app/core/memory/agent/utils/write_tools.py | 8 ++++++-- .../deduplication/deduped_and_disamb.py | 9 ++++++--- api/app/services/user_memory_service.py | 3 ++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index 5e51beba..3b0ea1ee 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -14,6 +14,7 @@ from dotenv import load_dotenv from app.core.logging_config import get_agent_logger from app.core.memory.agent.utils.get_dialogs import get_chunked_dialogs +from app.core.memory.storage_services.extraction_engine.deduplication.deduped_and_disamb import _USER_PLACEHOLDER_NAMES from app.core.memory.storage_services.extraction_engine.extraction_orchestrator import ExtractionOrchestrator from app.core.memory.storage_services.extraction_engine.knowledge_extraction.memory_summary import \ memory_summary_generation @@ -201,14 +202,17 @@ async def write( with get_db_context() as db_session: info = EndUserInfoRepository(db_session).get_by_end_user_id(uuid.UUID(end_user_id)) pg_aliases = info.aliases if info and info.aliases else [] - if pg_aliases: + if info is not None: + # 将 Python 侧占位名集合作为参数传入,避免 Cypher 硬编码 + placeholder_names = list(_USER_PLACEHOLDER_NAMES) await neo4j_connector.execute_query( """ MATCH (e:ExtractedEntity) - WHERE e.end_user_id = $end_user_id AND e.name IN ['用户', '我', 'User', 'I'] + WHERE e.end_user_id = $end_user_id AND toLower(e.name) IN $placeholder_names SET e.aliases = $aliases """, end_user_id=end_user_id, aliases=pg_aliases, + placeholder_names=placeholder_names, ) logger.info(f"[AliasSync] Neo4j 用户实体 aliases 已用 PgSQL 权威源覆盖: {pg_aliases}") except Exception as sync_err: diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py index 8f659a27..715f190c 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py @@ -88,11 +88,14 @@ def _merge_attribute(canonical: ExtractedEntityNode, ent: ExtractedEntityNode): if canonical_name.lower() not in _USER_PLACEHOLDER_NAMES: incoming_name = (getattr(ent, "name", "") or "").strip() - # 收集所有需要合并的别名 + # 收集所有需要合并的别名,过滤掉用户占位名避免污染非用户实体 all_aliases = list(getattr(canonical, "aliases", []) or []) - if incoming_name and incoming_name != canonical_name: + if incoming_name and incoming_name != canonical_name and incoming_name.lower() not in _USER_PLACEHOLDER_NAMES: all_aliases.append(incoming_name) - all_aliases.extend(getattr(ent, "aliases", []) or []) + all_aliases.extend( + a for a in (getattr(ent, "aliases", []) or []) + if a and a.strip().lower() not in _USER_PLACEHOLDER_NAMES + ) try: from app.core.memory.utils.alias_utils import normalize_aliases diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index cc18447e..9389ecfa 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.logging_config import get_logger +from app.core.memory.storage_services.extraction_engine.deduplication.deduped_and_disamb import _USER_PLACEHOLDER_NAMES from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.repositories.conversation_repository import ConversationRepository @@ -473,7 +474,7 @@ class UserMemoryService: allowed_fields = {'other_name', 'aliases', 'meta_data'} # 用户占位名称黑名单,不允许作为 other_name 或出现在 aliases 中 - _user_placeholder_names = {'用户', '我', 'User', 'I'} + _user_placeholder_names = _USER_PLACEHOLDER_NAMES # 过滤 other_name:不允许设置为占位名称 if 'other_name' in update_data and update_data['other_name'] and update_data['other_name'].strip() in _user_placeholder_names: From 18be1a9f89dd0f90499dfed3dfeeb4eb26faa07f Mon Sep 17 00:00:00 2001 From: wxy Date: Tue, 14 Apr 2026 18:14:45 +0800 Subject: [PATCH 054/113] feat(tenant): add tenant package query endpoint Add tenant package query functionality. Regular users can access this endpoint to retrieve their tenant's package information. --- api/app/controllers/__init__.py | 4 +- .../tenant_subscription_controller.py | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 api/app/controllers/tenant_subscription_controller.py diff --git a/api/app/controllers/__init__.py b/api/app/controllers/__init__.py index 50e9e0b0..377205c4 100644 --- a/api/app/controllers/__init__.py +++ b/api/app/controllers/__init__.py @@ -47,7 +47,8 @@ from . import ( user_memory_controllers, workspace_controller, ontology_controller, - skill_controller + skill_controller, + tenant_subscription_controller, ) # 创建管理端 API 路由器 @@ -98,5 +99,6 @@ manager_router.include_router(file_storage_controller.router) manager_router.include_router(ontology_controller.router) manager_router.include_router(skill_controller.router) manager_router.include_router(i18n_controller.router) +manager_router.include_router(tenant_subscription_controller.router) __all__ = ["manager_router"] diff --git a/api/app/controllers/tenant_subscription_controller.py b/api/app/controllers/tenant_subscription_controller.py new file mode 100644 index 00000000..2629f7f1 --- /dev/null +++ b/api/app/controllers/tenant_subscription_controller.py @@ -0,0 +1,53 @@ +""" +租户套餐查询接口(普通用户可访问) +""" +from typing import Callable + +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from app.core.logging_config import get_api_logger +from app.core.response_utils import success, fail +from app.db import get_db +from app.dependencies import get_current_user +from app.i18n.dependencies import get_translator +from app.models.user_model import User +from app.schemas.response_schema import ApiResponse + +logger = get_api_logger() + +router = APIRouter(prefix="/tenant", tags=["Tenant"]) + + +@router.get("/subscription", response_model=ApiResponse, summary="获取当前用户所属租户的套餐信息") +async def get_my_tenant_subscription( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), + t: Callable = Depends(get_translator), +): + """ + 获取当前登录用户所属租户的有效套餐订阅信息。 + 包含套餐名称、版本、配额、到期时间等。 + """ + try: + from premium.platform_admin.package_plan_service import TenantSubscriptionService + + if not current_user.tenant: + return JSONResponse(status_code=404, content=fail(code=404, msg="用户未关联租户")) + + tenant_id = current_user.tenant.id + svc = TenantSubscriptionService(db) + sub = svc.get_subscription(tenant_id) + + if not sub: + return success(data=None, msg="暂无有效套餐") + + return success(data=svc.build_response(sub)) + + except ModuleNotFoundError: + # 社区版无 premium 模块,返回空 + return success(data=None, msg="套餐功能未启用") + except Exception as e: + logger.error(f"获取租户套餐信息失败: {e}", exc_info=True) + return JSONResponse(status_code=500, content=fail(code=500, msg="获取套餐信息失败")) From 017efdc3202b424dc48d483ecba3498cd50810ff Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Wed, 15 Apr 2026 11:03:44 +0800 Subject: [PATCH 055/113] fix(prompt-optimizer): support list content type in prompt optimizer --- api/app/services/prompt_optimizer_service.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/app/services/prompt_optimizer_service.py b/api/app/services/prompt_optimizer_service.py index fde8c4f9..b1de84d2 100644 --- a/api/app/services/prompt_optimizer_service.py +++ b/api/app/services/prompt_optimizer_service.py @@ -227,7 +227,14 @@ class PromptOptimizerService: content = getattr(chunk, "content", chunk) if not content: continue - buffer += content + if isinstance(content, str): + buffer += content + elif isinstance(content, list): + for _ in content: + buffer += _["text"] + else: + logger.error(f"Unsupported content type - {content}") + raise Exception("Unsupported content type") cache = buffer[:-20] # 尝试找到 "prompt": " 开始位置 From daaee63bd590ff069ed4d50bce45e38e976426cd Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 15 Apr 2026 12:15:16 +0800 Subject: [PATCH 056/113] refactor(custom-tools): coerce query and request body parameters to schema types --- api/app/core/tools/custom/base.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/api/app/core/tools/custom/base.py b/api/app/core/tools/custom/base.py index c03fe206..93ad03a7 100644 --- a/api/app/core/tools/custom/base.py +++ b/api/app/core/tools/custom/base.py @@ -221,7 +221,7 @@ class CustomTool(BaseTool): query_params = {} for param_name, param_info in operation.get("parameters", {}).items(): if param_info.get("in") == "query" and param_name in params: - query_params[param_name] = params[param_name] + query_params[param_name] = self._coerce_param(params[param_name], param_info.get("type", "string")) if query_params: from urllib.parse import urlencode @@ -251,21 +251,34 @@ class CustomTool(BaseTool): return headers @staticmethod - def _build_request_data(operation: Dict[str, Any], params: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def _coerce_param(value: Any, schema_type: str) -> Any: + """根据 schema 类型转换参数值""" + if value is None: + return value + try: + if schema_type == "integer": + return int(value) + elif schema_type == "number": + return float(value) + elif schema_type == "boolean": + if isinstance(value, str): + return value.lower() not in ("false", "0", "") + return bool(value) + except (ValueError, TypeError): + pass + return value + + def _build_request_data(self, operation: Dict[str, Any], params: Dict[str, Any]) -> Optional[Dict[str, Any]]: """构建请求数据""" if operation["method"] in ["POST", "PUT", "PATCH"]: request_body = operation.get("request_body") if request_body: - # 构建请求体数据 data = {} properties = request_body.get("properties", {}) - for prop_name, prop_schema in properties.items(): if prop_name in params: - data[prop_name] = params[prop_name] - + data[prop_name] = self._coerce_param(params[prop_name], prop_schema.get("type", "string")) return data if data else None - return None async def _send_http_request( From 737858731b132d3dd92c418560d84cac098d0bd5 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 15 Apr 2026 12:24:11 +0800 Subject: [PATCH 057/113] fix(core): conditionally apply thinking parameters based on model support --- api/app/core/models/base.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index 1de4b120..89a7dcee 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -112,22 +112,23 @@ class RedBearModelFactory: params["stream_usage"] = True # 深度思考模式 is_streaming = bool(config.extra_params.get("streaming")) - if is_streaming and not config.is_omni: - if provider == ModelProvider.VOLCANO: - # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 - thinking_config: Dict[str, Any] = { - "type": "enabled" if config.deep_thinking else "disabled" - } - if config.deep_thinking and config.thinking_budget_tokens: - thinking_config["budget_tokens"] = config.thinking_budget_tokens - params["extra_body"] = {"thinking": thinking_config} - else: - # 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略 - model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) - model_kwargs["enable_thinking"] = config.deep_thinking - if config.deep_thinking and config.thinking_budget_tokens: - model_kwargs["thinking_budget"] = config.thinking_budget_tokens - params["model_kwargs"] = model_kwargs + if config.support_thinking: + if is_streaming and not config.is_omni: + if provider == ModelProvider.VOLCANO: + # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 + thinking_config: Dict[str, Any] = { + "type": "enabled" if config.deep_thinking else "disabled" + } + if config.deep_thinking and config.thinking_budget_tokens: + thinking_config["budget_tokens"] = config.thinking_budget_tokens + params["extra_body"] = {"thinking": thinking_config} + else: + # 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略 + model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) + model_kwargs["enable_thinking"] = config.deep_thinking + if config.deep_thinking and config.thinking_budget_tokens: + model_kwargs["thinking_budget"] = config.thinking_budget_tokens + params["model_kwargs"] = model_kwargs return params elif provider == ModelProvider.DASHSCOPE: params = { From 3018d186f73c26eda25b1acf6ba95d283c933cfc Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 15 Apr 2026 13:56:08 +0800 Subject: [PATCH 058/113] fix(custom-tools): remove parameter coercion in custom tool base class --- api/app/core/tools/custom/base.py | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/api/app/core/tools/custom/base.py b/api/app/core/tools/custom/base.py index 93ad03a7..c03fe206 100644 --- a/api/app/core/tools/custom/base.py +++ b/api/app/core/tools/custom/base.py @@ -221,7 +221,7 @@ class CustomTool(BaseTool): query_params = {} for param_name, param_info in operation.get("parameters", {}).items(): if param_info.get("in") == "query" and param_name in params: - query_params[param_name] = self._coerce_param(params[param_name], param_info.get("type", "string")) + query_params[param_name] = params[param_name] if query_params: from urllib.parse import urlencode @@ -251,34 +251,21 @@ class CustomTool(BaseTool): return headers @staticmethod - def _coerce_param(value: Any, schema_type: str) -> Any: - """根据 schema 类型转换参数值""" - if value is None: - return value - try: - if schema_type == "integer": - return int(value) - elif schema_type == "number": - return float(value) - elif schema_type == "boolean": - if isinstance(value, str): - return value.lower() not in ("false", "0", "") - return bool(value) - except (ValueError, TypeError): - pass - return value - - def _build_request_data(self, operation: Dict[str, Any], params: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def _build_request_data(operation: Dict[str, Any], params: Dict[str, Any]) -> Optional[Dict[str, Any]]: """构建请求数据""" if operation["method"] in ["POST", "PUT", "PATCH"]: request_body = operation.get("request_body") if request_body: + # 构建请求体数据 data = {} properties = request_body.get("properties", {}) + for prop_name, prop_schema in properties.items(): if prop_name in params: - data[prop_name] = self._coerce_param(params[prop_name], prop_schema.get("type", "string")) + data[prop_name] = params[prop_name] + return data if data else None + return None async def _send_http_request( From ed765b7c262712ba19a6a303c9838f72d857ff17 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Wed, 15 Apr 2026 13:19:02 +0800 Subject: [PATCH 059/113] fix(prompt-optimizer): handle escaped quotes in JSON parsing --- api/app/controllers/prompt_optimizer_controller.py | 5 +++-- api/app/services/prompt_optimizer_service.py | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/api/app/controllers/prompt_optimizer_controller.py b/api/app/controllers/prompt_optimizer_controller.py index 80f14cd3..b9fc697c 100644 --- a/api/app/controllers/prompt_optimizer_controller.py +++ b/api/app/controllers/prompt_optimizer_controller.py @@ -124,10 +124,11 @@ async def get_prompt_opt( skill=data.skill ): # chunk 是 prompt 的增量内容 - yield f"event:message\ndata: {json.dumps(chunk)}\n\n" + yield f"event:message\ndata: {json.dumps(chunk, ensure_ascii=False)}\n\n" except Exception as e: yield f"event:error\ndata: {json.dumps( - {"error": str(e)} + {"error": str(e)}, + ensure_ascii=False )}\n\n" yield "event:end\ndata: {}\n\n" diff --git a/api/app/services/prompt_optimizer_service.py b/api/app/services/prompt_optimizer_service.py index b1de84d2..30901111 100644 --- a/api/app/services/prompt_optimizer_service.py +++ b/api/app/services/prompt_optimizer_service.py @@ -236,8 +236,11 @@ class PromptOptimizerService: logger.error(f"Unsupported content type - {content}") raise Exception("Unsupported content type") cache = buffer[:-20] + last_idx = 19 + while cache and cache[-1] == '\\' and last_idx > 0: + cache = buffer[:-last_idx] + last_idx -= 1 - # 尝试找到 "prompt": " 开始位置 if prompt_finished: continue @@ -279,7 +282,7 @@ class PromptOptimizerService: def parser_prompt_variables(prompt: str): try: pattern = r'\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}' - matches = re.findall(pattern, prompt) + matches = re.findall(pattern, str(prompt)) variables = list(set(matches)) return variables except Exception as e: From 71e5b6586a1186b88d81e90ba1d02bfe41db6b98 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 15 Apr 2026 14:38:40 +0800 Subject: [PATCH 060/113] fix(web): prompt editor --- web/src/components/Chat/PromptChatPanel.tsx | 39 ++++++++ .../components/AiPromptModal.tsx | 56 +++++------ .../components/Editor/index.tsx | 93 +++++++++++++------ web/src/views/Prompt/index.tsx | 59 ++++++------ 4 files changed, 165 insertions(+), 82 deletions(-) create mode 100644 web/src/components/Chat/PromptChatPanel.tsx diff --git a/web/src/components/Chat/PromptChatPanel.tsx b/web/src/components/Chat/PromptChatPanel.tsx new file mode 100644 index 00000000..51343ae1 --- /dev/null +++ b/web/src/components/Chat/PromptChatPanel.tsx @@ -0,0 +1,39 @@ +import { forwardRef, useImperativeHandle, useState } from 'react' +import ChatContent from './ChatContent' +import type { ChatItem } from './types' +import type { ReactNode } from 'react' + +export interface PromptChatPanelRef { + append: (item: ChatItem) => void + clear: () => void +} + +interface PromptChatPanelProps { + classNames?: string + contentClassNames?: string + empty: ReactNode + labelFormat: (item: ChatItem) => any +} + +const PromptChatPanel = forwardRef((props, ref) => { + const [chatList, setChatList] = useState([]) + + useImperativeHandle(ref, () => ({ + append: (item) => setChatList(prev => [...prev, item]), + clear: () => setChatList([]), + })) + + return ( + + ) +}) + +export default PromptChatPanel diff --git a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx index 1666e075..fd2dc595 100644 --- a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx +++ b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:26:44 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-20 13:53:05 + * @Last Modified time: 2026-04-15 14:21:55 */ /** * AI Prompt Assistant Modal @@ -20,10 +20,9 @@ import { updatePromptMessages, createPromptSessions } from '@/api/prompt' import type { AiPromptModalRef, AiPromptVariableModalRef, AiPromptForm } from '../types' import RbModal from '@/components/RbModal' import type { Model } from '@/views/ModelManagement/types' -import ChatContent from '@/components/Chat/ChatContent' import Empty from '@/components/Empty' import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg' -import type { ChatItem } from '@/components/Chat/types' +import PromptChatPanel, { type PromptChatPanelRef } from '@/components/Chat/PromptChatPanel' import ModelSelect from '@/components/ModelSelect' import AiPromptVariableModal from './AiPromptVariableModal' import { type SSEMessage } from '@/utils/stream' @@ -55,12 +54,14 @@ const AiPromptModal = forwardRef(({ const [visible, setVisible] = useState(false); const [loading, setLoading] = useState(false) const [form] = Form.useForm() - const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) + const [hasPrompt, setHasPrompt] = useState(false) const aiPromptVariableModalRef = useRef(null) + const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef('') + const isStreamingRef = useRef(false) const values = Form.useWatch([], form) @@ -68,12 +69,13 @@ const AiPromptModal = forwardRef(({ const handleClose = () => { setVisible(false); setLoading(false) - setChatList([]) + chatPanelRef.current?.clear() setVariables([]) form.setFieldsValue({ message: undefined, current_prompt: undefined, }) + setHasPrompt(false) }; /** Open modal and create new prompt session */ @@ -102,9 +104,7 @@ const AiPromptModal = forwardRef(({ } const messageContent = values.message setLoading(true) - setChatList(prev => { - return [...prev, { role: 'user', content: messageContent}] - }) + chatPanelRef.current?.append({ role: 'user', content: messageContent }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -114,6 +114,8 @@ const AiPromptModal = forwardRef(({ switch (item.event) { case 'start': currentPromptValueRef.current = '' + isStreamingRef.current = true + setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } @@ -123,15 +125,12 @@ const AiPromptModal = forwardRef(({ currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); - editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - setChatList(prev => { - return [...prev, { role: 'assistant', content: desc }] - }) + chatPanelRef.current?.append({ role: 'assistant', content: desc }) } if (variables) { setVariables(variables) @@ -139,6 +138,7 @@ const AiPromptModal = forwardRef(({ break; case 'end': setLoading(false) + isStreamingRef.current = false // Sync form value when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) break @@ -193,7 +193,6 @@ const AiPromptModal = forwardRef(({ setIsFocus(false) } - console.log(values) return ( (({ body: 'rb:p-0! rb:border-t rb:border-t-[#EBEBEB]' }} > -
+
(({ /> - } - data={chatList || []} - streamLoading={false} - labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`${source}.you`) : t(`${source}.ai`)} /> (({ - - {values?.current_prompt - ? + form.setFieldValue('current_prompt', value)} + height="rb:h-[calc(100vh-276px)]" + className={clsx('rb:bg-white! rb:border-none! rb:p-0!')} + onChange={(value) => { + if (!isStreamingRef.current) { + form.setFieldValue('current_prompt', value) + } + }} /> - : - } - + + : + }
diff --git a/web/src/views/ApplicationConfig/components/Editor/index.tsx b/web/src/views/ApplicationConfig/components/Editor/index.tsx index ab89a610..c3edc1fc 100644 --- a/web/src/views/ApplicationConfig/components/Editor/index.tsx +++ b/web/src/views/ApplicationConfig/components/Editor/index.tsx @@ -2,14 +2,14 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:25:17 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-26 11:18:04 + * @Last Modified time: 2026-04-15 14:00:07 */ /** * Rich text editor component using Lexical framework * Provides text editing with insert, append, clear, and scroll capabilities */ -import {forwardRef, useImperativeHandle } from 'react'; +import {forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import clsx from 'clsx'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; @@ -50,7 +50,7 @@ interface LexicalEditorProps { /** Callback when content changes */ onChange?: (value: string) => void; /** Editor height in pixels */ - height?: number; + height?: string; disabled?: boolean; } @@ -73,9 +73,42 @@ const EditorContent = forwardRef(({ value, placeholder = "Please enter content...", onChange, - disabled + disabled, + height }, ref) => { const [editor] = useLexicalComposerContext(); + const scrollRef = useRef(null); + const pendingTextRef = useRef(''); + const rafRef = useRef(null); + const isAppendingRef = useRef(false); + const scrollTopRef = useRef(0); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onPointerDown = () => { + if (!isAppendingRef.current) scrollTopRef.current = el.scrollTop; + }; + el.addEventListener('pointerdown', onPointerDown); + return () => el.removeEventListener('pointerdown', onPointerDown); + }, []); + + useEffect(() => { + return editor.registerUpdateListener(({ tags }) => { + if (!scrollRef.current) return; + if (tags.has('append-text')) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } else { + scrollRef.current.scrollTop = scrollTopRef.current; + } + }); + }, [editor]); + + const scrollToBottom = () => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; /** * Expose editor methods to parent component @@ -94,24 +127,33 @@ const EditorContent = forwardRef(({ }); }, appendText: (text: string) => { - editor.update(() => { - const root = $getRoot(); - const lastChild = root.getLastChild(); - if (lastChild && $isParagraphNode(lastChild)) { - const lastTextNode = lastChild.getLastChild(); - if (lastTextNode && $isTextNode(lastTextNode)) { - const currentText = lastTextNode.getTextContent(); - lastTextNode.setTextContent(currentText + text); + pendingTextRef.current += text; + if (rafRef.current !== null) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + const batch = pendingTextRef.current; + pendingTextRef.current = ''; + if (scrollRef.current) scrollTopRef.current = scrollRef.current.scrollTop; + isAppendingRef.current = true; + editor.update(() => { + const root = $getRoot(); + const lastChild = root.getLastChild(); + if (lastChild && $isParagraphNode(lastChild)) { + const lastTextNode = lastChild.getLastChild(); + if (lastTextNode && $isTextNode(lastTextNode)) { + lastTextNode.setTextContent(lastTextNode.getTextContent() + batch); + } else { + lastChild.append($createTextNode(batch)); + } } else { - const textNode = $createTextNode(text); - lastChild.append(textNode); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(batch)); + root.append(paragraph); } - } else { - const paragraph = $createParagraphNode(); - const textNode = $createTextNode(text); - paragraph.append(textNode); - root.append(paragraph); - } + }, { + tag: 'append-text', + onUpdate: () => { isAppendingRef.current = false; } + }); }); }, clear: () => { @@ -122,21 +164,16 @@ const EditorContent = forwardRef(({ root.append(paragraph); }); }, - scrollToBottom: () => { - const editorElement = editor.getRootElement(); - if (editorElement) { - editorElement.scrollTop = editorElement.scrollHeight; - } - } + scrollToBottom, }), [editor]); return ( -
+
{ const { message } = App.useApp() const [loading, setLoading] = useState(false) const [form] = Form.useForm() - const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) const aiPromptVariableModalRef = useRef(null) const promptSaveModalRef = useRef(null) + const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef(undefined) + const isStreamingRef = useRef(false) + const [hasPrompt, setHasPrompt] = useState(false) const values = Form.useWatch([], form) const [editVo, setEditVo] = useState(null) @@ -56,14 +57,14 @@ const Prompt: FC = () => { useEffect(() => { if (editVo?.id) { form.setFieldValue('current_prompt', editVo.prompt) - setChatList([]) + setHasPrompt(true) + chatPanelRef.current?.clear() } updateSession() }, [editVo]) /** Update session ID */ const updateSession = () => { - console.log('updateSession') createPromptSessions().then(res => { const response = res as { id: string } setPromptSession(response.id) @@ -83,9 +84,7 @@ const Prompt: FC = () => { } const messageContent = values.message setLoading(true) - setChatList(prev => { - return [...prev, { role: 'user', content: messageContent}] - }) + chatPanelRef.current?.append({ role: 'user', content: messageContent }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -95,33 +94,35 @@ const Prompt: FC = () => { switch (item.event) { case 'start': currentPromptValueRef.current = '' + isStreamingRef.current = true + setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } break; case 'message': - if (typeof content === 'string') { + if (content) { currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); - editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - setChatList(prev => { - return [...prev, { role: 'assistant', content: desc }] - }) + chatPanelRef.current?.append({ role: 'assistant', content: desc }) } if (variables) { setVariables(variables) } + console.log('currentPromptValueRef.current', currentPromptValueRef.current) break; case 'end': setLoading(false) + isStreamingRef.current = false // Sync form values when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) + console.log('currentPromptValueRef.current', currentPromptValueRef.current) break } }) @@ -164,7 +165,8 @@ const Prompt: FC = () => { const handleRefresh = () => { form.setFieldValue('current_prompt', undefined) currentPromptValueRef.current = undefined; - setChatList([]) + setHasPrompt(false) + chatPanelRef.current?.clear() setEditVo(null) updateSession() } @@ -193,13 +195,11 @@ const Prompt: FC = () => { headerType="borderless" bodyClassName="rb:px-4! rb:pt-0! rb:pb-3!" > - } - data={chatList || []} - streamLoading={false} - labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`prompt.you`) : t(`prompt.ai`)} /> { > } > - - {values?.current_prompt - ? + form.setFieldValue('current_prompt', value)} + height="rb:h-[calc(100vh-193px)]" + className="rb:bg-white! rb:border-none! rb:p-0! rb:text-[#212332] rb:leading-5" + onChange={(value) => { + if (!isStreamingRef.current) { + form.setFieldValue('current_prompt', value) + } + }} /> - : - } - + + : + }
From 5ac2d5602e5fa77530c9ad31adb4aac5363d55d5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 15 Apr 2026 14:50:19 +0800 Subject: [PATCH 061/113] Revert "fix(web): prompt editor" This reverts commit 71e5b6586a1186b88d81e90ba1d02bfe41db6b98. --- web/src/components/Chat/PromptChatPanel.tsx | 39 -------- .../components/AiPromptModal.tsx | 56 ++++++----- .../components/Editor/index.tsx | 93 ++++++------------- web/src/views/Prompt/index.tsx | 59 ++++++------ 4 files changed, 82 insertions(+), 165 deletions(-) delete mode 100644 web/src/components/Chat/PromptChatPanel.tsx diff --git a/web/src/components/Chat/PromptChatPanel.tsx b/web/src/components/Chat/PromptChatPanel.tsx deleted file mode 100644 index 51343ae1..00000000 --- a/web/src/components/Chat/PromptChatPanel.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { forwardRef, useImperativeHandle, useState } from 'react' -import ChatContent from './ChatContent' -import type { ChatItem } from './types' -import type { ReactNode } from 'react' - -export interface PromptChatPanelRef { - append: (item: ChatItem) => void - clear: () => void -} - -interface PromptChatPanelProps { - classNames?: string - contentClassNames?: string - empty: ReactNode - labelFormat: (item: ChatItem) => any -} - -const PromptChatPanel = forwardRef((props, ref) => { - const [chatList, setChatList] = useState([]) - - useImperativeHandle(ref, () => ({ - append: (item) => setChatList(prev => [...prev, item]), - clear: () => setChatList([]), - })) - - return ( - - ) -}) - -export default PromptChatPanel diff --git a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx index fd2dc595..1666e075 100644 --- a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx +++ b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:26:44 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-15 14:21:55 + * @Last Modified time: 2026-03-20 13:53:05 */ /** * AI Prompt Assistant Modal @@ -20,9 +20,10 @@ import { updatePromptMessages, createPromptSessions } from '@/api/prompt' import type { AiPromptModalRef, AiPromptVariableModalRef, AiPromptForm } from '../types' import RbModal from '@/components/RbModal' import type { Model } from '@/views/ModelManagement/types' +import ChatContent from '@/components/Chat/ChatContent' import Empty from '@/components/Empty' import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg' -import PromptChatPanel, { type PromptChatPanelRef } from '@/components/Chat/PromptChatPanel' +import type { ChatItem } from '@/components/Chat/types' import ModelSelect from '@/components/ModelSelect' import AiPromptVariableModal from './AiPromptVariableModal' import { type SSEMessage } from '@/utils/stream' @@ -54,14 +55,12 @@ const AiPromptModal = forwardRef(({ const [visible, setVisible] = useState(false); const [loading, setLoading] = useState(false) const [form] = Form.useForm() + const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) - const [hasPrompt, setHasPrompt] = useState(false) const aiPromptVariableModalRef = useRef(null) - const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef('') - const isStreamingRef = useRef(false) const values = Form.useWatch([], form) @@ -69,13 +68,12 @@ const AiPromptModal = forwardRef(({ const handleClose = () => { setVisible(false); setLoading(false) - chatPanelRef.current?.clear() + setChatList([]) setVariables([]) form.setFieldsValue({ message: undefined, current_prompt: undefined, }) - setHasPrompt(false) }; /** Open modal and create new prompt session */ @@ -104,7 +102,9 @@ const AiPromptModal = forwardRef(({ } const messageContent = values.message setLoading(true) - chatPanelRef.current?.append({ role: 'user', content: messageContent }) + setChatList(prev => { + return [...prev, { role: 'user', content: messageContent}] + }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -114,8 +114,6 @@ const AiPromptModal = forwardRef(({ switch (item.event) { case 'start': currentPromptValueRef.current = '' - isStreamingRef.current = true - setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } @@ -125,12 +123,15 @@ const AiPromptModal = forwardRef(({ currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); + editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - chatPanelRef.current?.append({ role: 'assistant', content: desc }) + setChatList(prev => { + return [...prev, { role: 'assistant', content: desc }] + }) } if (variables) { setVariables(variables) @@ -138,7 +139,6 @@ const AiPromptModal = forwardRef(({ break; case 'end': setLoading(false) - isStreamingRef.current = false // Sync form value when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) break @@ -193,6 +193,7 @@ const AiPromptModal = forwardRef(({ setIsFocus(false) } + console.log(values) return ( (({ body: 'rb:p-0! rb:border-t rb:border-t-[#EBEBEB]' }} > -
+
(({ /> - } + data={chatList || []} + streamLoading={false} + labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`${source}.you`) : t(`${source}.ai`)} /> (({ - {hasPrompt - ? - + {values?.current_prompt + ? { - if (!isStreamingRef.current) { - form.setFieldValue('current_prompt', value) - } - }} + className="rb:h-119 rb:bg-white! rb:border-none! rb:p-0!" + onChange={(value) => form.setFieldValue('current_prompt', value)} /> - - : - } + : + } +
diff --git a/web/src/views/ApplicationConfig/components/Editor/index.tsx b/web/src/views/ApplicationConfig/components/Editor/index.tsx index c3edc1fc..ab89a610 100644 --- a/web/src/views/ApplicationConfig/components/Editor/index.tsx +++ b/web/src/views/ApplicationConfig/components/Editor/index.tsx @@ -2,14 +2,14 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:25:17 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-15 14:00:07 + * @Last Modified time: 2026-02-26 11:18:04 */ /** * Rich text editor component using Lexical framework * Provides text editing with insert, append, clear, and scroll capabilities */ -import {forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import {forwardRef, useImperativeHandle } from 'react'; import clsx from 'clsx'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; @@ -50,7 +50,7 @@ interface LexicalEditorProps { /** Callback when content changes */ onChange?: (value: string) => void; /** Editor height in pixels */ - height?: string; + height?: number; disabled?: boolean; } @@ -73,42 +73,9 @@ const EditorContent = forwardRef(({ value, placeholder = "Please enter content...", onChange, - disabled, - height + disabled }, ref) => { const [editor] = useLexicalComposerContext(); - const scrollRef = useRef(null); - const pendingTextRef = useRef(''); - const rafRef = useRef(null); - const isAppendingRef = useRef(false); - const scrollTopRef = useRef(0); - - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const onPointerDown = () => { - if (!isAppendingRef.current) scrollTopRef.current = el.scrollTop; - }; - el.addEventListener('pointerdown', onPointerDown); - return () => el.removeEventListener('pointerdown', onPointerDown); - }, []); - - useEffect(() => { - return editor.registerUpdateListener(({ tags }) => { - if (!scrollRef.current) return; - if (tags.has('append-text')) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } else { - scrollRef.current.scrollTop = scrollTopRef.current; - } - }); - }, [editor]); - - const scrollToBottom = () => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }; /** * Expose editor methods to parent component @@ -127,33 +94,24 @@ const EditorContent = forwardRef(({ }); }, appendText: (text: string) => { - pendingTextRef.current += text; - if (rafRef.current !== null) return; - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null; - const batch = pendingTextRef.current; - pendingTextRef.current = ''; - if (scrollRef.current) scrollTopRef.current = scrollRef.current.scrollTop; - isAppendingRef.current = true; - editor.update(() => { - const root = $getRoot(); - const lastChild = root.getLastChild(); - if (lastChild && $isParagraphNode(lastChild)) { - const lastTextNode = lastChild.getLastChild(); - if (lastTextNode && $isTextNode(lastTextNode)) { - lastTextNode.setTextContent(lastTextNode.getTextContent() + batch); - } else { - lastChild.append($createTextNode(batch)); - } + editor.update(() => { + const root = $getRoot(); + const lastChild = root.getLastChild(); + if (lastChild && $isParagraphNode(lastChild)) { + const lastTextNode = lastChild.getLastChild(); + if (lastTextNode && $isTextNode(lastTextNode)) { + const currentText = lastTextNode.getTextContent(); + lastTextNode.setTextContent(currentText + text); } else { - const paragraph = $createParagraphNode(); - paragraph.append($createTextNode(batch)); - root.append(paragraph); + const textNode = $createTextNode(text); + lastChild.append(textNode); } - }, { - tag: 'append-text', - onUpdate: () => { isAppendingRef.current = false; } - }); + } else { + const paragraph = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraph.append(textNode); + root.append(paragraph); + } }); }, clear: () => { @@ -164,16 +122,21 @@ const EditorContent = forwardRef(({ root.append(paragraph); }); }, - scrollToBottom, + scrollToBottom: () => { + const editorElement = editor.getRootElement(); + if (editorElement) { + editorElement.scrollTop = editorElement.scrollHeight; + } + } }), [editor]); return ( -
+
{ const { message } = App.useApp() const [loading, setLoading] = useState(false) const [form] = Form.useForm() + const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) const aiPromptVariableModalRef = useRef(null) const promptSaveModalRef = useRef(null) - const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef(undefined) - const isStreamingRef = useRef(false) - const [hasPrompt, setHasPrompt] = useState(false) const values = Form.useWatch([], form) const [editVo, setEditVo] = useState(null) @@ -57,14 +56,14 @@ const Prompt: FC = () => { useEffect(() => { if (editVo?.id) { form.setFieldValue('current_prompt', editVo.prompt) - setHasPrompt(true) - chatPanelRef.current?.clear() + setChatList([]) } updateSession() }, [editVo]) /** Update session ID */ const updateSession = () => { + console.log('updateSession') createPromptSessions().then(res => { const response = res as { id: string } setPromptSession(response.id) @@ -84,7 +83,9 @@ const Prompt: FC = () => { } const messageContent = values.message setLoading(true) - chatPanelRef.current?.append({ role: 'user', content: messageContent }) + setChatList(prev => { + return [...prev, { role: 'user', content: messageContent}] + }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -94,35 +95,33 @@ const Prompt: FC = () => { switch (item.event) { case 'start': currentPromptValueRef.current = '' - isStreamingRef.current = true - setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } break; case 'message': - if (content) { + if (typeof content === 'string') { currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); + editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - chatPanelRef.current?.append({ role: 'assistant', content: desc }) + setChatList(prev => { + return [...prev, { role: 'assistant', content: desc }] + }) } if (variables) { setVariables(variables) } - console.log('currentPromptValueRef.current', currentPromptValueRef.current) break; case 'end': setLoading(false) - isStreamingRef.current = false // Sync form values when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) - console.log('currentPromptValueRef.current', currentPromptValueRef.current) break } }) @@ -165,8 +164,7 @@ const Prompt: FC = () => { const handleRefresh = () => { form.setFieldValue('current_prompt', undefined) currentPromptValueRef.current = undefined; - setHasPrompt(false) - chatPanelRef.current?.clear() + setChatList([]) setEditVo(null) updateSession() } @@ -195,11 +193,13 @@ const Prompt: FC = () => { headerType="borderless" bodyClassName="rb:px-4! rb:pt-0! rb:pb-3!" > - } + data={chatList || []} + streamLoading={false} + labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`prompt.you`) : t(`prompt.ai`)} /> { > } > - {hasPrompt - ? - + {values?.current_prompt + ? { - if (!isStreamingRef.current) { - form.setFieldValue('current_prompt', value) - } - }} + className="rb:h-[calc(100vh-193px)] rb:bg-white! rb:border-none! rb:p-0! rb:text-[#212332] rb:leading-5" + onChange={(value) => form.setFieldValue('current_prompt', value)} /> - - : - } + : + } +
From 643a3fbe094e97d44b6ceb23b8f70b5e67ace039 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 15 Apr 2026 16:09:38 +0800 Subject: [PATCH 062/113] feat(web): node run status --- web/src/components/CodeMirrorEditor/index.tsx | 6 +-- web/src/store/workflow.ts | 8 ++++ .../views/Workflow/components/Chat/Chat.tsx | 17 +++++-- .../views/Workflow/components/NodeLibrary.tsx | 45 +++++++++---------- .../components/Nodes/ConditionNode.tsx | 17 +++++-- .../Workflow/components/Nodes/LoopNode.tsx | 15 ++++++- .../Workflow/components/Nodes/NormalNode.tsx | 17 +++++-- .../views/Workflow/hooks/useWorkflowGraph.ts | 30 ++++++++++++- 8 files changed, 115 insertions(+), 40 deletions(-) diff --git a/web/src/components/CodeMirrorEditor/index.tsx b/web/src/components/CodeMirrorEditor/index.tsx index ec2a6780..8671992a 100644 --- a/web/src/components/CodeMirrorEditor/index.tsx +++ b/web/src/components/CodeMirrorEditor/index.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-04 17:20:52 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-04 17:20:52 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-04-14 18:24:29 */ import { useEffect, useRef, useMemo } from 'react'; import { EditorView, basicSetup } from 'codemirror'; @@ -156,7 +156,7 @@ const CodeMirrorEditor = ({
); }; diff --git a/web/src/store/workflow.ts b/web/src/store/workflow.ts index 0999d35a..382d9255 100644 --- a/web/src/store/workflow.ts +++ b/web/src/store/workflow.ts @@ -6,11 +6,15 @@ */ import { create } from 'zustand' import type { NodeCheckResult } from '@/views/Workflow/components/CheckList' +import type { ChatItem } from '@/components/Chat/types' interface WorkflowState { checkResults: Record setCheckResults: (appId: string, results: NodeCheckResult[]) => void getCheckResults: (appId: string) => NodeCheckResult[] + chatHistoryMap: Record + setChatHistory: (conversationId: string, history: ChatItem[]) => void + getChatHistory: (conversationId: string) => ChatItem[] } export const useWorkflowStore = create((set, get) => ({ @@ -18,4 +22,8 @@ export const useWorkflowStore = create((set, get) => ({ setCheckResults: (appId, results) => set(state => ({ checkResults: { ...state.checkResults, [appId]: results } })), getCheckResults: (appId) => get().checkResults[appId] ?? [], + chatHistoryMap: {}, + setChatHistory: (conversationId, history) => + set(state => ({ chatHistoryMap: { ...state.chatHistoryMap, [conversationId]: history } })), + getChatHistory: (conversationId) => get().chatHistoryMap[conversationId] ?? [], })) diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index e1a0ad95..19b06a0d 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:10:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 18:07:38 + * @Last Modified time: 2026-04-15 15:57:35 */ /** * Workflow Chat Component @@ -41,12 +41,15 @@ import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar' import Runtime from './Runtime'; import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import { replaceVariables } from '@/views/ApplicationConfig/Agent'; +import { useWorkflowStore } from '@/store/workflow'; -const Chat = forwardRef(({ +const Chat = forwardRef(({ // eslint-disable-line appId, graphRef, features }, ref) => { const { t } = useTranslation() const { message: messageApi } = App.useApp() + const { setChatHistory } = useWorkflowStore() + const conversationIdRef = useRef('draft') const toolbarRef = useRef(null) const [toolbarReady, setToolbarReady] = useState(false) const toolbarCallbackRef = useCallback((node: ChatToolbarRef | null) => { @@ -118,6 +121,7 @@ const Chat = forwardRef; - status?: 'completed' | 'failed', + status?: 'completed' | 'failed' | 'running', citations?: { document_id: string; file_name: string; @@ -231,6 +235,7 @@ const Chat = forwardRef { + setChatHistory(conversationIdRef.current, chatList) + }, [chatList]) + return ( diff --git a/web/src/views/Workflow/components/NodeLibrary.tsx b/web/src/views/Workflow/components/NodeLibrary.tsx index e6190adb..525c09ae 100644 --- a/web/src/views/Workflow/components/NodeLibrary.tsx +++ b/web/src/views/Workflow/components/NodeLibrary.tsx @@ -34,29 +34,24 @@ const NodeLibrary: FC<{ collapsed: boolean; handleToggle: () => void }> = ({ col > {collapsed - ? <> - {nodeLibrary.map(category => ( - <> - {category.nodes - .filter(node => node.type !== 'cycle-start' && node.type !== 'break') - .map((node, nodeIndex) => ( - -
{ - e.dataTransfer.setData('application/reactflow', node.type); - e.dataTransfer.setData('application/json', JSON.stringify(node)); - }} - > -
-
- - )) - } - - ))} - + ? nodeLibrary.flatMap(category => + category.nodes + .filter(node => node.type !== 'cycle-start' && node.type !== 'break') + .map(node => ( + +
{ + e.dataTransfer.setData('application/reactflow', node.type); + e.dataTransfer.setData('application/json', JSON.stringify(node)); + }} + > +
+
+ + )) + ) : nodeLibrary.map(category => (
void }> = ({ col {category.nodes .filter(node => node.type !== 'cycle-start' && node.type !== 'break') - .map((node, nodeIndex) => ( + .map((node) => ( { return (
-
{data.name ?? t(`workflow.${data.type}`)}
+
{data.name ?? t(`workflow.${data.type}`)}
+ {data.executionStatus === 'completed' + ? + : data.executionStatus === 'failed' + ? + : data.executionStatus === 'running' + ? + : null + } {data.type === 'question-classifier' && diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index ca0eaeff..c540db76 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import clsx from 'clsx'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; import { Flex } from 'antd'; +import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'; import { graphNodeLibrary, edgeAttrs } from '../../constant'; import NodeTools from './NodeTools' @@ -131,12 +132,22 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { return (
-
{data.name ?? t(`workflow.${data.type}`)}
+
{data.name ?? t(`workflow.${data.type}`)}
+ {data.executionStatus === 'completed' + ? + : data.executionStatus === 'failed' + ? + : data.executionStatus === 'running' + ? + : null + }
diff --git a/web/src/views/Workflow/components/Nodes/NormalNode.tsx b/web/src/views/Workflow/components/Nodes/NormalNode.tsx index f947d004..ce936be9 100644 --- a/web/src/views/Workflow/components/Nodes/NormalNode.tsx +++ b/web/src/views/Workflow/components/Nodes/NormalNode.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx'; import { useTranslation } from 'react-i18next' import type { ReactShapeConfig } from '@antv/x6-react-shape'; import { Flex } from 'antd'; +import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'; import NodeTools from './NodeTools' @@ -11,13 +12,23 @@ const NormalNode: ReactShapeConfig['component'] = ({ node }) => { return (
-
{data.name ?? t(`workflow.${data.type}`)}
+
{data.name ?? t(`workflow.${data.type}`)}
+ {data.executionStatus === 'completed' + ? + : data.executionStatus === 'failed' + ? + : data.executionStatus === 'running' + ? + : null + }
{t('workflow.clickToConfigure')}
diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index f385acf3..516bc24c 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-04-07 23:17:50 + * @Last Modified time: 2026-04-15 16:02:49 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -18,6 +18,7 @@ import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import { conditionNodeHeight, conditionNodeItemHeight, conditionNodePortItemArgsY, defaultAbsolutePortGroups, defaultPortItems, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, edge_width, graphNodeLibrary, nodeLibrary, nodeRegisterLibrary, nodeWidth, notesConfig, portAttrs, portItemArgsY, portMarkup, portTextAttrs, unknownNode } from '../constant'; import type { ChatVariable, NodeProperties, WorkflowConfig } from '../types'; import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils'; +import { useWorkflowStore } from '@/store/workflow'; /** * Props for useWorkflowGraph hook @@ -94,6 +95,8 @@ export const useWorkflowGraph = ({ const { message } = App.useApp(); const { t } = useTranslation() const { user } = useUser(); + const { chatHistoryMap } = useWorkflowStore() + const chatHistory = Object.values(chatHistoryMap).at(-1) ?? [] // Refs const graphRef = useRef(); @@ -1425,6 +1428,31 @@ export const useWorkflowGraph = ({ } } } + useEffect(() => { + if (!graphRef.current) return; + const nodes = graphRef.current.getNodes(); + + const lastWithSub = [...chatHistory].reverse().find(item => item.subContent?.length); + // Reset all node execution status first + nodes.forEach(node => { + const data = node.getData(); + if (typeof data.status === 'string') { + node.setData({ ...data, executionStatus: undefined }); + } + }); + if (!lastWithSub?.subContent) return; + // Build a nodeId -> status map first + const statusMap: Record = {}; + lastWithSub.subContent.forEach(sub => { + if (typeof sub.status === 'string') { + statusMap[sub.node_id] = sub.status; + const node = nodes.find(n => n.getData()?.id === sub.node_id); + if (node) { + node.setData({ ...node.getData(), executionStatus: sub.status }); + } + } + }); + }, [chatHistory, graphRef.current]); return { config, From 466864afe362d784983174bbc10c89cbdbefee91 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 15 Apr 2026 16:46:47 +0800 Subject: [PATCH 063/113] fix(web): Cancel variable snapshot --- .../components/Properties/VariableSelect.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index 5523c06e..203dd850 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -40,7 +40,7 @@ const VariableSelect: FC = ({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); - const [expandedParent, setExpandedParent] = useState(null); + const [expandedParentKey, setExpandedParentKey] = useState(null); const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 }); const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 }); const containerRef = useRef(null); @@ -84,6 +84,10 @@ const VariableSelect: FC = ({ ? filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === value)) : undefined; + const expandedParent = expandedParentKey + ? filteredOptions.find(o => o.key === expandedParentKey) ?? null + : null; + const groupedSuggestions = filteredOptions.reduce((groups: Record, s) => { const nodeId = s.nodeData.id as string; if (!groups[nodeId]) groups[nodeId] = []; @@ -139,7 +143,7 @@ const VariableSelect: FC = ({ ) { setOpen(false); setSearch(''); - setExpandedParent(null); + setExpandedParentKey(null); setChildPanelPos({ top: 0, right: 0 }); } }; @@ -159,7 +163,7 @@ const VariableSelect: FC = ({ onChange?.(`{{${suggestion.value}}}`, suggestion); setOpen(false); setSearch(''); - setExpandedParent(null); + setExpandedParentKey(null); } }; @@ -312,16 +316,16 @@ const VariableSelect: FC = ({ if (s.disabled) return; if (hasChildren) { updateChildPos(s.key); - setExpandedParent(prev => prev?.key === s.key ? null : s); + setExpandedParentKey(prev => prev === s.key ? null : s.key); } handleSelect(s); }} onMouseEnter={() => { if (hasChildren) { updateChildPos(s.key); - setExpandedParent(s); + setExpandedParentKey(s.key); } else { - setExpandedParent(null); + setExpandedParentKey(null); } }} > @@ -358,7 +362,7 @@ const VariableSelect: FC = ({ id="variable-select-child-panel" className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" style={{ top: childPanelPos.top, right: childPanelPos.right }} - onMouseEnter={() => setExpandedParent(expandedParent)} + onMouseEnter={() => setExpandedParentKey(expandedParentKey)} >
From cc12ec3fa82b5d8a0acd4bc4ed895d3f9b715004 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 15 Apr 2026 18:03:39 +0800 Subject: [PATCH 064/113] fix(workflow): support direct variable reference in tool parameters to preserve native types --- api/app/core/workflow/nodes/tool/node.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/api/app/core/workflow/nodes/tool/node.py b/api/app/core/workflow/nodes/tool/node.py index 72c5c6a8..410f64c3 100644 --- a/api/app/core/workflow/nodes/tool/node.py +++ b/api/app/core/workflow/nodes/tool/node.py @@ -15,6 +15,7 @@ from app.services.tool_service import ToolService logger = logging.getLogger(__name__) TEMPLATE_PATTERN = re.compile(r"\{\{.*?}}") +PURE_VARIABLE_PATTERN = re.compile(r"^\{\{\s*([\w.]+)\s*}}$") class ToolNode(BaseNode): @@ -52,13 +53,21 @@ class ToolNode(BaseNode): # 渲染工具参数 rendered_parameters = {} for param_name, param_template in self.typed_config.tool_parameters.items(): - if isinstance(param_template, str) and TEMPLATE_PATTERN.search(param_template): - try: - rendered_value = self._render_template(param_template, variable_pool) - except Exception as e: - raise ValueError(f"模板渲染失败:参数 {param_name} 的模板 {param_template} 解析错误") from e + if isinstance(param_template, str): + pure_match = PURE_VARIABLE_PATTERN.match(param_template) + if pure_match: + # 纯单变量引用直接取原始值,保留 int/bool/float 等类型 + rendered_value = self.get_variable(pure_match.group(1), variable_pool, strict=False) + if rendered_value is None: + rendered_value = self._render_template(param_template, variable_pool) + elif TEMPLATE_PATTERN.search(param_template): + try: + rendered_value = self._render_template(param_template, variable_pool) + except Exception as e: + raise ValueError(f"模板渲染失败:参数 {param_name} 的模板 {param_template} 解析错误") from e + else: + rendered_value = param_template else: - # 非模板参数(数字/布尔/普通字符串)直接保留原值 rendered_value = param_template rendered_parameters[param_name] = rendered_value From 1faa258e233eb5712526ac1abe9086ac5a165035 Mon Sep 17 00:00:00 2001 From: wwq Date: Wed, 15 Apr 2026 18:48:09 +0800 Subject: [PATCH 065/113] feat(quota): implement unified quota management system and add community free plan - Add `default_free_plan.py` to define the configuration for the Community Free Plan. - Refactor `quota_stub.py` as a unified entry point, delegating checks to `core/quota_manager`. - Implement core logic in `quota_manager.py` to support retrieving quotas from the premium module or configuration files. - Update `tenant_subscription_controller` to return Community Free Plan information. --- api/app/config/default_free_plan.py | 30 ++ .../tenant_subscription_controller.py | 33 +- api/app/core/quota_manager.py | 473 ++++++++++++++++++ api/app/core/quota_stub.py | 74 ++- 4 files changed, 567 insertions(+), 43 deletions(-) create mode 100644 api/app/config/default_free_plan.py create mode 100644 api/app/core/quota_manager.py diff --git a/api/app/config/default_free_plan.py b/api/app/config/default_free_plan.py new file mode 100644 index 00000000..23a3a10e --- /dev/null +++ b/api/app/config/default_free_plan.py @@ -0,0 +1,30 @@ +""" +社区版默认免费套餐配置 +当无法从 SaaS 版获取 premium 模块时,使用此配置作为兜底 +""" + +DEFAULT_FREE_PLAN = { + "name": "记忆体验版", + "category": "saas_personal", + "tier_level": 0, + "version": "1.0", + "status": True, + "price": 0, + "billing_cycle": "permanent_free", + "core_value": "感受永久记忆", + "tech_support": "社群交流", + "sla_compliance": "无", + "page_customization": "无", + "theme_color": "#64748B", + "quotas": { + "workspace_quota": 1, + "skill_quota": 5, + "app_quota": 2, + "knowledge_capacity_quota": 0.3, + "memory_engine_quota": 1, + "end_user_quota": 1, + "ontology_project_quota": 3, + "model_quota": 1, + "api_ops_rate_limit": 50, + }, +} diff --git a/api/app/controllers/tenant_subscription_controller.py b/api/app/controllers/tenant_subscription_controller.py index 2629f7f1..c3fde572 100644 --- a/api/app/controllers/tenant_subscription_controller.py +++ b/api/app/controllers/tenant_subscription_controller.py @@ -1,6 +1,7 @@ """ 租户套餐查询接口(普通用户可访问) """ +import datetime from typing import Callable from fastapi import APIRouter, Depends @@ -46,8 +47,36 @@ async def get_my_tenant_subscription( return success(data=svc.build_response(sub)) except ModuleNotFoundError: - # 社区版无 premium 模块,返回空 - return success(data=None, msg="套餐功能未启用") + # 社区版无 premium 模块,从配置文件读取免费套餐 + if not current_user.tenant: + return JSONResponse(status_code=404, content=fail(code=404, msg="用户未关联租户")) + + from app.config.default_free_plan import DEFAULT_FREE_PLAN + + plan = DEFAULT_FREE_PLAN + response_data = { + "subscription_id": None, + "tenant_id": str(current_user.tenant.id), + "package_plan_id": None, + "package_version": plan["version"], + "package_plan": { + "id": None, + "name": plan["name"], + "version": plan["version"], + "category": plan["category"], + "tier_level": plan["tier_level"], + "price": float(plan["price"]), + "billing_cycle": plan["billing_cycle"], + }, + "started_at": None, + "expired_at": None, + "status": "active", + "quota": plan["quotas"], + "created_at": int(datetime.datetime.utcnow().timestamp() * 1000), + "updated_at": int(datetime.datetime.utcnow().timestamp() * 1000), + } + return success(data=response_data, msg="社区版免费套餐") + except Exception as e: logger.error(f"获取租户套餐信息失败: {e}", exc_info=True) return JSONResponse(status_code=500, content=fail(code=500, msg="获取套餐信息失败")) diff --git a/api/app/core/quota_manager.py b/api/app/core/quota_manager.py new file mode 100644 index 00000000..6c02ac7a --- /dev/null +++ b/api/app/core/quota_manager.py @@ -0,0 +1,473 @@ +""" +统一配额管理器 - 社区版和 SaaS 版共用 + +配额来源策略: +1. 优先从 premium 模块的 tenant_subscriptions 表读取(SaaS 版) +2. 降级到 default_free_plan.py 配置文件(社区版兜底) +""" +import asyncio +import time +from functools import wraps +from typing import Optional, Callable, Dict, Any +from uuid import UUID + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.core.logging_config import get_auth_logger +from app.i18n.exceptions import QuotaExceededError + +logger = get_auth_logger() + + +def _get_user_from_kwargs(kwargs: dict): + """从 kwargs 中获取 user 对象""" + for key in ["user", "current_user"]: + if key in kwargs: + return kwargs[key] + return None + + +def _get_tenant_id_from_kwargs(db: Session, kwargs: dict): + """从 kwargs 中获取 tenant_id""" + user = _get_user_from_kwargs(kwargs) + if user and hasattr(user, 'tenant_id'): + return user.tenant_id + + workspace_id = kwargs.get("workspace_id") + if workspace_id: + from app.models.workspace_model import Workspace + workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first() + if workspace: + return workspace.tenant_id + + api_key_auth = kwargs.get("api_key_auth") + if api_key_auth and hasattr(api_key_auth, 'workspace_id'): + from app.models.workspace_model import Workspace + workspace = db.query(Workspace).filter(Workspace.id == api_key_auth.workspace_id).first() + if workspace: + return workspace.tenant_id + + data = kwargs.get("data") or kwargs.get("body") or kwargs.get("payload") + if data and hasattr(data, "workspace_id"): + from app.models.workspace_model import Workspace + workspace = db.query(Workspace).filter(Workspace.id == data.workspace_id).first() + if workspace: + return workspace.tenant_id + + return None + + +def _get_quota_config(db: Session, tenant_id: UUID) -> Optional[Dict[str, Any]]: + """ + 获取租户的配额配置 + + 优先级: + 1. premium 模块的 tenant_subscriptions(SaaS 版) + 2. default_free_plan.py 配置文件(社区版兜底) + """ + # 尝试从 premium 模块获取 + try: + from premium.platform_admin.package_plan_service import TenantSubscriptionService + quota_config = TenantSubscriptionService(db).get_effective_quota(tenant_id) + if quota_config: + logger.debug(f"从 premium 模块获取租户 {tenant_id} 配额配置") + return quota_config + except (ModuleNotFoundError, ImportError, Exception) as e: + logger.debug(f"无法从 premium 模块获取配额配置: {e}") + + # 降级到配置文件 + try: + from app.config.default_free_plan import DEFAULT_FREE_PLAN + logger.info(f"使用配置文件中的免费套餐配额: tenant={tenant_id}") + return DEFAULT_FREE_PLAN.get("quotas") + except Exception as e: + logger.error(f"无法从配置文件获取配额: {e}") + return None + + +class QuotaUsageRepository: + """配额使用量数据访问层""" + + def __init__(self, db: Session): + self.db = db + + def count_workspaces(self, tenant_id: UUID) -> int: + from app.models.workspace_model import Workspace + return self.db.query(Workspace).filter( + Workspace.tenant_id == tenant_id, + Workspace.is_active.is_(True) + ).count() + + def count_apps(self, tenant_id: UUID) -> int: + from app.models.app_model import App + from app.models.workspace_model import Workspace + return self.db.query(App).join( + Workspace, App.workspace_id == Workspace.id + ).filter( + Workspace.tenant_id == tenant_id, + App.is_active.is_(True) + ).count() + + def count_skills(self, tenant_id: UUID) -> int: + from app.models.skill_model import Skill + return self.db.query(Skill).filter( + Skill.tenant_id == tenant_id, + Skill.is_active.is_(True) + ).count() + + def sum_knowledge_capacity_gb(self, tenant_id: UUID) -> float: + from app.models.document_model import Document + from app.models.knowledge_model import Knowledge + from app.models.workspace_model import Workspace + result = self.db.query(func.coalesce(func.sum(Document.file_size), 0)).join( + Knowledge, Document.kb_id == Knowledge.id + ).join( + Workspace, Knowledge.workspace_id == Workspace.id + ).filter( + Workspace.tenant_id == tenant_id, + Document.status == 1, + ).scalar() + return float(result) / (1024 ** 3) if result else 0.0 + + def count_memory_engines(self, tenant_id: UUID) -> int: + from app.models.memory_config_model import MemoryConfig + from app.models.workspace_model import Workspace + return self.db.query(MemoryConfig).join( + Workspace, MemoryConfig.workspace_id == Workspace.id + ).filter( + Workspace.tenant_id == tenant_id + ).count() + + def count_end_users(self, tenant_id: UUID) -> int: + from app.models.end_user_model import EndUser + from app.models.workspace_model import Workspace + return self.db.query(EndUser).join( + Workspace, EndUser.workspace_id == Workspace.id + ).filter( + Workspace.tenant_id == tenant_id + ).count() + + def count_models(self, tenant_id: UUID) -> int: + from app.models.models_model import ModelConfig + return self.db.query(ModelConfig).filter( + ModelConfig.tenant_id == tenant_id, + ModelConfig.is_active == True + ).count() + + def count_ontology_projects(self, tenant_id: UUID) -> int: + from app.models.ontology_scene import OntologyScene + from app.models.workspace_model import Workspace + return self.db.query(OntologyScene).join( + Workspace, OntologyScene.workspace_id == Workspace.id + ).filter( + Workspace.tenant_id == tenant_id + ).count() + + def get_usage_by_quota_type(self, tenant_id: UUID, quota_type: str): + """按配额类型分发,返回当前使用量""" + dispatch = { + "workspace_quota": self.count_workspaces, + "app_quota": self.count_apps, + "skill_quota": self.count_skills, + "knowledge_capacity_quota": self.sum_knowledge_capacity_gb, + "memory_engine_quota": self.count_memory_engines, + "end_user_quota": self.count_end_users, + "model_quota": self.count_models, + "ontology_project_quota": self.count_ontology_projects, + } + fn = dispatch.get(quota_type) + return fn(tenant_id) if fn else 0 + + +def _check_quota( + db: Session, + tenant_id: UUID, + quota_type: str, + resource_name: str, + usage_func: Optional[Callable] = None, +) -> None: + """核心配额检查逻辑:对比使用量和配额限制""" + try: + quota_config = _get_quota_config(db, tenant_id) + if not quota_config: + logger.warning(f"租户 {tenant_id} 无有效配额配置,跳过配额检查") + return + + quota_limit = quota_config.get(quota_type) + if quota_limit is None: + logger.warning(f"配额配置未包含 {quota_type},跳过配额检查") + return + + if usage_func: + current_usage = usage_func(db, tenant_id) + else: + current_usage = QuotaUsageRepository(db).get_usage_by_quota_type(tenant_id, quota_type) + + if current_usage >= quota_limit: + logger.warning( + f"配额不足: tenant={tenant_id}, type={quota_type}, " + f"usage={current_usage}, limit={quota_limit}" + ) + raise QuotaExceededError( + resource=resource_name, + current_usage=current_usage, + quota_limit=quota_limit, + ) + + logger.debug( + f"配额检查通过: tenant={tenant_id}, type={quota_type}, " + f"usage={current_usage}, limit={quota_limit}" + ) + + except QuotaExceededError: + raise + except Exception as e: + logger.error( + f"配额检查异常: tenant={tenant_id}, type={quota_type}, " + f"error_type={type(e).__name__}, error={str(e)}", + exc_info=True, + ) + raise + + +# ─── 具名装饰器 ──────────────────────────────────────────────────────────── + +def check_workspace_quota(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + user = _get_user_from_kwargs(kwargs) + if not db or not user: + logger.warning("配额检查失败:缺少 db 或 user 参数") + return func(*args, **kwargs) + _check_quota(db, user.tenant_id, "workspace_quota", "workspace") + return func(*args, **kwargs) + return wrapper + + +def check_skill_quota(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + user = _get_user_from_kwargs(kwargs) + if not db or not user: + logger.warning("配额检查失败:缺少 db 或 user 参数") + return func(*args, **kwargs) + _check_quota(db, user.tenant_id, "skill_quota", "skill") + return func(*args, **kwargs) + return wrapper + + +def check_app_quota(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + user = _get_user_from_kwargs(kwargs) + if not db or not user: + logger.warning("配额检查失败:缺少 db 或 user 参数") + return func(*args, **kwargs) + _check_quota(db, user.tenant_id, "app_quota", "app") + return func(*args, **kwargs) + return wrapper + + +def check_knowledge_capacity_quota(func: Callable) -> Callable: + @wraps(func) + async def async_wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + if not db: + logger.warning("配额检查失败:缺少 db 参数") + return await func(*args, **kwargs) + tenant_id = _get_tenant_id_from_kwargs(db, kwargs) + if not tenant_id: + logger.warning("配额检查失败:无法获取 tenant_id") + return await func(*args, **kwargs) + _check_quota(db, tenant_id, "knowledge_capacity_quota", "knowledge_capacity") + return await func(*args, **kwargs) + + @wraps(func) + def sync_wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + user = _get_user_from_kwargs(kwargs) + if not db or not user: + logger.warning("配额检查失败:缺少 db 或 user 参数") + return func(*args, **kwargs) + _check_quota(db, user.tenant_id, "knowledge_capacity_quota", "knowledge_capacity") + return func(*args, **kwargs) + + return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper + + +def check_memory_engine_quota(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + user = _get_user_from_kwargs(kwargs) + if not db or not user: + logger.warning("配额检查失败:缺少 db 或 user 参数") + return func(*args, **kwargs) + _check_quota(db, user.tenant_id, "memory_engine_quota", "memory_engine") + return func(*args, **kwargs) + return wrapper + + +def check_end_user_quota(func: Callable) -> Callable: + @wraps(func) + async def async_wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + if not db: + logger.warning("配额检查失败:缺少 db 参数") + return await func(*args, **kwargs) + tenant_id = _get_tenant_id_from_kwargs(db, kwargs) + if not tenant_id: + logger.warning("配额检查失败:无法获取 tenant_id") + return await func(*args, **kwargs) + _check_quota(db, tenant_id, "end_user_quota", "end_user") + return await func(*args, **kwargs) + + @wraps(func) + def sync_wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + if not db: + logger.warning("配额检查失败:缺少 db 参数") + return func(*args, **kwargs) + tenant_id = _get_tenant_id_from_kwargs(db, kwargs) + if not tenant_id: + logger.warning("配额检查失败:无法获取 tenant_id") + return func(*args, **kwargs) + _check_quota(db, tenant_id, "end_user_quota", "end_user") + return func(*args, **kwargs) + + return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper + + +def check_ontology_project_quota(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + user = _get_user_from_kwargs(kwargs) + if not db or not user: + logger.warning("配额检查失败:缺少 db 或 user 参数") + return func(*args, **kwargs) + _check_quota(db, user.tenant_id, "ontology_project_quota", "ontology_project") + return func(*args, **kwargs) + return wrapper + + +def check_model_quota(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + user = _get_user_from_kwargs(kwargs) + if not db or not user: + logger.warning("配额检查失败:缺少 db 或 user 参数") + return func(*args, **kwargs) + _check_quota(db, user.tenant_id, "model_quota", "model") + return func(*args, **kwargs) + return wrapper + + +def check_model_activation_quota(func: Callable) -> Callable: + """模型激活时的配额检查装饰器""" + @wraps(func) + def wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + user = _get_user_from_kwargs(kwargs) + if not db or not user: + logger.warning("配额检查失败:缺少 db 或 user 参数") + return func(*args, **kwargs) + + model_id = kwargs.get("model_id") or (args[1] if len(args) > 1 else None) + model_data = kwargs.get("model_data") + + if not model_id or not model_data: + logger.warning("模型激活配额检查失败:缺少 model_id 或 model_data 参数") + return func(*args, **kwargs) + + if model_data.is_active is True: + try: + from app.models.models_model import ModelConfig + from app.services.model_service import ModelConfigService + + existing_model = ModelConfigService.get_model_by_id( + db=db, + model_id=model_id, + tenant_id=user.tenant_id + ) + + if not existing_model.is_active: + logger.info(f"模型激活操作,检查配额: model_id={model_id}, tenant_id={user.tenant_id}") + _check_quota(db, user.tenant_id, "model_quota", "model") + except Exception as e: + logger.error(f"模型激活配额检查异常: model_id={model_id}, error={str(e)}") + raise + + return func(*args, **kwargs) + return wrapper + + +def check_quota(quota_type: str, resource_name: str, usage_func: Optional[Callable] = None): + """通用配额检查装饰器,支持自定义使用量获取函数""" + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + db: Session = kwargs.get("db") + user = _get_user_from_kwargs(kwargs) + if not db or not user: + logger.warning("配额检查失败:缺少 db 或 user 参数") + return func(*args, **kwargs) + _check_quota(db, user.tenant_id, quota_type, resource_name, usage_func) + return func(*args, **kwargs) + return wrapper + return decorator + + +# ─── 配额使用统计 ──────────────────────────────────────────────────────────── + +def get_quota_usage(db: Session, tenant_id: UUID) -> dict: + """获取租户所有配额的使用情况""" + quota_config = _get_quota_config(db, tenant_id) + if not quota_config: + return {} + + repo = QuotaUsageRepository(db) + + def pct(used, limit): + return round(used / limit * 100, 1) if limit else None + + workspace_count = repo.count_workspaces(tenant_id) + skill_count = repo.count_skills(tenant_id) + app_count = repo.count_apps(tenant_id) + knowledge_gb = repo.sum_knowledge_capacity_gb(tenant_id) + memory_count = repo.count_memory_engines(tenant_id) + end_user_count = repo.count_end_users(tenant_id) + model_count = repo.count_models(tenant_id) + ontology_count = repo.count_ontology_projects(tenant_id) + + api_ops_current = 0 + try: + from app.core.config import settings + import redis + _now = time.time() + _rk = f"rate_limit:tenant_qps:{tenant_id}" + _r = redis.StrictRedis( + host=settings.REDIS_HOST, port=settings.REDIS_PORT, + db=settings.REDIS_DB, password=settings.REDIS_PASSWORD, + decode_responses=True + ) + api_ops_current = int(_r.zcount(_rk, _now - 1, "+inf")) + except Exception: + pass + + return { + "workspace": {"used": workspace_count, "limit": quota_config.get("workspace_quota"), "percentage": pct(workspace_count, quota_config.get("workspace_quota"))}, + "skill": {"used": skill_count, "limit": quota_config.get("skill_quota"), "percentage": pct(skill_count, quota_config.get("skill_quota"))}, + "app": {"used": app_count, "limit": quota_config.get("app_quota"), "percentage": pct(app_count, quota_config.get("app_quota"))}, + "knowledge_capacity": {"used": round(knowledge_gb, 2), "limit": quota_config.get("knowledge_capacity_quota"), "percentage": pct(knowledge_gb, quota_config.get("knowledge_capacity_quota")), "unit": "GB"}, + "memory_engine": {"used": memory_count, "limit": quota_config.get("memory_engine_quota"), "percentage": pct(memory_count, quota_config.get("memory_engine_quota"))}, + "end_user": {"used": end_user_count, "limit": quota_config.get("end_user_quota"), "percentage": pct(end_user_count, quota_config.get("end_user_quota"))}, + "ontology_project": {"used": ontology_count, "limit": quota_config.get("ontology_project_quota"), "percentage": pct(ontology_count, quota_config.get("ontology_project_quota"))}, + "model": {"used": model_count, "limit": quota_config.get("model_quota"), "percentage": pct(model_count, quota_config.get("model_quota"))}, + "api_ops_rate_limit": {"current": api_ops_current, "limit": quota_config.get("api_ops_rate_limit"), "percentage": None, "unit": "次/秒"}, + } diff --git a/api/app/core/quota_stub.py b/api/app/core/quota_stub.py index b8f82e75..577dfadb 100644 --- a/api/app/core/quota_stub.py +++ b/api/app/core/quota_stub.py @@ -1,44 +1,36 @@ """ -配额检查 stub - 社区版使用,所有检查直接放行。 -企业版通过 premium.platform_admin.quota_decorator 提供真实实现。 +配额检查 stub - 社区版和 SaaS 版统一使用 core.quota_manager 实现 + +所有配额检查逻辑统一在 core 层实现,两个版本共用: +- 社区版:从 default_free_plan.py 读取配额限制 +- SaaS 版:优先从 tenant_subscriptions 表读取,降级到配置文件 """ -from functools import wraps -from typing import Callable +from app.core.quota_manager import ( + check_workspace_quota, + check_skill_quota, + check_app_quota, + check_knowledge_capacity_quota, + check_memory_engine_quota, + check_end_user_quota, + check_ontology_project_quota, + check_model_quota, + check_model_activation_quota, + get_quota_usage, + _check_quota, + QuotaUsageRepository, +) - -def _noop_decorator(func: Callable) -> Callable: - """空装饰器,直接放行""" - return func - - -def _noop_check(*args, **kwargs): - """空检查函数,直接放行""" - pass - - -try: - from premium.platform_admin.quota_decorator import ( - check_workspace_quota, - check_skill_quota, - check_app_quota, - check_knowledge_capacity_quota, - check_memory_engine_quota, - check_end_user_quota, - check_ontology_project_quota, - check_model_quota, - check_model_activation_quota, - get_quota_usage, - _check_quota, - ) -except ModuleNotFoundError: - check_workspace_quota = _noop_decorator - check_skill_quota = _noop_decorator - check_app_quota = _noop_decorator - check_knowledge_capacity_quota = _noop_decorator - check_memory_engine_quota = _noop_decorator - check_end_user_quota = _noop_decorator - check_ontology_project_quota = _noop_decorator - check_model_quota = _noop_decorator - check_model_activation_quota = _noop_decorator - get_quota_usage = lambda db, tenant_id: {} - _check_quota = _noop_check +__all__ = [ + "check_workspace_quota", + "check_skill_quota", + "check_app_quota", + "check_knowledge_capacity_quota", + "check_memory_engine_quota", + "check_end_user_quota", + "check_ontology_project_quota", + "check_model_quota", + "check_model_activation_quota", + "get_quota_usage", + "_check_quota", + "QuotaUsageRepository", +] From d5c8d16e642d8797f8ff00605a30dbd57a6cb0f5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 15 Apr 2026 16:27:45 +0800 Subject: [PATCH 066/113] fix(web): tool methods add cache --- web/src/views/Workflow/components/CheckList/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/views/Workflow/components/CheckList/index.tsx b/web/src/views/Workflow/components/CheckList/index.tsx index 0256416a..62132a71 100644 --- a/web/src/views/Workflow/components/CheckList/index.tsx +++ b/web/src/views/Workflow/components/CheckList/index.tsx @@ -118,6 +118,7 @@ const CheckList: FC = ({ workflowRef, appId }) => { const { setCheckResults, getCheckResults } = useWorkflowStore() const results = getCheckResults(appId) const timerRef = useRef>() + const toolMethodsCacheRef = useRef }>>>({}) const runCheck = useCallback(async () => { const graph = workflowRef.current?.graphRef?.current @@ -167,7 +168,10 @@ const CheckList: FC = ({ workflowRef, appId }) => { if (typeof toolId === 'string') { try { - const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> + if (!toolMethodsCacheRef.current[toolId]) { + toolMethodsCacheRef.current[toolId] = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> + } + const methods = toolMethodsCacheRef.current[toolId] const operation = toolParameters?.operation const method = operation ? methods.find(m => m.name === operation) : methods[0] if (method) { From 78ba313262ef94d92307408a5414251da128b858 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 16 Apr 2026 10:10:30 +0800 Subject: [PATCH 067/113] feat(web): Keep the last 4 characters of the API key as original --- web/src/utils/apiKeyReplacer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/utils/apiKeyReplacer.ts b/web/src/utils/apiKeyReplacer.ts index 561f146d..cc455fc3 100644 --- a/web/src/utils/apiKeyReplacer.ts +++ b/web/src/utils/apiKeyReplacer.ts @@ -43,7 +43,8 @@ export const maskApiKeys = (text: string): string => { result = result.replace(API_KEY_PATTERNS[key as keyof typeof API_KEY_PREFIX], (match) => { const prefixLength = API_KEY_PREFIX[key].length const prefix = match.substring(0, prefixLength) - return prefix + '*'.repeat(match.length - prefixLength) + const suffix = match.slice(-4) + return prefix + '*'.repeat(match.length - prefixLength - 4) + suffix }) }) From b6aca0b1e7602477686b48547b5b6ab18fc21507 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 16 Apr 2026 10:28:26 +0800 Subject: [PATCH 068/113] fix(web): userinfo --- web/src/components/Header/index.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index 23a89894..6819b202 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:07:49 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 12:18:58 + * @Last Modified time: 2026-04-16 10:28:18 */ /** * AppHeader Component @@ -31,7 +31,7 @@ const { Header } = Layout; /** * @param source - Breadcrumb source type ('space' or 'manage'), defaults to 'manage' */ -const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { +const AppHeader: FC<{ source?: 'space' | 'manage'; }> = ({ source = 'manage' }) => { const { t } = useTranslation(); const location = useLocation(); const settingModalRef = useRef(null) @@ -39,7 +39,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { const { user, logout } = useUser(); const { allBreadcrumbs } = useMenu(); - + /** * Dynamically select breadcrumb source based on current route * - Knowledge base list: uses 'space' breadcrumb @@ -48,24 +48,24 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { */ const getBreadcrumbSource = () => { const pathname = location.pathname; - + // Knowledge base list page uses default space breadcrumb if (pathname === '/knowledge-base') { return 'space'; } - + // Knowledge base detail pages use independent breadcrumb if (pathname.includes('/knowledge-base/') && pathname !== '/knowledge-base') { return 'space-detail'; } - + // Other pages use the passed source return source; }; - + const breadcrumbSource = getBreadcrumbSource(); const breadcrumbs = allBreadcrumbs[breadcrumbSource] || []; - + /** Handle user logout */ const handleLogout = () => { @@ -127,7 +127,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { onClick: handleLogout, }, ]; - + /** * Format breadcrumb items with proper titles, paths, and click handlers * - Translates i18n keys to display text @@ -146,7 +146,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { ), }; - + if (!isLast) { if ((menu as any).onClick) { item.onClick = (e: React.MouseEvent) => { @@ -162,7 +162,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { return item; }); } - + const [open, setOpen] = useState(false); const handleOpenChange = (open: boolean) => { setOpen(open); From f04412c455fd21b83283cd6f7adaf0f26bb7f0a0 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 16 Apr 2026 10:32:34 +0800 Subject: [PATCH 069/113] fix(web): userinfo --- web/src/components/Header/index.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index 6819b202..de87dcfc 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:07:49 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-16 10:28:18 + * @Last Modified time: 2026-04-16 10:31:21 */ /** * AppHeader Component @@ -76,9 +76,11 @@ const AppHeader: FC<{ source?: 'space' | 'manage'; }> = ({ source = 'manage' }) const userMenuItems: MenuProps['items'] = [ { key: '1', - icon: - {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(-2) : user.username[0]} - , + icon: user.username + ? + {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(-2) : user.username[0]} + + : null, label: (<>
{user.username}
{user.email}
@@ -181,9 +183,9 @@ const AppHeader: FC<{ source?: 'space' | 'manage'; }> = ({ source = 'manage' }) overlayClassName={styles.userDropdown} > - + {user.username && {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(-2) : user.username[0]} - + } {user.username}
Date: Thu, 16 Apr 2026 13:35:35 +0800 Subject: [PATCH 070/113] fix(model): fix issue where associated model config status was not updated when deleting API Key When deleting an API Key, check if the associated model configuration has other active keys; if not, automatically set it to inactive. Also optimize the model configuration query method to support multi-type queries and add sorting conditions. --- api/app/repositories/model_repository.py | 25 ++++++++++++------------ api/app/services/model_service.py | 13 +++++++++++- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/api/app/repositories/model_repository.py b/api/app/repositories/model_repository.py index 8c477d39..03870b4d 100644 --- a/api/app/repositories/model_repository.py +++ b/api/app/repositories/model_repository.py @@ -263,16 +263,15 @@ class ModelConfigRepository: raise @staticmethod - def get_by_type(db: Session, model_type: ModelType, tenant_id: uuid.UUID | None = None, is_active: bool = True) -> List[ModelConfig]: - """根据类型获取模型配置""" - db_logger.debug(f"根据类型查询模型配置: type={model_type}, tenant_id={tenant_id}, is_active={is_active}") - + def get_by_type(db: Session, model_types: List[ModelType], tenant_id: uuid.UUID | None = None, is_active: bool = True) -> List[ModelConfig]: + """根据类型获取模型配置,支持多类型查询""" + db_logger.debug(f"根据类型查询模型配置: types={[t.value for t in model_types]}, tenant_id={tenant_id}, is_active={is_active}") + try: query = db.query(ModelConfig).options( joinedload(ModelConfig.api_keys) - ).filter(ModelConfig.type == model_type) - - # 添加租户过滤 + ).filter(ModelConfig.type.in_([t.value for t in model_types])) + if tenant_id: query = query.filter( or_( @@ -280,16 +279,18 @@ class ModelConfigRepository: ModelConfig.is_public ) ) - + if is_active: query = query.filter(ModelConfig.is_active) - - models = query.order_by(ModelConfig.name).all() + + query = query.filter(ModelConfig.is_composite == False) + + models = query.order_by(ModelConfig.created_at.desc()).all() db_logger.debug(f"根据类型查询模型配置成功: 数量={len(models)}") return models - + except Exception as e: - db_logger.error(f"根据类型查询模型配置失败: type={model_type} - {str(e)}") + db_logger.error(f"根据类型查询模型配置失败: types={model_types} - {str(e)}") raise @staticmethod diff --git a/api/app/services/model_service.py b/api/app/services/model_service.py index 4cbb3509..d202b83a 100644 --- a/api/app/services/model_service.py +++ b/api/app/services/model_service.py @@ -729,10 +729,21 @@ class ModelApiKeyService: @staticmethod def delete_api_key(db: Session, api_key_id: uuid.UUID) -> bool: """删除API Key""" - if not ModelApiKeyRepository.get_by_id(db, api_key_id): + api_key = ModelApiKeyRepository.get_by_id(db, api_key_id) + if not api_key: raise BusinessException("API Key不存在", BizCode.NOT_FOUND) + model_config_ids = [mc.id for mc in api_key.model_configs] + success = ModelApiKeyRepository.delete(db, api_key_id) + + for model_config_id in model_config_ids: + model_config = ModelConfigRepository.get_by_id(db, model_config_id) + if model_config: + has_active_key = any(key.is_active for key in model_config.api_keys) + if not has_active_key and model_config.is_active: + model_config.is_active = False + db.commit() return success From 8c6b65db12523ce47cebca47d34b5322e0087d5b Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Thu, 16 Apr 2026 16:27:55 +0800 Subject: [PATCH 071/113] feat(llm): add json_output support for structured LLM responses --- api/app/controllers/chunk_controller.py | 4 +- api/app/controllers/ontology_controller.py | 2 +- api/app/core/agent/langchain_agent.py | 27 ++-- api/app/core/models/base.py | 125 ++++++++++++------ .../core/models/scripts/bedrock_models.yaml | 21 ++- .../core/models/scripts/dashscope_models.yaml | 70 +++++++--- .../core/models/scripts/openai_models.yaml | 33 ++++- .../core/models/scripts/volcano_models.yaml | 16 ++- api/app/core/workflow/nodes/llm/config.py | 5 + api/app/core/workflow/nodes/llm/node.py | 21 ++- api/app/schemas/app_schema.py | 1 + api/app/services/app_chat_service.py | 2 + api/app/services/conversation_service.py | 2 +- api/app/services/draft_run_service.py | 2 + api/app/services/llm_router.py | 8 +- api/app/services/master_agent_router.py | 2 +- api/app/services/memory_perceptual_service.py | 2 +- api/app/services/model_parameter_merger.py | 3 +- api/app/services/model_service.py | 8 +- api/app/services/multi_agent_orchestrator.py | 18 ++- api/app/services/prompt_optimizer_service.py | 2 +- api/app/services/shared_chat_service.py | 4 +- api/app/version_info.json | 36 +++++ 23 files changed, 304 insertions(+), 110 deletions(-) diff --git a/api/app/controllers/chunk_controller.py b/api/app/controllers/chunk_controller.py index b5c0a5ae..cc1f8c98 100644 --- a/api/app/controllers/chunk_controller.py +++ b/api/app/controllers/chunk_controller.py @@ -443,10 +443,10 @@ async def retrieve_chunks( match retrieve_data.retrieve_type: case chunk_schema.RetrieveType.PARTICIPLE: rs = vector_service.search_by_full_text(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.similarity_threshold, file_names_filter=retrieve_data.file_names_filter) - return success(data=rs, msg="retrieval successful") + return success(data=jsonable_encoder(rs), msg="retrieval successful") case chunk_schema.RetrieveType.SEMANTIC: rs = vector_service.search_by_vector(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.vector_similarity_weight, file_names_filter=retrieve_data.file_names_filter) - return success(data=rs, msg="retrieval successful") + return success(data=jsonable_encoder(rs), msg="retrieval successful") case _: rs1 = vector_service.search_by_vector(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.vector_similarity_weight, file_names_filter=retrieve_data.file_names_filter) rs2 = vector_service.search_by_full_text(query=retrieve_data.query, top_k=retrieve_data.top_k, indices=indices, score_threshold=retrieve_data.similarity_threshold, file_names_filter=retrieve_data.file_names_filter) diff --git a/api/app/controllers/ontology_controller.py b/api/app/controllers/ontology_controller.py index 83f75888..602ee709 100644 --- a/api/app/controllers/ontology_controller.py +++ b/api/app/controllers/ontology_controller.py @@ -165,7 +165,7 @@ def _get_ontology_service( api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, - support_thinking="thinking" in (api_key_config.capability or []), + capability=api_key_config.capability, max_retries=3, timeout=60.0 ) diff --git a/api/app/core/agent/langchain_agent.py b/api/app/core/agent/langchain_agent.py index ca7172e8..927eb734 100644 --- a/api/app/core/agent/langchain_agent.py +++ b/api/app/core/agent/langchain_agent.py @@ -41,6 +41,7 @@ class LangChainAgent: max_tool_consecutive_calls: int = 3, # 单个工具最大连续调用次数 deep_thinking: bool = False, # 是否启用深度思考模式 thinking_budget_tokens: Optional[int] = None, # 深度思考 token 预算 + json_output: bool = False, # 是否强制 JSON 输出 capability: Optional[List[str]] = None # 模型能力列表,用于校验是否支持深度思考 ): """初始化 LangChain Agent @@ -64,7 +65,6 @@ class LangChainAgent: self.streaming = streaming self.is_omni = is_omni self.max_tool_consecutive_calls = max_tool_consecutive_calls - self.deep_thinking = deep_thinking and ("thinking" in (capability or [])) # 工具调用计数器:记录每个工具的连续调用次数 self.tool_call_counter: Dict[str, int] = {} @@ -80,6 +80,12 @@ class LangChainAgent: self.system_prompt = system_prompt or "你是一个专业的AI助手" + # ChatTongyi 要求 messages 含 'json' 字样才能使用 response_format + # 在 system prompt 中注入 JSON 要求 + from app.models.models_model import ModelProvider + if json_output and provider.lower() == ModelProvider.DASHSCOPE and not is_omni: + self.system_prompt += "\n请以JSON格式输出。" + logger.debug( f"Agent 迭代次数配置: max_iterations={self.max_iterations}, " f"tool_count={len(self.tools)}, " @@ -87,23 +93,17 @@ class LangChainAgent: f"auto_calculated={max_iterations is None}" ) - # 根据 capability 校验是否真正支持深度思考 - actual_deep_thinking = self.deep_thinking - if deep_thinking and not actual_deep_thinking: - logger.warning( - f"模型 {model_name} 不支持深度思考(capability 中无 'thinking'),已自动关闭 deep_thinking" - ) - - # 创建 RedBearLLM(支持多提供商) + # 创建 RedBearLLM,capability 校验由 RedBearModelConfig 统一处理 model_config = RedBearModelConfig( model_name=model_name, provider=provider, api_key=api_key, base_url=api_base, is_omni=is_omni, - deep_thinking=actual_deep_thinking, - thinking_budget_tokens=thinking_budget_tokens if actual_deep_thinking else None, - support_thinking="thinking" in (capability or []), + capability=capability, + deep_thinking=deep_thinking, + thinking_budget_tokens=thinking_budget_tokens, + json_output=json_output, extra_params={ "temperature": temperature, "max_tokens": max_tokens, @@ -112,6 +112,9 @@ class LangChainAgent: ) self.llm = RedBearLLM(model_config, type=ModelType.CHAT) + # 从经过校验的 config 读取实际生效的能力开关 + self.deep_thinking = model_config.deep_thinking + self.json_output = model_config.json_output # 获取底层模型用于真正的流式调用 self._underlying_llm = self.llm._model if hasattr(self.llm, '_model') else self.llm diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index 89a7dcee..90046412 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import Any, Dict, Optional, TypeVar +from typing import Any, Dict, List, Optional, TypeVar from langchain_aws import ChatBedrock from langchain_community.chat_models import ChatTongyi @@ -9,7 +9,7 @@ from langchain_core.embeddings import Embeddings from langchain_core.language_models import BaseLLM from langchain_ollama import OllamaLLM from langchain_openai import ChatOpenAI, OpenAI -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from app.core.error_codes import BizCode from app.core.exceptions import BusinessException @@ -25,10 +25,11 @@ class RedBearModelConfig(BaseModel): provider: str api_key: str base_url: Optional[str] = None + capability: List[str] = Field(default_factory=list) # 模型能力列表,驱动所有能力开关 is_omni: bool = False # 是否为 Omni 模型 deep_thinking: bool = False # 是否启用深度思考模式 thinking_budget_tokens: Optional[int] = None # 深度思考 token 预算 - support_thinking: bool = False # 模型是否支持 enable_thinking 参数(capability 含 thinking) + json_output: bool = False # 是否强制 JSON 输出 # 请求超时时间(秒)- 默认120秒以支持复杂的LLM调用,可通过环境变量 LLM_TIMEOUT 配置 timeout: float = Field(default_factory=lambda: float(os.getenv("LLM_TIMEOUT", "120.0"))) # 最大重试次数 - 默认2次以避免过长等待,可通过环境变量 LLM_MAX_RETRIES 配置 @@ -36,6 +37,29 @@ class RedBearModelConfig(BaseModel): concurrency: int = 5 # 并发限流 extra_params: Dict[str, Any] = {} + @model_validator(mode="after") + def _resolve_capabilities(self) -> "RedBearModelConfig": + from app.core.logging_config import get_business_logger + logger = get_business_logger() + if self.deep_thinking and "thinking" not in self.capability: + logger.warning( + f"模型 {self.model_name} 不支持深度思考(capability 中无 'thinking'),已自动关闭 deep_thinking" + ) + self.deep_thinking = False + self.thinking_budget_tokens = None + if self.json_output and "json_output" not in self.capability: + logger.warning( + f"模型 {self.model_name} 不支持 JSON 输出(capability 中无 'json_output'),已自动关闭 json_output" + ) + self.json_output = False + if self.json_output and self.deep_thinking: + logger.warning( + f"模型 {self.model_name} json_output 与 deep_thinking 互斥,已自动关闭 deep_thinking" + ) + self.deep_thinking = False + self.thinking_budget_tokens = None + return self + class RedBearModelFactory: """模型工厂类""" @@ -74,17 +98,20 @@ class RedBearModelFactory: is_streaming = bool(config.extra_params.get("streaming")) if is_streaming: params["stream_usage"] = True - # 只有支持 thinking 的模型才传 enable_thinking - if config.support_thinking: - model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) - if is_streaming: - model_kwargs["enable_thinking"] = config.deep_thinking - if config.deep_thinking: - model_kwargs["incremental_output"] = True - if config.thinking_budget_tokens: - model_kwargs["thinking_budget"] = config.thinking_budget_tokens - else: - model_kwargs["enable_thinking"] = False + # 支持 thinking 的模型始终传 enable_thinking,关闭时显式传 False 避免模型默认开启思考 + if "thinking" in config.capability: + extra_body = params.setdefault("extra_body", {}) + if config.deep_thinking: + extra_body["enable_thinking"] = False + if is_streaming: + extra_body["enable_thinking"] = True + if config.thinking_budget_tokens: + extra_body["thinking_budget"] = config.thinking_budget_tokens + params["extra_body"] = extra_body + # JSON 输出模式 + if config.json_output: + model_kwargs = params.setdefault("model_kwargs", {}) + model_kwargs["response_format"] = {"type": "json_object"} params["model_kwargs"] = model_kwargs return params @@ -108,27 +135,30 @@ class RedBearModelFactory: **config.extra_params } # 流式模式下启用 stream_usage 以获取 token 统计 - if config.extra_params.get("streaming"): - params["stream_usage"] = True - # 深度思考模式 is_streaming = bool(config.extra_params.get("streaming")) - if config.support_thinking: - if is_streaming and not config.is_omni: - if provider == ModelProvider.VOLCANO: - # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 - thinking_config: Dict[str, Any] = { - "type": "enabled" if config.deep_thinking else "disabled" - } - if config.deep_thinking and config.thinking_budget_tokens: - thinking_config["budget_tokens"] = config.thinking_budget_tokens - params["extra_body"] = {"thinking": thinking_config} - else: - # 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略 - model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) - model_kwargs["enable_thinking"] = config.deep_thinking - if config.deep_thinking and config.thinking_budget_tokens: - model_kwargs["thinking_budget"] = config.thinking_budget_tokens - params["model_kwargs"] = model_kwargs + if is_streaming: + params["stream_usage"] = True + # 支持 thinking 的模型始终传 enable_thinking,关闭时显式传 False 避免模型默认开启思考 + if "thinking" in config.capability: + # VOLCANO 深度思考仅流式支持 + if provider == ModelProvider.VOLCANO: + thinking_config: Dict[str, Any] = {"type": "enabled" if config.deep_thinking else "disabled"} + if config.deep_thinking and config.thinking_budget_tokens: + thinking_config["budget_tokens"] = config.thinking_budget_tokens + params["extra_body"] = {"thinking": thinking_config} + else: + extra_body = params.setdefault("extra_body", {}) + if config.deep_thinking: + extra_body["enable_thinking"] = False + if is_streaming: + extra_body["enable_thinking"] = True + if config.thinking_budget_tokens: + extra_body["thinking_budget"] = config.thinking_budget_tokens + params["extra_body"] = extra_body + # JSON 输出模式 + if config.json_output: + params.setdefault("model_kwargs", {}) + params["model_kwargs"]["response_format"] = {"type": "json_object"} return params elif provider == ModelProvider.DASHSCOPE: params = { @@ -137,18 +167,21 @@ class RedBearModelFactory: "max_retries": config.max_retries, **config.extra_params } - # 只有支持 thinking 的模型才传 enable_thinking - if config.support_thinking: + # 支持 thinking 的模型始终传 enable_thinking,关闭时显式传 False 避免模型默认开启思考 + if "thinking" in config.capability: is_streaming = bool(config.extra_params.get("streaming")) - model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) - if is_streaming: - model_kwargs["enable_thinking"] = config.deep_thinking - if config.deep_thinking: - model_kwargs["incremental_output"] = True - if config.thinking_budget_tokens: - model_kwargs["thinking_budget"] = config.thinking_budget_tokens - else: + model_kwargs = params.setdefault("model_kwargs", {}) + if config.deep_thinking: model_kwargs["enable_thinking"] = False + if is_streaming: + model_kwargs["enable_thinking"] = True + model_kwargs["incremental_output"] = True + if config.thinking_budget_tokens: + model_kwargs["thinking_budget"] = config.thinking_budget_tokens + params["model_kwargs"] = model_kwargs + if config.json_output: + model_kwargs = params.setdefault("model_kwargs", {}) + model_kwargs["response_format"] = {"type": "json_object"} params["model_kwargs"] = model_kwargs return params elif provider == ModelProvider.BEDROCK: @@ -196,6 +229,10 @@ class RedBearModelFactory: params["additional_model_request_fields"] = { "thinking": {"type": "enabled", "budget_tokens": budget} } + # JSON 输出模式 + if config.json_output: + params.setdefault("model_kwargs", {}) + params["model_kwargs"]["response_format"] = {"type": "json_object"} return params else: raise BusinessException(f"不支持的提供商: {provider}", code=BizCode.PROVIDER_NOT_SUPPORTED) diff --git a/api/app/core/models/scripts/bedrock_models.yaml b/api/app/core/models/scripts/bedrock_models.yaml index 5b3a2f64..f96dba15 100644 --- a/api/app/core/models/scripts/bedrock_models.yaml +++ b/api/app/core/models/scripts/bedrock_models.yaml @@ -6,7 +6,8 @@ models: description: AI21 Labs大语言模型,completion生成模式,256000上下文窗口 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -20,6 +21,7 @@ models: is_official: true capability: - vision + - json_output is_omni: false tags: - 大语言模型 @@ -38,6 +40,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -54,7 +57,8 @@ models: description: Cohere大语言模型,支持智能体思考、工具调用、流式工具调用,128000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -72,6 +76,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -87,7 +92,8 @@ models: description: Meta Llama大语言模型,支持智能体思考、工具调用,128000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -101,7 +107,8 @@ models: description: Mistral AI大语言模型,支持智能体思考、工具调用,32000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -115,7 +122,8 @@ models: description: OpenAI大语言模型,支持智能体思考、工具调用、流式工具调用,32768上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -130,7 +138,8 @@ models: description: Qwen大语言模型,支持智能体思考、工具调用、流式工具调用,32768上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 diff --git a/api/app/core/models/scripts/dashscope_models.yaml b/api/app/core/models/scripts/dashscope_models.yaml index d9e6a00f..26c3cc2c 100644 --- a/api/app/core/models/scripts/dashscope_models.yaml +++ b/api/app/core/models/scripts/dashscope_models.yaml @@ -8,6 +8,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -22,6 +23,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -36,6 +38,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -48,7 +51,8 @@ models: description: DeepSeek-V3.1大语言模型,支持智能体思考,131072超大上下文窗口,对话模式,支持丰富生成参数调节 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -61,7 +65,8 @@ models: description: DeepSeek-V3.2-exp实验版大语言模型,支持智能体思考,131072超大上下文窗口,对话模式,支持丰富生成参数调节 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -74,7 +79,8 @@ models: description: DeepSeek-V3.2大语言模型,支持智能体思考,131072超大上下文窗口,对话模式,支持丰富生成参数调节 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -87,7 +93,8 @@ models: description: DeepSeek-V3大语言模型,支持智能体思考,64000上下文窗口,对话模式,支持文本与JSON格式输出 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -100,7 +107,8 @@ models: description: farui-plus大语言模型,支持多工具调用、智能体思考、流式工具调用,12288上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -115,7 +123,8 @@ models: description: GLM-4.7大语言模型,支持多工具调用、智能体思考、流式工具调用,202752超大上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -133,6 +142,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -150,6 +160,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -180,6 +191,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -210,7 +222,7 @@ models: is_deprecated: false is_official: true capability: - - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -376,6 +388,7 @@ models: capability: - vision - video + - json_output is_omni: false tags: - 大语言模型 @@ -448,6 +461,7 @@ models: capability: - vision - video + - qwen-vl-plus-latest is_omni: false tags: - 大语言模型 @@ -466,6 +480,7 @@ models: capability: - vision - video + - qwen-vl-plus-latest is_omni: false tags: - 大语言模型 @@ -481,7 +496,8 @@ models: description: qwen2.5-0.5b-instruct大语言模型,支持多工具调用、智能体思考、流式工具调用,32768上下文窗口,对话模式,未废弃 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -498,6 +514,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -513,7 +530,7 @@ models: is_deprecated: false is_official: true capability: - - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -530,6 +547,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -546,6 +564,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -561,7 +580,7 @@ models: is_deprecated: false is_official: true capability: - - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -578,6 +597,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -594,6 +614,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -610,6 +631,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -626,6 +648,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -641,7 +664,7 @@ models: is_deprecated: false is_official: true capability: - - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -656,7 +679,7 @@ models: is_deprecated: false is_official: true capability: - - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -672,6 +695,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -687,6 +711,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -702,6 +727,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -719,6 +745,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -736,6 +763,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -752,6 +780,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -768,7 +797,7 @@ models: is_deprecated: false is_official: true capability: - - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -785,6 +814,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -803,6 +833,8 @@ models: - vision - video - audio + - thinking + - json_output is_omni: true tags: - 大语言模型 @@ -822,7 +854,7 @@ models: capability: - vision - video - - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -844,6 +876,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -864,7 +897,7 @@ models: capability: - vision - video - - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -886,6 +919,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -907,6 +941,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -928,6 +963,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -947,6 +983,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -964,6 +1001,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -979,6 +1017,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -994,6 +1033,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 diff --git a/api/app/core/models/scripts/openai_models.yaml b/api/app/core/models/scripts/openai_models.yaml index 08b81008..1c0a0b2d 100644 --- a/api/app/core/models/scripts/openai_models.yaml +++ b/api/app/core/models/scripts/openai_models.yaml @@ -10,6 +10,7 @@ models: - vision - audio - video + - json_output is_omni: true tags: - 大语言模型 @@ -27,7 +28,8 @@ models: description: gpt-3.5-turbo-0125大语言模型,支持多工具调用、智能体思考、流式工具调用,16385上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -42,7 +44,8 @@ models: description: gpt-3.5-turbo-1106大语言模型,支持多工具调用、智能体思考、流式工具调用,16385上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -57,7 +60,8 @@ models: description: gpt-3.5-turbo-16k大语言模型,支持多工具调用、智能体思考、流式工具调用,16385上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -84,7 +88,8 @@ models: description: gpt-3.5-turbo大语言模型,支持多工具调用、智能体思考、流式工具调用,16385上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -99,7 +104,8 @@ models: description: gpt-4-0125-preview大语言模型,支持多工具调用、智能体思考、流式工具调用,128000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -114,7 +120,8 @@ models: description: gpt-4-1106-preview大语言模型,支持多工具调用、智能体思考、流式工具调用,128000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -131,6 +138,7 @@ models: is_official: true capability: - vision + - json_output is_omni: false tags: - 大语言模型 @@ -146,7 +154,8 @@ models: description: gpt-4-turbo-preview大语言模型,支持多工具调用、智能体思考、流式工具调用,128000上下文窗口,对话模式 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -163,6 +172,7 @@ models: is_official: true capability: - vision + - json_output is_omni: false tags: - 大语言模型 @@ -194,6 +204,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -213,6 +224,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -231,6 +243,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -248,6 +261,7 @@ models: is_official: true capability: - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -266,6 +280,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -284,6 +299,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -302,6 +318,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -321,6 +338,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -340,6 +358,7 @@ models: capability: - vision - thinking + - json_output is_omni: false tags: - 大语言模型 diff --git a/api/app/core/models/scripts/volcano_models.yaml b/api/app/core/models/scripts/volcano_models.yaml index c86d41ac..6658c2f9 100644 --- a/api/app/core/models/scripts/volcano_models.yaml +++ b/api/app/core/models/scripts/volcano_models.yaml @@ -11,6 +11,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -26,6 +27,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -41,6 +43,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -56,6 +59,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -72,6 +76,7 @@ models: capability: - vision - video + - json_output is_omni: false tags: - 大语言模型 @@ -87,6 +92,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -102,6 +108,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -117,6 +124,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -132,6 +140,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -148,6 +157,7 @@ models: - vision - video - thinking + - json_output is_omni: false tags: - 大语言模型 @@ -175,7 +185,8 @@ models: description: 全新一代主力模型,性能全面升级,在知识、代码、推理等方面表现卓越。最大支持 128k 上下文窗口,输出长度支持最大 12k tokens。 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 @@ -187,7 +198,8 @@ models: description: 全新一代轻量版模型,极致响应速度,效果与时延均达到全球一流水平。支持 32k 上下文窗口,输出长度支持最大 12k tokens。 is_deprecated: false is_official: true - capability: [] + capability: + - json_output is_omni: false tags: - 大语言模型 diff --git a/api/app/core/workflow/nodes/llm/config.py b/api/app/core/workflow/nodes/llm/config.py index 771262c1..b815c80f 100644 --- a/api/app/core/workflow/nodes/llm/config.py +++ b/api/app/core/workflow/nodes/llm/config.py @@ -116,6 +116,11 @@ class LLMNodeConfig(BaseNodeConfig): description="Top-p 采样参数" ) + json_output: bool = Field( + default=False, + description="是否以 JSON 格式输出" + ) + frequency_penalty: float | None = Field( default=None, ge=-2.0, diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index bb87c845..664a28fa 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -22,6 +22,7 @@ from app.db import get_db_context from app.models import ModelType from app.schemas.model_schema import ModelInfo from app.services.model_service import ModelConfigService +from app.models.models_model import ModelProvider logger = logging.getLogger(__name__) @@ -126,7 +127,11 @@ class LLMNode(BaseNode): # 4. 创建 LLM 实例(使用已提取的数据) # 注意:对于流式输出,需要在模型初始化时设置 streaming=True - extra_params = {"streaming": stream} if stream else {} + extra_params: dict[str, Any] = {"streaming": stream} if stream else {} + if self.typed_config.temperature is not None: + extra_params["temperature"] = self.typed_config.temperature + if self.typed_config.max_tokens is not None: + extra_params["max_tokens"] = self.typed_config.max_tokens llm = RedBearLLM( RedBearModelConfig( @@ -135,7 +140,9 @@ class LLMNode(BaseNode): api_key=model_info.api_key, base_url=model_info.api_base, extra_params=extra_params, - is_omni=model_info.is_omni + is_omni=model_info.is_omni, + capability=model_info.capability, + json_output=self.typed_config.json_output, ), type=model_info.model_type ) @@ -218,6 +225,16 @@ class LLMNode(BaseNode): rendered = self._render_template(prompt_template, variable_pool) self.messages = [{"role": "user", "content": rendered}] + # ChatTongyi 要求 messages 含 'json' 字样才能使用 response_format,在 system prompt 中注入 + if (self.typed_config.json_output + and model_info.provider.lower() == ModelProvider.DASHSCOPE + and not model_info.is_omni): + system_msg = next((m for m in self.messages if m["role"] == "system"), None) + if system_msg: + system_msg["content"] += "\n请以JSON格式输出。" + else: + self.messages.insert(0, {"role": "system", "content": "请以JSON格式输出。"}) + return llm async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> AIMessage: diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 1ed98f68..e93c513d 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -245,6 +245,7 @@ class ModelParameters(BaseModel): stop: Optional[List[str]] = Field(default=None, description="停止序列") deep_thinking: bool = Field(default=False, description="是否启用深度思考模式(需模型支持,如 DeepSeek-R1、QwQ 等)") thinking_budget_tokens: Optional[int] = Field(default=None, ge=1024, le=131072, description="深度思考 token 预算(仅部分模型支持)") + json_output: bool = Field(default=False, description="是否强制 JSON 格式输出(需模型支持 json_output 能力)") class VariableDefinition(BaseModel): diff --git a/api/app/services/app_chat_service.py b/api/app/services/app_chat_service.py index 2d10ed44..56e25713 100644 --- a/api/app/services/app_chat_service.py +++ b/api/app/services/app_chat_service.py @@ -120,6 +120,7 @@ class AppChatService: tools=tools, deep_thinking=model_parameters.get("deep_thinking", False), thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), + json_output=model_parameters.get("json_output", False), capability=api_key_obj.capability or [], ) @@ -392,6 +393,7 @@ class AppChatService: streaming=True, deep_thinking=model_parameters.get("deep_thinking", False), thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), + json_output=model_parameters.get("json_output", False), capability=api_key_obj.capability or [], ) diff --git a/api/app/services/conversation_service.py b/api/app/services/conversation_service.py index 6e9f3544..61744ec7 100644 --- a/api/app/services/conversation_service.py +++ b/api/app/services/conversation_service.py @@ -544,7 +544,7 @@ class ConversationService: api_key=api_key, base_url=api_base, is_omni=is_omni, - support_thinking="thinking" in (capability or []), + capability=capability, ), type=ModelType(model_type) ) diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index b47bd4cd..81457a08 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -597,6 +597,7 @@ class AgentRunService: tools=tools, deep_thinking=effective_params.get("deep_thinking", False), thinking_budget_tokens=effective_params.get("thinking_budget_tokens"), + json_output=effective_params.get("json_output", False), capability=api_key_config.get("capability", []), ) @@ -853,6 +854,7 @@ class AgentRunService: streaming=True, deep_thinking=effective_params.get("deep_thinking", False), thinking_budget_tokens=effective_params.get("thinking_budget_tokens"), + json_output=effective_params.get("json_output", False), capability=api_key_config.get("capability", []), ) diff --git a/api/app/services/llm_router.py b/api/app/services/llm_router.py index 7087415e..bd90eee9 100644 --- a/api/app/services/llm_router.py +++ b/api/app/services/llm_router.py @@ -415,9 +415,11 @@ class LLMRouter: api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, - support_thinking="thinking" in (api_key_config.capability or []), - temperature=0.3, - max_tokens=500 + capability=api_key_config.capability, + extra_params={ + "temperature": 0.3, + "max_tokens": 500 + } ) logger.debug(f"创建 LLM 实例 - Provider: {api_key_config.provider}, Model: {api_key_config.model_name}") diff --git a/api/app/services/master_agent_router.py b/api/app/services/master_agent_router.py index 206443bd..dfb3c2da 100644 --- a/api/app/services/master_agent_router.py +++ b/api/app/services/master_agent_router.py @@ -393,7 +393,7 @@ class MasterAgentRouter: api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, - support_thinking="thinking" in (api_key_config.capability or []), + capability=api_key_config.capability, extra_params = extra_params ) diff --git a/api/app/services/memory_perceptual_service.py b/api/app/services/memory_perceptual_service.py index 7d6d1092..8fa9c9bf 100644 --- a/api/app/services/memory_perceptual_service.py +++ b/api/app/services/memory_perceptual_service.py @@ -233,7 +233,7 @@ class MemoryPerceptualService: api_key=model_config.api_key, base_url=model_config.api_base, is_omni=model_config.is_omni, - support_thinking="thinking" in (model_config.capability or []), + capability=model_config.capability, ) ) return llm, model_config diff --git a/api/app/services/model_parameter_merger.py b/api/app/services/model_parameter_merger.py index 4be83851..6911a9d5 100644 --- a/api/app/services/model_parameter_merger.py +++ b/api/app/services/model_parameter_merger.py @@ -47,7 +47,8 @@ class ModelParameterMerger: "n": 1, "stop": None, "deep_thinking": False, - "thinking_budget_tokens": None + "thinking_budget_tokens": None, + "json_output": False } # 合并参数:默认值 -> 模型配置 -> Agent 配置 diff --git a/api/app/services/model_service.py b/api/app/services/model_service.py index d202b83a..8807020b 100644 --- a/api/app/services/model_service.py +++ b/api/app/services/model_service.py @@ -125,9 +125,11 @@ class ModelConfigService: api_key=api_key, base_url=api_base, is_omni=is_omni, - support_thinking="thinking" in (capability or []), - temperature=0.7, - max_tokens=100 + capability=capability, + extra_params={ + "temperature": 0.7, + "max_tokens": 100 + } ) # 根据模型类型选择不同的验证方式 diff --git a/api/app/services/multi_agent_orchestrator.py b/api/app/services/multi_agent_orchestrator.py index 216aeb6e..d30dc822 100644 --- a/api/app/services/multi_agent_orchestrator.py +++ b/api/app/services/multi_agent_orchestrator.py @@ -2616,9 +2616,11 @@ class MultiAgentOrchestrator: api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, - support_thinking="thinking" in (api_key_config.capability or []), - temperature=0.7, # 整合任务使用中等温度 - max_tokens=2000 + capability=api_key_config.capability, + extra_params={ + "temperature": 0.7, # 整合任务使用中等温度 + "max_tokens": 2000 + } ) # 创建 LLM 实例 @@ -2795,10 +2797,12 @@ class MultiAgentOrchestrator: api_key=api_key_config.api_key, base_url=api_key_config.api_base, is_omni=api_key_config.is_omni, - support_thinking="thinking" in (api_key_config.capability or []), - temperature=0.7, - max_tokens=2000, - extra_params={"streaming": True} # 启用流式输出 + capability=api_key_config.capability, + extra_params={ + "temperature": 0.7, + "max_tokens": 2000, + "streaming": True # 启用流式输出 + } ) # 创建 LLM 实例 diff --git a/api/app/services/prompt_optimizer_service.py b/api/app/services/prompt_optimizer_service.py index 30901111..1686a164 100644 --- a/api/app/services/prompt_optimizer_service.py +++ b/api/app/services/prompt_optimizer_service.py @@ -186,7 +186,7 @@ class PromptOptimizerService: api_key=api_config.api_key, base_url=api_config.api_base, is_omni=api_config.is_omni, - support_thinking="thinking" in (api_config.capability or []), + capability=api_config.capability, ), type=ModelType(model_config.type)) try: prompt_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'prompt') diff --git a/api/app/services/shared_chat_service.py b/api/app/services/shared_chat_service.py index b1e40a2d..37956d77 100644 --- a/api/app/services/shared_chat_service.py +++ b/api/app/services/shared_chat_service.py @@ -250,7 +250,8 @@ class SharedChatService: tools=tools, deep_thinking=model_parameters.get("deep_thinking", False), thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), - capability=api_key_obj.capability or [], + json_output=model_parameters.get("json_output", False), + capability=api_key_obj.capability, ) # 加载历史消息 @@ -455,6 +456,7 @@ class SharedChatService: streaming=True, deep_thinking=model_parameters.get("deep_thinking", False), thinking_budget_tokens=model_parameters.get("thinking_budget_tokens"), + json_output=model_parameters.get("json_output", False), capability=api_key_obj.capability or [], ) diff --git a/api/app/version_info.json b/api/app/version_info.json index d07035e2..a094b64c 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -1,4 +1,40 @@ { + "v0.3.0": { + "introduction": { + "codeName": "破晓", + "releaseDate": "2026-4-15", + "upgradePosition": "🐻 全面升级应用工作流、记忆智能与系统稳健性,引入版本化API、多模态记忆感知及大量工作流增强,打造更可靠、精准的 MemoryBear", + "coreUpgrades": [ + "1. 应用与API增强
* 版本化API调用支持:对外服务API支持指定版本调用
* 工作流检查清单:新增结构化验证步骤
* 深度思考参数精准控制:仅向支持深度推理的模型发送思考参数
* 提示器模型返回优化:优化提示器模型响应处理", + "2. 记忆智能 🧠
* 多模态记忆感知Agent:支持多模态记忆读取与写入
* OpenClaw内置工具:新增内置工具扩展Agent工具集", + "3. 用户体验 🎨
* 流式渲染稳定性优化:解决LLM流式输出页面抖动问题
* 记忆中枢更名:「记忆相关」更名为「记忆中枢」", + "4. 工作流改进 ⚙️
* 三级变量模板转换:支持三级变量解析
* VL模型Token统计:修复模型组合中VL模型Token未统计问题
* 导入工作流功能特性同步:正确同步开场白、引用等属性
* 会话变量名称唯一性校验:防止变量名冲突
* 文件类型提取修复:正确提取file.type信息
* 条件分支显示修复:值为0或会话变量时正确渲染
* Object/Array校验规则:防止JSON序列化错误
* HTTP请求Body字段修正:body字段从name改为key", + "5. 知识库 📚
* Embedding Token截断安全边界:统一添加8000 token截断,优化Excel独立chunk处理", + "6. 稳健性与缺陷修复 🔧
* 原子性更新与批量访问失败修复
* 对话别名提取错误修复
* 工作流别名提取修正(区分用户和AI回复)
* RAG记忆分页数据修复
* 隐式记忆详情显示修复
* 向量查询驱动关闭异常修复
* 用户管理启停异常修复
* 模型列表筛选不一致修复", + "
", + "v0.3.0 标志着 MemoryBear 向生产成熟度迈出坚实一步。后续版本将持续深化工作流表达力、记忆检索精度和跨模态理解能力,强化复杂Agent编排支持,稳固大规模生产部署基础。", + "
", + "MemoryBear — 破晓 🐻✨" + ] + }, + "introduction_en": { + "codeName": "PoXiao", + "releaseDate": "2026-4-15", + "upgradePosition": "🐻 Comprehensive upgrades across application workflows, memory intelligence, and system robustness — introducing versioned APIs, multimodal memory perception, and extensive workflow enhancements for a more reliable MemoryBear", + "coreUpgrades": [ + "1. Application & API Enhancements
* Versioned API Support: External APIs now support version-specific calls
* Workflow Checklist: Structured validation steps before deployment
* Deep Thinking Parameter Control: Only send thinking params to supported models
* Prompt Optimizer Return Optimization: Improved prompt optimizer response handling", + "2. Memory Intelligence 🧠
* Multimodal Memory Perception Agent: Read/write multimodal memory
* OpenClaw Built-in Tool: New built-in tool for agent operations", + "3. User Experience 🎨
* Streaming Render Stabilization: Eliminated page jitter during LLM output
* Memory Hub Renaming: Renamed to better reflect central memory role", + "4. Workflow Improvements ⚙️
* Three-Level Variable Template Conversion: Support for three-level variable resolution
* VL Model Token Tracking: Fixed token tracking for VL models in model groups
* Imported Workflow Feature Sync: Properly sync opening messages, citations, etc.
* Session Variable Name Uniqueness: Prevent variable name conflicts
* File Type Extraction Fix: Correctly extract file.type information
* Condition Branch Display Fix: Correct rendering for value 0 or session variables
* Object/Array Validation Rules: Prevent JSON serialization save errors
* HTTP Request Body Key Fix: Body field uses key instead of name", + "5. Knowledge Base 📚
* Embedding Token Truncation Safety: Unified 8000-token boundary, optimized Excel chunk processing", + "6. Robustness & Bug Fixes 🔧
* Atomic update & batch access failure fixes
* Conversation alias extraction fix
* Workflow alias extraction correction (user vs AI distinction)
* RAG memory pagination fix
* Implicit memory detail display fix
* Vector query driver closed exception fix
* User management enable/disable fix
* Model list filter inconsistency fix", + "
", + "v0.3.0 marks a meaningful step toward production maturity for MemoryBear. Upcoming releases will deepen workflow expressiveness, memory retrieval precision, and cross-modal understanding while strengthening complex agent orchestration and large-scale deployment foundations.", + "
", + "MemoryBear — Daybreak 🐻✨" + ] + } + }, "v0.2.10": { "introduction": { "codeName": "炼剑", From 7ba0726473a0443e482c506514d7ee60b65b4f1c Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Thu, 16 Apr 2026 16:36:15 +0800 Subject: [PATCH 072/113] refactor(model): remove mutual exclusion logic between json_output and deep_thinking --- api/app/core/models/base.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index 90046412..57054da1 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -52,12 +52,6 @@ class RedBearModelConfig(BaseModel): f"模型 {self.model_name} 不支持 JSON 输出(capability 中无 'json_output'),已自动关闭 json_output" ) self.json_output = False - if self.json_output and self.deep_thinking: - logger.warning( - f"模型 {self.model_name} json_output 与 deep_thinking 互斥,已自动关闭 deep_thinking" - ) - self.deep_thinking = False - self.thinking_budget_tokens = None return self From 73fbc197471780a750b5298cb351fb3c3307bd01 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 16 Apr 2026 17:14:30 +0800 Subject: [PATCH 073/113] refactor(memory): switch metadata extraction from full-replace to incremental changes - Replace UserMetadata full-object overwrite with incremental MetadataFieldChange operations (set/remove per field path) - Convert profile.role and profile.domain from scalar strings to lists - Remove UserMetadataBehavioralHints and knowledge_tags fields - Update Jinja2 prompt to instruct LLM to output incremental changes - Update extract_user_metadata_task to apply changes via deep-copy and per-field mutation for proper SQLAlchemy change detection - Minor lint: remove unnecessary f-string prefixes in tasks.py --- api/app/core/memory/models/__init__.py | 4 +- api/app/core/memory/models/metadata_models.py | 40 ++++--- .../metadata_extractor.py | 9 +- .../prompts/extract_user_metadata.jinja2 | 103 ++++++++--------- api/app/tasks.py | 104 ++++++++++++------ 5 files changed, 152 insertions(+), 108 deletions(-) diff --git a/api/app/core/memory/models/__init__.py b/api/app/core/memory/models/__init__.py index eed8e8c4..2a34159b 100644 --- a/api/app/core/memory/models/__init__.py +++ b/api/app/core/memory/models/__init__.py @@ -61,9 +61,9 @@ from app.core.memory.models.triplet_models import ( # User metadata models from app.core.memory.models.metadata_models import ( UserMetadata, - UserMetadataBehavioralHints, UserMetadataProfile, MetadataExtractionResponse, + MetadataFieldChange, ) # Ontology scenario models (LLM extracted from scenarios) @@ -133,9 +133,9 @@ __all__ = [ "Triplet", "TripletExtractionResponse", "UserMetadata", - "UserMetadataBehavioralHints", "UserMetadataProfile", "MetadataExtractionResponse", + "MetadataFieldChange", # Ontology models "OntologyClass", "OntologyExtractionResponse", diff --git a/api/app/core/memory/models/metadata_models.py b/api/app/core/memory/models/metadata_models.py index 55c2359e..1b490dbc 100644 --- a/api/app/core/memory/models/metadata_models.py +++ b/api/app/core/memory/models/metadata_models.py @@ -4,7 +4,7 @@ Independent from triplet_models.py - these models are used by the standalone metadata extraction pipeline (post-dedup async Celery task). """ -from typing import List +from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -13,8 +13,8 @@ class UserMetadataProfile(BaseModel): """用户画像信息""" model_config = ConfigDict(extra="ignore") - role: str = Field(default="", description="用户职业或角色") - domain: str = Field(default="", description="用户所在领域") + role: List[str] = Field(default_factory=list, description="用户职业或角色") + domain: List[str] = Field(default_factory=list, description="用户所在领域") expertise: List[str] = Field( default_factory=list, description="用户擅长的技能或工具" ) @@ -23,31 +23,37 @@ class UserMetadataProfile(BaseModel): ) -class UserMetadataBehavioralHints(BaseModel): - """行为偏好""" - - model_config = ConfigDict(extra="ignore") - learning_stage: str = Field(default="", description="学习阶段") - preferred_depth: str = Field(default="", description="偏好深度") - tone_preference: str = Field(default="", description="语气偏好") - - class UserMetadata(BaseModel): """用户元数据顶层结构""" model_config = ConfigDict(extra="ignore") profile: UserMetadataProfile = Field(default_factory=UserMetadataProfile) - behavioral_hints: UserMetadataBehavioralHints = Field( - default_factory=UserMetadataBehavioralHints + + +class MetadataFieldChange(BaseModel): + """单个元数据字段的变更操作""" + + model_config = ConfigDict(extra="ignore") + field_path: str = Field( + description="字段路径,用点号分隔,如 'profile.role'、'knowledge_tags'、'behavioral_hints.tone_preference'" + ) + action: str = Field( + description="操作类型:'set' 表示新增或修改,'remove' 表示移除" + ) + value: Optional[str] = Field( + default=None, + description="字段的新值(action='set' 时必填)。标量字段直接填值,列表字段填单个要新增的元素" ) - knowledge_tags: List[str] = Field(default_factory=list, description="知识标签") class MetadataExtractionResponse(BaseModel): - """元数据提取 LLM 响应结构""" + """元数据提取 LLM 响应结构(增量模式)""" model_config = ConfigDict(extra="ignore") - user_metadata: UserMetadata = Field(default_factory=UserMetadata) + metadata_changes: List[MetadataFieldChange] = Field( + default_factory=list, + description="元数据的增量变更列表,每项描述一个字段的新增、修改或移除操作", + ) aliases_to_add: List[str] = Field( default_factory=list, description="本次新发现的用户别名(用户自我介绍或他人对用户的称呼)", diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/metadata_extractor.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/metadata_extractor.py index 19f1e533..29f4e85b 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/metadata_extractor.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/metadata_extractor.py @@ -118,7 +118,7 @@ class MetadataExtractor: existing_aliases: Optional[List[str]] = None, ) -> Optional[tuple]: """ - 对筛选后的 statement 列表调用 LLM 提取元数据和用户别名。 + 对筛选后的 statement 列表调用 LLM 提取元数据增量变更和用户别名。 Args: statements: 用户发言的 statement 文本列表 @@ -126,7 +126,8 @@ class MetadataExtractor: existing_aliases: 数据库已有的用户别名列表(可选) Returns: - (UserMetadata, List[str], List[str]) tuple: (metadata, aliases_to_add, aliases_to_remove) on success, None on failure + (List[MetadataFieldChange], List[str], List[str]) tuple: + (metadata_changes, aliases_to_add, aliases_to_remove) on success, None on failure """ if not statements: return None @@ -160,12 +161,12 @@ class MetadataExtractor: ) if response: - metadata = response.user_metadata if response.user_metadata else None + changes = response.metadata_changes if response.metadata_changes else [] to_add = response.aliases_to_add if response.aliases_to_add else [] to_remove = ( response.aliases_to_remove if response.aliases_to_remove else [] ) - return metadata, to_add, to_remove + return changes, to_add, to_remove logger.warning("LLM 返回的响应为空") return None diff --git a/api/app/core/memory/utils/prompt/prompts/extract_user_metadata.jinja2 b/api/app/core/memory/utils/prompt/prompts/extract_user_metadata.jinja2 index 5d019b12..1c32d369 100644 --- a/api/app/core/memory/utils/prompt/prompts/extract_user_metadata.jinja2 +++ b/api/app/core/memory/utils/prompt/prompts/extract_user_metadata.jinja2 @@ -1,5 +1,5 @@ ===Task=== -Extract user metadata from the following conversation statements spoken by the user. +Extract user metadata changes from the following conversation statements spoken by the user. {% if language == "zh" %} **"三度原则"判断标准:** @@ -10,28 +10,36 @@ Extract user metadata from the following conversation statements spoken by the u **提取规则:** - **只提取关于"用户本人"的画像信息**,忽略用户提到的第三方人物(如朋友、同事、家人)的信息 - 仅提取文本中明确提到的信息,不要推测 -- 如果文本中没有可提取的用户画像信息,返回空的 user_metadata 对象 - **输出语言必须与输入文本的语言一致**(输入中文则输出中文值,输入英文则输出英文值) +**增量模式(重要):** +你只需要输出**本次对话引起的变更操作**,不要输出完整的元数据。每个变更是一个对象,包含: +- `field_path`:字段路径,用点号分隔(如 `profile.role`、`profile.expertise`) +- `action`:操作类型 + * `set`:新增或修改一个字段的值 + * `remove`:移除一个字段的值 +- `value`:字段的新值(`action="set"` 时必填,`action="remove"` 时填要移除的元素值) + * 所有字段均为列表类型,每个元素一条变更记录 + +**判断规则:** +- 用户提到新信息 → `action="set"`,填入新值 +- 用户明确否定已有信息(如"我不再做老师了"、"我已经不学Python了")→ `action="remove"`,`value` 填要移除的元素值 +- 如果本次对话没有任何可提取的变更,返回空的 `metadata_changes` 数组 `[]` +- **不要为未被提及的字段生成任何变更操作** + {% if existing_metadata %} -**重要:合并已有元数据** -下方提供了数据库中已有的用户元数据。请结合用户最新发言,输出**合并后的完整元数据**: -- 如果用户明确否定了已有信息(如"我不再教高中物理了"),在输出中**移除**该信息 -- 如果用户提到了新信息,**添加**到对应字段中 -- 如果已有信息未被用户否定,**保留**在输出中 -- 标量字段(如 role、domain):如果用户提到了新值,用新值替换;否则保留已有值 -- 最终输出应该是完整的、合并后的元数据,不是增量 +**已有元数据(仅供参考,用于判断是否需要变更):** +请对比已有数据和用户最新发言,只输出差异部分的变更操作。 +- 如果用户说的信息和已有数据一致,不需要输出变更 +- 如果用户否定了已有数据中的某个值,输出 `remove` 操作 +- 如果用户提到了新信息,输出 `set` 操作 {% endif %} **字段说明:** -- profile.role:用户的职业或角色,如 教师、医生、后端工程师 -- profile.domain:用户所在领域,如 教育、医疗、软件开发 -- profile.expertise:用户擅长的技能或工具(通用,不限于编程),如 Python、心理咨询、高中物理 -- profile.interests:用户主动表达兴趣的话题或领域标签 -- behavioral_hints.learning_stage:学习阶段(初学者/中级/高级) -- behavioral_hints.preferred_depth:偏好深度(概览/技术细节/深入探讨) -- behavioral_hints.tone_preference:语气偏好(轻松随意/专业简洁/学术严谨) -- knowledge_tags:用户涉及的知识领域标签 +- profile.role:用户的职业或角色(列表),如 教师、医生、后端工程师,一个人可以有多个角色 +- profile.domain:用户所在领域(列表),如 教育、医疗、软件开发,一个人可以涉及多个领域 +- profile.expertise:用户擅长的技能或工具(列表),如 Python、心理咨询、高中物理 +- profile.interests:用户主动表达兴趣的话题或领域标签(列表) **用户别名变更(增量模式):** - **aliases_to_add**:本次新发现的用户别名,包括: @@ -43,7 +51,6 @@ Extract user metadata from the following conversation statements spoken by the u - **aliases_to_remove**:用户明确否认的别名,包括: * 用户说"我不叫XX了"、"别叫我XX"、"我改名了,不叫XX" → 将 XX 放入此数组 * **严格限制**:只将用户原文中**逐字提到**的被否认名字放入,不要推断关联的其他别名 - * 例如:用户说"我不叫陈小刀了" → 只移除"陈小刀",不要移除"陈哥"、"老陈"等未被提及的别名 * 如果没有要移除的别名,返回空数组 `[]` {% if existing_aliases %} - 已有别名:{{ existing_aliases | tojson }}(仅供参考,不需要在输出中重复) @@ -57,28 +64,36 @@ Extract user metadata from the following conversation statements spoken by the u **Extraction rules:** - **Only extract profile information about the user themselves**, ignore information about third parties (friends, colleagues, family) mentioned by the user - Only extract information explicitly mentioned in the text, do not speculate -- If no user profile information can be extracted, return an empty user_metadata object - **Output language must match the input text language** +**Incremental mode (important):** +You should only output **the change operations caused by this conversation**, not the complete metadata. Each change is an object containing: +- `field_path`: Field path separated by dots (e.g. `profile.role`, `profile.expertise`) +- `action`: Operation type + * `set`: Add or update a field value + * `remove`: Remove a field value +- `value`: The new value for the field (required when `action="set"`, for `action="remove"` fill in the element value to remove) + * All fields are list types, one change record per element + +**Decision rules:** +- User mentions new information → `action="set"`, fill in the new value +- User explicitly negates existing info (e.g. "I'm no longer a teacher", "I stopped learning Python") → `action="remove"`, `value` is the element to remove +- If this conversation has no extractable changes, return an empty `metadata_changes` array `[]` +- **Do NOT generate any change operations for fields not mentioned in the conversation** + {% if existing_metadata %} -**Important: Merge with existing metadata** -Existing user metadata from the database is provided below. Combine with the user's latest statements to output the **complete merged metadata**: -- If the user explicitly negates existing info (e.g. "I no longer teach high school physics"), **remove** it from output -- If the user mentions new info, **add** it to the corresponding field -- If existing info is not negated by the user, **keep** it in the output -- Scalar fields (e.g. role, domain): replace with new value if user mentions one; otherwise keep existing -- The final output should be the complete, merged metadata — not an incremental update +**Existing metadata (for reference only, to determine if changes are needed):** +Compare existing data with the user's latest statements, and only output change operations for the differences. +- If the user's statement matches existing data, no change is needed +- If the user negates a value in existing data, output a `remove` operation +- If the user mentions new information, output a `set` operation {% endif %} **Field descriptions:** -- profile.role: User's occupation or role, e.g. teacher, doctor, software engineer -- profile.domain: User's domain, e.g. education, healthcare, software development -- profile.expertise: User's skills or tools (general, not limited to programming) -- profile.interests: Topics or domain tags the user actively expressed interest in -- behavioral_hints.learning_stage: Learning stage (beginner/intermediate/advanced) -- behavioral_hints.preferred_depth: Preferred depth (overview/detailed/deep dive) -- behavioral_hints.tone_preference: Tone preference (casual/professional/academic) -- knowledge_tags: Knowledge domain tags related to the user +- profile.role: User's occupation or role (list), e.g. teacher, doctor, software engineer. A person can have multiple roles +- profile.domain: User's domain (list), e.g. education, healthcare, software development. A person can span multiple domains +- profile.expertise: User's skills or tools (list), e.g. Python, counseling, physics +- profile.interests: Topics or domain tags the user actively expressed interest in (list) **User alias changes (incremental mode):** - **aliases_to_add**: Newly discovered user aliases from this conversation, including: @@ -90,7 +105,6 @@ Existing user metadata from the database is provided below. Combine with the use - **aliases_to_remove**: Aliases the user explicitly denies, including: * User says "Don't call me XX anymore", "I'm not called XX", "I changed my name from XX" → put XX in this array * **Strict rule**: Only include the exact name the user **verbatim mentions** as denied. Do NOT infer or remove related aliases - * Example: User says "I'm not called John anymore" → only remove "John", do NOT remove "Johnny", "J" or other related aliases not mentioned * If no aliases to remove, return empty array `[]` {% if existing_aliases %} - Existing aliases: {{ existing_aliases | tojson }} (for reference only, do not repeat in output) @@ -113,20 +127,11 @@ Existing user metadata from the database is provided below. Combine with the use Return a JSON object with the following structure: ```json { - "user_metadata": { - "profile": { - "role": "", - "domain": "", - "expertise": [], - "interests": [] - }, - "behavioral_hints": { - "learning_stage": "", - "preferred_depth": "", - "tone_preference": "" - }, - "knowledge_tags": [] - }, + "metadata_changes": [ + {"field_path": "profile.role", "action": "set", "value": "后端工程师"}, + {"field_path": "profile.expertise", "action": "set", "value": "Python"}, + {"field_path": "profile.expertise", "action": "remove", "value": "Java"} + ], "aliases_to_add": [], "aliases_to_remove": [] } diff --git a/api/app/tasks.py b/api/app/tasks.py index 5a71066a..2c2f50a9 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -455,7 +455,7 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): db_knowledge = db.query(Knowledge).filter(Knowledge.id == kb_id).first() if db_knowledge is None: logger.error(f"[GraphRAG-KB] knowledge={kb_id} not found") - return f"build knowledge graph failed: knowledge not found" + return "build knowledge graph failed: knowledge not found" if not (db_knowledge.parser_config and db_knowledge.parser_config.get("graphrag", {}).get("use_graphrag", False)): @@ -538,7 +538,7 @@ def build_graphrag_for_document(document_id: str, knowledge_id: str): db_knowledge = db.query(Knowledge).filter(Knowledge.id == uuid.UUID(knowledge_id)).first() if db_document is None or db_knowledge is None: logger.error(f"[GraphRAG] document={document_id} or knowledge={knowledge_id} not found") - return f"build_graphrag_for_document failed: record not found" + return "build_graphrag_for_document failed: record not found" graphrag_conf = db_knowledge.parser_config.get("graphrag", {}) with_resolution = graphrag_conf.get("resolution", False) @@ -617,7 +617,7 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID): db_knowledge = db.query(Knowledge).filter(Knowledge.id == kb_id).first() if db_knowledge is None: logger.error(f"[SyncKB] knowledge={kb_id} not found") - return f"sync knowledge failed: knowledge not found" + return "sync knowledge failed: knowledge not found" # 1. get vector_service vector_service = ElasticSearchVectorFactory().init_vector(knowledge=db_knowledge) @@ -3102,29 +3102,11 @@ def extract_user_metadata_task( logger.info(f"[CELERY METADATA] No metadata extracted for end_user_id={end_user_id}") return {"status": "SUCCESS", "result": "no_metadata_extracted"} - user_metadata, aliases_to_add, aliases_to_remove = extract_result - logger.info(f"[CELERY METADATA] LLM 别名新增: {aliases_to_add}, 移除: {aliases_to_remove}") - - # 4. 清洗元数据、覆盖写入元数据和别名 - def clean_metadata(raw: dict) -> dict: - """递归移除空字符串、空列表、空字典。""" - result = {} - for k, v in raw.items(): - if v == "" or v == []: - continue - if isinstance(v, dict): - cleaned = clean_metadata(v) - if cleaned: - result[k] = cleaned - else: - result[k] = v - return result - - raw_dict = user_metadata.model_dump(exclude_none=True) if user_metadata else {} - logger.info(f"[CELERY METADATA] LLM 输出完整元数据: {json.dumps(raw_dict, ensure_ascii=False)}") - - cleaned = clean_metadata(raw_dict) if raw_dict else {} - logger.info(f"[CELERY METADATA] 清洗后元数据: {json.dumps(cleaned, ensure_ascii=False)}") + metadata_changes, aliases_to_add, aliases_to_remove = extract_result + logger.info( + f"[CELERY METADATA] LLM 元数据变更: {[c.model_dump() for c in metadata_changes]}, " + f"别名新增: {aliases_to_add}, 移除: {aliases_to_remove}" + ) from datetime import datetime as dt, timezone as tz now = dt.now(tz.utc).isoformat() @@ -3152,15 +3134,50 @@ def extract_user_metadata_task( end_user = EndUserRepository(db).get_by_id(end_user_uuid) if info: - # 元数据覆盖写入 - if cleaned: - existing_meta = info.meta_data if info.meta_data else {} + # 4. 元数据增量更新(按 LLM 输出的变更操作逐条执行,所有字段均为列表类型) + if metadata_changes: + # 深拷贝,确保 SQLAlchemy 能检测到变更 + import copy + existing_meta = copy.deepcopy(info.meta_data) if info.meta_data else {} updated_at = dict(existing_meta.get("_updated_at", {})) - _update_timestamps(existing_meta, cleaned, updated_at, now) - final = dict(cleaned) - final["_updated_at"] = updated_at - info.meta_data = final - logger.info("[CELERY METADATA] 覆盖写入元数据") + + for change in metadata_changes: + field_path = change.field_path + action = change.action + value = change.value + + if not value or not value.strip(): + continue + + # 定位到目标字段的父级节点 + parts = field_path.split(".") + target = existing_meta + for part in parts[:-1]: + target = target.setdefault(part, {}) + leaf = parts[-1] + + current_list = target.get(leaf, []) + if not isinstance(current_list, list): + current_list = [] + + if action == "set": + if value not in current_list: + current_list.append(value) + target[leaf] = current_list + logger.info(f"[CELERY METADATA] set {field_path} = {value}") + + elif action == "remove": + if value in current_list: + current_list.remove(value) + target[leaf] = current_list + logger.info(f"[CELERY METADATA] remove {value} from {field_path}") + + updated_at[field_path] = now + + existing_meta["_updated_at"] = updated_at + # 赋值深拷贝后的新对象,SQLAlchemy 会检测到字段变更并写入 + info.meta_data = existing_meta + logger.info(f"[CELERY METADATA] 增量更新元数据完成: {json.dumps(existing_meta, ensure_ascii=False)}") # 别名增量增删:(已有 - remove) + add old_aliases = info.aliases if info.aliases else [] @@ -3196,12 +3213,27 @@ def extract_user_metadata_task( from app.models.end_user_info_model import EndUserInfo initial_aliases = filtered_add # 新记录只有 add,没有 remove first_alias = initial_aliases[0] if initial_aliases else "" - if first_alias or cleaned: + + # 从变更操作构建初始元数据(所有字段均为列表类型) + initial_meta = {} + for change in metadata_changes: + if change.action == "set" and change.value is not None and change.value.strip(): + parts = change.field_path.split(".") + target = initial_meta + for part in parts[:-1]: + target = target.setdefault(part, {}) + leaf = parts[-1] + current_list = target.get(leaf, []) + if change.value not in current_list: + current_list.append(change.value) + target[leaf] = current_list + + if first_alias or initial_meta: new_info = EndUserInfo( end_user_id=end_user_uuid, other_name=first_alias or "", aliases=initial_aliases, - meta_data=cleaned if cleaned else None, + meta_data=initial_meta if initial_meta else None, ) db.add(new_info) if end_user and first_alias and ( From 643f69bb907d1dcb80aa0518550c3ede4782235c Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 16 Apr 2026 17:29:00 +0800 Subject: [PATCH 074/113] refactor(memory): tighten metadata field types and clean up descriptions - Use Literal['set', 'remove'] for MetadataFieldChange.action instead of str - Simplify field_path description to reflect current schema - Remove redundant isinstance check in extract_user_metadata_task --- api/app/core/memory/models/metadata_models.py | 6 +++--- api/app/tasks.py | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/api/app/core/memory/models/metadata_models.py b/api/app/core/memory/models/metadata_models.py index 1b490dbc..e12c3d97 100644 --- a/api/app/core/memory/models/metadata_models.py +++ b/api/app/core/memory/models/metadata_models.py @@ -4,7 +4,7 @@ Independent from triplet_models.py - these models are used by the standalone metadata extraction pipeline (post-dedup async Celery task). """ -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field @@ -35,9 +35,9 @@ class MetadataFieldChange(BaseModel): model_config = ConfigDict(extra="ignore") field_path: str = Field( - description="字段路径,用点号分隔,如 'profile.role'、'knowledge_tags'、'behavioral_hints.tone_preference'" + description="字段路径,用点号分隔,如 'profile.role'、'profile.expertise'" ) - action: str = Field( + action: Literal["set", "remove"] = Field( description="操作类型:'set' 表示新增或修改,'remove' 表示移除" ) value: Optional[str] = Field( diff --git a/api/app/tasks.py b/api/app/tasks.py index 2c2f50a9..6d2e07a3 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -3157,8 +3157,6 @@ def extract_user_metadata_task( leaf = parts[-1] current_list = target.get(leaf, []) - if not isinstance(current_list, list): - current_list = [] if action == "set": if value not in current_list: From ccdf7ae81da4107fbd5eb2d4686b2ebd2b62fe11 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Thu, 16 Apr 2026 17:40:30 +0800 Subject: [PATCH 075/113] refactor(model): replace VolcanoChatOpenAI with CompatibleChatOpenAI for unified omni model support --- api/app/core/models/base.py | 8 ++++---- .../core/models/{volcano_chat.py => compatible_chat.py} | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) rename api/app/core/models/{volcano_chat.py => compatible_chat.py} (92%) diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index 57054da1..7b570b47 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -14,7 +14,7 @@ from pydantic import BaseModel, Field, model_validator from app.core.error_codes import BizCode from app.core.exceptions import BusinessException from app.models.models_model import ModelProvider, ModelType -from app.core.models.volcano_chat import VolcanoChatOpenAI +from app.core.models.compatible_chat import CompatibleChatOpenAI T = TypeVar("T") @@ -255,11 +255,11 @@ def get_provider_llm_class(config: RedBearModelConfig, type: ModelType = ModelTy """根据模型提供商获取对应的模型类""" provider = config.provider.lower() - # dashscope 的 omni 模型使用 OpenAI 兼容模式 + # dashscope的omni模型 和 volcano模型使用 if provider == ModelProvider.DASHSCOPE and config.is_omni: - return ChatOpenAI + return CompatibleChatOpenAI if provider == ModelProvider.VOLCANO: - return VolcanoChatOpenAI + return CompatibleChatOpenAI if provider in [ModelProvider.OPENAI, ModelProvider.XINFERENCE, ModelProvider.GPUSTACK]: if type == ModelType.LLM: return OpenAI diff --git a/api/app/core/models/volcano_chat.py b/api/app/core/models/compatible_chat.py similarity index 92% rename from api/app/core/models/volcano_chat.py rename to api/app/core/models/compatible_chat.py index d9a51d13..114a3567 100644 --- a/api/app/core/models/volcano_chat.py +++ b/api/app/core/models/compatible_chat.py @@ -12,8 +12,8 @@ from langchain_core.outputs import ChatGenerationChunk, ChatResult from langchain_openai import ChatOpenAI -class VolcanoChatOpenAI(ChatOpenAI): - """火山引擎 Chat 模型,支持深度思考内容(reasoning_content)的流式和非流式透传。""" +class CompatibleChatOpenAI(ChatOpenAI): + """火山和千问的omni兼容模型,支持深度思考内容(reasoning_content)的流式和非流式透传。""" def _create_chat_result(self, response: Union[dict, Any], generation_info: Optional[dict] = None) -> ChatResult: result = super()._create_chat_result(response, generation_info) From 62edafcebe7e268063596194b3ae71eb1b612781 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Thu, 16 Apr 2026 17:43:23 +0800 Subject: [PATCH 076/113] ci(workflow): add PR number and merge commit SHA to WeChat release notification - Add PR_NUMBER environment variable to capture pull request number - Add MERGE_SHA environment variable to capture merge commit SHA - Extract short SHA (first 7 characters) from merge commit for display - Update notification content to include PR number with # prefix - Update notification content to include short commit SHA - Improve release notification with additional metadata for better traceability --- .github/workflows/release-notify-wechat.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index bc67518b..935d84d5 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -121,6 +121,8 @@ jobs: AUTHOR: ${{ github.event.pull_request.user.login }} PR_TITLE: ${{ github.event.pull_request.title }} PR_URL: ${{ github.event.pull_request.html_url }} + PR_NUMBER: ${{ github.event.pull_request.number }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} SOURCERY_FOUND: ${{ steps.sourcery.outputs.found }} SOURCERY_SUMMARY: ${{ steps.sourcery.outputs.summary }} QWEN_SUMMARY: ${{ steps.qwen.outputs.summary }} @@ -135,11 +137,16 @@ jobs: label = "AI变更摘要" summary = os.environ.get("QWEN_SUMMARY", "AI 摘要生成失败") + pr_number = os.environ.get("PR_NUMBER", "") + short_sha = os.environ.get("MERGE_SHA", "")[:7] + content = ( "## 🚀 Release 发布通知\n" - "> 📦 **分支**: " + os.environ["BRANCH"] + "\n" + "> � **分支**: " + os.environ["BRANCH"] + "\n" "> 👤 **提交人**: " + os.environ["AUTHOR"] + "\n" - "> 📝 **标题**: " + os.environ["PR_TITLE"] + "\n\n" + "> 📝 **标题**: " + os.environ["PR_TITLE"] + "\n" + "> 🔢 **PR编号**: #" + pr_number + "\n" + "> 🔖 **Commit**: " + short_sha + "\n\n" "### 🧠 " + label + "\n" + summary + "\n\n" "---\n" From e1f8ad871b4c78b29177cc9df837228ebfbc8504 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Thu, 16 Apr 2026 17:47:47 +0800 Subject: [PATCH 077/113] refactor(model): replace qwen-vl-plus-latest with json_output capability in dashscope_models.yaml --- api/app/core/models/scripts/dashscope_models.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/core/models/scripts/dashscope_models.yaml b/api/app/core/models/scripts/dashscope_models.yaml index 26c3cc2c..9b45f107 100644 --- a/api/app/core/models/scripts/dashscope_models.yaml +++ b/api/app/core/models/scripts/dashscope_models.yaml @@ -461,7 +461,7 @@ models: capability: - vision - video - - qwen-vl-plus-latest + - json_output is_omni: false tags: - 大语言模型 @@ -480,7 +480,7 @@ models: capability: - vision - video - - qwen-vl-plus-latest + - json_output is_omni: false tags: - 大语言模型 From 915cb54f2130cb8b0479d7508763cda0f42b27b6 Mon Sep 17 00:00:00 2001 From: wxy Date: Thu, 16 Apr 2026 17:54:50 +0800 Subject: [PATCH 078/113] feat(tenant): add public subscription plan list endpoint and enhance plan information Add a public subscription plan list endpoint that can be accessed without authentication. Enhance the returned subscription plan information fields, including multi-language support and default free plan fallback logic. Additionally, implement automatic model binding for the knowledge base service. --- api/app/config/default_free_plan.py | 5 + api/app/controllers/__init__.py | 1 + .../tenant_subscription_controller.py | 95 ++++++++++++++++++- api/app/services/knowledge_service.py | 51 +++++++++- 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/api/app/config/default_free_plan.py b/api/app/config/default_free_plan.py index 23a3a10e..4b357236 100644 --- a/api/app/config/default_free_plan.py +++ b/api/app/config/default_free_plan.py @@ -5,6 +5,7 @@ DEFAULT_FREE_PLAN = { "name": "记忆体验版", + "name_en": "Memory Experience", "category": "saas_personal", "tier_level": 0, "version": "1.0", @@ -12,9 +13,13 @@ DEFAULT_FREE_PLAN = { "price": 0, "billing_cycle": "permanent_free", "core_value": "感受永久记忆", + "core_value_en": "Experience Permanent Memory", "tech_support": "社群交流", + "tech_support_en": "Community Support", "sla_compliance": "无", + "sla_compliance_en": "None", "page_customization": "无", + "page_customization_en": "None", "theme_color": "#64748B", "quotas": { "workspace_quota": 1, diff --git a/api/app/controllers/__init__.py b/api/app/controllers/__init__.py index 377205c4..e9417d68 100644 --- a/api/app/controllers/__init__.py +++ b/api/app/controllers/__init__.py @@ -100,5 +100,6 @@ manager_router.include_router(ontology_controller.router) manager_router.include_router(skill_controller.router) manager_router.include_router(i18n_controller.router) manager_router.include_router(tenant_subscription_controller.router) +manager_router.include_router(tenant_subscription_controller.public_router) __all__ = ["manager_router"] diff --git a/api/app/controllers/tenant_subscription_controller.py b/api/app/controllers/tenant_subscription_controller.py index c3fde572..141d48a8 100644 --- a/api/app/controllers/tenant_subscription_controller.py +++ b/api/app/controllers/tenant_subscription_controller.py @@ -2,7 +2,7 @@ 租户套餐查询接口(普通用户可访问) """ import datetime -from typing import Callable +from typing import Callable, Optional from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse @@ -19,6 +19,7 @@ from app.schemas.response_schema import ApiResponse logger = get_api_logger() router = APIRouter(prefix="/tenant", tags=["Tenant"]) +public_router = APIRouter(tags=["Tenant"]) @router.get("/subscription", response_model=ApiResponse, summary="获取当前用户所属租户的套餐信息") @@ -42,7 +43,41 @@ async def get_my_tenant_subscription( sub = svc.get_subscription(tenant_id) if not sub: - return success(data=None, msg="暂无有效套餐") + # 无订阅记录时,兜底返回免费套餐信息 + free_plan = svc.plan_repo.get_free_plan() + if not free_plan: + return success(data=None, msg="暂无有效套餐") + return success(data={ + "subscription_id": None, + "tenant_id": str(tenant_id), + "package_plan_id": str(free_plan.id), + "package_version": free_plan.version, + "package_plan": { + "id": str(free_plan.id), + "name": free_plan.name, + "name_en": free_plan.name_en, + "version": free_plan.version, + "category": free_plan.category, + "tier_level": free_plan.tier_level, + "price": float(free_plan.price) if free_plan.price is not None else 0.0, + "billing_cycle": free_plan.billing_cycle, + "core_value": free_plan.core_value, + "core_value_en": free_plan.core_value_en, + "tech_support": free_plan.tech_support, + "tech_support_en": free_plan.tech_support_en, + "sla_compliance": free_plan.sla_compliance, + "sla_compliance_en": free_plan.sla_compliance_en, + "page_customization": free_plan.page_customization, + "page_customization_en": free_plan.page_customization_en, + "theme_color": free_plan.theme_color, + }, + "started_at": None, + "expired_at": None, + "status": "active", + "quota": free_plan.quotas or {}, + "created_at": int(datetime.datetime.utcnow().timestamp() * 1000), + "updated_at": int(datetime.datetime.utcnow().timestamp() * 1000), + }, msg="免费套餐") return success(data=svc.build_response(sub)) @@ -62,11 +97,21 @@ async def get_my_tenant_subscription( "package_plan": { "id": None, "name": plan["name"], + "name_en": plan.get("name_en"), "version": plan["version"], "category": plan["category"], "tier_level": plan["tier_level"], "price": float(plan["price"]), "billing_cycle": plan["billing_cycle"], + "core_value": plan.get("core_value"), + "core_value_en": plan.get("core_value_en"), + "tech_support": plan.get("tech_support"), + "tech_support_en": plan.get("tech_support_en"), + "sla_compliance": plan.get("sla_compliance"), + "sla_compliance_en": plan.get("sla_compliance_en"), + "page_customization": plan.get("page_customization"), + "page_customization_en": plan.get("page_customization_en"), + "theme_color": plan.get("theme_color"), }, "started_at": None, "expired_at": None, @@ -80,3 +125,49 @@ async def get_my_tenant_subscription( except Exception as e: logger.error(f"获取租户套餐信息失败: {e}", exc_info=True) return JSONResponse(status_code=500, content=fail(code=500, msg="获取套餐信息失败")) + + +@public_router.get("/package-plans", response_model=ApiResponse, summary="获取套餐列表(公开)") +async def list_package_plans_public( + category: Optional[str] = None, + status: Optional[bool] = None, + search: Optional[str] = None, + db: Session = Depends(get_db), +): + """ + 公开接口,无需鉴权。 + SaaS 版从数据库读取套餐列表;社区版降级返回 default_free_plan.py 中的免费套餐。 + """ + try: + from premium.platform_admin.package_plan_service import PackagePlanService + from premium.platform_admin.package_plan_schema import PackagePlanResponse + svc = PackagePlanService(db) + result = svc.get_list(page=1, size=9999, category=category, status=status, search=search) + return success(data=[PackagePlanResponse.model_validate(p).model_dump(mode="json") for p in result["items"]]) + except ModuleNotFoundError: + from app.config.default_free_plan import DEFAULT_FREE_PLAN + plan = DEFAULT_FREE_PLAN + return success(data=[{ + "id": None, + "name": plan["name"], + "name_en": plan.get("name_en"), + "version": plan["version"], + "category": plan["category"], + "tier_level": plan["tier_level"], + "price": float(plan["price"]), + "billing_cycle": plan["billing_cycle"], + "core_value": plan.get("core_value"), + "core_value_en": plan.get("core_value_en"), + "tech_support": plan.get("tech_support"), + "tech_support_en": plan.get("tech_support_en"), + "sla_compliance": plan.get("sla_compliance"), + "sla_compliance_en": plan.get("sla_compliance_en"), + "page_customization": plan.get("page_customization"), + "page_customization_en": plan.get("page_customization_en"), + "theme_color": plan.get("theme_color"), + "status": plan.get("status", True), + "quota": plan["quotas"], + }]) + except Exception as e: + logger.error(f"获取套餐列表失败: {e}", exc_info=True) + return JSONResponse(status_code=500, content=fail(code=500, msg="获取套餐列表失败")) diff --git a/api/app/services/knowledge_service.py b/api/app/services/knowledge_service.py index bac02e96..46108efd 100644 --- a/api/app/services/knowledge_service.py +++ b/api/app/services/knowledge_service.py @@ -2,11 +2,14 @@ import uuid from sqlalchemy.orm import Session from app.models.user_model import User from app.models.knowledge_model import Knowledge +from app.models.workspace_model import Workspace +from app.models.models_model import ModelConfig from app.schemas.knowledge_schema import KnowledgeCreate, KnowledgeUpdate from app.repositories import knowledge_repository from app.core.logging_config import get_business_logger +from app.repositories.model_repository import ModelConfigRepository +from app.models.models_model import ModelType -# Obtain a dedicated logger for business logic business_logger = get_business_logger() @@ -60,13 +63,57 @@ def create_knowledge( db: Session, knowledge: KnowledgeCreate, current_user: User ) -> Knowledge: business_logger.info(f"Create a knowledge base: {knowledge.name}, creator: {current_user.username}") - + try: knowledge.created_by = current_user.id if knowledge.workspace_id is None: knowledge.workspace_id = current_user.current_workspace_id if knowledge.parent_id is None: knowledge.parent_id = knowledge.workspace_id + + workspace = db.query(Workspace).filter(Workspace.id == knowledge.workspace_id).first() + if not workspace: + raise Exception(f"Workspace {knowledge.workspace_id} not found") + + tenant_id = workspace.tenant_id + + if not knowledge.embedding_id: + embedding_models = ModelConfigRepository.get_by_type( + db=db, model_types=[ModelType.EMBEDDING], tenant_id=tenant_id, is_active=True + ) + if embedding_models: + knowledge.embedding_id = embedding_models[0].id + business_logger.debug(f"Auto-bind embedding model: {embedding_models[0].id}") + + if not knowledge.reranker_id: + rerank_models = ModelConfigRepository.get_by_type( + db=db, model_types=[ModelType.RERANK], tenant_id=tenant_id, is_active=True + ) + if rerank_models: + knowledge.reranker_id = rerank_models[0].id + business_logger.debug(f"Auto-bind rerank model: {rerank_models[0].id}") + + if not knowledge.llm_id: + llm_models = ModelConfigRepository.get_by_type( + db=db, model_types=[ModelType.LLM, ModelType.CHAT], tenant_id=tenant_id, is_active=True + ) + if llm_models: + knowledge.llm_id = llm_models[0].id + business_logger.debug(f"Auto-bind llm model: {llm_models[0].id}") + + if not knowledge.image2text_id: + image2text_models = db.query(ModelConfig).filter( + ModelConfig.tenant_id == tenant_id, + ModelConfig.type.in_([ModelType.CHAT.value, ModelType.IMAGE.value]), + ModelConfig.capability.contains(["vision"]), + ModelConfig.is_active == True, + ModelConfig.is_composite == False + ).order_by(ModelConfig.created_at.desc()).all() + if not image2text_models: + raise Exception("租户下没有可用的视觉模型,创建知识库失败") + knowledge.image2text_id = image2text_models[0].id + business_logger.debug(f"Auto-bind image2text model: {image2text_models[0].id}") + business_logger.debug(f"Start creating the knowledge base: {knowledge.name}") db_knowledge = knowledge_repository.create_knowledge( db=db, knowledge=knowledge From e015455fb89c2443d3685168ff3752e52bea1382 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 16 Apr 2026 19:00:58 +0800 Subject: [PATCH 079/113] feat(web): model support json --- web/src/components/ModelSelect/index.tsx | 24 +++++++------- web/src/i18n/en.ts | 3 ++ web/src/i18n/zh.ts | 3 ++ .../components/ModelConfigModal.tsx | 22 +++++++------ web/src/views/ApplicationConfig/types.ts | 15 +++++---- .../components/CustomModelModal.tsx | 17 +++++++--- web/src/views/ModelManagement/types.ts | 5 +-- .../Properties/ModelConfig/index.tsx | 32 ++++++++++++++++--- web/src/views/Workflow/constant.ts | 6 +++- 9 files changed, 88 insertions(+), 39 deletions(-) diff --git a/web/src/components/ModelSelect/index.tsx b/web/src/components/ModelSelect/index.tsx index 8f9152fb..0b4f671e 100644 --- a/web/src/components/ModelSelect/index.tsx +++ b/web/src/components/ModelSelect/index.tsx @@ -2,9 +2,9 @@ * @Author: ZhaoYing * @Date: 2026-03-07 16:49:59 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-25 11:21:59 + * @Last Modified time: 2026-04-16 17:46:02 */ -import { useEffect, useState, type FC } from 'react'; +import { useEffect, useState, forwardRef, useImperativeHandle } from 'react'; import { Select, Flex, Space } from 'antd'; import type { SelectProps } from 'antd/es/select'; import { useTranslation } from 'react-i18next'; @@ -14,6 +14,10 @@ import type { Query, Model } from '@/views/ModelManagement/types'; import { getListLogoUrl } from '@/views/ModelManagement/utils'; import Tag from '@/components/Tag'; +export interface ModelSelectRef { + options: Model[]; +} + /** Extends AntD SelectProps; omits filterOption since it's handled internally */ interface ModelSelectProps extends SelectProps { /** Extra query params passed to getModelList */ @@ -24,17 +28,15 @@ interface ModelSelectProps extends SelectProps { initialData?: Model[]; } -const ModelSelect: FC = ({ - params, - placeholder, - fontClassName, - isAutoFetch = true, - initialData = [], - ...props -}) => { +const ModelSelect = forwardRef(( + { params, placeholder, fontClassName, isAutoFetch = true, initialData = [], ...props }, + ref +) => { const { t } = useTranslation(); const [options, setOptions] = useState([]); + useImperativeHandle(ref, () => ({ options }), [options]); + // Fetch active models whenever params change; stringify for stable deep comparison useEffect(() => { if (!isAutoFetch) return @@ -89,6 +91,6 @@ const ModelSelect: FC = ({ {...props} /> ); -}; +}); export default ModelSelect; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 394606a3..d65e4436 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -629,6 +629,7 @@ export const en = { video: 'Video', thinking: 'Deep Thinking', is_thinking: 'Deep Thinking Support', + json_output: 'Support JSON formatted output', }, knowledgeBase: { home: 'Home', @@ -1524,6 +1525,7 @@ export const en = { }`, uploadCover: 'Import and Overwrite', refresh: 'Refresh Current Page', + json_output: 'Support JSON formatted output', }, userMemory: { userMemory: 'User Memory', @@ -2287,6 +2289,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re messagesPlaceholder: 'Write prompts here, type "{" to insert variables, type "insert" to insert', vision: 'Vision', parameterSettings: 'Parameter Settings', + json_output: 'Support JSON formatted output', }, start: { variables: 'Input Fields', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 98676ef4..921b3554 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -859,6 +859,7 @@ export const zh = { }`, uploadCover: '导入并覆盖', refresh: '刷新当前页', + json_output: '支持JSON格式化输出', }, table: { totalRecords: '共 {{total}} 条记录' @@ -1307,6 +1308,7 @@ export const zh = { video: '视频', thinking: '深度思考', is_thinking: '支持深度思考', + json_output: '支持JSON格式化输出', }, timezones: { 'Asia/Shanghai': '中国标准时间 (UTC+8)', @@ -2248,6 +2250,7 @@ export const zh = { messagesPlaceholder: '在此处编写提示,输入“{”插入变量,输入“insert”插入', vision: '视觉', parameterSettings: '参数设置', + json_output: '支持JSON格式化输出', }, start: { variables: '输入字段', diff --git a/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx b/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx index 8e3e3257..30af7a8c 100644 --- a/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx +++ b/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:28:07 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-31 16:56:57 + * @Last Modified time: 2026-04-16 18:51:01 */ /** * Model Configuration Modal @@ -102,14 +102,15 @@ const ModelConfigModal = forwardRef( } /** Handle model selection change */ const handleChange: SelectProps['onChange'] = (_value, option) => { - if (source === 'chat') { - form.setFieldValue('label', (option as Model).name) - } - - form.setFieldsValue({ + const newValues: ModelConfig = { capability: (option as Model).capability, deep_thinking: false, - }) + json_output: false + } + if (source === 'chat') { + newValues.label = (option as Model).name + } + form.setFieldsValue(newValues) } /** Expose methods to parent component */ @@ -119,12 +120,10 @@ const ModelConfigModal = forwardRef( })); useEffect(() => { - const { deep_thinking: _, ...rest } = data?.model_parameters || {} + const { deep_thinking: _, json_output: __, ...rest } = data?.model_parameters || {} form.setFieldsValue(rest) }, [values?.default_model_config_id]) - - console.log('handleChange values', values) return ( ( + {source === 'chat' &&
{t('userMemory.role')}
-
{data?.profile?.role || '-'}
+
{data?.profile?.role?.join(' | ') || '-'}
{t('userMemory.domain')}
-
{data?.profile?.domain || '-'}
+
{data?.profile?.domain?.join(' | ') || '-'}
{t('userMemory.expertise')}
diff --git a/web/src/views/UserMemoryDetail/types.ts b/web/src/views/UserMemoryDetail/types.ts index 4eb4fe5b..667d8272 100644 --- a/web/src/views/UserMemoryDetail/types.ts +++ b/web/src/views/UserMemoryDetail/types.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:15 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-14 16:03:16 + * @Last Modified time: 2026-04-17 17:57:00 */ /** * User Memory Detail Types @@ -178,8 +178,8 @@ export interface EndUser { created_at: string; updated_at: string; profile: { - role: string; - domain: string; + role: string[]; + domain: string[]; expertise: string[]; interests: string[]; }; From 48b56a3d88d3a76623cd54eca8e550134e4fc735 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 17 Apr 2026 19:58:44 +0800 Subject: [PATCH 108/113] fix(web): update interface type --- .../Workflow/components/Properties/CaseList/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index 227b6ffb..6eaa08e1 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-09 18:24:53 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-17 19:48:59 + * @Last Modified time: 2026-04-17 19:58:13 */ import { useEffect, useMemo, type FC } from 'react' import clsx from 'clsx' @@ -45,7 +45,7 @@ interface CaseItem { } interface CaseListProps { value?: CaseItem[]; - onChange?: (value: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; }[] }>) => void; + onChange?: (value: CaseItem[]) => void; options: Suggestion[]; name: string; selectedNode?: any; @@ -221,7 +221,7 @@ const ArrayFileSubConditions: FC = ({ conditionFiel onChange={(value: string) => { form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'conditions', subIndex], { key: value, - input_type: value === 'size' ? 'Constant' : undefined, + input_type: value === 'size' ? 'constant' : undefined, value: undefined, operator: value === 'size' ? 'ge' : 'eq', }); @@ -488,7 +488,7 @@ const CaseList: FC = ({ left: newValue, operator: undefined, right: undefined, - input_type: 'Constant' + input_type: 'constant' }); }; From 7b5d7696cb6c4d55e4f833fcd9f547ffd2e67324 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 17 Apr 2026 20:26:44 +0800 Subject: [PATCH 109/113] feat(workflow): support variable input type in if-else node conditions --- api/app/core/workflow/nodes/if_else/config.py | 14 +++++- api/app/core/workflow/nodes/if_else/node.py | 2 +- api/app/core/workflow/nodes/operators.py | 50 ++++++++++--------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/api/app/core/workflow/nodes/if_else/config.py b/api/app/core/workflow/nodes/if_else/config.py index 302d469f..4a5b3860 100644 --- a/api/app/core/workflow/nodes/if_else/config.py +++ b/api/app/core/workflow/nodes/if_else/config.py @@ -10,8 +10,18 @@ class SubVariableConditionItem(BaseModel): """A single condition on a file object's field, used inside sub_variable_condition.""" key: str = Field(..., description="Field name of the file object, e.g. type, size, name") operator: ComparisonOperator = Field(..., description="Comparison operator") - value: Any = Field(default=None, description="Value to compare with") - var_type: str = Field(default="string", description="Field value type: string or number") + value: Any = Field(default=None, description="Value to compare with, or variable selector when input_type=variable") + input_type: ValueInputType = Field(default=ValueInputType.CONSTANT, description="constant or variable") + + @field_validator("input_type", mode="before") + @classmethod + def lower_input_type(cls, v): + if isinstance(v, str): + try: + return ValueInputType(v.lower()) + except ValueError: + raise ValueError(f"Invalid input_type: {v}") + return v class SubVariableCondition(BaseModel): diff --git a/api/app/core/workflow/nodes/if_else/node.py b/api/app/core/workflow/nodes/if_else/node.py index faecd87c..c4d3a0e6 100644 --- a/api/app/core/workflow/nodes/if_else/node.py +++ b/api/app/core/workflow/nodes/if_else/node.py @@ -103,7 +103,7 @@ class IfElseNode(BaseNode): left_value = None if expression.sub_variable_condition is not None and isinstance(left_value, list): - evaluator = ArrayFileContainsOperator(left_value, expression.sub_variable_condition) + evaluator = ArrayFileContainsOperator(left_value, expression.sub_variable_condition, variable_pool) else: evaluator = ConditionExpressionResolver.resolve_by_value(left_value)( variable_pool, diff --git a/api/app/core/workflow/nodes/operators.py b/api/app/core/workflow/nodes/operators.py index 30634bbe..62eebbfe 100644 --- a/api/app/core/workflow/nodes/operators.py +++ b/api/app/core/workflow/nodes/operators.py @@ -396,47 +396,49 @@ class NoneObjectComparisonOperator: class ArrayFileContainsOperator: - """Handles contains/not_contains on array[file] with sub_variable_condition. + """Handles contains/not_contains on array[file] with sub_variable_condition.""" - Evaluates whether any (contains) or no (not_contains) file element - in the array satisfies all sub-conditions. - """ - - def __init__(self, left_value: list[dict], sub_variable_condition: Any): + def __init__(self, left_value: list[dict], sub_variable_condition: Any, pool: VariablePool | None = None): self.left_value = left_value self.sub_variable_condition = sub_variable_condition + self.pool = pool + + def _resolve_value(self, cond: Any) -> Any: + if cond.input_type == ValueInputType.VARIABLE and self.pool is not None: + pattern = r"\{\{\s*(.*?)\s*\}\}" + selector = re.sub(pattern, r"\1", str(cond.value)).strip() + return self.pool.get_value(selector, default=None, strict=False) + return cond.value def _match_item(self, file_item: dict) -> bool: - """Check if a single file dict satisfies all sub-conditions.""" results = [] for cond in self.sub_variable_condition.conditions: field_val = file_item.get(cond.key) - result = self._eval_sub(field_val, cond) + expected = self._resolve_value(cond) + result = self._eval_sub(field_val, cond.operator.value, expected) results.append(result) if self.sub_variable_condition.logical_operator.value == "and": return all(results) return any(results) @staticmethod - def _eval_sub(field_val: Any, cond: Any) -> bool: - op = cond.operator.value - expected = cond.value + def _eval_sub(field_val: Any, op: str, expected: Any) -> bool: if field_val is None: - return op in ("empty", "not_empty") and op == "empty" + return op == "empty" match op: - case "eq": return str(field_val) == str(expected) - case "ne": return str(field_val) != str(expected) - case "contains": return isinstance(field_val, str) and str(expected) in field_val + case "eq": return str(field_val) == str(expected) + case "ne": return str(field_val) != str(expected) + case "contains": return isinstance(field_val, str) and str(expected) in field_val case "not_contains": return isinstance(field_val, str) and str(expected) not in field_val - case "in": return field_val in (expected if isinstance(expected, list) else [expected]) - case "not_in": return field_val not in (expected if isinstance(expected, list) else [expected]) - case "gt": return isinstance(field_val, (int, float)) and field_val > float(expected) - case "ge": return isinstance(field_val, (int, float)) and field_val >= float(expected) - case "lt": return isinstance(field_val, (int, float)) and field_val < float(expected) - case "le": return isinstance(field_val, (int, float)) and field_val <= float(expected) - case "empty": return field_val in (None, "", 0) - case "not_empty": return field_val not in (None, "", 0) - case _: return False + case "in": return field_val in (expected if isinstance(expected, list) else [expected]) + case "not_in": return field_val not in (expected if isinstance(expected, list) else [expected]) + case "gt": return isinstance(field_val, (int, float)) and field_val > float(expected) + case "ge": return isinstance(field_val, (int, float)) and field_val >= float(expected) + case "lt": return isinstance(field_val, (int, float)) and field_val < float(expected) + case "le": return isinstance(field_val, (int, float)) and field_val <= float(expected) + case "empty": return field_val in (None, "", 0) + case "not_empty": return field_val not in (None, "", 0) + case _: return False def contains(self) -> bool: return any(self._match_item(f) for f in self.left_value if isinstance(f, dict)) From bbd85733b8b24a9abc00aa2ace9745ba10f21da3 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 17 Apr 2026 20:41:23 +0800 Subject: [PATCH 110/113] fix(web): if-else port position --- .../Workflow/components/Nodes/ConditionNode.tsx | 2 +- web/src/views/Workflow/utils.ts | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index 5e62775d..9cd1309e 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -109,7 +109,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { } {expression.sub_variable_condition?.conditions?.length > 0 && expression.sub_variable_condition?.conditions.every(isSubExprSet) - ?
+ ?
{expression.sub_variable_condition?.conditions.map((sub: any, sIndex: number) => (
{expression.sub_variable_condition?.conditions.length > 1 && sIndex > 0 &&
{expression.sub_variable_condition?.logical_operator?.toLocaleUpperCase()}
} diff --git a/web/src/views/Workflow/utils.ts b/web/src/views/Workflow/utils.ts index 429be70d..bd81b6eb 100644 --- a/web/src/views/Workflow/utils.ts +++ b/web/src/views/Workflow/utils.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-03-24 15:07:49 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-17 19:13:22 + * @Last Modified time: 2026-04-17 20:40:47 */ import { portItemArgsY, conditionNodePortItemArgsY, conditionNodeHeight } from './constant' @@ -91,6 +91,7 @@ export const getConditionNodeCasePortY = (cases: any[], caseIndex: number) => { let portItemArgsYNum = 0; for (let i = 0; i < caseIndex; i++) { + const notHasSub = cases[i]?.expressions?.filter((e: any) => !e?.sub_variable_condition?.conditions || e?.sub_variable_condition?.conditions.length <1).length const n = cases[i]?.expressions?.length || 0; let casePortItemArgsYNum = n + 1; // Add extra y for expressions with all sub_variable_condition set @@ -110,11 +111,16 @@ export const getConditionNodeCasePortY = (cases: any[], caseIndex: number) => { cases[i]?.expressions?.forEach((e: any) => { const subs = e?.sub_variable_condition?.conditions; if (subs?.length && subs.every(isSubExprSet) && subs.length > 1) { - extraExprs += subs.length; - } else if (!subs && n > 2) { - extraExprs += n - 2; - } + extraExprs += subs.length + 2; + } }); + + console.log('extraExprs notHasSub', notHasSub) + if (notHasSub > 3) { + extraExprs += n - 2 + notHasSub/4; + } else { + extraExprs += n - 2 + notHasSub/4 + } } } From 86540a81d1779f622101bd4b837e2949656ab716 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 17 Apr 2026 20:46:03 +0800 Subject: [PATCH 111/113] fix(web): SubCondition interface --- .../views/Workflow/components/Properties/CaseList/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index 6eaa08e1..bdf516bc 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-09 18:24:53 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-17 19:58:13 + * @Last Modified time: 2026-04-17 20:45:31 */ import { useEffect, useMemo, type FC } from 'react' import clsx from 'clsx' @@ -23,7 +23,7 @@ interface SubCondition { key: string; operator: string; value: string | number; - var_type: string; + input_type: string; } interface SubVariableCondition { @@ -303,7 +303,7 @@ const ArrayFileSubConditions: FC = ({ conditionFiel })}
diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index f385acf3..14bffaec 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,9 +2,10 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 23:17:50 + * @Last Modified time: 2026-04-20 16:00:26 */ -import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; +import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6'; +import type { HistoryCommand as Command } from '@antv/x6/lib/plugin/history/type'; import { register } from '@antv/x6-react-shape'; import type { PortMetadata } from '@antv/x6/lib/model/port'; import { App } from 'antd'; @@ -63,6 +64,14 @@ export interface UseWorkflowGraphReturn { copyEvent: () => boolean | void; /** Handler for paste keyboard event */ parseEvent: () => boolean | void; + /** Whether undo is available */ + canUndo: boolean; + /** Whether redo is available */ + canRedo: boolean; + /** Undo last action */ + undo: () => void; + /** Redo last undone action */ + redo: () => void; /** Function to save workflow configuration */ handleSave: (flag?: boolean) => Promise; /** Chat variables for workflow */ @@ -105,6 +114,8 @@ export const useWorkflowGraph = ({ const [config, setConfig] = useState(null); const [chatVariables, setChatVariables] = useState([]) const featuresRef = useRef(undefined) + const [canUndo, setCanUndo] = useState(false) + const [canRedo, setCanRedo] = useState(false) useEffect(() => { if (!graphRef.current) return @@ -469,6 +480,8 @@ export const useWorkflowGraph = ({ graphRef.current.getNodes().forEach(node => { if (node.getData()?.cycle) node.toFront(); }); + graphRef.current.enableHistory() + graphRef.current.cleanHistory() } }, 200) } @@ -504,6 +517,22 @@ export const useWorkflowGraph = ({ global: true, }), ); + graphRef.current.use( + new History({ + enabled: false, + beforeAddCommand(_event, args: any) { + const event = args?.key ? `cell:change:${args.key}` : _event; + if (event.startsWith('cell:change:') && + event !== 'cell:change:position' && + event !== 'cell:change:source' && + event !== 'cell:change:target') return false; + }, + }), + ); + graphRef.current.on('history:change', ({ cmds }: { cmds: Command[] }) => { + setCanUndo(graphRef.current?.canUndo() ?? false) + setCanRedo(graphRef.current?.canRedo() ?? false) + }) }; // 显示/隐藏连接桩 // const showPorts = (show: boolean) => { @@ -1077,6 +1106,9 @@ export const useWorkflowGraph = ({ graphRef.current.bindKey(['ctrl+v', 'cmd+v'], parseEvent); // Delete selected nodes and edges graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent); + // Undo / Redo + graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { graphRef.current?.undo(); return false; }); + graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { graphRef.current?.redo(); return false; }); }; @@ -1390,6 +1422,9 @@ export const useWorkflowGraph = ({ return userVars } + const undo = () => graphRef.current?.undo() + const redo = () => graphRef.current?.redo() + const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => { const { statement = '' } = value?.opening_statement || {} featuresRef.current = value @@ -1449,5 +1484,9 @@ export const useWorkflowGraph = ({ handleSaveFeaturesConfig, features: featuresRef.current, getStartNodeVariables, + canUndo, + canRedo, + undo, + redo, }; }; diff --git a/web/src/views/Workflow/index.tsx b/web/src/views/Workflow/index.tsx index 26d7420c..f98cf308 100644 --- a/web/src/views/Workflow/index.tsx +++ b/web/src/views/Workflow/index.tsx @@ -39,6 +39,10 @@ const Workflow = forwardRef { @@ -96,6 +100,10 @@ const Workflow = forwardRef