Merge branch 'release/v0.2.4' into develop
# Conflicts: # web/src/views/Workflow/constant.ts # web/src/views/Workflow/hooks/useWorkflowGraph.ts
This commit is contained in:
@@ -90,40 +90,41 @@ celery_app.conf.update(
|
||||
celery_app.autodiscover_tasks(['app'])
|
||||
|
||||
# Celery Beat schedule for periodic tasks
|
||||
# memory_increment_schedule = timedelta(hours=settings.MEMORY_INCREMENT_INTERVAL_HOURS)
|
||||
# memory_cache_regeneration_schedule = timedelta(hours=settings.MEMORY_CACHE_REGENERATION_HOURS)
|
||||
# workspace_reflection_schedule = timedelta(seconds=30) # 每30秒运行一次settings.REFLECTION_INTERVAL_TIME
|
||||
# forgetting_cycle_schedule = timedelta(hours=24) # 每24小时运行一次遗忘周期
|
||||
memory_increment_schedule = timedelta(hours=settings.MEMORY_INCREMENT_INTERVAL_HOURS)
|
||||
memory_cache_regeneration_schedule = timedelta(hours=settings.MEMORY_CACHE_REGENERATION_HOURS)
|
||||
# 这个30秒的设计不合理
|
||||
workspace_reflection_schedule = timedelta(seconds=30) # 每30秒运行一次settings.REFLECTION_INTERVAL_TIME
|
||||
forgetting_cycle_schedule = timedelta(hours=24) # 每24小时运行一次遗忘周期
|
||||
|
||||
# 构建定时任务配置
|
||||
# beat_schedule_config = {
|
||||
# "run-workspace-reflection": {
|
||||
# "task": "app.tasks.workspace_reflection_task",
|
||||
# "schedule": workspace_reflection_schedule,
|
||||
# "args": (),
|
||||
# },
|
||||
# "regenerate-memory-cache": {
|
||||
# "task": "app.tasks.regenerate_memory_cache",
|
||||
# "schedule": memory_cache_regeneration_schedule,
|
||||
# "args": (),
|
||||
# },
|
||||
# "run-forgetting-cycle": {
|
||||
# "task": "app.tasks.run_forgetting_cycle_task",
|
||||
# "schedule": forgetting_cycle_schedule,
|
||||
# "kwargs": {
|
||||
# "config_id": None, # 使用默认配置,可以通过环境变量配置
|
||||
# },
|
||||
# },
|
||||
# }
|
||||
#构建定时任务配置
|
||||
beat_schedule_config = {
|
||||
"run-workspace-reflection": {
|
||||
"task": "app.tasks.workspace_reflection_task",
|
||||
"schedule": workspace_reflection_schedule,
|
||||
"args": (),
|
||||
},
|
||||
"regenerate-memory-cache": {
|
||||
"task": "app.tasks.regenerate_memory_cache",
|
||||
"schedule": memory_cache_regeneration_schedule,
|
||||
"args": (),
|
||||
},
|
||||
"run-forgetting-cycle": {
|
||||
"task": "app.tasks.run_forgetting_cycle_task",
|
||||
"schedule": forgetting_cycle_schedule,
|
||||
"kwargs": {
|
||||
"config_id": None, # 使用默认配置,可以通过环境变量配置
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# 如果配置了默认工作空间ID,则添加记忆总量统计任务
|
||||
# if settings.DEFAULT_WORKSPACE_ID:
|
||||
# beat_schedule_config["write-total-memory"] = {
|
||||
# "task": "app.controllers.memory_storage_controller.search_all",
|
||||
# "schedule": memory_increment_schedule,
|
||||
# "kwargs": {
|
||||
# "workspace_id": settings.DEFAULT_WORKSPACE_ID,
|
||||
# },
|
||||
# }
|
||||
#如果配置了默认工作空间ID,则添加记忆总量统计任务
|
||||
if settings.DEFAULT_WORKSPACE_ID:
|
||||
beat_schedule_config["write-total-memory"] = {
|
||||
"task": "app.controllers.memory_storage_controller.search_all",
|
||||
"schedule": memory_increment_schedule,
|
||||
"kwargs": {
|
||||
"workspace_id": settings.DEFAULT_WORKSPACE_ID,
|
||||
},
|
||||
}
|
||||
|
||||
# celery_app.conf.beat_schedule = beat_schedule_config
|
||||
celery_app.conf.beat_schedule = beat_schedule_config
|
||||
|
||||
@@ -186,7 +186,7 @@ async def get_emotion_health(
|
||||
"情绪健康指数获取成功",
|
||||
extra={
|
||||
"end_user_id": request.end_user_id,
|
||||
"health_score": data.get("health_score", 0),
|
||||
"health_score": data.get("health_score") or 0,
|
||||
"level": data.get("level", "未知")
|
||||
}
|
||||
)
|
||||
@@ -244,16 +244,46 @@ async def get_emotion_suggestions(
|
||||
)
|
||||
|
||||
if data is None:
|
||||
# 缓存不存在或已过期
|
||||
# 缓存不存在或已过期,自动触发生成
|
||||
api_logger.info(
|
||||
f"用户 {request.end_user_id} 的建议缓存不存在或已过期",
|
||||
f"用户 {request.end_user_id} 的建议缓存不存在或已过期,自动生成新建议",
|
||||
extra={"end_user_id": request.end_user_id}
|
||||
)
|
||||
return fail(
|
||||
BizCode.NOT_FOUND,
|
||||
"建议缓存不存在或已过期,请右上角刷新生成新建议",
|
||||
""
|
||||
)
|
||||
try:
|
||||
data = await emotion_service.generate_emotion_suggestions(
|
||||
end_user_id=request.end_user_id,
|
||||
db=db,
|
||||
language=language
|
||||
)
|
||||
# 保存到缓存
|
||||
await emotion_service.save_suggestions_cache(
|
||||
end_user_id=request.end_user_id,
|
||||
suggestions_data=data,
|
||||
db=db,
|
||||
expires_hours=24
|
||||
)
|
||||
except (ValueError, KeyError) as gen_e:
|
||||
# 预期内的业务异常:配置缺失、数据格式问题等
|
||||
api_logger.warning(
|
||||
f"自动生成建议失败(业务异常): {str(gen_e)}",
|
||||
extra={"end_user_id": request.end_user_id}
|
||||
)
|
||||
return fail(
|
||||
BizCode.NOT_FOUND,
|
||||
f"自动生成建议失败: {str(gen_e)}",
|
||||
""
|
||||
)
|
||||
except Exception as gen_e:
|
||||
# 非预期异常:记录完整 traceback 便于排查
|
||||
api_logger.error(
|
||||
f"自动生成建议时发生未预期异常: {str(gen_e)}",
|
||||
extra={"end_user_id": request.end_user_id},
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"生成建议时发生内部错误: {str(gen_e)}"
|
||||
)
|
||||
|
||||
api_logger.info(
|
||||
"个性化建议获取成功(缓存)",
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
包含情景记忆总览和详情查询接口
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
|
||||
from app.core.error_codes import BizCode
|
||||
from app.core.language_utils import get_language_from_header
|
||||
from app.core.logging_config import get_api_logger
|
||||
from app.core.response_utils import fail, success
|
||||
from app.dependencies import get_current_user
|
||||
@@ -14,6 +15,7 @@ from app.schemas.response_schema import ApiResponse
|
||||
from app.schemas.memory_episodic_schema import (
|
||||
EpisodicMemoryOverviewRequest,
|
||||
EpisodicMemoryDetailsRequest,
|
||||
translate_episodic_type,
|
||||
)
|
||||
from app.services.memory_episodic_service import memory_episodic_service
|
||||
|
||||
@@ -84,6 +86,7 @@ async def get_episodic_memory_overview_api(
|
||||
@router.post("/details", response_model=ApiResponse)
|
||||
async def get_episodic_memory_details_api(
|
||||
request: EpisodicMemoryDetailsRequest,
|
||||
language_type: str = Header(default=None, alias="X-Language-Type"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""
|
||||
@@ -111,6 +114,11 @@ async def get_episodic_memory_details_api(
|
||||
summary_id=request.summary_id
|
||||
)
|
||||
|
||||
# 根据语言参数翻译 episodic_type
|
||||
language = get_language_from_header(language_type)
|
||||
if "episodic_type" in result:
|
||||
result["episodic_type"] = translate_episodic_type(result["episodic_type"], language)
|
||||
|
||||
api_logger.info(
|
||||
f"成功获取情景记忆详情: end_user_id={request.end_user_id}, summary_id={request.summary_id}"
|
||||
)
|
||||
|
||||
@@ -233,6 +233,14 @@ async def extract_ontology(
|
||||
language=language
|
||||
)
|
||||
|
||||
# 根据语言类型统一 name 字段
|
||||
# zh: name 使用 name_chinese(中文名)
|
||||
# en: name 保持原值(英文 PascalCase)
|
||||
if language == "zh":
|
||||
for cls in result.classes:
|
||||
if cls.name_chinese:
|
||||
cls.name = cls.name_chinese
|
||||
|
||||
# 构建响应
|
||||
response = ExtractionResponse(
|
||||
classes=result.classes,
|
||||
@@ -1007,7 +1015,7 @@ async def export_owl_by_scene(
|
||||
|
||||
# 2. 查询场景下的所有本体类型
|
||||
class_repo = OntologyClassRepository(db)
|
||||
ontology_classes_db = class_repo.get_by_scene(request.scene_id)
|
||||
ontology_classes_db = class_repo.get_classes_by_scene(request.scene_id)
|
||||
|
||||
if not ontology_classes_db:
|
||||
api_logger.warning(f"No classes found in scene: {request.scene_id}")
|
||||
|
||||
@@ -38,6 +38,56 @@ class SensitiveDataLoggingFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
class Neo4jSuccessNotificationFilter(logging.Filter):
|
||||
"""Neo4j 日志过滤器:过滤成功/信息性状态的通知,保留真正的警告和错误
|
||||
|
||||
Neo4j 驱动会以 WARNING 级别记录所有数据库通知,包括成功的操作。
|
||||
这个过滤器会过滤掉以下 GQL 状态码的通知,只保留真正的警告和错误:
|
||||
- 00000: 成功完成 (successful completion)
|
||||
- 00N00: 无数据 (no data)
|
||||
- 00NA0: 无数据,信息性通知 (no data, informational notification)
|
||||
|
||||
使用正则表达式进行更严格的匹配,避免误过滤无关的警告。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# 编译正则表达式以提高性能
|
||||
# 匹配所有"成功/信息性"的 GQL 状态码:
|
||||
# 00000 = 成功完成, 00N00 = 无数据, 00NA0 = 无数据信息性通知
|
||||
GQL_STATUS_PATTERN = re.compile(r"gql_status=['\"](00000|00N00|00NA0)['\"]")
|
||||
|
||||
# 匹配 status_description 中的成功完成或信息性通知消息
|
||||
SUCCESS_DESC_PATTERN = re.compile(r"status_description=['\"]note:\s*(successful\s+completion|no\s+data)['\"]", re.IGNORECASE)
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""
|
||||
过滤 Neo4j 成功通知
|
||||
|
||||
Args:
|
||||
record: 日志记录
|
||||
|
||||
Returns:
|
||||
True表示允许记录,False表示拒绝(过滤掉)
|
||||
"""
|
||||
# 只处理 INFO 和 WARNING 级别的日志
|
||||
# Neo4j 驱动对 severity='INFORMATION' 的通知使用 INFO 级别,
|
||||
# 对 severity='WARNING' 的通知使用 WARNING 级别
|
||||
if record.levelno not in (logging.INFO, logging.WARNING):
|
||||
return True
|
||||
|
||||
# 检查是否是 Neo4j 的成功通知
|
||||
message = str(record.msg)
|
||||
|
||||
# 使用正则表达式进行更严格的匹配
|
||||
# 这样可以避免误过滤包含这些子字符串但不是 Neo4j 通知的日志
|
||||
if self.GQL_STATUS_PATTERN.search(message) or self.SUCCESS_DESC_PATTERN.search(message):
|
||||
return False # 过滤掉这条日志
|
||||
|
||||
# 保留其他所有日志(包括真正的警告和错误)
|
||||
return True
|
||||
|
||||
|
||||
class LoggingConfig:
|
||||
"""全局日志配置类"""
|
||||
|
||||
@@ -65,6 +115,22 @@ class LoggingConfig:
|
||||
# 清除现有处理器
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Neo4j 通知过滤器 - 挂在 handler 上确保所有传播上来的日志都能被过滤
|
||||
neo4j_filter = Neo4jSuccessNotificationFilter()
|
||||
|
||||
# 抑制 Neo4j 通知日志
|
||||
# Neo4j 驱动内部会给 neo4j.notifications logger 配置自己的 handler,
|
||||
# 导致日志绕过根 logger 的 filter 直接输出。
|
||||
# 多管齐下确保过滤生效:
|
||||
# 1. 设置 neo4j.notifications 级别为 WARNING(过滤 INFO 级别的 00NA0 通知)
|
||||
# 2. 在所有 neo4j logger 上添加 filter(过滤 WARNING 级别的成功通知)
|
||||
# 3. 在根 handler 上也添加 filter(兜底)
|
||||
neo4j_notifications_logger = logging.getLogger("neo4j.notifications")
|
||||
neo4j_notifications_logger.setLevel(logging.WARNING)
|
||||
for neo4j_logger_name in ["neo4j", "neo4j.io", "neo4j.pool", "neo4j.notifications"]:
|
||||
neo4j_logger = logging.getLogger(neo4j_logger_name)
|
||||
neo4j_logger.addFilter(neo4j_filter)
|
||||
|
||||
# 创建格式化器
|
||||
formatter = logging.Formatter(
|
||||
fmt=settings.LOG_FORMAT,
|
||||
@@ -80,6 +146,7 @@ class LoggingConfig:
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(getattr(logging, settings.LOG_LEVEL.upper()))
|
||||
console_handler.addFilter(sensitive_filter)
|
||||
console_handler.addFilter(neo4j_filter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 文件处理器(带轮转)
|
||||
@@ -93,6 +160,7 @@ class LoggingConfig:
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setLevel(getattr(logging, settings.LOG_LEVEL.upper()))
|
||||
file_handler.addFilter(sensitive_filter)
|
||||
file_handler.addFilter(neo4j_filter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
cls._initialized = True
|
||||
|
||||
@@ -126,6 +126,7 @@ async def write(
|
||||
config=pipeline_config,
|
||||
embedding_id=embedding_model_id,
|
||||
language=language,
|
||||
ontology_types=ontology_types,
|
||||
)
|
||||
|
||||
# Run the complete extraction pipeline
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
- reload_ontology_registry: 重新加载本体注册表(实验模式)
|
||||
- clear_ontology_cache: 清除本体缓存
|
||||
- is_general_ontology_enabled: 检查通用本体类型功能是否启用
|
||||
- load_ontology_types_for_scene: 从数据库加载场景的本体类型
|
||||
- create_empty_ontology_type_list: 创建空的本体类型列表
|
||||
- load_ontology_types_with_fallback: 加载本体类型(带通用类型回退)
|
||||
"""
|
||||
|
||||
from .ontology_type_merger import OntologyTypeMerger, DEFAULT_CORE_GENERAL_TYPES
|
||||
@@ -17,6 +20,9 @@ from .ontology_type_loader import (
|
||||
reload_ontology_registry,
|
||||
clear_ontology_cache,
|
||||
is_general_ontology_enabled,
|
||||
load_ontology_types_for_scene,
|
||||
create_empty_ontology_type_list,
|
||||
load_ontology_types_with_fallback,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -27,4 +33,7 @@ __all__ = [
|
||||
"reload_ontology_registry",
|
||||
"clear_ontology_cache",
|
||||
"is_general_ontology_enabled",
|
||||
"load_ontology_types_for_scene",
|
||||
"create_empty_ontology_type_list",
|
||||
"load_ontology_types_with_fallback",
|
||||
]
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
Functions:
|
||||
load_ontology_types_for_scene: 从数据库加载场景的本体类型
|
||||
is_general_ontology_enabled: 检查是否启用通用本体
|
||||
get_general_ontology_registry: 获取通用本体类型注册表(单例,懒加载)
|
||||
get_ontology_type_merger: 获取类型合并服务实例
|
||||
reload_ontology_registry: 重新加载本体注册表
|
||||
clear_ontology_cache: 清除本体缓存
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
@@ -15,6 +20,10 @@ from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 模块级缓存(单例)
|
||||
_general_registry_cache = None
|
||||
_ontology_type_merger_cache = None
|
||||
|
||||
|
||||
def load_ontology_types_for_scene(
|
||||
scene_id: Optional[UUID],
|
||||
@@ -52,8 +61,7 @@ def load_ontology_types_for_scene(
|
||||
# 查询场景的本体类型
|
||||
ontology_repo = OntologyClassRepository(db)
|
||||
ontology_classes = ontology_repo.get_classes_by_scene(
|
||||
scene_id=scene_id,
|
||||
workspace_id=workspace_id
|
||||
scene_id=scene_id
|
||||
)
|
||||
|
||||
if not ontology_classes:
|
||||
@@ -96,20 +104,137 @@ def create_empty_ontology_type_list() -> Optional["OntologyTypeList"]:
|
||||
def is_general_ontology_enabled() -> bool:
|
||||
"""检查是否启用了通用本体
|
||||
|
||||
通过配置开关和注册表是否可用来判断。
|
||||
|
||||
Returns:
|
||||
True 如果通用本体已启用,否则 False
|
||||
"""
|
||||
try:
|
||||
from app.core.memory.ontology_services.ontology_type_merger import OntologyTypeMerger
|
||||
from app.core.config import settings
|
||||
|
||||
merger = OntologyTypeMerger()
|
||||
return merger.general_registry is not None
|
||||
if not settings.ENABLE_GENERAL_ONTOLOGY_TYPES:
|
||||
return False
|
||||
|
||||
registry = get_general_ontology_registry()
|
||||
return registry is not None and len(registry.types) > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check general ontology status: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_general_ontology_registry():
|
||||
"""获取通用本体类型注册表(单例,懒加载)
|
||||
|
||||
从配置的本体文件中解析并缓存注册表。
|
||||
|
||||
Returns:
|
||||
GeneralOntologyTypeRegistry 实例,如果加载失败则返回 None
|
||||
"""
|
||||
global _general_registry_cache
|
||||
|
||||
if _general_registry_cache is not None:
|
||||
return _general_registry_cache
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
|
||||
if not settings.ENABLE_GENERAL_ONTOLOGY_TYPES:
|
||||
logger.info("通用本体类型功能已禁用")
|
||||
return None
|
||||
|
||||
# 解析本体文件路径
|
||||
file_names = [f.strip() for f in settings.GENERAL_ONTOLOGY_FILES.split(",") if f.strip()]
|
||||
if not file_names:
|
||||
logger.warning("未配置通用本体文件")
|
||||
return None
|
||||
|
||||
# 构建完整路径(相对于项目根目录)
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||
file_paths = []
|
||||
for name in file_names:
|
||||
full_path = os.path.join(base_dir, name)
|
||||
if os.path.exists(full_path):
|
||||
file_paths.append(full_path)
|
||||
else:
|
||||
logger.warning(f"本体文件不存在: {full_path}")
|
||||
|
||||
if not file_paths:
|
||||
logger.warning("没有找到可用的通用本体文件")
|
||||
return None
|
||||
|
||||
# 解析本体文件
|
||||
from app.core.memory.utils.ontology.ontology_parser import MultiOntologyParser
|
||||
|
||||
parser = MultiOntologyParser(file_paths)
|
||||
_general_registry_cache = parser.parse_all()
|
||||
logger.info(f"通用本体注册表加载完成: {len(_general_registry_cache.types)} 个类型")
|
||||
|
||||
return _general_registry_cache
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"加载通用本体注册表失败: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def get_ontology_type_merger():
|
||||
"""获取类型合并服务实例(单例,懒加载)
|
||||
|
||||
Returns:
|
||||
OntologyTypeMerger 实例,如果通用本体未启用则返回 None
|
||||
"""
|
||||
global _ontology_type_merger_cache
|
||||
|
||||
if _ontology_type_merger_cache is not None:
|
||||
return _ontology_type_merger_cache
|
||||
|
||||
try:
|
||||
registry = get_general_ontology_registry()
|
||||
if registry is None:
|
||||
return None
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.memory.ontology_services.ontology_type_merger import OntologyTypeMerger
|
||||
|
||||
# 从配置读取核心类型
|
||||
core_types_str = settings.CORE_GENERAL_TYPES
|
||||
core_types = [t.strip() for t in core_types_str.split(",") if t.strip()] if core_types_str else None
|
||||
|
||||
_ontology_type_merger_cache = OntologyTypeMerger(
|
||||
general_registry=registry,
|
||||
max_types_in_prompt=settings.MAX_ONTOLOGY_TYPES_IN_PROMPT,
|
||||
core_types=core_types,
|
||||
)
|
||||
logger.info("OntologyTypeMerger 实例创建完成")
|
||||
|
||||
return _ontology_type_merger_cache
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建 OntologyTypeMerger 失败: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def reload_ontology_registry():
|
||||
"""重新加载本体注册表(清除缓存后重新加载)
|
||||
|
||||
用于实验模式下动态更新本体配置。
|
||||
"""
|
||||
clear_ontology_cache()
|
||||
registry = get_general_ontology_registry()
|
||||
if registry:
|
||||
get_ontology_type_merger()
|
||||
logger.info("本体注册表已重新加载")
|
||||
return registry
|
||||
|
||||
|
||||
def clear_ontology_cache():
|
||||
"""清除本体缓存"""
|
||||
global _general_registry_cache, _ontology_type_merger_cache
|
||||
_general_registry_cache = None
|
||||
_ontology_type_merger_cache = None
|
||||
logger.info("本体缓存已清除")
|
||||
|
||||
|
||||
def load_ontology_types_with_fallback(
|
||||
scene_id: Optional[UUID],
|
||||
workspace_id: UUID,
|
||||
|
||||
@@ -645,9 +645,25 @@ class ExtractionOrchestrator:
|
||||
logger.info(f"总陈述句: {total_statements}, 用户陈述句: {filtered_statements}, 开始全局并行提取情绪")
|
||||
|
||||
# 初始化情绪提取服务
|
||||
# 如果 emotion_model_id 为空,回退到工作空间默认 LLM
|
||||
from app.services.emotion_extraction_service import EmotionExtractionService
|
||||
|
||||
emotion_model_id = memory_config.emotion_model_id
|
||||
if not emotion_model_id and memory_config.workspace_id:
|
||||
from app.repositories.workspace_repository import get_workspace_models_configs
|
||||
from app.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
workspace_models = get_workspace_models_configs(db, memory_config.workspace_id)
|
||||
if workspace_models and workspace_models.get("llm"):
|
||||
emotion_model_id = workspace_models["llm"]
|
||||
logger.info(f"emotion_model_id 为空,使用工作空间默认 LLM: {emotion_model_id}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
emotion_service = EmotionExtractionService(
|
||||
llm_id=memory_config.emotion_model_id if memory_config.emotion_model_id else None
|
||||
llm_id=emotion_model_id if emotion_model_id else None
|
||||
)
|
||||
|
||||
# 全局并行处理所有陈述句
|
||||
|
||||
@@ -349,19 +349,39 @@ async def render_emotion_suggestions_prompt(
|
||||
import json
|
||||
|
||||
# 预处理 emotion_distribution 为 JSON 字符串
|
||||
# 如果是中文,将 emotion_distribution 的 key 翻译为中文
|
||||
emotion_distribution = health_data.get('emotion_distribution', {})
|
||||
if language == "zh":
|
||||
emotion_type_zh = {
|
||||
'joy': '喜悦', 'sadness': '悲伤', 'anger': '愤怒',
|
||||
'fear': '恐惧', 'surprise': '惊讶', 'neutral': '中性'
|
||||
}
|
||||
emotion_distribution = {
|
||||
emotion_type_zh.get(k, k): v for k, v in emotion_distribution.items()
|
||||
}
|
||||
emotion_distribution_json = json.dumps(
|
||||
health_data.get('emotion_distribution', {}),
|
||||
emotion_distribution,
|
||||
ensure_ascii=False,
|
||||
indent=2
|
||||
)
|
||||
|
||||
# 翻译 dominant_negative_emotion
|
||||
dominant_negative_translated = None
|
||||
dominant_neg = patterns.get('dominant_negative_emotion')
|
||||
if dominant_neg and language == "zh":
|
||||
emotion_type_zh_map = {
|
||||
'sadness': '悲伤', 'anger': '愤怒', 'fear': '恐惧'
|
||||
}
|
||||
dominant_negative_translated = emotion_type_zh_map.get(dominant_neg, dominant_neg)
|
||||
|
||||
template = prompt_env.get_template("generate_emotion_suggestions.jinja2")
|
||||
rendered_prompt = template.render(
|
||||
health_data=health_data,
|
||||
patterns=patterns,
|
||||
user_profile=user_profile,
|
||||
emotion_distribution_json=emotion_distribution_json,
|
||||
language=language
|
||||
language=language,
|
||||
dominant_negative_translated=dominant_negative_translated
|
||||
)
|
||||
|
||||
# 记录渲染结果到提示日志
|
||||
|
||||
@@ -18,18 +18,21 @@ Extract entities and knowledge triplets from the given statement.
|
||||
{% if ontology_types %}
|
||||
===Ontology Type Guidance===
|
||||
|
||||
**CRITICAL: Use predefined ontology types for entity classification with the following priority:**
|
||||
**CRITICAL RULE: You MUST ONLY use the predefined ontology type names listed below for the entity "type" field. Do NOT use any other type names, even if they seem reasonable.**
|
||||
|
||||
**If no predefined type fits an entity, use the CLOSEST matching predefined type. NEVER invent new type names.**
|
||||
|
||||
**Type Priority (from highest to lowest):**
|
||||
1. **[场景类型] Scene Types** - Domain-specific types, use these first if applicable
|
||||
1. **[场景类型] Scene Types** - Domain-specific types, ALWAYS prefer these first
|
||||
2. **[通用类型] General Types** - Common types from standard ontologies (DBpedia)
|
||||
3. **[通用父类] Parent Types** - Provide type hierarchy context
|
||||
|
||||
**Type Matching Rules:**
|
||||
- Entity type MUST exactly match one of the predefined type names
|
||||
- Do NOT modify, translate, or use variations of type names
|
||||
- Prefer scene types over general types when both could apply
|
||||
- If uncertain between types, check the type description for guidance
|
||||
- Entity type MUST exactly match one of the predefined type names below
|
||||
- Do NOT use types like "Equipment", "Component", "Concept", "Action", "Condition", "Data", "Duration" unless they appear in the predefined list
|
||||
- Do NOT modify, translate, abbreviate, or create variations of type names
|
||||
- Prefer scene types (marked [场景类型]) over general types when both could apply
|
||||
- If uncertain, check the type description to find the best match
|
||||
|
||||
**Predefined Ontology Types:**
|
||||
{{ ontology_types }}
|
||||
@@ -42,7 +45,7 @@ The following shows type inheritance relationships (Child → Parent → Grandpa
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
**Available Type Names (use EXACTLY as shown):**
|
||||
**ALLOWED Type Names (use EXACTLY one of these, no exceptions):**
|
||||
{{ ontology_type_names | join(', ') }}
|
||||
|
||||
{% endif %}
|
||||
@@ -207,6 +210,10 @@ Output:
|
||||
{% endif %}
|
||||
===End of Examples===
|
||||
|
||||
{% if ontology_types %}
|
||||
**⚠️ REMINDER: The examples above use generic type names for illustration only. You MUST use ONLY the predefined ontology type names from the "ALLOWED Type Names" list above. For example, use "PredictiveMaintenance" instead of "Concept", use "ProductionLine" instead of "Equipment", etc. Map each entity to the closest matching predefined type.**
|
||||
{% endif %}
|
||||
|
||||
===Output Format===
|
||||
|
||||
**JSON Requirements:**
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
{% if language == "en" %}
|
||||
You are a professional mental health consultant. Based on the following user's emotional health data and personal information, generate 3-5 personalized emotional improvement suggestions.
|
||||
|
||||
## Core Principle (Highest Priority)
|
||||
|
||||
**You must strictly base your suggestions on the emotion distribution data provided below. As long as any emotion type has a count ≥ 1, that emotion EXISTS and you must acknowledge and address it in your suggestions. You must NEVER claim an emotion is "zero" or "absent" when its count is ≥ 1.**
|
||||
|
||||
Specific rules:
|
||||
1. Carefully check the count for each emotion type in "Emotion Distribution" — count ≥ 1 means the emotion exists
|
||||
2. Even if an emotion appeared only once, you must mention it in health_summary or suggestions and provide targeted advice
|
||||
3. Never state that an emotion is "zero" or "non-existent" unless its count in the distribution data is truly 0
|
||||
4. If positive emotions (e.g., Joy) exist, health_summary must affirm this positive signal
|
||||
5. If negative emotions (e.g., Sadness, Anger, Fear) exist even once, you must provide targeted improvement suggestions
|
||||
6. A high proportion of neutral emotions does NOT mean other emotions are absent — address all non-zero emotions
|
||||
|
||||
## User Emotional Health Data
|
||||
|
||||
Health Score: {{ health_data.health_score }}/100
|
||||
Health Level: {{ health_data.level }}
|
||||
Total Emotion Records: {{ health_data.dimensions.positivity_rate.positive_count + health_data.dimensions.positivity_rate.negative_count + health_data.dimensions.positivity_rate.neutral_count }}
|
||||
|
||||
Dimension Analysis:
|
||||
- Positivity Rate: {{ health_data.dimensions.positivity_rate.score }}/100
|
||||
@@ -18,7 +31,7 @@ Dimension Analysis:
|
||||
- Resilience: {{ health_data.dimensions.resilience.score }}/100
|
||||
- Recovery Rate: {{ health_data.dimensions.resilience.recovery_rate }}
|
||||
|
||||
Emotion Distribution:
|
||||
Emotion Distribution (check each item — every emotion with count ≥ 1 must be reflected in suggestions):
|
||||
{{ emotion_distribution_json }}
|
||||
|
||||
## Emotion Pattern Analysis
|
||||
@@ -41,6 +54,7 @@ Please generate 3-5 personalized suggestions, each containing:
|
||||
5. actionable_steps: 3 specific executable steps
|
||||
|
||||
Also provide a health_summary (no more than 50 words) summarizing the user's overall emotional state.
|
||||
**The health_summary must truthfully reflect ALL non-zero emotions from the distribution data. Do not omit any emotion type that has appeared.**
|
||||
|
||||
Please return in JSON format as follows:
|
||||
{
|
||||
@@ -57,6 +71,7 @@ Please return in JSON format as follows:
|
||||
}
|
||||
|
||||
Notes:
|
||||
- CRITICAL: Any emotion with count ≥ 1 in the distribution MUST be acknowledged and addressed — never ignore or claim it is zero
|
||||
- Suggestions should be specific and actionable, avoid vague advice
|
||||
- Provide personalized suggestions based on user's interests and hobbies
|
||||
- Provide targeted suggestions for main issues (such as dominant negative emotions)
|
||||
@@ -66,10 +81,23 @@ Notes:
|
||||
{% else %}
|
||||
你是一位专业的心理健康顾问。请根据以下用户的情绪健康数据和个人信息,生成3-5条个性化的情绪改善建议。
|
||||
|
||||
## 核心原则(最高优先级)
|
||||
|
||||
**你必须严格基于下方提供的情绪分布数据来生成建议。只要某种情绪的出现次数 ≥ 1,就代表该情绪确实存在,你必须在建议中承认并回应这一情绪,绝对不能说"该情绪为零"或"没有该情绪"。**
|
||||
|
||||
具体规则:
|
||||
1. 仔细查看"情绪分布"中每种情绪的出现次数,次数 ≥ 1 即表示该情绪存在
|
||||
2. 即使某种情绪只出现了1次,也必须在 health_summary 或建议中提及并给出针对性建议
|
||||
3. 严禁在输出中声称某种情绪"为零"或"不存在",除非该情绪在分布数据中确实为0次
|
||||
4. 如果正面情绪(如喜悦)存在,health_summary 中必须肯定这一积极信号
|
||||
5. 如果负面情绪(如悲伤、愤怒、恐惧)存在,即使只有1次,也必须给出针对性的改善建议
|
||||
6. 中性情绪占比高不代表没有其他情绪,必须同时关注所有非零情绪
|
||||
|
||||
## 用户情绪健康数据
|
||||
|
||||
健康分数:{{ health_data.health_score }}/100
|
||||
健康等级:{{ health_data.level }}
|
||||
情绪记录总数:{{ health_data.dimensions.positivity_rate.positive_count + health_data.dimensions.positivity_rate.negative_count + health_data.dimensions.positivity_rate.neutral_count }}条
|
||||
|
||||
维度分析:
|
||||
- 积极率:{{ health_data.dimensions.positivity_rate.score }}/100
|
||||
@@ -83,12 +111,12 @@ Notes:
|
||||
- 恢复力:{{ health_data.dimensions.resilience.score }}/100
|
||||
- 恢复率:{{ health_data.dimensions.resilience.recovery_rate }}
|
||||
|
||||
情绪分布:
|
||||
情绪分布(请逐项检查,次数≥1的情绪都必须在建议中体现):
|
||||
{{ emotion_distribution_json }}
|
||||
|
||||
## 情绪模式分析
|
||||
|
||||
主要负面情绪:{{ patterns.dominant_negative_emotion|default('无') }}
|
||||
主要负面情绪:{{ dominant_negative_translated|default(patterns.dominant_negative_emotion)|default('无') }}
|
||||
情绪波动性:{{ patterns.emotion_volatility|default('未知') }}
|
||||
高强度情绪次数:{{ patterns.high_intensity_emotions|default([])|length }}
|
||||
|
||||
@@ -106,6 +134,7 @@ Notes:
|
||||
5. actionable_steps: 3个可执行的具体步骤
|
||||
|
||||
同时提供一个health_summary(不超过50字),概括用户的整体情绪状态。
|
||||
**health_summary 必须如实反映情绪分布中所有非零情绪的存在,不得遗漏任何已出现的情绪类型。**
|
||||
|
||||
请以JSON格式返回,格式如下:
|
||||
{
|
||||
@@ -122,9 +151,11 @@ Notes:
|
||||
}
|
||||
|
||||
注意事项:
|
||||
- 所有输出内容必须完全使用中文,严禁出现任何英文单词或短语(包括情绪类型名称如fear、sadness、anger等,必须使用对应的中文:恐惧、悲伤、愤怒等)
|
||||
- 再次强调:情绪分布中出现次数≥1的情绪必须在建议中被提及和回应,绝不能忽略或声称为零
|
||||
- 建议要具体、可执行,避免空泛
|
||||
- 结合用户的兴趣爱好提供个性化建议
|
||||
- 针对主要问题(如主要负面情绪)提供针对性建议
|
||||
- 优先级要合理分配(至少1个high,1-2个medium,其余low)
|
||||
- 优先级要合理分配(至少1个高,1-2个中,其余低)
|
||||
- 每个建议的3个步骤要循序渐进、易于实施
|
||||
{% endif %}
|
||||
|
||||
@@ -4,7 +4,6 @@ Validators package for various validation utilities.
|
||||
from app.core.validators.file_validator import FileValidator, ValidationResult
|
||||
from app.core.validators.memory_config_validators import (
|
||||
validate_and_resolve_model_id,
|
||||
validate_embedding_model,
|
||||
validate_llm_model,
|
||||
validate_model_exists_and_active,
|
||||
)
|
||||
@@ -16,6 +15,5 @@ __all__ = [
|
||||
# Memory config validators
|
||||
"validate_model_exists_and_active",
|
||||
"validate_and_resolve_model_id",
|
||||
"validate_embedding_model",
|
||||
"validate_llm_model",
|
||||
]
|
||||
|
||||
@@ -6,7 +6,6 @@ This module provides validation functions for memory configuration models.
|
||||
Functions:
|
||||
validate_model_exists_and_active: Validate model exists and is active
|
||||
validate_and_resolve_model_id: Validate and resolve model ID with DB lookup
|
||||
validate_embedding_model: Validate embedding model availability
|
||||
validate_llm_model: Validate LLM model availability
|
||||
"""
|
||||
|
||||
@@ -203,58 +202,6 @@ def validate_and_resolve_model_id(
|
||||
return model_uuid, model_name
|
||||
|
||||
|
||||
def validate_embedding_model(
|
||||
config_id: UUID,
|
||||
embedding_id: Union[str, UUID, None],
|
||||
db: Session,
|
||||
tenant_id: Optional[UUID] = None,
|
||||
workspace_id: Optional[UUID] = None
|
||||
) -> tuple[UUID, str]:
|
||||
"""Validate that embedding model is available and return its UUID and name.
|
||||
|
||||
Returns:
|
||||
Tuple of (embedding_uuid, embedding_name)
|
||||
|
||||
Raises:
|
||||
InvalidConfigError: If embedding_id is not provided or invalid
|
||||
ModelNotFoundError: If embedding model does not exist
|
||||
ModelInactiveError: If embedding model is inactive
|
||||
"""
|
||||
if embedding_id is None or (isinstance(embedding_id, str) and not embedding_id.strip()):
|
||||
raise InvalidConfigError(
|
||||
f"Configuration {config_id} has no embedding model configured",
|
||||
field_name="embedding_model_id",
|
||||
invalid_value=embedding_id,
|
||||
config_id=config_id,
|
||||
workspace_id=workspace_id
|
||||
)
|
||||
|
||||
embedding_uuid, embedding_name = validate_and_resolve_model_id(
|
||||
embedding_id, "embedding", db, tenant_id, required=True,
|
||||
config_id=config_id, workspace_id=workspace_id
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Embedding model validated",
|
||||
extra={
|
||||
"embedding_uuid": str(embedding_uuid),
|
||||
"embedding_name": embedding_name,
|
||||
"config_id": config_id
|
||||
}
|
||||
)
|
||||
|
||||
if embedding_uuid is None:
|
||||
raise InvalidConfigError(
|
||||
f"Configuration {config_id} has no embedding model configured",
|
||||
field_name="embedding_model_id",
|
||||
invalid_value=embedding_id,
|
||||
config_id=config_id,
|
||||
workspace_id=workspace_id
|
||||
)
|
||||
|
||||
return embedding_uuid, embedding_name
|
||||
|
||||
|
||||
def validate_llm_model(
|
||||
config_id: UUID,
|
||||
llm_id: Union[str, UUID, None],
|
||||
|
||||
@@ -337,6 +337,7 @@ class WorkflowExecutor:
|
||||
logger.warning(f"[STREAM] Failed to evaluate segment: {current_segment.literal}")
|
||||
|
||||
if final_chunk:
|
||||
logger.info(f"[STREAM] StreamOutput Node:{self.activate_end}, chunk:{final_chunk}")
|
||||
yield {
|
||||
"event": "message",
|
||||
"data": {
|
||||
@@ -701,7 +702,8 @@ class WorkflowExecutor:
|
||||
end_time = datetime.datetime.now()
|
||||
elapsed_time = (end_time - start_time).total_seconds()
|
||||
|
||||
logger.info(f"Workflow execution completed: execution_id={self.execution_id}, elapsed_time={elapsed_time:.2f}s")
|
||||
logger.info(
|
||||
f"Workflow execution completed: execution_id={self.execution_id}, elapsed_time={elapsed_time:.2f}s")
|
||||
|
||||
return self._build_final_output(result, elapsed_time, full_content)
|
||||
|
||||
@@ -763,7 +765,6 @@ class WorkflowExecutor:
|
||||
await self.__init_variable_pool(input_data)
|
||||
initial_state = self._prepare_initial_state(input_data)
|
||||
|
||||
|
||||
try:
|
||||
full_content = ''
|
||||
self._update_scope_activate("sys")
|
||||
@@ -789,7 +790,7 @@ class WorkflowExecutor:
|
||||
event_type = data.get("type", "node_chunk") # "message" or "node_chunk"
|
||||
if event_type == "node_chunk":
|
||||
async for msg_event in self._handle_node_chunk_event(data):
|
||||
full_content += data.get("chunk")
|
||||
full_content += msg_event["data"]["chunk"]
|
||||
yield msg_event
|
||||
|
||||
elif event_type == "node_error":
|
||||
|
||||
@@ -100,7 +100,7 @@ class StreamOutputConfig(BaseModel):
|
||||
)
|
||||
)
|
||||
|
||||
control_nodes: dict[str, str] = Field(
|
||||
control_nodes: dict[str, list[str]] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Control branch conditions for this End node output.\n"
|
||||
@@ -161,7 +161,7 @@ class StreamOutputConfig(BaseModel):
|
||||
if scope in self.control_nodes.keys():
|
||||
if status is None:
|
||||
raise RuntimeError("[Stream Output] Control node activation status not provided")
|
||||
if status == self.control_nodes[scope]:
|
||||
if status in self.control_nodes[scope]:
|
||||
self.activate = True
|
||||
|
||||
# Case 2: activate variable segments related to this node
|
||||
@@ -229,6 +229,13 @@ class GraphBuilder:
|
||||
except KeyError:
|
||||
raise RuntimeError(f"Node not found: Id={node_id}")
|
||||
|
||||
@staticmethod
|
||||
def _merge_control_nodes(control_nodes: list[tuple[str, str]]) -> dict[str, list]:
|
||||
result = defaultdict(list)
|
||||
for node in control_nodes:
|
||||
result[node[0]].append(node[1])
|
||||
return result
|
||||
|
||||
def _find_upstream_branch_node(self, target_node: str) -> tuple[bool, tuple[tuple[str, str]]]:
|
||||
"""
|
||||
Recursively find all upstream branch (control) nodes that influence the execution
|
||||
@@ -372,7 +379,7 @@ class GraphBuilder:
|
||||
activate=not has_branch,
|
||||
|
||||
# Branch nodes that control activation of this End node
|
||||
control_nodes=dict(control_nodes),
|
||||
control_nodes=self._merge_control_nodes(control_nodes),
|
||||
|
||||
# Convert output segments into OutputContent objects
|
||||
outputs=list(
|
||||
|
||||
@@ -68,12 +68,13 @@ class LLMNode(BaseNode):
|
||||
- ai/assistant: AI 消息(AIMessage)
|
||||
"""
|
||||
|
||||
def _output_types(self) -> dict[str, VariableType]:
|
||||
return {"output": VariableType.STRING}
|
||||
|
||||
def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]):
|
||||
super().__init__(node_config, workflow_config)
|
||||
self.typed_config: LLMNodeConfig | None = None
|
||||
self.messages = []
|
||||
|
||||
def _output_types(self) -> dict[str, VariableType]:
|
||||
return {"output": VariableType.STRING}
|
||||
|
||||
def _render_context(self, message: str, variable_pool: VariablePool):
|
||||
context = f"<context>{self._render_template(self.typed_config.context, variable_pool)}</context>"
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel, field_validator, Field
|
||||
|
||||
from app.core.workflow.nodes.base_config import BaseNodeConfig
|
||||
|
||||
|
||||
class MessageConfig(BaseModel):
|
||||
"""消息配置"""
|
||||
|
||||
role: str = Field(
|
||||
default='user',
|
||||
description="消息角色:system, user, assistant"
|
||||
)
|
||||
|
||||
content: str = Field(
|
||||
default="",
|
||||
description="消息内容,支持模板变量,如:{{ sys.message }}"
|
||||
)
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role(cls, v: str) -> str:
|
||||
"""验证角色"""
|
||||
allowed_roles = ["system", "user", "human", "assistant", "ai"]
|
||||
if v.lower() not in allowed_roles:
|
||||
raise ValueError(f"角色必须是以下之一: {', '.join(allowed_roles)}")
|
||||
return v.lower()
|
||||
|
||||
|
||||
class MemoryReadNodeConfig(BaseNodeConfig):
|
||||
message: str = Field(
|
||||
...
|
||||
@@ -22,7 +45,11 @@ class MemoryReadNodeConfig(BaseNodeConfig):
|
||||
|
||||
class MemoryWriteNodeConfig(BaseNodeConfig):
|
||||
message: str = Field(
|
||||
...
|
||||
default=""
|
||||
)
|
||||
|
||||
messages: list[MessageConfig] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
config_id: UUID | int = Field(
|
||||
|
||||
@@ -55,10 +55,22 @@ class MemoryWriteNode(BaseNode):
|
||||
|
||||
if not end_user_id:
|
||||
raise RuntimeError("End user id is required")
|
||||
messages = []
|
||||
if self.typed_config.message:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": self._render_template(self.typed_config.message, variable_pool)
|
||||
})
|
||||
|
||||
for message in self.typed_config.messages:
|
||||
messages.append({
|
||||
"role": message.role,
|
||||
"content": self._render_template(message.content, variable_pool)
|
||||
})
|
||||
|
||||
write_message_task.delay(
|
||||
end_user_id,
|
||||
self._render_template(self.typed_config.message, variable_pool),
|
||||
messages,
|
||||
str(self.typed_config.config_id),
|
||||
"neo4j",
|
||||
""
|
||||
|
||||
@@ -32,8 +32,8 @@ class ParameterExtractorNode(BaseNode):
|
||||
usage = self.response_metadata.get('token_usage')
|
||||
if usage:
|
||||
return {
|
||||
"prompt_tokens": usage.get('prompt_tokens', 0),
|
||||
"completion_tokens": usage.get('completion_tokens', 0),
|
||||
"prompt_tokens": usage.get('input_tokens', 0),
|
||||
"completion_tokens": usage.get('output_tokens', 0),
|
||||
"total_tokens": usage.get('total_tokens', 0)
|
||||
}
|
||||
return None
|
||||
|
||||
@@ -32,8 +32,8 @@ class QuestionClassifierNode(BaseNode):
|
||||
usage = self.response_metadata.get('token_usage')
|
||||
if usage:
|
||||
return {
|
||||
"prompt_tokens": usage.get('prompt_tokens', 0),
|
||||
"completion_tokens": usage.get('completion_tokens', 0),
|
||||
"prompt_tokens": usage.get('input_tokens', 0),
|
||||
"completion_tokens": usage.get('output_tokens', 0),
|
||||
"total_tokens": usage.get('total_tokens', 0)
|
||||
}
|
||||
return None
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
验证工作流配置的有效性,确保配置符合规范。
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import Any, Union, TYPE_CHECKING
|
||||
|
||||
@@ -114,6 +115,7 @@ class WorkflowValidator:
|
||||
>>> is_valid
|
||||
True
|
||||
"""
|
||||
workflow_config = copy.deepcopy(workflow_config)
|
||||
errors = []
|
||||
|
||||
graphs = cls.get_subgraph(workflow_config)
|
||||
|
||||
@@ -39,18 +39,17 @@ class VariableType(StrEnum):
|
||||
Raises:
|
||||
TypeError: If the type of the input value is not supported.
|
||||
"""
|
||||
var_type = type(var)
|
||||
if isinstance(var_type, str):
|
||||
if isinstance(var, str):
|
||||
return cls.STRING
|
||||
elif isinstance(var_type, (int, float)):
|
||||
elif isinstance(var, (int, float)):
|
||||
return cls.NUMBER
|
||||
elif isinstance(var_type, bool):
|
||||
elif isinstance(var, bool):
|
||||
return cls.BOOLEAN
|
||||
elif isinstance(var_type, FileObject) or (isinstance(var, dict) and var.get('__file')):
|
||||
elif isinstance(var, FileObject) or (isinstance(var, dict) and var.get('__file')):
|
||||
return cls.FILE
|
||||
elif isinstance(var_type, dict):
|
||||
elif isinstance(var, dict):
|
||||
return cls.OBJECT
|
||||
elif isinstance(var_type, list):
|
||||
elif isinstance(var, list):
|
||||
if len(var) == 0:
|
||||
return cls.ARRAY_STRING
|
||||
else:
|
||||
@@ -67,7 +66,7 @@ class VariableType(StrEnum):
|
||||
return cls.NESTED_ARRAY
|
||||
else:
|
||||
raise TypeError(f"Unsupported array child type - {child_type}")
|
||||
raise TypeError(f"Unsupported type - {var_type}")
|
||||
raise TypeError(f"Unsupported type - {type(var)}")
|
||||
|
||||
|
||||
def DEFAULT_VALUE(var_type: VariableType) -> Any:
|
||||
|
||||
@@ -119,7 +119,7 @@ class VariablePool:
|
||||
Storage for all variables managed by the pool.
|
||||
"""
|
||||
self.locks = defaultdict(Lock)
|
||||
self.variables: dict[str, dict[str, VariableStruct[Any]]] = {}
|
||||
self.variables: dict[str, dict[str, VariableStruct[Any]]] = {"sys": {}, "conv": {}}
|
||||
|
||||
@staticmethod
|
||||
def transform_selector(selector):
|
||||
|
||||
@@ -9,7 +9,7 @@ api\scripts\query_ontology_matched_entities.py
|
||||
|
||||
用法: python scripts/query_ontology_matched_entities.py <end_user_id> [config_id]
|
||||
示例: python scripts/query_ontology_matched_entities.py 075660cf-08e6-40a6-a76e-308b6f52fbf1
|
||||
python scripts/query_ontology_matched_entities.py 075660cf-08e6-40a6-a76e-308b6f52fbf1 fd547bb9-7b9e-47ea-ae53-242d208a31a2
|
||||
python scripts/query_ontology_matched_entities.py 075660cf-08e6-40a6-a76e-308b6f52fbf1 fd547bb9-7b9e-47ea-ae53-242d208a31a2
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -59,7 +59,7 @@ async def get_entities_by_end_user_id(connector: Neo4jConnector, end_user_id: st
|
||||
def get_ontology_types_from_scene(db, scene_id: UUID) -> Set[str]:
|
||||
"""获取场景下所有本体类型名称"""
|
||||
class_repo = OntologyClassRepository(db)
|
||||
ontology_classes = class_repo.get_by_scene(scene_id)
|
||||
ontology_classes = class_repo.get_classes_by_scene(scene_id)
|
||||
return {oc.class_name for oc in ontology_classes}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ def get_all_ontology_types(db) -> Dict[str, Set[str]]:
|
||||
|
||||
for scene in scenes:
|
||||
class_repo = OntologyClassRepository(db)
|
||||
ontology_classes = class_repo.get_by_scene(scene.scene_id)
|
||||
ontology_classes = class_repo.get_classes_by_scene(scene.scene_id)
|
||||
for oc in ontology_classes:
|
||||
if oc.class_name not in all_types:
|
||||
all_types[oc.class_name] = set()
|
||||
@@ -169,10 +169,10 @@ async def query_ontology_matched_entities(end_user_id: str, config_id: Optional[
|
||||
|
||||
print(f" 找到 {len(entities)} 个实体")
|
||||
|
||||
# 4. 分类实体(场景类型、通用类型、未匹配)
|
||||
scene_matched_entities = []
|
||||
general_matched_entities = []
|
||||
both_matched_entities = [] # 同时匹配场景和通用类型
|
||||
# 4. 互斥分类实体:场景类型优先 > 通用类型 > 未匹配
|
||||
# 确保: 场景实体数 + 通用实体数 + 未匹配数 = 总实体数
|
||||
scene_matched_entities = [] # 匹配场景类型(含同时匹配两者的)
|
||||
general_matched_entities = [] # 仅匹配通用类型(不含已归入场景的)
|
||||
unmatched_entities = []
|
||||
|
||||
scene_type_distribution = defaultdict(list)
|
||||
@@ -183,11 +183,8 @@ async def query_ontology_matched_entities(end_user_id: str, config_id: Optional[
|
||||
in_scene = entity_type in scene_ontology_types
|
||||
in_general = entity_type in general_ontology_types
|
||||
|
||||
if in_scene and in_general:
|
||||
both_matched_entities.append(entity)
|
||||
scene_type_distribution[entity_type].append(entity)
|
||||
general_type_distribution[entity_type].append(entity)
|
||||
elif in_scene:
|
||||
if in_scene:
|
||||
# 场景类型优先,同时匹配两者的也归入场景
|
||||
scene_matched_entities.append(entity)
|
||||
scene_type_distribution[entity_type].append(entity)
|
||||
elif in_general:
|
||||
@@ -197,9 +194,8 @@ async def query_ontology_matched_entities(end_user_id: str, config_id: Optional[
|
||||
unmatched_entities.append(entity)
|
||||
|
||||
# 5. 输出匹配场景类型的实体
|
||||
total_scene_matched = len(scene_matched_entities) + len(both_matched_entities)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"✅ 匹配场景本体类型的实体 (共 {total_scene_matched} 个)")
|
||||
print(f"✅ 匹配场景本体类型的实体 (共 {len(scene_matched_entities)} 个)")
|
||||
print(f"{'='*70}")
|
||||
|
||||
if scene_type_distribution:
|
||||
@@ -219,9 +215,8 @@ async def query_ontology_matched_entities(end_user_id: str, config_id: Optional[
|
||||
print(f"\n (无匹配场景类型的实体)")
|
||||
|
||||
# 6. 输出匹配通用类型的实体
|
||||
total_general_matched = len(general_matched_entities) + len(both_matched_entities)
|
||||
print(f"\n{'='*70}")
|
||||
print(f"✅ 匹配通用本体类型的实体 (共 {total_general_matched} 个)")
|
||||
print(f"✅ 匹配通用本体类型的实体 (共 {len(general_matched_entities)} 个)")
|
||||
print(f"{'='*70}")
|
||||
|
||||
if general_type_distribution:
|
||||
@@ -265,7 +260,6 @@ async def query_ontology_matched_entities(end_user_id: str, config_id: Optional[
|
||||
|
||||
# 8. 统计摘要
|
||||
total_entities = len(entities)
|
||||
any_matched = total_entities - len(unmatched_entities)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📊 统计摘要")
|
||||
@@ -276,35 +270,35 @@ async def query_ontology_matched_entities(end_user_id: str, config_id: Optional[
|
||||
print(f" 场景本体类型数: {len(scene_ontology_types)}")
|
||||
print(f" 通用本体类型数: {len(general_ontology_types)}")
|
||||
|
||||
print(f"\n 匹配率统计:")
|
||||
print(f"\n 互斥分类统计 (三者之和 = 总实体数):")
|
||||
print(f" {'-'*50}")
|
||||
scene_rate = total_scene_matched / total_entities * 100 if total_entities > 0 else 0
|
||||
general_rate = total_general_matched / total_entities * 100 if total_entities > 0 else 0
|
||||
any_rate = any_matched / total_entities * 100 if total_entities > 0 else 0
|
||||
scene_rate = len(scene_matched_entities) / total_entities * 100 if total_entities > 0 else 0
|
||||
general_rate = len(general_matched_entities) / total_entities * 100 if total_entities > 0 else 0
|
||||
unmatched_rate = len(unmatched_entities) / total_entities * 100 if total_entities > 0 else 0
|
||||
|
||||
print(f" 匹配场景类型: {total_scene_matched} 个 ({scene_rate:.1f}%)")
|
||||
print(f" 匹配通用类型: {total_general_matched} 个 ({general_rate:.1f}%)")
|
||||
print(f" 同时匹配两者: {len(both_matched_entities)} 个 ({len(both_matched_entities)/total_entities*100:.1f}%)")
|
||||
print(f" 仅匹配场景类型: {len(scene_matched_entities)} 个 ({len(scene_matched_entities)/total_entities*100:.1f}%)")
|
||||
print(f" 仅匹配通用类型: {len(general_matched_entities)} 个 ({len(general_matched_entities)/total_entities*100:.1f}%)")
|
||||
print(f" 匹配任一类型: {any_matched} 个 ({any_rate:.1f}%)")
|
||||
print(f" 匹配场景类型: {len(scene_matched_entities)} 个 ({scene_rate:.1f}%)")
|
||||
print(f" 匹配通用类型: {len(general_matched_entities)} 个 ({general_rate:.1f}%)")
|
||||
print(f" 未匹配任何类型: {len(unmatched_entities)} 个 ({unmatched_rate:.1f}%)")
|
||||
print(f" ─────────────────────────────")
|
||||
print(f" 合计: {len(scene_matched_entities)} + {len(general_matched_entities)} + {len(unmatched_entities)} = {len(scene_matched_entities) + len(general_matched_entities) + len(unmatched_entities)}")
|
||||
|
||||
# 9. 类型分布详情
|
||||
# 9. 场景类型分布详情(全部)
|
||||
if scene_type_distribution:
|
||||
print(f"\n 场景类型分布 (Top 10):")
|
||||
print(f"\n 场景类型分布 (全部 {len(scene_type_distribution)} 种):")
|
||||
print(f" {'-'*50}")
|
||||
sorted_scene_types = sorted(scene_type_distribution.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
for type_name, entities_list in sorted_scene_types[:10]:
|
||||
for type_name, entities_list in sorted_scene_types:
|
||||
print(f" - {type_name}: {len(entities_list)} 个")
|
||||
print(f" 场景类型实体总数: {len(scene_matched_entities)} 个")
|
||||
|
||||
# 10. 通用类型分布详情(全部)
|
||||
if general_type_distribution:
|
||||
print(f"\n 通用类型分布 (Top 10):")
|
||||
print(f"\n 通用类型分布 (全部 {len(general_type_distribution)} 种):")
|
||||
print(f" {'-'*50}")
|
||||
sorted_general_types = sorted(general_type_distribution.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
for type_name, entities_list in sorted_general_types[:10]:
|
||||
for type_name, entities_list in sorted_general_types:
|
||||
print(f" - {type_name}: {len(entities_list)} 个")
|
||||
print(f" 通用类型实体总数: {len(general_matched_entities)} 个")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 查询出错: {str(e)}")
|
||||
|
||||
@@ -715,3 +715,95 @@ class MemoryConfigRepository:
|
||||
db_logger.error(f"删除记忆配置失败: config_id={config_id} - {str(e)}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_workspace_default(db: Session, workspace_id: uuid.UUID) -> Optional[MemoryConfig]:
|
||||
"""获取工作空间的默认记忆配置
|
||||
|
||||
优先返回标记为默认的配置,如果没有则返回最早创建的活跃配置。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
workspace_id: 工作空间ID
|
||||
|
||||
Returns:
|
||||
Optional[MemoryConfig]: 默认配置对象,不存在则返回None
|
||||
"""
|
||||
db_logger.debug(f"查询工作空间默认配置: workspace_id={workspace_id}")
|
||||
|
||||
try:
|
||||
# 优先查找显式标记为默认的配置
|
||||
stmt = (
|
||||
select(MemoryConfig)
|
||||
.where(
|
||||
MemoryConfig.workspace_id == workspace_id,
|
||||
MemoryConfig.is_default.is_(True),
|
||||
MemoryConfig.state.is_(True),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
config = db.scalars(stmt).first()
|
||||
|
||||
if config:
|
||||
db_logger.debug(f"找到默认配置: config_id={config.config_id}")
|
||||
return config
|
||||
|
||||
# 回退:获取最早创建的活跃配置
|
||||
stmt = (
|
||||
select(MemoryConfig)
|
||||
.where(
|
||||
MemoryConfig.workspace_id == workspace_id,
|
||||
MemoryConfig.state.is_(True),
|
||||
)
|
||||
.order_by(MemoryConfig.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
config = db.scalars(stmt).first()
|
||||
|
||||
if config:
|
||||
db_logger.debug(f"使用最早创建的配置作为默认: config_id={config.config_id}")
|
||||
else:
|
||||
db_logger.warning(f"工作空间没有活跃的记忆配置: workspace_id={workspace_id}")
|
||||
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
db_logger.error(f"查询工作空间默认配置失败: workspace_id={workspace_id} - {str(e)}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_with_fallback(
|
||||
db: Session,
|
||||
config_id: Optional[uuid.UUID],
|
||||
workspace_id: uuid.UUID
|
||||
) -> Optional[MemoryConfig]:
|
||||
"""获取记忆配置,支持回退到工作空间默认配置
|
||||
|
||||
如果 config_id 为 None 或配置不存在,则回退到工作空间默认配置。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
config_id: 配置ID(可为None)
|
||||
workspace_id: 工作空间ID,用于回退查询
|
||||
|
||||
Returns:
|
||||
Optional[MemoryConfig]: 配置对象,如果都不存在则返回None
|
||||
"""
|
||||
db_logger.debug(f"查询配置(支持回退): config_id={config_id}, workspace_id={workspace_id}")
|
||||
|
||||
if not config_id:
|
||||
db_logger.debug("config_id 为空,使用工作空间默认配置")
|
||||
return MemoryConfigRepository.get_workspace_default(db, workspace_id)
|
||||
|
||||
config = db.get(MemoryConfig, config_id)
|
||||
|
||||
if config:
|
||||
return config
|
||||
|
||||
db_logger.warning(
|
||||
f"配置不存在,回退到工作空间默认配置: missing_config_id={config_id}, workspace_id={workspace_id}"
|
||||
)
|
||||
|
||||
return MemoryConfigRepository.get_workspace_default(db, workspace_id)
|
||||
|
||||
|
||||
@@ -73,11 +73,11 @@ class EmotionRepository:
|
||||
params["emotion_type"] = emotion_type
|
||||
|
||||
if start_date:
|
||||
where_clauses.append("s.created_at >= $start_date")
|
||||
where_clauses.append("s.created_at IS NOT NULL AND datetime(s.created_at) >= datetime($start_date)")
|
||||
params["start_date"] = start_date
|
||||
|
||||
if end_date:
|
||||
where_clauses.append("s.created_at <= $end_date")
|
||||
where_clauses.append("s.created_at IS NOT NULL AND datetime(s.created_at) <= datetime($end_date)")
|
||||
params["end_date"] = end_date
|
||||
|
||||
where_str = " AND ".join(where_clauses)
|
||||
@@ -211,17 +211,18 @@ class EmotionRepository:
|
||||
# 计算起始日期(使用字符串比较,避免时区问题)
|
||||
start_date = (datetime.now() - timedelta(days=days)).isoformat()
|
||||
|
||||
# 优化的 Cypher 查询:使用字符串比较避免时区问题
|
||||
# 使用 datetime() 函数进行时间比较,与其他查询保持一致
|
||||
query = """
|
||||
MATCH (s:Statement)
|
||||
WHERE s.end_user_id = $end_user_id
|
||||
AND s.emotion_type IS NOT NULL
|
||||
AND s.created_at >= $start_date
|
||||
AND s.created_at IS NOT NULL
|
||||
AND datetime(s.created_at) >= datetime($start_date)
|
||||
RETURN s.id as statement_id,
|
||||
s.emotion_type as emotion_type,
|
||||
s.emotion_intensity as emotion_intensity,
|
||||
s.created_at as created_at
|
||||
ORDER BY s.created_at ASC
|
||||
ORDER BY datetime(s.created_at) ASC
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
@@ -202,7 +202,7 @@ class OntologyClassRepository:
|
||||
)
|
||||
raise
|
||||
|
||||
def get_by_scene(self, scene_id: UUID) -> List[OntologyClass]:
|
||||
def get_classes_by_scene(self, scene_id: UUID) -> List[OntologyClass]:
|
||||
"""获取场景下的所有类型
|
||||
|
||||
按创建时间倒序排列。
|
||||
@@ -215,7 +215,7 @@ class OntologyClassRepository:
|
||||
|
||||
Examples:
|
||||
>>> repo = OntologyClassRepository(db)
|
||||
>>> classes = repo.get_by_scene(scene_id)
|
||||
>>> classes = repo.get_classes_by_scene(scene_id)
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Getting ontology classes by scene: {scene_id}")
|
||||
|
||||
@@ -25,6 +25,31 @@ type_mapping = {
|
||||
"Condition": "条件实体节点",
|
||||
"Numeric": "数值实体节点"
|
||||
}
|
||||
EPISODIC_TYPE_MAPPING = {
|
||||
"conversation": "对话",
|
||||
"project_work": "项目/工作",
|
||||
"learning": "学习",
|
||||
"decision": "决策",
|
||||
"important_event": "重要事件",
|
||||
}
|
||||
|
||||
|
||||
def translate_episodic_type(episodic_type: str, language: str = "zh") -> str:
|
||||
"""
|
||||
根据语言参数翻译情景类型
|
||||
|
||||
Args:
|
||||
episodic_type: 英文枚举值 (conversation, project_work, etc.)
|
||||
language: 语言类型 ("zh" 中文, "en" 英文)
|
||||
|
||||
Returns:
|
||||
翻译后的类型字符串
|
||||
"""
|
||||
if language == "en":
|
||||
return episodic_type
|
||||
return EPISODIC_TYPE_MAPPING.get(episodic_type, episodic_type)
|
||||
|
||||
|
||||
class EmotionType(ABC):
|
||||
JOY_TYPE = "joy"
|
||||
SURPRISE_TYPE = "surprise"
|
||||
@@ -51,7 +76,6 @@ class EpisodicMemoryOverviewRequest(BaseModel):
|
||||
"""情景记忆总览查询请求"""
|
||||
|
||||
end_user_id: str = Field(..., description="终端用户ID")
|
||||
language_type: Optional[str] = Field("zh", description="语言类型(zh/en)")
|
||||
time_range: str = Field(
|
||||
default="all",
|
||||
description="时间范围筛选,可选值:all, today, this_week, this_month"
|
||||
@@ -71,4 +95,3 @@ class EpisodicMemoryDetailsRequest(BaseModel):
|
||||
|
||||
end_user_id: str = Field(..., description="终端用户ID")
|
||||
summary_id: str = Field(..., description="情景记忆摘要ID")
|
||||
language_type: Optional[str] = Field("zh", description="语言类型(zh/en)")
|
||||
|
||||
@@ -1193,7 +1193,7 @@ class AppService:
|
||||
app_type: str,
|
||||
config: Dict[str, Any]
|
||||
) -> Tuple[Optional[uuid.UUID], bool]:
|
||||
"""从发布配置中提取 memory_config_id(根据应用类型分发)
|
||||
"""从发布配置中提取 memory_config_id(委托给 MemoryConfigService)
|
||||
|
||||
Args:
|
||||
app_type: 应用类型 (agent, workflow, multi_agent)
|
||||
@@ -1204,128 +1204,10 @@ class AppService:
|
||||
- memory_config_id: 提取的配置ID,如果不存在或为旧格式则返回 None
|
||||
- is_legacy_int: 是否检测到旧格式 int 数据,需要回退到工作空间默认配置
|
||||
"""
|
||||
if app_type == AppType.AGENT:
|
||||
return self._extract_memory_config_id_from_agent(config)
|
||||
elif app_type == AppType.WORKFLOW:
|
||||
return self._extract_memory_config_id_from_workflow(config)
|
||||
elif app_type == AppType.MULTI_AGENT:
|
||||
# Multi-agent 暂不支持记忆配置提取
|
||||
logger.debug(f"多智能体应用暂不支持记忆配置提取: app_type={app_type}")
|
||||
return None, False
|
||||
else:
|
||||
logger.warning(f"不支持的应用类型,无法提取记忆配置: app_type={app_type}")
|
||||
return None, False
|
||||
|
||||
def _extract_memory_config_id_from_agent(
|
||||
self,
|
||||
config: Dict[str, Any]
|
||||
) -> Tuple[Optional[uuid.UUID], bool]:
|
||||
"""从 Agent 应用配置中提取 memory_config_id
|
||||
from app.services.memory_config_service import MemoryConfigService
|
||||
|
||||
路径: config.memory.memory_content
|
||||
|
||||
Args:
|
||||
config: Agent 配置字典
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[uuid.UUID], bool]: (memory_config_id, is_legacy_int)
|
||||
- memory_config_id: 记忆配置ID,如果不存在或为旧格式则返回 None
|
||||
- is_legacy_int: 是否检测到旧格式 int 数据
|
||||
"""
|
||||
try:
|
||||
memory_dict = config.get("memory", {})
|
||||
# Support both field names: memory_config_id (new) and memory_content (legacy)
|
||||
memory_value = memory_dict.get("memory_config_id") or memory_dict.get("memory_content")
|
||||
logger.info(f"Extracting memory_config_id: memory_value={memory_value}, type={type(memory_value).__name__ if memory_value else 'None'}")
|
||||
if memory_value:
|
||||
# 处理字符串、UUID 和 int(旧数据兼容)三种情况
|
||||
if isinstance(memory_value, uuid.UUID):
|
||||
return memory_value, False
|
||||
elif isinstance(memory_value, str):
|
||||
# Check if it's a numeric string (legacy int format)
|
||||
if memory_value.isdigit():
|
||||
logger.warning(
|
||||
f"Agent 配置中 memory_config_id 为旧格式 int 字符串,将使用工作空间默认配置: "
|
||||
f"value={memory_value}"
|
||||
)
|
||||
return None, True
|
||||
try:
|
||||
return uuid.UUID(memory_value), False
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid UUID string: {memory_value}")
|
||||
return None, False
|
||||
elif isinstance(memory_value, int):
|
||||
# 旧数据存储为 int,需要回退到工作空间默认配置
|
||||
logger.warning(
|
||||
f"Agent 配置中 memory_config_id 为旧格式 int,将使用工作空间默认配置: "
|
||||
f"value={memory_value}"
|
||||
)
|
||||
return None, True
|
||||
else:
|
||||
logger.warning(
|
||||
f"Agent 配置中 memory_config_id 格式无效: type={type(memory_value)}, "
|
||||
f"value={memory_value}"
|
||||
)
|
||||
return None, False
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(
|
||||
f"Agent 配置中 memory_config_id 格式无效: error={str(e)}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
def _extract_memory_config_id_from_workflow(
|
||||
self,
|
||||
config: Dict[str, Any]
|
||||
) -> Tuple[Optional[uuid.UUID], bool]:
|
||||
"""从 Workflow 应用配置中提取 memory_config_id
|
||||
|
||||
扫描工作流节点,查找 MemoryRead 或 MemoryWrite 节点。
|
||||
返回第一个找到的记忆节点的 config_id。
|
||||
|
||||
Args:
|
||||
config: Workflow 配置字典
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[uuid.UUID], bool]: (memory_config_id, is_legacy_int)
|
||||
- memory_config_id: 记忆配置ID,如果不存在或为旧格式则返回 None
|
||||
- is_legacy_int: 是否检测到旧格式 int 数据
|
||||
"""
|
||||
nodes = config.get("nodes", [])
|
||||
|
||||
for node in nodes:
|
||||
node_type = node.get("type", "")
|
||||
|
||||
# 检查是否为记忆节点 (support both formats: memory-read/memory-write and MemoryRead/MemoryWrite)
|
||||
if node_type.lower() in ["memoryread", "memorywrite", "memory-read", "memory-write"]:
|
||||
config_id = node.get("config", {}).get("config_id")
|
||||
|
||||
if config_id:
|
||||
try:
|
||||
# 处理字符串、UUID 和 int(旧数据兼容)三种情况
|
||||
if isinstance(config_id, uuid.UUID):
|
||||
return config_id, False
|
||||
elif isinstance(config_id, str):
|
||||
return uuid.UUID(config_id), False
|
||||
elif isinstance(config_id, int):
|
||||
# 旧数据存储为 int,需要回退到工作空间默认配置
|
||||
logger.warning(
|
||||
f"工作流记忆节点 config_id 为旧格式 int,将使用工作空间默认配置: "
|
||||
f"node_id={node.get('id')}, node_type={node_type}, value={config_id}"
|
||||
)
|
||||
return None, True
|
||||
else:
|
||||
logger.warning(
|
||||
f"工作流记忆节点 config_id 格式无效: node_id={node.get('id')}, "
|
||||
f"node_type={node_type}, type={type(config_id)}"
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(
|
||||
f"工作流记忆节点 config_id 格式无效: node_id={node.get('id')}, "
|
||||
f"node_type={node_type}, error={str(e)}"
|
||||
)
|
||||
|
||||
logger.debug("工作流配置中未找到记忆节点")
|
||||
return None, False
|
||||
service = MemoryConfigService(self.db)
|
||||
return service.extract_memory_config_id(app_type, config)
|
||||
|
||||
def _get_workspace_default_memory_config_id(
|
||||
self,
|
||||
@@ -1488,7 +1370,7 @@ class AppService:
|
||||
|
||||
is_valid, errors = WorkflowValidator.validate_for_publish(config)
|
||||
if not is_valid:
|
||||
raise BusinessException("应用缺少有效配置,无法发布", BizCode.CONFIG_MISSING)
|
||||
raise BusinessException(f"应用缺少有效配置,无法发布, errors:{','.join(errors)}", BizCode.CONFIG_MISSING)
|
||||
logger.info(
|
||||
"应用发布配置准备完成"
|
||||
)
|
||||
|
||||
@@ -220,14 +220,16 @@ class EmotionAnalyticsService:
|
||||
"""计算积极率
|
||||
|
||||
根据情绪类型分类正面、负面和中性情绪,计算积极率。
|
||||
公式:(正面数 / (正面数 + 负面数)) * 100
|
||||
当存在非中性情绪时:(正面数 / (正面数 + 负面数)) * 100
|
||||
当只有中性情绪时:基于中性情绪的存在给出基准分数
|
||||
当完全没有情绪数据时:score 为 None,表示无法计算
|
||||
|
||||
Args:
|
||||
emotions: 情绪数据列表,每个包含 emotion_type 字段
|
||||
|
||||
Returns:
|
||||
Dict: 包含积极率计算结果:
|
||||
- score: 积极率分数(0-100)
|
||||
- score: 积极率分数(0-100),无数据时为 None
|
||||
- positive_count: 正面情绪数量
|
||||
- negative_count: 负面情绪数量
|
||||
- neutral_count: 中性情绪数量
|
||||
@@ -245,14 +247,19 @@ class EmotionAnalyticsService:
|
||||
total_non_neutral = positive_count + negative_count
|
||||
if total_non_neutral > 0:
|
||||
score = (positive_count / total_non_neutral) * 100
|
||||
elif neutral_count > 0:
|
||||
# 只有中性情绪,说明情绪状态平稳,给予基准分 50
|
||||
score = 50.0
|
||||
else:
|
||||
score = 50.0 # 如果没有非中性情绪,默认为50
|
||||
# 完全没有情绪数据,无法计算积极率
|
||||
score = None
|
||||
|
||||
score_display = f"{score:.2f}" if score is not None else "N/A"
|
||||
logger.debug(f"积极率计算: positive={positive_count}, negative={negative_count}, "
|
||||
f"neutral={neutral_count}, score={score:.2f}")
|
||||
f"neutral={neutral_count}, score={score_display}")
|
||||
|
||||
return {
|
||||
"score": round(score, 2),
|
||||
"score": round(score, 2) if score is not None else None,
|
||||
"positive_count": positive_count,
|
||||
"negative_count": negative_count,
|
||||
"neutral_count": neutral_count
|
||||
@@ -381,16 +388,26 @@ class EmotionAnalyticsService:
|
||||
time_range=time_range
|
||||
)
|
||||
|
||||
# 如果指定时间范围内没有数据,尝试更大的时间范围
|
||||
if not emotions and time_range != "90d":
|
||||
logger.info(f"用户 {end_user_id} 在 {time_range} 内无数据,尝试90天范围")
|
||||
emotions = await self.emotion_repo.get_emotions_in_range(
|
||||
end_user_id=end_user_id,
|
||||
time_range="90d"
|
||||
)
|
||||
if emotions:
|
||||
time_range = "90d"
|
||||
|
||||
# 如果没有数据,返回默认值
|
||||
if not emotions:
|
||||
logger.warning(f"用户 {end_user_id} 在时间范围 {time_range} 内没有情绪数据")
|
||||
return {
|
||||
"health_score": 0.0,
|
||||
"health_score": None,
|
||||
"level": "无数据",
|
||||
"dimensions": {
|
||||
"positivity_rate": {"score": 0.0, "positive_count": 0, "negative_count": 0, "neutral_count": 0},
|
||||
"stability": {"score": 0.0, "std_deviation": 0.0},
|
||||
"resilience": {"score": 0.0, "recovery_rate": 0.0}
|
||||
"positivity_rate": {"score": None, "positive_count": 0, "negative_count": 0, "neutral_count": 0},
|
||||
"stability": {"score": None, "std_deviation": 0.0},
|
||||
"resilience": {"score": None, "recovery_rate": 0.0}
|
||||
},
|
||||
"emotion_distribution": {},
|
||||
"time_range": time_range
|
||||
@@ -403,8 +420,10 @@ class EmotionAnalyticsService:
|
||||
|
||||
# 计算综合健康分数
|
||||
# 公式:positivity_rate * 0.4 + stability * 0.3 + resilience * 0.3
|
||||
# 如果积极率无法计算(无数据),视为 0 参与加权
|
||||
positivity_score = positivity_rate["score"] if positivity_rate["score"] is not None else 0.0
|
||||
health_score = (
|
||||
positivity_rate["score"] * 0.4 +
|
||||
positivity_score * 0.4 +
|
||||
stability["score"] * 0.3 +
|
||||
resilience["score"] * 0.3
|
||||
)
|
||||
@@ -565,6 +584,27 @@ class EmotionAnalyticsService:
|
||||
time_range="30d"
|
||||
)
|
||||
|
||||
# 3.1 如果30天内没有数据,尝试获取90天的数据
|
||||
if not emotions:
|
||||
logger.info(f"用户 {end_user_id} 30天内无情绪数据,尝试获取90天数据")
|
||||
emotions = await self.emotion_repo.get_emotions_in_range(
|
||||
end_user_id=end_user_id,
|
||||
time_range="90d"
|
||||
)
|
||||
health_data = await self.calculate_emotion_health_index(end_user_id, time_range="90d")
|
||||
|
||||
# 3.2 如果仍然没有时间范围内的数据,从情绪标签统计获取(无时间过滤)
|
||||
if not emotions:
|
||||
logger.info(f"用户 {end_user_id} 90天内也无情绪数据,从标签统计获取全量数据")
|
||||
tags_data = await self.get_emotion_tags(end_user_id=end_user_id)
|
||||
if tags_data.get("total_count", 0) > 0:
|
||||
# 用标签统计数据构建简化的 health_data
|
||||
health_data["emotion_distribution"] = {
|
||||
tag["emotion_type"]: tag["count"]
|
||||
for tag in tags_data.get("tags", [])
|
||||
}
|
||||
health_data["total_emotion_count"] = tags_data["total_count"]
|
||||
|
||||
# 4. 分析情绪模式
|
||||
patterns = self._analyze_emotion_patterns(emotions)
|
||||
|
||||
@@ -700,7 +740,7 @@ class EmotionAnalyticsService:
|
||||
Returns:
|
||||
EmotionSuggestionsResponse: 默认建议
|
||||
"""
|
||||
health_score = health_data.get('health_score', 0)
|
||||
health_score = health_data.get('health_score') or 0
|
||||
|
||||
if language == "en":
|
||||
if health_score >= 80:
|
||||
|
||||
@@ -1191,8 +1191,8 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An
|
||||
"""
|
||||
获取终端用户关联的记忆配置
|
||||
|
||||
使用 MemoryConfigService.get_config_with_fallback 获取配置,
|
||||
支持终端用户已分配配置和工作空间默认配置的回退机制。
|
||||
兼容旧数据:如果 end_user.memory_config_id 为空,则从 AppRelease.config 中获取
|
||||
并回填到 end_user.memory_config_id 字段(懒迁移)。
|
||||
|
||||
Args:
|
||||
end_user_id: 终端用户ID
|
||||
@@ -1204,7 +1204,13 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An
|
||||
Raises:
|
||||
ValueError: 当终端用户不存在或应用未发布时
|
||||
"""
|
||||
import json as json_module
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.app_model import App
|
||||
from app.models.app_release_model import AppRelease
|
||||
from app.models.end_user_model import EndUser
|
||||
from app.services.memory_config_service import MemoryConfigService
|
||||
|
||||
@@ -1217,6 +1223,7 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An
|
||||
raise ValueError(f"终端用户不存在: {end_user_id}")
|
||||
|
||||
app_id = end_user.app_id
|
||||
logger.debug(f"Found end_user app_id: {app_id}")
|
||||
|
||||
# 2. 获取应用以确定 workspace_id
|
||||
app = db.query(App).filter(App.id == app_id).first()
|
||||
@@ -1228,10 +1235,71 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An
|
||||
logger.warning(f"No current release for app: {app_id}")
|
||||
raise ValueError(f"应用未发布: {app_id}")
|
||||
|
||||
# 3. 使用 get_config_with_fallback 获取记忆配置
|
||||
# 3. 兼容旧数据:如果 memory_config_id 为空,从 AppRelease.config 获取并回填
|
||||
memory_config_id_to_use = end_user.memory_config_id
|
||||
|
||||
# 如果已有 memory_config_id,直接使用
|
||||
# 如果新创建enduser,enduser.memory_config_id 必定为none
|
||||
# 那么使用从release中获取memory_config_id为预期行为,并且回填到
|
||||
# end_user.memory_config_id
|
||||
if not memory_config_id_to_use:
|
||||
logger.info(f"end_user.memory_config_id is None, migrating from AppRelease.config")
|
||||
|
||||
# 获取最新发布版本
|
||||
stmt = (
|
||||
select(AppRelease)
|
||||
.where(AppRelease.app_id == app_id, AppRelease.is_active.is_(True))
|
||||
.order_by(AppRelease.version.desc())
|
||||
)
|
||||
# TODO: change to current_release_id
|
||||
latest_release = db.scalars(stmt).first()
|
||||
|
||||
if latest_release:
|
||||
config = latest_release.config or {}
|
||||
|
||||
# 如果 config 是字符串,解析为字典
|
||||
if isinstance(config, str):
|
||||
try:
|
||||
config = json_module.loads(config)
|
||||
except json_module.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse config JSON for release {latest_release.id}")
|
||||
config = {}
|
||||
|
||||
# 使用 MemoryConfigService 的提取方法
|
||||
memory_config_service = MemoryConfigService(db)
|
||||
legacy_config_id, is_legacy_int = memory_config_service.extract_memory_config_id(
|
||||
app_type=app.type,
|
||||
config=config
|
||||
)
|
||||
|
||||
if legacy_config_id:
|
||||
# 验证提取的 config_id 是否存在于数据库中
|
||||
from app.models.memory_config_model import MemoryConfig as MemoryConfigModel
|
||||
existing_config = db.get(MemoryConfigModel, legacy_config_id)
|
||||
|
||||
if existing_config:
|
||||
memory_config_id_to_use = legacy_config_id
|
||||
|
||||
# 回填到 end_user 表(lazy update)
|
||||
end_user.memory_config_id = memory_config_id_to_use
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Migrated memory_config_id for end_user {end_user_id}: {memory_config_id_to_use}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Extracted memory_config_id does not exist, skipping backfill: "
|
||||
f"end_user_id={end_user_id}, config_id={legacy_config_id}"
|
||||
)
|
||||
elif is_legacy_int:
|
||||
logger.info(
|
||||
f"Legacy int config detected for end_user {end_user_id}, will use workspace default"
|
||||
)
|
||||
|
||||
# 4. 使用 get_config_with_fallback 获取记忆配置
|
||||
memory_config_service = MemoryConfigService(db)
|
||||
memory_config = memory_config_service.get_config_with_fallback(
|
||||
memory_config_id=end_user.memory_config_id,
|
||||
memory_config_id=memory_config_id_to_use,
|
||||
workspace_id=app.workspace_id
|
||||
)
|
||||
|
||||
@@ -1255,7 +1323,8 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session)
|
||||
|
||||
使用与 get_end_user_connected_config 相同的逻辑:
|
||||
1. 优先使用 end_user.memory_config_id
|
||||
2. 如果没有,回退到工作空间默认配置
|
||||
2. 如果没有,尝试从 AppRelease.config 提取并回填
|
||||
3. 如果仍然没有,回退到工作空间默认配置
|
||||
|
||||
Args:
|
||||
end_user_ids: 终端用户ID列表
|
||||
@@ -1269,7 +1338,12 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session)
|
||||
...
|
||||
}
|
||||
"""
|
||||
import json as json_module
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.app_model import App
|
||||
from app.models.app_release_model import AppRelease
|
||||
from app.models.end_user_model import EndUser
|
||||
from app.models.memory_config_model import MemoryConfig
|
||||
from app.services.memory_config_service import MemoryConfigService
|
||||
@@ -1284,7 +1358,8 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session)
|
||||
# 1. 批量查询所有 end_user 及其 app_id 和 memory_config_id
|
||||
end_users = db.query(EndUser).filter(EndUser.id.in_(end_user_ids)).all()
|
||||
|
||||
# 创建映射
|
||||
# 创建映射 - 保留 EndUser 对象引用以便回填
|
||||
end_user_map = {str(eu.id): eu for eu in end_users}
|
||||
user_data = {str(eu.id): {"app_id": eu.app_id, "memory_config_id": eu.memory_config_id} for eu in end_users}
|
||||
|
||||
# 记录未找到的用户
|
||||
@@ -1295,15 +1370,116 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session)
|
||||
for user_id in missing_user_ids:
|
||||
result[user_id] = {"memory_config_id": None, "memory_config_name": None}
|
||||
|
||||
# 2. 批量获取所有相关应用以获取 workspace_id
|
||||
# 2. 批量获取所有相关应用以获取 workspace_id 和 type
|
||||
app_ids = list(set(data["app_id"] for data in user_data.values()))
|
||||
if not app_ids:
|
||||
return result
|
||||
|
||||
apps = db.query(App).filter(App.id.in_(app_ids)).all()
|
||||
app_map = {app.id: app for app in apps}
|
||||
app_to_workspace = {app.id: app.workspace_id for app in apps}
|
||||
|
||||
# 3. 收集需要查询的 memory_config_id 和需要回退的 workspace_id
|
||||
# 3. 对于没有 memory_config_id 的用户,尝试从 AppRelease.config 提取
|
||||
users_needing_migration = [
|
||||
(end_user_id, data["app_id"])
|
||||
for end_user_id, data in user_data.items()
|
||||
if not data["memory_config_id"]
|
||||
]
|
||||
|
||||
if users_needing_migration:
|
||||
# 批量获取相关应用的最新发布版本
|
||||
migration_app_ids = list(set(app_id for _, app_id in users_needing_migration))
|
||||
|
||||
# 查询每个应用的最新活跃发布版本
|
||||
app_latest_releases = {}
|
||||
for app_id in migration_app_ids:
|
||||
stmt = (
|
||||
select(AppRelease)
|
||||
.where(AppRelease.app_id == app_id, AppRelease.is_active.is_(True))
|
||||
.order_by(AppRelease.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_release = db.scalars(stmt).first()
|
||||
if latest_release:
|
||||
app_latest_releases[app_id] = latest_release
|
||||
|
||||
# 为每个需要迁移的用户提取 memory_config_id
|
||||
config_service = MemoryConfigService(db)
|
||||
users_to_backfill = [] # [(end_user, memory_config_id), ...]
|
||||
|
||||
for end_user_id, app_id in users_needing_migration:
|
||||
latest_release = app_latest_releases.get(app_id)
|
||||
if not latest_release:
|
||||
continue
|
||||
|
||||
config = latest_release.config or {}
|
||||
|
||||
# 如果 config 是字符串,解析为字典
|
||||
if isinstance(config, str):
|
||||
try:
|
||||
config = json_module.loads(config)
|
||||
except json_module.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse config JSON for release {latest_release.id}")
|
||||
continue
|
||||
|
||||
# 使用 MemoryConfigService 的提取方法
|
||||
app = app_map.get(app_id)
|
||||
if not app:
|
||||
continue
|
||||
|
||||
legacy_config_id, is_legacy_int = config_service.extract_memory_config_id(
|
||||
app_type=app.type,
|
||||
config=config
|
||||
)
|
||||
|
||||
if legacy_config_id:
|
||||
# 更新 user_data 中的 memory_config_id
|
||||
user_data[end_user_id]["memory_config_id"] = legacy_config_id
|
||||
|
||||
# 记录需要回填的用户(稍后验证配置存在后再回填)
|
||||
end_user = end_user_map.get(end_user_id)
|
||||
if end_user:
|
||||
users_to_backfill.append((end_user, legacy_config_id))
|
||||
elif is_legacy_int:
|
||||
logger.info(
|
||||
f"Legacy int config detected for end_user {end_user_id}, will use workspace default"
|
||||
)
|
||||
|
||||
# 验证提取的 config_id 是否存在于数据库中
|
||||
if users_to_backfill:
|
||||
config_ids_to_validate = list(set(cid for _, cid in users_to_backfill))
|
||||
existing_configs = db.query(MemoryConfig).filter(
|
||||
MemoryConfig.config_id.in_(config_ids_to_validate)
|
||||
).all()
|
||||
valid_config_ids = {mc.config_id for mc in existing_configs}
|
||||
|
||||
# 只回填存在的配置
|
||||
valid_backfills = [
|
||||
(eu, cid) for eu, cid in users_to_backfill
|
||||
if cid in valid_config_ids
|
||||
]
|
||||
invalid_backfills = [
|
||||
(eu, cid) for eu, cid in users_to_backfill
|
||||
if cid not in valid_config_ids
|
||||
]
|
||||
|
||||
if invalid_backfills:
|
||||
invalid_ids = [str(cid) for _, cid in invalid_backfills]
|
||||
logger.warning(
|
||||
f"Skipping backfill for non-existent memory_config_ids: {invalid_ids}"
|
||||
)
|
||||
# 清除 user_data 中无效的 config_id
|
||||
for eu, cid in invalid_backfills:
|
||||
user_data[str(eu.id)]["memory_config_id"] = None
|
||||
|
||||
# 批量回填 end_user.memory_config_id
|
||||
if valid_backfills:
|
||||
for end_user, memory_config_id in valid_backfills:
|
||||
end_user.memory_config_id = memory_config_id
|
||||
db.commit()
|
||||
logger.info(f"Migrated memory_config_id for {len(valid_backfills)} end_users")
|
||||
|
||||
# 4. 收集需要查询的 memory_config_id 和需要回退的 workspace_id
|
||||
direct_config_ids = []
|
||||
workspace_fallback_users = [] # [(end_user_id, workspace_id), ...]
|
||||
|
||||
@@ -1315,13 +1491,13 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session)
|
||||
if workspace_id:
|
||||
workspace_fallback_users.append((end_user_id, workspace_id))
|
||||
|
||||
# 4. 批量查询直接分配的配置
|
||||
# 5. 批量查询直接分配的配置
|
||||
config_id_to_config = {}
|
||||
if direct_config_ids:
|
||||
configs = db.query(MemoryConfig).filter(MemoryConfig.config_id.in_(direct_config_ids)).all()
|
||||
config_id_to_config = {mc.config_id: mc for mc in configs}
|
||||
|
||||
# 5. 获取工作空间默认配置(需要逐个查询,因为 get_workspace_default_config 有复杂逻辑)
|
||||
# 6. 获取工作空间默认配置(需要逐个查询,因为 get_workspace_default_config 有复杂逻辑)
|
||||
workspace_default_configs = {}
|
||||
unique_workspace_ids = list(set(ws_id for _, ws_id in workspace_fallback_users))
|
||||
|
||||
@@ -1332,7 +1508,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session)
|
||||
if default_config:
|
||||
workspace_default_configs[workspace_id] = default_config
|
||||
|
||||
# 6. 构建最终结果
|
||||
# 7. 构建最终结果
|
||||
for end_user_id, data in user_data.items():
|
||||
memory_config = None
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from sqlalchemy.orm import Session
|
||||
from app.core.logging_config import get_config_logger, get_logger
|
||||
from app.core.validators.memory_config_validators import (
|
||||
validate_and_resolve_model_id,
|
||||
validate_embedding_model,
|
||||
)
|
||||
from app.models.memory_config_model import MemoryConfig as MemoryConfigModel
|
||||
from app.repositories.memory_config_repository import MemoryConfigRepository
|
||||
@@ -217,53 +216,108 @@ class MemoryConfigService:
|
||||
|
||||
memory_config, workspace = result
|
||||
|
||||
# Step 2: Validate embedding model (returns both UUID and name)
|
||||
# Helper function to validate model with workspace fallback
|
||||
def _validate_model_with_fallback(
|
||||
model_id: str,
|
||||
model_type: str,
|
||||
workspace_default: str,
|
||||
required: bool = False
|
||||
) -> tuple:
|
||||
"""Validate model ID, falling back to workspace default if invalid.
|
||||
|
||||
Args:
|
||||
model_id: The model ID to validate
|
||||
model_type: Type of model (llm, embedding, rerank)
|
||||
workspace_default: Workspace default model ID to use as fallback
|
||||
required: Whether the model is required
|
||||
|
||||
Returns:
|
||||
Tuple of (model_uuid, model_name) or (None, None)
|
||||
"""
|
||||
# Try the configured model first
|
||||
if model_id:
|
||||
try:
|
||||
return validate_and_resolve_model_id(
|
||||
model_id,
|
||||
model_type,
|
||||
self.db,
|
||||
workspace.tenant_id,
|
||||
required=False,
|
||||
config_id=validated_config_id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"{model_type} model validation failed, trying workspace default: {e}"
|
||||
)
|
||||
|
||||
# Fallback to workspace default
|
||||
if workspace_default:
|
||||
try:
|
||||
result = validate_and_resolve_model_id(
|
||||
workspace_default,
|
||||
model_type,
|
||||
self.db,
|
||||
workspace.tenant_id,
|
||||
required=required,
|
||||
config_id=validated_config_id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
if result[0]:
|
||||
logger.info(
|
||||
f"Using workspace default {model_type} model: {workspace_default}"
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Workspace default {model_type} model also invalid: {e}")
|
||||
if required:
|
||||
raise
|
||||
|
||||
if required:
|
||||
raise InvalidConfigError(
|
||||
f"{model_type.title()} model is required but not configured",
|
||||
field_name=f"{model_type}_model_id",
|
||||
invalid_value=model_id,
|
||||
config_id=validated_config_id,
|
||||
workspace_id=workspace.id
|
||||
)
|
||||
|
||||
return None, None
|
||||
|
||||
# Step 2: Validate embedding model with workspace fallback
|
||||
embed_start = time.time()
|
||||
embedding_uuid, embedding_name = validate_embedding_model(
|
||||
validated_config_id,
|
||||
embedding_uuid, embedding_name = _validate_model_with_fallback(
|
||||
memory_config.embedding_id,
|
||||
self.db,
|
||||
workspace.tenant_id,
|
||||
workspace.id,
|
||||
"embedding",
|
||||
workspace.embedding,
|
||||
required=True
|
||||
)
|
||||
embed_time = time.time() - embed_start
|
||||
logger.info(f"[PERF] Embedding validation: {embed_time:.4f}s")
|
||||
|
||||
# Step 3: Resolve LLM model
|
||||
# Step 3: Resolve LLM model with workspace fallback
|
||||
llm_start = time.time()
|
||||
llm_uuid, llm_name = validate_and_resolve_model_id(
|
||||
llm_uuid, llm_name = _validate_model_with_fallback(
|
||||
memory_config.llm_id,
|
||||
"llm",
|
||||
self.db,
|
||||
workspace.tenant_id,
|
||||
required=True,
|
||||
config_id=validated_config_id,
|
||||
workspace_id=workspace.id,
|
||||
workspace.llm,
|
||||
required=True
|
||||
)
|
||||
llm_time = time.time() - llm_start
|
||||
logger.info(f"[PERF] LLM validation: {llm_time:.4f}s")
|
||||
|
||||
# Step 4: Resolve optional rerank model
|
||||
# Step 4: Resolve optional rerank model with workspace fallback
|
||||
rerank_start = time.time()
|
||||
rerank_uuid = None
|
||||
rerank_name = None
|
||||
if memory_config.rerank_id:
|
||||
rerank_uuid, rerank_name = validate_and_resolve_model_id(
|
||||
memory_config.rerank_id,
|
||||
"rerank",
|
||||
self.db,
|
||||
workspace.tenant_id,
|
||||
required=False,
|
||||
config_id=validated_config_id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
rerank_uuid, rerank_name = _validate_model_with_fallback(
|
||||
memory_config.rerank_id,
|
||||
"rerank",
|
||||
workspace.rerank,
|
||||
required=False
|
||||
)
|
||||
rerank_time = time.time() - rerank_start
|
||||
if memory_config.rerank_id:
|
||||
if memory_config.rerank_id or workspace.rerank:
|
||||
logger.info(f"[PERF] Rerank validation: {rerank_time:.4f}s")
|
||||
|
||||
# Note: embedding_name is now returned from validate_embedding_model above
|
||||
# No need for redundant query!
|
||||
|
||||
# Create immutable MemoryConfig object
|
||||
config = MemoryConfig(
|
||||
config_id=memory_config.config_id,
|
||||
@@ -496,7 +550,7 @@ class MemoryConfigService:
|
||||
|
||||
try:
|
||||
ontology_repo = OntologyClassRepository(self.db)
|
||||
ontology_classes = ontology_repo.get_by_scene(memory_config.scene_id)
|
||||
ontology_classes = ontology_repo.get_classes_by_scene(memory_config.scene_id)
|
||||
|
||||
if not ontology_classes:
|
||||
logger.info(f"No ontology classes found for scene_id: {memory_config.scene_id}")
|
||||
@@ -530,38 +584,7 @@ class MemoryConfigService:
|
||||
Returns:
|
||||
Optional[MemoryConfigModel]: Default config or None if no configs exist
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.memory_config_model import MemoryConfig as MemoryConfigModel
|
||||
|
||||
# First, try to find the explicitly marked default config
|
||||
stmt = (
|
||||
select(MemoryConfigModel)
|
||||
.where(
|
||||
MemoryConfigModel.workspace_id == workspace_id,
|
||||
MemoryConfigModel.is_default.is_(True),
|
||||
MemoryConfigModel.state.is_(True),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
config = self.db.scalars(stmt).first()
|
||||
|
||||
if config:
|
||||
return config
|
||||
|
||||
# Fallback: get the oldest active config if no explicit default
|
||||
stmt = (
|
||||
select(MemoryConfigModel)
|
||||
.where(
|
||||
MemoryConfigModel.workspace_id == workspace_id,
|
||||
MemoryConfigModel.state.is_(True),
|
||||
)
|
||||
.order_by(MemoryConfigModel.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
config = self.db.scalars(stmt).first()
|
||||
config = MemoryConfigRepository.get_workspace_default(self.db, workspace_id)
|
||||
|
||||
if not config:
|
||||
logger.warning(
|
||||
@@ -588,29 +611,28 @@ class MemoryConfigService:
|
||||
Returns:
|
||||
Optional[MemoryConfigModel]: Memory config or None if no fallback available
|
||||
"""
|
||||
from app.models.memory_config_model import MemoryConfig as MemoryConfigModel
|
||||
|
||||
if not memory_config_id:
|
||||
logger.debug(
|
||||
"No memory config ID provided, using workspace default",
|
||||
extra={"workspace_id": str(workspace_id)}
|
||||
)
|
||||
return self.get_workspace_default_config(workspace_id)
|
||||
|
||||
config = self.db.get(MemoryConfigModel, memory_config_id)
|
||||
|
||||
if config:
|
||||
return config
|
||||
|
||||
logger.warning(
|
||||
"Memory config not found, falling back to workspace default",
|
||||
extra={
|
||||
"missing_config_id": str(memory_config_id),
|
||||
"workspace_id": str(workspace_id)
|
||||
}
|
||||
config = MemoryConfigRepository.get_with_fallback(
|
||||
self.db,
|
||||
memory_config_id,
|
||||
workspace_id
|
||||
)
|
||||
|
||||
return self.get_workspace_default_config(workspace_id)
|
||||
if not config and memory_config_id:
|
||||
logger.warning(
|
||||
"Memory config not found, falling back to workspace default",
|
||||
extra={
|
||||
"missing_config_id": str(memory_config_id),
|
||||
"workspace_id": str(workspace_id)
|
||||
}
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
def delete_config(
|
||||
self,
|
||||
@@ -624,7 +646,7 @@ class MemoryConfigService:
|
||||
|
||||
Args:
|
||||
config_id: Memory config ID to delete (UUID or legacy int)
|
||||
force: If True, delete even if end users are connected
|
||||
force: If True, clear end user references before deleting
|
||||
|
||||
Returns:
|
||||
Dict with status, message, and affected_users count
|
||||
@@ -632,8 +654,11 @@ class MemoryConfigService:
|
||||
Raises:
|
||||
ResourceNotFoundException: If config doesn't exist
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import ResourceNotFoundException
|
||||
from app.models.memory_config_model import MemoryConfig as MemoryConfigModel
|
||||
from app.repositories.end_user_repository import EndUserRepository
|
||||
|
||||
# 处理旧格式 int 类型的 config_id
|
||||
if isinstance(config_id, int):
|
||||
@@ -663,54 +688,227 @@ class MemoryConfigService:
|
||||
"is_default": True
|
||||
}
|
||||
|
||||
# TODO: add back delete warning
|
||||
# # Count connected end users
|
||||
# end_user_repo = EndUserRepository(self.db)
|
||||
# connected_count = end_user_repo.count_by_memory_config_id(config_id)
|
||||
# Use repository to count connected end users
|
||||
end_user_repo = EndUserRepository(self.db)
|
||||
connected_count = end_user_repo.count_by_memory_config_id(config_id)
|
||||
|
||||
# if connected_count > 0 and not force:
|
||||
# logger.warning(
|
||||
# "Attempted to delete memory config with connected end users",
|
||||
# extra={
|
||||
# "config_id": str(config_id),
|
||||
# "connected_count": connected_count
|
||||
# }
|
||||
# )
|
||||
if connected_count > 0 and not force:
|
||||
logger.warning(
|
||||
"Attempted to delete memory config with connected end users",
|
||||
extra={
|
||||
"config_id": str(config_id),
|
||||
"connected_count": connected_count
|
||||
}
|
||||
)
|
||||
|
||||
# return {
|
||||
# "status": "warning",
|
||||
# "message": f"Cannot delete memory config: {connected_count} end users are using it",
|
||||
# "connected_count": connected_count,
|
||||
# "force_required": True
|
||||
# }
|
||||
return {
|
||||
"status": "warning",
|
||||
"message": f"无法删除记忆配置:{connected_count} 个终端用户正在使用此配置",
|
||||
"connected_count": connected_count,
|
||||
"force_required": True
|
||||
}
|
||||
|
||||
# # Force delete: clear end user references first
|
||||
# if connected_count > 0 and force:
|
||||
# cleared_count = end_user_repo.clear_memory_config_id(config_id)
|
||||
# Force delete: use repository to clear end user references first
|
||||
if connected_count > 0 and force:
|
||||
cleared_count = end_user_repo.clear_memory_config_id(config_id)
|
||||
|
||||
# logger.warning(
|
||||
# "Force deleting memory config",
|
||||
# extra={
|
||||
# "config_id": str(config_id),
|
||||
# "cleared_end_users": cleared_count
|
||||
# }
|
||||
# )
|
||||
connected_count = 0
|
||||
logger.warning(
|
||||
"Force deleting memory config, clearing end user references",
|
||||
extra={
|
||||
"config_id": str(config_id),
|
||||
"cleared_end_users": cleared_count
|
||||
}
|
||||
)
|
||||
|
||||
self.db.delete(config)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(
|
||||
"Memory config deleted",
|
||||
extra={
|
||||
"config_id": str(config_id),
|
||||
"force": force,
|
||||
try:
|
||||
self.db.delete(config)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(
|
||||
"Memory config deleted",
|
||||
extra={
|
||||
"config_id": str(config_id),
|
||||
"force": force,
|
||||
"affected_users": connected_count
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "记忆配置删除成功",
|
||||
"affected_users": connected_count
|
||||
}
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
|
||||
# Handle foreign key violation gracefully
|
||||
error_str = str(e.orig) if e.orig else str(e)
|
||||
if "ForeignKeyViolation" in error_str or "foreign key constraint" in error_str.lower():
|
||||
logger.warning(
|
||||
"Delete failed due to foreign key constraint",
|
||||
extra={
|
||||
"config_id": str(config_id),
|
||||
"error": error_str
|
||||
}
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "无法删除记忆配置:仍有终端用户引用此配置,请使用 force=true 强制删除",
|
||||
"force_required": True
|
||||
}
|
||||
|
||||
# Re-raise other integrity errors
|
||||
logger.error(
|
||||
"Delete failed due to integrity error",
|
||||
extra={
|
||||
"config_id": str(config_id),
|
||||
"error": error_str
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
# ==================== 记忆配置提取方法 ====================
|
||||
|
||||
def extract_memory_config_id(
|
||||
self,
|
||||
app_type: str,
|
||||
config: dict
|
||||
) -> tuple[Optional[uuid.UUID], bool]:
|
||||
"""从发布配置中提取 memory_config_id(根据应用类型分发)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Memory config deleted successfully",
|
||||
"affected_users": connected_count
|
||||
}
|
||||
Args:
|
||||
app_type: 应用类型 (agent, workflow, multi_agent)
|
||||
config: 发布配置字典
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[uuid.UUID], bool]: (memory_config_id, is_legacy_int)
|
||||
- memory_config_id: 提取的配置ID,如果不存在或为旧格式则返回 None
|
||||
- is_legacy_int: 是否检测到旧格式 int 数据,需要回退到工作空间默认配置
|
||||
"""
|
||||
if app_type == "agent":
|
||||
return self._extract_memory_config_id_from_agent(config)
|
||||
elif app_type == "workflow":
|
||||
return self._extract_memory_config_id_from_workflow(config)
|
||||
elif app_type == "multi_agent":
|
||||
# Multi-agent 暂不支持记忆配置提取
|
||||
logger.debug(f"多智能体应用暂不支持记忆配置提取: app_type={app_type}")
|
||||
return None, False
|
||||
else:
|
||||
logger.warning(f"不支持的应用类型,无法提取记忆配置: app_type={app_type}")
|
||||
return None, False
|
||||
|
||||
def _extract_memory_config_id_from_agent(
|
||||
self,
|
||||
config: dict
|
||||
) -> tuple[Optional[uuid.UUID], bool]:
|
||||
"""从 Agent 应用配置中提取 memory_config_id
|
||||
|
||||
路径: config.memory.memory_content 或 config.memory.memory_config_id
|
||||
|
||||
Args:
|
||||
config: Agent 配置字典
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[uuid.UUID], bool]: (memory_config_id, is_legacy_int)
|
||||
- memory_config_id: 记忆配置ID,如果不存在或为旧格式则返回 None
|
||||
- is_legacy_int: 是否检测到旧格式 int 数据
|
||||
"""
|
||||
try:
|
||||
memory_dict = config.get("memory", {})
|
||||
# Support both field names: memory_config_id (new) and memory_content (legacy)
|
||||
memory_value = memory_dict.get("memory_config_id") or memory_dict.get("memory_content")
|
||||
logger.info(
|
||||
f"Extracting memory_config_id: memory_value={memory_value}, "
|
||||
f"type={type(memory_value).__name__ if memory_value else 'None'}"
|
||||
)
|
||||
if memory_value:
|
||||
# 处理字符串、UUID 和 int(旧数据兼容)三种情况
|
||||
if isinstance(memory_value, uuid.UUID):
|
||||
return memory_value, False
|
||||
elif isinstance(memory_value, str):
|
||||
# Check if it's a numeric string (legacy int format)
|
||||
if memory_value.isdigit():
|
||||
logger.warning(
|
||||
f"Agent 配置中 memory_config_id 为旧格式 int 字符串,将使用工作空间默认配置: "
|
||||
f"value={memory_value}"
|
||||
)
|
||||
return None, True
|
||||
try:
|
||||
return uuid.UUID(memory_value), False
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid UUID string: {memory_value}")
|
||||
return None, False
|
||||
elif isinstance(memory_value, int):
|
||||
# 旧数据存储为 int,需要回退到工作空间默认配置
|
||||
logger.warning(
|
||||
f"Agent 配置中 memory_config_id 为旧格式 int,将使用工作空间默认配置: "
|
||||
f"value={memory_value}"
|
||||
)
|
||||
return None, True
|
||||
else:
|
||||
logger.warning(
|
||||
f"Agent 配置中 memory_config_id 格式无效: type={type(memory_value)}, "
|
||||
f"value={memory_value}"
|
||||
)
|
||||
return None, False
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(
|
||||
f"Agent 配置中 memory_config_id 格式无效: error={str(e)}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
def _extract_memory_config_id_from_workflow(
|
||||
self,
|
||||
config: dict
|
||||
) -> tuple[Optional[uuid.UUID], bool]:
|
||||
"""从 Workflow 应用配置中提取 memory_config_id
|
||||
|
||||
扫描工作流节点,查找 MemoryRead 或 MemoryWrite 节点。
|
||||
返回第一个找到的记忆节点的 config_id。
|
||||
|
||||
Args:
|
||||
config: Workflow 配置字典
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[uuid.UUID], bool]: (memory_config_id, is_legacy_int)
|
||||
- memory_config_id: 记忆配置ID,如果不存在或为旧格式则返回 None
|
||||
- is_legacy_int: 是否检测到旧格式 int 数据
|
||||
"""
|
||||
nodes = config.get("nodes", [])
|
||||
|
||||
for node in nodes:
|
||||
node_type = node.get("type", "")
|
||||
|
||||
# 检查是否为记忆节点 (support both formats: memory-read/memory-write and MemoryRead/MemoryWrite)
|
||||
if node_type.lower() in ["memoryread", "memorywrite", "memory-read", "memory-write"]:
|
||||
config_id = node.get("config", {}).get("config_id")
|
||||
|
||||
if config_id:
|
||||
try:
|
||||
# 处理字符串、UUID 和 int(旧数据兼容)三种情况
|
||||
if isinstance(config_id, uuid.UUID):
|
||||
return config_id, False
|
||||
elif isinstance(config_id, str):
|
||||
return uuid.UUID(config_id), False
|
||||
elif isinstance(config_id, int):
|
||||
# 旧数据存储为 int,需要回退到工作空间默认配置
|
||||
logger.warning(
|
||||
f"工作流记忆节点 config_id 为旧格式 int,将使用工作空间默认配置: "
|
||||
f"node_id={node.get('id')}, node_type={node_type}, value={config_id}"
|
||||
)
|
||||
return None, True
|
||||
else:
|
||||
logger.warning(
|
||||
f"工作流记忆节点 config_id 格式无效: node_id={node.get('id')}, "
|
||||
f"node_type={node_type}, type={type(config_id)}"
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(
|
||||
f"工作流记忆节点 config_id 格式无效: node_id={node.get('id')}, "
|
||||
f"node_type={node_type}, error={str(e)}"
|
||||
)
|
||||
|
||||
logger.debug("工作流配置中未找到记忆节点")
|
||||
return None, False
|
||||
|
||||
@@ -120,7 +120,14 @@ class WorkspaceAppService:
|
||||
return None
|
||||
|
||||
def _get_memory_config(self, memory_content: str) -> Dict[str, Any]:
|
||||
"""Retrieve memory_config information based on memory_content"""
|
||||
"""Retrieve memory_config information based on memory_content
|
||||
|
||||
Args:
|
||||
memory_content: Memory config ID string
|
||||
|
||||
Returns:
|
||||
Dict containing memory config info including workspace_id for model fallback
|
||||
"""
|
||||
try:
|
||||
memory_content = resolve_config_id(memory_content, self.db)
|
||||
memory_config_result = MemoryConfigRepository.query_reflection_config_by_id(self.db, (memory_content))
|
||||
@@ -128,6 +135,7 @@ class WorkspaceAppService:
|
||||
if memory_config_result:
|
||||
return {
|
||||
"config_id": memory_content,
|
||||
"workspace_id": memory_config_result.workspace_id,
|
||||
"enable_self_reflexion": memory_config_result.enable_self_reflexion,
|
||||
"iteration_period": memory_config_result.iteration_period,
|
||||
"reflexion_range": memory_config_result.reflexion_range,
|
||||
@@ -359,7 +367,17 @@ class MemoryReflectionService:
|
||||
}
|
||||
|
||||
def _create_reflection_config_from_data(self, config_data: Dict[str, Any]) -> ReflectionConfig:
|
||||
"""Create reflective configuration objects from configuration data"""
|
||||
"""Create reflective configuration objects from configuration data
|
||||
|
||||
If reflection_model_id is not set, falls back to workspace default LLM.
|
||||
|
||||
Args:
|
||||
config_data: Dict containing reflection config including workspace_id
|
||||
|
||||
Returns:
|
||||
ReflectionConfig object with model_id resolved
|
||||
"""
|
||||
from app.repositories.workspace_repository import get_workspace_models_configs
|
||||
|
||||
reflexion_range_value = config_data.get("reflexion_range")
|
||||
if reflexion_range_value is None or reflexion_range_value == "":
|
||||
@@ -392,6 +410,17 @@ class MemoryReflectionService:
|
||||
if reflection_model_id:
|
||||
reflection_model_id = str(reflection_model_id)
|
||||
|
||||
# 如果 reflection_model_id 为空,回退到工作空间默认 LLM
|
||||
if not reflection_model_id:
|
||||
workspace_id = config_data.get("workspace_id")
|
||||
if workspace_id:
|
||||
workspace_models = get_workspace_models_configs(self.db, workspace_id)
|
||||
if workspace_models and workspace_models.get("llm"):
|
||||
reflection_model_id = workspace_models["llm"]
|
||||
api_logger.info(
|
||||
f"reflection_model_id 为空,使用工作空间默认 LLM: {reflection_model_id}"
|
||||
)
|
||||
|
||||
return ReflectionConfig(
|
||||
enabled=config_data.get("enable_self_reflexion", False),
|
||||
iteration_period=str(iteration_period), # ReflectionConfig期望字符串
|
||||
|
||||
@@ -399,12 +399,22 @@ class DataConfigService: # 数据配置服务类(PostgreSQL)
|
||||
with open(result_path, "r", encoding="utf-8") as rf:
|
||||
extracted_result = json.load(rf)
|
||||
|
||||
# 步骤 6: 发出结果事件
|
||||
# 步骤 6: 计算本体覆盖率并合并到结果中
|
||||
result_data = {
|
||||
"config_id": cid,
|
||||
"time_log": os.path.join(project_root, "logs", "time.log"),
|
||||
"extracted_result": extracted_result,
|
||||
}
|
||||
try:
|
||||
ontology_coverage = await self._compute_ontology_coverage(
|
||||
extracted_result=extracted_result,
|
||||
memory_config=memory_config,
|
||||
)
|
||||
if ontology_coverage:
|
||||
result_data["ontology_coverage"] = ontology_coverage
|
||||
except Exception as cov_err:
|
||||
logger.warning(f"[PILOT_RUN_STREAM] Ontology coverage computation failed: {cov_err}", exc_info=True)
|
||||
|
||||
yield format_sse_message("result", result_data)
|
||||
|
||||
# 步骤 7: 发出完成事件
|
||||
@@ -428,6 +438,100 @@ class DataConfigService: # 数据配置服务类(PostgreSQL)
|
||||
})
|
||||
|
||||
|
||||
async def _compute_ontology_coverage(
|
||||
self,
|
||||
extracted_result: Dict[str, Any],
|
||||
memory_config,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""根据提取结果中的实体类型,与场景/通用本体类型做互斥分类统计。
|
||||
|
||||
分类规则(互斥):场景类型优先 > 通用类型 > 未匹配
|
||||
确保: 场景实体数 + 通用实体数 + 未匹配数 = 总实体数
|
||||
|
||||
Returns:
|
||||
包含三部分统计的字典,或 None(无实体数据时)
|
||||
"""
|
||||
core_entities = extracted_result.get("core_entities", [])
|
||||
if not core_entities:
|
||||
return None
|
||||
|
||||
# 1. 加载场景本体类型集合
|
||||
scene_ontology_types: set = set()
|
||||
try:
|
||||
from app.repositories.ontology_class_repository import OntologyClassRepository
|
||||
|
||||
if memory_config.scene_id:
|
||||
class_repo = OntologyClassRepository(self.db)
|
||||
ontology_classes = class_repo.get_classes_by_scene(memory_config.scene_id)
|
||||
scene_ontology_types = {oc.class_name for oc in ontology_classes}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load scene ontology types: {e}")
|
||||
|
||||
# 2. 加载通用本体类型集合
|
||||
general_ontology_types: set = set()
|
||||
try:
|
||||
from app.core.memory.ontology_services.ontology_type_loader import (
|
||||
get_general_ontology_registry,
|
||||
is_general_ontology_enabled,
|
||||
)
|
||||
|
||||
if is_general_ontology_enabled():
|
||||
registry = get_general_ontology_registry()
|
||||
if registry:
|
||||
general_ontology_types = set(registry.types.keys())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load general ontology types: {e}")
|
||||
|
||||
# 3. 互斥分类:场景优先 > 通用 > 未匹配
|
||||
scene_distribution: list = []
|
||||
general_distribution: list = []
|
||||
unmatched_distribution: list = []
|
||||
scene_total = 0
|
||||
general_total = 0
|
||||
unmatched_total = 0
|
||||
|
||||
for item in core_entities:
|
||||
entity_type = item.get("type", "")
|
||||
count = item.get("count", 0)
|
||||
|
||||
if entity_type in scene_ontology_types:
|
||||
scene_distribution.append({"type": entity_type, "count": count})
|
||||
scene_total += count
|
||||
elif entity_type in general_ontology_types:
|
||||
general_distribution.append({"type": entity_type, "count": count})
|
||||
general_total += count
|
||||
else:
|
||||
unmatched_distribution.append({"type": entity_type, "count": count})
|
||||
unmatched_total += count
|
||||
|
||||
# 按数量降序排列
|
||||
scene_distribution.sort(key=lambda x: x["count"], reverse=True)
|
||||
general_distribution.sort(key=lambda x: x["count"], reverse=True)
|
||||
unmatched_distribution.sort(key=lambda x: x["count"], reverse=True)
|
||||
|
||||
total_entities = scene_total + general_total + unmatched_total
|
||||
|
||||
return {
|
||||
"scene_type_distribution": {
|
||||
"type_count": len(scene_distribution),
|
||||
"entity_total": scene_total,
|
||||
"types": scene_distribution,
|
||||
},
|
||||
"general_type_distribution": {
|
||||
"type_count": len(general_distribution),
|
||||
"entity_total": general_total,
|
||||
"types": general_distribution,
|
||||
},
|
||||
"unmatched": {
|
||||
"type_count": len(unmatched_distribution),
|
||||
"entity_total": unmatched_total,
|
||||
"types": unmatched_distribution,
|
||||
},
|
||||
"total_entities": total_entities,
|
||||
"time": int(time.time() * 1000),
|
||||
}
|
||||
|
||||
|
||||
# -------------------- Neo4j Search & Analytics (fused from data_search_service.py) --------------------
|
||||
# Ensure env for connector (e.g., NEO4J_PASSWORD)
|
||||
load_dotenv()
|
||||
|
||||
@@ -1155,7 +1155,7 @@ class OntologyService:
|
||||
raise ValueError("无权限访问该场景的类型")
|
||||
|
||||
# 获取类型列表
|
||||
classes = self.class_repo.get_by_scene(scene_id)
|
||||
classes = self.class_repo.get_classes_by_scene(scene_id)
|
||||
|
||||
logger.info(f"Found {len(classes)} classes in scene {scene_id}")
|
||||
|
||||
|
||||
@@ -48,11 +48,13 @@ class SkillService:
|
||||
if tool_id:
|
||||
tool_info = tool_service.get_tool_info(tool_id, tenant_id)
|
||||
if tool_info:
|
||||
enriched_tools.append({
|
||||
enriched_tool = {
|
||||
"tool_id": tool_id,
|
||||
"operation": tool_config.get("operation"),
|
||||
"tool_info": tool_info
|
||||
})
|
||||
}
|
||||
if "operation" in tool_config:
|
||||
enriched_tool["operation"] = tool_config["operation"]
|
||||
enriched_tools.append(enriched_tool)
|
||||
skill.tools = enriched_tools
|
||||
|
||||
return skill
|
||||
|
||||
@@ -449,7 +449,7 @@ class WorkflowService:
|
||||
|
||||
input_data = {"message": payload.message, "variables": payload.variables,
|
||||
"conversation_id": payload.conversation_id,
|
||||
"files": [file.model_dump() for file in payload.files] if payload.files else []
|
||||
"files": [file.model_dump(mode='json') for file in payload.files]
|
||||
}
|
||||
|
||||
# 转换 conversation_id 为 UUID
|
||||
@@ -636,9 +636,10 @@ class WorkflowService:
|
||||
code=BizCode.CONFIG_MISSING,
|
||||
message=f"工作流配置不存在: app_id={app_id}"
|
||||
)
|
||||
|
||||
input_data = {"message": payload.message, "variables": payload.variables,
|
||||
"conversation_id": payload.conversation_id,
|
||||
"files": [file.model_dump() for file in payload.files] if payload.files else []
|
||||
"files": [file.model_dump(mode='json') for file in payload.files]
|
||||
}
|
||||
|
||||
# 转换 conversation_id 为 UUID
|
||||
|
||||
@@ -899,6 +899,8 @@ def update_workspace_models_configs(
|
||||
def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None:
|
||||
"""Ensure a workspace has a default memory config, creating one if missing.
|
||||
|
||||
Also fills empty model fields for all configs in this workspace.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace: The workspace to check
|
||||
@@ -911,28 +913,92 @@ def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None:
|
||||
MemoryConfig.is_default == True
|
||||
).first()
|
||||
|
||||
if existing_default:
|
||||
if not existing_default:
|
||||
# No default config exists, create one
|
||||
business_logger.info(
|
||||
f"Workspace {workspace.id} missing default memory config, creating one"
|
||||
)
|
||||
|
||||
try:
|
||||
_create_default_memory_config(
|
||||
db=db,
|
||||
workspace_id=workspace.id,
|
||||
workspace_name=workspace.name,
|
||||
llm_id=uuid.UUID(workspace.llm) if workspace.llm else None,
|
||||
embedding_id=uuid.UUID(workspace.embedding) if workspace.embedding else None,
|
||||
rerank_id=uuid.UUID(workspace.rerank) if workspace.rerank else None,
|
||||
)
|
||||
except Exception as e:
|
||||
business_logger.error(
|
||||
f"Failed to create default memory config for workspace {workspace.id}: {str(e)}"
|
||||
)
|
||||
|
||||
# Fill empty model fields for ALL configs in this workspace
|
||||
_fill_workspace_configs_model_defaults(db, workspace)
|
||||
|
||||
|
||||
def _fill_workspace_configs_model_defaults(
|
||||
db: Session,
|
||||
workspace: Workspace
|
||||
) -> None:
|
||||
"""Fill empty model fields for all memory configs in a workspace.
|
||||
|
||||
Updates llm_id, embedding_id, rerank_id, reflection_model_id, and emotion_model_id
|
||||
if they are None, using the corresponding workspace default models.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace: The workspace containing default model settings
|
||||
"""
|
||||
from app.models.memory_config_model import MemoryConfig
|
||||
|
||||
# Get all configs for this workspace
|
||||
configs = db.query(MemoryConfig).filter(
|
||||
MemoryConfig.workspace_id == workspace.id
|
||||
).all()
|
||||
|
||||
if not configs:
|
||||
return
|
||||
|
||||
# No default config exists, create one
|
||||
business_logger.info(
|
||||
f"Workspace {workspace.id} missing default memory config, creating one"
|
||||
)
|
||||
# Map of memory_config field -> workspace field
|
||||
model_field_mappings = [
|
||||
("llm_id", "llm"),
|
||||
("embedding_id", "embedding"),
|
||||
("rerank_id", "rerank"),
|
||||
("reflection_model_id", "llm"), # reflection uses LLM
|
||||
("emotion_model_id", "llm"), # emotion uses LLM
|
||||
]
|
||||
|
||||
try:
|
||||
_create_default_memory_config(
|
||||
db=db,
|
||||
workspace_id=workspace.id,
|
||||
workspace_name=workspace.name,
|
||||
llm_id=uuid.UUID(workspace.llm) if workspace.llm else None,
|
||||
embedding_id=uuid.UUID(workspace.embedding) if workspace.embedding else None,
|
||||
rerank_id=uuid.UUID(workspace.rerank) if workspace.rerank else None,
|
||||
)
|
||||
except Exception as e:
|
||||
business_logger.error(
|
||||
f"Failed to create default memory config for workspace {workspace.id}: {str(e)}"
|
||||
)
|
||||
# Don't fail the workspace list operation if config creation fails
|
||||
configs_updated = 0
|
||||
|
||||
for memory_config in configs:
|
||||
updated_fields = []
|
||||
|
||||
for config_field, workspace_field in model_field_mappings:
|
||||
config_value = getattr(memory_config, config_field, None)
|
||||
workspace_value = getattr(workspace, workspace_field, None)
|
||||
|
||||
if not config_value and workspace_value:
|
||||
setattr(memory_config, config_field, workspace_value)
|
||||
updated_fields.append(config_field)
|
||||
|
||||
if updated_fields:
|
||||
configs_updated += 1
|
||||
business_logger.debug(
|
||||
f"Updated memory config {memory_config.config_id} fields: {updated_fields}"
|
||||
)
|
||||
|
||||
if configs_updated > 0:
|
||||
try:
|
||||
db.commit()
|
||||
business_logger.info(
|
||||
f"Updated {configs_updated} memory configs in workspace {workspace.id} with default models"
|
||||
)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
business_logger.error(
|
||||
f"Failed to update memory configs in workspace {workspace.id}: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _create_default_memory_config(
|
||||
|
||||
@@ -40,6 +40,7 @@ from app.models.file_model import File
|
||||
from app.models.knowledge_model import Knowledge
|
||||
from app.schemas import file_schema, document_schema
|
||||
from app.services.memory_agent_service import MemoryAgentService
|
||||
from app.utils.config_utils import resolve_config_id
|
||||
|
||||
|
||||
@celery_app.task(name="tasks.process_item")
|
||||
@@ -905,7 +906,8 @@ def read_message_task(self, end_user_id: str, message: str, history: List[Dict[s
|
||||
actual_config_id = None
|
||||
if config_id:
|
||||
try:
|
||||
actual_config_id = uuid.UUID(config_id) if isinstance(config_id, str) else config_id
|
||||
with get_db_context() as db:
|
||||
actual_config_id = resolve_config_id(config_id, db)
|
||||
except (ValueError, AttributeError):
|
||||
# If conversion fails, leave as None and try to resolve
|
||||
pass
|
||||
@@ -981,14 +983,13 @@ def read_message_task(self, end_user_id: str, message: str, history: List[Dict[s
|
||||
|
||||
|
||||
@celery_app.task(name="app.core.memory.agent.write_message", bind=True)
|
||||
def write_message_task(self, end_user_id: str, message: str, config_id: str, storage_type: str, user_rag_memory_id: str,
|
||||
def write_message_task(self, end_user_id: str, message: list[dict], config_id: str | int, storage_type: str, user_rag_memory_id: str,
|
||||
language: str = "zh") -> Dict[str, Any]:
|
||||
"""Celery task to process a write message via MemoryAgentService.
|
||||
|
||||
Args:
|
||||
end_user_id: Group ID for the memory agent (also used as end_user_id)
|
||||
message: Message to write
|
||||
config_id: Configuration ID as string (will be converted to UUID)
|
||||
config_id: Configuration ID (can be UUID string, integer, or config_id_old)
|
||||
storage_type: Storage type (neo4j or rag)
|
||||
user_rag_memory_id: User RAG memory ID
|
||||
language: 语言类型 ("zh" 中文, "en" 英文)
|
||||
@@ -1002,24 +1003,28 @@ def write_message_task(self, end_user_id: str, message: str, config_id: str, sto
|
||||
from app.core.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
logger.info(
|
||||
f"[CELERY WRITE] Starting write task - end_user_id={end_user_id}, config_id={config_id}, storage_type={storage_type}, language={language}")
|
||||
logger.info(f"[CELERY WRITE] Starting write task - end_user_id={end_user_id}, config_id={config_id} (type: {type(config_id).__name__}), storage_type={storage_type}, language={language}")
|
||||
start_time = time.time()
|
||||
|
||||
# Convert config_id string to UUID
|
||||
# Convert config_id to UUID
|
||||
actual_config_id = None
|
||||
|
||||
if config_id:
|
||||
try:
|
||||
actual_config_id = uuid.UUID(config_id) if isinstance(config_id, str) else config_id
|
||||
with get_db_context() as db:
|
||||
actual_config_id = resolve_config_id(config_id, db)
|
||||
print(100*'-')
|
||||
print(actual_config_id)
|
||||
print(100*'-')
|
||||
logger.info(
|
||||
f"[CELERY WRITE] Converted config_id to UUID: {actual_config_id} (type: {type(actual_config_id).__name__})")
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.error(f"[CELERY WRITE] Invalid config_id format: {config_id}, error: {e}")
|
||||
logger.error(f"[CELERY WRITE] Invalid config_id format: {config_id} (type: {type(config_id).__name__}), error: {e}")
|
||||
return {
|
||||
"status": "FAILURE",
|
||||
"error": f"Invalid config_id format: {config_id}",
|
||||
"error": f"Invalid config_id format: {config_id} - {str(e)}",
|
||||
"end_user_id": end_user_id,
|
||||
"config_id": config_id,
|
||||
"config_id": str(config_id),
|
||||
"elapsed_time": 0.0,
|
||||
"task_id": self.request.id
|
||||
}
|
||||
|
||||
@@ -28,33 +28,7 @@ def resolve_config_id(config_id: UUID | int | str, db: Session) -> UUID:
|
||||
if isinstance(config_id, UUID):
|
||||
return config_id
|
||||
|
||||
# 2. 如果是字符串类型
|
||||
if isinstance(config_id, str):
|
||||
config_id_stripped = config_id.strip()
|
||||
|
||||
# 2.1 尝试解析为 UUID(标准 UUID 字符串长度为 36)
|
||||
try:
|
||||
return uuid_module.UUID(config_id_stripped)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 2.2 尝试解析为整数(用于查询 config_id_old)
|
||||
try:
|
||||
old_id = int(config_id_stripped)
|
||||
if old_id > 0:
|
||||
memory_config = db.query(MemoryConfig).filter(
|
||||
MemoryConfig.config_id_old == old_id
|
||||
).first()
|
||||
if not memory_config:
|
||||
raise ValueError(f"未找到 config_id_old={old_id} 对应的配置")
|
||||
return memory_config.config_id
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 2.3 无法解析的字符串格式
|
||||
raise ValueError(f"无效的 config_id 格式: '{config_id}'(必须是 UUID 或正整数)")
|
||||
|
||||
# 3. 如果是整数类型,通过 config_id_old 查找
|
||||
# 2. 如果是整数类型,通过 config_id_old 查找
|
||||
if isinstance(config_id, int):
|
||||
if config_id <= 0:
|
||||
raise ValueError(f"config_id 必须是正整数: {config_id}")
|
||||
@@ -67,6 +41,34 @@ def resolve_config_id(config_id: UUID | int | str, db: Session) -> UUID:
|
||||
raise ValueError(f"未找到 config_id_old={config_id} 对应的配置")
|
||||
|
||||
return memory_config.config_id
|
||||
|
||||
# 3. 如果是字符串类型
|
||||
if isinstance(config_id, str):
|
||||
config_id_stripped = config_id.strip()
|
||||
|
||||
# 3.1 先尝试解析为整数(用于查询 config_id_old)
|
||||
# 这样可以处理 "17" 这样的字符串
|
||||
try:
|
||||
old_id = int(config_id_stripped)
|
||||
if old_id > 0:
|
||||
memory_config = db.query(MemoryConfig).filter(
|
||||
MemoryConfig.config_id_old == old_id
|
||||
).first()
|
||||
if not memory_config:
|
||||
raise ValueError(f"未找到 config_id_old={old_id} 对应的配置")
|
||||
return memory_config.config_id
|
||||
except ValueError:
|
||||
# 不是整数,继续尝试 UUID
|
||||
pass
|
||||
|
||||
# 3.2 尝试解析为 UUID
|
||||
try:
|
||||
return uuid_module.UUID(config_id_stripped)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 3.3 无法解析的字符串格式
|
||||
raise ValueError(f"无效的 config_id 格式: '{config_id}'(必须是 UUID 或正整数)")
|
||||
|
||||
# 4. 不支持的类型
|
||||
raise ValueError(f"不支持的 config_id 类型: {type(config_id).__name__}")
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 13:59:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 13:59:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 16:24:05
|
||||
*/
|
||||
import { request, API_PREFIX } from '@/utils/request'
|
||||
|
||||
// Upload file,file storage has expiration period
|
||||
export const fileUploadUrl = `${API_PREFIX}/storage/files`
|
||||
export const fileUploadUrlWithoutApiPrefix = '/storage/files'
|
||||
export const fileUploadUrl = `${API_PREFIX}${fileUploadUrlWithoutApiPrefix}`
|
||||
export const fileUpload = (formData?: unknown) => {
|
||||
return request.uploadFile('/storage/files', formData)
|
||||
return request.uploadFile(fileUploadUrlWithoutApiPrefix, formData)
|
||||
}
|
||||
|
||||
// Get file access URL (no token required)
|
||||
@@ -30,4 +31,5 @@ export const deleteFile = (fileId: string) => {
|
||||
return request.delete(deleteFileUrl(fileId))
|
||||
}
|
||||
|
||||
export const shareFileUploadUrl = `${API_PREFIX}/storage/share/files`
|
||||
export const shareFileUploadUrlWithoutApiPrefix = `/storage/share/files`
|
||||
export const shareFileUploadUrl = `${API_PREFIX}${shareFileUploadUrlWithoutApiPrefix}`
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:23:37
|
||||
* @Last Modified time: 2026-02-10 12:13:52
|
||||
*/
|
||||
import { type FC, useEffect, useMemo } from 'react'
|
||||
import { Flex, Input, Form } from 'antd'
|
||||
@@ -60,13 +60,14 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
}, [fileList])
|
||||
|
||||
const handleSend = () => {
|
||||
if (loading || !values || !values?.message || values?.message?.trim() === '') return
|
||||
onSend(values.message)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rb:absolute rb:bottom-3 rb:left-0 rb:right-0 rb:w-full ${className}`}>
|
||||
<Flex vertical justify="space-between" className="rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:min-h-30">
|
||||
{previewFileList.length > 0 && <Flex gap={14} className="rb:mx-3! rb:mt-3!">
|
||||
{previewFileList.length > 0 && <div className="rb:overflow-x-auto rb:max-w-full"><Flex gap={14} className="rb:mx-3! rb:mt-3! rb:w-max!">
|
||||
{previewFileList.map((file) => {
|
||||
if (file.type.includes('image')) {
|
||||
return (
|
||||
@@ -101,7 +102,7 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>}
|
||||
</Flex></div>}
|
||||
{/* Message input form */}
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="message" noStyle>
|
||||
|
||||
@@ -766,6 +766,9 @@ export const en = {
|
||||
toWorkspace: 'Authorization to workspace',
|
||||
shareTitle:'Please select the workspace you want to share',
|
||||
shareNote:'Note: Sharing is not possible when workspace app is closed',
|
||||
shareSpace:'Manage Sharing',
|
||||
shareSpaceTitle:'Shared with the following workspaces',
|
||||
shareSpaceNote: 'Note: sharing is turned off, others will no longer have access.',
|
||||
authorizedPerson:'Authorized person',
|
||||
chunkList:'Chunk List',
|
||||
delimiter:'Text paragraph delimiter',
|
||||
@@ -1588,6 +1591,11 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
creating_nodes_edges_desc: 'Entity relationship creation completed, {{num}} relationships in total',
|
||||
deduplication_desc: 'Deduplication and disambiguation completed, {{count}} unique entities in total',
|
||||
custom_text: 'Debug Text',
|
||||
ontologyCoverage: 'Ontology Type',
|
||||
entity_total: 'Total {{num}} entities',
|
||||
scene_type_distribution: 'Scene Type Distribution',
|
||||
general_type_distribution: 'General Type Distribution',
|
||||
unmatched: 'Unmatched',
|
||||
},
|
||||
memoryConversation: {
|
||||
searchPlaceholder: 'Enter user ID...',
|
||||
@@ -2105,7 +2113,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
search_switch: 'Search Mode',
|
||||
},
|
||||
'memory-write': {
|
||||
message: 'Message',
|
||||
messages: 'Message',
|
||||
config_id: 'Memory Configuration',
|
||||
search_switch: 'Search Mode',
|
||||
},
|
||||
@@ -2446,6 +2454,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
involved_objects: 'Involved Objects',
|
||||
content_records: 'Episode Content Records',
|
||||
emotion: 'Emotion and State Records',
|
||||
none: 'None',
|
||||
},
|
||||
implicitDetail: {
|
||||
title: 'The invisible forces that shaped me',
|
||||
|
||||
@@ -305,6 +305,9 @@ export const zh = {
|
||||
toWorkspace: '授权到工作空间',
|
||||
shareTitle: '请选择要分享的工作空间',
|
||||
shareNote: '注意:工作空间应用关闭时无法分享',
|
||||
shareSpace:'管理共享',
|
||||
shareSpaceTitle:'已共享至以下工作空间',
|
||||
shareSpaceNote: '注意:关闭共享后对方将无法访问',
|
||||
authorizedPerson: '授权人',
|
||||
chunkList: '分块列表',
|
||||
delimiter: '文本段落分隔符',
|
||||
@@ -1668,6 +1671,11 @@ export const zh = {
|
||||
creating_nodes_edges_desc: '实体关系创建完成,共{{num}}条关系',
|
||||
deduplication_desc: '去重消歧完成,最终{{count}}个唯一实体',
|
||||
custom_text: '调试文本',
|
||||
ontologyCoverage: '本体类型',
|
||||
entity_total: '一共{{num}}个实体',
|
||||
scene_type_distribution: '场景类型',
|
||||
general_type_distribution: '通用类型',
|
||||
unmatched: '未匹配',
|
||||
},
|
||||
memoryConversation: {
|
||||
chatEmpty:'有什么我可以帮您的吗?',
|
||||
@@ -2200,7 +2208,7 @@ export const zh = {
|
||||
search_switch: '检索模式',
|
||||
},
|
||||
'memory-write': {
|
||||
message: '消息',
|
||||
messages: '消息',
|
||||
config_id: '记忆配置',
|
||||
search_switch: '检索模式',
|
||||
},
|
||||
@@ -2541,6 +2549,7 @@ export const zh = {
|
||||
involved_objects: '涉及对象',
|
||||
content_records: '情景内容记录',
|
||||
emotion: '情绪与状态记录',
|
||||
none: '无',
|
||||
},
|
||||
implicitDetail: {
|
||||
title: '那些塑造了我的无形力量',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 11:20:14
|
||||
* @Last Modified time: 2026-02-09 16:56:27
|
||||
*/
|
||||
import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import clsx from 'clsx'
|
||||
@@ -210,7 +210,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
})
|
||||
if (default_model_config_id === values?.default_model_config_id) {
|
||||
setChatList([{
|
||||
label: vo.label || '',
|
||||
label: defaultModel?.id === default_model_config_id && defaultModel?.name ? defaultModel.name : vo.label || '',
|
||||
model_config_id: default_model_config_id || '',
|
||||
model_parameters: {...rest},
|
||||
list: []
|
||||
@@ -284,11 +284,19 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
...(item.config || {})
|
||||
}))
|
||||
} as KnowledgeConfig : null,
|
||||
tools: tools.map(vo => ({
|
||||
tool_id: vo.tool_id,
|
||||
operation: vo.operation,
|
||||
enabled: vo.enabled
|
||||
})),
|
||||
tools: tools.map(vo => {
|
||||
if (!vo.operation) {
|
||||
return {
|
||||
tool_id: vo.tool_id,
|
||||
enabled: vo.enabled
|
||||
}
|
||||
}
|
||||
return {
|
||||
tool_id: vo.tool_id,
|
||||
operation: vo.operation,
|
||||
enabled: vo.enabled
|
||||
}
|
||||
}),
|
||||
skills: {
|
||||
...skills,
|
||||
skill_ids: (skills?.skill_ids as Skill[])?.map(vo => vo.id)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:27:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-10 12:18:23
|
||||
*/
|
||||
/**
|
||||
* Chat debugging component for application testing
|
||||
@@ -61,6 +61,8 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
|
||||
useEffect(() => {
|
||||
setIsCluster(source === 'multi_agent')
|
||||
setFileList([])
|
||||
setMessage(undefined)
|
||||
}, [source])
|
||||
|
||||
/** Add user message to all chat lists */
|
||||
@@ -388,7 +390,6 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
setFileList([...list || []])
|
||||
}
|
||||
|
||||
console.log('chatList', chatList, fileList)
|
||||
return (
|
||||
<div className="rb:relative rb:h-full rb:flex rb:flex-col">
|
||||
{chatList.length === 0
|
||||
@@ -424,9 +425,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
}
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mx-[16px] rb:pt-[24px]': true,
|
||||
'rb:h-[calc(100vh-258px)]': isCluster,
|
||||
'rb:h-[calc(100vh-356px)]': !isCluster,
|
||||
'rb:mx-[16px] rb:mt-6': true,
|
||||
'rb:h-[calc(100vh-282px)]': isCluster,
|
||||
'rb:h-[calc(100vh-380px)]': !isCluster,
|
||||
}}
|
||||
contentClassNames={{
|
||||
'rb:max-w-[400px]!': chatList.length === 1,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 11:10:16
|
||||
* @Last Modified time: 2026-02-09 13:52:22
|
||||
*/
|
||||
/**
|
||||
* Application Management Page
|
||||
@@ -83,9 +83,9 @@ const ApplicationManagement: React.FC = () => {
|
||||
setQuery(prev => ({...prev, type: value}))
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
uploadWorkflowModalRef.current?.handleOpen()
|
||||
}
|
||||
// const handleImport = () => {
|
||||
// uploadWorkflowModalRef.current?.handleOpen()
|
||||
// }
|
||||
return (
|
||||
<>
|
||||
<Row gutter={16} className="rb:mb-4">
|
||||
@@ -111,9 +111,9 @@ const ApplicationManagement: React.FC = () => {
|
||||
</Col>
|
||||
<Col span={12} className="rb:text-right">
|
||||
<Space size={12}>
|
||||
<Button onClick={handleImport}>
|
||||
{/* <Button onClick={handleImport}>
|
||||
{t('application.importWorkflow')}
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button type="primary" onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:09:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 16:41:31
|
||||
*/
|
||||
/**
|
||||
* File Upload Component
|
||||
@@ -25,8 +25,8 @@ import { Upload, Progress, App } from 'antd';
|
||||
import type { UploadProps, UploadFile } from 'antd';
|
||||
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
import { fileUploadUrl } from '@/api/fileStorage'
|
||||
import { request } from '@/utils/request'
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
|
||||
interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
/** Upload API endpoint */
|
||||
@@ -99,7 +99,7 @@ export interface UploadFilesRef {
|
||||
* Supports single/multiple file uploads, drag-and-drop, file validation, and preview
|
||||
*/
|
||||
const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
action = fileUploadUrl,
|
||||
action = fileUploadUrlWithoutApiPrefix,
|
||||
multiple = false,
|
||||
fileList: propFileList = [],
|
||||
onChange,
|
||||
@@ -110,6 +110,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
maxCount = 1,
|
||||
onRemove: customOnRemove,
|
||||
update,
|
||||
requestConfig,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -163,6 +164,24 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
return isAutoUpload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom upload request handler
|
||||
*/
|
||||
const handleCustomRequest: RcUploadProps['customRequest'] = async (options) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await request.uploadFile(action, formData, requestConfig);
|
||||
|
||||
onSuccess?.({data: response});
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles upload state changes
|
||||
*/
|
||||
@@ -207,13 +226,10 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
|
||||
// Generate upload component configuration
|
||||
const uploadProps: UploadProps = {
|
||||
action,
|
||||
customRequest: handleCustomRequest,
|
||||
multiple: multiple && maxCount > 1,
|
||||
fileList,
|
||||
beforeUpload,
|
||||
headers: {
|
||||
authorization: `Bearer ${cookieUtils.get('authToken')}`,
|
||||
},
|
||||
onChange: handleChange,
|
||||
accept,
|
||||
disabled,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:09:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 10:17:54
|
||||
*/
|
||||
/**
|
||||
* Upload File List Modal Component
|
||||
@@ -19,8 +19,7 @@
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, Select, Button, Space } from 'antd';
|
||||
import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Select, Button, Flex } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UploadFileListModalRef } from '../types'
|
||||
@@ -95,11 +94,12 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
<>
|
||||
{/* Render each file entry with type selector and URL input */}
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex' }} align="baseline">
|
||||
<Flex key={key} gap={8} align="center" className="rb:mb-3!">
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'type']}
|
||||
initialValue="image"
|
||||
className="rb:mb-0!"
|
||||
>
|
||||
<Select
|
||||
placeholder={t('memoryConversation.fileType')}
|
||||
@@ -113,15 +113,19 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
{...restField}
|
||||
name={[name, 'url']}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
className="rb:mb-0!"
|
||||
>
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} className="rb:w-82.5" />
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} className="rb:w-82.5!" />
|
||||
</FormItem>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} style={{ marginTop: 30 }} />
|
||||
</Space>
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
|
||||
onClick={() => remove(name)}
|
||||
></div>
|
||||
</Flex>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
{t('common.add')}
|
||||
<Form.Item noStyle>
|
||||
<Button type="dashed" onClick={() => add()} block>
|
||||
+ {t('common.add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:58:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:11:23
|
||||
* @Last Modified time: 2026-02-09 20:20:01
|
||||
*/
|
||||
/**
|
||||
* Conversation Page
|
||||
@@ -35,7 +35,7 @@ import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFuncti
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import UploadFiles from './components/FileUpload'
|
||||
// import AudioRecorder from '@/components/AudioRecorder'
|
||||
import { shareFileUploadUrl } from '@/api/fileStorage'
|
||||
import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import UploadFileListModal from './components/UploadFileListModal'
|
||||
|
||||
/**
|
||||
@@ -350,11 +350,16 @@ const Conversation: FC = () => {
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
action={shareFileUploadUrl}
|
||||
action={shareFileUploadUrlWithoutApiPrefix}
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
fileList={[]}
|
||||
update={update}
|
||||
requestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
} }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-04 18:34:36
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-04 18:49:59
|
||||
* @Last Modified time: 2026-02-09 15:46:07
|
||||
*/
|
||||
import { useEffect, type FC } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
import { useI18n } from '@/store/locale'
|
||||
|
||||
/**
|
||||
* JumpPage Component
|
||||
@@ -26,11 +27,17 @@ import { cookieUtils } from '@/utils/request'
|
||||
const JumpPage: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { changeLanguage } = useI18n()
|
||||
|
||||
useEffect(() => {
|
||||
// Convert URLSearchParams to a plain object for easier access
|
||||
const data = Object.fromEntries(searchParams)
|
||||
const { access_token, refresh_token, target } = data
|
||||
const { access_token, refresh_token, target, language } = data
|
||||
|
||||
if (language) {
|
||||
changeLanguage(language)
|
||||
cookieUtils.set('language', language)
|
||||
}
|
||||
|
||||
// Store authentication tokens in cookies for API authorization
|
||||
cookieUtils.set('authToken', access_token)
|
||||
|
||||
@@ -33,8 +33,8 @@ const DocumentDetails: FC = () => {
|
||||
documentId,
|
||||
parentId: locationParentId,
|
||||
breadcrumbPath
|
||||
} = location.state as {
|
||||
documentId: string;
|
||||
} = (location.state || {}) as {
|
||||
documentId?: string;
|
||||
parentId?: string;
|
||||
breadcrumbPath?: BreadcrumbPath;
|
||||
};
|
||||
@@ -51,6 +51,18 @@ const DocumentDetails: FC = () => {
|
||||
const insertModalRef = useRef<InsertModalRef>(null);
|
||||
const isManualRefreshRef = useRef(false);
|
||||
|
||||
// Early return if no documentId
|
||||
if (!documentId) {
|
||||
return (
|
||||
<div className="rb:flex rb:items-center rb:justify-center rb:h-full rb:flex-col rb:gap-4">
|
||||
<div className="rb:text-gray-500">{t('knowledgeBase.documentIdRequired') || '文档ID不能为空'}</div>
|
||||
<Button type="primary" onClick={() => navigate(-1)}>
|
||||
{t('common.back') || '返回'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (documentId) {
|
||||
fetchDocumentDetail();
|
||||
|
||||
@@ -46,6 +46,16 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
const entityTypes = graphragConfig?.entity_types || '';
|
||||
const entityNormalization = graphragConfig?.resolution || false;
|
||||
const communityReportGeneration = graphragConfig?.community || false;
|
||||
|
||||
// Watch for changes to _third_party_platform field directly
|
||||
const formThirdPartyPlatform = Form.useWatch(['parser_config', '_third_party_platform'], form);
|
||||
|
||||
// Sync form value to state when form value changes
|
||||
useEffect(() => {
|
||||
if (formThirdPartyPlatform && (formThirdPartyPlatform === 'yuque' || formThirdPartyPlatform === 'feishu')) {
|
||||
setThirdPartyPlatform(formThirdPartyPlatform);
|
||||
}
|
||||
}, [formThirdPartyPlatform]);
|
||||
|
||||
// Encapsulate cancel method, add close modal logic
|
||||
const handleClose = () => {
|
||||
@@ -199,6 +209,8 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
type: type || currentType,
|
||||
};
|
||||
form.setFieldsValue(defaults);
|
||||
// Reset third party platform to default when creating new
|
||||
setThirdPartyPlatform('yuque');
|
||||
return;
|
||||
}
|
||||
const baseValues: Partial<KnowledgeBaseFormData> = {
|
||||
@@ -210,7 +222,6 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
};
|
||||
|
||||
// Process parser_config data, set default values if not present
|
||||
const recordAny = record as any;
|
||||
baseValues.parser_config = {
|
||||
...record.parser_config,
|
||||
graphrag: {
|
||||
@@ -224,43 +235,6 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
}
|
||||
};
|
||||
|
||||
// Add Third-party specific fields to parser_config if exists
|
||||
if (recordAny.parser_config?.third_party_platform) {
|
||||
baseValues.parser_config.third_party_platform = recordAny.parser_config.third_party_platform;
|
||||
}
|
||||
if (recordAny.parser_config?.yuque_user_id) {
|
||||
baseValues.parser_config.yuque_user_id = recordAny.parser_config.yuque_user_id;
|
||||
}
|
||||
if (recordAny.parser_config?.yuque_token) {
|
||||
baseValues.parser_config.yuque_token = recordAny.parser_config.yuque_token;
|
||||
}
|
||||
if (recordAny.parser_config?.app_id) {
|
||||
baseValues.parser_config.app_id = recordAny.parser_config.app_id;
|
||||
}
|
||||
if (recordAny.parser_config?.app_secret) {
|
||||
baseValues.parser_config.app_secret = recordAny.parser_config.app_secret;
|
||||
}
|
||||
if (recordAny.parser_config?.folder_token) {
|
||||
baseValues.parser_config.folder_token = recordAny.parser_config.folder_token;
|
||||
}
|
||||
|
||||
// Add Web specific fields to parser_config if exists
|
||||
if (recordAny.parser_config?.entry_url) {
|
||||
baseValues.parser_config.entry_url = recordAny.parser_config.entry_url;
|
||||
}
|
||||
if (recordAny.parser_config?.max_pages) {
|
||||
baseValues.parser_config.max_pages = recordAny.parser_config.max_pages;
|
||||
}
|
||||
if (recordAny.parser_config?.delay_seconds) {
|
||||
baseValues.parser_config.delay_seconds = recordAny.parser_config.delay_seconds;
|
||||
}
|
||||
if (recordAny.parser_config?.timeout_seconds) {
|
||||
baseValues.parser_config.timeout_seconds = recordAny.parser_config.timeout_seconds;
|
||||
}
|
||||
if (recordAny.parser_config?.user_agent) {
|
||||
baseValues.parser_config.user_agent = recordAny.parser_config.user_agent;
|
||||
}
|
||||
|
||||
// If entity_types exists, convert to newline-separated format for TextArea display
|
||||
if (baseValues.parser_config.graphrag.entity_types) {
|
||||
if (Array.isArray(baseValues.parser_config.graphrag.entity_types)) {
|
||||
@@ -272,7 +246,18 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
}
|
||||
}
|
||||
|
||||
// Set form values first
|
||||
form.setFieldsValue(baseValues);
|
||||
|
||||
// Then sync third party platform state from form value
|
||||
// This ensures the state matches what's actually in the form
|
||||
const platform = baseValues.parser_config?._third_party_platform;
|
||||
if (platform === 'yuque' || platform === 'feishu') {
|
||||
setThirdPartyPlatform(platform);
|
||||
} else {
|
||||
// Reset to default if no platform specified
|
||||
setThirdPartyPlatform('yuque');
|
||||
}
|
||||
};
|
||||
|
||||
const setDynamicModelFields = (record: KnowledgeBaseListItem | null, types: string[]) => {
|
||||
@@ -295,20 +280,17 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
setDatasets(record || null);
|
||||
|
||||
// If rebuild mode, use record's actual type, otherwise use passed type
|
||||
const actualType = type === 'rebuild' ? (record?.type || 'General') : (type || currentType);
|
||||
// If editing (record exists but no type passed), use record's type
|
||||
const actualType = type === 'rebuild'
|
||||
? (record?.type || 'General')
|
||||
: (type || record?.type || currentType);
|
||||
|
||||
setCurrentType(actualType as any);
|
||||
setIsRebuildMode(type === 'rebuild'); // Set rebuild mode flag
|
||||
setOriginalType(type || ''); // Save original type parameter
|
||||
|
||||
// Set third party platform if editing Third-party type
|
||||
if (actualType === 'Third-party' && record) {
|
||||
const platform = (record as any).parser_config?.third_party_platform;
|
||||
if (platform === 'yuque' || platform === 'feishu') {
|
||||
setThirdPartyPlatform(platform);
|
||||
}
|
||||
} else {
|
||||
setThirdPartyPlatform('yuque'); // Reset to default
|
||||
}
|
||||
// Note: third party platform state will be set in setBaseFields function
|
||||
// No need to set it here separately to avoid inconsistency
|
||||
|
||||
// If rebuild mode, default to knowledge graph tab
|
||||
if (type === 'rebuild') {
|
||||
@@ -336,9 +318,13 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setBaseFields(datasets, currentType);
|
||||
setDynamicModelFields(datasets, modelTypeList);
|
||||
}, [visible, datasets, currentType, modelTypeList]);
|
||||
// Only set fields when modal becomes visible, not on every state change
|
||||
// setBaseFields is already called in handleOpen
|
||||
// This useEffect is mainly for syncing dynamic model fields
|
||||
if (datasets && modelTypeList.length > 0) {
|
||||
setDynamicModelFields(datasets, modelTypeList);
|
||||
}
|
||||
}, [visible, modelTypeList]);
|
||||
|
||||
// Encapsulate save method, add submit logic
|
||||
const handleSave = () => {
|
||||
@@ -382,7 +368,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
|
||||
// Check Third-party authentication before saving
|
||||
if (formValues.type === 'Third-party' || currentType === 'Third-party') {
|
||||
const platform = formValues.parser_config?.third_party_platform || thirdPartyPlatform;
|
||||
const platform = formValues.parser_config?._third_party_platform || thirdPartyPlatform;
|
||||
|
||||
try {
|
||||
if (platform === 'yuque') {
|
||||
@@ -404,12 +390,12 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
} else if (platform === 'feishu') {
|
||||
// Validate Feishu credentials
|
||||
const feishuParams = {
|
||||
app_id: formValues.parser_config?.app_id,
|
||||
app_secret: formValues.parser_config?.app_secret,
|
||||
folder_token: formValues.parser_config?.folder_token
|
||||
feishu_app_id: formValues.parser_config?.feishu_app_id,
|
||||
feishu_app_secret: formValues.parser_config?.feishu_app_secret,
|
||||
feishu_folder_token: formValues.parser_config?.feishu_folder_token
|
||||
};
|
||||
|
||||
if (!feishuParams.app_id || !feishuParams.app_secret || !feishuParams.folder_token) {
|
||||
if (!feishuParams.feishu_app_id || !feishuParams.feishu_app_secret || !feishuParams.feishu_folder_token) {
|
||||
messageApi.error(t('knowledgeBase.feishuAuthRequired'));
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -533,7 +519,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
{ type: 'url', message: t('knowledgeBase.createForm.entryUrlInvalid') }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="https://ai.redbearai.com" />
|
||||
<Input placeholder="https://ai.redbearai.com" disabled={!!datasets?.id} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -545,7 +531,8 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
<SliderInput
|
||||
min={10}
|
||||
max={200}
|
||||
step={1}
|
||||
step={10}
|
||||
disabled={!!datasets?.id}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -553,12 +540,13 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
name={['parser_config', 'delay_seconds']}
|
||||
label={t('knowledgeBase.createForm.delaySeconds')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.delaySecondsRequired') }]}
|
||||
initialValue={1.0}
|
||||
initialValue={2}
|
||||
>
|
||||
<SliderInput
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
step={1}
|
||||
disabled={!!datasets?.id}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -572,6 +560,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
min={5}
|
||||
max={15}
|
||||
step={1}
|
||||
disabled={!!datasets?.id}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -581,7 +570,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.userAgentRequired') }]}
|
||||
initialValue="KnowledgeBaseCrawler/1.0"
|
||||
>
|
||||
<Input placeholder="KnowledgeBaseCrawler/1.0" />
|
||||
<Input placeholder="KnowledgeBaseCrawler/1.0" disabled={!!datasets?.id} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
@@ -590,14 +579,14 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
{currentType === 'Third-party' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={['parser_config', 'third_party_platform']}
|
||||
name={['parser_config', '_third_party_platform']}
|
||||
label={t('knowledgeBase.createForm.platform')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.platformRequired') }]}
|
||||
initialValue="yuque"
|
||||
>
|
||||
<Select
|
||||
value={thirdPartyPlatform}
|
||||
onChange={(value) => setThirdPartyPlatform(value)}
|
||||
disabled={!!datasets?.id}
|
||||
options={[
|
||||
{ value: 'yuque', label: t('knowledgeBase.createForm.yuque') },
|
||||
{ value: 'feishu', label: t('knowledgeBase.createForm.feishu') }
|
||||
@@ -612,7 +601,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
label={t('knowledgeBase.createForm.yuqueUserId')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.yuqueUserIdRequired') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.createForm.yuqueUserIdPlaceholder')} />
|
||||
<Input placeholder={t('knowledgeBase.createForm.yuqueUserIdPlaceholder')} disabled={!!datasets?.id} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -620,7 +609,7 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
label={t('knowledgeBase.createForm.yuqueToken')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.yuqueTokenRequired') }]}
|
||||
>
|
||||
<Input.Password placeholder={t('knowledgeBase.createForm.yuqueTokenPlaceholder')} />
|
||||
<Input.Password placeholder={t('knowledgeBase.createForm.yuqueTokenPlaceholder')} disabled={!!datasets?.id} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
@@ -628,27 +617,27 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
{thirdPartyPlatform === 'feishu' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={['parser_config', 'app_id']}
|
||||
name={['parser_config', 'feishu_app_id']}
|
||||
label={t('knowledgeBase.createForm.feishuAppId')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.feishuAppIdRequired') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.createForm.feishuAppIdPlaceholder')} />
|
||||
<Input placeholder={t('knowledgeBase.createForm.feishuAppIdPlaceholder')} disabled={!!datasets?.id} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['parser_config', 'app_secret']}
|
||||
name={['parser_config', 'feishu_app_secret']}
|
||||
label={t('knowledgeBase.createForm.feishuAppSecret')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.feishuAppSecretRequired') }]}
|
||||
>
|
||||
<Input.Password placeholder={t('knowledgeBase.createForm.feishuAppSecretPlaceholder')} />
|
||||
<Input.Password placeholder={t('knowledgeBase.createForm.feishuAppSecretPlaceholder')} disabled={!!datasets?.id} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={['parser_config', 'folder_token']}
|
||||
name={['parser_config', 'feishu_folder_token']}
|
||||
label={t('knowledgeBase.createForm.feishuFolderToken')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.feishuFolderTokenRequired') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.createForm.feishuFolderTokenPlaceholder')} />
|
||||
<Input placeholder={t('knowledgeBase.createForm.feishuFolderTokenPlaceholder')} disabled={!!datasets?.id} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NoData } from './noData';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
import RbMarkdown from '@/components/Markdown';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface RecallTestResultProps {
|
||||
data: RecallTestData[];
|
||||
@@ -61,6 +62,36 @@ const RecallTestResult = ({
|
||||
return `**${t('knowledgeBase.question')}:** ${question}\n**${t('knowledgeBase.answer')}:** ${answer}`;
|
||||
};
|
||||
|
||||
// Check if content is valid HTML
|
||||
const isValidHTML = (content: string): boolean => {
|
||||
if (!content) return false;
|
||||
// Check if content contains HTML tags
|
||||
const htmlTagPattern = /<[^>]+>/;
|
||||
return htmlTagPattern.test(content);
|
||||
};
|
||||
|
||||
// Render content with HTML or Markdown fallback
|
||||
const renderTextContent = useMemo(() => {
|
||||
return (content: string) => {
|
||||
// Try to render as HTML first
|
||||
if (isValidHTML(content)) {
|
||||
try {
|
||||
return (
|
||||
<div
|
||||
className='rb:prose rb:prose-sm rb:max-w-none'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('HTML parsing failed, falling back to Markdown:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Markdown rendering
|
||||
return <RbMarkdown content={content} showHtmlComments={true} />;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleItemClick = (e: React.MouseEvent, item: RecallTestData, index: number) => {
|
||||
// Check if the click is on an image or image-related element
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -100,6 +131,20 @@ const RecallTestResult = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Show skeleton when initial loading
|
||||
if (loading && data.length === 0) {
|
||||
return (
|
||||
<div className='rb:flex rb:flex-col'>
|
||||
<div className='rb:flex rb:items-center rb:justify-start rb:gap-2 rb:mb-4'>
|
||||
<span className='rb:text-lg rb:font-medium'>{t('knowledgeBase.recallResult')}</span>
|
||||
</div>
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
<Skeleton active paragraph={{ rows: 3 }} className='rb:mt-4' />
|
||||
<Skeleton active paragraph={{ rows: 3 }} className='rb:mt-4' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0 && showEmpty) {
|
||||
return (
|
||||
<NoData
|
||||
@@ -153,9 +198,9 @@ const RecallTestResult = ({
|
||||
const qaContent = parseQAContent(item.page_content);
|
||||
if (qaContent) {
|
||||
const formattedContent = formatQAContent(qaContent.question, qaContent.answer);
|
||||
return <RbMarkdown content={formattedContent} showHtmlComments={true} />;
|
||||
return renderTextContent(formattedContent);
|
||||
}
|
||||
return <RbMarkdown content={item.page_content} showHtmlComments={true} />;
|
||||
return renderTextContent(item.page_content);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-10 18:52:55
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-02-03 17:08:00
|
||||
* @LastEditTime: 2026-02-10 15:18:32
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
@@ -93,7 +93,7 @@ const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare:
|
||||
<>
|
||||
{contextHolder}
|
||||
<RbModal
|
||||
title={t('knowledgeBase.toWorkspace')}
|
||||
title={t('knowledgeBase.shareSpace')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('knowledgeBase.share')}
|
||||
@@ -101,8 +101,8 @@ const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare:
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<div className='rb:flex rb:flex-col rb:text-left'>
|
||||
<h4 className='rb:text-sm rb:font-medium rb:text-gray-800'>{t('knowledgeBase.shareTitle')}</h4>
|
||||
<span className='rb:text-xs rb:text-gray-500'>{t('knowledgeBase.shareNote')}</span>
|
||||
<h4 className='rb:text-sm rb:font-medium rb:text-gray-800'>{t('knowledgeBase.shareSpaceTitle')}</h4>
|
||||
<span className='rb:text-xs rb:text-gray-500'>{t('knowledgeBase.shareSpaceNote')}</span>
|
||||
<div className='rb:flex rb:flex-col rb:text-left rb:gap-4 rb:mt-4 '>
|
||||
{spaceList.length === 0 && (
|
||||
<NoData />
|
||||
|
||||
@@ -105,14 +105,14 @@ export interface ParserConfig {
|
||||
user_agent?: string; // 用户代理
|
||||
|
||||
// Third-party 类型特有字段
|
||||
third_party_platform?: 'yuque' | 'feishu'; // 第三方平台类型
|
||||
_third_party_platform?: 'yuque' | 'feishu'; // 第三方平台类型
|
||||
// 语雀字段
|
||||
yuque_user_id?: string; // 语雀用户ID
|
||||
yuque_token?: string; // 语雀Token
|
||||
// 飞书字段
|
||||
app_id?: string; // 飞书应用ID
|
||||
app_secret?: string; // 飞书应用密钥
|
||||
folder_token?: string; // 飞书文件夹Token
|
||||
feishu_app_id?: string; // 飞书应用ID
|
||||
feishu_app_secret?: string; // 飞书应用密钥
|
||||
feishu_folder_token?: string; // 飞书文件夹Token
|
||||
}
|
||||
// 文件数据
|
||||
export interface KnowledgeBaseDocumentData { // 知识库文档数据
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:30:11
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-04 10:08:49
|
||||
* @Last Modified time: 2026-02-09 21:04:14
|
||||
*/
|
||||
/**
|
||||
* Result Component
|
||||
@@ -21,7 +21,7 @@ import type { AnyObject } from 'antd/es/_util/type';
|
||||
import Card from './Card'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import RbAlert from '@/components/RbAlert'
|
||||
import type { TestResult } from '../types'
|
||||
import type { TestResult, OntologyCoverage } from '../types'
|
||||
import { pilotRunMemoryExtractionConfig } from '@/api/memory'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import Tag, { type TagProps } from '@/components/Tag'
|
||||
@@ -78,6 +78,7 @@ const Result: FC<ResultProps> = ({ loading, handleSave }) => {
|
||||
const [knowledgeExtraction, setKnowledgeExtraction] = useState<ModuleItem>(initObj as ModuleItem)
|
||||
const [creatingNodesEdges, setCreatingNodesEdges] = useState<ModuleItem>(initObj as ModuleItem)
|
||||
const [deduplication, setDeduplication] = useState<ModuleItem>(initObj as ModuleItem)
|
||||
const [ontologyCoverage, setOntologyCoverage] = useState<OntologyCoverage>({} as OntologyCoverage)
|
||||
|
||||
const [runForm] = Form.useForm()
|
||||
|
||||
@@ -181,6 +182,7 @@ const Result: FC<ResultProps> = ({ loading, handleSave }) => {
|
||||
break
|
||||
case 'result': // Result
|
||||
setTestResult(data.data?.extracted_result)
|
||||
setOntologyCoverage(data.data?.ontology_coverage)
|
||||
break
|
||||
}
|
||||
})
|
||||
@@ -284,8 +286,8 @@ const Result: FC<ResultProps> = ({ loading, handleSave }) => {
|
||||
headerType="borderL"
|
||||
headerClassName="rb:before:bg-[#155EEF]!"
|
||||
>
|
||||
{knowledgeExtraction.data.map(vo =>
|
||||
<div key={vo.statement_index} className="rb:mb-3 rb:text-[12px] rb:text-[#5B6167] rb:leading-4 rb:font-regular">{vo.statement}</div>
|
||||
{knowledgeExtraction.data.map((vo, index) =>
|
||||
<div key={index} className="rb:mb-3 rb:text-[12px] rb:text-[#5B6167] rb:leading-4 rb:font-regular">{vo.statement}</div>
|
||||
)}
|
||||
{formatTime(knowledgeExtraction)}
|
||||
{knowledgeExtraction.result && <RbAlert color="blue" icon={<CheckCircleFilled />} className="rb:mt-3">
|
||||
@@ -450,6 +452,36 @@ const Result: FC<ResultProps> = ({ loading, handleSave }) => {
|
||||
</RbAlert>
|
||||
</RbCard>
|
||||
}
|
||||
{ontologyCoverage && Object.keys(ontologyCoverage).length > 0 &&
|
||||
<RbCard
|
||||
title={<>{t('memoryExtractionEngine.ontologyCoverage')}({ontologyCoverage.total_entities})</>}
|
||||
headerType="borderL"
|
||||
headerClassName="rb:before:bg-[#369F21]!"
|
||||
>
|
||||
<div className="rb:grid rb:grid-cols-2 rb:gap-3">
|
||||
{(['scene_type_distribution', 'general_type_distribution', 'unmatched'] as const).map((key, idx) => {
|
||||
if (!ontologyCoverage[key]) return null
|
||||
return (
|
||||
<div key={idx} className="rb:text-[12px]">
|
||||
<div className="rb:text-[#369F21] rb:font-medium">{t(`memoryExtractionEngine.${key}`)}({ontologyCoverage[key].type_count})</div>
|
||||
<div>{t('memoryExtractionEngine.entity_total', { num: ontologyCoverage[key].entity_total })}</div>
|
||||
<div>
|
||||
{ontologyCoverage[key].types.map((type, index) => {
|
||||
if (!type.type || type.type === '') return null
|
||||
return (
|
||||
<div key={index} className="rb:text-[#5B6167] rb:font-regular rb:leading-4">
|
||||
-{type.type}({type.count})
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</RbCard>
|
||||
}
|
||||
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:29:55
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 17:29:55
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 20:56:31
|
||||
*/
|
||||
/**
|
||||
* Memory Extraction Engine Configuration Form Types
|
||||
@@ -106,4 +106,17 @@ export interface TestResult {
|
||||
predicate: string;
|
||||
object: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface OntologyCoverageItem {
|
||||
type_count: number;
|
||||
entity_total: number;
|
||||
types: Array<{ type: string; count: number; }>
|
||||
}
|
||||
export interface OntologyCoverage {
|
||||
scene_type_distribution: OntologyCoverageItem;
|
||||
general_type_distribution: OntologyCoverageItem;
|
||||
unmatched: OntologyCoverageItem;
|
||||
total_entities: number;
|
||||
time: number;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:10:24
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 14:10:56
|
||||
* @Last Modified time: 2026-02-09 18:02:13
|
||||
*/
|
||||
import { type FC, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -45,7 +45,7 @@ const PageHeader: FC<ConfigHeaderProps> = ({
|
||||
}
|
||||
return (
|
||||
<Header className="rb:w-full rb:h-16 rb:flex rb:justify-between rb:p-[0_16px_0_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8">
|
||||
<div className="rb:flex rb:flex-col rb:justify-center rb:gap-1 rb:mr-4">
|
||||
<div className="rb:flex rb:flex-col rb:justify-center rb:gap-1 rb:mr-4 rb:max-w-[calc(100%-300px)]">
|
||||
<div className="rb:text-[16px] rb:leading-6 rb:font-medium">
|
||||
{name}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:10:20
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 14:10:20
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 17:56:35
|
||||
*/
|
||||
import { type FC, useEffect, useState, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -100,7 +100,7 @@ const Detail: FC = () => {
|
||||
<>
|
||||
<PageHeader
|
||||
name={data.scene_name}
|
||||
subTitle={<div>{data.scene_description}</div>}
|
||||
subTitle={<Tooltip title={data.scene_description}><div className="rb:h-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{data.scene_description}</div></Tooltip>}
|
||||
extra={<Space>
|
||||
<Button type="primary" ghost className="rb:h-6! rb:px-2! rb:leading-5.5!" onClick={handleAdd}>+ {t('ontology.addClass')}</Button>
|
||||
<Button className="rb:h-6! rb:px-2! rb:leading-5.5!" type="primary" onClick={handleExtract}>+ {t('ontology.extract')}</Button>
|
||||
|
||||
@@ -242,7 +242,7 @@ const EpisodicDetail: FC = () => {
|
||||
{detail.content_records.map((vo, index) => <div key={index} className="rb:text-[#5B6167] rb:leading-5">- {vo}</div>)}
|
||||
</div>
|
||||
<RbAlert>
|
||||
{t('episodicDetail.emotion')}: {t(`statementDetail.${detail.emotion}`)}
|
||||
{t('episodicDetail.emotion')}: {t(`episodicDetail.${detail.emotion || 'none'}`)}
|
||||
</RbAlert>
|
||||
</Space>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:10:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:10:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-10 12:17:41
|
||||
*/
|
||||
/**
|
||||
* Workflow Chat Component
|
||||
@@ -99,6 +99,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
setChatList([])
|
||||
setVariables([])
|
||||
setConversationId(null)
|
||||
setMessage(undefined)
|
||||
setFileList([])
|
||||
}
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
@@ -148,7 +150,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return
|
||||
}
|
||||
|
||||
// setLoading(true)
|
||||
setLoading(true)
|
||||
const message = msg
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'user',
|
||||
@@ -284,6 +286,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return newList
|
||||
})
|
||||
setStreamLoading(false)
|
||||
setLoading(false)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:39:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-05 14:21:45
|
||||
* @Last Modified time: 2026-02-09 19:56:42
|
||||
*/
|
||||
import { type FC, useEffect, useState, useMemo } from "react";
|
||||
import clsx from 'clsx'
|
||||
@@ -491,7 +491,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
|
||||
if (config.type === 'messageEditor') {
|
||||
return (
|
||||
<Form.Item key={key} name={key}>
|
||||
<Form.Item key={key} name={key} label={selectedNode?.data?.type === 'memory-write' ? t(`workflow.config.${selectedNode?.data?.type}.${key}`) : undefined }>
|
||||
<MessageEditor
|
||||
title={t(`workflow.config.${selectedNode?.data?.type}.${key}`)}
|
||||
isArray={!!config.isArray}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:06:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 17:48:46
|
||||
* @Last Modified time: 2026-02-09 20:08:03
|
||||
*/
|
||||
import LoopNode from './components/Nodes/LoopNode';
|
||||
import NormalNode from './components/Nodes/NormalNode';
|
||||
@@ -242,6 +242,12 @@ export const nodeLibrary: NodeLibrary[] = [
|
||||
type: 'editor',
|
||||
isArray: false
|
||||
},
|
||||
messages: {
|
||||
type: 'messageEditor',
|
||||
defaultValue: [],
|
||||
placeholder: 'workflow.config.llm.messagesPlaceholder',
|
||||
isArray: true
|
||||
},
|
||||
config_id: {
|
||||
type: 'customSelect',
|
||||
url: memoryConfigListUrl,
|
||||
|
||||
@@ -135,7 +135,10 @@ export const useWorkflowGraph = ({
|
||||
|
||||
if (nodeLibraryConfig?.config) {
|
||||
Object.keys(nodeLibraryConfig.config).forEach(key => {
|
||||
if (key === 'memory' && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
|
||||
if (type === 'memory-write' && key === 'message' && nodeLibraryConfig.config) {
|
||||
nodeLibraryConfig.config['messages'].defaultValue = [{ role: 'USER', content: config[key] }]
|
||||
delete nodeLibraryConfig.config[key]
|
||||
} else if (key === 'memory' && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
|
||||
const { memory, messages } = config as any;
|
||||
if (memory?.enable && messages && messages.length > 0) {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
Reference in New Issue
Block a user