Merge branch 'develop' into fix/workflow

This commit is contained in:
Eternity
2026-01-14 10:58:13 +08:00
committed by GitHub
77 changed files with 3193 additions and 1889 deletions

View File

@@ -20,6 +20,8 @@ from . import (
knowledgeshare_controller, knowledgeshare_controller,
memory_agent_controller, memory_agent_controller,
memory_dashboard_controller, memory_dashboard_controller,
memory_episodic_controller,
memory_explicit_controller,
memory_forget_controller, memory_forget_controller,
memory_reflection_controller, memory_reflection_controller,
memory_short_term_controller, memory_short_term_controller,
@@ -67,6 +69,8 @@ manager_router.include_router(memory_agent_controller.router)
manager_router.include_router(memory_dashboard_controller.router) manager_router.include_router(memory_dashboard_controller.router)
manager_router.include_router(memory_storage_controller.router) manager_router.include_router(memory_storage_controller.router)
manager_router.include_router(user_memory_controllers.router) manager_router.include_router(user_memory_controllers.router)
manager_router.include_router(memory_episodic_controller.router)
manager_router.include_router(memory_explicit_controller.router)
manager_router.include_router(api_key_controller.router) manager_router.include_router(api_key_controller.router)
manager_router.include_router(release_share_controller.router) manager_router.include_router(release_share_controller.router)
manager_router.include_router(public_share_controller.router) # 公开路由(无需认证) manager_router.include_router(public_share_controller.router) # 公开路由(无需认证)

View File

@@ -30,7 +30,7 @@ from sqlalchemy.orm import Session
api_logger = get_api_logger() api_logger = get_api_logger()
router = APIRouter( router = APIRouter(
prefix="/memory/emotion", prefix="/memory/emotion-memory",
tags=["Emotion Analysis"], tags=["Emotion Analysis"],
dependencies=[Depends(get_current_user)] # 所有路由都需要认证 dependencies=[Depends(get_current_user)] # 所有路由都需要认证
) )

View File

@@ -32,7 +32,6 @@ def get_workspace_list(
@router.get("/version", response_model=ApiResponse) @router.get("/version", response_model=ApiResponse)
def get_system_version(): def get_system_version():
"""获取系统版本号+说明""" """获取系统版本号+说明"""
return success(data={ current_version = settings.SYSTEM_VERSION
"version": settings.SYSTEM_VERSION, version_introduction = HomePageService.load_version_introduction(current_version)
"introduction": settings.SYSTEM_INTRODUCTION return success(data={"version": current_version, "introduction": version_introduction}, msg="系统版本获取成功")
}, msg="系统版本获取成功")

View File

@@ -1,18 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import Optional
import uuid
from app.repositories.end_user_repository import update_end_user_other_name
import uuid
from app.core.response_utils import success from app.core.response_utils import success
from app.db import get_db from app.db import get_db
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.models.user_model import User from app.models.user_model import User
from app.schemas.memory_agent_schema import End_User_Information from app.schemas.memory_agent_schema import End_User_Information
from app.schemas.response_schema import ApiResponse from app.schemas.response_schema import ApiResponse
from app.schemas.app_schema import App as AppSchema
from app.services import memory_dashboard_service, memory_storage_service, workspace_service from app.services import memory_dashboard_service, memory_storage_service, workspace_service
from app.services.memory_agent_service import get_end_users_connected_configs_batch
from app.core.logging_config import get_api_logger from app.core.logging_config import get_api_logger
# 获取API专用日志器 # 获取API专用日志器
@@ -102,7 +99,8 @@ async def get_workspace_end_users(
""" """
获取工作空间的宿主列表 获取工作空间的宿主列表
返回格式与原 memory_list 接口中的 end_users 字段相同 返回格式与原 memory_list 接口中的 end_users 字段相同
并包含每个用户的记忆配置信息memory_config_id 和 memory_config_name
""" """
workspace_id = current_user.current_workspace_id workspace_id = current_user.current_workspace_id
# 获取当前空间类型 # 获取当前空间类型
@@ -113,6 +111,17 @@ async def get_workspace_end_users(
workspace_id=workspace_id, workspace_id=workspace_id,
current_user=current_user current_user=current_user
) )
# 批量获取所有用户的记忆配置信息(优化:一次查询而非 N 次)
end_user_ids = [str(user.id) for user in end_users]
memory_configs_map = {}
if end_user_ids:
try:
memory_configs_map = get_end_users_connected_configs_batch(end_user_ids, db)
except Exception as e:
api_logger.error(f"批量获取记忆配置失败: {str(e)}")
# 失败时使用空字典,不影响其他数据返回
result = [] result = []
for end_user in end_users: for end_user in end_users:
memory_num = {} memory_num = {}
@@ -123,10 +132,25 @@ async def get_workspace_end_users(
memory_num = { memory_num = {
"total":memory_dashboard_service.get_current_user_total_chunk(str(end_user.id), db, current_user) "total":memory_dashboard_service.get_current_user_total_chunk(str(end_user.id), db, current_user)
} }
# 从批量查询结果中获取配置信息
user_id = str(end_user.id)
memory_config_info = memory_configs_map.get(user_id, {
"memory_config_id": None,
"memory_config_name": None
})
# 只保留需要的字段,移除 error 字段(如果有)
memory_config = {
"memory_config_id": memory_config_info.get("memory_config_id"),
"memory_config_name": memory_config_info.get("memory_config_name")
}
result.append( result.append(
{ {
'end_user': end_user, 'end_user': end_user,
'memory_num':memory_num 'memory_num': memory_num,
'memory_config': memory_config
} }
) )
@@ -465,7 +489,6 @@ async def dashboard_data(
if storage_type is None: if storage_type is None:
storage_type = 'neo4j' storage_type = 'neo4j'
user_rag_memory_id = None
# 根据 storage_type 决定返回哪个数据对象 # 根据 storage_type 决定返回哪个数据对象
# 如果是 'rag'neo4j_data 为 null否则 rag_data 为 null # 如果是 'rag'neo4j_data 为 null否则 rag_data 为 null

View File

@@ -0,0 +1,125 @@
"""
情景记忆相关的控制器
包含情景记忆总览和详情查询接口
"""
from fastapi import APIRouter, Depends
from app.core.error_codes import BizCode
from app.core.logging_config import get_api_logger
from app.core.response_utils import fail, success
from app.dependencies import get_current_user
from app.models.user_model import User
from app.schemas.response_schema import ApiResponse
from app.schemas.memory_episodic_schema import (
EpisodicMemoryOverviewRequest,
EpisodicMemoryDetailsRequest,
)
from app.services.memory_episodic_service import memory_episodic_service
# Get API logger
api_logger = get_api_logger()
router = APIRouter(
prefix="/memory/episodic-memory",
tags=["Episodic Memory"],
)
@router.post("/overview", response_model=ApiResponse)
async def get_episodic_memory_overview_api(
request: EpisodicMemoryOverviewRequest,
current_user: User = Depends(get_current_user),
) -> dict:
"""
获取情景记忆总览
返回指定用户的所有情景记忆列表,包括标题和创建时间。
支持通过时间范围、情景类型和标题关键词进行筛选。
"""
workspace_id = current_user.current_workspace_id
# 检查用户是否已选择工作空间
if workspace_id is None:
api_logger.warning(f"用户 {current_user.username} 尝试查询情景记忆总览但未选择工作空间")
return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None")
# 验证参数
valid_time_ranges = ["all", "today", "this_week", "this_month"]
valid_episodic_types = ["all", "conversation", "project_work", "learning", "decision", "important_event"]
if request.time_range not in valid_time_ranges:
return fail(BizCode.INVALID_PARAMETER, f"无效的时间范围参数,可选值:{', '.join(valid_time_ranges)}")
if request.episodic_type not in valid_episodic_types:
return fail(BizCode.INVALID_PARAMETER, f"无效的情景类型参数,可选值:{', '.join(valid_episodic_types)}")
# 处理 title_keyword去除首尾空格
title_keyword = request.title_keyword.strip() if request.title_keyword else None
api_logger.info(
f"情景记忆总览查询请求: end_user_id={request.end_user_id}, user={current_user.username}, "
f"workspace={workspace_id}, time_range={request.time_range}, episodic_type={request.episodic_type}, "
f"title_keyword={title_keyword}"
)
try:
# 调用Service层方法
result = await memory_episodic_service.get_episodic_memory_overview(
request.end_user_id, request.time_range, request.episodic_type, title_keyword
)
api_logger.info(
f"成功获取情景记忆总览: end_user_id={request.end_user_id}, "
f"total={result['total']}"
)
return success(data=result, msg="查询成功")
except Exception as e:
api_logger.error(f"情景记忆总览查询失败: end_user_id={request.end_user_id}, error={str(e)}")
return fail(BizCode.INTERNAL_ERROR, "情景记忆总览查询失败", str(e))
@router.post("/details", response_model=ApiResponse)
async def get_episodic_memory_details_api(
request: EpisodicMemoryDetailsRequest,
current_user: User = Depends(get_current_user),
) -> dict:
"""
获取情景记忆详情
返回指定情景记忆的详细信息,包括涉及对象、情景类型、内容记录和情绪。
"""
workspace_id = current_user.current_workspace_id
# 检查用户是否已选择工作空间
if workspace_id is None:
api_logger.warning(f"用户 {current_user.username} 尝试查询情景记忆详情但未选择工作空间")
return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None")
api_logger.info(
f"情景记忆详情查询请求: end_user_id={request.end_user_id}, summary_id={request.summary_id}, "
f"user={current_user.username}, workspace={workspace_id}"
)
try:
# 调用Service层方法
result = await memory_episodic_service.get_episodic_memory_details(
end_user_id=request.end_user_id,
summary_id=request.summary_id
)
api_logger.info(
f"成功获取情景记忆详情: end_user_id={request.end_user_id}, summary_id={request.summary_id}"
)
return success(data=result, msg="查询成功")
except ValueError as e:
# 处理情景记忆不存在的情况
api_logger.warning(f"情景记忆不存在: end_user_id={request.end_user_id}, summary_id={request.summary_id}, error={str(e)}")
return fail(BizCode.INVALID_PARAMETER, "情景记忆不存在", str(e))
except Exception as e:
api_logger.error(f"情景记忆详情查询失败: end_user_id={request.end_user_id}, summary_id={request.summary_id}, error={str(e)}")
return fail(BizCode.INTERNAL_ERROR, "情景记忆详情查询失败", str(e))

View File

@@ -0,0 +1,115 @@
"""
显性记忆控制器
处理显性记忆相关的API接口包括情景记忆和语义记忆的查询。
"""
from fastapi import APIRouter, Depends
from app.core.logging_config import get_api_logger
from app.core.response_utils import success, fail
from app.core.error_codes import BizCode
from app.services.memory_explicit_service import MemoryExplicitService
from app.schemas.response_schema import ApiResponse
from app.schemas.memory_explicit_schema import (
ExplicitMemoryOverviewRequest,
ExplicitMemoryDetailsRequest,
)
from app.dependencies import get_current_user
from app.models.user_model import User
# Get API logger
api_logger = get_api_logger()
# Initialize service
memory_explicit_service = MemoryExplicitService()
router = APIRouter(
prefix="/memory/explicit-memory",
tags=["Explicit Memory"],
)
@router.post("/overview", response_model=ApiResponse)
async def get_explicit_memory_overview_api(
request: ExplicitMemoryOverviewRequest,
current_user: User = Depends(get_current_user),
) -> dict:
"""
获取显性记忆总览
返回指定用户的所有显性记忆列表,包括标题、完整内容、创建时间和情绪信息。
"""
workspace_id = current_user.current_workspace_id
# 检查用户是否已选择工作空间
if workspace_id is None:
api_logger.warning(f"用户 {current_user.username} 尝试查询显性记忆总览但未选择工作空间")
return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None")
api_logger.info(
f"显性记忆总览查询请求: end_user_id={request.end_user_id}, user={current_user.username}, "
f"workspace={workspace_id}"
)
try:
# 调用Service层方法
result = await memory_explicit_service.get_explicit_memory_overview(
request.end_user_id
)
api_logger.info(
f"成功获取显性记忆总览: end_user_id={request.end_user_id}, "
f"total={result['total']}"
)
return success(data=result, msg="查询成功")
except Exception as e:
api_logger.error(f"显性记忆总览查询失败: end_user_id={request.end_user_id}, error={str(e)}")
return fail(BizCode.INTERNAL_ERROR, "显性记忆总览查询失败", str(e))
@router.post("/details", response_model=ApiResponse)
async def get_explicit_memory_details_api(
request: ExplicitMemoryDetailsRequest,
current_user: User = Depends(get_current_user),
) -> dict:
"""
获取显性记忆详情
根据 memory_id 返回情景记忆或语义记忆的详细信息。
- 情景记忆:包括标题、内容、情绪、创建时间
- 语义记忆:包括名称、核心定义、详细笔记、创建时间
"""
workspace_id = current_user.current_workspace_id
# 检查用户是否已选择工作空间
if workspace_id is None:
api_logger.warning(f"用户 {current_user.username} 尝试查询显性记忆详情但未选择工作空间")
return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None")
api_logger.info(
f"显性记忆详情查询请求: end_user_id={request.end_user_id}, memory_id={request.memory_id}, "
f"user={current_user.username}, workspace={workspace_id}"
)
try:
# 调用Service层方法
result = await memory_explicit_service.get_explicit_memory_details(
end_user_id=request.end_user_id,
memory_id=request.memory_id
)
api_logger.info(
f"成功获取显性记忆详情: end_user_id={request.end_user_id}, memory_id={request.memory_id}, "
f"memory_type={result.get('memory_type')}"
)
return success(data=result, msg="查询成功")
except ValueError as e:
# 处理记忆不存在的情况
api_logger.warning(f"显性记忆不存在: end_user_id={request.end_user_id}, memory_id={request.memory_id}, error={str(e)}")
return fail(BizCode.INVALID_PARAMETER, "显性记忆不存在", str(e))
except Exception as e:
api_logger.error(f"显性记忆详情查询失败: end_user_id={request.end_user_id}, memory_id={request.memory_id}, error={str(e)}")
return fail(BizCode.INTERNAL_ERROR, "显性记忆详情查询失败", str(e))

View File

@@ -39,7 +39,7 @@ from app.services.memory_forget_service import MemoryForgetService
api_logger = get_api_logger() api_logger = get_api_logger()
router = APIRouter( router = APIRouter(
prefix="/memory/forget", prefix="/memory/forget-memory",
tags=["Memory Forgetting Engine"], tags=["Memory Forgetting Engine"],
dependencies=[Depends(get_current_user)] # 所有路由都需要认证 dependencies=[Depends(get_current_user)] # 所有路由都需要认证
) )

View File

@@ -20,12 +20,6 @@ from app.services.user_memory_service import (
from app.services.memory_entity_relationship_service import MemoryEntityService,MemoryEmotion,MemoryInteraction from app.services.memory_entity_relationship_service import MemoryEntityService,MemoryEmotion,MemoryInteraction
from app.schemas.response_schema import ApiResponse from app.schemas.response_schema import ApiResponse
from app.schemas.memory_storage_schema import GenerateCacheRequest from app.schemas.memory_storage_schema import GenerateCacheRequest
from app.schemas.user_memory_schema import (
EpisodicMemoryOverviewRequest,
EpisodicMemoryDetailsRequest,
ExplicitMemoryOverviewRequest,
ExplicitMemoryDetailsRequest,
)
from app.schemas.end_user_schema import ( from app.schemas.end_user_schema import (
EndUserProfileResponse, EndUserProfileResponse,
@@ -440,195 +434,3 @@ async def memory_space_relationship_evolution(id: str, label: str,
except Exception as e: except Exception as e:
api_logger.error(f"关系演变查询失败: id={id}, table={label}, error={str(e)}", exc_info=True) api_logger.error(f"关系演变查询失败: id={id}, table={label}, error={str(e)}", exc_info=True)
return fail(BizCode.INTERNAL_ERROR, "关系演变查询失败", str(e)) return fail(BizCode.INTERNAL_ERROR, "关系演变查询失败", str(e))
@router.post("/classifications/episodic-memory", response_model=ApiResponse)
async def get_episodic_memory_overview_api(
request: EpisodicMemoryOverviewRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""
获取情景记忆总览
返回指定用户的所有情景记忆列表,包括标题和创建时间。
支持通过时间范围、情景类型和标题关键词进行筛选。
"""
workspace_id = current_user.current_workspace_id
# 检查用户是否已选择工作空间
if workspace_id is None:
api_logger.warning(f"用户 {current_user.username} 尝试查询情景记忆总览但未选择工作空间")
return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None")
# 验证参数
valid_time_ranges = ["all", "today", "this_week", "this_month"]
valid_episodic_types = ["all", "conversation", "project_work", "learning", "decision", "important_event"]
if request.time_range not in valid_time_ranges:
return fail(BizCode.INVALID_PARAMETER, f"无效的时间范围参数,可选值:{', '.join(valid_time_ranges)}")
if request.episodic_type not in valid_episodic_types:
return fail(BizCode.INVALID_PARAMETER, f"无效的情景类型参数,可选值:{', '.join(valid_episodic_types)}")
# 处理 title_keyword去除首尾空格
title_keyword = request.title_keyword.strip() if request.title_keyword else None
api_logger.info(
f"情景记忆总览查询请求: end_user_id={request.end_user_id}, user={current_user.username}, "
f"workspace={workspace_id}, time_range={request.time_range}, episodic_type={request.episodic_type}, "
f"title_keyword={title_keyword}"
)
try:
# 调用Service层方法
result = await user_memory_service.get_episodic_memory_overview(
db, request.end_user_id, request.time_range, request.episodic_type, title_keyword
)
api_logger.info(
f"成功获取情景记忆总览: end_user_id={request.end_user_id}, "
f"total={result['total']}"
)
return success(data=result, msg="查询成功")
except Exception as e:
api_logger.error(f"情景记忆总览查询失败: end_user_id={request.end_user_id}, error={str(e)}")
return fail(BizCode.INTERNAL_ERROR, "情景记忆总览查询失败", str(e))
@router.post("/classifications/episodic-memory-details", response_model=ApiResponse)
async def get_episodic_memory_details_api(
request: EpisodicMemoryDetailsRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""
获取情景记忆详情
返回指定情景记忆的详细信息,包括涉及对象、情景类型、内容记录和情绪。
"""
workspace_id = current_user.current_workspace_id
# 检查用户是否已选择工作空间
if workspace_id is None:
api_logger.warning(f"用户 {current_user.username} 尝试查询情景记忆详情但未选择工作空间")
return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None")
api_logger.info(
f"情景记忆详情查询请求: end_user_id={request.end_user_id}, summary_id={request.summary_id}, "
f"user={current_user.username}, workspace={workspace_id}"
)
try:
# 调用Service层方法
result = await user_memory_service.get_episodic_memory_details(
db=db,
end_user_id=request.end_user_id,
summary_id=request.summary_id
)
api_logger.info(
f"成功获取情景记忆详情: end_user_id={request.end_user_id}, summary_id={request.summary_id}"
)
return success(data=result, msg="查询成功")
except ValueError as e:
# 处理情景记忆不存在的情况
api_logger.warning(f"情景记忆不存在: end_user_id={request.end_user_id}, summary_id={request.summary_id}, error={str(e)}")
return fail(BizCode.INVALID_PARAMETER, "情景记忆不存在", str(e))
except Exception as e:
api_logger.error(f"情景记忆详情查询失败: end_user_id={request.end_user_id}, summary_id={request.summary_id}, error={str(e)}")
return fail(BizCode.INTERNAL_ERROR, "情景记忆详情查询失败", str(e))
@router.post("/classifications/explicit-memory", response_model=ApiResponse)
async def get_explicit_memory_overview_api(
request: ExplicitMemoryOverviewRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""
获取显性记忆总览
返回指定用户的所有显性记忆列表,包括标题、完整内容、创建时间和情绪信息。
"""
workspace_id = current_user.current_workspace_id
# 检查用户是否已选择工作空间
if workspace_id is None:
api_logger.warning(f"用户 {current_user.username} 尝试查询显性记忆总览但未选择工作空间")
return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None")
api_logger.info(
f"显性记忆总览查询请求: end_user_id={request.end_user_id}, user={current_user.username}, "
f"workspace={workspace_id}"
)
try:
# 调用Service层方法
result = await user_memory_service.get_explicit_memory_overview(
db, request.end_user_id
)
api_logger.info(
f"成功获取显性记忆总览: end_user_id={request.end_user_id}, "
f"total={result['total']}"
)
return success(data=result, msg="查询成功")
except Exception as e:
api_logger.error(f"显性记忆总览查询失败: end_user_id={request.end_user_id}, error={str(e)}")
return fail(BizCode.INTERNAL_ERROR, "显性记忆总览查询失败", str(e))
@router.post("/classifications/explicit-memory-details", response_model=ApiResponse)
async def get_explicit_memory_details_api(
request: ExplicitMemoryDetailsRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""
获取显性记忆详情
根据 memory_id 返回情景记忆或语义记忆的详细信息。
- 情景记忆:包括标题、内容、情绪、创建时间
- 语义记忆:包括名称、核心定义、详细笔记、创建时间
"""
workspace_id = current_user.current_workspace_id
# 检查用户是否已选择工作空间
if workspace_id is None:
api_logger.warning(f"用户 {current_user.username} 尝试查询显性记忆详情但未选择工作空间")
return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None")
api_logger.info(
f"显性记忆详情查询请求: end_user_id={request.end_user_id}, memory_id={request.memory_id}, "
f"user={current_user.username}, workspace={workspace_id}"
)
try:
# 调用Service层方法
result = await user_memory_service.get_explicit_memory_details(
db=db,
end_user_id=request.end_user_id,
memory_id=request.memory_id
)
api_logger.info(
f"成功获取显性记忆详情: end_user_id={request.end_user_id}, memory_id={request.memory_id}, "
f"memory_type={result.get('memory_type')}"
)
return success(data=result, msg="查询成功")
except ValueError as e:
# 处理记忆不存在的情况
api_logger.warning(f"显性记忆不存在: end_user_id={request.end_user_id}, memory_id={request.memory_id}, error={str(e)}")
return fail(BizCode.INVALID_PARAMETER, "显性记忆不存在", str(e))
except Exception as e:
api_logger.error(f"显性记忆详情查询失败: end_user_id={request.end_user_id}, memory_id={request.memory_id}, error={str(e)}")
return fail(BizCode.INTERNAL_ERROR, "显性记忆详情查询失败", str(e))

View File

@@ -167,7 +167,6 @@ class Settings:
# official environment system version # official environment system version
SYSTEM_VERSION: str = os.getenv("SYSTEM_VERSION", "v0.2.0") SYSTEM_VERSION: str = os.getenv("SYSTEM_VERSION", "v0.2.0")
SYSTEM_INTRODUCTION: str = os.getenv("SYSTEM_INTRODUCTION", "")
def get_memory_output_path(self, filename: str = "") -> str: def get_memory_output_path(self, filename: str = "") -> str:
""" """

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import json
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional, Tuple
from uuid import uuid4 from uuid import uuid4
from app.core.logging_config import get_memory_logger from app.core.logging_config import get_memory_logger
@@ -28,6 +29,118 @@ class MemorySummaryResponse(RobustLLMResponse):
) )
async def generate_title_and_type_for_summary(
content: str,
llm_client
) -> Tuple[str, str]:
"""
为MemorySummary生成标题和类型
此方法应该在创建MemorySummary节点时调用生成title和type
Args:
content: Summary的内容文本
llm_client: LLM客户端实例
Returns:
(标题, 类型)元组
"""
from app.core.memory.utils.prompt.prompt_utils import render_episodic_title_and_type_prompt
# 定义有效的类型集合
VALID_TYPES = {
"conversation", # 对话
"project_work", # 项目/工作
"learning", # 学习
"decision", # 决策
"important_event" # 重要事件
}
DEFAULT_TYPE = "conversation" # 默认类型
try:
if not content:
logger.warning("content为空无法生成标题和类型")
return ("空内容", DEFAULT_TYPE)
# 1. 渲染Jinja2提示词模板
prompt = await render_episodic_title_and_type_prompt(content)
# 2. 调用LLM生成标题和类型
messages = [
{"role": "user", "content": prompt}
]
response = await llm_client.chat(messages=messages)
# 3. 解析LLM响应
content_response = response.content
if isinstance(content_response, list):
if len(content_response) > 0:
if isinstance(content_response[0], dict):
text = content_response[0].get('text', content_response[0].get('content', str(content_response[0])))
full_response = str(text)
else:
full_response = str(content_response[0])
else:
full_response = ""
elif isinstance(content_response, dict):
full_response = str(content_response.get('text', content_response.get('content', str(content_response))))
else:
full_response = str(content_response) if content_response is not None else ""
# 4. 解析JSON响应
try:
# 尝试从响应中提取JSON
# 移除可能的markdown代码块标记
json_str = full_response.strip()
if json_str.startswith("```json"):
json_str = json_str[7:]
if json_str.startswith("```"):
json_str = json_str[3:]
if json_str.endswith("```"):
json_str = json_str[:-3]
json_str = json_str.strip()
result_data = json.loads(json_str)
title = result_data.get("title", "未知标题")
episodic_type_raw = result_data.get("type", DEFAULT_TYPE)
# 5. 校验和归一化类型
# 将类型转换为小写并去除空格
episodic_type_normalized = str(episodic_type_raw).lower().strip()
# 检查是否在有效类型集合中
if episodic_type_normalized in VALID_TYPES:
episodic_type = episodic_type_normalized
else:
# 尝试映射常见的中文类型到英文
type_mapping = {
"对话": "conversation",
"项目": "project_work",
"工作": "project_work",
"项目/工作": "project_work",
"学习": "learning",
"决策": "decision",
"重要事件": "important_event",
"事件": "important_event"
}
episodic_type = type_mapping.get(episodic_type_raw, DEFAULT_TYPE)
logger.warning(
f"LLM返回的类型 '{episodic_type_raw}' 不在有效集合中,"
f"已归一化为 '{episodic_type}'"
)
logger.info(f"成功生成标题和类型: title={title}, type={episodic_type}")
return (title, episodic_type)
except json.JSONDecodeError:
logger.error(f"无法解析LLM响应为JSON: {full_response}")
return ("解析失败", DEFAULT_TYPE)
except Exception as e:
logger.error(f"生成标题和类型时出错: {str(e)}", exc_info=True)
return ("错误", DEFAULT_TYPE)
async def _process_chunk_summary( async def _process_chunk_summary(
dialog: DialogData, dialog: DialogData,
chunk, chunk,
@@ -63,10 +176,9 @@ async def _process_chunk_summary(
title = None title = None
episodic_type = None episodic_type = None
try: try:
from app.services.user_memory_service import UserMemoryService title, episodic_type = await generate_title_and_type_for_summary(
title, episodic_type = await UserMemoryService.generate_title_and_type_for_summary(
content=summary_text, content=summary_text,
end_user_id=dialog.group_id llm_client=llm_client
) )
logger.info(f"Generated title and type for MemorySummary: title={title}, type={episodic_type}") logger.info(f"Generated title and type for MemorySummary: title={title}, type={episodic_type}")
except Exception as e: except Exception as e:

View File

@@ -260,17 +260,32 @@ class ForgettingStrategy:
) )
# 生成标题和类型使用LLM # 生成标题和类型使用LLM
from app.services.user_memory_service import UserMemoryService from app.core.memory.storage_services.extraction_engine.knowledge_extraction.memory_summary import generate_title_and_type_for_summary
# 获取 LLM 客户端
llm_client = None
if config_id is not None and db is not None:
try: try:
title, episodic_type = await UserMemoryService.generate_title_and_type_for_summary( llm_client = await self._get_llm_client(db, config_id)
except Exception as e:
logger.warning(f"获取 LLM 客户端失败: {str(e)}")
# 生成标题和类型
try:
if llm_client is not None:
title, episodic_type = await generate_title_and_type_for_summary(
content=summary_text, content=summary_text,
end_user_id=group_id llm_client=llm_client
) )
logger.info(f"成功为MemorySummary生成标题和类型: title={title}, type={episodic_type}") logger.info(f"成功为MemorySummary生成标题和类型: title={title}, type={episodic_type}")
else:
logger.warning("LLM 客户端不可用,使用默认标题和类型")
title = "未命名"
episodic_type = "conversation"
except Exception as e: except Exception as e:
logger.error(f"生成标题和类型失败,使用默认值: {str(e)}") logger.error(f"生成标题和类型失败,使用默认值: {str(e)}")
title = "未命名" title = "未命名"
episodic_type = "其他" episodic_type = "conversation"
# 计算继承的激活值和重要性(取较高值) # 计算继承的激活值和重要性(取较高值)
inherited_activation = max(statement_activation, entity_activation) inherited_activation = max(statement_activation, entity_activation)

View File

@@ -110,7 +110,7 @@ class BaiduSearchTool(BuiltinTool):
execution_time = time.time() - start_time execution_time = time.time() - start_time
return ToolResult.success_result( return ToolResult.success_result(
data=result, data=result["results"],
execution_time=execution_time execution_time=execution_time
) )

View File

@@ -95,7 +95,7 @@ class DateTimeTool(BuiltinTool):
execution_time = time.time() - start_time execution_time = time.time() - start_time
return ToolResult.success_result( return ToolResult.success_result(
data=result, data=result["result_data"],
execution_time=execution_time execution_time=execution_time
) )
@@ -123,12 +123,14 @@ class DateTimeTool(BuiltinTool):
utc_now = datetime.now(timezone.utc) utc_now = datetime.now(timezone.utc)
return { return {
"datetime": now.strftime(output_format),
"timestamp": int(now.timestamp()),
"timezone": timezone_str, "timezone": timezone_str,
"iso_format": now.isoformat(), "iso_format": now.isoformat(),
"result_data": {
"datetime": now.strftime(output_format),
"timestamp": int(now.timestamp()),
"timestamp_ms": int(now.timestamp() * 1000), "timestamp_ms": int(now.timestamp() * 1000),
"utc_datetime": utc_now.strftime(output_format) "utc_datetime": utc_now.strftime(output_format),
}
} }
@staticmethod @staticmethod
@@ -148,7 +150,8 @@ class DateTimeTool(BuiltinTool):
"original": input_value, "original": input_value,
"formatted": dt.strftime(output_format), "formatted": dt.strftime(output_format),
"timestamp": int(dt.timestamp()), "timestamp": int(dt.timestamp()),
"iso_format": dt.isoformat() "iso_format": dt.isoformat(),
"result_data": dt.strftime(output_format)
} }
@staticmethod @staticmethod
@@ -189,7 +192,8 @@ class DateTimeTool(BuiltinTool):
"original_timezone": from_timezone, "original_timezone": from_timezone,
"converted": converted_dt.strftime(output_format), "converted": converted_dt.strftime(output_format),
"converted_timezone": to_timezone, "converted_timezone": to_timezone,
"timestamp": int(converted_dt.timestamp()) "timestamp": int(converted_dt.timestamp()),
"result_data": converted_dt.strftime(output_format)
} }
@staticmethod @staticmethod
@@ -219,7 +223,8 @@ class DateTimeTool(BuiltinTool):
"timestamp": timestamp, "timestamp": timestamp,
"datetime": dt.strftime(output_format), "datetime": dt.strftime(output_format),
"timezone": timezone_str, "timezone": timezone_str,
"iso_format": dt.isoformat() "iso_format": dt.isoformat(),
"result_data": dt.strftime(output_format)
} }
@staticmethod @staticmethod
@@ -249,7 +254,8 @@ class DateTimeTool(BuiltinTool):
"datetime": input_value, "datetime": input_value,
"timezone": timezone_str, "timezone": timezone_str,
"timestamp": int(dt.timestamp()), "timestamp": int(dt.timestamp()),
"iso_format": dt.isoformat() "iso_format": dt.isoformat(),
"result_data": int(dt.timestamp())
} }
def _calculate_datetime(self, kwargs) -> dict: def _calculate_datetime(self, kwargs) -> dict:
@@ -287,7 +293,8 @@ class DateTimeTool(BuiltinTool):
"calculation": calculation, "calculation": calculation,
"result": calculated_dt.strftime(output_format), "result": calculated_dt.strftime(output_format),
"timezone": timezone_str, "timezone": timezone_str,
"timestamp": int(calculated_dt.timestamp()) "timestamp": int(calculated_dt.timestamp()),
"result_data": calculated_dt.strftime(output_format)
} }
@staticmethod @staticmethod

