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:
Mark
2026-02-10 15:51:28 +08:00
66 changed files with 1772 additions and 674 deletions

View File

@@ -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

View File

@@ -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(
"个性化建议获取成功(缓存)",

View File

@@ -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}"
)

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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,

View File

@@ -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
)
# 全局并行处理所有陈述句

View File

@@ -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
)
# 记录渲染结果到提示日志

View File

@@ -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:**

View File

@@ -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个high1-2个medium其余low
- 优先级要合理分配至少1个1-2个中,其余低
- 每个建议的3个步骤要循序渐进、易于实施
{% endif %}

View File

@@ -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",
]

View File

@@ -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],

View File

@@ -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":

View File

@@ -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(

View File

@@ -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>"

View File

@@ -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(

View File

@@ -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",
""

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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):

View File

@@ -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)}")

View File

@@ -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)

View File

@@ -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:

View File

@@ -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}")

View File

@@ -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")

View File

@@ -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(
"应用发布配置准备完成"
)

View File

@@ -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:

View File

@@ -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直接使用
# 如果新创建enduserenduser.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

View File

@@ -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

View File

@@ -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期望字符串

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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
}

View File

@@ -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__}")

View File

@@ -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 filefile 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}`

View File

@@ -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>

View File

@@ -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',

View File

@@ -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: '那些塑造了我的无形力量',

View File

@@ -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)

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>
</>

View File

@@ -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 || ''}`,
} }}
/>
)
},

View File

@@ -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)

View File

@@ -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();

View File

@@ -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>
</>
)}

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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 { // 知识库文档数据

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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]