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,
memory_agent_controller,
memory_dashboard_controller,
memory_episodic_controller,
memory_explicit_controller,
memory_forget_controller,
memory_reflection_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_storage_controller.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(release_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()
router = APIRouter(
prefix="/memory/emotion",
prefix="/memory/emotion-memory",
tags=["Emotion Analysis"],
dependencies=[Depends(get_current_user)] # 所有路由都需要认证
)

View File

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

View File

@@ -1,18 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
import uuid
from app.repositories.end_user_repository import update_end_user_other_name
import uuid
from typing import Optional
from app.core.response_utils import success
from app.db import get_db
from app.dependencies import get_current_user
from app.models.user_model import User
from app.schemas.memory_agent_schema import End_User_Information
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.memory_agent_service import get_end_users_connected_configs_batch
from app.core.logging_config import get_api_logger
# 获取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
# 获取当前空间类型
@@ -113,6 +111,17 @@ async def get_workspace_end_users(
workspace_id=workspace_id,
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 = []
for end_user in end_users:
memory_num = {}
@@ -123,10 +132,25 @@ async def get_workspace_end_users(
memory_num = {
"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(
{
'end_user':end_user,
'memory_num':memory_num
'end_user': end_user,
'memory_num': memory_num,
'memory_config': memory_config
}
)
@@ -465,7 +489,6 @@ async def dashboard_data(
if storage_type is None:
storage_type = 'neo4j'
user_rag_memory_id = None
# 根据 storage_type 决定返回哪个数据对象
# 如果是 '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()
router = APIRouter(
prefix="/memory/forget",
prefix="/memory/forget-memory",
tags=["Memory Forgetting Engine"],
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.schemas.response_schema import ApiResponse
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 (
EndUserProfileResponse,
@@ -440,195 +434,3 @@ async def memory_space_relationship_evolution(id: str, label: str,
except Exception as e:
api_logger.error(f"关系演变查询失败: id={id}, table={label}, error={str(e)}", exc_info=True)
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
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:
"""

View File

@@ -1,6 +1,7 @@
import asyncio
import json
from datetime import datetime
from typing import List, Optional
from typing import List, Optional, Tuple
from uuid import uuid4
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(
dialog: DialogData,
chunk,
@@ -63,10 +176,9 @@ async def _process_chunk_summary(
title = None
episodic_type = None
try:
from app.services.user_memory_service import UserMemoryService
title, episodic_type = await UserMemoryService.generate_title_and_type_for_summary(
title, episodic_type = await generate_title_and_type_for_summary(
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}")
except Exception as e:

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,7 @@ class JsonTool(BuiltinTool):
ToolParameter(
name="json_path",
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
),
ToolParameter(
@@ -136,7 +136,7 @@ class JsonTool(BuiltinTool):
execution_time = time.time() - start_time
return ToolResult.success_result(
data=result,
data=result["result_data"],
execution_time=execution_time
)
@@ -671,7 +671,8 @@ class JsonTool(BuiltinTool):
"success": True,
"value": current,
"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:
@@ -680,7 +681,8 @@ class JsonTool(BuiltinTool):
"json_path": json_path,
"success": False,
"error": str(e),
"value": None
"value": None,
"result_data": None
}
def _analyze_json_structure(self, data: Any, depth: int = 0) -> Dict[str, Any]:

View File

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

View File

@@ -211,12 +211,11 @@ class ToolExecution(Base):
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)
# 关联关系
tool_config = relationship("ToolConfig", back_populates="executions")
user = relationship("User")
workspace = relationship("Workspace")
def __repr__(self):

View File

@@ -1,5 +1,5 @@
"""
用户记忆相关的请求和响应模型
情景记忆的请求和响应模型
"""
from pydantic import BaseModel, Field
from typing import Optional
@@ -28,16 +28,3 @@ class EpisodicMemoryDetailsRequest(BaseModel):
end_user_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")
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}")
@tool(args_schema=LongTermMemoryInput)
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,
message=question,
history=[],
search_switch="1",
search_switch="2",
config_id=config_id,
db=db,
storage_type=storage_type,

View File

@@ -1,6 +1,11 @@
import json
from pathlib import Path
from datetime import datetime, timedelta
from fastapi import HTTPException
from sqlalchemy.orm import Session
from uuid import UUID
from typing import Dict, Any
from app.repositories.home_page_repository import HomePageRepository
from app.schemas.home_page_schema import HomeStatistics, WorkspaceInfo
@@ -68,4 +73,69 @@ class HomePageService:
)
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,
health checks, and message type classification.
"""
import datetime
import json
import os
import re
@@ -27,7 +26,7 @@ from app.db import get_db_context
from app.models.knowledge_model import Knowledge, KnowledgeType
from app.repositories.memory_short_repository import ShortTermMemoryRepository
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
from app.schemas.memory_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_konwledges_server import (
write_rag,
@@ -610,7 +609,7 @@ class MemoryAgentService:
reranked_results=raw_results.get('reranked_results',[])
try:
statements=[statement['statement'] for statement in reranked_results.get('statements', [])]
except Exception as e:
except Exception:
statements=[]
statements=list(set(statements))
retrieved_content.append({query:statements})
@@ -832,7 +831,6 @@ class MemoryAgentService:
# 获取当前空间下的所有宿主
from app.repositories import app_repository, end_user_repository
from app.schemas.app_schema import App as AppSchema
from app.schemas.end_user_schema import EndUser as EndUserSchema
# 查询应用并转换为 Pydantic 模型
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
2. 获取该应用的最新发布版本
3. 从发布版本的 config 字段中提取 memory_config_id
4. 根据 memory_config_id 查询配置名称
Args:
end_user_id: 终端用户ID
db: 数据库会话
Returns:
包含 memory_config_id 和相关信息的字典
包含 memory_config_id、config_name 和相关信息的字典
Raises:
ValueError: 当终端用户不存在或应用未发布时
"""
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"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_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 = {
"end_user_id": str(end_user_id),
"app_id": str(app_id),
"release_id": str(latest_release.id),
"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

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.repositories.end_user_repository import EndUserRepository
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 pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -883,866 +884,6 @@ class UserMemoryService:
"failed": failed,
"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
) -> List[Dict[str, Any]]:
"""
统计8种记忆类型的数量和百分比
统计9种记忆类型的数量和百分比
计算规则:
1. 感知记忆 (PERCEPTUAL_MEMORY) = statement + entity
2. 工作记忆 (WORKING_MEMORY) = chunk + entity
3. 短期记忆 (SHORT_TERM_MEMORY) = chunk
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
7. 情绪记忆 (EMOTIONAL_MEMORY) = statement
8. 情景记忆 (EPISODIC_MEMORY) = memory_summary
7. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取)
8. 情景记忆 (EPISODIC_MEMORY) = memory_summary(通过 MemoryBaseService.get_episodic_memory_count 获取)
9. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_memory_count 获取)
Args:
db: 数据库会话
@@ -2090,13 +1232,16 @@ async def analytics_memory_types(
- IMPLICIT_MEMORY: 隐性记忆
- EMOTIONAL_MEMORY: 情绪记忆
- EPISODIC_MEMORY: 情景记忆
- FORGET_MEMORY: 遗忘记忆
"""
# 定义需要查询的节点类型
# 初始化基础服务
base_service = MemoryBaseService()
# 定义需要查询的基础节点类型
node_types = {
"Statement": "Statement",
"Entity": "ExtractedEntity",
"Chunk": "Chunk",
"MemorySummary": "MemorySummary"
"Chunk": "Chunk"
}
# 存储每种节点类型的计数
@@ -2126,18 +1271,45 @@ async def analytics_memory_types(
statement_count = node_counts.get("Statement", 0)
entity_count = node_counts.get("Entity", 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 = {
"PERCEPTUAL_MEMORY": statement_count + entity_count, # 感知记忆
"WORKING_MEMORY": chunk_count + entity_count, # 工作记忆
"SHORT_TERM_MEMORY": chunk_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)
"EMOTIONAL_MEMORY": statement_count, # 情绪记忆
"EPISODIC_MEMORY": memory_summary_count # 情景记忆
"EMOTIONAL_MEMORY": emotion_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{
version: string;
introduction: string;
introduction: {
releaseDate: string;
upgradePosition: string;
coreUpgrades: string[];
codeName: string;
};
}
// 首页数据统计
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) => {
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) => {
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) => {
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) => {
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) => {
return request.post('/memory-storage/analytics/generate_cache', { end_user_id })
}
// 遗忘
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) => {
@@ -176,10 +176,10 @@ export const getPerceptualTimeline = (end_user: 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; } ) => {
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; } ) => {
@@ -190,10 +190,10 @@ export const getTimelineMemories = (data: { id: string; label: string; }) => {
return request.get(`/memory-storage/memory_space/timeline_memories`, data)
}
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; }) => {
return request.post(`/memory-storage/classifications/explicit-memory-details`, data)
return request.post(`/memory/explicit-memory/details`, data)
}
export const getConversations = (end_user: string) => {
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) => {
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 用户记忆 相关接口 ******************************/
/****************** 记忆管理 相关接口 *******************************/
@@ -228,11 +229,11 @@ export const deleteMemoryConfig = (config_id: number) => {
}
// 遗忘引擎-获取配置
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) => {
return request.post('/memory/forget/update_config', values)
return request.post('/memory/forget-memory/update_config', values)
}
// 记忆萃取引擎-获取配置
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
* @Date: 2025-12-10 16:46:17
* @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 clsx from 'clsx'
@@ -55,7 +55,7 @@ const ChatContent: FC<ChatContentProps> = ({
</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且非助手消息
'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 type { SelectProps, DefaultOptionType } from 'antd/es/select'
import { useTranslation } from 'react-i18next';
@@ -26,7 +26,7 @@ interface CustomSelectProps extends Omit<SelectProps, 'filterOption'> {
disabled?: boolean;
style?: React.CSSProperties;
className?: string;
filterOption?: (inputValue: string, option: DefaultOptionType) => boolean;
filterOption?: (inputValue: string, option?: DefaultOptionType) => boolean;
}
interface OptionType {
[key: string]: Key | string | number;
@@ -48,44 +48,27 @@ const CustomSelect: FC<CustomSelectProps> = ({
}) => {
const { t } = useTranslation();
const [options, setOptions] = useState<OptionType[]>([]);
// 创建防抖定时器引用
const debounceRef = useRef<number>();
// 防抖搜索函数
const handleSearch = useCallback((value?: string) => {
// 清除之前的定时器
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
// 设置新的定时器
debounceRef.current = window.setTimeout(() => {
request.get<ApiResponse<OptionType>>(url, {...params, [optionFilterProp]: value}).then((res) => {
const data = res;
setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []);
});
}, 300); // 300毫秒防抖延迟
}, [url, params, optionFilterProp]);
// 默认模糊搜索函数
const defaultFilterOption = (inputValue: string, option?: DefaultOptionType) => {
if (!option || !inputValue) return true;
const label = String(option.children || option.label || '');
return label.toLowerCase().includes(inputValue.toLowerCase());
};
// 组件挂载时获取初始数据
useEffect(() => {
handleSearch();
// 组件卸载时清除定时器
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [url, handleSearch]);
request.get<ApiResponse<OptionType>>(url, params).then((res) => {
const data = res;
setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []);
});
}, []);
return (
<Select
placeholder={placeholder ? placeholder : t('common.select')}
onChange={onChange}
defaultValue={hasAll ? null : undefined}
showSearch={showSearch}
onSearch={handleSearch}
filterOption={filterOption || false} // 禁用本地过滤,使用服务器端过滤
filterOption={filterOption || defaultFilterOption}
{...props}
>
{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 pricingIcon from '@/assets/images/menu/pricing.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> = {
@@ -68,7 +70,9 @@ const iconPathMap: Record<string, string> = {
'apiKey': apiKeyIcon,
'apiKeyActive': apiKeyActiveIcon,
'pricing': pricingIcon,
'pricingActive': pricingActiveIcon
'pricingActive': pricingActiveIcon,
'spaceConfig': spaceConfigIcon,
'spaceConfigActive': spaceConfigActiveIcon,
};
const { Sider } = Layout;

View File

@@ -17,6 +17,11 @@ export const en = {
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',
},
version:{
releaseDate: 'Release Date',
version: 'Version',
name: 'Code Name'
},
quickActions:{
title: 'Quick Actions',
spaceManagement: 'Space Management',
@@ -82,7 +87,7 @@ export const en = {
modelManagement: 'Model Management',
memoryStore: 'Memory Store',
apiParameters: 'API Parameters',
userMemory: 'User Memory',
userMemory: 'Memory Store',
memberManagement: 'Member Management',
memorySummary: 'Memory Summary',
memoryConversation: 'Memory Validation',
@@ -105,6 +110,7 @@ export const en = {
pricing: 'Pricing Management',
orderPayment: 'Order Payment',
orderHistory: 'Order History',
spaceConfig: 'Space Configuration'
},
dashboard: {
total_models: 'Total number of available models',
@@ -1227,6 +1233,8 @@ export const en = {
hire_date: 'Hire Date',
memoryContent: 'Memory Content',
created_at: 'Created At',
updated_at: 'Updated At',
fullScreen: 'Full Screen',
memoryWindow: "{{name}}'s Window of Memory",
memory_insight: 'Overall Overview',
@@ -1253,13 +1261,23 @@ export const en = {
unix: 'items',
completeMemory: 'Complete Memory',
relationshipEvolution: 'Relationship Evolution',
timelineMemories: 'Shared Memory Timeline',
timelineMemories: 'Long-term Memory',
emotionLine: 'Emotion Changes Over Time',
interaction: 'Interaction Frequency & Relationship Stages',
timelines_memory: 'All',
MemorySummary: 'Long-term Accumulation',
Statement: 'Emotional 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: {
createSpace: 'Create Space',
@@ -1275,7 +1293,8 @@ export const en = {
neo4jDesc: 'Based on knowledge graph, suitable for relational reasoning and path query',
llmModel: 'LLM 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: {
title: 'Memory Engine Module Configuration Center',
@@ -1450,6 +1469,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
quickReply: 'Quick Reply',
web_search: 'Online search',
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: {
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_features: 'JSON formatting, compression, validation and conversion functions',
jsonFormat: 'JSON Formatting',
jsonGzip: 'JSON Compression',
jsonCheck: 'JSON Validation',
jsonConversion: 'Format Conversion',
jsonParse: 'JSON Parse',
jsonInsert: 'JSON Insert',
jsonReplace: 'JSON Validation',
jsonDelete: 'JSON Delete',
jsonEg: 'Example JSON',
enterJson: 'Enter JSON',
jsonPlaceholder: 'Enter JSON data, e.g.: {"name": "test", "value": 123}',
clear: 'Clear',
parse: 'Paste',
format: 'Format',
minify: 'Minify',
validate: 'Validate',
convert: 'Escape',
paste: 'Paste',
parse: 'Parse',
json_path: 'JSON Path Parameters',
outputResult: 'Output Result',
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',
variableRequired: 'Required',
addMessage: 'Add Message',
answerDesc: 'Reply'
answerDesc: 'Reply',
addNode: 'Add Node',
},
emotionEngine: {
emotionEngineConfig: 'Emotion Engine Configuration',
@@ -2219,6 +2239,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
node_type: 'Node Type',
last_access_time: 'Last Activation Time',
activation_value: 'Current Activation Value',
refreshSuccess: 'Forgetting Execution Successful',
},
episodicDetail: {
title: 'Record every important scene you have truly experienced',

View File

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

View File

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

View File

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

View File

@@ -376,6 +376,21 @@
"icon": null,
"iconActive": 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')
let currentEvent: SSEMessage = {}
let dataContent = ''
for (const line of lines) {
if (line.startsWith('event:')) {
if (currentEvent.event && dataContent) {
currentEvent.data = parseDataContent(dataContent)
events.push(currentEvent)
}
currentEvent = { event: line.substring(6).trim() }
dataContent = ''
} else if (line.startsWith('data:')) {
if (dataContent) dataContent += '\n'
dataContent += line.substring(5).trim()
}
}
if (currentEvent.event && dataContent) {
currentEvent.data = parseDataContent(dataContent)
console.log('currentEvent', currentEvent)
events.push(currentEvent)
}
return events
}
function parseDataContent(dataContent: string): string | object {
try {
for (const line of lines) {
if (line.startsWith('event:')) {
if (Object.keys(currentEvent).length > 0) {
events.push(currentEvent)
currentEvent = {}
}
currentEvent.event = line.substring(6).trim()
} else if (line.startsWith('data:')) {
const dataStr = line.substring(5).trim()
if (dataStr) {
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
}
}
}
// 第一层解码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 {
// 保持原字符串
}
}
if (Object.keys(currentEvent).length > 0) {
events.push(currentEvent)
}
return events
} catch (error) {
console.error('Parse stream error:', error)
return []
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 decoder = new TextDecoder();
let buffer = ''; // 添加缓冲区来处理不完整的消息
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
if (onMessage) {
onMessage(parseSSEToJSON(chunk) ?? {});
buffer += 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;
}
} catch (error) {

View File

@@ -59,7 +59,7 @@ const GuideCard: React.FC = () => {
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' >
{ t('index.getStarted')}
</div>

View File

@@ -59,7 +59,6 @@ const list = [
]
const TopCardList: FC<{data?: DataResponse}> = ({ data }) => {
const { t } = useTranslation()
debugger
return (
<div className="rb:grid rb:grid-cols-4 rb:gap-[16px]">
{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 { useTranslation } from 'react-i18next';
import { Button } from 'antd';
import arrowRight from '@/assets/images/index/arrow_right.svg'
import { Button, Divider } from 'antd';
// import arrowRight from '@/assets/images/index/arrow_right.svg'
import { getVersion, type versionResponse } from '@/api/common'
const GuideCard: React.FC = () => {
@@ -27,20 +35,38 @@ const GuideCard: React.FC = () => {
return (
<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'>
{ t('index.latestUpdate')}
{versionInfo && (
<span className='rb:ml-2 rb:text-sm rb:text-[#1890FF]'>
{versionInfo.version}
</span>
)}
<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')}
<span className='rb:text-xs rb:text-[#1890FF]'>
{versionInfo?.version}
</span>
</div>
<div className='rb:flex rb:text-xs rb:text-[#5B6167] rb:leading-[18px] rb:mt-3 rb:pl-2'>
{loading ? (
<div className='rb:flex rb:flex-col rb:text-[#5B6167]'>
{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')
) : (
versionInfo?.introduction || t('index.latestUpdateDesc')
)}
)} */}
</div>
{/* <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] '>

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

View File

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

View File

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

View File

@@ -130,6 +130,7 @@ export interface ExecuteData {
ensure_ascii?: boolean;
sort_keys?: boolean;
input_data?: string;
json_path?: string;
}
}
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 { useNavigate } from 'react-router-dom'
import { Row, Col, Radio, Button, List, Skeleton, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { RadioChangeEvent } from 'antd';
import { AppstoreOutlined, MenuOutlined } from '@ant-design/icons';
import { Row, Col, List, Skeleton } from 'antd';
import Empty from '@/components/Empty'
import type { Data, ConfigModalRef } from './types'
import totalNum from '@/assets/images/memory/totalNum.svg'
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 type { Data } from './types'
import { getUserMemoryList } from '@/api/memory';
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() {
const { t } = useTranslation();
const navigate = useNavigate()
const { storageType } = useUser()
const configModalRef = useRef<ConfigModalRef>(null)
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<Data[]>([]);
const [countData, setCountData] = useState<Record<string, number>>({});
const [layout, setLayout] = useState<'card' | 'list'>('card');
const [search, setSearch] = useState<string | undefined>(undefined);
// 获取数据
useEffect(() => {
getCountData()
getData()
}, []);
// 用户记忆统计
const getCountData = () => {
getTotalEndUsers().then((res) => {
setCountData(res as Record<string, number> || {})
})
}
const getData = () => {
setLoading(true)
getUserMemoryList().then((res) => {
@@ -60,7 +32,6 @@ export default function UserMemory() {
setLoading(false)
})
}
console.log('storageType', storageType)
const handleViewDetail = (id: string | number) => {
switch (storageType) {
case 'neo4j':
@@ -70,112 +41,77 @@ export default function UserMemory() {
navigate(`/user-memory/${id}`)
}
}
const handleChangeLayout = (e: RadioChangeEvent) => {
const type = e.target.value
setLayout(type)
const handleViewMemoryConfig = () => {
navigate(`/memory`)
}
// 表格列配置
const columns: ColumnsType = [
{
title: t('userMemory.user'),
dataIndex: 'end_user',
key: 'end_user',
render: (value) => value?.other_name && value?.other_name !== '' ? value?.other_name : value?.id || '-'
},
{
title: t('userMemory.knowledgeEntryCount'),
dataIndex: 'memory_num',
key: 'memory_num',
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>
),
},
];
const filterData = useMemo(() => {
if (search && search.trim() !== '') {
return data.filter((item) => {
const { end_user } = item as Data;
const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id
return name?.includes(search)
})
}
return data
}, [search, data])
return (
<div>
<Row gutter={16} className="rb:mb-4">
{countList.map(key => (
<Col key={key} span={6}>
<div className="rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:p-[18px_20px_20px_20px]">
<div className="rb:text-[28px] rb:font-extrabold rb:leading-8.75 rb:flex rb:items-center rb:justify-between rb:mb-3">
{countData[key] || 0}{key === 'avgInteractionTime' ? 's' : ''}
<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 span={8}>
<SearchInput
placeholder={t('userMemory.searchPlaceholder')}
onSearch={(value) => setSearch(value)}
style={{ width: '100%' }}
/>
</Col>
</Row>
{layout === 'card' &&
<>
{loading ?
<Skeleton active />
: data.length > 0 ? (
<List
grid={{ gutter: 16, column: 4 }}
dataSource={data}
renderItem={(item, index) => {
const { end_user, memory_num } = item as Data;
const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id
return (
<List.Item key={index}>
<div
className="rb:p-5 rb:rounded-xl rb:border rb:border-[#DFE4ED] rb:cursor-pointer"
style={{
background: bgList[index % bgList.length],
}}
{loading ?
<Skeleton active />
: filterData.length > 0 ? (
<List
grid={{ gutter: 16, column: 3 }}
dataSource={filterData}
renderItem={(item, index) => {
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
return (
<List.Item key={index}>
<RbCard
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>}
title={name || '-'}
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)}
>
<div className="rb:flex 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 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">
{name || '-'}<br/>
</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:text-[24px] rb:leading-7.5 rb:font-extrabold">{memory_num.total || 0}</div>
<div className="rb:wrap-break-word">{t(`userMemory.knowledgeEntryCount`)}</div>
</div>
</div>
></div>}
>
<div className="rb:flex rb:justify-between rb:items-center">
<div>{t('userMemory.capacity')}</div>
<div>{memory_num?.total || 0} {t('userMemory.memoryNum')}</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>
</List.Item>
)
}}
/>
) : <Empty />}
</>
}
{layout === 'list' &&
<Table
apiUrl={userMemoryListUrl}
columns={columns}
rowKey="end_user.id"
pagination={false}
/>
<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-[#5B6167] rb:leading-5 rb:flex rb:justify-between rb:items-center">
{t('userMemory.memory_config_name')}
<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 className="rb:font-medium rb:leading-5 rb:mt-1">{memory_config?.memory_config_name || '-'}</div>
</div>
</RbCard>
</List.Item>
)
}}
/>
) : <Empty />
}
<ConfigModal ref={configModalRef} />
</div>
);
}

View File

@@ -17,13 +17,10 @@ export interface Data {
entity: number;
}
},
memory_config: {
memory_config_id: string;
memory_config_name: string;
},
type: 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 Empty from '@/components/Empty'
import Loading from '@/components/Empty/Loading'
import type { Emotion } from './GraphDetail'
import type { Emotion } from '../pages/GraphDetail'
interface EmotionLineProps {
chartData: Emotion[];
@@ -26,7 +26,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
const seriesData = timePoints.map(time => dataMap.get(time) || 0)
return {
name: emotionType,
name: t(`userMemory.${emotionType}`),
type: 'line',
smooth: true,
lineStyle: {
@@ -71,7 +71,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
formatter: function(params: any) {
let result = `${params[0].axisValue}<br/>`
params.forEach((param: any) => {
result += `${param.marker}${param.seriesName}: ${param.value}<br/>`
result += `${param.marker}${param.seriesName}: ${param.value}%<br/>`
})
return result
}
@@ -92,7 +92,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
},
grid: {
top: 16,
left: 30,
left: 40,
right: 36,
bottom: 48,
// containLabel: false
@@ -103,7 +103,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
boundaryGap: false,
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC'
fontFamily: 'PingFangSC, PingFang SC',
},
axisLine: {
show: true,
@@ -130,7 +130,8 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
type: 'value',
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC'
fontFamily: 'PingFangSC, PingFang SC',
formatter: '{value}%'
},
axisLine: {
show: true,
@@ -152,7 +153,7 @@ const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
type: 'solid'
}
},
max: 1,
max: 100,
min: 0
},
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 ReactEcharts from 'echarts-for-react'
import Empty from '@/components/Empty'
import Loading from '@/components/Empty/Loading'
import type { Interaction } from './GraphDetail'
import type { Interaction } from '../pages/GraphDetail'
interface InteractionBarProps {
chartData: Interaction[];
@@ -14,11 +14,13 @@ const Colors = ['#155EEF', '#369F21', '#FF5D34']
const InteractionBar: FC<InteractionBarProps> = ({ chartData, loading }) => {
const { t } = useTranslation()
const series = [{
name: 'Interaction Count',
type: 'bar',
data: chartData.map(item => item.count)
}]
const series = useMemo(() => {
return [{
name: t('userMemory.interactionCountData'),
type: 'bar',
data: chartData.map(item => item.count)
}]
}, [chartData, t])
return (
<>
@@ -80,6 +82,7 @@ const InteractionBar: FC<InteractionBarProps> = ({ chartData, loading }) => {
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC'
@@ -104,8 +107,6 @@ const InteractionBar: FC<InteractionBarProps> = ({ chartData, loading }) => {
type: 'solid'
}
},
max: 1,
min: 0
},
series
}}

View File

@@ -1,20 +1,22 @@
import { type FC, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from 'antd';
import { Layout, Space, Button } from 'antd';
import { useTranslation } from 'react-i18next';
import logoutIcon from '@/assets/images/logout.svg'
import logoutIcon from '@/assets/images/logout_hover.svg'
const { Header } = Layout;
interface ConfigHeaderProps {
name?: string;
operation?: ReactNode;
source?: 'detail' | 'node'
source?: 'detail' | 'node';
extra?: ReactNode;
}
const PageHeader: FC<ConfigHeaderProps> = ({
name,
operation,
source = 'detail'
source = 'detail',
extra
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -33,10 +35,13 @@ const PageHeader: FC<ConfigHeaderProps> = ({
{operation}
</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}>
<img src={logoutIcon} className="rb:mr-2 rb:w-4 rb:h-4" />
{t('common.return')}
</div>
<Space size={12}>
<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')}
</Button>
{extra}
</Space>
</Header>
);
};

View File

@@ -1,19 +1,18 @@
import React, { type FC, useEffect, useState, useRef, useCallback } from 'react'
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 dayjs from 'dayjs'
import RbCard from '@/components/RbCard/Card'
import ReactEcharts from 'echarts-for-react'
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 {
getMemorySearchEdges,
} from '@/api/memory'
import Empty from '@/components/Empty'
import Tag from '@/components/Tag'
import GraphDetail from '../components/GraphDetail'
const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048']
const RelationshipNetwork:FC = () => {
@@ -26,7 +25,7 @@ const RelationshipNetwork:FC = () => {
const [categories, setCategories] = useState<{ name: string }[]>([])
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
// const [fullScreen, setFullScreen] = useState<boolean>(false)
const graphDetailRef = useRef<GraphDetailRef>(null)
const navigate = useNavigate()
console.log('categories', categories)
// 关系网络
@@ -133,15 +132,14 @@ const RelationshipNetwork:FC = () => {
}
}, [nodes])
// const handleFullScreen = () => {
// setFullScreen(prev => !prev)
// }
console.log('selectedNode', selectedNode)
const handleViewAll = () => {
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 (
@@ -336,8 +334,6 @@ const RelationshipNetwork:FC = () => {
</div>
</RbCard>
</Col>
<GraphDetail ref={graphDetailRef} />
</Row>
)
}

View File

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

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 { 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 {
getForgetStats,
@@ -12,6 +12,7 @@ import RecentTrendsLineCard from '../components/RecentTrendsLineCard'
import Table from '@/components/Table'
import { formatDateTime } from '@/utils/format'
import StatusTag from '@/components/StatusTag'
import ForgetRefreshModal from '../components/ForgetRefreshModal'
const statusTagColors: Record<string, 'success' | 'purple' | 'default' | 'warning' | 'error' | 'lightBlue'> = {
statement: 'success',
@@ -20,24 +21,33 @@ const statusTagColors: Record<string, 'success' | 'purple' | 'default' | 'warnin
chunk: 'warning',
}
const ForgetDetail: FC = () => {
export interface ForgetRefreshModalRef {
handleOpen: () => void;
}
const ForgetDetail = forwardRef((_props, ref) => {
const { t } = useTranslation()
const { id } = useParams()
const { message } = App.useApp()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<ForgetData>({} as ForgetData)
const forgetRefreshModalRef = useRef<ForgetRefreshModalRef>(null)
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
const getData = (flag: boolean = false) => {
if (!id) return
setLoading(true)
getForgetStats(id).then((res) => {
const response = res as ForgetData
setData(response)
setLoading(false)
if (flag) {
message.success(t('forgetDetail.refreshSuccess'))
}
})
.finally(() => {
setLoading(false)
@@ -67,6 +77,14 @@ const ForgetDetail: FC = () => {
}
}, [data.recent_trends])
const handleRefresh = () => {
forgetRefreshModalRef.current?.handleOpen()
}
useImperativeHandle(ref, () => ({
handleRefresh
}));
return (
<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>
@@ -152,7 +170,12 @@ const ForgetDetail: FC = () => {
]}
pagination={false}
/>
<ForgetRefreshModal
ref={forgetRefreshModalRef}
refresh={getData}
/>
</div>
)
}
})
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 { useSearchParams } from 'react-router-dom'
import { Row, Col, Tabs, Space, Skeleton } from 'antd'
import { getRelationshipEvolution, getTimelineMemories } from '@/api/memory'
import type { Node, GraphDetailRef } from '../types'
import RbDrawer from '@/components/RbDrawer'
import RbCard from '@/components/RbCard/Card'
import EmotionLine from './EmotionLine'
import EmotionLine from '../components/EmotionLine'
import { formatDateTime } from '@/utils/format'
import Tag from '@/components/Tag'
import InteractionBar from './InteractionBar'
import InteractionBar from '../components/InteractionBar'
import Empty from '@/components/Empty'
import PageHeader from '../components/PageHeader'
export interface Emotion {
emotion_intensity: number;
@@ -35,7 +36,7 @@ interface Timeline {
const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false);
const [searchParams] = useSearchParams()
const [vo, setVo] = useState<Node | null>(null)
const [loading, setLoading] = useState(false)
const [emotionData, setEmotionData] = useState<Emotion[]>([])
@@ -43,14 +44,23 @@ const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => {
const [activeTab, setActiveTab] = useState('timelines_memory')
const [timelineLoading, setTimelineLoading] = useState(false)
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')
if (nodeId && nodeLabel) {
const nodeFromUrl = {
id: nodeId,
label: nodeLabel,
name: nodeName || nodeLabel
}
handleOpen(nodeFromUrl as Node)
}
}, [searchParams])
const handleCancel = () => {
setVo(null)
setOpen(false)
}
const handleOpen = (vo: Node) => {
setActiveTab('timelines_memory')
setOpen(true)
setVo(vo)
getRelationshipEvolutionData(vo)
getTimelineMemoriesData(vo)
@@ -85,56 +95,57 @@ const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => {
}, [activeTab, timelineMemories])
return (
<RbDrawer
title={vo?.name}
open={open}
onClose={handleCancel}
width={1000}
>
<div className="rb:text-[16px] rb:font-medium rb:leading-5.5 rb:mb-3">{t('userMemory.relationshipEvolution')}</div>
<RbCard>
<Row gutter={16}>
<Col span={12}>
<EmotionLine chartData={emotionData} loading={loading} />
</Col>
<Col span={12}>
<InteractionBar chartData={interactionData} loading={loading} />
</Col>
</Row>
</RbCard>
<>
<PageHeader
name={vo?.name}
source="node"
/>
<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>
<RbCard>
<Row gutter={16}>
<Col span={12}>
<EmotionLine chartData={emotionData} loading={loading} />
</Col>
<Col span={12}>
<InteractionBar chartData={interactionData} loading={loading} />
</Col>
</Row>
</RbCard>
<div className="rb:text-[16px] rb:font-medium rb:leading-5.5 rb:mb-3 rb:mt-6">{t('userMemory.timelineMemories')}</div>
<RbCard>
<Tabs
activeKey={activeTab}
items={['timelines_memory', 'ExtractedEntity', 'Statement', 'MemorySummary'].map(key => ({
label: t(`userMemory.${key}`),
key
}))}
onChange={(key: string) => setActiveTab(key)}
/>
{timelineLoading
? <Skeleton active />
: !activeContent || activeContent.length === 0
? <Empty size={120} className="rb:mt-12 rb:mb-20.25" />
: <Space size={16} direction="vertical" className="rb:w-full">
{activeContent.map((vo, index) => (
<RbCard
key={index}
headerType="borderL"
headerClassName="rb:before:bg-[#155EEF]!"
title={vo.text}
>
<div className="rb:text-[#A8A9AA] rb:text-[12px] rb:leading-4">{formatDateTime(vo.created_at)}</div>
<Tag className="rb:mt-2">{vo.type}</Tag>
</RbCard>
))}
</Space>
}
<div className="rb:text-[16px] rb:font-medium rb:leading-5.5 rb:mb-3 rb:mt-6">{t('userMemory.timelineMemories')}</div>
<RbCard>
<Tabs
activeKey={activeTab}
items={['timelines_memory', 'Statement', 'MemorySummary'].map(key => ({
label: t(`userMemory.${key}`),
key
}))}
onChange={(key: string) => setActiveTab(key)}
/>
{timelineLoading
? <Skeleton active />
: !activeContent || activeContent.length === 0
? <Empty size={120} className="rb:mt-12 rb:mb-20.25" />
: <Space size={16} direction="vertical" className="rb:w-full">
{activeContent.map((vo, index) => (
<RbCard
key={index}
headerType="borderL"
headerClassName="rb:before:bg-[#155EEF]!"
title={vo.text}
>
<div className="rb:text-[#A8A9AA] rb:text-[12px] rb:leading-4">{formatDateTime(vo.created_at)}</div>
<Tag className="rb:mt-2">{vo.type}</Tag>
</RbCard>
))}
</Space>
}
</RbCard>
</RbDrawer>
</RbCard>
</div>
</>
)
})
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 { useTranslation } from 'react-i18next'
import { Dropdown } from 'antd'
import { Dropdown, Button } from 'antd'
import PageHeader from '../components/PageHeader'
import StatementDetail from './StatementDetail'
@@ -15,12 +15,16 @@ import WorkingDetail from './WorkingDetail'
import {
getEndUserProfile,
} from '@/api/memory'
import refreshIcon from '@/assets/images/refresh_hover.svg'
import GraphDetail from './GraphDetail'
const Detail: FC = () => {
const { t } = useTranslation()
const { id, type } = useParams()
const navigate = useNavigate()
const [name, setName] = useState<string>('')
const forgetDetailRef = useRef<{ handleRefresh: () => void }>(null)
useEffect(() => {
if (!id) return
getData()
@@ -40,6 +44,13 @@ const Detail: FC = () => {
const onClick = ({ key }: { key: string }) => {
navigate(`/user-memory/detail/${id}/${key}`, { replace: true })
}
const handleRefresh = () => {
forgetDetailRef.current?.handleRefresh()
}
if (type === 'GRAPH') {
return <GraphDetail />
}
return (
<div className="rb:h-full rb:w-full">
@@ -49,17 +60,22 @@ const Detail: FC = () => {
operation={
<Dropdown menu={{ items, onClick, selectedKeys: type ? [type] : [] }}>
<div className="rb:cursor-pointer rb:group rb:flex rb:items-center rb:gap-1">
- {type ? t(`userMemory.${type}`) : ''}
- {type ? t(`userMemory.${type}`) : ''}
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/up_border.svg')] rb:transform-[rotate(180deg)] rb:group-hover:transform-[rotate(0deg)]"
></div>
</div>
</div>
</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">
{type === 'EMOTIONAL_MEMORY' && <StatementDetail />}
{type === 'FORGETTING_MANAGEMENT' && <ForgetDetail />}
{type === 'FORGETTING_MANAGEMENT' && <ForgetDetail ref={forgetDetailRef} />}
{type === 'IMPLICIT_MEMORY' && <ImplicitDetail />}
{type === 'SHORT_TERM_MEMORY' && <ShortTermDetail />}
{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 [variables, setVariables] = useState<StartVariableItem[]>([])
const [streamLoading, setStreamLoading] = useState(false)
const [conversationId, setConversationId] = useState<string | null>(null)
const handleOpen = () => {
setOpen(true)
@@ -100,7 +101,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
setStreamLoading(false)
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) {
case 'message':
@@ -131,6 +132,10 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
setStreamLoading(false)
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, {
message: message,
variables: params,
stream: true
stream: true,
conversation_id: conversationId
}, handleStreamMessage)
.finally(() => {
setLoading(false)

View File

@@ -107,7 +107,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
<div style={{ maxHeight: '300px', overflowY: 'auto', minWidth: '240px' }}>
{nodeLibrary.map((category, categoryIndex) => {
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,22 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
isCanAdd = false
}) => {
const { t } = useTranslation();
const form = Form.useFormInstance();
const value = form.getFieldValue(name) || [];
console.log('GroupVariableList', value)
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 (
<div className="rb:mb-4">
<Row gutter={12} className="rb:mb-2!">
@@ -38,7 +52,7 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
>
<VariableSelect
placeholder={t('common.pleaseSelect')}
options={options}
options={filteredOptions}
mode="multiple"
/>
</Form.Item>
@@ -77,7 +91,18 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
>
<VariableSelect
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"
/>
</Form.Item>

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import ConditionList from './ConditionList'
import CycleVarsList from './CycleVarsList'
import AssignmentList from './AssignmentList'
import ToolConfig from './ToolConfig'
// import { calculateVariableList } from './utils/variableListCalculator'
interface PropertiesProps {
selectedNode?: Node | null;
@@ -338,112 +339,35 @@ const Properties: FC<PropertiesProps> = ({
const parentLoopNode = getParentLoopNode(selectedNode.id);
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) {
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)) {
addedKeys.add(key);
variableList.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
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);
}
// Add conversation variables from global config
const conversationVariables = workflowConfig?.variables || [];
conversationVariables.forEach((variable: any) => {
const key = `CONVERSATION_${variable.name}`;
if (!addedKeys.has(key)) {
addedKeys.add(key);
variableList.push({
key,
label: variable.name,
type: 'variable',
dataType: variable.type,
value: `conv.${variable.name}`,
nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' },
group: 'CONVERSATION'
});
}
});
allRelevantNodeIds.forEach(nodeId => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
@@ -496,7 +420,7 @@ const Properties: FC<PropertiesProps> = ({
key: llmKey,
label: 'output',
type: 'variable',
dataType: 'String',
dataType: 'string',
value: `${dataNodeId}.output`,
nodeData: nodeData,
});
@@ -565,6 +489,17 @@ const Properties: FC<PropertiesProps> = ({
const groupVariables = nodeData.config.group_variables.defaultValue || [];
groupVariables?.forEach((groupVar: any) => {
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}`;
if (!addedKeys.has(groupVarKey)) {
addedKeys.add(groupVarKey);
@@ -572,14 +507,26 @@ const Properties: FC<PropertiesProps> = ({
key: groupVarKey,
label: groupVar.key,
type: 'variable',
dataType: 'string',
dataType: groupDataType,
value: `${dataNodeId}.${groupVar.key}`,
nodeData: nodeData,
});
}
});
} 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`;
if (!addedKeys.has(varAggregatorKey)) {
addedKeys.add(varAggregatorKey);
@@ -587,7 +534,7 @@ const Properties: FC<PropertiesProps> = ({
key: varAggregatorKey,
label: 'output',
type: 'variable',
dataType: 'string',
dataType: outputDataType,
value: `${dataNodeId}.output`,
nodeData: nodeData,
});
@@ -684,21 +631,20 @@ const Properties: FC<PropertiesProps> = ({
nodeData: nodeData,
});
}
if (!addedKeys.has(outputKey)) {
addedKeys.add(outputKey);
variableList.push({
key: outputKey,
label: 'output',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.output`,
nodeData: nodeData,
});
}
// if (!addedKeys.has(outputKey)) {
// addedKeys.add(outputKey);
// variableList.push({
// key: outputKey,
// label: 'output',
// type: 'variable',
// dataType: 'string',
// value: `${dataNodeId}.output`,
// nodeData: nodeData,
// });
// }
break
case 'iteration':
const iterationOutputKey = `${dataNodeId}_output`;
const iterationItemKey = `${dataNodeId}_item`;
if (!addedKeys.has(iterationOutputKey)) {
addedKeys.add(iterationOutputKey);
// Get the data type from the output configuration, default to string
@@ -715,22 +661,11 @@ const Properties: FC<PropertiesProps> = ({
key: iterationOutputKey,
label: 'output',
type: 'variable',
dataType: outputDataType,
dataType: `array[${outputDataType}]`,
value: `${dataNodeId}.output`,
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
case 'loop':
const cycleVars = nodeData.config.cycle_vars.defaultValue || [];
@@ -760,47 +695,337 @@ const Properties: FC<PropertiesProps> = ({
key: toolDataKey,
label: 'data',
type: 'variable',
dataType: 'object',
dataType: 'string',
value: `${dataNodeId}.data`,
nodeData: nodeData,
});
}
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) => {
const key = `CONVERSATION_${variable.name}`;
if (!addedKeys.has(key)) {
addedKeys.add(key);
variableList.push({
key,
label: variable.name,
type: 'variable',
dataType: variable.type,
value: `conv.${variable.name}`,
nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' },
group: 'CONVERSATION'
// Add parent loop/iteration node variables if current node is a child
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)) {
addedKeys.add(key);
variableList.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 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;
}, [selectedNode, graphRef, workflowConfig?.variables]);
// Filter out boolean type variables for loop and llm nodes
const getFilteredVariableList = (nodeType?: string) => {
if (nodeType === 'loop' || nodeType === 'llm') {
return variableList.filter(variable => variable.dataType !== 'boolean');
const getFilteredVariableList = (nodeType?: string, key?: string) => {
// Check if current node is a child of iteration node
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 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;
}
return variableList;
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('variableList', variableList, selectedNode?.data)
// console.log('variableList', variableList, defaultVariableList)
return (
<div className="rb:w-75 rb:fixed rb:right-0 rb:top-16 rb:bottom-0 rb:p-3">
@@ -901,11 +1126,10 @@ const Properties: FC<PropertiesProps> = ({
});
}
}
return (
<Form.Item key={key} name={key}>
<MessageEditor
key={key}
key={key}
options={contextVariableList.filter(variable => variable.nodeData?.type !== 'knowledge-retrieval')}
parentName={key}
/>
@@ -915,7 +1139,12 @@ const Properties: FC<PropertiesProps> = ({
if (selectedNode?.data?.type === 'end' && key === 'output') {
return (
<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>
)
}
@@ -943,7 +1172,7 @@ const Properties: FC<PropertiesProps> = ({
isArray={!!config.isArray}
parentName={key}
enableJinja2={config.enableJinja2 as boolean}
options={getFilteredVariableList(selectedNode?.data?.type)}
options={getFilteredVariableList(selectedNode?.data?.type, key)}
/>
</Form.Item>
)
@@ -964,7 +1193,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}>
<GroupVariableList
name={key}
options={getFilteredVariableList(selectedNode?.data?.type)}
options={getFilteredVariableList(selectedNode?.data?.type, key)}
isCanAdd={!!(values as any)?.group}
/>
</Form.Item>
@@ -976,7 +1205,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}>
<CaseList
name={key}
options={getFilteredVariableList(selectedNode?.data?.type)}
options={getFilteredVariableList(selectedNode?.data?.type, key)}
selectedNode={selectedNode}
graphRef={graphRef}
/>
@@ -989,7 +1218,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={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>
)
@@ -999,7 +1228,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}>
<CycleVarsList
parentName={key}
options={getFilteredVariableList(selectedNode?.data?.type)}
options={getFilteredVariableList(selectedNode?.data?.type, key)}
/>
</Form.Item>
)
@@ -1013,9 +1242,9 @@ const Properties: FC<PropertiesProps> = ({
if (config.filterLoopIterationVars) {
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
placeholder={t('common.pleaseSelect')}
options={(() => {
const baseVariableList = getFilteredVariableList(selectedNode?.data?.type);
const baseVariableList = getFilteredVariableList(selectedNode?.data?.type, key);
// Apply filtering if specified in config
if (config.filterNodeTypes || config.filterVariableNames) {
return baseVariableList.filter(variable => {
@@ -1068,7 +1297,7 @@ const Properties: FC<PropertiesProps> = ({
(Array.isArray(config.filterNodeTypes) && config.filterNodeTypes.includes(variable.nodeData?.type));
const variableNameMatch = !config.filterVariableNames ||
(Array.isArray(config.filterVariableNames) && config.filterVariableNames.includes(variable.label));
return nodeTypeMatch && variableNameMatch;
return nodeTypeMatch || variableNameMatch;
});
}
// Filter child nodes for iteration output
@@ -1085,7 +1314,7 @@ const Properties: FC<PropertiesProps> = ({
});
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;
@@ -1095,7 +1324,12 @@ const Properties: FC<PropertiesProps> = ({
: config.type === 'switch'
? <Switch onChange={key === 'group' ? () => { form.setFieldValue('group_variables', []) } : undefined} />
: 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'
? <ConditionList
parentName={key}
@@ -1109,18 +1343,9 @@ const Properties: FC<PropertiesProps> = ({
value: `${selectedNode.getData().id}.${cycleVar.name}`,
nodeData: selectedNode.getData(),
}));
return [...variableList.filter(variable => {
// Keep conversation variables
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];
})()
}
return [...getFilteredVariableList(selectedNode?.data?.type, key), ...cycleVarSuggestions];
})()}
selectedNode={selectedNode}
graphRef={graphRef}
addBtnText={t('workflow.config.addCase')}

View File

@@ -270,7 +270,7 @@ export const nodeLibrary: NodeLibrary[] = [
config: {
input: {
type: 'variableList',
filterNodeTypes: ['knowledge-retrieval'],
filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop'],
filterVariableNames: ['message']
},
parallel: {
@@ -334,8 +334,7 @@ export const nodeLibrary: NodeLibrary[] = [
}
}
},
{
type: "assigner", icon: assignerIcon,
{ type: "assigner", icon: assignerIcon,
config: {
assignments: {
type: 'assignmentList',
@@ -628,4 +627,114 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
items: [{ group: 'left' }],
},
}
}
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)
}
if (edges.length) {
// 去重处理:相同节点之间的连线仅连一次
// 去重处理:对于if-else和question-classifier节点不同连接桩允许连接到相同节点
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 => {
@@ -954,7 +966,10 @@ export const useWorkflowGraph = ({
itemConfig = {
...itemConfig,
...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, index, arr) => {
// 去重:相同节点之间的连线仅保留一次
return arr.findIndex(e => e && e.source === edge?.source && e.target === edge?.target) === index;
// 去重:对于if-else和question-classifier节点不同连接桩允许连接到相同节点
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)