View File

@@ -69,7 +69,7 @@ class JsonTool(BuiltinTool):
ToolParameter( ToolParameter(
name="json_path", name="json_path",
type=ParameterType.STRING, type=ParameterType.STRING,
description="JSON路径表达式用于extract、insert、replace、delete、parse操作$.user.name或users[0].name", description="JSON路径表达式用于insert、replace、delete、parse操作$.user.name或users[0].name",
required=False required=False
), ),
ToolParameter( ToolParameter(
@@ -136,7 +136,7 @@ class JsonTool(BuiltinTool):
execution_time = time.time() - start_time execution_time = time.time() - start_time
return ToolResult.success_result( return ToolResult.success_result(
data=result, data=result["result_data"],
execution_time=execution_time execution_time=execution_time
) )
@@ -671,7 +671,8 @@ class JsonTool(BuiltinTool):
"success": True, "success": True,
"value": current, "value": current,
"value_type": type(current).__name__, "value_type": type(current).__name__,
"value_json": json.dumps(current, indent=2, ensure_ascii=False) if isinstance(current, (dict, list)) else str(current) "value_json": json.dumps(current, indent=2, ensure_ascii=False) if isinstance(current, (dict, list)) else str(current),
"result_data": json.dumps(current, indent=2, ensure_ascii=False) if isinstance(current, (dict, list)) else str(current)
} }
except (KeyError, IndexError, TypeError) as e: except (KeyError, IndexError, TypeError) as e:
@@ -680,7 +681,8 @@ class JsonTool(BuiltinTool):
"json_path": json_path, "json_path": json_path,
"success": False, "success": False,
"error": str(e), "error": str(e),
"value": None "value": None,
"result_data": None
} }
def _analyze_json_structure(self, data: Any, depth: int = 0) -> Dict[str, Any]: def _analyze_json_structure(self, data: Any, depth: int = 0) -> Dict[str, Any]:

View File

@@ -1,5 +1,7 @@
import json
import logging import logging
import re import re
import uuid
from typing import Any from typing import Any
from app.core.workflow.nodes.base_node import BaseNode, WorkflowState from app.core.workflow.nodes.base_node import BaseNode, WorkflowState
@@ -25,10 +27,10 @@ class ToolNode(BaseNode):
# 获取租户ID和用户ID # 获取租户ID和用户ID
tenant_id = self.get_variable("sys.tenant_id", state) tenant_id = self.get_variable("sys.tenant_id", state)
user_id = self.get_variable("sys.user_id", state) user_id = self.get_variable("sys.user_id", state)
workspace_id = self.get_variable("sys.workspace_id", state)
# 如果没有租户ID尝试从工作流ID获取 # 如果没有租户ID尝试从工作流ID获取
if not tenant_id: if not tenant_id:
workspace_id = self.get_variable("sys.workspace_id", state)
if workspace_id: if workspace_id:
from app.repositories.tool_repository import ToolRepository from app.repositories.tool_repository import ToolRepository
with get_db_read() as db: with get_db_read() as db:
@@ -63,21 +65,21 @@ class ToolNode(BaseNode):
tool_id=self.typed_config.tool_id, tool_id=self.typed_config.tool_id,
parameters=rendered_parameters, parameters=rendered_parameters,
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=user_id user_id=uuid.UUID(user_id),
workspace_id=uuid.UUID(workspace_id)
) )
if result.success: if result.success:
logger.info(f"节点 {self.node_id} 工具执行成功") logger.info(f"节点 {self.node_id} 工具执行成功")
return { return {
"success": True, "data": result.data if isinstance(result.data, str) else json.dumps(result.data, ensure_ascii=False),
"data": result.data, "error_code": "",
"execution_time": result.execution_time "execution_time": result.execution_time
} }
else: else:
logger.error(f"节点 {self.node_id} 工具执行失败: {result.error}") logger.error(f"节点 {self.node_id} 工具执行失败: {result.error}")
return { return {
"success": False, "data": result.error if isinstance(result.error, str) else json.dumps(result.error, ensure_ascii=False),
"data": result.error,
"error_code": result.error_code, "error_code": result.error_code,
"execution_time": result.execution_time "execution_time": result.execution_time
} }

View File

@@ -211,12 +211,11 @@ class ToolExecution(Base):
token_usage = Column(JSON) token_usage = Column(JSON)
# 用户信息 # 用户信息
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) user_id = Column(UUID(as_uuid=True), index=True, nullable=True)
workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False, index=True) workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=False, index=True)
# 关联关系 # 关联关系
tool_config = relationship("ToolConfig", back_populates="executions") tool_config = relationship("ToolConfig", back_populates="executions")
user = relationship("User")
workspace = relationship("Workspace") workspace = relationship("Workspace")
def __repr__(self): def __repr__(self):

View File

@@ -1,5 +1,5 @@
""" """
用户记忆相关的请求和响应模型 情景记忆的请求和响应模型
""" """
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
@@ -28,16 +28,3 @@ class EpisodicMemoryDetailsRequest(BaseModel):
end_user_id: str = Field(..., description="终端用户ID") end_user_id: str = Field(..., description="终端用户ID")
summary_id: str = Field(..., description="情景记忆摘要ID") summary_id: str = Field(..., description="情景记忆摘要ID")
class ExplicitMemoryOverviewRequest(BaseModel):
"""显性记忆总览查询请求"""
end_user_id: str = Field(..., description="终端用户ID")
class ExplicitMemoryDetailsRequest(BaseModel):
"""显性记忆详情查询请求"""
end_user_id: str = Field(..., description="终端用户ID")
memory_id: str = Field(..., description="记忆ID情景记忆或语义记忆的ID")

View File

@@ -0,0 +1,15 @@
"""
显性记忆的请求和响应模型
"""
from pydantic import BaseModel, Field
class ExplicitMemoryOverviewRequest(BaseModel):
"""显性记忆总览查询请求"""
end_user_id: str = Field(..., description="终端用户ID")
class ExplicitMemoryDetailsRequest(BaseModel):
"""显性记忆详情查询请求"""
end_user_id: str = Field(..., description="终端用户ID")
memory_id: str = Field(..., description="记忆ID情景记忆或语义记忆的ID")

View File

@@ -55,7 +55,7 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str
长期记忆工具 长期记忆工具
""" """
# search_switch = memory_config.get("search_switch", "2") # search_switch = memory_config.get("search_switch", "2")
config_id= memory_config.get("memory_content",'17') config_id= memory_config.get("memory_content",None)
logger.info(f"创建长期记忆工具,配置: end_user_id={end_user_id}, config_id={config_id}, storage_type={storage_type}") logger.info(f"创建长期记忆工具,配置: end_user_id={end_user_id}, config_id={config_id}, storage_type={storage_type}")
@tool(args_schema=LongTermMemoryInput) @tool(args_schema=LongTermMemoryInput)
def long_term_memory(question: str) -> str: def long_term_memory(question: str) -> str:
@@ -94,7 +94,7 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str
group_id=end_user_id, group_id=end_user_id,
message=question, message=question,
history=[], history=[],
search_switch="1", search_switch="2",
config_id=config_id, config_id=config_id,
db=db, db=db,
storage_type=storage_type, storage_type=storage_type,

View File

@@ -1,6 +1,11 @@
import json
from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from uuid import UUID from uuid import UUID
from typing import Dict, Any
from app.repositories.home_page_repository import HomePageRepository from app.repositories.home_page_repository import HomePageRepository
from app.schemas.home_page_schema import HomeStatistics, WorkspaceInfo from app.schemas.home_page_schema import HomeStatistics, WorkspaceInfo
@@ -69,3 +74,68 @@ class HomePageService:
workspace_list.append(workspace_info) workspace_list.append(workspace_info)
return workspace_list return workspace_list
@staticmethod
def load_version_introduction(version: str) -> Dict[str, Any]:
"""
从 JSON 文件加载对应版本的介绍
:param version: 系统版本号(如 "0.2.0"
:return: 对应版本的详细介绍
"""
# 1. 定义 JSON 文件路径(使用 Path 处理跨平台路径问题)
json_file_path = Path(__file__).parent.parent / "version_info.json"
# 转换为绝对路径,便于调试
json_abs_path = json_file_path.resolve()
try:
# 2. 读取 JSON 文件
if not json_abs_path.exists():
return {
"message": f"版本介绍文件不存在:{json_abs_path}",
"codeName": "",
"releaseDate": "",
"upgradePosition": "",
"coreUpgrades": []
}
with open(json_abs_path, "r", encoding="utf-8") as f:
changelogs = json.load(f)
# 3. 匹配对应版本的介绍,若版本不存在返回默认提示
if version not in changelogs:
return {
"message": f"暂未查询到 {version} 版本的详细介绍",
"codeName": "",
"releaseDate": "",
"upgradePosition": "",
"coreUpgrades": []
}
return changelogs[version]
except FileNotFoundError as e:
# 处理文件不存在异常
return {
"message": f"系统内部错误:{str(e)}",
"codeName": "",
"releaseDate": "",
"upgradePosition": "",
"coreUpgrades": []
}
except json.JSONDecodeError:
# 处理 JSON 格式错误
return {
"message": "版本介绍文件格式错误,无法解析 JSON",
"codeName": "",
"releaseDate": "",
"upgradePosition": "",
"coreUpgrades": []
}
except Exception as e:
# 处理其他未知异常
return {
"message": f"加载版本介绍失败:{str(e)}",
"codeName": "",
"releaseDate": "",
"upgradePosition": "",
"coreUpgrades": []
}

View File

@@ -4,7 +4,6 @@ Memory Agent Service
Handles business logic for memory agent operations including read/write services, Handles business logic for memory agent operations including read/write services,
health checks, and message type classification. health checks, and message type classification.
""" """
import datetime
import json import json
import os import os
import re import re
@@ -27,7 +26,7 @@ from app.db import get_db_context
from app.models.knowledge_model import Knowledge, KnowledgeType from app.models.knowledge_model import Knowledge, KnowledgeType
from app.repositories.memory_short_repository import ShortTermMemoryRepository from app.repositories.memory_short_repository import ShortTermMemoryRepository
from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.repositories.neo4j.neo4j_connector import Neo4jConnector
from app.schemas.memory_config_schema import ConfigurationError, MemoryConfig from app.schemas.memory_config_schema import ConfigurationError
from app.services.memory_config_service import MemoryConfigService from app.services.memory_config_service import MemoryConfigService
from app.services.memory_konwledges_server import ( from app.services.memory_konwledges_server import (
write_rag, write_rag,
@@ -610,7 +609,7 @@ class MemoryAgentService:
reranked_results=raw_results.get('reranked_results',[]) reranked_results=raw_results.get('reranked_results',[])
try: try:
statements=[statement['statement'] for statement in reranked_results.get('statements', [])] statements=[statement['statement'] for statement in reranked_results.get('statements', [])]
except Exception as e: except Exception:
statements=[] statements=[]
statements=list(set(statements)) statements=list(set(statements))
retrieved_content.append({query:statements}) retrieved_content.append({query:statements})
@@ -832,7 +831,6 @@ class MemoryAgentService:
# 获取当前空间下的所有宿主 # 获取当前空间下的所有宿主
from app.repositories import app_repository, end_user_repository from app.repositories import app_repository, end_user_repository
from app.schemas.app_schema import App as AppSchema from app.schemas.app_schema import App as AppSchema
from app.schemas.end_user_schema import EndUser as EndUserSchema
# 查询应用并转换为 Pydantic 模型 # 查询应用并转换为 Pydantic 模型
apps_orm = app_repository.get_apps_by_workspace_id(db, current_workspace_id) apps_orm = app_repository.get_apps_by_workspace_id(db, current_workspace_id)
@@ -1175,19 +1173,21 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An
1. 根据 end_user_id 获取用户的 app_id 1. 根据 end_user_id 获取用户的 app_id
2. 获取该应用的最新发布版本 2. 获取该应用的最新发布版本
3. 从发布版本的 config 字段中提取 memory_config_id 3. 从发布版本的 config 字段中提取 memory_config_id
4. 根据 memory_config_id 查询配置名称
Args: Args:
end_user_id: 终端用户ID end_user_id: 终端用户ID
db: 数据库会话 db: 数据库会话
Returns: Returns:
包含 memory_config_id 和相关信息的字典 包含 memory_config_id、config_name 和相关信息的字典
Raises: Raises:
ValueError: 当终端用户不存在或应用未发布时 ValueError: 当终端用户不存在或应用未发布时
""" """
from app.models.app_release_model import AppRelease from app.models.app_release_model import AppRelease
from app.models.end_user_model import EndUser from app.models.end_user_model import EndUser
from app.models.data_config_model import DataConfig
from sqlalchemy import select from sqlalchemy import select
logger.info(f"Getting connected config for end_user: {end_user_id}") logger.info(f"Getting connected config for end_user: {end_user_id}")
@@ -1220,13 +1220,158 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An
memory_obj = config.get('memory', {}) memory_obj = config.get('memory', {})
memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None
# 4. 根据 memory_config_id 查询配置名称
config_name = None
if memory_config_id:
try:
# memory_config_id 可能是整数或字符串,需要转换
config_id = int(memory_config_id) if isinstance(memory_config_id, str) else memory_config_id
data_config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first()
if data_config:
config_name = data_config.config_name
logger.debug(f"Found config_name: {config_name} for config_id: {config_id}")
else:
logger.warning(f"DataConfig not found for config_id: {config_id}")
except (ValueError, TypeError) as e:
logger.warning(f"Invalid memory_config_id format: {memory_config_id}, error: {str(e)}")
result = { result = {
"end_user_id": str(end_user_id), "end_user_id": str(end_user_id),
"app_id": str(app_id), "app_id": str(app_id),
"release_id": str(latest_release.id), "release_id": str(latest_release.id),
"release_version": latest_release.version, "release_version": latest_release.version,
"memory_config_id": memory_config_id "memory_config_id": memory_config_id,
"memory_config_name": config_name
} }
logger.info(f"Successfully retrieved connected config: memory_config_id={memory_config_id}") logger.info(f"Successfully retrieved connected config: memory_config_id={memory_config_id}, config_name={config_name}")
return result
def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) -> Dict[str, Dict[str, Any]]:
"""
批量获取多个终端用户关联的记忆配置
通过优化的查询减少数据库往返次数:
1. 一次性查询所有 end_user 及其 app_id
2. 批量查询所有相关的 app_release
3. 批量查询所有相关的 data_config
Args:
end_user_ids: 终端用户ID列表
db: 数据库会话
Returns:
字典key 为 end_user_idvalue 为配置信息字典
对于查询失败的用户value 包含 error 字段
"""
from app.models.app_release_model import AppRelease
from app.models.end_user_model import EndUser
from app.models.data_config_model import DataConfig
from sqlalchemy import select
logger.info(f"Batch getting connected configs for {len(end_user_ids)} end users")
result = {}
# 1. 批量查询所有 end_user 及其 app_id
end_users = db.query(EndUser).filter(EndUser.id.in_(end_user_ids)).all()
# 构建 end_user_id -> end_user 的映射
end_user_map = {str(user.id): user for user in end_users}
# 记录不存在的用户
for user_id in end_user_ids:
if user_id not in end_user_map:
result[user_id] = {
"end_user_id": user_id,
"memory_config_id": None,
"memory_config_name": None,
"error": f"终端用户不存在: {user_id}"
}
if not end_users:
logger.warning("No valid end users found")
return result
# 2. 批量查询所有相关应用的最新发布版本
app_ids = [user.app_id for user in end_users]
# 使用子查询找到每个 app 的最新版本
from sqlalchemy import and_
# 查询所有相关的活跃发布版本
releases = db.query(AppRelease).filter(
and_(
AppRelease.app_id.in_(app_ids),
AppRelease.is_active.is_(True)
)
).order_by(AppRelease.app_id, AppRelease.version.desc()).all()
# 构建 app_id -> latest_release 的映射(每个 app 只保留最新版本)
app_release_map = {}
for release in releases:
app_id_str = str(release.app_id)
if app_id_str not in app_release_map:
app_release_map[app_id_str] = release
# 3. 收集所有 memory_config_id
memory_config_ids = []
for release in app_release_map.values():
config = release.config or {}
memory_obj = config.get('memory', {})
memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None
if memory_config_id:
try:
config_id = int(memory_config_id) if isinstance(memory_config_id, str) else memory_config_id
memory_config_ids.append(config_id)
except (ValueError, TypeError):
pass
# 4. 批量查询所有 data_config
config_name_map = {}
if memory_config_ids:
data_configs = db.query(DataConfig).filter(
DataConfig.config_id.in_(memory_config_ids)
).all()
config_name_map = {config.config_id: config.config_name for config in data_configs}
# 5. 组装结果
for user in end_users:
user_id = str(user.id)
app_id = str(user.app_id)
# 检查是否有发布版本
if app_id not in app_release_map:
result[user_id] = {
"end_user_id": user_id,
"memory_config_id": None,
"memory_config_name": None,
"error": f"应用未发布: {app_id}"
}
continue
release = app_release_map[app_id]
# 提取 memory_config_id
config = release.config or {}
memory_obj = config.get('memory', {})
memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None
# 获取 config_name
config_name = None
if memory_config_id:
try:
config_id = int(memory_config_id) if isinstance(memory_config_id, str) else memory_config_id
config_name = config_name_map.get(config_id)
except (ValueError, TypeError):
pass
result[user_id] = {
"end_user_id": user_id,
"memory_config_id": memory_config_id,
"memory_config_name": config_name
}
logger.info(f"Successfully retrieved batch configs: total={len(result)}, with_config={sum(1 for v in result.values() if v.get('memory_config_id'))}")
return result return result

View File

@@ -0,0 +1,297 @@
"""
Memory Base Service
提供记忆服务的基础功能和共享辅助方法。
"""
from datetime import datetime
from typing import Optional
from app.core.logging_config import get_logger
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
from app.services.emotion_analytics_service import EmotionAnalyticsService
logger = get_logger(__name__)
class MemoryBaseService:
"""记忆服务基类,提供共享的辅助方法"""
def __init__(self):
self.neo4j_connector = Neo4jConnector()
@staticmethod
def parse_timestamp(timestamp_value) -> Optional[int]:
"""
将时间戳转换为毫秒级时间戳
支持多种输入格式:
- Neo4j DateTime 对象
- ISO格式的时间戳字符串
- Python datetime 对象
Args:
timestamp_value: 时间戳值(可以是多种类型)
Returns:
毫秒级时间戳如果解析失败则返回None
"""
if not timestamp_value:
return None
try:
# 处理 Neo4j DateTime 对象
if hasattr(timestamp_value, 'to_native'):
dt_object = timestamp_value.to_native()
return int(dt_object.timestamp() * 1000)
# 处理 Python datetime 对象
if isinstance(timestamp_value, datetime):
return int(timestamp_value.timestamp() * 1000)
# 处理字符串格式
if isinstance(timestamp_value, str):
dt_object = datetime.fromisoformat(timestamp_value.replace("Z", "+00:00"))
return int(dt_object.timestamp() * 1000)
# 其他情况尝试转换为字符串再解析
dt_object = datetime.fromisoformat(str(timestamp_value).replace("Z", "+00:00"))
return int(dt_object.timestamp() * 1000)
except (ValueError, TypeError, AttributeError) as e:
logger.warning(f"无法解析时间戳: {timestamp_value}, error={str(e)}")
return None
async def extract_episodic_emotion(
self,
summary_id: str,
end_user_id: str
) -> Optional[str]:
"""
提取情景记忆的主要情绪
查询MemorySummary节点关联的Statement节点
返回emotion_intensity最大的emotion_type。
Args:
summary_id: Summary节点的ID
end_user_id: 终端用户ID (group_id)
Returns:
最大emotion_intensity对应的emotion_type如果没有则返回None
"""
try:
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement)
WHERE stmt.emotion_type IS NOT NULL
AND stmt.emotion_intensity IS NOT NULL
RETURN stmt.emotion_type AS emotion_type,
stmt.emotion_intensity AS emotion_intensity
ORDER BY emotion_intensity DESC
LIMIT 1
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
if result and len(result) > 0:
emotion_type = result[0].get("emotion_type")
logger.info(f"成功提取 summary_id={summary_id} 的情绪: {emotion_type}")
return emotion_type
else:
logger.info(f"summary_id={summary_id} 没有情绪信息")
return None
except Exception as e:
logger.error(f"提取情景记忆情绪时出错: {str(e)}", exc_info=True)
return None
async def get_episodic_memory_count(
self,
end_user_id: Optional[str] = None
) -> int:
"""
获取情景记忆数量
查询 MemorySummary 节点的数量。
Args:
end_user_id: 可选的终端用户ID用于过滤特定用户的节点
Returns:
情景记忆的数量
"""
try:
if end_user_id:
query = """
MATCH (n:MemorySummary)
WHERE n.group_id = $group_id
RETURN count(n) as count
"""
result = await self.neo4j_connector.execute_query(query, group_id=end_user_id)
else:
query = """
MATCH (n:MemorySummary)
RETURN count(n) as count
"""
result = await self.neo4j_connector.execute_query(query)
count = result[0]["count"] if result and len(result) > 0 else 0
logger.debug(f"情景记忆数量: {count} (end_user_id={end_user_id})")
return count
except Exception as e:
logger.error(f"获取情景记忆数量时出错: {str(e)}", exc_info=True)
return 0
async def get_explicit_memory_count(
self,
end_user_id: Optional[str] = None
) -> int:
"""
获取显性记忆数量
显性记忆 = 情景记忆MemorySummary+ 语义记忆ExtractedEntity with is_explicit_memory=true
Args:
end_user_id: 可选的终端用户ID用于过滤特定用户的节点
Returns:
显性记忆的数量
"""
try:
# 1. 获取情景记忆数量
episodic_count = await self.get_episodic_memory_count(end_user_id)
# 2. 获取语义记忆数量ExtractedEntity 且 is_explicit_memory = true
if end_user_id:
semantic_query = """
MATCH (e:ExtractedEntity)
WHERE e.group_id = $group_id AND e.is_explicit_memory = true
RETURN count(e) as count
"""
semantic_result = await self.neo4j_connector.execute_query(
semantic_query,
group_id=end_user_id
)
else:
semantic_query = """
MATCH (e:ExtractedEntity)
WHERE e.is_explicit_memory = true
RETURN count(e) as count
"""
semantic_result = await self.neo4j_connector.execute_query(semantic_query)
semantic_count = semantic_result[0]["count"] if semantic_result and len(semantic_result) > 0 else 0
# 3. 计算总数
explicit_count = episodic_count + semantic_count
logger.debug(
f"显性记忆数量: {explicit_count} "
f"(情景={episodic_count}, 语义={semantic_count}, end_user_id={end_user_id})"
)
return explicit_count
except Exception as e:
logger.error(f"获取显性记忆数量时出错: {str(e)}", exc_info=True)
return 0
async def get_emotional_memory_count(
self,
end_user_id: Optional[str] = None,
statement_count_fallback: int = 0
) -> int:
"""
获取情绪记忆数量
通过 EmotionAnalyticsService 获取情绪标签统计总数。
如果获取失败或没有指定 end_user_id使用 statement_count_fallback 作为后备。
Args:
end_user_id: 可选的终端用户ID
statement_count_fallback: 后备方案的数量(通常是 statement 节点数量)
Returns:
情绪记忆的数量
"""
try:
if end_user_id:
emotion_service = EmotionAnalyticsService()
emotion_data = await emotion_service.get_emotion_tags(
end_user_id=end_user_id,
emotion_type=None,
start_date=None,
end_date=None,
limit=10
)
emotion_count = emotion_data.get("total_count", 0)
logger.debug(f"情绪记忆数量: {emotion_count} (end_user_id={end_user_id})")
return emotion_count
else:
# 如果没有指定 end_user_id使用后备方案
logger.debug(f"情绪记忆数量: {statement_count_fallback} (使用后备方案)")
return statement_count_fallback
except Exception as e:
logger.warning(f"获取情绪记忆数量失败,使用后备方案: {str(e)}")
return statement_count_fallback
async def get_forget_memory_count(
self,
end_user_id: Optional[str] = None,
forgetting_threshold: float = 0.3
) -> int:
"""
获取遗忘记忆数量
统计激活值低于遗忘阈值的节点数量low_activation_nodes
查询范围包括Statement、ExtractedEntity、MemorySummary、Chunk 节点。
Args:
end_user_id: 可选的终端用户ID用于过滤特定用户的节点
forgetting_threshold: 遗忘阈值,默认 0.3
Returns:
遗忘记忆的数量(激活值低于阈值的节点数)
"""
try:
# 构建查询语句
query = """
MATCH (n)
WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary OR n:Chunk)
"""
if end_user_id:
query += " AND n.group_id = $group_id"
query += """
RETURN sum(CASE WHEN n.activation_value IS NOT NULL AND n.activation_value < $threshold THEN 1 ELSE 0 END) as low_activation_nodes
"""
# 设置查询参数
params = {'threshold': forgetting_threshold}
if end_user_id:
params['group_id'] = end_user_id
# 执行查询
result = await self.neo4j_connector.execute_query(query, **params)
# 提取结果
forget_count = result[0]['low_activation_nodes'] if result and len(result) > 0 else 0
forget_count = forget_count or 0 # 处理 None 值
logger.debug(
f"遗忘记忆数量: {forget_count} "
f"(threshold={forgetting_threshold}, end_user_id={end_user_id})"
)
return forget_count
except Exception as e:
logger.error(f"获取遗忘记忆数量时出错: {str(e)}", exc_info=True)
return 0

View File

@@ -0,0 +1,405 @@
"""
Episodic Memory Service
处理情景记忆相关的业务逻辑,包括情景记忆总览、详情查询等。
"""
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
import pytz
from app.core.logging_config import get_logger
from app.services.memory_base_service import MemoryBaseService
logger = get_logger(__name__)
class MemoryEpisodicService(MemoryBaseService):
"""情景记忆服务类"""
def __init__(self):
super().__init__()
logger.info("MemoryEpisodicService initialized")
async def _get_title_and_type(
self,
summary_id: str,
end_user_id: str
) -> Tuple[str, str]:
"""
读取情景记忆的标题(title)和类型(type)
仅负责读取已存在的title和type不进行生成
title从name属性读取type从memory_type属性读取
Args:
summary_id: Summary节点的ID
end_user_id: 终端用户ID (group_id)
Returns:
(标题, 类型)元组,如果不存在则返回默认值
"""
try:
# 查询Summary节点的name(作为title)和memory_type(作为type)
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
RETURN s.name AS title, s.memory_type AS type
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
if not result or len(result) == 0:
logger.warning(f"未找到 summary_id={summary_id} 的节点")
return ("未知标题", "其他")
record = result[0]
title = record.get("title") or "未命名"
episodic_type = record.get("type") or "其他"
return (title, episodic_type)
except Exception as e:
logger.error(f"读取标题和类型时出错: {str(e)}", exc_info=True)
return ("错误", "其他")
async def _extract_involved_objects(
self,
summary_id: str,
end_user_id: str
) -> List[str]:
"""
提取情景记忆涉及的前3个最重要实体
Args:
summary_id: Summary节点的ID
end_user_id: 终端用户ID (group_id)
Returns:
前3个实体的name属性列表
"""
try:
# 查询Summary节点指向的Statement节点,再查询Statement指向的ExtractedEntity节点
# 按activation_value降序排序,返回前3个
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement)
MATCH (stmt)-[:REFERENCES_ENTITY]->(entity:ExtractedEntity)
WHERE entity.activation_value IS NOT NULL
RETURN DISTINCT entity.name AS name, entity.activation_value AS activation
ORDER BY activation DESC
LIMIT 3
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
# 提取实体名称
involved_objects = [record["name"] for record in result if record.get("name")]
logger.info(f"成功提取 summary_id={summary_id} 的涉及对象: {involved_objects}")
return involved_objects
except Exception as e:
logger.error(f"提取涉及对象时出错: {str(e)}", exc_info=True)
return []
async def _extract_content_records(
self,
summary_id: str,
end_user_id: str
) -> List[str]:
"""
提取情景记忆的内容记录
Args:
summary_id: Summary节点的ID
end_user_id: 终端用户ID (group_id)
Returns:
所有Statement节点的statement属性内容列表
"""
try:
# 查询Summary节点指向的所有Statement节点
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement)
WHERE stmt.statement IS NOT NULL AND stmt.statement <> ''
RETURN stmt.statement AS statement
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
# 提取statement内容
content_records = [record["statement"] for record in result if record.get("statement")]
logger.info(f"成功提取 summary_id={summary_id} 的内容记录,共 {len(content_records)}")
return content_records
except Exception as e:
logger.error(f"提取内容记录时出错: {str(e)}", exc_info=True)
return []
def _calculate_time_filter(self, time_range: str) -> Optional[str]:
"""
根据时间范围计算过滤的起始时间
Args:
time_range: 时间范围 (all/today/this_week/this_month)
Returns:
ISO格式的时间字符串如果是"all"则返回None
"""
if time_range == "all":
return None
# 获取当前时间UTC
now = datetime.now(pytz.UTC)
if time_range == "today":
# 今天的开始时间00:00:00
start_time = now.replace(hour=0, minute=0, second=0, microsecond=0)
elif time_range == "this_week":
# 本周的开始时间周一00:00:00
days_since_monday = now.weekday()
start_time = (now - timedelta(days=days_since_monday)).replace(
hour=0, minute=0, second=0, microsecond=0
)
elif time_range == "this_month":
# 本月的开始时间1号00:00:00
start_time = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
else:
return None
# 返回ISO格式字符串
return start_time.isoformat()
async def get_episodic_memory_overview(
self,
end_user_id: str,
time_range: str = "all",
episodic_type: str = "all",
title_keyword: Optional[str] = None
) -> Dict[str, Any]:
"""
获取情景记忆总览信息
Args:
end_user_id: 终端用户ID
time_range: 时间范围筛选
episodic_type: 情景类型筛选
title_keyword: 标题关键词(可选,用于模糊搜索)
"""
try:
logger.info(
f"开始查询 end_user_id={end_user_id} 的情景记忆总览, "
f"time_range={time_range}, episodic_type={episodic_type}, title_keyword={title_keyword}"
)
# 1. 先查询所有情景记忆的总数(不受筛选条件限制)
total_all_query = """
MATCH (s:MemorySummary)
WHERE s.group_id = $group_id
RETURN count(s) AS total_all
"""
total_all_result = await self.neo4j_connector.execute_query(
total_all_query,
group_id=end_user_id
)
total_all = total_all_result[0]["total_all"] if total_all_result else 0
# 2. 计算时间范围的起始时间戳
time_filter = self._calculate_time_filter(time_range)
# 3. 构建Cypher查询
query = """
MATCH (s:MemorySummary)
WHERE s.group_id = $group_id
"""
# 添加时间范围过滤
if time_filter:
query += " AND s.created_at >= $time_filter"
# 添加标题关键词过滤如果提供了title_keyword
if title_keyword:
query += " AND toLower(s.name) CONTAINS toLower($title_keyword)"
query += """
RETURN elementId(s) AS id,
s.created_at AS created_at,
s.memory_type AS type,
s.name AS title
ORDER BY s.created_at DESC
"""
params = {"group_id": end_user_id}
if time_filter:
params["time_filter"] = time_filter
if title_keyword:
params["title_keyword"] = title_keyword
result = await self.neo4j_connector.execute_query(query, **params)
# 4. 如果没有数据,返回空列表
if not result:
logger.info(f"end_user_id={end_user_id} 没有情景记忆数据")
return {
"total": 0,
"total_all": total_all,
"episodic_memories": []
}
# 5. 对每个节点读取标题和类型,并应用类型筛选
episodic_memories = []
for record in result:
summary_id = record["id"]
created_at_str = record.get("created_at")
memory_type = record.get("type", "其他")
title = record.get("title") or "未命名" # 直接从查询结果获取标题
# 应用情景类型筛选
if episodic_type != "all":
# 检查类型是否匹配
# 注意Neo4j 中存储的 memory_type 现在应该是英文格式(如 "conversation", "project_work"
# 但为了兼容旧数据,我们也支持中文格式的匹配
type_mapping = {
"conversation": "对话",
"project_work": "项目/工作",
"learning": "学习",
"decision": "决策",
"important_event": "重要事件"
}
# 获取对应的中文类型(用于兼容旧数据)
chinese_type = type_mapping.get(episodic_type)
# 检查类型是否匹配(支持新的英文格式和旧的中文格式)
if memory_type != episodic_type and memory_type != chinese_type:
continue
# 使用基类方法转换时间戳
created_at_timestamp = self.parse_timestamp(created_at_str)
episodic_memories.append({
"id": summary_id,
"title": title,
"type": memory_type,
"created_at": created_at_timestamp
})
logger.info(
f"成功获取 end_user_id={end_user_id} 的情景记忆总览,"
f"筛选后 {len(episodic_memories)} 条,总共 {total_all}"
)
return {
"total": len(episodic_memories),
"total_all": total_all,
"episodic_memories": episodic_memories
}
except Exception as e:
logger.error(f"获取情景记忆总览时出错: {str(e)}", exc_info=True)
raise
async def get_episodic_memory_details(
self,
end_user_id: str,
summary_id: str
) -> Dict[str, Any]:
"""
获取单个情景记忆的详细信息
"""
try:
logger.info(f"开始查询 end_user_id={end_user_id}, summary_id={summary_id} 的情景记忆详情")
# 1. 查询指定的MemorySummary节点
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
RETURN elementId(s) AS id, s.created_at AS created_at
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
# 2. 如果节点不存在,返回错误
if not result or len(result) == 0:
logger.warning(f"未找到 summary_id={summary_id} 的情景记忆")
raise ValueError(f"情景记忆不存在: summary_id={summary_id}")
# 3. 获取基本信息
record = result[0]
created_at_str = record.get("created_at")
# 使用基类方法转换时间戳
created_at_timestamp = self.parse_timestamp(created_at_str)
# 4. 调用_get_title_and_type读取标题和类型
title, episodic_type = await self._get_title_and_type(
summary_id=summary_id,
end_user_id=end_user_id
)
# 5. 调用_extract_involved_objects提取涉及对象
involved_objects = await self._extract_involved_objects(
summary_id=summary_id,
end_user_id=end_user_id
)
# 6. 调用_extract_content_records提取内容记录
content_records = await self._extract_content_records(
summary_id=summary_id,
end_user_id=end_user_id
)
# 7. 使用基类方法提取情绪
emotion = await self.extract_episodic_emotion(
summary_id=summary_id,
end_user_id=end_user_id
)
# 8. 返回完整的详情信息
details = {
"id": summary_id,
"created_at": created_at_timestamp,
"involved_objects": involved_objects,
"episodic_type": episodic_type,
"content_records": content_records,
"emotion": emotion
}
logger.info(f"成功获取 summary_id={summary_id} 的情景记忆详情")
return details
except ValueError:
# 重新抛出ValueError让Controller层处理
raise
except Exception as e:
logger.error(f"获取情景记忆详情时出错: {str(e)}", exc_info=True)
raise
# 创建全局服务实例(供控制器层使用)
memory_episodic_service = MemoryEpisodicService()

View File

@@ -0,0 +1,274 @@
"""
显性记忆服务
处理显性记忆相关的业务逻辑,包括情景记忆和语义记忆的查询。
"""
from typing import Any, Dict
from app.core.logging_config import get_logger
from app.services.memory_base_service import MemoryBaseService
logger = get_logger(__name__)
class MemoryExplicitService(MemoryBaseService):
"""显性记忆服务类"""
def __init__(self):
super().__init__()
logger.info("MemoryExplicitService initialized")
async def get_explicit_memory_overview(
self,
end_user_id: str
) -> Dict[str, Any]:
"""
获取显性记忆总览信息
返回两部分:
1. 情景记忆episodic_memories- 来自MemorySummary节点
2. 语义记忆semantic_memories- 来自ExtractedEntity节点is_explicit_memory=true
Args:
end_user_id: 终端用户ID
Returns:
{
"total": int,
"episodic_memories": [
{
"id": str,
"title": str,
"content": str,
"created_at": int
}
],
"semantic_memories": [
{
"id": str,
"name": str,
"entity_type": str,
"core_definition": str
}
]
}
"""
try:
logger.info(f"开始查询 end_user_id={end_user_id} 的显性记忆总览(情景记忆+语义记忆)")
# ========== 1. 查询情景记忆MemorySummary节点 ==========
episodic_query = """
MATCH (s:MemorySummary)
WHERE s.group_id = $group_id
RETURN elementId(s) AS id,
s.name AS title,
s.content AS content,
s.created_at AS created_at
ORDER BY s.created_at DESC
"""
episodic_result = await self.neo4j_connector.execute_query(
episodic_query,
group_id=end_user_id
)
# 处理情景记忆数据
episodic_memories = []
if episodic_result:
for record in episodic_result:
summary_id = record["id"]
title = record.get("title") or "未命名"
content = record.get("content") or ""
created_at_str = record.get("created_at")
# 使用基类方法转换时间戳
created_at_timestamp = self.parse_timestamp(created_at_str)
# 注意:总览接口不返回 emotion 字段
episodic_memories.append({
"id": summary_id,
"title": title,
"content": content,
"created_at": created_at_timestamp
})
# ========== 2. 查询语义记忆ExtractedEntity节点 ==========
semantic_query = """
MATCH (e:ExtractedEntity)
WHERE e.group_id = $group_id
AND e.is_explicit_memory = true
RETURN elementId(e) AS id,
e.name AS name,
e.entity_type AS entity_type,
e.description AS core_definition
ORDER BY e.name ASC
"""
semantic_result = await self.neo4j_connector.execute_query(
semantic_query,
group_id=end_user_id
)
# 处理语义记忆数据
semantic_memories = []
if semantic_result:
for record in semantic_result:
entity_id = record["id"]
name = record.get("name") or "未命名"
entity_type = record.get("entity_type") or "未分类"
core_definition = record.get("core_definition") or ""
# 注意:总览接口不返回 detailed_notes 和 created_at 字段
semantic_memories.append({
"id": entity_id,
"name": name,
"entity_type": entity_type,
"core_definition": core_definition
})
# ========== 3. 返回结果 ==========
total_count = len(episodic_memories) + len(semantic_memories)
logger.info(
f"成功获取 end_user_id={end_user_id} 的显性记忆总览,"
f"情景记忆={len(episodic_memories)} 条,语义记忆={len(semantic_memories)} 条,"
f"总计 {total_count}"
)
return {
"total": total_count,
"episodic_memories": episodic_memories,
"semantic_memories": semantic_memories
}
except Exception as e:
logger.error(f"获取显性记忆总览时出错: {str(e)}", exc_info=True)
raise
async def get_explicit_memory_details(
self,
end_user_id: str,
memory_id: str
) -> Dict[str, Any]:
"""
获取显性记忆详情
根据 memory_id 查询情景记忆或语义记忆的详细信息。
先尝试查询情景记忆,如果找不到再查询语义记忆。
Args:
end_user_id: 终端用户ID
memory_id: 记忆ID可以是情景记忆或语义记忆的ID
Returns:
情景记忆返回:
{
"memory_type": "episodic",
"title": str,
"content": str,
"emotion": Dict,
"created_at": int
}
语义记忆返回:
{
"memory_type": "semantic",
"name": str,
"core_definition": str,
"detailed_notes": str,
"created_at": int
}
Raises:
ValueError: 当记忆不存在时
"""
try:
logger.info(f"开始查询显性记忆详情: end_user_id={end_user_id}, memory_id={memory_id}")
# ========== 1. 先尝试查询情景记忆 ==========
episodic_query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $memory_id AND s.group_id = $group_id
RETURN s.name AS title,
s.content AS content,
s.created_at AS created_at
"""
episodic_result = await self.neo4j_connector.execute_query(
episodic_query,
memory_id=memory_id,
group_id=end_user_id
)
if episodic_result and len(episodic_result) > 0:
record = episodic_result[0]
title = record.get("title") or "未命名"
content = record.get("content") or ""
created_at_str = record.get("created_at")
# 使用基类方法转换时间戳
created_at_timestamp = self.parse_timestamp(created_at_str)
# 使用基类方法获取情绪信息
emotion = await self.extract_episodic_emotion(
summary_id=memory_id,
end_user_id=end_user_id
)
logger.info(f"成功获取情景记忆详情: memory_id={memory_id}")
return {
"memory_type": "episodic",
"title": title,
"content": content,
"emotion": emotion,
"created_at": created_at_timestamp
}
# ========== 2. 如果不是情景记忆,尝试查询语义记忆 ==========
semantic_query = """
MATCH (e:ExtractedEntity)
WHERE elementId(e) = $memory_id
AND e.group_id = $group_id
AND e.is_explicit_memory = true
RETURN e.name AS name,
e.description AS core_definition,
e.example AS detailed_notes,
e.created_at AS created_at
"""
semantic_result = await self.neo4j_connector.execute_query(
semantic_query,
memory_id=memory_id,
group_id=end_user_id
)
if semantic_result and len(semantic_result) > 0:
record = semantic_result[0]
name = record.get("name") or "未命名"
core_definition = record.get("core_definition") or ""
detailed_notes = record.get("detailed_notes") or ""
created_at_str = record.get("created_at")
# 使用基类方法转换时间戳
created_at_timestamp = self.parse_timestamp(created_at_str)
logger.info(f"成功获取语义记忆详情: memory_id={memory_id}")
return {
"memory_type": "semantic",
"name": name,
"core_definition": core_definition,
"detailed_notes": detailed_notes,
"created_at": created_at_timestamp
}
# ========== 3. 两种记忆都找不到 ==========
logger.warning(f"记忆不存在: memory_id={memory_id}, end_user_id={end_user_id}")
raise ValueError(f"记忆不存在: memory_id={memory_id}")
except ValueError:
# 重新抛出 ValueError记忆不存在
raise
except Exception as e:
logger.error(f"获取显性记忆详情时出错: {str(e)}", exc_info=True)
raise

View File

@@ -15,6 +15,7 @@ from app.core.memory.utils.llm.llm_utils import MemoryClientFactory
from app.db import get_db_context from app.db import get_db_context
from app.repositories.end_user_repository import EndUserRepository from app.repositories.end_user_repository import EndUserRepository
from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.repositories.neo4j.neo4j_connector import Neo4jConnector
from app.services.memory_base_service import MemoryBaseService
from app.services.memory_config_service import MemoryConfigService from app.services.memory_config_service import MemoryConfigService
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -884,866 +885,6 @@ class UserMemoryService:
"errors": errors + [{"error": f"批量处理失败: {str(e)}"}] "errors": errors + [{"error": f"批量处理失败: {str(e)}"}]
} }
async def _get_title_and_type(
self,
summary_id: str,
end_user_id: str
) -> Tuple[str, str]:
"""
读取情景记忆的标题(title)和类型(type)
仅负责读取已存在的title和type不进行生成
title从name属性读取type从memory_type属性读取
Args:
summary_id: Summary节点的ID
end_user_id: 终端用户ID (group_id)
Returns:
(标题, 类型)元组,如果不存在则返回默认值
"""
try:
# 查询Summary节点的name(作为title)和memory_type(作为type)
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
RETURN s.name AS title, s.memory_type AS type
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
if not result or len(result) == 0:
logger.warning(f"未找到 summary_id={summary_id} 的节点")
return ("未知标题", "其他")
record = result[0]
title = record.get("title") or "未命名"
episodic_type = record.get("type") or "其他"
return (title, episodic_type)
except Exception as e:
logger.error(f"读取标题和类型时出错: {str(e)}", exc_info=True)
return ("错误", "其他")
@staticmethod
async def generate_title_and_type_for_summary(
content: str,
end_user_id: str
) -> Tuple[str, str]:
"""
为MemorySummary生成标题和类型静态方法用于创建节点时调用
此方法应该在创建MemorySummary节点时调用生成title和type
Args:
content: Summary的内容文本
end_user_id: 终端用户ID (group_id)
Returns:
(标题, 类型)元组
"""
from app.core.memory.utils.prompt.prompt_utils import render_episodic_title_and_type_prompt
import json
# 定义有效的类型集合
VALID_TYPES = {
"conversation", # 对话
"project_work", # 项目/工作
"learning", # 学习
"decision", # 决策
"important_event" # 重要事件
}
DEFAULT_TYPE = "conversation" # 默认类型
try:
if not content:
logger.warning("content为空无法生成标题和类型")
return ("空内容", DEFAULT_TYPE)
# 1. 渲染Jinja2提示词模板
prompt = await render_episodic_title_and_type_prompt(content)
# 2. 调用LLM生成标题和类型
llm_client = _get_llm_client_for_user(end_user_id)
messages = [
{"role": "user", "content": prompt}
]
response = await llm_client.chat(messages=messages)
# 3. 解析LLM响应
content_response = response.content
if isinstance(content_response, list):
if len(content_response) > 0:
if isinstance(content_response[0], dict):
text = content_response[0].get('text', content_response[0].get('content', str(content_response[0])))
full_response = str(text)
else:
full_response = str(content_response[0])
else:
full_response = ""
elif isinstance(content_response, dict):
full_response = str(content_response.get('text', content_response.get('content', str(content_response))))
else:
full_response = str(content_response) if content_response is not None else ""
# 4. 解析JSON响应
try:
# 尝试从响应中提取JSON
# 移除可能的markdown代码块标记
json_str = full_response.strip()
if json_str.startswith("```json"):
json_str = json_str[7:]
if json_str.startswith("```"):
json_str = json_str[3:]
if json_str.endswith("```"):
json_str = json_str[:-3]
json_str = json_str.strip()
result_data = json.loads(json_str)
title = result_data.get("title", "未知标题")
episodic_type_raw = result_data.get("type", DEFAULT_TYPE)
# 5. 校验和归一化类型
# 将类型转换为小写并去除空格
episodic_type_normalized = str(episodic_type_raw).lower().strip()
# 检查是否在有效类型集合中
if episodic_type_normalized in VALID_TYPES:
episodic_type = episodic_type_normalized
else:
# 尝试映射常见的中文类型到英文
type_mapping = {
"对话": "conversation",
"项目": "project_work",
"工作": "project_work",
"项目/工作": "project_work",
"学习": "learning",
"决策": "decision",
"重要事件": "important_event",
"事件": "important_event"
}
episodic_type = type_mapping.get(episodic_type_raw, DEFAULT_TYPE)
logger.warning(
f"LLM返回的类型 '{episodic_type_raw}' 不在有效集合中,"
f"已归一化为 '{episodic_type}'"
)
logger.info(f"成功生成标题和类型: title={title}, type={episodic_type}")
return (title, episodic_type)
except json.JSONDecodeError:
logger.error(f"无法解析LLM响应为JSON: {full_response}")
return ("解析失败", DEFAULT_TYPE)
except Exception as e:
logger.error(f"生成标题和类型时出错: {str(e)}", exc_info=True)
return ("错误", DEFAULT_TYPE)
async def _extract_involved_objects(
self,
summary_id: str,
end_user_id: str
) -> List[str]:
"""
提取情景记忆涉及的前3个最重要实体
Args:
summary_id: Summary节点的ID
end_user_id: 终端用户ID (group_id)
Returns:
前3个实体的name属性列表
"""
try:
# 查询Summary节点指向的Statement节点,再查询Statement指向的ExtractedEntity节点
# 按activation_value降序排序,返回前3个
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement)
MATCH (stmt)-[:REFERENCES_ENTITY]->(entity:ExtractedEntity)
WHERE entity.activation_value IS NOT NULL
RETURN DISTINCT entity.name AS name, entity.activation_value AS activation
ORDER BY activation DESC
LIMIT 3
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
# 提取实体名称
involved_objects = [record["name"] for record in result if record.get("name")]
logger.info(f"成功提取 summary_id={summary_id} 的涉及对象: {involved_objects}")
return involved_objects
except Exception as e:
logger.error(f"提取涉及对象时出错: {str(e)}", exc_info=True)
return []
async def _extract_content_records(
self,
summary_id: str,
end_user_id: str
) -> List[str]:
"""
提取情景记忆的内容记录
Args:
summary_id: Summary节点的ID
end_user_id: 终端用户ID (group_id)
Returns:
所有Statement节点的statement属性内容列表
"""
try:
# 查询Summary节点指向的所有Statement节点
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement)
WHERE stmt.statement IS NOT NULL AND stmt.statement <> ''
RETURN stmt.statement AS statement
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
# 提取statement内容
content_records = [record["statement"] for record in result if record.get("statement")]
logger.info(f"成功提取 summary_id={summary_id} 的内容记录,共 {len(content_records)}")
return content_records
except Exception as e:
logger.error(f"提取内容记录时出错: {str(e)}", exc_info=True)
return []
async def _extract_episodic_emotion(
self,
summary_id: str,
end_user_id: str
) -> Optional[str]:
"""
提取情景记忆的主要情绪
Args:
summary_id: Summary节点的ID
end_user_id: 终端用户ID (group_id)
Returns:
最大emotion_intensity对应的emotion_type,如果没有则返回None
"""
try:
# 查询Summary节点指向的所有Statement节点
# 筛选具有emotion_type属性的节点
# 按emotion_intensity降序排序,返回第一个
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement)
WHERE stmt.emotion_type IS NOT NULL
AND stmt.emotion_intensity IS NOT NULL
RETURN stmt.emotion_type AS emotion_type,
stmt.emotion_intensity AS emotion_intensity
ORDER BY emotion_intensity DESC
LIMIT 1
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
# 提取emotion_type
if result and len(result) > 0:
emotion_type = result[0].get("emotion_type")
logger.info(f"成功提取 summary_id={summary_id} 的情绪: {emotion_type}")
return emotion_type
else:
logger.info(f"summary_id={summary_id} 没有情绪信息")
return None
except Exception as e:
logger.error(f"提取情景记忆情绪时出错: {str(e)}", exc_info=True)
return None
async def get_episodic_memory_overview(
self,
db: Session,
end_user_id: str,
time_range: str = "all",
episodic_type: str = "all",
title_keyword: Optional[str] = None
) -> Dict[str, Any]:
"""
获取情景记忆总览信息
Args:
db: 数据库会话
end_user_id: 终端用户ID
time_range: 时间范围筛选
episodic_type: 情景类型筛选
title_keyword: 标题关键词(可选,用于模糊搜索)
"""
try:
logger.info(
f"开始查询 end_user_id={end_user_id} 的情景记忆总览, "
f"time_range={time_range}, episodic_type={episodic_type}, title_keyword={title_keyword}"
)
# 1. 先查询所有情景记忆的总数(不受筛选条件限制)
total_all_query = """
MATCH (s:MemorySummary)
WHERE s.group_id = $group_id
RETURN count(s) AS total_all
"""
total_all_result = await self.neo4j_connector.execute_query(
total_all_query,
group_id=end_user_id
)
total_all = total_all_result[0]["total_all"] if total_all_result else 0
# 2. 计算时间范围的起始时间戳
time_filter = self._calculate_time_filter(time_range)
# 3. 构建Cypher查询
query = """
MATCH (s:MemorySummary)
WHERE s.group_id = $group_id
"""
# 添加时间范围过滤
if time_filter:
query += " AND s.created_at >= $time_filter"
# 添加标题关键词过滤如果提供了title_keyword
if title_keyword:
query += " AND toLower(s.name) CONTAINS toLower($title_keyword)"
query += """
RETURN elementId(s) AS id,
s.created_at AS created_at,
s.memory_type AS type,
s.name AS title
ORDER BY s.created_at DESC
"""
params = {"group_id": end_user_id}
if time_filter:
params["time_filter"] = time_filter
if title_keyword:
params["title_keyword"] = title_keyword
result = await self.neo4j_connector.execute_query(query, **params)
# 4. 如果没有数据,返回空列表
if not result:
logger.info(f"end_user_id={end_user_id} 没有情景记忆数据")
return {
"total": 0,
"total_all": total_all,
"episodic_memories": []
}
# 5. 对每个节点读取标题和类型,并应用类型筛选
episodic_memories = []
for record in result:
summary_id = record["id"]
created_at_str = record.get("created_at")
memory_type = record.get("type", "其他")
title = record.get("title") or "未命名" # 直接从查询结果获取标题
# 应用情景类型筛选
if episodic_type != "all":
# 检查类型是否匹配
# 注意Neo4j 中存储的 memory_type 现在应该是英文格式(如 "conversation", "project_work"
# 但为了兼容旧数据,我们也支持中文格式的匹配
type_mapping = {
"conversation": "对话",
"project_work": "项目/工作",
"learning": "学习",
"decision": "决策",
"important_event": "重要事件"
}
# 获取对应的中文类型(用于兼容旧数据)
chinese_type = type_mapping.get(episodic_type)
# 检查类型是否匹配(支持新的英文格式和旧的中文格式)
if memory_type != episodic_type and memory_type != chinese_type:
continue
# 转换时间戳
created_at_timestamp = None
if created_at_str:
try:
from datetime import datetime
dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
created_at_timestamp = int(dt_object.timestamp() * 1000)
except (ValueError, TypeError, AttributeError) as e:
logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}")
episodic_memories.append({
"id": summary_id,
"title": title,
"type": memory_type,
"created_at": created_at_timestamp
})
logger.info(
f"成功获取 end_user_id={end_user_id} 的情景记忆总览,"
f"筛选后 {len(episodic_memories)} 条,总共 {total_all}"
)
return {
"total": len(episodic_memories),
"total_all": total_all,
"episodic_memories": episodic_memories
}
except Exception as e:
logger.error(f"获取情景记忆总览时出错: {str(e)}", exc_info=True)
raise
def _calculate_time_filter(self, time_range: str) -> Optional[str]:
"""
根据时间范围计算过滤的起始时间
Args:
time_range: 时间范围 (all/today/this_week/this_month)
Returns:
ISO格式的时间字符串如果是"all"则返回None
"""
from datetime import datetime, timedelta
import pytz
if time_range == "all":
return None
# 获取当前时间UTC
now = datetime.now(pytz.UTC)
if time_range == "today":
# 今天的开始时间00:00:00
start_time = now.replace(hour=0, minute=0, second=0, microsecond=0)
elif time_range == "this_week":
# 本周的开始时间周一00:00:00
days_since_monday = now.weekday()
start_time = (now - timedelta(days=days_since_monday)).replace(
hour=0, minute=0, second=0, microsecond=0
)
elif time_range == "this_month":
# 本月的开始时间1号00:00:00
start_time = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
else:
return None
# 返回ISO格式字符串
return start_time.isoformat()
async def get_episodic_memory_details(
self,
db: Session,
end_user_id: str,
summary_id: str
) -> Dict[str, Any]:
"""
获取单个情景记忆的详细信息
"""
try:
logger.info(f"开始查询 end_user_id={end_user_id}, summary_id={summary_id} 的情景记忆详情")
# 1. 查询指定的MemorySummary节点
query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $summary_id AND s.group_id = $group_id
RETURN elementId(s) AS id, s.created_at AS created_at
"""
result = await self.neo4j_connector.execute_query(
query,
summary_id=summary_id,
group_id=end_user_id
)
# 2. 如果节点不存在,返回错误
if not result or len(result) == 0:
logger.warning(f"未找到 summary_id={summary_id} 的情景记忆")
raise ValueError(f"情景记忆不存在: summary_id={summary_id}")
# 3. 获取基本信息
record = result[0]
created_at_str = record.get("created_at")
# 转换时间戳
created_at_timestamp = None
if created_at_str:
try:
from datetime import datetime
dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
created_at_timestamp = int(dt_object.timestamp() * 1000)
except (ValueError, TypeError, AttributeError) as e:
logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}")
# 4. 调用_get_title_and_type读取标题和类型
title, episodic_type = await self._get_title_and_type(
summary_id=summary_id,
end_user_id=end_user_id
)
# 5. 调用_extract_involved_objects提取涉及对象
involved_objects = await self._extract_involved_objects(
summary_id=summary_id,
end_user_id=end_user_id
)
# 6. 调用_extract_content_records提取内容记录
content_records = await self._extract_content_records(
summary_id=summary_id,
end_user_id=end_user_id
)
# 7. 调用_extract_episodic_emotion提取情绪
emotion = await self._extract_episodic_emotion(
summary_id=summary_id,
end_user_id=end_user_id
)
# 8. 返回完整的详情信息
details = {
"id": summary_id,
"created_at": created_at_timestamp,
"involved_objects": involved_objects,
"episodic_type": episodic_type,
"content_records": content_records,
"emotion": emotion
}
logger.info(f"成功获取 summary_id={summary_id} 的情景记忆详情")
return details
except ValueError:
# 重新抛出ValueError让Controller层处理
raise
except Exception as e:
logger.error(f"获取情景记忆详情时出错: {str(e)}", exc_info=True)
raise
async def get_explicit_memory_overview(
self,
db: Session,
end_user_id: str
) -> Dict[str, Any]:
"""
获取显性记忆总览信息
返回两部分:
1. 情景记忆episodic_memories- 来自MemorySummary节点
2. 语义记忆semantic_memories- 来自ExtractedEntity节点is_explicit_memory=true
Args:
db: 数据库会话
end_user_id: 终端用户ID
Returns:
{
"total": int,
"episodic_memories": [
{
"id": str,
"title": str,
"content": str,
"created_at": int,
"emotion": Dict
}
],
"semantic_memories": [
{
"id": str,
"name": str,
"entity_type": str,
"core_definition": str,
"detailed_notes": str,
"created_at": int
}
]
}
"""
try:
logger.info(f"开始查询 end_user_id={end_user_id} 的显性记忆总览(情景记忆+语义记忆)")
# ========== 1. 查询情景记忆MemorySummary节点 ==========
episodic_query = """
MATCH (s:MemorySummary)
WHERE s.group_id = $group_id
RETURN elementId(s) AS id,
s.name AS title,
s.content AS content,
s.created_at AS created_at
ORDER BY s.created_at DESC
"""
episodic_result = await self.neo4j_connector.execute_query(
episodic_query,
group_id=end_user_id
)
# 处理情景记忆数据
episodic_memories = []
if episodic_result:
for record in episodic_result:
summary_id = record["id"]
title = record.get("title") or "未命名"
content = record.get("content") or ""
created_at_str = record.get("created_at")
# 转换时间戳
created_at_timestamp = None
if created_at_str:
try:
from datetime import datetime
dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
created_at_timestamp = int(dt_object.timestamp() * 1000)
except (ValueError, TypeError, AttributeError) as e:
logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}")
# 注意:总览接口不返回 emotion 字段
episodic_memories.append({
"id": summary_id,
"title": title,
"content": content,
"created_at": created_at_timestamp
})
# ========== 2. 查询语义记忆ExtractedEntity节点 ==========
semantic_query = """
MATCH (e:ExtractedEntity)
WHERE e.group_id = $group_id
AND e.is_explicit_memory = true
RETURN elementId(e) AS id,
e.name AS name,
e.entity_type AS entity_type,
e.description AS core_definition,
e.example AS detailed_notes,
e.created_at AS created_at
ORDER BY e.created_at DESC
"""
semantic_result = await self.neo4j_connector.execute_query(
semantic_query,
group_id=end_user_id
)
# 处理语义记忆数据
semantic_memories = []
if semantic_result:
for record in semantic_result:
entity_id = record["id"]
name = record.get("name") or "未命名"
entity_type = record.get("entity_type") or "未分类"
core_definition = record.get("core_definition") or ""
created_at_str = record.get("created_at")
# 转换时间戳
created_at_timestamp = None
if created_at_str:
try:
from datetime import datetime
dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
created_at_timestamp = int(dt_object.timestamp() * 1000)
except (ValueError, TypeError, AttributeError) as e:
logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}")
# 注意:总览接口不返回 detailed_notes 字段
semantic_memories.append({
"id": entity_id,
"name": name,
"entity_type": entity_type,
"core_definition": core_definition,
"created_at": created_at_timestamp
})
# ========== 3. 返回结果 ==========
total_count = len(episodic_memories) + len(semantic_memories)
logger.info(
f"成功获取 end_user_id={end_user_id} 的显性记忆总览,"
f"情景记忆={len(episodic_memories)} 条,语义记忆={len(semantic_memories)} 条,"
f"总计 {total_count}"
)
return {
"total": total_count,
"episodic_memories": episodic_memories,
"semantic_memories": semantic_memories
}
except Exception as e:
logger.error(f"获取显性记忆总览时出错: {str(e)}", exc_info=True)
raise
async def get_explicit_memory_details(
self,
db: Session,
end_user_id: str,
memory_id: str
) -> Dict[str, Any]:
"""
获取显性记忆详情
根据 memory_id 查询情景记忆或语义记忆的详细信息。
先尝试查询情景记忆,如果找不到再查询语义记忆。
Args:
db: 数据库会话
end_user_id: 终端用户ID
memory_id: 记忆ID可以是情景记忆或语义记忆的ID
Returns:
情景记忆返回:
{
"memory_type": "episodic",
"title": str,
"content": str,
"emotion": Dict,
"created_at": int
}
语义记忆返回:
{
"memory_type": "semantic",
"name": str,
"core_definition": str,
"detailed_notes": str,
"created_at": int
}
Raises:
ValueError: 当记忆不存在时
"""
try:
logger.info(f"开始查询显性记忆详情: end_user_id={end_user_id}, memory_id={memory_id}")
# ========== 1. 先尝试查询情景记忆 ==========
episodic_query = """
MATCH (s:MemorySummary)
WHERE elementId(s) = $memory_id AND s.group_id = $group_id
RETURN s.name AS title,
s.content AS content,
s.created_at AS created_at
"""
episodic_result = await self.neo4j_connector.execute_query(
episodic_query,
memory_id=memory_id,
group_id=end_user_id
)
if episodic_result and len(episodic_result) > 0:
record = episodic_result[0]
title = record.get("title") or "未命名"
content = record.get("content") or ""
created_at_str = record.get("created_at")
# 转换时间戳
created_at_timestamp = None
if created_at_str:
try:
from datetime import datetime
dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
created_at_timestamp = int(dt_object.timestamp() * 1000)
except (ValueError, TypeError, AttributeError) as e:
logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}")
# 获取情绪信息
emotion = await self._extract_episodic_emotion(
summary_id=memory_id,
end_user_id=end_user_id
)
logger.info(f"成功获取情景记忆详情: memory_id={memory_id}")
return {
"memory_type": "episodic",
"title": title,
"content": content,
"emotion": emotion,
"created_at": created_at_timestamp
}
# ========== 2. 如果不是情景记忆,尝试查询语义记忆 ==========
semantic_query = """
MATCH (e:ExtractedEntity)
WHERE elementId(e) = $memory_id
AND e.group_id = $group_id
AND e.is_explicit_memory = true
RETURN e.name AS name,
e.description AS core_definition,
e.example AS detailed_notes,
e.created_at AS created_at
"""
semantic_result = await self.neo4j_connector.execute_query(
semantic_query,
memory_id=memory_id,
group_id=end_user_id
)
if semantic_result and len(semantic_result) > 0:
record = semantic_result[0]
name = record.get("name") or "未命名"
core_definition = record.get("core_definition") or ""
detailed_notes = record.get("detailed_notes") or ""
created_at_str = record.get("created_at")
# 转换时间戳
created_at_timestamp = None
if created_at_str:
try:
from datetime import datetime
dt_object = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
created_at_timestamp = int(dt_object.timestamp() * 1000)
except (ValueError, TypeError, AttributeError) as e:
logger.warning(f"无法解析时间戳: {created_at_str}, error={str(e)}")
logger.info(f"成功获取语义记忆详情: memory_id={memory_id}")
return {
"memory_type": "semantic",
"name": name,
"core_definition": core_definition,
"detailed_notes": detailed_notes,
"created_at": created_at_timestamp
}
# ========== 3. 两种记忆都找不到 ==========
logger.warning(f"记忆不存在: memory_id={memory_id}, end_user_id={end_user_id}")
raise ValueError(f"记忆不存在: memory_id={memory_id}")
except ValueError:
# 重新抛出 ValueError记忆不存在
raise
except Exception as e:
logger.error(f"获取显性记忆详情时出错: {str(e)}", exc_info=True)
raise
# 独立的分析函数 # 独立的分析函数
@@ -2055,17 +1196,18 @@ async def analytics_memory_types(
end_user_id: Optional[str] = None end_user_id: Optional[str] = None
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
统计8种记忆类型的数量和百分比 统计9种记忆类型的数量和百分比
计算规则: 计算规则:
1. 感知记忆 (PERCEPTUAL_MEMORY) = statement + entity 1. 感知记忆 (PERCEPTUAL_MEMORY) = statement + entity
2. 工作记忆 (WORKING_MEMORY) = chunk + entity 2. 工作记忆 (WORKING_MEMORY) = chunk + entity
3. 短期记忆 (SHORT_TERM_MEMORY) = chunk 3. 短期记忆 (SHORT_TERM_MEMORY) = chunk
4. 长期记忆 (LONG_TERM_MEMORY) = entity 4. 长期记忆 (LONG_TERM_MEMORY) = entity
5. 显性记忆 (EXPLICIT_MEMORY) = 1/2 * entity 5. 显性记忆 (EXPLICIT_MEMORY) = 情景记忆 + 语义记忆(通过 MemoryBaseService.get_explicit_memory_count 获取)
6. 隐性记忆 (IMPLICIT_MEMORY) = 1/3 * entity 6. 隐性记忆 (IMPLICIT_MEMORY) = 1/3 * entity
7. 情绪记忆 (EMOTIONAL_MEMORY) = statement 7. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取)
8. 情景记忆 (EPISODIC_MEMORY) = memory_summary 8. 情景记忆 (EPISODIC_MEMORY) = memory_summary(通过 MemoryBaseService.get_episodic_memory_count 获取)
9. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_memory_count 获取)
Args: Args:
db: 数据库会话 db: 数据库会话
@@ -2090,13 +1232,16 @@ async def analytics_memory_types(
- IMPLICIT_MEMORY: 隐性记忆 - IMPLICIT_MEMORY: 隐性记忆
- EMOTIONAL_MEMORY: 情绪记忆 - EMOTIONAL_MEMORY: 情绪记忆
- EPISODIC_MEMORY: 情景记忆 - EPISODIC_MEMORY: 情景记忆
- FORGET_MEMORY: 遗忘记忆
""" """
# 定义需要查询的节点类型 # 初始化基础服务
base_service = MemoryBaseService()
# 定义需要查询的基础节点类型
node_types = { node_types = {
"Statement": "Statement", "Statement": "Statement",
"Entity": "ExtractedEntity", "Entity": "ExtractedEntity",
"Chunk": "Chunk", "Chunk": "Chunk"
"MemorySummary": "MemorySummary"
} }
# 存储每种节点类型的计数 # 存储每种节点类型的计数
@@ -2126,18 +1271,45 @@ async def analytics_memory_types(
statement_count = node_counts.get("Statement", 0) statement_count = node_counts.get("Statement", 0)
entity_count = node_counts.get("Entity", 0) entity_count = node_counts.get("Entity", 0)
chunk_count = node_counts.get("Chunk", 0) chunk_count = node_counts.get("Chunk", 0)
memory_summary_count = node_counts.get("MemorySummary", 0)
# 按规则计算8种记忆类型的数量使用英文枚举作为key # 获取用户的遗忘阈值配置
forgetting_threshold = 0.3 # 默认值
if end_user_id:
try:
from app.services.memory_agent_service import get_end_user_connected_config
from app.core.memory.storage_services.forgetting_engine.config_utils import load_actr_config_from_db
# 获取用户关联的 config_id
connected_config = get_end_user_connected_config(end_user_id, db)
config_id = connected_config.get('memory_config_id')
if config_id:
# 从数据库加载配置
config = load_actr_config_from_db(db, config_id)
forgetting_threshold = config.get('forgetting_threshold', 0.3)
logger.debug(f"使用用户配置的遗忘阈值: {forgetting_threshold} (end_user_id={end_user_id}, config_id={config_id})")
else:
logger.debug(f"用户未关联配置,使用默认遗忘阈值: {forgetting_threshold} (end_user_id={end_user_id})")
except Exception as e:
logger.warning(f"获取用户遗忘阈值配置失败,使用默认值 {forgetting_threshold}: {str(e)}")
# 使用 MemoryBaseService 的共享方法获取特殊记忆类型的数量
episodic_count = await base_service.get_episodic_memory_count(end_user_id)
explicit_count = await base_service.get_explicit_memory_count(end_user_id)
emotion_count = await base_service.get_emotional_memory_count(end_user_id, statement_count)
forget_count = await base_service.get_forget_memory_count(end_user_id, forgetting_threshold)
# 按规则计算9种记忆类型的数量使用英文枚举作为key
memory_counts = { memory_counts = {
"PERCEPTUAL_MEMORY": statement_count + entity_count, # 感知记忆 "PERCEPTUAL_MEMORY": statement_count + entity_count, # 感知记忆
"WORKING_MEMORY": chunk_count + entity_count, # 工作记忆 "WORKING_MEMORY": chunk_count + entity_count, # 工作记忆
"SHORT_TERM_MEMORY": chunk_count, # 短期记忆 "SHORT_TERM_MEMORY": chunk_count, # 短期记忆
"LONG_TERM_MEMORY": entity_count, # 长期记忆 "LONG_TERM_MEMORY": entity_count, # 长期记忆
"EXPLICIT_MEMORY": entity_count // 2, # 显性记忆 (1/2 entity) "EXPLICIT_MEMORY": explicit_count, # 显性记忆(情景记忆 + 语义记忆)
"IMPLICIT_MEMORY": entity_count // 3, # 隐性记忆 (1/3 entity) "IMPLICIT_MEMORY": entity_count // 3, # 隐性记忆 (1/3 entity)
"EMOTIONAL_MEMORY": statement_count, # 情绪记忆 "EMOTIONAL_MEMORY": emotion_count, # 情绪记忆(使用情绪标签统计)
"EPISODIC_MEMORY": memory_summary_count # 情景记忆 "EPISODIC_MEMORY": episodic_count, # 情景记忆
"FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值)
} }
# 计算总数 # 计算总数

33
api/app/version_info.json Normal file
View File

@@ -0,0 +1,33 @@
{
"v0.2.0": {
"codeName": "启知",
"releaseDate": "2026-1-16",
"upgradePosition": "本次为架构升级,核心目标是把“被动存储”升级为“主动认知”,让系统具备情绪感知、情景理解与类人记忆机制,为后续多智能体协作与专业场景落地奠定底座。",
"coreUpgrades": [
"记忆详情:拟人记忆——情绪引擎、情景记忆、短期记忆、工作记忆、感知记忆、显性记忆、隐性记忆,并配套类脑遗忘机制,实现从感知→情绪→情景→长期沉淀的完整人类记忆闭环",
"可视化工作流拖拽式节点编排LLM、知识库、逻辑、工具业务落地周期由天缩至小时。",
"多模态知识处理PDF、PPT、MP3、MP4 一键解析,时间感知检索准确率 94.3%,问答对数据即插即用。",
"Agent集群内置“记忆-知识-工具-审核”四类角色模板用户一键生成主控Agent把复杂任务拆为子任务并行分发再靠情景记忆统一消解冲突、校验一致性输出完整报告。"
]
},
"v0.1.0": {
"codeName": "初心",
"releaseDate": "2025-12-01",
"upgradePosition": "这是一款专注于管理和利用AI记忆的工具支持RAG和知识图谱两种主流存储方式旨在为AI应用提供持久化、结构化的“记忆”能力。",
"coreUpgrades": [
"记忆空间:用户可以创建独立的空间来隔离不同记忆,并灵活选择存储方式。",
"记忆配置:简化了配置流程,内置自动提取关键信息的“记忆萃取”和管理生命周期的\"遗忘\"引擎。",
"知识检索:提供语义、分词和混合三种检索模式,并支持多种参数微调和结果重排序,以提升召回效果。",
"全局管理:支持统一设置默认检索参数,并可一键应用到所有知识库。",
"测试与调试:内置\"召回测试\"功能,方便用户实时验证检索效果并调整参数,支持通过分享码与他人协作。",
"记忆洞察可查看详细的对话记录、用户画像和分析报告帮助理解AI的\"记忆\"内容。",
"集成与管理提供API Key用于系统集成并包含基本的用户管理功能。",
"界面与体验:采用现代化的卡片式布局和渐变色设计,注重交互的流畅性和视觉美感。",
"起步与使用:文档中提供了清晰的基础使用流程,引导用户从创建空间、配置记忆到测试检索快速上手。",
"版本说明与限制: 记忆熊 v0.1.0 版本\"初心\"囊括智能记忆管理的核心思路和基础能力,为后续开发奠定了基础。",
"文档资源用户手册、API文档、FAQ",
"问题反馈GitHub Issues、邮件支持",
"致谢:感谢所有参与测试和提供反馈的用户!"
]
}
}

View File

@@ -0,0 +1,30 @@
"""20261511
Revision ID: 9ab9b6393f32
Revises: 793c31683aa5
Create Date: 2026-01-13 15:14:54.708405
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9ab9b6393f32'
down_revision: Union[str, None] = '793c31683aa5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('tool_executions_user_id_fkey'), 'tool_executions', type_='foreignkey')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_foreign_key(op.f('tool_executions_user_id_fkey'), 'tool_executions', 'users', ['user_id'], ['id'])
# ### end Alembic commands ###

View File

@@ -25,7 +25,12 @@ export interface DataResponse {
} }
export interface versionResponse{ export interface versionResponse{
version: string; version: string;
introduction: string; introduction: {
releaseDate: string;
upgradePosition: string;
coreUpgrades: string[];
codeName: string;
};
} }
// 首页数据统计 // 首页数据统计
export const getDashboardData = `/home-page/workspaces` export const getDashboardData = `/home-page/workspaces`

View File

@@ -117,26 +117,26 @@ export const getRagContent = (end_user_id: string) => {
} }
// 情感分布分析 // 情感分布分析
export const getWordCloud = (group_id: string) => { export const getWordCloud = (group_id: string) => {
return request.post(`/memory/emotion/wordcloud`, { group_id, limit: 20 }) return request.post(`/memory/emotion-memory/wordcloud`, { group_id, limit: 20 })
} }
// 高频情绪关键词 // 高频情绪关键词
export const getEmotionTags = (group_id: string) => { export const getEmotionTags = (group_id: string) => {
return request.post(`/memory/emotion/tags`, { group_id, limit: 20 }) return request.post(`/memory/emotion-memory/tags`, { group_id, limit: 20 })
} }
// 情绪健康指数 // 情绪健康指数
export const getEmotionHealth = (group_id: string) => { export const getEmotionHealth = (group_id: string) => {
return request.post(`/memory/emotion/health`, { group_id, limit: 20 }) return request.post(`/memory/emotion-memory/health`, { group_id, limit: 20 })
} }
// 个性化建议 // 个性化建议
export const getEmotionSuggestions = (group_id: string) => { export const getEmotionSuggestions = (group_id: string) => {
return request.post(`/memory/emotion/suggestions`, { group_id, limit: 20 }) return request.post(`/memory/emotion-memory/suggestions`, { group_id, limit: 20 })
} }
export const analyticsRefresh = (end_user_id: string) => { export const analyticsRefresh = (end_user_id: string) => {
return request.post('/memory-storage/analytics/generate_cache', { end_user_id }) return request.post('/memory-storage/analytics/generate_cache', { end_user_id })
} }
// 遗忘 // 遗忘
export const getForgetStats = (group_id: string) => { export const getForgetStats = (group_id: string) => {
return request.get(`/memory/forget/stats`, { group_id }) return request.get(`/memory/forget-memory/stats`, { group_id })
} }
// 隐性记忆-偏好 // 隐性记忆-偏好
export const getImplicitPreferences = (end_user_id: string) => { export const getImplicitPreferences = (end_user_id: string) => {
@@ -176,10 +176,10 @@ export const getPerceptualTimeline = (end_user: string) => {
} }
// 情景记忆-总览 // 情景记忆-总览
export const getEpisodicOverview = (data: { end_user_id: string; time_range: string; episodic_type: string; } ) => { export const getEpisodicOverview = (data: { end_user_id: string; time_range: string; episodic_type: string; } ) => {
return request.post(`/memory-storage/classifications/episodic-memory`, data) return request.post(`/memory/episodic-memory/overview`, data)
} }
export const getEpisodicDetail = (data: { end_user_id: string; summary_id: string; } ) => { export const getEpisodicDetail = (data: { end_user_id: string; summary_id: string; } ) => {
return request.post(`/memory-storage/classifications/episodic-memory-details`, data) return request.post(`/memory/episodic-memory/details`, data)
} }
// 关系演化 // 关系演化
export const getRelationshipEvolution = (data: { id: string; label: string; } ) => { export const getRelationshipEvolution = (data: { id: string; label: string; } ) => {
@@ -190,10 +190,10 @@ export const getTimelineMemories = (data: { id: string; label: string; }) => {
return request.get(`/memory-storage/memory_space/timeline_memories`, data) return request.get(`/memory-storage/memory_space/timeline_memories`, data)
} }
export const getExplicitMemory = (end_user_id: string) => { export const getExplicitMemory = (end_user_id: string) => {
return request.post(`/memory-storage/classifications/explicit-memory`, { end_user_id }) return request.post(`/memory/explicit-memory/overview`, { end_user_id })
} }
export const getExplicitMemoryDetails = (data: { end_user_id: string, memory_id: string; }) => { export const getExplicitMemoryDetails = (data: { end_user_id: string, memory_id: string; }) => {
return request.post(`/memory-storage/classifications/explicit-memory-details`, data) return request.post(`/memory/explicit-memory/details`, data)
} }
export const getConversations = (end_user: string) => { export const getConversations = (end_user: string) => {
return request.get(`/memory/work/${end_user}/conversations`) return request.get(`/memory/work/${end_user}/conversations`)
@@ -204,8 +204,9 @@ export const getConversationMessages = (end_user: string, conversation_id: strin
export const getConversationDetail = (end_user: string, conversation_id: string) => { export const getConversationDetail = (end_user: string, conversation_id: string) => {
return request.get(`/memory/work/${end_user}/detail`, { conversation_id }) return request.get(`/memory/work/${end_user}/detail`, { conversation_id })
} }
export const forgetTrigger = (data: { max_merge_batch_size: number; min_days_since_access: number; end_user_id: string;}) => {
return request.post(`/memory/forget-memory/trigger`, data)
}
/*************** end 用户记忆 相关接口 ******************************/ /*************** end 用户记忆 相关接口 ******************************/
/****************** 记忆管理 相关接口 *******************************/ /****************** 记忆管理 相关接口 *******************************/
@@ -228,11 +229,11 @@ export const deleteMemoryConfig = (config_id: number) => {
} }
// 遗忘引擎-获取配置 // 遗忘引擎-获取配置
export const getMemoryForgetConfig = (config_id: number | string) => { export const getMemoryForgetConfig = (config_id: number | string) => {
return request.get('/memory/forget/read_config', { config_id }) return request.get('/memory/forget-memory/read_config', { config_id })
} }
// 遗忘引擎-更新配置 // 遗忘引擎-更新配置
export const updateMemoryForgetConfig = (values: ForgetConfigForm) => { export const updateMemoryForgetConfig = (values: ForgetConfigForm) => {
return request.post('/memory/forget/update_config', values) return request.post('/memory/forget-memory/update_config', values)
} }
// 记忆萃取引擎-获取配置 // 记忆萃取引擎-获取配置
export const getMemoryExtractionConfig = (config_id: number | string) => { export const getMemoryExtractionConfig = (config_id: number | string) => {

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>退出</title>
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="应用管理-编排-默认状态" transform="translate(-1262, -24)" stroke="#155EEF">
<g id="返回空间" transform="translate(1262, 24)">
<g id="退出" transform="translate(8, 8) scale(-1, 1) translate(-8, -8)">
<g id="编组-7" transform="translate(2.5, 2)">
<path d="M6,12 L1,12 C0.44771525,12 0,11.5522847 0,11 L0,1 C0,0.44771525 0.44771525,1.11022302e-16 1,0 L6,0 L6,0" id="路径"></path>
<line x1="11" y1="6" x2="3" y2="6" id="路径-6"></line>
<polyline id="路径" points="8 3 11 6 8 9"></polyline>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>模型 (1)</title>
<g id="v0.2.0" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="红熊空间-记忆管理" transform="translate(-24, -409)" stroke="#5B6167">
<g id="记忆对话备份-2" transform="translate(12, 401)">
<g id="模型-(1)" transform="translate(12, 8)">
<g id="编组-21" transform="translate(1.5, 1.5)">
<path d="M7,0.288675135 L11.6291651,2.96132487 C11.9385662,3.13995766 12.1291651,3.47008468 12.1291651,3.82735027 L12.1291651,9.17264973 C12.1291651,9.52991532 11.9385662,9.86004234 11.6291651,10.0386751 L7,12.7113249 C6.69059892,12.8899577 6.30940108,12.8899577 6,12.7113249 L1.37083488,10.0386751 C1.0614338,9.86004234 0.870834875,9.52991532 0.870834875,9.17264973 L0.870834875,3.82735027 C0.870834875,3.47008468 1.0614338,3.13995766 1.37083488,2.96132487 L6,0.288675135 C6.30940108,0.11004234 6.69059892,0.11004234 7,0.288675135 Z" id="多边形"></path>
<polyline id="路径-15" points="0.931223827 3.37218958 6.5 6.5 6.5 12.8581283"></polyline>
<line x1="6.5" y1="6.49748419" x2="12.0714286" y2="3.37218958" id="路径-16"></line>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>模型 (1)</title>
<g id="v0.2.0" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="红熊空间-记忆管理" transform="translate(-24, -409)" stroke="#212332">
<g id="记忆对话备份-2" transform="translate(12, 401)">
<g id="模型-(1)" transform="translate(12, 8)">
<g id="编组-21" transform="translate(1.5, 1.5)">
<path d="M7,0.288675135 L11.6291651,2.96132487 C11.9385662,3.13995766 12.1291651,3.47008468 12.1291651,3.82735027 L12.1291651,9.17264973 C12.1291651,9.52991532 11.9385662,9.86004234 11.6291651,10.0386751 L7,12.7113249 C6.69059892,12.8899577 6.30940108,12.8899577 6,12.7113249 L1.37083488,10.0386751 C1.0614338,9.86004234 0.870834875,9.52991532 0.870834875,9.17264973 L0.870834875,3.82735027 C0.870834875,3.47008468 1.0614338,3.13995766 1.37083488,2.96132487 L6,0.288675135 C6.30940108,0.11004234 6.69059892,0.11004234 7,0.288675135 Z" id="多边形"></path>
<polyline id="路径-15" points="0.931223827 3.37218958 6.5 6.5 6.5 12.8581283"></polyline>
<line x1="6.5" y1="6.49748419" x2="12.0714286" y2="3.37218958" id="路径-16"></line>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="28px" height="28px" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组 13备份</title>
<g id="V1.1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="红熊空间-记忆管理" transform="translate(-947, -144)">
<g id="1备份-2" transform="translate(651, 128)">
<g id="编组-13备份" transform="translate(296, 16)">
<rect id="矩形" stroke="#DFE4ED" x="0.5" y="0.5" width="27" height="27" rx="6"></rect>
<g id="进入@2x" transform="translate(5.8333, 5.8333)">
<g id="编组-11" transform="translate(2.0417, 2.5521)">
<path d="M5.42385066,3.34516089 L8.15899029,5.47250014 C8.23746067,5.5335329 8.25159666,5.64662254 8.1905639,5.72509292 C8.1813906,5.73688711 8.17078448,5.74749323 8.15899029,5.75666652 L5.42385066,7.88400578 C5.34538028,7.94503854 5.23229064,7.93090256 5.17125788,7.85243218 C5.14668314,7.82083621 5.13334107,7.78195037 5.13334107,7.74192259 L5.13334107,6.2384308 L5.13334107,6.2384308 L0,6.2384308 L0,4.99073587 L5.13334107,4.99073587 L5.13334107,3.48724407 C5.13334107,3.38783282 5.21392981,3.30724407 5.31334107,3.30724407 C5.35336884,3.30724407 5.39225469,3.32058615 5.42385066,3.34516089 Z" id="路径" fill="#5B6167" fill-rule="nonzero"></path>
<path d="M1.60417096,2.83745334 L1.60417096,0.9 C1.60417096,0.402943725 2.00711469,0 2.50417096,-1.11022302e-16 L10.3291667,-1.11022302e-16 C10.8262229,-2.22044605e-16 11.2291667,0.402943725 11.2291667,0.9 L11.2291667,10.3291667 C11.2291667,10.8262229 10.8262229,11.2291667 10.3291667,11.2291667 L2.50417096,11.2291667 C2.00711469,11.2291667 1.60417096,10.8262229 1.60417096,10.3291667 L1.60417096,8.46506778 L1.60417096,8.46506778" id="路径" stroke="#5B6167" stroke-width="1.1" stroke-linejoin="round"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-10 16:46:17 * @Date: 2025-12-10 16:46:17
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2025-12-11 13:40:18 * @Last Modified time: 2026-01-12 20:41:27
*/ */
import { type FC, useRef, useEffect } from 'react' import { type FC, useRef, useEffect } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -55,7 +55,7 @@ const ChatContent: FC<ChatContentProps> = ({
</div> </div>
} }
{/* 消息气泡框 */} {/* 消息气泡框 */}
<div className={clsx('rb:border rb:text-left rb:rounded-lg rb:mt-1.5 rb:leading-4.5 rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-100', contentClassNames, { <div className={clsx('rb:border rb:text-left rb:rounded-lg rb:mt-1.5 rb:leading-4.5 rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-100 rb:wrap-break-word', contentClassNames, {
// 错误消息样式内容为null且非助手消息 // 错误消息样式内容为null且非助手消息
'rb:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)] rb:text-[#FF5D34]': errorDesc && item.role === 'assistant' && item.content === null, 'rb:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)] rb:text-[#FF5D34]': errorDesc && item.role === 'assistant' && item.content === null,
// 助手消息样式 // 助手消息样式

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef, type FC, type Key } from 'react'; import { useEffect, useState, type FC, type Key } from 'react';
import { Select } from 'antd' import { Select } from 'antd'
import type { SelectProps, DefaultOptionType } from 'antd/es/select' import type { SelectProps, DefaultOptionType } from 'antd/es/select'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -26,7 +26,7 @@ interface CustomSelectProps extends Omit<SelectProps, 'filterOption'> {
disabled?: boolean; disabled?: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
filterOption?: (inputValue: string, option: DefaultOptionType) => boolean; filterOption?: (inputValue: string, option?: DefaultOptionType) => boolean;
} }
interface OptionType { interface OptionType {
[key: string]: Key | string | number; [key: string]: Key | string | number;
@@ -48,44 +48,27 @@ const CustomSelect: FC<CustomSelectProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [options, setOptions] = useState<OptionType[]>([]); const [options, setOptions] = useState<OptionType[]>([]);
// 创建防抖定时器引用
const debounceRef = useRef<number>();
// 防抖搜索函数 // 默认模糊搜索函数
const handleSearch = useCallback((value?: string) => { const defaultFilterOption = (inputValue: string, option?: DefaultOptionType) => {
// 清除之前的定时器 if (!option || !inputValue) return true;
if (debounceRef.current) { const label = String(option.children || option.label || '');
clearTimeout(debounceRef.current); return label.toLowerCase().includes(inputValue.toLowerCase());
} };
// 组件挂载时获取初始数据
// 设置新的定时器 useEffect(() => {
debounceRef.current = window.setTimeout(() => { request.get<ApiResponse<OptionType>>(url, params).then((res) => {
request.get<ApiResponse<OptionType>>(url, {...params, [optionFilterProp]: value}).then((res) => {
const data = res; const data = res;
setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []); setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []);
}); });
}, 300); // 300毫秒防抖延迟 }, []);
}, [url, params, optionFilterProp]);
// 组件挂载时获取初始数据
useEffect(() => {
handleSearch();
// 组件卸载时清除定时器
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [url, handleSearch]);
return ( return (
<Select <Select
placeholder={placeholder ? placeholder : t('common.select')} placeholder={placeholder ? placeholder : t('common.select')}
onChange={onChange} onChange={onChange}
defaultValue={hasAll ? null : undefined} defaultValue={hasAll ? null : undefined}
showSearch={showSearch} showSearch={showSearch}
onSearch={handleSearch} filterOption={filterOption || defaultFilterOption}
filterOption={filterOption || false} // 禁用本地过滤,使用服务器端过滤
{...props} {...props}
> >
{hasAll && (<Select.Option>{allTitle || t('common.all')}</Select.Option>)} {hasAll && (<Select.Option>{allTitle || t('common.all')}</Select.Option>)}

View File

@@ -40,6 +40,8 @@ import apiKeyIcon from '@/assets/images/menu/apiKey.png';
import apiKeyActiveIcon from '@/assets/images/menu/apiKey_active.png'; import apiKeyActiveIcon from '@/assets/images/menu/apiKey_active.png';
import pricingIcon from '@/assets/images/menu/pricing.svg' import pricingIcon from '@/assets/images/menu/pricing.svg'
import pricingActiveIcon from '@/assets/images/menu/pricing_active.svg' import pricingActiveIcon from '@/assets/images/menu/pricing_active.svg'
import spaceConfigIcon from '@/assets/images/menu/spaceConfig.svg'
import spaceConfigActiveIcon from '@/assets/images/menu/spaceConfig_active.svg'
// 图标路径映射表 // 图标路径映射表
const iconPathMap: Record<string, string> = { const iconPathMap: Record<string, string> = {
@@ -68,7 +70,9 @@ const iconPathMap: Record<string, string> = {
'apiKey': apiKeyIcon, 'apiKey': apiKeyIcon,
'apiKeyActive': apiKeyActiveIcon, 'apiKeyActive': apiKeyActiveIcon,
'pricing': pricingIcon, 'pricing': pricingIcon,
'pricingActive': pricingActiveIcon 'pricingActive': pricingActiveIcon,
'spaceConfig': spaceConfigIcon,
'spaceConfigActive': spaceConfigActiveIcon,
}; };
const { Sider } = Layout; const { Sider } = Layout;

View File

@@ -17,6 +17,11 @@ export const en = {
spaceTitle:'Memory Bear Intelligent Space Management Platform', spaceTitle:'Memory Bear Intelligent Space Management Platform',
spaceSubTitle: 'Making it easier to implement intelligent models - a one-stop platform for model management, knowledge building, workflow orchestration, and spatial operations', spaceSubTitle: 'Making it easier to implement intelligent models - a one-stop platform for model management, knowledge building, workflow orchestration, and spatial operations',
}, },
version:{
releaseDate: 'Release Date',
version: 'Version',
name: 'Code Name'
},
quickActions:{ quickActions:{
title: 'Quick Actions', title: 'Quick Actions',
spaceManagement: 'Space Management', spaceManagement: 'Space Management',
@@ -82,7 +87,7 @@ export const en = {
modelManagement: 'Model Management', modelManagement: 'Model Management',
memoryStore: 'Memory Store', memoryStore: 'Memory Store',
apiParameters: 'API Parameters', apiParameters: 'API Parameters',
userMemory: 'User Memory', userMemory: 'Memory Store',
memberManagement: 'Member Management', memberManagement: 'Member Management',
memorySummary: 'Memory Summary', memorySummary: 'Memory Summary',
memoryConversation: 'Memory Validation', memoryConversation: 'Memory Validation',
@@ -105,6 +110,7 @@ export const en = {
pricing: 'Pricing Management', pricing: 'Pricing Management',
orderPayment: 'Order Payment', orderPayment: 'Order Payment',
orderHistory: 'Order History', orderHistory: 'Order History',
spaceConfig: 'Space Configuration'
}, },
dashboard: { dashboard: {
total_models: 'Total number of available models', total_models: 'Total number of available models',
@@ -1227,6 +1233,8 @@ export const en = {
hire_date: 'Hire Date', hire_date: 'Hire Date',
memoryContent: 'Memory Content', memoryContent: 'Memory Content',
created_at: 'Created At', created_at: 'Created At',
updated_at: 'Updated At',
fullScreen: 'Full Screen',
memoryWindow: "{{name}}'s Window of Memory", memoryWindow: "{{name}}'s Window of Memory",
memory_insight: 'Overall Overview', memory_insight: 'Overall Overview',
@@ -1253,13 +1261,23 @@ export const en = {
unix: 'items', unix: 'items',
completeMemory: 'Complete Memory', completeMemory: 'Complete Memory',
relationshipEvolution: 'Relationship Evolution', relationshipEvolution: 'Relationship Evolution',
timelineMemories: 'Shared Memory Timeline', timelineMemories: 'Long-term Memory',
emotionLine: 'Emotion Changes Over Time', emotionLine: 'Emotion Changes Over Time',
interaction: 'Interaction Frequency & Relationship Stages', interaction: 'Interaction Frequency & Relationship Stages',
timelines_memory: 'All', timelines_memory: 'All',
MemorySummary: 'Long-term Accumulation', MemorySummary: 'Long-term Accumulation',
Statement: 'Emotional Memory', Statement: 'Emotional Memory',
ExtractedEntity: 'Episodic Memory', ExtractedEntity: 'Episodic Memory',
positive: 'Positive Emotion',
negative: 'Negative Emotion',
neutral: 'Neutral Emotion',
interactionCountData: 'Interaction Count',
capacity: 'Capacity',
type: 'Type',
person: 'Personal',
memoryNum: 'memories',
memory_config_name: 'Memory Engine',
searchPlaceholder: 'Search memory store name',
}, },
space: { space: {
createSpace: 'Create Space', createSpace: 'Create Space',
@@ -1275,7 +1293,8 @@ export const en = {
neo4jDesc: 'Based on knowledge graph, suitable for relational reasoning and path query', neo4jDesc: 'Based on knowledge graph, suitable for relational reasoning and path query',
llmModel: 'LLM Model', llmModel: 'LLM Model',
embeddingModel: 'Embedding Model', embeddingModel: 'Embedding Model',
rerankModel: 'Rerank Model' rerankModel: 'Rerank Model',
configAlert: 'Space model configuration ensures that the space can correctly call the corresponding models to process business data during runtime.',
}, },
memoryExtractionEngine: { memoryExtractionEngine: {
title: 'Memory Engine Module Configuration Center', title: 'Memory Engine Module Configuration Center',
@@ -1450,6 +1469,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
quickReply: 'Quick Reply', quickReply: 'Quick Reply',
web_search: 'Online search', web_search: 'Online search',
memory: 'Memory', memory: 'Memory',
memoryConversationAnalysisEmpty: 'There is currently no dialogue analysis content available',
memoryConversationAnalysisEmptySubTitle: 'After entering your user ID, click on "Test Memory" to view the conversation memory',
}, },
login: { login: {
title: 'Red Bear Memory Science', title: 'Red Bear Memory Science',
@@ -1604,19 +1625,17 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
JsonTool_desc: 'Data Format Conversion', JsonTool_desc: 'Data Format Conversion',
JsonTool_features: 'JSON formatting, compression, validation and conversion functions', JsonTool_features: 'JSON formatting, compression, validation and conversion functions',
jsonFormat: 'JSON Formatting', jsonParse: 'JSON Parse',
jsonGzip: 'JSON Compression', jsonInsert: 'JSON Insert',
jsonCheck: 'JSON Validation', jsonReplace: 'JSON Validation',
jsonConversion: 'Format Conversion', jsonDelete: 'JSON Delete',
jsonEg: 'Example JSON', jsonEg: 'Example JSON',
enterJson: 'Enter JSON', enterJson: 'Enter JSON',
jsonPlaceholder: 'Enter JSON data, e.g.: {"name": "test", "value": 123}', jsonPlaceholder: 'Enter JSON data, e.g.: {"name": "test", "value": 123}',
clear: 'Clear', clear: 'Clear',
parse: 'Paste', paste: 'Paste',
format: 'Format', parse: 'Parse',
minify: 'Minify', json_path: 'JSON Path Parameters',
validate: 'Validate',
convert: 'Escape',
outputResult: 'Output Result', outputResult: 'Output Result',
validJosn: 'JSON format is correct, validation passed!', validJosn: 'JSON format is correct, validation passed!',
@@ -1935,7 +1954,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
variableConfig: 'Variable Configuration', variableConfig: 'Variable Configuration',
variableRequired: 'Required', variableRequired: 'Required',
addMessage: 'Add Message', addMessage: 'Add Message',
answerDesc: 'Reply' answerDesc: 'Reply',
addNode: 'Add Node',
}, },
emotionEngine: { emotionEngine: {
emotionEngineConfig: 'Emotion Engine Configuration', emotionEngineConfig: 'Emotion Engine Configuration',
@@ -2219,6 +2239,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
node_type: 'Node Type', node_type: 'Node Type',
last_access_time: 'Last Activation Time', last_access_time: 'Last Activation Time',
activation_value: 'Current Activation Value', activation_value: 'Current Activation Value',
refreshSuccess: 'Forgetting Execution Successful',
}, },
episodicDetail: { episodicDetail: {
title: 'Record every important scene you have truly experienced', title: 'Record every important scene you have truly experienced',

View File

@@ -17,6 +17,11 @@ export const zh = {
spaceTitle:'记忆熊智能空间管理平台', spaceTitle:'记忆熊智能空间管理平台',
spaceSubTitle: '使智能模型的实施变得更加容易——一个集模型管理、知识构建、工作流程编排以及空间操作于一体的综合性平台', spaceSubTitle: '使智能模型的实施变得更加容易——一个集模型管理、知识构建、工作流程编排以及空间操作于一体的综合性平台',
}, },
version:{
releaseDate: '发布日',
version: '版本',
name: '代号'
},
quickActions:{ quickActions:{
title: '快速操作', title: '快速操作',
spaceManagement: '空间管理', spaceManagement: '空间管理',
@@ -82,7 +87,7 @@ export const zh = {
modelManagement: '模型管理', modelManagement: '模型管理',
memoryStore: '记忆存储', memoryStore: '记忆存储',
apiParameters: 'API参数', apiParameters: 'API参数',
userMemory: '用户记忆', userMemory: '记忆',
memberManagement: '成员管理', memberManagement: '成员管理',
memorySummary: '记忆摘要', memorySummary: '记忆摘要',
memoryConversation: '记忆验证', memoryConversation: '记忆验证',
@@ -97,7 +102,7 @@ export const zh = {
knowledgeShare: '详情', knowledgeShare: '详情',
knowledgeCreateDataset: '新建数据集', knowledgeCreateDataset: '新建数据集',
knowledgeDocumentDetails: '详情', knowledgeDocumentDetails: '详情',
userMemoryDetail: '用户记忆详情', userMemoryDetail: '记忆详情',
toolManagement: '工具管理', toolManagement: '工具管理',
emotionEngine: '情感引擎', emotionEngine: '情感引擎',
statementDetail: '情绪记忆', statementDetail: '情绪记忆',
@@ -105,6 +110,7 @@ export const zh = {
pricing: '收费管理', pricing: '收费管理',
orderPayment: '订单支付', orderPayment: '订单支付',
orderHistory: '订单记录', orderHistory: '订单记录',
spaceConfig: '空间配置'
}, },
knowledgeBase: { knowledgeBase: {
home: '首页', home: '首页',
@@ -1333,13 +1339,23 @@ export const zh = {
unix: '个', unix: '个',
completeMemory: '完整记忆', completeMemory: '完整记忆',
relationshipEvolution: '关系演化', relationshipEvolution: '关系演化',
timelineMemories: '共同记忆时间线', timelineMemories: '长期记忆',
emotionLine: '情绪随时间变化', emotionLine: '情绪随时间变化',
interaction: '互动频率 & 关系阶段', interaction: '互动频率 & 关系阶段',
timelines_memory: '全部', timelines_memory: '全部',
MemorySummary: '长期沉淀', MemorySummary: '长期沉淀',
Statement: '情绪记忆', Statement: '情绪记忆',
ExtractedEntity: '情景记忆', ExtractedEntity: '情景记忆',
positive: '正向情绪',
negative: '负向情绪',
neutral: '中性情绪',
interactionCountData: '互动次数',
capacity: '容量',
type: '类型',
person: '个人',
memoryNum: '条记忆',
memory_config_name: '记忆引擎',
searchPlaceholder: '搜索记忆库名称',
}, },
space: { space: {
createSpace: '创建空间', createSpace: '创建空间',
@@ -1355,7 +1371,8 @@ export const zh = {
neo4jDesc: '基于知识图谱,适合关系推理和路径查询', neo4jDesc: '基于知识图谱,适合关系推理和路径查询',
llmModel: 'LLM 模型', llmModel: 'LLM 模型',
embeddingModel: 'Embedding 模型', embeddingModel: 'Embedding 模型',
rerankModel: 'Rerank 模型' rerankModel: 'Rerank 模型',
configAlert: '空间模型配置为空间的模型模型,保障空间运行时能正确的调用到相应的模型来处理业务数据。',
}, },
memoryExtractionEngine: { memoryExtractionEngine: {
title: '记忆引擎模块配置中心', title: '记忆引擎模块配置中心',
@@ -1528,6 +1545,8 @@ export const zh = {
quickReply: '快速回复', quickReply: '快速回复',
web_search: '联网搜索', web_search: '联网搜索',
memory: '记忆', memory: '记忆',
memoryConversationAnalysisEmpty: '目前没有可用的对话分析内容',
memoryConversationAnalysisEmptySubTitle: '输入您的用户ID后点击"测试记忆"查看对话记忆',
}, },
login: { login: {
title: '红熊记忆科学', title: '红熊记忆科学',
@@ -1702,19 +1721,17 @@ export const zh = {
JsonTool_desc: '数据格式转换', JsonTool_desc: '数据格式转换',
JsonTool_features: 'JSON格式化、压缩、验证和转换功能', JsonTool_features: 'JSON格式化、压缩、验证和转换功能',
jsonFormat: 'JSON格式化', jsonParse: 'JSON解析',
jsonGzip: 'JSON压缩', jsonInsert: 'JSON插入',
jsonCheck: 'JSON验证', jsonReplace: 'JSON验证',
jsonConversion: '格式转换', jsonDelete: 'JSON删除',
jsonEg: '示例JSON', jsonEg: '示例JSON',
enterJson: '输入JSON', enterJson: '输入JSON',
jsonPlaceholder: '输入JSON数据例如{"name": "测试", "value": 123}', jsonPlaceholder: '输入JSON数据例如{"name": "测试", "value": 123}',
clear: '清空', clear: '清空',
parse: '粘贴', paste: '粘贴',
format: '格式化', parse: '解析',
minify: '压缩', json_path: 'JSON 路径参数',
validate: '验证',
convert: '转义',
outputResult: '输出结果', outputResult: '输出结果',
validJosn: 'JSON格式正确验证通过', validJosn: 'JSON格式正确验证通过',
@@ -2034,7 +2051,8 @@ export const zh = {
variableConfig: '变量配置', variableConfig: '变量配置',
variableRequired: '必填', variableRequired: '必填',
addMessage: '添加消息', addMessage: '添加消息',
answerDesc: '回复' answerDesc: '回复',
addNode: '添加节点',
}, },
emotionEngine: { emotionEngine: {
emotionEngineConfig: '情感引擎配置', emotionEngineConfig: '情感引擎配置',
@@ -2318,6 +2336,7 @@ export const zh = {
node_type: '节点类型', node_type: '节点类型',
last_access_time: '最后激活时间', last_access_time: '最后激活时间',
activation_value: '当前激活值', activation_value: '当前激活值',
refreshSuccess: '遗忘执行成功',
}, },
episodicDetail: { episodicDetail: {
title: '记录你真实经历过的每一个重要场景', title: '记录你真实经历过的每一个重要场景',

View File

@@ -66,6 +66,7 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
OrderHistory: lazy(() => import('@/views/OrderHistory')), OrderHistory: lazy(() => import('@/views/OrderHistory')),
Pricing: lazy(() => import('@/views/Pricing')), Pricing: lazy(() => import('@/views/Pricing')),
ToolManagement: lazy(() => import('@/views/ToolManagement')), ToolManagement: lazy(() => import('@/views/ToolManagement')),
SpaceConfig: lazy(() => import('@/views/SpaceConfig')),
Login: lazy(() => import('@/views/Login')), Login: lazy(() => import('@/views/Login')),
InviteRegister: lazy(() => import('@/views/InviteRegister')), InviteRegister: lazy(() => import('@/views/InviteRegister')),
NoPermission: lazy(() => import('@/views/NoPermission')), NoPermission: lazy(() => import('@/views/NoPermission')),

View File

@@ -33,6 +33,7 @@
{ "path": "/api-key", "element": "ApiKeyManagement" }, { "path": "/api-key", "element": "ApiKeyManagement" },
{ "path": "/emotion-engine/:id", "element": "EmotionEngine" }, { "path": "/emotion-engine/:id", "element": "EmotionEngine" },
{ "path": "/reflection-engine/:id", "element": "SelfReflectionEngine" }, { "path": "/reflection-engine/:id", "element": "SelfReflectionEngine" },
{ "path": "/space-config", "element": "SpaceConfig" },
{ "path": "/no-permission", "element": "NoPermission" }, { "path": "/no-permission", "element": "NoPermission" },
{ "path": "/*", "element": "NotFound" } { "path": "/*", "element": "NotFound" }
] ]

View File

@@ -376,6 +376,21 @@
"icon": null, "icon": null,
"iconActive": null, "iconActive": null,
"subs": null "subs": null
},
{
"id": 12,
"parent": 0,
"code": "spaceConfig",
"label": "空间配置",
"i18nKey": "menu.spaceConfig",
"path": "/space-config",
"enable": true,
"display": true,
"level": 1,
"sort": 0,
"icon": null,
"iconActive": null,
"subs": null
} }
] ]
} }

View File

@@ -12,43 +12,57 @@ export function parseSSEToJSON(sseString: string) {
const lines = sseString.trim().split('\n') const lines = sseString.trim().split('\n')
let currentEvent: SSEMessage = {} let currentEvent: SSEMessage = {}
let dataContent = ''
try {
for (const line of lines) { for (const line of lines) {
if (line.startsWith('event:')) { if (line.startsWith('event:')) {
if (Object.keys(currentEvent).length > 0) { if (currentEvent.event && dataContent) {
currentEvent.data = parseDataContent(dataContent)
events.push(currentEvent) events.push(currentEvent)
currentEvent = {}
} }
currentEvent.event = line.substring(6).trim() currentEvent = { event: line.substring(6).trim() }
dataContent = ''
} else if (line.startsWith('data:')) { } else if (line.startsWith('data:')) {
const dataStr = line.substring(5).trim() if (dataContent) dataContent += '\n'
if (dataStr) { dataContent += line.substring(5).trim()
try {
// 尝试解析为 JSON
currentEvent.data = JSON.parse(dataStr)
} catch {
// JSON 解析失败时,检查是否是被转义的 JSON 字符串
try {
const unescaped = dataStr.replace(/&quot;/g, '"').replace(/&amp;/g, '&')
currentEvent.data = JSON.parse(unescaped)
} catch {
// 如果仍然失败,保存为原始字符串
currentEvent.data = dataStr
}
}
}
} }
} }
if (Object.keys(currentEvent).length > 0) {
if (currentEvent.event && dataContent) {
currentEvent.data = parseDataContent(dataContent)
console.log('currentEvent', currentEvent)
events.push(currentEvent) events.push(currentEvent)
} }
return events return events
} catch (error) { }
console.error('Parse stream error:', error)
return [] function parseDataContent(dataContent: string): string | object {
try {
// 第一层解码HTML实体
let unescaped = dataContent
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#39;/g, "'")
// 解析第一层JSON
const firstParse = JSON.parse(unescaped)
// 如果data字段是字符串且包含JSON解析data层但保持chunk为字符串
if (firstParse.data && typeof firstParse.data === 'string' && firstParse.data.includes("{")) {
try {
firstParse.data = JSON.parse(firstParse.data)
} catch {
// 保持原字符串
}
}
return firstParse
} catch {
return dataContent
} }
} }
@@ -80,16 +94,30 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; // 添加缓冲区来处理不完整的消息
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
const chunk = decoder.decode(value, { stream: true }); const chunk = decoder.decode(value, { stream: true });
if (onMessage) { buffer += chunk;
onMessage(parseSSEToJSON(chunk) ?? {});
// 处理完整的事件
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // 保留最后一个可能不完整的事件
for (const event of events) {
if (event.trim() && onMessage) {
onMessage(parseSSEToJSON(event) ?? {});
} }
} }
}
// 处理剩余的缓冲区内容
if (buffer.trim() && onMessage) {
onMessage(parseSSEToJSON(buffer) ?? {});
}
break; break;
} }
} catch (error) { } catch (error) {

View File

@@ -59,7 +59,7 @@ const GuideCard: React.FC = () => {
return ( return (
<> <>
<div className='rb:w-full rb:h-[204px] rb:p-4' style={{ backgroundImage: `url(${guideBgImg})`, backgroundSize: '100% 100%' }}> <div className='rb:w-full rb:p-4' style={{ backgroundImage: `url(${guideBgImg})`, backgroundSize: '100% 100%' }}>
<div className='rb:flex rb:justify-start rb:text-white rb:text-base rb:font-semibold' > <div className='rb:flex rb:justify-start rb:text-white rb:text-base rb:font-semibold' >
{ t('index.getStarted')} { t('index.getStarted')}
</div> </div>

View File

@@ -59,7 +59,6 @@ const list = [
] ]
const TopCardList: FC<{data?: DataResponse}> = ({ data }) => { const TopCardList: FC<{data?: DataResponse}> = ({ data }) => {
const { t } = useTranslation() const { t } = useTranslation()
debugger
return ( return (
<div className="rb:grid rb:grid-cols-4 rb:gap-[16px]"> <div className="rb:grid rb:grid-cols-4 rb:gap-[16px]">
{list.map((item) => { {list.map((item) => {

View File

@@ -1,7 +1,15 @@
/*
* @Description:
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2026-01-12 16:34:59
* @LastEditors: yujiangping
* @LastEditTime: 2026-01-13 19:14:30
*/
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from 'antd'; import { Button, Divider } from 'antd';
import arrowRight from '@/assets/images/index/arrow_right.svg' // import arrowRight from '@/assets/images/index/arrow_right.svg'
import { getVersion, type versionResponse } from '@/api/common' import { getVersion, type versionResponse } from '@/api/common'
const GuideCard: React.FC = () => { const GuideCard: React.FC = () => {
@@ -27,20 +35,38 @@ const GuideCard: React.FC = () => {
return ( return (
<div className='rb:w-full rb:p-4 rb:border-1 rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded-xl'> <div className='rb:w-full rb:p-4 rb:border-1 rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded-xl'>
<div className='rb:flex rb:items-center rb:justify-start rb:text-[#5B6167] rb:text-base rb:font-semibold'> <div className='rb:flex rb:items-center rb:justify-start rb:text-[#5B6167] rb:text-base rb:font-semibold rb:gap-2'>
{ t('index.latestUpdate')} { t('index.latestUpdate')}
{versionInfo && ( <span className='rb:text-xs rb:text-[#1890FF]'>
<span className='rb:ml-2 rb:text-sm rb:text-[#1890FF]'> {versionInfo?.version}
{versionInfo.version}
</span> </span>
)}
</div> </div>
<div className='rb:flex rb:text-xs rb:text-[#5B6167] rb:leading-[18px] rb:mt-3 rb:pl-2'> <div className='rb:flex rb:flex-col rb:text-[#5B6167]'>
{loading ? ( {versionInfo && (<>
<div className='rb:flex rb:items-center rb:gap-2 rb:text-sm rb:text-[#5B6167] rb:leading-5 '>
<span className='rb:text-xs rb:text-[#5B6167]'>
{t('version.releaseDate')}: {versionInfo.introduction?.releaseDate}
</span>
<Divider type='vertical' />
<span className='rb:text-xs rb:text-[#5B6167]'>
{t('version.name')}: {versionInfo.introduction?.codeName}
</span>
</div>
<p className='rb:text-sm rb:text-[#5B6167] rb:leading-5 rb:mt-2 '>
{versionInfo.introduction?.upgradePosition}
</p>
{versionInfo.introduction?.coreUpgrades?.map((item,index) => (
<p className='rb:text-sm rb:text-[#5B6167] rb:leading-5'>
{index + 1}. {item}
</p>
))}
</>)}
{/* {loading ? (
t('index.loading') t('index.loading')
) : ( ) : (
versionInfo?.introduction || t('index.latestUpdateDesc') versionInfo?.introduction || t('index.latestUpdateDesc')
)} )} */}
</div> </div>
{/* <div className='rb:flex rb:w-full rb:items-center rb:justify-between rb:gap-3 rb:mt-4'> {/* <div className='rb:flex rb:w-full rb:items-center rb:justify-between rb:gap-3 rb:mt-4'>
<Button className='rb:gap-2 rb:flex rb:items-center rb:text-[#212332] '> <Button className='rb:gap-2 rb:flex rb:items-center rb:text-[#212332] '>

View File

@@ -0,0 +1,118 @@
import { type FC, useEffect, useState } from 'react';
import { Form, App, Button, Skeleton } from 'antd';
import { useTranslation } from 'react-i18next';
import type { SpaceConfigData } from './types'
import { getWorkspaceModels, updateWorkspaceModels } from '@/api/workspaces'
import { getModelListUrl } from '@/api/models'
import CustomSelect from '@/components/CustomSelect'
import RbAlert from '@/components/RbAlert';
const SpaceConfig: FC = () => {
const { t } = useTranslation();
const { message } = App.useApp();
const [pageLoading, setPageLoding] = useState(false)
const [form] = Form.useForm<SpaceConfigData>();
const [loading, setLoading] = useState(false)
const values = Form.useWatch([], form);
useEffect(() => {
setPageLoding(true)
getWorkspaceModels().then((res) => {
const { llm, embedding, rerank } = res as SpaceConfigData
form.setFieldsValue({
llm,
embedding,
rerank
})
})
.finally(() => {
setPageLoding(false)
})
}, [])
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
updateWorkspaceModels(values)
.then(() => {
setLoading(false)
message.success(t('common.updateSuccess'))
})
.catch(() => {
setLoading(false)
});
})
.catch((err) => {
console.log('err', err)
});
}
return (
<div className="rb:h-full rb:max-w-140 rb:mx-auto">
{pageLoading
? <Skeleton active />
: <Form
form={form}
layout="vertical"
>
<Form.Item
label={t('space.llmModel')}
name="llm"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'llm', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
style={{width: '100%'}}
/>
</Form.Item>
<Form.Item
label={t('space.embeddingModel')}
name="embedding"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'embedding', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
style={{width: '100%'}}
/>
</Form.Item>
<Form.Item
label={t('space.rerankModel')}
name="rerank"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'rerank', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
style={{width: '100%'}}
/>
</Form.Item>
<RbAlert>{t('space.configAlert')}</RbAlert>
<Form.Item className="rb:text-right">
<Button type="primary" className="rb:mt-6" onClick={handleSave} loading={loading}>
{t('common.save')}
</Button>
</Form.Item>
</Form>
}
</div>
);
};
export default SpaceConfig;

View File

@@ -0,0 +1,8 @@
export interface SpaceConfigData {
llm: string;
embedding: string;
rerank: string;
}
export interface SpaceConfigRef {
handleOpen: () => void;
}

View File

@@ -1,5 +1,5 @@
import { forwardRef, useImperativeHandle, useState } from 'react'; import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, Button, Space, Tree } from 'antd'; import { Form, Input, Button, Space } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TreeDataNode } from 'antd'; import type { TreeDataNode } from 'antd';
@@ -12,7 +12,7 @@ import { execute } from '@/api/tools';
const JsonToolModal = forwardRef<JsonToolModalRef>((_props, ref) => { const JsonToolModal = forwardRef<JsonToolModalRef>((_props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [form] = Form.useForm<{ json: string; }>(); const [form] = Form.useForm<{ json: string; json_path: string; }>();
const [data, setData] = useState<ToolItem>({} as ToolItem) const [data, setData] = useState<ToolItem>({} as ToolItem)
const [formatValue, setFormatValue] = useState<string | Record<string, any> | null>(null) const [formatValue, setFormatValue] = useState<string | Record<string, any> | null>(null)
@@ -60,44 +60,29 @@ const JsonToolModal = forwardRef<JsonToolModalRef>((_props, ref) => {
} }
const handleOperate = (type: string) => { const handleOperate = (type: string) => {
const json = form.getFieldValue('json') const json = form.getFieldValue('json')
const json_path = form.getFieldValue('json_path')
if (!json || !data.id) return if (!json || !data.id) return
let params: ExecuteData = { let params: ExecuteData = {
tool_id: data.id, tool_id: data.id,
parameters: { parameters: {
operation: type, operation: type,
input_data: json input_data: json,
json_path
} }
} }
if (type === 'format') { if (type === 'parse') {
params = { params = {
...params, ...params,
parameters: { parameters: {
...params.parameters, ...params.parameters,
indent: 2,
ensure_ascii: false,
sort_keys: false
} }
} }
} }
execute(params) execute(params)
.then(res => { .then(res => {
const { data } = res as {data: { const { data } = res as { data: string; }
formatted_json: string; setFormatValue(data);
minified_json: string;
is_valid: boolean;
converted_json: string;
error: string;
structure: Record<string, string | number>
}}
switch (type) {
case 'format':
setFormatValue(data.formatted_json);
break
case 'minify':
setFormatValue(data.minified_json)
break
}
}) })
} }
const clear = () => { const clear = () => {
@@ -126,15 +111,20 @@ const JsonToolModal = forwardRef<JsonToolModalRef>((_props, ref) => {
label={<Space size={8}> label={<Space size={8}>
{t('tool.enterJson')} {t('tool.enterJson')}
<Button onClick={clear}>{t('tool.clear')}</Button> <Button onClick={clear}>{t('tool.clear')}</Button>
<Button onClick={handleParse}>{t('tool.parse')}</Button> <Button onClick={handleParse}>{t('tool.paste')}</Button>
</Space>} </Space>}
> >
<Input.TextArea rows={10} placeholder={t('tool.jsonPlaceholder')} /> <Input.TextArea rows={10} placeholder={t('tool.jsonPlaceholder')} />
</FormItem> </FormItem>
<FormItem
name="json_path"
label={t('tool.json_path')}
>
<Input placeholder={t('common.pleaseEnter')} />
</FormItem>
<Space size={8} className="rb:mb-3"> <Space size={8} className="rb:mb-3">
<Button onClick={() => handleOperate('format')}>{t('tool.format')}</Button> <Button onClick={() => handleOperate('parse')}>{t('tool.parse')}</Button>
<Button onClick={() => handleOperate('minify')}>{t('tool.minify')}</Button>
</Space> </Space>
<FormItem <FormItem
label={t('tool.outputResult')} label={t('tool.outputResult')}

View File

@@ -23,6 +23,7 @@ interface CurrentTimeObj {
iso_format: string; iso_format: string;
timestamp: string; timestamp: string;
timestamp_ms: string; timestamp_ms: string;
utc_datetime: string;
} }
const TimeToolModal = forwardRef<TimeToolModalRef>((_props, ref) => { const TimeToolModal = forwardRef<TimeToolModalRef>((_props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -88,8 +89,8 @@ const TimeToolModal = forwardRef<TimeToolModalRef>((_props, ref) => {
} }
}) })
.then(res => { .then(res => {
const response = res as { data: CurrentTimeObj } const response = res as { data: string }
setTimestampFormat(response.data.datetime) setTimestampFormat(response.data)
}) })
} }
const handleChangeFormatType = () => { const handleChangeFormatType = () => {
@@ -149,7 +150,7 @@ const TimeToolModal = forwardRef<TimeToolModalRef>((_props, ref) => {
<Input disabled value={currentTime?.datetime} /> <Input disabled value={currentTime?.datetime} />
</FormItem> </FormItem>
<FormItem label={t('tool.utcTime')} > <FormItem label={t('tool.utcTime')} >
<Input disabled value={currentTime?.iso_format} /> <Input disabled value={currentTime?.utc_datetime} />
</FormItem> </FormItem>
<FormItem label={t('tool.secondsTimestamp')} > <FormItem label={t('tool.secondsTimestamp')} >
<Input disabled value={currentTime?.timestamp} /> <Input disabled value={currentTime?.timestamp} />

View File

@@ -10,10 +10,10 @@ export const InnerConfigData: Record<string, InnerConfigItem> = {
}, },
JsonTool: { JsonTool: {
features: [ features: [
'jsonFormat', 'jsonParse',
'jsonGzip', 'jsonInsert',
'jsonCheck', 'jsonReplace',
'jsonConversion' 'jsonDelete'
], ],
eg: '{"name":"工具","tool_class":"内置"}' eg: '{"name":"工具","tool_class":"内置"}'
}, },

View File

@@ -130,6 +130,7 @@ export interface ExecuteData {
ensure_ascii?: boolean; ensure_ascii?: boolean;
sort_keys?: boolean; sort_keys?: boolean;
input_data?: string; input_data?: string;
json_path?: string;
} }
} }
export interface CustomToolModalRef { export interface CustomToolModalRef {

View File

@@ -1,127 +0,0 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, App } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ConfigModalData, ConfigModalRef } from '../types'
import { getWorkspaceModels, updateWorkspaceModels } from '@/api/workspaces'
import { getModelListUrl } from '@/api/models'
import CustomSelect from '@/components/CustomSelect'
import RbModal from '@/components/RbModal'
const ConfigModal = forwardRef<ConfigModalRef>((_props, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<ConfigModalData>();
const [loading, setLoading] = useState(false)
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = () => {
getWorkspaceModels().then((res) => {
const { llm, embedding, rerank } = res as ConfigModalData
form.setFieldsValue({
llm,
embedding,
rerank
})
})
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then(() => {
setLoading(true)
updateWorkspaceModels(values)
.then(() => {
setLoading(false)
handleClose()
message.success(t('common.updateSuccess'))
})
.catch(() => {
setLoading(false)
});
handleClose()
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t(`userMemory.editConfig`)}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<Form.Item
label={t('space.llmModel')}
name="llm"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'llm', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
style={{width: '100%'}}
/>
</Form.Item>
<Form.Item
label={t('space.embeddingModel')}
name="embedding"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'embedding', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
style={{width: '100%'}}
/>
</Form.Item>
<Form.Item
label={t('space.rerankModel')}
name="rerank"
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
params={{ type: 'rerank', pagesize: 100 }}
valueKey="id"
labelKey="name"
hasAll={false}
style={{width: '100%'}}
/>
</Form.Item>
</Form>
</RbModal>
);
});
export default ConfigModal;

View File

@@ -1,56 +1,28 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Row, Col, Radio, Button, List, Skeleton, Space } from 'antd'; import { Row, Col, List, Skeleton } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { RadioChangeEvent } from 'antd';
import { AppstoreOutlined, MenuOutlined } from '@ant-design/icons';
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import type { Data, ConfigModalRef } from './types' import type { Data } from './types'
import totalNum from '@/assets/images/memory/totalNum.svg' import { getUserMemoryList } from '@/api/memory';
import onlineNum from '@/assets/images/memory/onlineNum.svg'
import Table from '@/components/Table'
import { getTotalEndUsers, userMemoryListUrl, getUserMemoryList } from '@/api/memory';
import ConfigModal from './components/ConfigModal';
import { useUser } from '@/store/user' import { useUser } from '@/store/user'
import RbCard from '@/components/RbCard/Card'
import SearchInput from '@/components/SearchInput';
const bgList = [
'linear-gradient( 180deg, #F1F6FE 0%, #FBFDFF 100%)',
'linear-gradient( 180deg, #F1F9FE 0%, #FBFDFF 100%)',
'linear-gradient( 180deg, #FEFBF7 0%, #FBFDFF 100%)',
'linear-gradient( 180deg, #F1F9FE 0%, #FBFDFF 100%)',
]
const countList = [
'total_num', 'online_num',
]
const IconList: Record<string, string> = {
total_num: totalNum,
online_num: onlineNum,
}
export default function UserMemory() { export default function UserMemory() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate() const navigate = useNavigate()
const { storageType } = useUser() const { storageType } = useUser()
const configModalRef = useRef<ConfigModalRef>(null)
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<Data[]>([]); const [data, setData] = useState<Data[]>([]);
const [countData, setCountData] = useState<Record<string, number>>({}); const [search, setSearch] = useState<string | undefined>(undefined);
const [layout, setLayout] = useState<'card' | 'list'>('card');
// 获取数据 // 获取数据
useEffect(() => { useEffect(() => {
getCountData()
getData() getData()
}, []); }, []);
// 用户记忆统计
const getCountData = () => {
getTotalEndUsers().then((res) => {
setCountData(res as Record<string, number> || {})
})
}
const getData = () => { const getData = () => {
setLoading(true) setLoading(true)
getUserMemoryList().then((res) => { getUserMemoryList().then((res) => {
@@ -60,7 +32,6 @@ export default function UserMemory() {
setLoading(false) setLoading(false)
}) })
} }
console.log('storageType', storageType)
const handleViewDetail = (id: string | number) => { const handleViewDetail = (id: string | number) => {
switch (storageType) { switch (storageType) {
case 'neo4j': case 'neo4j':
@@ -70,112 +41,77 @@ export default function UserMemory() {
navigate(`/user-memory/${id}`) navigate(`/user-memory/${id}`)
} }
} }
const handleChangeLayout = (e: RadioChangeEvent) => { const handleViewMemoryConfig = () => {
const type = e.target.value navigate(`/memory`)
setLayout(type)
} }
// 表格列配置
const columns: ColumnsType = [ const filterData = useMemo(() => {
{ if (search && search.trim() !== '') {
title: t('userMemory.user'), return data.filter((item) => {
dataIndex: 'end_user', const { end_user } = item as Data;
key: 'end_user', const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id
render: (value) => value?.other_name && value?.other_name !== '' ? value?.other_name : value?.id || '-' return name?.includes(search)
}, })
{ }
title: t('userMemory.knowledgeEntryCount'),
dataIndex: 'memory_num', return data
key: 'memory_num', }, [search, data])
render: (value) => value?.total || 0
},
{
title: t('common.operation'),
key: 'action',
render: (_, record) => (
<Button
type="link"
onClick={() => handleViewDetail(record.end_user?.id)}
>
{t('common.viewDetail')}
</Button>
),
},
];
return ( return (
<div> <div>
<Row gutter={16} className="rb:mb-4"> <Row gutter={16} className="rb:mb-4">
{countList.map(key => ( <Col span={8}>
<Col key={key} span={6}> <SearchInput
<div className="rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:p-[18px_20px_20px_20px]"> placeholder={t('userMemory.searchPlaceholder')}
<div className="rb:text-[28px] rb:font-extrabold rb:leading-8.75 rb:flex rb:items-center rb:justify-between rb:mb-3"> onSearch={(value) => setSearch(value)}
{countData[key] || 0}{key === 'avgInteractionTime' ? 's' : ''} style={{ width: '100%' }}
<img className="rb:w-6 rb:h-6" src={IconList[key]} /> />
</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4">{t(`userMemory.${key}`)}</div>
</div>
</Col>
))}
<Col span={12} className="rb:text-right">
<Space>
<Button type="primary" onClick={() => configModalRef?.current?.handleOpen()}>{t('userMemory.chooseModel')}</Button>
<Radio.Group value={layout} onChange={handleChangeLayout}>
<Radio.Button value="card" disabled={layout === 'card'}><AppstoreOutlined /></Radio.Button>
<Radio.Button value="list" disabled={layout === 'list'}><MenuOutlined /></Radio.Button>
</Radio.Group>
</Space>
</Col> </Col>
</Row> </Row>
{layout === 'card' &&
<>
{loading ? {loading ?
<Skeleton active /> <Skeleton active />
: data.length > 0 ? ( : filterData.length > 0 ? (
<List <List
grid={{ gutter: 16, column: 4 }} grid={{ gutter: 16, column: 3 }}
dataSource={data} dataSource={filterData}
renderItem={(item, index) => { renderItem={(item, index) => {
const { end_user, memory_num } = item as Data; const { end_user, memory_num, memory_config } = item as Data;
const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id
return ( return (
<List.Item key={index}> <List.Item key={index}>
<div <RbCard
className="rb:p-5 rb:rounded-xl rb:border rb:border-[#DFE4ED] rb:cursor-pointer" avatar={<div className="rb:w-12 rb:h-12 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-12 rb:rounded-lg rb:text-[#FBFDFF] rb:bg-[#155EEF] rb:mr-2">{name[0]}</div>}
style={{ title={name || '-'}
background: bgList[index % bgList.length], extra={<div
}} className="rb:w-7 rb:h-7 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/goto.svg')]"
onClick={() => handleViewDetail(end_user.id)} onClick={() => handleViewDetail(end_user.id)}
></div>}
> >
<div className="rb:flex rb:items-center"> <div className="rb:flex rb:justify-between rb:items-center">
<div className="rb:w-12 rb:h-12 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-12 rb:rounded-lg rb:text-[#FBFDFF] rb:bg-[#155EEF]">{name[0]}</div> <div>{t('userMemory.capacity')}</div>
<div className="rb:max-w-[calc(100%-60px)] rb:text-base rb:font-medium rb:leading-6 rb:ml-3 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap"> <div>{memory_num?.total || 0} {t('userMemory.memoryNum')}</div>
{name || '-'}<br/>
</div> </div>
<div className="rb:flex rb:justify-between rb:items-center rb:mt-2.5">
<div>{t('userMemory.type')}</div>
<div>{t(`userMemory.${item.type || 'person'}`)}</div>
</div> </div>
<div className="rb:grid rb:grid-cols-1 rb:gap-3 rb:mt-7 rb:mb-7">
<div className="rb:text-center"> <div className="rb:mt-3 rb:bg-[#F6F8FC] rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:py-2 rb:px-3" onClick={handleViewMemoryConfig}>
<div className="rb:text-[24px] rb:leading-7.5 rb:font-extrabold">{memory_num.total || 0}</div> <div className="rb:text-[#5B6167] rb:leading-5 rb:flex rb:justify-between rb:items-center">
<div className="rb:wrap-break-word">{t(`userMemory.knowledgeEntryCount`)}</div> {t('userMemory.memory_config_name')}
</div> <div
className="rb:w-7 rb:h-7 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/arrow_right.svg')]"
></div>
</div> </div>
<div className="rb:font-medium rb:leading-5 rb:mt-1">{memory_config?.memory_config_name || '-'}</div>
</div> </div>
</RbCard>
</List.Item> </List.Item>
) )
}} }}
/> />
) : <Empty />} ) : <Empty />
</>
} }
{layout === 'list' &&
<Table
apiUrl={userMemoryListUrl}
columns={columns}
rowKey="end_user.id"
pagination={false}
/>
}
<ConfigModal ref={configModalRef} />
</div> </div>
); );
} }

View File

@@ -17,13 +17,10 @@ export interface Data {
entity: number; entity: number;
} }
}, },
memory_config: {
memory_config_id: string;
memory_config_name: string;
},
type: string;
name?: string; name?: string;
} }
export interface ConfigModalData {
llm: string;
embedding: string;
rerank: string;
}
export interface ConfigModalRef {
handleOpen: () => void;
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import ReactEcharts from 'echarts-for-react'; import ReactEcharts from 'echarts-for-react';
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import Loading from '@/components/Empty/Loading' import Loading from '@/components/Empty/Loading'
import type { Emotion } from './GraphDetail' import type { Emotion } from '../pages/GraphDetail'
interface EmotionLineProps { interface EmotionLineProps {
chartData: Emotion[]; chartData: Emotion[];
@@ -26,7 +26,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
const seriesData = timePoints.map(time => dataMap.get(time) || 0) const seriesData = timePoints.map(time => dataMap.get(time) || 0)
return { return {
name: emotionType, name: t(`userMemory.${emotionType}`),
type: 'line', type: 'line',
smooth: true, smooth: true,
lineStyle: { lineStyle: {
@@ -71,7 +71,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
formatter: function(params: any) { formatter: function(params: any) {
let result = `${params[0].axisValue}<br/>` let result = `${params[0].axisValue}<br/>`
params.forEach((param: any) => { params.forEach((param: any) => {
result += `${param.marker}${param.seriesName}: ${param.value}<br/>` result += `${param.marker}${param.seriesName}: ${param.value}%<br/>`
}) })
return result return result
} }
@@ -92,7 +92,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
}, },
grid: { grid: {
top: 16, top: 16,
left: 30, left: 40,
right: 36, right: 36,
bottom: 48, bottom: 48,
// containLabel: false // containLabel: false
@@ -103,7 +103,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
boundaryGap: false, boundaryGap: false,
axisLabel: { axisLabel: {
color: '#A8A9AA', color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC' fontFamily: 'PingFangSC, PingFang SC',
}, },
axisLine: { axisLine: {
show: true, show: true,
@@ -130,7 +130,8 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
type: 'value', type: 'value',
axisLabel: { axisLabel: {
color: '#A8A9AA', color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC' fontFamily: 'PingFangSC, PingFang SC',
formatter: '{value}%'
}, },
axisLine: { axisLine: {
show: true, show: true,
@@ -152,7 +153,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
type: 'solid' type: 'solid'
} }
}, },
max: 1, max: 100,
min: 0 min: 0
}, },
series: getSeries() series: getSeries()

View File

@@ -0,0 +1,113 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { useParams } from 'react-router-dom'
import { Form, Slider } from 'antd';
import { useTranslation } from 'react-i18next';
import RbModal from '@/components/RbModal'
import { forgetTrigger } from '@/api/memory'
import type { ForgetRefreshModalRef } from '../pages/ForgetDetail'
interface ForgetRefreshModalProps {
refresh: (flag: boolean) => void;
}
const ForgetRefreshModal = forwardRef<ForgetRefreshModalRef, ForgetRefreshModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const { id } = useParams()
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<{ max_merge_batch_size: number; min_days_since_access: number; }>();
const [loading, setLoading] = useState(false)
const values = Form.useWatch([], form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = () => {
form.resetFields();
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
if(!id) return
form
.validateFields()
.then((values) => {
setLoading(true)
forgetTrigger({
...values,
end_user_id: id
})
.then(() => {
refresh(true)
handleClose()
})
.finally(() => {
setLoading(false)
})
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('common.refresh')}
open={visible}
onCancel={handleClose}
okText={t('common.refresh')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<div className="rb:pl-3">
<div className="rb:text-[14px] rb:font-medium rb:leading-5 rb:mb-2">
{t(`forgettingEngine.max_merge_batch_size`)}
</div>
<Form.Item
name="max_merge_batch_size"
>
<Slider tooltip={{ open: false }} max={1000} min={1} step={1} style={{ margin: '0' }} />
</Form.Item>
<div className="rb:flex rb:text-[12px] rb:items-center rb:justify-between rb:text-[#5B6167] rb:leading-5 rb:-mt-6.5">
<span>{t(`forgettingEngine.range`)}: {[1, 1000]?.join('-')}</span>
{t('forgettingEngine.CurrentValue')}: {values?.min_days_since_access || 0}
</div>
</div>
<div className="rb:pl-3 rb:mt-4">
<div className="rb:text-[14px] rb:font-medium rb:leading-5 rb:mb-2">
{t(`forgettingEngine.min_days_since_access`)}
</div>
<Form.Item
name="min_days_since_access"
>
<Slider tooltip={{ open: false }} max={365} min={1} step={1} style={{ margin: '0' }} />
</Form.Item>
<div className="rb:flex rb:text-[12px] rb:items-center rb:justify-between rb:text-[#5B6167] rb:leading-5 rb:-mt-6.5">
<span>{t(`forgettingEngine.range`)}: {[1, 365]?.join('-')}</span>
{t('forgettingEngine.CurrentValue')}: {values?.min_days_since_access || 0}
</div>
</div>
</Form>
</RbModal>
);
});
export default ForgetRefreshModal;

View File

@@ -1,9 +1,9 @@
import { type FC } from 'react' import { type FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ReactEcharts from 'echarts-for-react' import ReactEcharts from 'echarts-for-react'
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import Loading from '@/components/Empty/Loading' import Loading from '@/components/Empty/Loading'
import type { Interaction } from './GraphDetail' import type { Interaction } from '../pages/GraphDetail'
interface InteractionBarProps { interface InteractionBarProps {
chartData: Interaction[]; chartData: Interaction[];
@@ -14,11 +14,13 @@ const Colors = ['#155EEF', '#369F21', '#FF5D34']
const InteractionBar: FC<InteractionBarProps> = ({ chartData, loading }) => { const InteractionBar: FC<InteractionBarProps> = ({ chartData, loading }) => {
const { t } = useTranslation() const { t } = useTranslation()
const series = [{ const series = useMemo(() => {
name: 'Interaction Count', return [{
name: t('userMemory.interactionCountData'),
type: 'bar', type: 'bar',
data: chartData.map(item => item.count) data: chartData.map(item => item.count)
}] }]
}, [chartData, t])
return ( return (
<> <>
@@ -80,6 +82,7 @@ const InteractionBar: FC<InteractionBarProps> = ({ chartData, loading }) => {
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
minInterval: 1,
axisLabel: { axisLabel: {
color: '#A8A9AA', color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC' fontFamily: 'PingFangSC, PingFang SC'
@@ -104,8 +107,6 @@ const InteractionBar: FC<InteractionBarProps> = ({ chartData, loading }) => {
type: 'solid' type: 'solid'
} }
}, },
max: 1,
min: 0
}, },
series series
}} }}

View File

@@ -1,20 +1,22 @@
import { type FC, type ReactNode } from 'react'; import { type FC, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Layout } from 'antd'; import { Layout, Space, Button } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import logoutIcon from '@/assets/images/logout.svg' import logoutIcon from '@/assets/images/logout_hover.svg'
const { Header } = Layout; const { Header } = Layout;
interface ConfigHeaderProps { interface ConfigHeaderProps {
name?: string; name?: string;
operation?: ReactNode; operation?: ReactNode;
source?: 'detail' | 'node' source?: 'detail' | 'node';
extra?: ReactNode;
} }
const PageHeader: FC<ConfigHeaderProps> = ({ const PageHeader: FC<ConfigHeaderProps> = ({
name, name,
operation, operation,
source = 'detail' source = 'detail',
extra
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -33,10 +35,13 @@ const PageHeader: FC<ConfigHeaderProps> = ({
{operation} {operation}
</div> </div>
<div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:cursor-pointer" onClick={goBack}> <Space size={12}>
<img src={logoutIcon} className="rb:mr-2 rb:w-4 rb:h-4" /> <Button type="primary" ghost className="rb:group rb:h-6! rb:px-2!" onClick={goBack}>
<img src={logoutIcon} className="rb:w-4 rb:h-4" />
{t('common.return')} {t('common.return')}
</div> </Button>
{extra}
</Space>
</Header> </Header>
); );
}; };

View File

@@ -1,19 +1,18 @@
import React, { type FC, useEffect, useState, useRef, useCallback } from 'react' import React, { type FC, useEffect, useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { Col, Row, Space, Button } from 'antd' import { Col, Row, Space, Button } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import RbCard from '@/components/RbCard/Card' import RbCard from '@/components/RbCard/Card'
import ReactEcharts from 'echarts-for-react' import ReactEcharts from 'echarts-for-react'
import detailEmpty from '@/assets/images/userMemory/detail_empty.png' import detailEmpty from '@/assets/images/userMemory/detail_empty.png'
import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties, GraphDetailRef } from '../types' import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties } from '../types'
import { import {
getMemorySearchEdges, getMemorySearchEdges,
} from '@/api/memory' } from '@/api/memory'
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import Tag from '@/components/Tag' import Tag from '@/components/Tag'
import GraphDetail from '../components/GraphDetail'
const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048'] const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048']
const RelationshipNetwork:FC = () => { const RelationshipNetwork:FC = () => {
@@ -26,7 +25,7 @@ const RelationshipNetwork:FC = () => {
const [categories, setCategories] = useState<{ name: string }[]>([]) const [categories, setCategories] = useState<{ name: string }[]>([])
const [selectedNode, setSelectedNode] = useState<Node | null>(null) const [selectedNode, setSelectedNode] = useState<Node | null>(null)
// const [fullScreen, setFullScreen] = useState<boolean>(false) // const [fullScreen, setFullScreen] = useState<boolean>(false)
const graphDetailRef = useRef<GraphDetailRef>(null) const navigate = useNavigate()
console.log('categories', categories) console.log('categories', categories)
// 关系网络 // 关系网络
@@ -133,15 +132,14 @@ const RelationshipNetwork:FC = () => {
} }
}, [nodes]) }, [nodes])
// const handleFullScreen = () => {
// setFullScreen(prev => !prev)
// }
console.log('selectedNode', selectedNode)
const handleViewAll = () => { const handleViewAll = () => {
if (!selectedNode) return if (!selectedNode) return
graphDetailRef.current?.handleOpen(selectedNode) const params = new URLSearchParams({
nodeId: selectedNode.id,
nodeLabel: selectedNode.label,
nodeName: selectedNode.name || ''
})
navigate(`/user-memory/detail/${id}/GRAPH?${params.toString()}`)
} }
return ( return (
@@ -336,8 +334,6 @@ const RelationshipNetwork:FC = () => {
</div> </div>
</RbCard> </RbCard>
</Col> </Col>
<GraphDetail ref={graphDetailRef} />
</Row> </Row>
) )
} }

View File

@@ -9,6 +9,7 @@ import {
} from '@/api/memory' } from '@/api/memory'
import { formatDateTime } from '@/utils/format'; import { formatDateTime } from '@/utils/format';
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import Tag from '@/components/Tag'
interface TimelineItem { interface TimelineItem {
id: string; id: string;
@@ -18,6 +19,9 @@ interface TimelineItem {
summary: string; summary: string;
storage_type: number; storage_type: number;
created_time: string | number; created_time: string | number;
domain: string;
topic: string;
keywords: string[]
} }
const KEYS = { const KEYS = {
@@ -68,9 +72,14 @@ const Timeline: FC = () => {
{formatDateTime(vo.created_time)} {formatDateTime(vo.created_time)}
{index !== data.length - 1 && <Divider type="vertical" className="rb:flex-1 rb:w-px rb:border-[#155EEF]!" />} {index !== data.length - 1 && <Divider type="vertical" className="rb:flex-1 rb:w-px rb:border-[#155EEF]!" />}
</div> </div>
<div className="rb:flex rb:justify-between rb:flex-1 rb:mb-4"> <div className="rb:flex-1 rb:pb-4">
<div className="rb:w-150 rb:leading-5">{vo.summary}</div> <div className="rb:flex rb:justify-between">
<div className="rb:text-[#5B6167] rb:font-medium">{t(`perceptualDetail.${perceptual_type[vo.perceptual_type]}`)}</div> <div className="rb:w-150 rb:leading-5 rb:font-medium">{vo.summary}</div>
<div className="rb:text-[#5B6167] rb:font-medium rb:flex-1 rb:text-right">{t(`perceptualDetail.${perceptual_type[vo.perceptual_type]}`)}</div>
</div>
<div className="rb:text-[#5B6167] rb:leading-5 rb:mt-2">{[vo.domain, vo.topic].join(' | ')}</div>
<Space size={8} className="rb:mt-2">{vo.keywords.map(tag => <Tag>{tag}</Tag>)}</Space>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,7 +1,7 @@
import { type FC, useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo, forwardRef, useImperativeHandle, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { Row, Col, Progress } from 'antd' import { Row, Col, Progress, App } from 'antd'
import RbCard from '@/components/RbCard/Card' import RbCard from '@/components/RbCard/Card'
import { import {
getForgetStats, getForgetStats,
@@ -12,6 +12,7 @@ import RecentTrendsLineCard from '../components/RecentTrendsLineCard'
import Table from '@/components/Table' import Table from '@/components/Table'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import StatusTag from '@/components/StatusTag' import StatusTag from '@/components/StatusTag'
import ForgetRefreshModal from '../components/ForgetRefreshModal'
const statusTagColors: Record<string, 'success' | 'purple' | 'default' | 'warning' | 'error' | 'lightBlue'> = { const statusTagColors: Record<string, 'success' | 'purple' | 'default' | 'warning' | 'error' | 'lightBlue'> = {
statement: 'success', statement: 'success',
@@ -20,24 +21,33 @@ const statusTagColors: Record<string, 'success' | 'purple' | 'default' | 'warnin
chunk: 'warning', chunk: 'warning',
} }
const ForgetDetail: FC = () => { export interface ForgetRefreshModalRef {
handleOpen: () => void;
}
const ForgetDetail = forwardRef((_props, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { id } = useParams() const { id } = useParams()
const { message } = App.useApp()
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<ForgetData>({} as ForgetData) const [data, setData] = useState<ForgetData>({} as ForgetData)
const forgetRefreshModalRef = useRef<ForgetRefreshModalRef>(null)
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
getData() getData()
}, [id]) }, [id])
const getData = () => { const getData = (flag: boolean = false) => {
if (!id) return if (!id) return
setLoading(true) setLoading(true)
getForgetStats(id).then((res) => { getForgetStats(id).then((res) => {
const response = res as ForgetData const response = res as ForgetData
setData(response) setData(response)
setLoading(false) setLoading(false)
if (flag) {
message.success(t('forgetDetail.refreshSuccess'))
}
}) })
.finally(() => { .finally(() => {
setLoading(false) setLoading(false)
@@ -67,6 +77,14 @@ const ForgetDetail: FC = () => {
} }
}, [data.recent_trends]) }, [data.recent_trends])
const handleRefresh = () => {
forgetRefreshModalRef.current?.handleOpen()
}
useImperativeHandle(ref, () => ({
handleRefresh
}));
return ( return (
<div className="rb:h-full rb:max-w-266 rb:mx-auto"> <div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:text-[#5B6167] rb:leading-5 rb:mt-3">{t('forgetDetail.title')}</div> <div className="rb:text-[#5B6167] rb:leading-5 rb:mt-3">{t('forgetDetail.title')}</div>
@@ -152,7 +170,12 @@ const ForgetDetail: FC = () => {
]} ]}
pagination={false} pagination={false}
/> />
<ForgetRefreshModal
ref={forgetRefreshModalRef}
refresh={getData}
/>
</div> </div>
) )
} })
export default ForgetDetail export default ForgetDetail

View File

@@ -1,16 +1,17 @@
import { useState, forwardRef, useImperativeHandle, useMemo } from 'react' import { useState, forwardRef, useImperativeHandle, useMemo, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router-dom'
import { Row, Col, Tabs, Space, Skeleton } from 'antd' import { Row, Col, Tabs, Space, Skeleton } from 'antd'
import { getRelationshipEvolution, getTimelineMemories } from '@/api/memory' import { getRelationshipEvolution, getTimelineMemories } from '@/api/memory'
import type { Node, GraphDetailRef } from '../types' import type { Node, GraphDetailRef } from '../types'
import RbDrawer from '@/components/RbDrawer'
import RbCard from '@/components/RbCard/Card' import RbCard from '@/components/RbCard/Card'
import EmotionLine from './EmotionLine' import EmotionLine from '../components/EmotionLine'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import Tag from '@/components/Tag' import Tag from '@/components/Tag'
import InteractionBar from './InteractionBar' import InteractionBar from '../components/InteractionBar'
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import PageHeader from '../components/PageHeader'
export interface Emotion { export interface Emotion {
emotion_intensity: number; emotion_intensity: number;
@@ -35,7 +36,7 @@ interface Timeline {
const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => { const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false); const [searchParams] = useSearchParams()
const [vo, setVo] = useState<Node | null>(null) const [vo, setVo] = useState<Node | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [emotionData, setEmotionData] = useState<Emotion[]>([]) const [emotionData, setEmotionData] = useState<Emotion[]>([])
@@ -43,14 +44,23 @@ const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => {
const [activeTab, setActiveTab] = useState('timelines_memory') const [activeTab, setActiveTab] = useState('timelines_memory')
const [timelineLoading, setTimelineLoading] = useState(false) const [timelineLoading, setTimelineLoading] = useState(false)
const [timelineMemories, setTimelineMemories] = useState<Timeline>({ timelines_memory: [], MemorySummary: [], Statement: [], ExtractedEntity: []}) const [timelineMemories, setTimelineMemories] = useState<Timeline>({ timelines_memory: [], MemorySummary: [], Statement: [], ExtractedEntity: []})
useEffect(() => {
const nodeId = searchParams.get('nodeId')
const nodeLabel = searchParams.get('nodeLabel')
const nodeName = searchParams.get('nodeName')
const handleCancel = () => { if (nodeId && nodeLabel) {
setVo(null) const nodeFromUrl = {
setOpen(false) id: nodeId,
label: nodeLabel,
name: nodeName || nodeLabel
} }
handleOpen(nodeFromUrl as Node)
}
}, [searchParams])
const handleOpen = (vo: Node) => { const handleOpen = (vo: Node) => {
setActiveTab('timelines_memory') setActiveTab('timelines_memory')
setOpen(true)
setVo(vo) setVo(vo)
getRelationshipEvolutionData(vo) getRelationshipEvolutionData(vo)
getTimelineMemoriesData(vo) getTimelineMemoriesData(vo)
@@ -85,12 +95,12 @@ const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => {
}, [activeTab, timelineMemories]) }, [activeTab, timelineMemories])
return ( return (
<RbDrawer <>
title={vo?.name} <PageHeader
open={open} name={vo?.name}
onClose={handleCancel} source="node"
width={1000} />
> <div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:text-[16px] rb:font-medium rb:leading-5.5 rb:mb-3">{t('userMemory.relationshipEvolution')}</div> <div className="rb:text-[16px] rb:font-medium rb:leading-5.5 rb:mb-3">{t('userMemory.relationshipEvolution')}</div>
<RbCard> <RbCard>
<Row gutter={16}> <Row gutter={16}>
@@ -107,7 +117,7 @@ const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => {
<RbCard> <RbCard>
<Tabs <Tabs
activeKey={activeTab} activeKey={activeTab}
items={['timelines_memory', 'ExtractedEntity', 'Statement', 'MemorySummary'].map(key => ({ items={['timelines_memory', 'Statement', 'MemorySummary'].map(key => ({
label: t(`userMemory.${key}`), label: t(`userMemory.${key}`),
key key
}))} }))}
@@ -134,7 +144,8 @@ const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => {
</RbCard> </RbCard>
</RbDrawer> </div>
</>
) )
}) })
export default GraphDetail export default GraphDetail

View File

@@ -1,7 +1,7 @@
import { type FC, useEffect, useState, useMemo } from 'react' import { type FC, useEffect, useState, useMemo, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Dropdown } from 'antd' import { Dropdown, Button } from 'antd'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import StatementDetail from './StatementDetail' import StatementDetail from './StatementDetail'
@@ -15,12 +15,16 @@ import WorkingDetail from './WorkingDetail'
import { import {
getEndUserProfile, getEndUserProfile,
} from '@/api/memory' } from '@/api/memory'
import refreshIcon from '@/assets/images/refresh_hover.svg'
import GraphDetail from './GraphDetail'
const Detail: FC = () => { const Detail: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { id, type } = useParams() const { id, type } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [name, setName] = useState<string>('') const [name, setName] = useState<string>('')
const forgetDetailRef = useRef<{ handleRefresh: () => void }>(null)
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
getData() getData()
@@ -40,6 +44,13 @@ const Detail: FC = () => {
const onClick = ({ key }: { key: string }) => { const onClick = ({ key }: { key: string }) => {
navigate(`/user-memory/detail/${id}/${key}`, { replace: true }) navigate(`/user-memory/detail/${id}/${key}`, { replace: true })
} }
const handleRefresh = () => {
forgetDetailRef.current?.handleRefresh()
}
if (type === 'GRAPH') {
return <GraphDetail />
}
return ( return (
<div className="rb:h-full rb:w-full"> <div className="rb:h-full rb:w-full">
@@ -56,10 +67,15 @@ const Detail: FC = () => {
</div> </div>
</Dropdown> </Dropdown>
} }
extra={type === 'FORGETTING_MANAGEMENT' &&
<Button type="primary" ghost className="rb:group rb:h-6! rb:px-2!" onClick={handleRefresh}>
<img src={refreshIcon} className="rb:w-4 rb:h-4" />
{t('common.refresh')}
</Button>}
/> />
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4"> <div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4">
{type === 'EMOTIONAL_MEMORY' && <StatementDetail />} {type === 'EMOTIONAL_MEMORY' && <StatementDetail />}
{type === 'FORGETTING_MANAGEMENT' && <ForgetDetail />} {type === 'FORGETTING_MANAGEMENT' && <ForgetDetail ref={forgetDetailRef} />}
{type === 'IMPLICIT_MEMORY' && <ImplicitDetail />} {type === 'IMPLICIT_MEMORY' && <ImplicitDetail />}
{type === 'SHORT_TERM_MEMORY' && <ShortTermDetail />} {type === 'SHORT_TERM_MEMORY' && <ShortTermDetail />}
{type === 'PERCEPTUAL_MEMORY' && <PerceptualDetail />} {type === 'PERCEPTUAL_MEMORY' && <PerceptualDetail />}

View File

@@ -26,6 +26,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
const [chatList, setChatList] = useState<ChatItem[]>([]) const [chatList, setChatList] = useState<ChatItem[]>([])
const [variables, setVariables] = useState<StartVariableItem[]>([]) const [variables, setVariables] = useState<StartVariableItem[]>([])
const [streamLoading, setStreamLoading] = useState(false) const [streamLoading, setStreamLoading] = useState(false)
const [conversationId, setConversationId] = useState<string | null>(null)
const handleOpen = () => { const handleOpen = () => {
setOpen(true) setOpen(true)
@@ -100,7 +101,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
setStreamLoading(false) setStreamLoading(false)
data.forEach(item => { data.forEach(item => {
const { chunk } = item.data as { chunk: string; }; const { chunk, conversation_id } = item.data as { chunk: string; conversation_id: string | null; };
switch(item.event) { switch(item.event) {
case 'message': case 'message':
@@ -131,6 +132,10 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
setStreamLoading(false) setStreamLoading(false)
break break
} }
if (conversation_id && conversationId !== conversation_id) {
setConversationId(conversation_id)
}
}) })
} }
@@ -138,7 +143,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
draftRun(appId, { draftRun(appId, {
message: message, message: message,
variables: params, variables: params,
stream: true stream: true,
conversation_id: conversationId
}, handleStreamMessage) }, handleStreamMessage)
.finally(() => { .finally(() => {
setLoading(false) setLoading(false)

View File

@@ -107,7 +107,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
<div style={{ maxHeight: '300px', overflowY: 'auto', minWidth: '240px' }}> <div style={{ maxHeight: '300px', overflowY: 'auto', minWidth: '240px' }}>
{nodeLibrary.map((category, categoryIndex) => { {nodeLibrary.map((category, categoryIndex) => {
const filteredNodes = category.nodes.filter(nodeType => const filteredNodes = category.nodes.filter(nodeType =>
nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start' nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'iteration' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start'
); );
if (filteredNodes.length === 0) return null; if (filteredNodes.length === 0) return null;

View File

@@ -33,7 +33,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
y: cycleStartBBox.y, y: cycleStartBBox.y,
data: { data: {
type: 'add-node', type: 'add-node',
label: '添加节点', label: t('workflow.addNode'),
icon: '+', icon: '+',
parentId: node.id, parentId: node.id,
cycle: data.id, cycle: data.id,
@@ -61,7 +61,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
}, },
}, },
}, },
zIndex: 3 zIndex: 10
}); });
} }
} }
@@ -97,7 +97,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
y: centerY, y: centerY,
data: { data: {
type: 'add-node', type: 'add-node',
label: '添加节点', label: t('workflow.addNode'),
icon: '+', icon: '+',
parentId: node.id, parentId: node.id,
cycle: data.id, cycle: data.id,
@@ -128,7 +128,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
}, },
}, },
}, },
zIndex: 3 zIndex: 10
} }
graph.addEdge(edgeConfig) graph.addEdge(edgeConfig)

View File

@@ -151,11 +151,11 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
let filteredNodes; let filteredNodes;
if (isChildOfLoop) { if (isChildOfLoop) {
// Use same filtering as AddNode for child nodes of loop // Use same filtering as AddNode for child nodes of loop, but allow break
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type)); filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
} else if (isChildOfIteration) { } else if (isChildOfIteration) {
// Filter out loop and iteration nodes for children of iteration nodes // Filter out loop and iteration nodes for children of iteration nodes, but allow break
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'break', 'cycle-start', 'iteration'].includes(nodeType.type)); filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
} else { } else {
// Original filtering for non-loop child nodes // Original filtering for non-loop child nodes
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'break', 'cycle-start'].includes(nodeType.type)); filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'break', 'cycle-start'].includes(nodeType.type));

View File

@@ -60,7 +60,7 @@ const AssignmentList: FC<AssignmentListProps> = ({
> >
<VariableSelect <VariableSelect
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={options} options={options.filter(vo => vo.nodeData.type === 'loop' || vo.value.includes('conv.'))}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
onChange={() => { onChange={() => {
form.setFieldValue([parentName, name, 'operation'], undefined); form.setFieldValue([parentName, name, 'operation'], undefined);

View File

@@ -1,17 +1,19 @@
import { type FC } from 'react'; import { type FC } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Input, Button, Form, Space } from 'antd'; import { Button, Form, Space } from 'antd';
import { PlusOutlined, CopyOutlined, DeleteOutlined, ExpandOutlined } from '@ant-design/icons'; import { DeleteOutlined } from '@ant-design/icons';
import { Graph, Node } from '@antv/x6'; import { Graph, Node } from '@antv/x6';
import type { PortMetadata } from '@antv/x6/lib/model/port'; import Editor from '../../Editor';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
interface CategoryListProps { interface CategoryListProps {
parentName: string; parentName: string;
options: Suggestion[];
selectedNode?: Node | null; selectedNode?: Node | null;
graphRef?: React.MutableRefObject<Graph | undefined>; graphRef?: React.MutableRefObject<Graph | undefined>;
} }
const CategoryList: FC<CategoryListProps> = ({ parentName, selectedNode, graphRef }) => { const CategoryList: FC<CategoryListProps> = ({ parentName, selectedNode, graphRef, options }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const formValues = Form.useWatch([parentName], form); const formValues = Form.useWatch([parentName], form);
@@ -167,9 +169,9 @@ const CategoryList: FC<CategoryListProps> = ({ parentName, selectedNode, graphRe
name={[name, 'class_name']} name={[name, 'class_name']}
noStyle noStyle
> >
<Input.TextArea <Editor
placeholder={t('common.pleaseEnter')} placeholder={t('common.pleaseEnter')}
rows={2} options={options}
/> />
</Form.Item> </Form.Item>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { type FC } from 'react' import { type FC } from 'react'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Button, Select, Row, Col, InputNumber, Radio, type SelectProps } from 'antd' import { Form, Button, Select, Row, Col, InputNumber, Radio, Input, type SelectProps } from 'antd'
import { DeleteOutlined } from '@ant-design/icons'; import { DeleteOutlined } from '@ant-design/icons';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
@@ -114,7 +114,7 @@ const ConditionList: FC<CaseListProps> = ({
<Col span={14}> <Col span={14}>
<Form.Item name={[field.name, 'left']} noStyle> <Form.Item name={[field.name, 'left']} noStyle>
<VariableSelect <VariableSelect
options={options} options={options.filter(vo => vo.value.includes('sys.') || vo.value.includes('conv.') || vo.nodeData.type === 'loop')}
size="small" size="small"
allowClear={false} allowClear={false}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
@@ -186,7 +186,7 @@ const ConditionList: FC<CaseListProps> = ({
<Radio.Button value={true}>True</Radio.Button> <Radio.Button value={true}>True</Radio.Button>
<Radio.Button value={false}>False</Radio.Button> <Radio.Button value={false}>False</Radio.Button>
</Radio.Group> </Radio.Group>
: <Editor options={options} /> : <Input placeholder={t('common.pleaseEnter')} />
} }
</Form.Item> </Form.Item>
</Col> </Col>

View File

@@ -1,6 +1,6 @@
import { type FC } from 'react' import { type FC } from 'react'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Button, Select, Row, Col, Input } from 'antd' import { Form, Select, Row, Col, Input } from 'antd'
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import VariableSelect from '../VariableSelect' import VariableSelect from '../VariableSelect'
@@ -36,7 +36,6 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
value = [], value = [],
options, options,
parentName, parentName,
onChange,
selectedNode, selectedNode,
graphRef graphRef
}) => { }) => {
@@ -139,12 +138,17 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
<Form.Item name={[name, 'value']} noStyle> <Form.Item name={[name, 'value']} noStyle>
{currentInputType === 'variable' ? ( {currentInputType === 'variable' ? (
<VariableSelect <VariableSelect
placeholder="选择变量" placeholder={t('common.pleaseSelect')}
options={availableOptions} options={availableOptions.filter(option => {
const currentType = value?.[index]?.type;
if (!currentType) return true;
return option.dataType === currentType
})}
/> />
) : ( ) : (
<Input.TextArea <Input.TextArea
placeholder="输入值" placeholder={t('common.pleaseEnter')}
rows={3} rows={3}
className="rb:w-full" className="rb:w-full"
/> />

View File

@@ -18,8 +18,22 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
isCanAdd = false isCanAdd = false
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance();
const value = form.getFieldValue(name) || [];
console.log('GroupVariableList', value)
if (!isCanAdd) { if (!isCanAdd) {
// Filter options based on first variable's dataType if value exists
let filteredOptions = options;
if (value.length > 0) {
const firstVariableValue = value[0];
const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue);
if (firstVariable) {
filteredOptions = options.filter(opt => opt.dataType === firstVariable.dataType);
}
}
return ( return (
<div className="rb:mb-4"> <div className="rb:mb-4">
<Row gutter={12} className="rb:mb-2!"> <Row gutter={12} className="rb:mb-2!">
@@ -38,7 +52,7 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
> >
<VariableSelect <VariableSelect
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={options} options={filteredOptions}
mode="multiple" mode="multiple"
/> />
</Form.Item> </Form.Item>
@@ -77,7 +91,18 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
> >
<VariableSelect <VariableSelect
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={options} options={(() => {
const currentGroupValue = value[name]?.value || [];
if (currentGroupValue.length > 0) {
const firstVariableValue = currentGroupValue[0];
const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue);
if (firstVariable) {
return options.filter(opt => opt.dataType === firstVariable.dataType);
}
}
return options;
})()
}
mode="multiple" mode="multiple"
/> />
</Form.Item> </Form.Item>

View File

@@ -90,7 +90,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
</Col> </Col>
<Col span={16}> <Col span={16}>
<Form.Item name="url"> <Form.Item name="url">
<Editor options={options} variant="outlined" /> <Editor options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')} variant="outlined" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@@ -144,7 +144,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<Form.Item name={['body', 'data']} noStyle> <Form.Item name={['body', 'data']} noStyle>
<EditableTable <EditableTable
parentName={['body', 'data']} parentName={['body', 'data']}
options={options} options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')}
filterBooleanType={true} filterBooleanType={true}
/> />
</Form.Item> </Form.Item>
@@ -154,7 +154,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<MessageEditor <MessageEditor
key="json" key="json"
parentName={['body', 'data']} parentName={['body', 'data']}
options={options} options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')}
isArray={false} isArray={false}
title="JSON" title="JSON"
/> />

View File

@@ -91,6 +91,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
showSearch showSearch
allowClear={allowClear} allowClear={allowClear}
filterOption={(input, option) => { filterOption={(input, option) => {
if (input === '/') return true;
if (option?.options) { if (option?.options) {
return option.label?.toLowerCase().includes(input.toLowerCase()) || return option.label?.toLowerCase().includes(input.toLowerCase()) ||
option.options.some((opt: any) => option.options.some((opt: any) =>

View File

@@ -22,6 +22,7 @@ import ConditionList from './ConditionList'
import CycleVarsList from './CycleVarsList' import CycleVarsList from './CycleVarsList'
import AssignmentList from './AssignmentList' import AssignmentList from './AssignmentList'
import ToolConfig from './ToolConfig' import ToolConfig from './ToolConfig'
// import { calculateVariableList } from './utils/variableListCalculator'
interface PropertiesProps { interface PropertiesProps {
selectedNode?: Node | null; selectedNode?: Node | null;
@@ -338,111 +339,34 @@ const Properties: FC<PropertiesProps> = ({
const parentLoopNode = getParentLoopNode(selectedNode.id); const parentLoopNode = getParentLoopNode(selectedNode.id);
console.log('childNodeIds', selectedNode, childNodeIds) console.log('childNodeIds', selectedNode, childNodeIds)
const allRelevantNodeIds = [...allPreviousNodeIds, ...childNodeIds]; let allRelevantNodeIds = [...allPreviousNodeIds, ...childNodeIds];
// Add parent loop/iteration node variables if current node is a child // Add variables from nodes preceding the parent loop/iteration node if current node is a child
if (parentLoopNode) { if (parentLoopNode) {
const parentData = parentLoopNode.getData(); const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id);
const parentNodeId = parentLoopNode.getData().id; allRelevantNodeIds.push(...parentPreviousNodeIds);
}
if (parentData.type === 'loop') {
const cycleVars = parentData.cycle_vars || [];
cycleVars.forEach((cycleVar: any) => { // Add conversation variables from global config
const key = `${parentNodeId}_cycle_${cycleVar.name}`; const conversationVariables = workflowConfig?.variables || [];
conversationVariables.forEach((variable: any) => {
const key = `CONVERSATION_${variable.name}`;
if (!addedKeys.has(key)) { if (!addedKeys.has(key)) {
addedKeys.add(key); addedKeys.add(key);
variableList.push({ variableList.push({
key, key,
label: cycleVar.name, label: variable.name,
type: 'variable', type: 'variable',
dataType: cycleVar.type || 'String', dataType: variable.type,
value: `${parentNodeId}.${cycleVar.name}`, value: `conv.${variable.name}`,
nodeData: parentData, nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' },
group: 'CONVERSATION'
}); });
} }
}); });
} else if (parentData.type === 'iteration') {
// Add item and index variables for iteration parent
const itemKey = `${parentNodeId}_item`;
const indexKey = `${parentNodeId}_index`;
if (!addedKeys.has(itemKey)) {
addedKeys.add(itemKey);
variableList.push({
key: itemKey,
label: 'item',
type: 'variable',
dataType: 'Object',
value: `${parentNodeId}.item`,
nodeData: parentData,
});
}
if (!addedKeys.has(indexKey)) {
addedKeys.add(indexKey);
variableList.push({
key: indexKey,
label: 'index',
type: 'variable',
dataType: 'Number',
value: `${parentNodeId}.index`,
nodeData: parentData,
});
}
}
// Check if parent loop/iteration is connected to http-request via ERROR connection
if (parentData.type === 'loop' || parentData.type === 'iteration') {
const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id);
parentPreviousNodeIds.forEach(prevNodeId => {
const prevNode = nodes.find(n => n.id === prevNodeId);
if (!prevNode) return;
const prevNodeData = prevNode.getData();
if (prevNodeData.type === 'http-request') {
// Check if connected via ERROR connection point
const errorEdges = edges.filter(edge => {
return edge.getTargetCellId() === parentLoopNode.id &&
edge.getSourceCellId() === prevNodeId &&
edge.getSourcePortId() === 'ERROR'
});
if (errorEdges.length > 0) {
const errorMessageKey = `${prevNodeData.id}_error_message`;
const errorTypeKey = `${prevNodeData.id}_error_type`;
if (!addedKeys.has(errorMessageKey)) {
addedKeys.add(errorMessageKey);
variableList.push({
key: errorMessageKey,
label: 'error_message',
type: 'variable',
dataType: 'string',
value: `${prevNodeData.id}.error_message`,
nodeData: prevNodeData,
});
}
if (!addedKeys.has(errorTypeKey)) {
addedKeys.add(errorTypeKey);
variableList.push({
key: errorTypeKey,
label: 'error_type',
type: 'variable',
dataType: 'string',
value: `${prevNodeData.id}.error_type`,
nodeData: prevNodeData,
});
}
}
}
});
}
// Add variables from nodes preceding the parent loop/iteration node
const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id);
allRelevantNodeIds.push(...parentPreviousNodeIds);
}
allRelevantNodeIds.forEach(nodeId => { allRelevantNodeIds.forEach(nodeId => {
const node = nodes.find(n => n.id === nodeId); const node = nodes.find(n => n.id === nodeId);
@@ -496,7 +420,7 @@ const Properties: FC<PropertiesProps> = ({
key: llmKey, key: llmKey,
label: 'output', label: 'output',
type: 'variable', type: 'variable',
dataType: 'String', dataType: 'string',
value: `${dataNodeId}.output`, value: `${dataNodeId}.output`,
nodeData: nodeData, nodeData: nodeData,
}); });
@@ -565,6 +489,17 @@ const Properties: FC<PropertiesProps> = ({
const groupVariables = nodeData.config.group_variables.defaultValue || []; const groupVariables = nodeData.config.group_variables.defaultValue || [];
groupVariables?.forEach((groupVar: any) => { groupVariables?.forEach((groupVar: any) => {
if (!groupVar || !groupVar.key) return; if (!groupVar || !groupVar.key) return;
// Determine dataType from first variable in the group
let groupDataType = 'string';
if (groupVar.value && Array.isArray(groupVar.value) && groupVar.value.length > 0) {
const firstVariableValue = groupVar.value[0];
const firstVariable = variableList.find(v => `{{${v.value}}}` === firstVariableValue);
if (firstVariable) {
groupDataType = firstVariable.dataType;
}
}
const groupVarKey = `${dataNodeId}_${groupVar.key}`; const groupVarKey = `${dataNodeId}_${groupVar.key}`;
if (!addedKeys.has(groupVarKey)) { if (!addedKeys.has(groupVarKey)) {
addedKeys.add(groupVarKey); addedKeys.add(groupVarKey);
@@ -572,14 +507,26 @@ const Properties: FC<PropertiesProps> = ({
key: groupVarKey, key: groupVarKey,
label: groupVar.key, label: groupVar.key,
type: 'variable', type: 'variable',
dataType: 'string', dataType: groupDataType,
value: `${dataNodeId}.${groupVar.key}`, value: `${dataNodeId}.${groupVar.key}`,
nodeData: nodeData, nodeData: nodeData,
}); });
} }
}); });
} else { } else {
// If group=false, add output variable // If group=false, add output variable with type from first group_variable
const groupVariables = nodeData.config.group_variables.defaultValue || [];
const firstVariable = groupVariables[0];
let outputDataType: string = 'any';
if (firstVariable) {
const filterVo = [...variableList].find(v => {
return `{{${v.value}}}` === firstVariable
})
if (filterVo) {
outputDataType = filterVo?.dataType
}
}
const varAggregatorKey = `${dataNodeId}_output`; const varAggregatorKey = `${dataNodeId}_output`;
if (!addedKeys.has(varAggregatorKey)) { if (!addedKeys.has(varAggregatorKey)) {
addedKeys.add(varAggregatorKey); addedKeys.add(varAggregatorKey);
@@ -587,7 +534,7 @@ const Properties: FC<PropertiesProps> = ({
key: varAggregatorKey, key: varAggregatorKey,
label: 'output', label: 'output',
type: 'variable', type: 'variable',
dataType: 'string', dataType: outputDataType,
value: `${dataNodeId}.output`, value: `${dataNodeId}.output`,
nodeData: nodeData, nodeData: nodeData,
}); });
@@ -684,21 +631,20 @@ const Properties: FC<PropertiesProps> = ({
nodeData: nodeData, nodeData: nodeData,
}); });
} }
if (!addedKeys.has(outputKey)) { // if (!addedKeys.has(outputKey)) {
addedKeys.add(outputKey); // addedKeys.add(outputKey);
variableList.push({ // variableList.push({
key: outputKey, // key: outputKey,
label: 'output', // label: 'output',
type: 'variable', // type: 'variable',
dataType: 'string', // dataType: 'string',
value: `${dataNodeId}.output`, // value: `${dataNodeId}.output`,
nodeData: nodeData, // nodeData: nodeData,
}); // });
} // }
break break
case 'iteration': case 'iteration':
const iterationOutputKey = `${dataNodeId}_output`; const iterationOutputKey = `${dataNodeId}_output`;
const iterationItemKey = `${dataNodeId}_item`;
if (!addedKeys.has(iterationOutputKey)) { if (!addedKeys.has(iterationOutputKey)) {
addedKeys.add(iterationOutputKey); addedKeys.add(iterationOutputKey);
// Get the data type from the output configuration, default to string // Get the data type from the output configuration, default to string
@@ -715,22 +661,11 @@ const Properties: FC<PropertiesProps> = ({
key: iterationOutputKey, key: iterationOutputKey,
label: 'output', label: 'output',
type: 'variable', type: 'variable',
dataType: outputDataType, dataType: `array[${outputDataType}]`,
value: `${dataNodeId}.output`, value: `${dataNodeId}.output`,
nodeData: nodeData, nodeData: nodeData,
}); });
} }
if (!addedKeys.has(iterationItemKey)) {
addedKeys.add(iterationItemKey);
variableList.push({
key: iterationItemKey,
label: 'item',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.item`,
nodeData: nodeData,
});
}
break break
case 'loop': case 'loop':
const cycleVars = nodeData.config.cycle_vars.defaultValue || []; const cycleVars = nodeData.config.cycle_vars.defaultValue || [];
@@ -760,47 +695,337 @@ const Properties: FC<PropertiesProps> = ({
key: toolDataKey, key: toolDataKey,
label: 'data', label: 'data',
type: 'variable', type: 'variable',
dataType: 'object', dataType: 'string',
value: `${dataNodeId}.data`, value: `${dataNodeId}.data`,
nodeData: nodeData, nodeData: nodeData,
}); });
} }
break break
case 'memory-read':
const memoryReadAnswerKey = `${dataNodeId}_answer`;
const memoryReadIntermediateOutputs = `${dataNodeId}_intermediate_outputs`;
if (!addedKeys.has(memoryReadAnswerKey)) {
addedKeys.add(memoryReadAnswerKey);
variableList.push({
key: memoryReadAnswerKey,
label: 'answer',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.answer`,
nodeData: nodeData,
});
}
if (!addedKeys.has(memoryReadIntermediateOutputs)) {
addedKeys.add(memoryReadIntermediateOutputs);
variableList.push({
key: memoryReadIntermediateOutputs,
label: 'intermediate_outputs',
type: 'variable',
dataType: 'array[object]',
value: `${dataNodeId}.intermediate_outputs`,
nodeData: nodeData,
});
}
break
} }
}); });
// Add conversation variables from global config
const conversationVariables = workflowConfig?.variables || [];
conversationVariables.forEach((variable: any) => { // Add parent loop/iteration node variables if current node is a child
const key = `CONVERSATION_${variable.name}`; if (parentLoopNode) {
const parentData = parentLoopNode.getData();
const parentNodeId = parentLoopNode.getData().id;
if (parentData.type === 'loop') {
const cycleVars = parentData.cycle_vars || [];
cycleVars.forEach((cycleVar: any) => {
const key = `${parentNodeId}_cycle_${cycleVar.name}`;
if (!addedKeys.has(key)) { if (!addedKeys.has(key)) {
addedKeys.add(key); addedKeys.add(key);
variableList.push({ variableList.push({
key, key,
label: variable.name, label: cycleVar.name,
type: 'variable', type: 'variable',
dataType: variable.type, dataType: cycleVar.type || 'String',
value: `conv.${variable.name}`, value: `${parentNodeId}.${cycleVar.name}`,
nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, nodeData: parentData,
group: 'CONVERSATION'
}); });
} }
}); });
} else if (parentData.type === 'iteration') {
// Add item and index variables for iteration parent only if input has value
if (parentData.config.input.defaultValue) {
const itemKey = `${parentNodeId}_item`;
const indexKey = `${parentNodeId}_index`;
// Determine item dataType from input variable
let itemDataType = 'object';
const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue);
console.log('itemDataType defaultValue', parentData.config.input.defaultValue, variableList, inputVariable)
if (inputVariable && inputVariable.dataType.startsWith('array[')) {
itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1');
console.log('itemDataType', itemDataType)
}
if (!addedKeys.has(itemKey)) {
addedKeys.add(itemKey);
variableList.push({
key: itemKey,
label: 'item',
type: 'variable',
dataType: itemDataType,
value: `${parentNodeId}.item`,
nodeData: parentData,
});
}
if (!addedKeys.has(indexKey)) {
addedKeys.add(indexKey);
variableList.push({
key: indexKey,
label: 'index',
type: 'variable',
dataType: 'number',
value: `${parentNodeId}.index`,
nodeData: parentData,
});
}
}
}
// Check if parent loop/iteration is connected to http-request via ERROR connection
if (parentData.type === 'loop' || parentData.type === 'iteration') {
const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id);
parentPreviousNodeIds.forEach(prevNodeId => {
const prevNode = nodes.find(n => n.id === prevNodeId);
if (!prevNode) return;
const prevNodeData = prevNode.getData();
if (prevNodeData.type === 'http-request') {
// Check if connected via ERROR connection point
const errorEdges = edges.filter(edge => {
return edge.getTargetCellId() === parentLoopNode.id &&
edge.getSourceCellId() === prevNodeId &&
edge.getSourcePortId() === 'ERROR'
});
if (errorEdges.length > 0) {
const errorMessageKey = `${prevNodeData.id}_error_message`;
const errorTypeKey = `${prevNodeData.id}_error_type`;
if (!addedKeys.has(errorMessageKey)) {
addedKeys.add(errorMessageKey);
variableList.push({
key: errorMessageKey,
label: 'error_message',
type: 'variable',
dataType: 'string',
value: `${prevNodeData.id}.error_message`,
nodeData: prevNodeData,
});
}
if (!addedKeys.has(errorTypeKey)) {
addedKeys.add(errorTypeKey);
variableList.push({
key: errorTypeKey,
label: 'error_type',
type: 'variable',
dataType: 'string',
value: `${prevNodeData.id}.error_type`,
nodeData: prevNodeData,
});
}
}
}
});
}
}
return variableList; return variableList;
}, [selectedNode, graphRef, workflowConfig?.variables]); }, [selectedNode, graphRef, workflowConfig?.variables]);
// Filter out boolean type variables for loop and llm nodes // Filter out boolean type variables for loop and llm nodes
const getFilteredVariableList = (nodeType?: string) => { const getFilteredVariableList = (nodeType?: string, key?: string) => {
if (nodeType === 'loop' || nodeType === 'llm') { // Check if current node is a child of iteration node
return variableList.filter(variable => variable.dataType !== 'boolean'); const parentIterationNode = selectedNode ? (() => {
const nodes = graphRef.current?.getNodes() || [];
const nodeData = selectedNode.getData();
const cycle = nodeData?.cycle;
if (cycle) {
const parentNode = nodes.find(n => n.getData().id === cycle);
if (parentNode) {
const parentData = parentNode.getData();
if (parentData?.type === 'iteration') {
return parentNode;
} }
return variableList; }
}
return null;
})() : null;
// Helper function to add parent iteration variables
const addParentIterationVars = (filteredList: any[]) => {
if (parentIterationNode) {
const parentData = parentIterationNode.getData();
const parentNodeId = parentData.id;
if (parentData.config?.input?.defaultValue) {
const itemKey = `${parentNodeId}_item`;
const indexKey = `${parentNodeId}_index`;
const existingItemVar = filteredList.find(v => v.key === itemKey);
const existingIndexVar = filteredList.find(v => v.key === indexKey);
if (!existingItemVar) {
// Determine item dataType from input variable
let itemDataType = 'object';
const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue);
if (inputVariable && inputVariable.dataType.startsWith('array[')) {
itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1');
}
filteredList.push({
key: itemKey,
label: 'item',
type: 'variable',
dataType: itemDataType,
value: `${parentNodeId}.item`,
nodeData: parentData,
});
}
if (!existingIndexVar) {
filteredList.push({
key: indexKey,
label: 'index',
type: 'variable',
dataType: 'number',
value: `${parentNodeId}.index`,
nodeData: parentData,
});
}
}
}
return filteredList;
}; };
if (nodeType === 'llm') {
// For LLM nodes that are children of iteration or loop nodes, include parent variables
const parentLoopNode = selectedNode ? (() => {
const nodes = graphRef.current?.getNodes() || [];
const nodeData = selectedNode.getData();
const cycle = nodeData?.cycle;
if (cycle) {
const parentNode = nodes.find(n => n.getData().id === cycle);
if (parentNode) {
const parentData = parentNode.getData();
if (parentData?.type === 'loop' || parentData?.type === 'iteration') {
return parentNode;
}
}
}
return null;
})() : null;
let filteredList = variableList.filter(variable => variable.dataType !== 'boolean');
// If this LLM node is a child of iteration/loop, ensure parent variables are included
if (parentLoopNode) {
const parentData = parentLoopNode.getData();
const parentNodeId = parentData.id;
// Ensure parent loop/iteration variables are included
if (parentData.type === 'loop') {
const cycleVars = parentData.cycle_vars || [];
cycleVars.forEach((cycleVar: any) => {
const key = `${parentNodeId}_cycle_${cycleVar.name}`;
const existingVar = filteredList.find(v => v.key === key);
if (!existingVar && cycleVar.name && cycleVar.type !== 'boolean') {
filteredList.push({
key,
label: cycleVar.name,
type: 'variable',
dataType: cycleVar.type || 'String',
value: `${parentNodeId}.${cycleVar.name}`,
nodeData: parentData,
});
}
});
} else if (parentData.type === 'iteration') {
// Add item and index variables for iteration parent
if (parentData.config?.input?.defaultValue) {
const itemKey = `${parentNodeId}_item`;
const indexKey = `${parentNodeId}_index`;
const existingItemVar = filteredList.find(v => v.key === itemKey);
const existingIndexVar = filteredList.find(v => v.key === indexKey);
if (!existingItemVar) {
// Determine item dataType from input variable
let itemDataType = 'object';
const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue);
if (inputVariable && inputVariable.dataType.startsWith('array[')) {
itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1');
}
filteredList.push({
key: itemKey,
label: 'item',
type: 'variable',
dataType: itemDataType,
value: `${parentNodeId}.item`,
nodeData: parentData,
});
}
if (!existingIndexVar) {
filteredList.push({
key: indexKey,
label: 'index',
type: 'variable',
dataType: 'Number',
value: `${parentNodeId}.index`,
nodeData: parentData,
});
}
}
}
}
return filteredList;
}
if (nodeType === 'knowledge-retrieval' || nodeType === 'parameter-extractor' && key !== 'prompt' || nodeType === 'memory-read' || nodeType === 'memory-write' || nodeType === 'question-classifier') {
let filteredList = variableList.filter(variable => variable.dataType === 'string');
return addParentIterationVars(filteredList);
}
if (nodeType === 'parameter-extractor' && key === 'prompt') {
let filteredList = variableList.filter(variable => variable.dataType === 'string' || variable.dataType === 'number');
return addParentIterationVars(filteredList);
}
if (nodeType === 'iteration' && key === 'output') {
return variableList.filter(variable => variable.value.includes('sys.'));
}
if (nodeType === 'iteration') {
return variableList.filter(variable => variable.dataType.includes('array'));
}
if (nodeType === 'loop' && key === 'condition') {
let filteredList = variableList.filter(variable => variable.nodeData.type !== 'loop');
return addParentIterationVars(filteredList);
}
// For all other node types, add parent iteration variables if applicable
let baseList = variableList;
return addParentIterationVars(baseList);
};
// const defaultVariableList = calculateVariableList(selectedNode as Node, graphRef, workflowConfig )
console.log('values', values) console.log('values', values)
console.log('variableList', variableList, selectedNode?.data) // console.log('variableList', variableList, defaultVariableList)
return ( return (
<div className="rb:w-75 rb:fixed rb:right-0 rb:top-16 rb:bottom-0 rb:p-3"> <div className="rb:w-75 rb:fixed rb:right-0 rb:top-16 rb:bottom-0 rb:p-3">
@@ -901,7 +1126,6 @@ const Properties: FC<PropertiesProps> = ({
}); });
} }
} }
return ( return (
<Form.Item key={key} name={key}> <Form.Item key={key} name={key}>
<MessageEditor <MessageEditor
@@ -915,7 +1139,12 @@ const Properties: FC<PropertiesProps> = ({
if (selectedNode?.data?.type === 'end' && key === 'output') { if (selectedNode?.data?.type === 'end' && key === 'output') {
return ( return (
<Form.Item key={key} name={key}> <Form.Item key={key} name={key}>
<MessageEditor key={key} isArray={false} parentName={key} options={variableList} /> <MessageEditor
key={key}
isArray={false}
parentName={key}
options={variableList.filter(variable => variable.nodeData?.type !== 'knowledge-retrieval')}
/>
</Form.Item> </Form.Item>
) )
} }
@@ -943,7 +1172,7 @@ const Properties: FC<PropertiesProps> = ({
isArray={!!config.isArray} isArray={!!config.isArray}
parentName={key} parentName={key}
enableJinja2={config.enableJinja2 as boolean} enableJinja2={config.enableJinja2 as boolean}
options={getFilteredVariableList(selectedNode?.data?.type)} options={getFilteredVariableList(selectedNode?.data?.type, key)}
/> />
</Form.Item> </Form.Item>
) )
@@ -964,7 +1193,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}> <Form.Item key={key} name={key}>
<GroupVariableList <GroupVariableList
name={key} name={key}
options={getFilteredVariableList(selectedNode?.data?.type)} options={getFilteredVariableList(selectedNode?.data?.type, key)}
isCanAdd={!!(values as any)?.group} isCanAdd={!!(values as any)?.group}
/> />
</Form.Item> </Form.Item>
@@ -976,7 +1205,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}> <Form.Item key={key} name={key}>
<CaseList <CaseList
name={key} name={key}
options={getFilteredVariableList(selectedNode?.data?.type)} options={getFilteredVariableList(selectedNode?.data?.type, key)}
selectedNode={selectedNode} selectedNode={selectedNode}
graphRef={graphRef} graphRef={graphRef}
/> />
@@ -989,7 +1218,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key} <Form.Item key={key} name={key}
label={t(`workflow.config.${selectedNode?.data?.type}.${key}`)} label={t(`workflow.config.${selectedNode?.data?.type}.${key}`)}
> >
<MappingList name={key} options={getFilteredVariableList(selectedNode?.data?.type)} /> <MappingList name={key} options={getFilteredVariableList(selectedNode?.data?.type, key)} />
</Form.Item> </Form.Item>
) )
@@ -999,7 +1228,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}> <Form.Item key={key} name={key}>
<CycleVarsList <CycleVarsList
parentName={key} parentName={key}
options={getFilteredVariableList(selectedNode?.data?.type)} options={getFilteredVariableList(selectedNode?.data?.type, key)}
/> />
</Form.Item> </Form.Item>
) )
@@ -1013,9 +1242,9 @@ const Properties: FC<PropertiesProps> = ({
if (config.filterLoopIterationVars) { if (config.filterLoopIterationVars) {
const loopIterationVars: Suggestion[] = []; const loopIterationVars: Suggestion[] = [];
return [...getFilteredVariableList(selectedNode?.data?.type), ...loopIterationVars]; return [...getFilteredVariableList(selectedNode?.data?.type, key), ...loopIterationVars];
} }
return getFilteredVariableList(selectedNode?.data?.type); return getFilteredVariableList(selectedNode?.data?.type, key);
})() })()
} }
/> />
@@ -1060,7 +1289,7 @@ const Properties: FC<PropertiesProps> = ({
? <VariableSelect ? <VariableSelect
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={(() => { options={(() => {
const baseVariableList = getFilteredVariableList(selectedNode?.data?.type); const baseVariableList = getFilteredVariableList(selectedNode?.data?.type, key);
// Apply filtering if specified in config // Apply filtering if specified in config
if (config.filterNodeTypes || config.filterVariableNames) { if (config.filterNodeTypes || config.filterVariableNames) {
return baseVariableList.filter(variable => { return baseVariableList.filter(variable => {
@@ -1068,7 +1297,7 @@ const Properties: FC<PropertiesProps> = ({
(Array.isArray(config.filterNodeTypes) && config.filterNodeTypes.includes(variable.nodeData?.type)); (Array.isArray(config.filterNodeTypes) && config.filterNodeTypes.includes(variable.nodeData?.type));
const variableNameMatch = !config.filterVariableNames || const variableNameMatch = !config.filterVariableNames ||
(Array.isArray(config.filterVariableNames) && config.filterVariableNames.includes(variable.label)); (Array.isArray(config.filterVariableNames) && config.filterVariableNames.includes(variable.label));
return nodeTypeMatch && variableNameMatch; return nodeTypeMatch || variableNameMatch;
}); });
} }
// Filter child nodes for iteration output // Filter child nodes for iteration output
@@ -1085,7 +1314,7 @@ const Properties: FC<PropertiesProps> = ({
}); });
return baseVariableList.filter(variable => return baseVariableList.filter(variable =>
childNodes.some(node => node.id === variable.nodeData?.id) childNodes.some(node => node.id === variable.nodeData?.id) || selectedNode?.data?.type === 'iteration' && key === 'output' && variable.value.includes('sys.')
); );
} }
return baseVariableList; return baseVariableList;
@@ -1095,7 +1324,12 @@ const Properties: FC<PropertiesProps> = ({
: config.type === 'switch' : config.type === 'switch'
? <Switch onChange={key === 'group' ? () => { form.setFieldValue('group_variables', []) } : undefined} /> ? <Switch onChange={key === 'group' ? () => { form.setFieldValue('group_variables', []) } : undefined} />
: config.type === 'categoryList' : config.type === 'categoryList'
? <CategoryList parentName={key} selectedNode={selectedNode} graphRef={graphRef} /> ? <CategoryList
parentName={key}
selectedNode={selectedNode}
graphRef={graphRef}
options={getFilteredVariableList(selectedNode?.data?.type, key)}
/>
: config.type === 'conditionList' : config.type === 'conditionList'
? <ConditionList ? <ConditionList
parentName={key} parentName={key}
@@ -1109,18 +1343,9 @@ const Properties: FC<PropertiesProps> = ({
value: `${selectedNode.getData().id}.${cycleVar.name}`, value: `${selectedNode.getData().id}.${cycleVar.name}`,
nodeData: selectedNode.getData(), nodeData: selectedNode.getData(),
})); }));
return [...variableList.filter(variable => {
// Keep conversation variables return [...getFilteredVariableList(selectedNode?.data?.type, key), ...cycleVarSuggestions];
if (variable.group === 'CONVERSATION') return true; })()}
// Keep sys variables from start nodes
if (variable.nodeData?.type === 'start' && variable.value?.startsWith('sys.')) return true;
// Keep variables from non-start nodes
if (variable.nodeData?.type !== 'start' && variable.nodeData?.type !== 'http-request' && variable.dataType !== 'boolean') return true;
// Filter out custom variables from start nodes
return false;
}), ...cycleVarSuggestions];
})()
}
selectedNode={selectedNode} selectedNode={selectedNode}
graphRef={graphRef} graphRef={graphRef}
addBtnText={t('workflow.config.addCase')} addBtnText={t('workflow.config.addCase')}

View File

@@ -270,7 +270,7 @@ export const nodeLibrary: NodeLibrary[] = [
config: { config: {
input: { input: {
type: 'variableList', type: 'variableList',
filterNodeTypes: ['knowledge-retrieval'], filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop'],
filterVariableNames: ['message'] filterVariableNames: ['message']
}, },
parallel: { parallel: {
@@ -334,8 +334,7 @@ export const nodeLibrary: NodeLibrary[] = [
} }
} }
}, },
{ { type: "assigner", icon: assignerIcon,
type: "assigner", icon: assignerIcon,
config: { config: {
assignments: { assignments: {
type: 'assignmentList', type: 'assignmentList',
@@ -629,3 +628,113 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
}, },
} }
} }
export interface OutputVariable {
default?: Array<{
name: string;
type: string;
}>;
define?: string[];
sys?: Array<{
name: string;
type: string;
}>;
error?: Array<{
name: string;
type: string;
}>;
}
export const outputVariable: { [key: string]: OutputVariable } = {
start: {
sys: [
{ name: "message", type: "string" },
{ name: "conversation_id", type: "string" },
{ name: "execution_id", type: "string", },
{ name: "workspace_id", type: "string" },
{ name: "user_id", type: "string" },
],
define: ['variables']
},
end: {
},
llm: {
default: [
{ name: "output", type: "string" },
]
},
'knowledge-retrieval': {
default: [
{ name: "output", type: "array[object]" },
]
},
'parameter-extractor': {
default: [
{ name: "__is_success", type: "number" },
{ name: "__reason", type: "string" },
],
define: ['params']
},
'memory-read': {
default: [
{ name: "answer", type: "string" },
{ name: "intermediate_outputs", type: "array[object]" },
],
},
'memory-write': {
},
'if-else': {
},
'question-classifier': {
default: [
{ name: "class_name", type: "string" },
// { name: "output", type: "string" },
],
},
'iteration': {
default: [
// { name: "item", type: "string" }, // 仅内部使用
{ name: "output", type: "array[string]" },
],
},
'loop': {
define: ['cycle_vars']
},
'cycle-start': {
},
'break': {
},
'var-aggregator': {
// default: [
// { name: "output", type: "string" },
// ],
define: ['group_variables']
},
'assigner': {
},
'http-request': {
default: [
{ name: "body", type: "string" },
{ name: "status_code", type: "number" },
],
error: [
{ name: "error_message", type: "string" },
{ name: "error_type", type: "string" },
]
},
'tool': {
default: [
{ name: "data", type: "string" },
],
},
'jinja-render': {
default: [
{ name: "output", type: "string" },
],
},
}

View File

@@ -282,9 +282,21 @@ export const useWorkflowGraph = ({
}, 100) }, 100)
} }
if (edges.length) { if (edges.length) {
// 去重处理:相同节点之间的连线仅连一次 // 去重处理:对于if-else和question-classifier节点不同连接桩允许连接到相同节点
const uniqueEdges = edges.filter((edge, index, arr) => { const uniqueEdges = edges.filter((edge, index, arr) => {
return arr.findIndex(e => e.source === edge.source && e.target === edge.target) === index; return arr.findIndex(e => {
const sourceCell = graphRef.current?.getCellById(e.source);
const sourceType = sourceCell?.getData()?.type;
const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else';
if (isMultiPortNode) {
// 多端口节点需要同时比较source、target和label
return e.source === edge.source && e.target === edge.target && e.label === edge.label;
} else {
// 其他节点只比较source和target
return e.source === edge.source && e.target === edge.target;
}
}) === index;
}); });
const edgeList = uniqueEdges.map(edge => { const edgeList = uniqueEdges.map(edge => {
@@ -954,7 +966,10 @@ export const useWorkflowGraph = ({
itemConfig = { itemConfig = {
...itemConfig, ...itemConfig,
...data.config[key].defaultValue, ...data.config[key].defaultValue,
knowledge_bases: knowledge_bases?.map((vo: any) => ({ kb_id: vo.id, ...vo.config })) knowledge_bases: knowledge_bases?.map((vo: any) => {
const kb_config = vo.config || { similarity_threshold: vo.similarity_threshold, strategy: vo.strategy, top_k: vo.top_k, weight: vo.weight }
return { kb_id: vo.kb_id || vo.id, ...kb_config, }
})
} }
} }
}) })
@@ -1025,8 +1040,21 @@ export const useWorkflowGraph = ({
}) })
.filter(edge => edge !== null) .filter(edge => edge !== null)
.filter((edge, index, arr) => { .filter((edge, index, arr) => {
// 去重:相同节点之间的连线仅保留一次 // 去重:对于if-else和question-classifier节点不同连接桩允许连接到相同节点
return arr.findIndex(e => e && e.source === edge?.source && e.target === edge?.target) === index; return arr.findIndex(e => {
if (!e || !edge) return false;
const sourceCell = graphRef.current?.getCellById(e.source);
const sourceType = sourceCell?.getData()?.type;
const isMultiPortNode = sourceType === 'question-classifier' || sourceType === 'if-else';
if (isMultiPortNode) {
// 多端口节点需要同时比较source、target和label
return e.source === edge.source && e.target === edge.target && e.label === edge.label;
} else {
// 其他节点只比较source和target
return e.source === edge.source && e.target === edge.target;
}
}) === index;
}), }),
} }
saveWorkflowConfig(config.app_id, params as WorkflowConfig) saveWorkflowConfig(config.app_id, params as WorkflowConfig)