Merge branch 'refs/heads/develop' into feature/agent-tool_xjn

This commit is contained in:
Timebomb2018
2026-04-10 18:30:46 +08:00
44 changed files with 1522 additions and 227 deletions

View File

@@ -111,6 +111,9 @@ celery_app.conf.update(
# Clustering tasks → memory_tasks queue (使用相同的 worker避免 macOS fork 问题) # Clustering tasks → memory_tasks queue (使用相同的 worker避免 macOS fork 问题)
'app.tasks.run_incremental_clustering': {'queue': 'memory_tasks'}, 'app.tasks.run_incremental_clustering': {'queue': 'memory_tasks'},
# Metadata extraction → memory_tasks queue
'app.tasks.extract_user_metadata': {'queue': 'memory_tasks'},
# Document tasks → document_tasks queue (prefork worker) # Document tasks → document_tasks queue (prefork worker)
'app.core.rag.tasks.parse_document': {'queue': 'document_tasks'}, 'app.core.rag.tasks.parse_document': {'queue': 'document_tasks'},
'app.core.rag.tasks.build_graphrag_for_kb': {'queue': 'document_tasks'}, 'app.core.rag.tasks.build_graphrag_for_kb': {'queue': 'document_tasks'},

View File

@@ -14,6 +14,7 @@ from app.core.response_utils import success
from app.db import get_db from app.db import get_db
from app.models.app_model import App from app.models.app_model import App
from app.models.app_model import AppType from app.models.app_model import AppType
from app.models.app_release_model import AppRelease
from app.repositories import knowledge_repository from app.repositories import knowledge_repository
from app.repositories.end_user_repository import EndUserRepository from app.repositories.end_user_repository import EndUserRepository
from app.schemas import AppChatRequest, conversation_schema from app.schemas import AppChatRequest, conversation_schema
@@ -61,18 +62,18 @@ async def list_apps():
# return success(data={"received": True}, msg="消息已接收") # return success(data={"received": True}, msg="消息已接收")
def _checkAppConfig(app: App): def _checkAppConfig(release: AppRelease):
if app.type == AppType.AGENT: if release.type == AppType.AGENT:
if not app.current_release.config: if not release.config:
raise BusinessException("Agent 应用未配置模型", BizCode.AGENT_CONFIG_MISSING) raise BusinessException("Agent 应用未配置模型", BizCode.AGENT_CONFIG_MISSING)
elif app.type == AppType.MULTI_AGENT: elif release.type == AppType.MULTI_AGENT:
if not app.current_release.config: if not release.config:
raise BusinessException("Multi-Agent 应用未配置模型", BizCode.AGENT_CONFIG_MISSING) raise BusinessException("Multi-Agent 应用未配置模型", BizCode.AGENT_CONFIG_MISSING)
elif app.type == AppType.WORKFLOW: elif release.type == AppType.WORKFLOW:
if not app.current_release.config: if not release.config:
raise BusinessException("工作流应用未配置模型", BizCode.AGENT_CONFIG_MISSING) raise BusinessException("工作流应用未配置模型", BizCode.AGENT_CONFIG_MISSING)
else: else:
raise BusinessException("不支持的应用类型", BizCode.AGENT_CONFIG_MISSING) raise BusinessException("不支持的应用类型", BizCode.APP_TYPE_NOT_SUPPORTED)
@router.post("/chat") @router.post("/chat")
@@ -86,10 +87,22 @@ async def chat(
app_service: Annotated[AppService, Depends(get_app_service)] = None, app_service: Annotated[AppService, Depends(get_app_service)] = None,
message: str = Body(..., description="聊天消息内容"), message: str = Body(..., description="聊天消息内容"),
): ):
"""
Agent/Workflow 聊天接口
- 不传 version使用当前生效版本current_release回滚后为回滚目标版本
- 传 version=N使用指定版本号的历史快照例如 {"version": 2}
"""
body = await request.json() body = await request.json()
payload = AppChatRequest(**body) payload = AppChatRequest(**body)
app = app_service.get_app(api_key_auth.resource_id, api_key_auth.workspace_id) app = app_service.get_app(api_key_auth.resource_id, api_key_auth.workspace_id)
# 版本切换:指定 version 时查找对应历史快照,否则使用当前激活版本
if payload.version is not None:
active_release = app_service.get_release_by_version(app.id, payload.version)
else:
active_release = app.current_release
other_id = payload.user_id other_id = payload.user_id
workspace_id = api_key_auth.workspace_id workspace_id = api_key_auth.workspace_id
end_user_repo = EndUserRepository(db) end_user_repo = EndUserRepository(db)
@@ -127,7 +140,7 @@ async def chat(
storage_type = 'neo4j' storage_type = 'neo4j'
app_type = app.type app_type = app.type
# check app config # check app config
_checkAppConfig(app) _checkAppConfig(active_release)
# 获取或创建会话(提前验证) # 获取或创建会话(提前验证)
conversation = conversation_service.create_or_get_conversation( conversation = conversation_service.create_or_get_conversation(
@@ -142,7 +155,7 @@ async def chat(
# print("="*50) # print("="*50)
# print(app.current_release.default_model_config_id) # print(app.current_release.default_model_config_id)
agent_config = agent_config_4_app_release(app.current_release) agent_config = agent_config_4_app_release(active_release)
# print(agent_config.default_model_config_id) # print(agent_config.default_model_config_id)
# thinking 开关:仅当 agent 配置了 deep_thinking 且请求 thinking=True 时才启用 # thinking 开关:仅当 agent 配置了 deep_thinking 且请求 thinking=True 时才启用
@@ -194,7 +207,7 @@ async def chat(
return success(data=conversation_schema.ChatResponse(**result).model_dump(mode="json")) return success(data=conversation_schema.ChatResponse(**result).model_dump(mode="json"))
elif app_type == AppType.MULTI_AGENT: elif app_type == AppType.MULTI_AGENT:
# 多 Agent 流式返回 # 多 Agent 流式返回
config = multi_agent_config_4_app_release(app.current_release) config = multi_agent_config_4_app_release(active_release)
if payload.stream: if payload.stream:
async def event_generator(): async def event_generator():
async for event in app_chat_service.multi_agent_chat_stream( async for event in app_chat_service.multi_agent_chat_stream(
@@ -237,7 +250,7 @@ async def chat(
return success(data=conversation_schema.ChatResponse(**result).model_dump(mode="json")) return success(data=conversation_schema.ChatResponse(**result).model_dump(mode="json"))
elif app_type == AppType.WORKFLOW: elif app_type == AppType.WORKFLOW:
# 多 Agent 流式返回 # 多 Agent 流式返回
config = workflow_config_4_app_release(app.current_release) config = workflow_config_4_app_release(active_release)
if payload.stream: if payload.stream:
async def event_generator(): async def event_generator():
async for event in app_chat_service.workflow_chat_stream( async for event in app_chat_service.workflow_chat_stream(
@@ -253,7 +266,7 @@ async def chat(
user_rag_memory_id=user_rag_memory_id, user_rag_memory_id=user_rag_memory_id,
app_id=app.id, app_id=app.id,
workspace_id=workspace_id, workspace_id=workspace_id,
release_id=app.current_release.id, release_id=active_release.id,
public=True public=True
): ):
event_type = event.get("event", "message") event_type = event.get("event", "message")
@@ -288,7 +301,7 @@ async def chat(
files=payload.files, files=payload.files,
app_id=app.id, app_id=app.id,
workspace_id=workspace_id, workspace_id=workspace_id,
release_id=app.current_release.id release_id=active_release.id
) )
logger.debug( logger.debug(
"工作流试运行返回结果", "工作流试运行返回结果",
@@ -302,6 +315,4 @@ async def chat(
msg="工作流任务执行成功" msg="工作流任务执行成功"
) )
else: else:
from app.core.exceptions import BusinessException
from app.core.error_codes import BizCode
raise BusinessException(f"不支持的应用类型: {app_type}", BizCode.APP_TYPE_NOT_SUPPORTED) raise BusinessException(f"不支持的应用类型: {app_type}", BizCode.APP_TYPE_NOT_SUPPORTED)

View File

@@ -153,7 +153,7 @@ class PerceptualSearchService:
return [] return []
try: try:
r = await search_perceptual( r = await search_perceptual(
connector=connector, q=escaped, connector=connector, query=escaped,
end_user_id=self.end_user_id, end_user_id=self.end_user_id,
limit=limit * 5, # 多查一些以提高命中率 limit=limit * 5, # 多查一些以提高命中率
) )
@@ -178,7 +178,7 @@ class PerceptualSearchService:
if not escaped.strip(): if not escaped.strip():
return [] return []
r = await search_perceptual( r = await search_perceptual(
connector=connector, q=escaped, connector=connector, query=escaped,
end_user_id=self.end_user_id, limit=limit, end_user_id=self.end_user_id, limit=limit,
) )
return r.get("perceptuals", []) return r.get("perceptuals", [])

View File

@@ -58,6 +58,14 @@ from app.core.memory.models.triplet_models import (
TripletExtractionResponse, TripletExtractionResponse,
) )
# User metadata models
from app.core.memory.models.metadata_models import (
UserMetadata,
UserMetadataBehavioralHints,
UserMetadataProfile,
MetadataExtractionResponse,
)
# Ontology scenario models (LLM extracted from scenarios) # Ontology scenario models (LLM extracted from scenarios)
from app.core.memory.models.ontology_scenario_models import ( from app.core.memory.models.ontology_scenario_models import (
OntologyClass, OntologyClass,
@@ -124,6 +132,10 @@ __all__ = [
"Entity", "Entity",
"Triplet", "Triplet",
"TripletExtractionResponse", "TripletExtractionResponse",
"UserMetadata",
"UserMetadataBehavioralHints",
"UserMetadataProfile",
"MetadataExtractionResponse",
# Ontology models # Ontology models
"OntologyClass", "OntologyClass",
"OntologyExtractionResponse", "OntologyExtractionResponse",

View File

@@ -364,12 +364,14 @@ class ChunkNode(Node):
Attributes: Attributes:
dialog_id: ID of the parent dialog dialog_id: ID of the parent dialog
content: The text content of the chunk content: The text content of the chunk
speaker: Speaker identifier ('user' or 'assistant')
chunk_embedding: Optional embedding vector for the chunk chunk_embedding: Optional embedding vector for the chunk
sequence_number: Order of this chunk within the dialog sequence_number: Order of this chunk within the dialog
metadata: Additional chunk metadata as key-value pairs metadata: Additional chunk metadata as key-value pairs
""" """
dialog_id: str = Field(..., description="ID of the parent dialog") dialog_id: str = Field(..., description="ID of the parent dialog")
content: str = Field(..., description="The text content of the chunk") content: str = Field(..., description="The text content of the chunk")
speaker: Optional[str] = Field(None, description="Speaker identifier: 'user' for user messages, 'assistant' for AI responses")
chunk_embedding: Optional[List[float]] = Field(None, description="Chunk embedding vector") chunk_embedding: Optional[List[float]] = Field(None, description="Chunk embedding vector")
sequence_number: int = Field(..., description="Order of this chunk within the dialog") sequence_number: int = Field(..., description="Order of this chunk within the dialog")
metadata: dict = Field(default_factory=dict, description="Additional chunk metadata") metadata: dict = Field(default_factory=dict, description="Additional chunk metadata")

View File

@@ -0,0 +1,57 @@
"""Models for user metadata extraction.
Independent from triplet_models.py - these models are used by the
standalone metadata extraction pipeline (post-dedup async Celery task).
"""
from typing import List
from pydantic import BaseModel, ConfigDict, Field
class UserMetadataProfile(BaseModel):
"""用户画像信息"""
model_config = ConfigDict(extra="ignore")
role: str = Field(default="", description="用户职业或角色")
domain: str = Field(default="", description="用户所在领域")
expertise: List[str] = Field(
default_factory=list, description="用户擅长的技能或工具"
)
interests: List[str] = Field(
default_factory=list, description="用户关注的话题或领域标签"
)
class UserMetadataBehavioralHints(BaseModel):
"""行为偏好"""
model_config = ConfigDict(extra="ignore")
learning_stage: str = Field(default="", description="学习阶段")
preferred_depth: str = Field(default="", description="偏好深度")
tone_preference: str = Field(default="", description="语气偏好")
class UserMetadata(BaseModel):
"""用户元数据顶层结构"""
model_config = ConfigDict(extra="ignore")
profile: UserMetadataProfile = Field(default_factory=UserMetadataProfile)
behavioral_hints: UserMetadataBehavioralHints = Field(
default_factory=UserMetadataBehavioralHints
)
knowledge_tags: List[str] = Field(default_factory=list, description="知识标签")
class MetadataExtractionResponse(BaseModel):
"""元数据提取 LLM 响应结构"""
model_config = ConfigDict(extra="ignore")
user_metadata: UserMetadata = Field(default_factory=UserMetadata)
aliases_to_add: List[str] = Field(
default_factory=list,
description="本次新发现的用户别名(用户自我介绍或他人对用户的称呼)",
)
aliases_to_remove: List[str] = Field(
default_factory=list, description="用户明确否认的别名(如'我不叫XX了'"
)

View File

@@ -1,4 +1,3 @@
import argparse
import asyncio import asyncio
import json import json
import math import math
@@ -6,7 +5,6 @@ import os
import time import time
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional from typing import TYPE_CHECKING, Any, Dict, List, Optional
from uuid import UUID
if TYPE_CHECKING: if TYPE_CHECKING:
from app.schemas.memory_config_schema import MemoryConfig from app.schemas.memory_config_schema import MemoryConfig
@@ -23,7 +21,7 @@ from app.core.memory.utils.config.config_utils import (
) )
from app.core.memory.utils.data.text_utils import extract_plain_query from app.core.memory.utils.data.text_utils import extract_plain_query
from app.core.memory.utils.data.time_utils import normalize_date_safe from app.core.memory.utils.data.time_utils import normalize_date_safe
from app.core.memory.utils.llm.llm_utils import get_reranker_client # from app.core.memory.utils.llm.llm_utils import get_reranker_client
from app.core.models.base import RedBearModelConfig from app.core.models.base import RedBearModelConfig
from app.db import get_db_context from app.db import get_db_context
from app.repositories.neo4j.graph_search import ( from app.repositories.neo4j.graph_search import (
@@ -748,11 +746,10 @@ async def run_hybrid_search(
if search_type in ["keyword", "hybrid"]: if search_type in ["keyword", "hybrid"]:
# Keyword-based search # Keyword-based search
logger.info("[PERF] Starting keyword search...") logger.info("[PERF] Starting keyword search...")
keyword_start = time.time()
keyword_task = asyncio.create_task( keyword_task = asyncio.create_task(
search_graph( search_graph(
connector=connector, connector=connector,
q=query_text, query=query_text,
end_user_id=end_user_id, end_user_id=end_user_id,
limit=limit, limit=limit,
include=include include=include
@@ -762,7 +759,6 @@ async def run_hybrid_search(
if search_type in ["embedding", "hybrid"]: if search_type in ["embedding", "hybrid"]:
# Embedding-based search # Embedding-based search
logger.info("[PERF] Starting embedding search...") logger.info("[PERF] Starting embedding search...")
embedding_start = time.time()
# 从数据库读取嵌入器配置(按 ID并构建 RedBearModelConfig # 从数据库读取嵌入器配置(按 ID并构建 RedBearModelConfig
config_load_start = time.time() config_load_start = time.time()
@@ -904,10 +900,10 @@ async def run_hybrid_search(
else: else:
results["latency_metrics"] = latency_metrics results["latency_metrics"] = latency_metrics
logger.info(f"[PERF] ===== SEARCH PERFORMANCE SUMMARY =====") logger.info("[PERF] ===== SEARCH PERFORMANCE SUMMARY =====")
logger.info(f"[PERF] Total search completed in {total_latency:.4f}s") logger.info(f"[PERF] Total search completed in {total_latency:.4f}s")
logger.info(f"[PERF] Latency breakdown: {json.dumps(latency_metrics, indent=2)}") logger.info(f"[PERF] Latency breakdown: {json.dumps(latency_metrics, indent=2)}")
logger.info(f"[PERF] =========================================") logger.info("[PERF] =========================================")
# Sanitize results: drop large/unused fields # Sanitize results: drop large/unused fields
_remove_keys_recursive(results, ["name_embedding"]) # drop entity name embeddings from outputs _remove_keys_recursive(results, ["name_embedding"]) # drop entity name embeddings from outputs

View File

@@ -311,10 +311,53 @@ class ExtractionOrchestrator:
dialog_data_list, dialog_data_list,
) )
# 步骤 7: 同步用户别名到数据库表(仅正式模式) # 步骤 7: 触发异步元数据和别名提取(仅正式模式)
if not is_pilot_run: if not is_pilot_run:
logger.info("步骤 7: 同步用户别名到 end_user 和 end_user_info 表") try:
await self._update_end_user_other_name(entity_nodes, dialog_data_list) from app.core.memory.storage_services.extraction_engine.knowledge_extraction.metadata_extractor import (
MetadataExtractor,
)
metadata_extractor = MetadataExtractor(
llm_client=self.llm_client, language=self.language
)
user_statements = (
metadata_extractor.collect_user_related_statements(
entity_nodes, statement_nodes, statement_entity_edges
)
)
if user_statements:
end_user_id = (
dialog_data_list[0].end_user_id
if dialog_data_list
else None
)
config_id = (
dialog_data_list[0].config_id
if dialog_data_list
and hasattr(dialog_data_list[0], "config_id")
else None
)
if end_user_id:
from app.tasks import extract_user_metadata_task
extract_user_metadata_task.delay(
end_user_id=str(end_user_id),
statements=user_statements,
config_id=str(config_id) if config_id else None,
language=self.language,
)
logger.info(
f"已触发异步元数据提取任务,共 {len(user_statements)} 条用户相关 statement"
)
else:
logger.info("未找到用户相关 statement跳过元数据提取")
except Exception as e:
logger.error(
f"触发元数据提取任务失败(不影响主流程): {e}", exc_info=True
)
# 别名同步已迁移到 Celery 元数据提取任务中,不再在此处执行
logger.info(f"知识提取流水线运行完成({mode_str}") logger.info(f"知识提取流水线运行完成({mode_str}")
return ( return (
@@ -1107,6 +1150,7 @@ class ExtractionOrchestrator:
end_user_id=dialog_data.end_user_id, end_user_id=dialog_data.end_user_id,
run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id
content=chunk.content, content=chunk.content,
speaker=getattr(chunk, 'speaker', None),
chunk_embedding=chunk.chunk_embedding, chunk_embedding=chunk.chunk_embedding,
sequence_number=chunk_idx, # 添加必需的 sequence_number 字段 sequence_number=chunk_idx, # 添加必需的 sequence_number 字段
created_at=dialog_data.created_at, created_at=dialog_data.created_at,
@@ -1342,7 +1386,7 @@ class ExtractionOrchestrator:
async def _update_end_user_other_name( async def _update_end_user_other_name(
self, self,
entity_nodes: List[ExtractedEntityNode], entity_nodes: List[ExtractedEntityNode],
dialog_data_list: List[DialogData] dialog_data_list: List[DialogData],
) -> None: ) -> None:
""" """
将本轮提取的用户别名同步到 end_user 和 end_user_info 表。 将本轮提取的用户别名同步到 end_user 和 end_user_info 表。
@@ -1470,7 +1514,6 @@ class ExtractionOrchestrator:
end_user_id=end_user_uuid, end_user_id=end_user_uuid,
other_name=first_alias, other_name=first_alias,
aliases=merged_aliases, aliases=merged_aliases,
meta_data={}
)) ))
logger.info(f"创建 end_user_info 记录other_name={first_alias}, aliases={merged_aliases}") logger.info(f"创建 end_user_info 记录other_name={first_alias}, aliases={merged_aliases}")
@@ -1478,9 +1521,6 @@ class ExtractionOrchestrator:
except Exception as e: except Exception as e:
logger.error(f"更新 end_user other_name 失败: {e}", exc_info=True) logger.error(f"更新 end_user other_name 失败: {e}", exc_info=True)
# 用户实体占位名称,不允许作为 other_name 或出现在 aliases 中 # 用户实体占位名称,不允许作为 other_name 或出现在 aliases 中
# 复用 deduped_and_disamb 模块级常量,避免重复维护 # 复用 deduped_and_disamb 模块级常量,避免重复维护
USER_PLACEHOLDER_NAMES = _USER_PLACEHOLDER_NAMES USER_PLACEHOLDER_NAMES = _USER_PLACEHOLDER_NAMES
@@ -1587,7 +1627,6 @@ class ExtractionOrchestrator:
if candidate and candidate.lower() in self.USER_PLACEHOLDER_NAMES: if candidate and candidate.lower() in self.USER_PLACEHOLDER_NAMES:
return None return None
return candidate return candidate
return None return None
async def _run_dedup_and_write_summary( async def _run_dedup_and_write_summary(

View File

@@ -0,0 +1,175 @@
"""
Metadata extractor module.
Collects user-related statements from post-dedup graph data and
extracts user metadata via an independent LLM call.
"""
import logging
from typing import List, Optional
from app.core.memory.models.graph_models import (
ExtractedEntityNode,
StatementEntityEdge,
StatementNode,
)
logger = logging.getLogger(__name__)
# Reuse the same user-entity detection logic from dedup module
_USER_NAMES = {"用户", "", "user", "i"}
_CANONICAL_USER_TYPE = "用户"
def _is_user_entity(ent: ExtractedEntityNode) -> bool:
"""判断实体是否为用户实体"""
name = (getattr(ent, "name", "") or "").strip().lower()
etype = (getattr(ent, "entity_type", "") or "").strip()
return name in _USER_NAMES or etype == _CANONICAL_USER_TYPE
class MetadataExtractor:
"""Extracts user metadata from post-dedup graph data via independent LLM call."""
def __init__(self, llm_client, language: Optional[str] = None):
self.llm_client = llm_client
self.language = language
@staticmethod
def detect_language(statements: List[str]) -> str:
"""根据 statement 文本内容检测语言。
如果文本中包含中文字符则返回 "zh",否则返回 "en"
"""
import re
combined = " ".join(statements)
if re.search(r"[\u4e00-\u9fff]", combined):
return "zh"
return "en"
def collect_user_related_statements(
self,
entity_nodes: List[ExtractedEntityNode],
statement_nodes: List[StatementNode],
statement_entity_edges: List[StatementEntityEdge],
) -> List[str]:
"""
从去重后的数据中筛选与用户直接相关且由用户发言的 statement 文本。
筛选逻辑:
1. 用户实体 → StatementEntityEdge → statement直接关联
2. 只保留 speaker="user" 的 statement过滤 assistant 回复的噪声)
Returns:
用户发言的 statement 文本列表
"""
# Find user entity IDs
user_entity_ids = set()
for ent in entity_nodes:
if _is_user_entity(ent):
user_entity_ids.add(ent.id)
if not user_entity_ids:
logger.debug("未找到用户实体节点,跳过 statement 收集")
return []
# 用户实体 → StatementEntityEdge → statement
target_stmt_ids = set()
for edge in statement_entity_edges:
if edge.target in user_entity_ids:
target_stmt_ids.add(edge.source)
# Collect: only speaker="user" statements, preserving order
result = []
seen = set()
total_associated = 0
skipped_non_user = 0
for stmt_node in statement_nodes:
if stmt_node.id in target_stmt_ids and stmt_node.id not in seen:
total_associated += 1
speaker = getattr(stmt_node, "speaker", None) or "unknown"
if speaker == "user":
text = (stmt_node.statement or "").strip()
if text:
result.append(text)
else:
skipped_non_user += 1
seen.add(stmt_node.id)
logger.info(
f"收集到 {len(result)} 条用户发言 statement "
f"(直接关联: {total_associated}, speaker=user: {len(result)}, "
f"跳过非user: {skipped_non_user})"
)
if result:
for i, text in enumerate(result):
logger.info(f" [user statement {i + 1}] {text}")
if total_associated > 0 and len(result) == 0:
logger.warning(
f"{total_associated} 条直接关联 statement 但全部被 speaker 过滤,"
f"可能本次写入不包含 user 消息"
)
return result
async def extract_metadata(
self,
statements: List[str],
existing_metadata: Optional[dict] = None,
existing_aliases: Optional[List[str]] = None,
) -> Optional[tuple]:
"""
对筛选后的 statement 列表调用 LLM 提取元数据和用户别名。
Args:
statements: 用户发言的 statement 文本列表
existing_metadata: 数据库已有的元数据(可选)
existing_aliases: 数据库已有的用户别名列表(可选)
Returns:
(UserMetadata, List[str], List[str]) tuple: (metadata, aliases_to_add, aliases_to_remove) on success, None on failure
"""
if not statements:
return None
try:
from app.core.memory.utils.prompt.prompt_utils import prompt_env
if self.language:
detected_language = self.language
logger.info(f"元数据提取使用显式指定语言: {detected_language}")
else:
detected_language = self.detect_language(statements)
logger.info(f"元数据提取语言自动检测结果: {detected_language}")
template = prompt_env.get_template("extract_user_metadata.jinja2")
prompt = template.render(
statements=statements,
language=detected_language,
existing_metadata=existing_metadata,
existing_aliases=existing_aliases,
json_schema="",
)
from app.core.memory.models.metadata_models import (
MetadataExtractionResponse,
)
response = await self.llm_client.response_structured(
messages=[{"role": "user", "content": prompt}],
response_model=MetadataExtractionResponse,
)
if response:
metadata = response.user_metadata if response.user_metadata else None
to_add = response.aliases_to_add if response.aliases_to_add else []
to_remove = (
response.aliases_to_remove if response.aliases_to_remove else []
)
return metadata, to_add, to_remove
logger.warning("LLM 返回的响应为空")
return None
except Exception as e:
logger.error(f"元数据提取 LLM 调用失败: {e}", exc_info=True)
return None

View File

@@ -1,6 +1,5 @@
import asyncio import asyncio
import logging import logging
import os
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -82,6 +81,7 @@ class StatementExtractor:
logger.warning(f"Chunk {getattr(chunk, 'id', 'unknown')} has no speaker field or is empty") logger.warning(f"Chunk {getattr(chunk, 'id', 'unknown')} has no speaker field or is empty")
return None return None
async def _extract_statements(self, chunk, end_user_id: Optional[str] = None, dialogue_content: str = None) -> List[Statement]: async def _extract_statements(self, chunk, end_user_id: Optional[str] = None, dialogue_content: str = None) -> List[Statement]:
"""Process a single chunk and return extracted statements """Process a single chunk and return extracted statements
@@ -94,6 +94,7 @@ class StatementExtractor:
List of ExtractedStatement objects extracted from the chunk List of ExtractedStatement objects extracted from the chunk
""" """
chunk_content = chunk.content chunk_content = chunk.content
chunk_speaker = self._get_speaker_from_chunk(chunk)
if not chunk_content or len(chunk_content.strip()) < 5: if not chunk_content or len(chunk_content.strip()) < 5:
logger.warning(f"Chunk {chunk.id} content too short or empty, skipping") logger.warning(f"Chunk {chunk.id} content too short or empty, skipping")
@@ -150,8 +151,6 @@ class StatementExtractor:
except (KeyError, ValueError): except (KeyError, ValueError):
relevence_info = RelevenceInfo.RELEVANT relevence_info = RelevenceInfo.RELEVANT
chunk_speaker = self._get_speaker_from_chunk(chunk)
chunk_statement = Statement( chunk_statement = Statement(
statement=extracted_stmt.statement, statement=extracted_stmt.statement,
stmt_type=stmt_type, stmt_type=stmt_type,

View File

@@ -1,4 +1,3 @@
import os
import asyncio import asyncio
from typing import List, Dict, Optional from typing import List, Dict, Optional

View File

@@ -5,7 +5,7 @@
使用Neo4j的全文索引进行高效的文本匹配。 使用Neo4j的全文索引进行高效的文本匹配。
""" """
from typing import List, Dict, Any, Optional from typing import List, Optional
from app.core.logging_config import get_memory_logger from app.core.logging_config import get_memory_logger
from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.repositories.neo4j.neo4j_connector import Neo4jConnector
from app.core.memory.storage_services.search.search_strategy import SearchStrategy, SearchResult from app.core.memory.storage_services.search.search_strategy import SearchStrategy, SearchResult
@@ -74,7 +74,7 @@ class KeywordSearchStrategy(SearchStrategy):
# 调用底层的关键词搜索函数 # 调用底层的关键词搜索函数
results_dict = await search_graph( results_dict = await search_graph(
connector=self.connector, connector=self.connector,
q=query_text, query=query_text,
end_user_id=end_user_id, end_user_id=end_user_id,
limit=limit, limit=limit,
include=include_list include=include_list

View File

@@ -22,7 +22,9 @@ def escape_lucene_query(query: str) -> str:
s = s.replace("\r", " ").replace("\n", " ").strip() s = s.replace("\r", " ").replace("\n", " ").strip()
# Lucene reserved tokens/special characters # Lucene reserved tokens/special characters
specials = ['&&', '||', '\\', '+', '-', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':'] # NOTE: '/' is the regex delimiter in Lucene — must be escaped to prevent
# TokenMgrError when the query contains unmatched slashes.
specials = ['&&', '||', '\\', '+', '-', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '/']
# Replace longer tokens first to avoid partial double-escaping # Replace longer tokens first to avoid partial double-escaping
for token in sorted(specials, key=len, reverse=True): for token in sorted(specials, key=len, reverse=True):
s = s.replace(token, f"\\{token}") s = s.replace(token, f"\\{token}")

View File

@@ -43,8 +43,9 @@ Each statement must be labeled as per the criteria mentioned below.
对话上下文和共指消解: 对话上下文和共指消解:
- 将每个陈述句归属于说出它的参与者。 - 将每个陈述句归属于说出它的参与者。
- 如果参与者列表为说话者提供了名称(例如,"李雪(用户)"),请在提取的陈述句中使用具体名称("李雪"),而不是通用角色("用户" - **对于用户的发言:必须使用"用户"作为主语**,禁止将"用户"或"我"替换为用户的真实姓名或别名。例如,用户说"我叫张三"应提取为"用户叫张三",而不是"张三叫张三"
- 将所有代词解析为对话上下文中的具体人物或实体 - 对于 AI 助手的发言:使用"助手"或"AI助手"作为主语
- 将所有代词解析为对话上下文中的具体人物或实体,但"我"必须解析为"用户"。
- 识别并将抽象引用解析为其具体名称(如果提到)。 - 识别并将抽象引用解析为其具体名称(如果提到)。
- 将缩写和首字母缩略词扩展为其完整形式。 - 将缩写和首字母缩略词扩展为其完整形式。
{% else %} {% else %}
@@ -68,8 +69,9 @@ Context Resolution Requirements:
Conversational Context & Co-reference Resolution: Conversational Context & Co-reference Resolution:
- Attribute every statement to the participant who uttered it. - Attribute every statement to the participant who uttered it.
- If the participant list provides a name for a speaker (e.g., "李雪 (用户)"), use the specific name ("李雪") in the extracted statement, not the generic role ("用户"). - **For user's statements: always use "用户" (User) as the subject**. Do NOT replace "用户" or "I" with the user's real name or alias. For example, if the user says "I'm John", extract as "用户 is John", not "John is John".
- Resolve all pronouns to the specific person or entity from the conversation's context. - For AI assistant's statements: use "助手" or "AI助手" as the subject.
- Resolve all pronouns to the specific person or entity from the conversation's context, but "I"/"我" must always resolve to "用户".
- Identify and resolve abstract references to their specific names if mentioned. - Identify and resolve abstract references to their specific names if mentioned.
- Expand abbreviations and acronyms to their full form. - Expand abbreviations and acronyms to their full form.
{% endif %} {% endif %}
@@ -139,13 +141,13 @@ AI: "水彩画很有趣!水彩颜料通常由颜料与阿拉伯树胶等粘合
示例输出: { 示例输出: {
"statements": [ "statements": [
{ {
"statement": "Sarah Chen 最近一直在尝试水彩画。", "statement": "用户最近一直在尝试水彩画。",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "DYNAMIC", "temporal_type": "DYNAMIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
}, },
{ {
"statement": "Sarah Chen 画了一些花朵。", "statement": "用户画了一些花朵。",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "DYNAMIC", "temporal_type": "DYNAMIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
@@ -157,13 +159,13 @@ AI: "水彩画很有趣!水彩颜料通常由颜料与阿拉伯树胶等粘合
"relevance": "IRRELEVANT" "relevance": "IRRELEVANT"
}, },
{ {
"statement": "Sarah Chen 认为她的水彩画中的色彩组合可以改进。", "statement": "用户认为她的水彩画中的色彩组合可以改进。",
"statement_type": "OPINION", "statement_type": "OPINION",
"temporal_type": "STATIC", "temporal_type": "STATIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
}, },
{ {
"statement": "Sarah Chen 真的很喜欢玫瑰和百合。", "statement": "用户真的很喜欢玫瑰和百合。",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "STATIC", "temporal_type": "STATIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
@@ -186,13 +188,13 @@ AI: "水彩画很有趣!水彩颜料通常由颜料和阿拉伯树胶等粘合
示例输出: { 示例输出: {
"statements": [ "statements": [
{ {
"statement": "张曼婷最近在尝试水彩画。", "statement": "用户最近在尝试水彩画。",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "DYNAMIC", "temporal_type": "DYNAMIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
}, },
{ {
"statement": "张曼婷画了一些花朵。", "statement": "用户画了一些花朵。",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "DYNAMIC", "temporal_type": "DYNAMIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
@@ -204,13 +206,13 @@ AI: "水彩画很有趣!水彩颜料通常由颜料和阿拉伯树胶等粘合
"relevance": "IRRELEVANT" "relevance": "IRRELEVANT"
}, },
{ {
"statement": "张曼婷觉得水彩画的色彩搭配还有提升的空间。", "statement": "用户觉得水彩画的色彩搭配还有提升的空间。",
"statement_type": "OPINION", "statement_type": "OPINION",
"temporal_type": "STATIC", "temporal_type": "STATIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
}, },
{ {
"statement": "张曼婷很喜欢玫瑰和百合。", "statement": "用户很喜欢玫瑰和百合。",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "STATIC", "temporal_type": "STATIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
@@ -233,13 +235,13 @@ User: "I think the color combinations could use some improvement, but I really l
Example Output: { Example Output: {
"statements": [ "statements": [
{ {
"statement": "Sarah Chen has been trying watercolor painting recently.", "statement": "用户 has been trying watercolor painting recently.",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "DYNAMIC", "temporal_type": "DYNAMIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
}, },
{ {
"statement": "Sarah Chen painted some flowers.", "statement": "用户 painted some flowers.",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "DYNAMIC", "temporal_type": "DYNAMIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
@@ -251,13 +253,13 @@ Example Output: {
"relevance": "IRRELEVANT" "relevance": "IRRELEVANT"
}, },
{ {
"statement": "Sarah Chen thinks the color combinations in her watercolor paintings could use some improvement.", "statement": "用户 thinks the color combinations in her watercolor paintings could use some improvement.",
"statement_type": "OPINION", "statement_type": "OPINION",
"temporal_type": "STATIC", "temporal_type": "STATIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
}, },
{ {
"statement": "Sarah Chen really likes roses and lilies.", "statement": "用户 really likes roses and lilies.",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "STATIC", "temporal_type": "STATIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
@@ -280,13 +282,13 @@ AI: "水彩画很有趣!水彩颜料通常由颜料和阿拉伯树胶等粘合
Example Output: { Example Output: {
"statements": [ "statements": [
{ {
"statement": "张曼婷最近在尝试水彩画。", "statement": "用户最近在尝试水彩画。",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "DYNAMIC", "temporal_type": "DYNAMIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
}, },
{ {
"statement": "张曼婷画了一些花朵。", "statement": "用户画了一些花朵。",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "DYNAMIC", "temporal_type": "DYNAMIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
@@ -298,13 +300,13 @@ Example Output: {
"relevance": "IRRELEVANT" "relevance": "IRRELEVANT"
}, },
{ {
"statement": "张曼婷觉得水彩画的色彩搭配还有提升的空间。", "statement": "用户觉得水彩画的色彩搭配还有提升的空间。",
"statement_type": "OPINION", "statement_type": "OPINION",
"temporal_type": "STATIC", "temporal_type": "STATIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"
}, },
{ {
"statement": "张曼婷很喜欢玫瑰和百合。", "statement": "用户很喜欢玫瑰和百合。",
"statement_type": "FACT", "statement_type": "FACT",
"temporal_type": "STATIC", "temporal_type": "STATIC",
"relevance": "RELEVANT" "relevance": "RELEVANT"

View File

@@ -406,4 +406,12 @@ Output:
- **⚠️ ALIASES ORDER: preserve temporal order of appearance** - **⚠️ ALIASES ORDER: preserve temporal order of appearance**
- **🚨 MANDATORY FIELD: EVERY entity MUST include "aliases" field, even if empty array []** - **🚨 MANDATORY FIELD: EVERY entity MUST include "aliases" field, even if empty array []**
**Output JSON structure:**
```json
{
"triplets": [...],
"entities": [...]
}
```
{{ json_schema }} {{ json_schema }}

View File

@@ -0,0 +1,135 @@
===Task===
Extract user metadata from the following conversation statements spoken by the user.
{% if language == "zh" %}
**"三度原则"判断标准:**
- 复用度:该信息是否会被多个功能模块使用?
- 约束度:该信息是否会影响系统行为?
- 时效性:该信息是长期稳定的还是临时的?仅提取长期稳定信息。
**提取规则:**
- **只提取关于"用户本人"的画像信息**,忽略用户提到的第三方人物(如朋友、同事、家人)的信息
- 仅提取文本中明确提到的信息,不要推测
- 如果文本中没有可提取的用户画像信息,返回空的 user_metadata 对象
- **输出语言必须与输入文本的语言一致**(输入中文则输出中文值,输入英文则输出英文值)
{% if existing_metadata %}
**重要:合并已有元数据**
下方提供了数据库中已有的用户元数据。请结合用户最新发言,输出**合并后的完整元数据**
- 如果用户明确否定了已有信息(如"我不再教高中物理了"),在输出中**移除**该信息
- 如果用户提到了新信息,**添加**到对应字段中
- 如果已有信息未被用户否定,**保留**在输出中
- 标量字段(如 role、domain如果用户提到了新值用新值替换否则保留已有值
- 最终输出应该是完整的、合并后的元数据,不是增量
{% endif %}
**字段说明:**
- profile.role用户的职业或角色如 教师、医生、后端工程师
- profile.domain用户所在领域如 教育、医疗、软件开发
- profile.expertise用户擅长的技能或工具通用不限于编程如 Python、心理咨询、高中物理
- profile.interests用户主动表达兴趣的话题或领域标签
- behavioral_hints.learning_stage学习阶段初学者/中级/高级)
- behavioral_hints.preferred_depth偏好深度概览/技术细节/深入探讨)
- behavioral_hints.tone_preference语气偏好轻松随意/专业简洁/学术严谨)
- knowledge_tags用户涉及的知识领域标签
**用户别名变更(增量模式):**
- **aliases_to_add**:本次新发现的用户别名,包括:
* 用户主动自我介绍:如"我叫张三"、"我的名字是XX"、"我的网名是XX"
* 他人对用户的称呼:如"同事叫我陈哥"、"大家叫我小张"、"领导叫我老陈"
* 只提取原文中逐字出现的名字,严禁推测或创造
* 禁止提取:用户给 AI 取的名字、第三方人物自身的名字、"用户"/"我" 等占位词
* 如果没有新别名,返回空数组 `[]`
- **aliases_to_remove**:用户明确否认的别名,包括:
* 用户说"我不叫XX了"、"别叫我XX"、"我改名了不叫XX" → 将 XX 放入此数组
* **严格限制**:只将用户原文中**逐字提到**的被否认名字放入,不要推断关联的其他别名
* 例如:用户说"我不叫陈小刀了" → 只移除"陈小刀",不要移除"陈哥"、"老陈"等未被提及的别名
* 如果没有要移除的别名,返回空数组 `[]`
{% if existing_aliases %}
- 已有别名:{{ existing_aliases | tojson }}(仅供参考,不需要在输出中重复)
{% endif %}
{% else %}
**"Three-Degree Principle" criteria:**
- Reusability: Will this information be used by multiple functional modules?
- Constraint: Will this information affect system behavior?
- Timeliness: Is this information long-term stable or temporary? Only extract long-term stable information.
**Extraction rules:**
- **Only extract profile information about the user themselves**, ignore information about third parties (friends, colleagues, family) mentioned by the user
- Only extract information explicitly mentioned in the text, do not speculate
- If no user profile information can be extracted, return an empty user_metadata object
- **Output language must match the input text language**
{% if existing_metadata %}
**Important: Merge with existing metadata**
Existing user metadata from the database is provided below. Combine with the user's latest statements to output the **complete merged metadata**:
- If the user explicitly negates existing info (e.g. "I no longer teach high school physics"), **remove** it from output
- If the user mentions new info, **add** it to the corresponding field
- If existing info is not negated by the user, **keep** it in the output
- Scalar fields (e.g. role, domain): replace with new value if user mentions one; otherwise keep existing
- The final output should be the complete, merged metadata — not an incremental update
{% endif %}
**Field descriptions:**
- profile.role: User's occupation or role, e.g. teacher, doctor, software engineer
- profile.domain: User's domain, e.g. education, healthcare, software development
- profile.expertise: User's skills or tools (general, not limited to programming)
- profile.interests: Topics or domain tags the user actively expressed interest in
- behavioral_hints.learning_stage: Learning stage (beginner/intermediate/advanced)
- behavioral_hints.preferred_depth: Preferred depth (overview/detailed/deep dive)
- behavioral_hints.tone_preference: Tone preference (casual/professional/academic)
- knowledge_tags: Knowledge domain tags related to the user
**User alias changes (incremental mode):**
- **aliases_to_add**: Newly discovered user aliases from this conversation, including:
* User self-introductions: e.g. "I'm John", "My name is XX", "My username is XX"
* How others address the user: e.g. "My colleagues call me Johnny", "People call me Mike"
* Only extract names that appear VERBATIM in the text — never infer or fabricate
* Do NOT extract: names the user gives to the AI, third-party people's own names, placeholder words like "User"/"I"
* If no new aliases, return empty array `[]`
- **aliases_to_remove**: Aliases the user explicitly denies, including:
* User says "Don't call me XX anymore", "I'm not called XX", "I changed my name from XX" → put XX in this array
* **Strict rule**: Only include the exact name the user **verbatim mentions** as denied. Do NOT infer or remove related aliases
* Example: User says "I'm not called John anymore" → only remove "John", do NOT remove "Johnny", "J" or other related aliases not mentioned
* If no aliases to remove, return empty array `[]`
{% if existing_aliases %}
- Existing aliases: {{ existing_aliases | tojson }} (for reference only, do not repeat in output)
{% endif %}
{% endif %}
===User Statements===
{% for stmt in statements %}
- {{ stmt }}
{% endfor %}
{% if existing_metadata %}
===Existing User Metadata===
```json
{{ existing_metadata | tojson }}
```
{% endif %}
===Output Format===
Return a JSON object with the following structure:
```json
{
"user_metadata": {
"profile": {
"role": "",
"domain": "",
"expertise": [],
"interests": []
},
"behavioral_hints": {
"learning_stage": "",
"preferred_depth": "",
"tone_preference": ""
},
"knowledge_tags": []
},
"aliases_to_add": [],
"aliases_to_remove": []
}
```
{{ json_schema }}

View File

@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
# Example: # Example:
# "Hello {{user.name}}!" -> # "Hello {{user.name}}!" ->
# ["Hello ", "{{user.name}}", "!"] # ["Hello ", "{{user.name}}", "!"]
_OUTPUT_PATTERN = re.compile(r'\{\{.*?}}|[^{}]+') _OUTPUT_PATTERN = re.compile(r'\{\{.*?}}|[^{]+|{')
# Strict variable format: {{ node_id.field_name }} # Strict variable format: {{ node_id.field_name }}
_VARIABLE_PATTERN = re.compile(r'\{\{\s*[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?\s*}}') _VARIABLE_PATTERN = re.compile(r'\{\{\s*[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)?\s*}}')

View File

@@ -55,9 +55,9 @@ class CycleGraphNode(BaseNode):
if config.output_type in [ if config.output_type in [
VariableType.ARRAY_FILE, VariableType.ARRAY_FILE,
VariableType.ARRAY_STRING, VariableType.ARRAY_STRING,
VariableType.NUMBER, VariableType.ARRAY_NUMBER,
VariableType.ARRAY_OBJECT, VariableType.ARRAY_OBJECT,
VariableType.BOOLEAN VariableType.ARRAY_BOOLEAN
]: ]:
if config.flatten: if config.flatten:
outputs['output'] = config.output_type outputs['output'] = config.output_type

View File

@@ -61,3 +61,15 @@ def get_apps_by_id(db: Session, app_id: uuid.UUID) -> App:
"""根据工作空间ID查询应用""" """根据工作空间ID查询应用"""
repo = AppRepository(db) repo = AppRepository(db)
return repo.get_apps_by_id(app_id) return repo.get_apps_by_id(app_id)
def get_release_by_version(db: Session, app_id: uuid.UUID, version: int):
"""根据版本号查询发布快照(仅返回激活状态)"""
from app.models.app_release_model import AppRelease
return db.scalars(
select(AppRelease).where(
AppRelease.app_id == app_id,
AppRelease.version == version,
AppRelease.is_active.is_(True),
)
).first()

View File

@@ -23,6 +23,7 @@ SET s += {
end_user_id: statement.end_user_id, end_user_id: statement.end_user_id,
stmt_type: statement.stmt_type, stmt_type: statement.stmt_type,
statement: statement.statement, statement: statement.statement,
speaker: statement.speaker,
emotion_intensity: statement.emotion_intensity, emotion_intensity: statement.emotion_intensity,
emotion_target: statement.emotion_target, emotion_target: statement.emotion_target,
emotion_subject: statement.emotion_subject, emotion_subject: statement.emotion_subject,
@@ -56,6 +57,7 @@ SET c += {
expired_at: chunk.expired_at, expired_at: chunk.expired_at,
dialog_id: chunk.dialog_id, dialog_id: chunk.dialog_id,
content: chunk.content, content: chunk.content,
speaker: chunk.speaker,
chunk_embedding: chunk.chunk_embedding, chunk_embedding: chunk.chunk_embedding,
sequence_number: chunk.sequence_number, sequence_number: chunk.sequence_number,
start_index: chunk.start_index, start_index: chunk.start_index,
@@ -283,7 +285,7 @@ LIMIT $limit
""" """
SEARCH_STATEMENTS_BY_KEYWORD = """ SEARCH_STATEMENTS_BY_KEYWORD = """
CALL db.index.fulltext.queryNodes("statementsFulltext", $q) YIELD node AS s, score CALL db.index.fulltext.queryNodes("statementsFulltext", $query) YIELD node AS s, score
WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id)
OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s) OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s)
OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity) OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity)
@@ -307,7 +309,7 @@ LIMIT $limit
""" """
# 查询实体名称包含指定字符串的实体 # 查询实体名称包含指定字符串的实体
SEARCH_ENTITIES_BY_NAME = """ SEARCH_ENTITIES_BY_NAME = """
CALL db.index.fulltext.queryNodes("entitiesFulltext", $q) YIELD node AS e, score CALL db.index.fulltext.queryNodes("entitiesFulltext", $query) YIELD node AS e, score
WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id) WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id)
OPTIONAL MATCH (s:Statement)-[:REFERENCES_ENTITY]->(e) OPTIONAL MATCH (s:Statement)-[:REFERENCES_ENTITY]->(e)
OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s) OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s)
@@ -337,21 +339,21 @@ LIMIT $limit
""" """
SEARCH_ENTITIES_BY_NAME_OR_ALIAS = """ SEARCH_ENTITIES_BY_NAME_OR_ALIAS = """
CALL db.index.fulltext.queryNodes("entitiesFulltext", $q) YIELD node AS e, score CALL db.index.fulltext.queryNodes("entitiesFulltext", $query) YIELD node AS e, score
WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id) WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id)
WITH e, score WITH e, score
WITH collect({entity: e, score: score}) AS fulltextResults With collect({entity: e, score: score}) AS fulltextResults
OPTIONAL MATCH (ae:ExtractedEntity) OPTIONAL MATCH (ae:ExtractedEntity)
WHERE ($end_user_id IS NULL OR ae.end_user_id = $end_user_id) WHERE ($end_user_id IS NULL OR ae.end_user_id = $end_user_id)
AND ae.aliases IS NOT NULL AND ae.aliases IS NOT NULL
AND ANY(alias IN ae.aliases WHERE toLower(alias) CONTAINS toLower($q)) AND ANY(alias IN ae.aliases WHERE toLower(alias) CONTAINS toLower($query))
WITH fulltextResults, collect(ae) AS aliasEntities WITH fulltextResults, collect(ae) AS aliasEntities
UNWIND (fulltextResults + [x IN aliasEntities | {entity: x, score: UNWIND (fulltextResults + [x IN aliasEntities | {entity: x, score:
CASE CASE
WHEN ANY(alias IN x.aliases WHERE toLower(alias) = toLower($q)) THEN 1.0 WHEN ANY(alias IN x.aliases WHERE toLower(alias) = toLower($query)) THEN 1.0
WHEN ANY(alias IN x.aliases WHERE toLower(alias) STARTS WITH toLower($q)) THEN 0.9 WHEN ANY(alias IN x.aliases WHERE toLower(alias) STARTS WITH toLower($query)) THEN 0.9
ELSE 0.8 ELSE 0.8
END END
}]) AS row }]) AS row
@@ -384,7 +386,7 @@ LIMIT $limit
SEARCH_CHUNKS_BY_CONTENT = """ SEARCH_CHUNKS_BY_CONTENT = """
CALL db.index.fulltext.queryNodes("chunksFulltext", $q) YIELD node AS c, score CALL db.index.fulltext.queryNodes("chunksFulltext", $query) YIELD node AS c, score
WHERE ($end_user_id IS NULL OR c.end_user_id = $end_user_id) WHERE ($end_user_id IS NULL OR c.end_user_id = $end_user_id)
OPTIONAL MATCH (c)-[:CONTAINS]->(s:Statement) OPTIONAL MATCH (c)-[:CONTAINS]->(s:Statement)
OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity) OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity)
@@ -501,7 +503,7 @@ LIMIT $limit
""" """
SEARCH_STATEMENTS_BY_KEYWORD_TEMPORAL = """ SEARCH_STATEMENTS_BY_KEYWORD_TEMPORAL = """
CALL db.index.fulltext.queryNodes("statementsFulltext", $q) YIELD node AS s, score CALL db.index.fulltext.queryNodes("statementsFulltext", $query) YIELD node AS s, score
WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id)
AND ((($start_date IS NULL OR (s.created_at IS NOT NULL AND datetime(s.created_at) >= datetime($start_date))) AND ((($start_date IS NULL OR (s.created_at IS NOT NULL AND datetime(s.created_at) >= datetime($start_date)))
AND ($end_date IS NULL OR (s.created_at IS NOT NULL AND datetime(s.created_at) <= datetime($end_date)))) AND ($end_date IS NULL OR (s.created_at IS NOT NULL AND datetime(s.created_at) <= datetime($end_date))))
@@ -677,7 +679,7 @@ SET n.invalid_at = $new_invalid_at
# MemorySummary keyword search using fulltext index # MemorySummary keyword search using fulltext index
SEARCH_MEMORY_SUMMARIES_BY_KEYWORD = """ SEARCH_MEMORY_SUMMARIES_BY_KEYWORD = """
CALL db.index.fulltext.queryNodes("summariesFulltext", $q) YIELD node AS m, score CALL db.index.fulltext.queryNodes("summariesFulltext", $query) YIELD node AS m, score
WHERE ($end_user_id IS NULL OR m.end_user_id = $end_user_id) WHERE ($end_user_id IS NULL OR m.end_user_id = $end_user_id)
OPTIONAL MATCH (m)-[:DERIVED_FROM_STATEMENT]->(s:Statement) OPTIONAL MATCH (m)-[:DERIVED_FROM_STATEMENT]->(s:Statement)
RETURN m.id AS id, RETURN m.id AS id,
@@ -1363,7 +1365,7 @@ RETURN c.community_id AS community_id
# Community keyword search: matches name or summary via fulltext index # Community keyword search: matches name or summary via fulltext index
SEARCH_COMMUNITIES_BY_KEYWORD = """ SEARCH_COMMUNITIES_BY_KEYWORD = """
CALL db.index.fulltext.queryNodes("communitiesFulltext", $q) YIELD node AS c, score CALL db.index.fulltext.queryNodes("communitiesFulltext", $query) YIELD node AS c, score
WHERE ($end_user_id IS NULL OR c.end_user_id = $end_user_id) WHERE ($end_user_id IS NULL OR c.end_user_id = $end_user_id)
RETURN c.community_id AS id, RETURN c.community_id AS id,
c.name AS name, c.name AS name,
@@ -1451,7 +1453,7 @@ RETURN elementId(r) AS uuid
""" """
SEARCH_PERCEPTUAL_BY_KEYWORD = """ SEARCH_PERCEPTUAL_BY_KEYWORD = """
CALL db.index.fulltext.queryNodes("perceptualFulltext", $q) YIELD node AS p, score CALL db.index.fulltext.queryNodes("perceptualFulltext", $query) YIELD node AS p, score
WHERE p.end_user_id = $end_user_id WHERE p.end_user_id = $end_user_id
RETURN p.id AS id, RETURN p.id AS id,
p.end_user_id AS end_user_id, p.end_user_id AS end_user_id,

View File

@@ -186,6 +186,58 @@ async def save_dialog_and_statements_to_neo4j(
Returns: Returns:
bool: True if successful, False otherwise bool: True if successful, False otherwise
""" """
# TODO 需要在去重消歧节阶段,做以下逻辑的处理
# 预处理:对特殊实体("用户"、"AI助手")复用 Neo4j 中已有节点的 ID
# 确保同一个 end_user_id 下只有一个"用户"节点和一个"AI助手"节点。
if entity_nodes:
_SPECIAL_NAMES = {"用户", "", "user", "i", "ai助手", "助手", "ai assistant", "assistant"}
end_user_id = entity_nodes[0].end_user_id if entity_nodes else None
if end_user_id:
try:
# 查询已有的特殊实体
cypher = """
MATCH (e:ExtractedEntity)
WHERE e.end_user_id = $end_user_id AND toLower(e.name) IN $names
RETURN e.id AS id, e.name AS name
"""
existing = await connector.execute_query(
cypher,
end_user_id=end_user_id,
names=list(_SPECIAL_NAMES),
)
# 建立 name(lower) → existing_id 映射
existing_id_map = {}
for record in (existing or []):
name_lower = (record.get("name") or "").strip().lower()
if name_lower and record.get("id"):
existing_id_map[name_lower] = record["id"]
if existing_id_map:
# 替换新实体的 ID 为已有 ID同时更新所有引用该 ID 的边
for ent in entity_nodes:
name_lower = (ent.name or "").strip().lower()
if name_lower in existing_id_map:
old_id = ent.id
new_id = existing_id_map[name_lower]
if old_id != new_id:
ent.id = new_id
# 更新 statement_entity_edges 中的引用
for edge in statement_entity_edges:
if edge.target == old_id:
edge.target = new_id
if edge.source == old_id:
edge.source = new_id
# 更新 entity_edges 中的引用
for edge in entity_edges:
if edge.source == old_id:
edge.source = new_id
if edge.target == old_id:
edge.target = new_id
logger.info(
f"特殊实体 '{ent.name}' ID 复用: {old_id[:8]}... → {new_id[:8]}..."
)
except Exception as e:
logger.warning(f"特殊实体 ID 复用查询失败(不影响写入): {e}")
# 定义事务函数,将所有写操作放在一个事务中 # 定义事务函数,将所有写操作放在一个事务中
async def _save_all_in_transaction(tx): async def _save_all_in_transaction(tx):

View File

@@ -2,6 +2,7 @@ import asyncio
import logging import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from app.core.memory.utils.data.text_utils import escape_lucene_query
from app.repositories.neo4j.cypher_queries import ( from app.repositories.neo4j.cypher_queries import (
CHUNK_EMBEDDING_SEARCH, CHUNK_EMBEDDING_SEARCH,
COMMUNITY_EMBEDDING_SEARCH, COMMUNITY_EMBEDDING_SEARCH,
@@ -87,7 +88,7 @@ async def _update_activation_values_batch(
unique_node_ids.append(node_id) unique_node_ids.append(node_id)
if not unique_node_ids: if not unique_node_ids:
logger.warning(f"批量更新激活值没有有效的节点ID") logger.warning("批量更新激活值没有有效的节点ID")
return nodes return nodes
# 记录去重信息(仅针对具有有效 ID 的节点) # 记录去重信息(仅针对具有有效 ID 的节点)
@@ -223,7 +224,7 @@ async def _update_search_results_activation(
async def search_graph( async def search_graph(
connector: Neo4jConnector, connector: Neo4jConnector,
q: str, query: str,
end_user_id: Optional[str] = None, end_user_id: Optional[str] = None,
limit: int = 50, limit: int = 50,
include: List[str] = None, include: List[str] = None,
@@ -234,14 +235,14 @@ async def search_graph(
OPTIMIZED: Runs all queries in parallel using asyncio.gather() OPTIMIZED: Runs all queries in parallel using asyncio.gather()
INTEGRATED: Updates activation values for knowledge nodes before returning results INTEGRATED: Updates activation values for knowledge nodes before returning results
- Statements: matches s.statement CONTAINS q - Statements: matches s.statement CONTAINS query
- Entities: matches e.name CONTAINS q - Entities: matches e.name CONTAINS query
- Chunks: matches s.content CONTAINS q (from Statement nodes) - Chunks: matches s.content CONTAINS query (from Statement nodes)
- Summaries: matches ms.content CONTAINS q - Summaries: matches ms.content CONTAINS query
Args: Args:
connector: Neo4j connector connector: Neo4j connector
q: Query text query: Query text for full-text search
end_user_id: Optional group filter end_user_id: Optional group filter
limit: Max results per category limit: Max results per category
include: List of categories to search (default: all) include: List of categories to search (default: all)
@@ -252,6 +253,9 @@ async def search_graph(
if include is None: if include is None:
include = ["statements", "chunks", "entities", "summaries"] include = ["statements", "chunks", "entities", "summaries"]
# Escape Lucene special characters to prevent query parse errors
escaped_query = escape_lucene_query(query)
# Prepare tasks for parallel execution # Prepare tasks for parallel execution
tasks = [] tasks = []
task_keys = [] task_keys = []
@@ -260,7 +264,7 @@ async def search_graph(
tasks.append(connector.execute_query( tasks.append(connector.execute_query(
SEARCH_STATEMENTS_BY_KEYWORD, SEARCH_STATEMENTS_BY_KEYWORD,
json_format=True, json_format=True,
q=q, query=escaped_query,
end_user_id=end_user_id, end_user_id=end_user_id,
limit=limit, limit=limit,
)) ))
@@ -270,7 +274,7 @@ async def search_graph(
tasks.append(connector.execute_query( tasks.append(connector.execute_query(
SEARCH_ENTITIES_BY_NAME_OR_ALIAS, SEARCH_ENTITIES_BY_NAME_OR_ALIAS,
json_format=True, json_format=True,
q=q, query=escaped_query,
end_user_id=end_user_id, end_user_id=end_user_id,
limit=limit, limit=limit,
)) ))
@@ -280,7 +284,7 @@ async def search_graph(
tasks.append(connector.execute_query( tasks.append(connector.execute_query(
SEARCH_CHUNKS_BY_CONTENT, SEARCH_CHUNKS_BY_CONTENT,
json_format=True, json_format=True,
q=q, query=escaped_query,
end_user_id=end_user_id, end_user_id=end_user_id,
limit=limit, limit=limit,
)) ))
@@ -290,7 +294,7 @@ async def search_graph(
tasks.append(connector.execute_query( tasks.append(connector.execute_query(
SEARCH_MEMORY_SUMMARIES_BY_KEYWORD, SEARCH_MEMORY_SUMMARIES_BY_KEYWORD,
json_format=True, json_format=True,
q=q, query=escaped_query,
end_user_id=end_user_id, end_user_id=end_user_id,
limit=limit, limit=limit,
)) ))
@@ -300,7 +304,7 @@ async def search_graph(
tasks.append(connector.execute_query( tasks.append(connector.execute_query(
SEARCH_COMMUNITIES_BY_KEYWORD, SEARCH_COMMUNITIES_BY_KEYWORD,
json_format=True, json_format=True,
q=q, query=escaped_query,
end_user_id=end_user_id, end_user_id=end_user_id,
limit=limit, limit=limit,
)) ))
@@ -482,7 +486,7 @@ async def search_graph_by_embedding(
update_time = time.time() - update_start update_time = time.time() - update_start
logger.info(f"[PERF] Activation value updates took: {update_time:.4f}s") logger.info(f"[PERF] Activation value updates took: {update_time:.4f}s")
else: else:
logger.info(f"[PERF] Skipping activation updates (only summaries)") logger.info("[PERF] Skipping activation updates (only summaries)")
return results return results
@@ -520,7 +524,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全
# 全文索引按名称检索(包含 CONTAINS 语义) # 全文索引按名称检索(包含 CONTAINS 语义)
rows = await connector.execute_query( rows = await connector.execute_query(
SEARCH_ENTITIES_BY_NAME, SEARCH_ENTITIES_BY_NAME,
q=name, query=escape_lucene_query(name),
end_user_id=end_user_id, end_user_id=end_user_id,
limit=100, limit=100,
) )
@@ -544,7 +548,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全
try: try:
rows = await connector.execute_query( rows = await connector.execute_query(
SEARCH_ENTITIES_BY_NAME, SEARCH_ENTITIES_BY_NAME,
q=name.lower(), query=escape_lucene_query(name.lower()),
end_user_id=end_user_id, end_user_id=end_user_id,
limit=100, limit=100,
) )
@@ -593,11 +597,12 @@ async def search_graph_by_keyword_temporal(
- Returns up to 'limit' statements - Returns up to 'limit' statements
""" """
if not query_text: if not query_text:
logger.warning(f"query_text不能为空") logger.warning("query_text不能为空")
return {"statements": []} return {"statements": []}
escaped_query = escape_lucene_query(query_text)
statements = await connector.execute_query( statements = await connector.execute_query(
SEARCH_STATEMENTS_BY_KEYWORD_TEMPORAL, SEARCH_STATEMENTS_BY_KEYWORD_TEMPORAL,
q=query_text, query=escaped_query,
end_user_id=end_user_id, end_user_id=end_user_id,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
@@ -671,7 +676,7 @@ async def search_graph_by_dialog_id(
- Returns up to 'limit' dialogues - Returns up to 'limit' dialogues
""" """
if not dialog_id: if not dialog_id:
logger.warning(f"dialog_id不能为空") logger.warning("dialog_id不能为空")
return {"dialogues": []} return {"dialogues": []}
dialogues = await connector.execute_query( dialogues = await connector.execute_query(
@@ -690,7 +695,7 @@ async def search_graph_by_chunk_id(
limit: int = 1, limit: int = 1,
) -> Dict[str, List[Dict[str, Any]]]: ) -> Dict[str, List[Dict[str, Any]]]:
if not chunk_id: if not chunk_id:
logger.warning(f"chunk_id不能为空") logger.warning("chunk_id不能为空")
return {"chunks": []} return {"chunks": []}
chunks = await connector.execute_query( chunks = await connector.execute_query(
SEARCH_CHUNK_BY_CHUNK_ID, SEARCH_CHUNK_BY_CHUNK_ID,
@@ -968,7 +973,7 @@ async def search_graph_l_valid_at(
async def search_perceptual( async def search_perceptual(
connector: Neo4jConnector, connector: Neo4jConnector,
q: str, query: str,
end_user_id: Optional[str] = None, end_user_id: Optional[str] = None,
limit: int = 10, limit: int = 10,
) -> Dict[str, List[Dict[str, Any]]]: ) -> Dict[str, List[Dict[str, Any]]]:
@@ -979,7 +984,7 @@ async def search_perceptual(
Args: Args:
connector: Neo4j connector connector: Neo4j connector
q: Query text query: Query text for full-text search
end_user_id: Optional user filter end_user_id: Optional user filter
limit: Max results limit: Max results
@@ -989,7 +994,7 @@ async def search_perceptual(
try: try:
perceptuals = await connector.execute_query( perceptuals = await connector.execute_query(
SEARCH_PERCEPTUAL_BY_KEYWORD, SEARCH_PERCEPTUAL_BY_KEYWORD,
q=q, query=escape_lucene_query(query),
end_user_id=end_user_id, end_user_id=end_user_id,
limit=limit, limit=limit,
) )

View File

@@ -616,6 +616,7 @@ class AppChatRequest(BaseModel):
stream: bool = Field(default=False, description="是否流式返回") stream: bool = Field(default=False, description="是否流式返回")
thinking: bool = Field(default=False, description="是否启用深度思考需Agent配置支持") thinking: bool = Field(default=False, description="是否启用深度思考需Agent配置支持")
files: List[FileInput] = Field(default_factory=list, description="附件列表(支持多文件)") files: List[FileInput] = Field(default_factory=list, description="附件列表(支持多文件)")
version: Optional[int] = Field(default=None, description="指定发布版本号,不传则使用当前发布版本")
class DraftRunRequest(BaseModel): class DraftRunRequest(BaseModel):

View File

@@ -620,6 +620,28 @@ class AppService:
self._validate_app_accessible(app, workspace_id) self._validate_app_accessible(app, workspace_id)
return app return app
def get_release_by_version(self, app_id: uuid.UUID, version: int) -> AppRelease:
"""按版本号获取发布快照
Args:
app_id: 应用ID
version: 版本号(整数,按应用内递增)
Returns:
AppRelease: 发布快照
Raises:
BusinessException: 版本不存在或已下线
"""
from app.repositories.app_repository import get_release_by_version
release = get_release_by_version(self.db, app_id, version)
if not release:
raise BusinessException(
f"版本 {version} 不存在或已下线",
BizCode.RELEASE_NOT_FOUND,
)
return release
def create_app( def create_app(
self, self,
*, *,

View File

@@ -803,7 +803,6 @@ def get_rag_content(
"page": { "page": {
"page": page, "page": page,
"pagesize": pagesize, "pagesize": pagesize,
"total": 0,
"hasnext": False, "hasnext": False,
}, },
"items": [] "items": []
@@ -897,13 +896,12 @@ def get_rag_content(
"page": { "page": {
"page": page, "page": page,
"pagesize": pagesize, "pagesize": pagesize,
"total": global_total,
"hasnext": offset_end < global_total, "hasnext": offset_end < global_total,
}, },
"items": conversations "items": conversations
} }
business_logger.info(f"成功获取RAG内容: total={global_total}, page={page}, 返回={len(conversations)} 条对话") business_logger.info(f"成功获取RAG内容: page={page}, 返回={len(conversations)} 条对话")
return result return result
except Exception as e: except Exception as e:

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import json
import os import os
import re import re
import shutil import shutil
@@ -1001,7 +1002,7 @@ def sync_knowledge_for_kb(kb_id: uuid.UUID):
except Exception as e: except Exception as e:
print(f"\n\nError during fetch feishu: {e}") print(f"\n\nError during fetch feishu: {e}")
case _: # General case _: # General
print(f"General: No synchronization needed\n") print("General: No synchronization needed\n")
result = f"sync knowledge '{db_knowledge.name}' processed successfully." result = f"sync knowledge '{db_knowledge.name}' processed successfully."
return result return result
@@ -1510,6 +1511,7 @@ def write_all_workspaces_memory_task(self) -> Dict[str, Any]:
"status": "SUCCESS", "status": "SUCCESS",
"total_num": total_num, "total_num": total_num,
"end_user_count": len(end_users), "end_user_count": len(end_users),
"end_user_details": end_user_details,
"memory_increment_id": str(memory_increment.id), "memory_increment_id": str(memory_increment.id),
"created_at": memory_increment.created_at.isoformat(), "created_at": memory_increment.created_at.isoformat(),
}) })
@@ -2602,35 +2604,34 @@ def init_interest_distribution_for_users(self, end_user_ids: List[str]) -> Dict[
service = MemoryAgentService() service = MemoryAgentService()
with get_db_context() as db: for end_user_id in end_user_ids:
for end_user_id in end_user_ids: # 存在性检查:缓存有数据则跳过
# 存在性检查:缓存有数据则跳过 cached = await InterestMemoryCache.get_interest_distribution(
cached = await InterestMemoryCache.get_interest_distribution( end_user_id=end_user_id,
language=language,
)
if cached is not None:
skipped += 1
continue
logger.info(f"用户 {end_user_id} 无兴趣分布缓存,开始生成")
try:
result = await service.get_interest_distribution_by_user(
end_user_id=end_user_id, end_user_id=end_user_id,
limit=5,
language=language, language=language,
) )
if cached is not None: await InterestMemoryCache.set_interest_distribution(
skipped += 1 end_user_id=end_user_id,
continue language=language,
data=result,
logger.info(f"用户 {end_user_id} 无兴趣分布缓存,开始生成") expire=INTEREST_CACHE_EXPIRE,
try: )
result = await service.get_interest_distribution_by_user( initialized += 1
end_user_id=end_user_id, logger.info(f"用户 {end_user_id} 兴趣分布缓存生成成功")
limit=5, except Exception as e:
language=language, failed += 1
) logger.error(f"用户 {end_user_id} 兴趣分布缓存生成失败: {e}")
await InterestMemoryCache.set_interest_distribution(
end_user_id=end_user_id,
language=language,
data=result,
expire=INTEREST_CACHE_EXPIRE,
)
initialized += 1
logger.info(f"用户 {end_user_id} 兴趣分布缓存生成成功")
except Exception as e:
failed += 1
logger.error(f"用户 {end_user_id} 兴趣分布缓存生成失败: {e}")
logger.info(f"兴趣分布按需初始化完成: 初始化={initialized}, 跳过={skipped}, 失败={failed}") logger.info(f"兴趣分布按需初始化完成: 初始化={initialized}, 跳过={skipped}, 失败={failed}")
return { return {
@@ -2914,4 +2915,270 @@ def init_community_clustering_for_users(self, end_user_ids: List[str], workspace
} }
# ─── User Metadata Extraction Task ───────────────────────────────────────────
def _update_timestamps(existing: dict, new: dict, updated_at: dict, now: str, prefix: str = "") -> None:
"""对比新旧元数据,更新变更字段的 _updated_at 时间戳。"""
for key, new_val in new.items():
if key == "_updated_at":
continue
path = f"{prefix}.{key}" if prefix else key
old_val = existing.get(key)
if isinstance(new_val, dict) and isinstance(old_val, dict):
_update_timestamps(old_val, new_val, updated_at, now, prefix=path)
elif old_val != new_val:
updated_at[path] = now
@celery_app.task(
bind=True,
name='app.tasks.extract_user_metadata',
ignore_result=False,
max_retries=0,
acks_late=True,
time_limit=300,
soft_time_limit=240,
)
def extract_user_metadata_task(
self,
end_user_id: str,
statements: List[str],
config_id: Optional[str] = None,
language: str = "zh",
) -> Dict[str, Any]:
"""异步提取用户元数据并写入数据库。
在去重消歧完成后由编排器触发,使用独立 LLM 调用提取元数据。
LLM 配置优先使用 config_id 对应的应用配置,失败时回退到工作空间默认配置。
Args:
end_user_id: 终端用户 ID
statements: 用户相关的 statement 文本列表
config_id: 应用配置 ID可选
language: 语言类型 ("zh" 中文, "en" 英文)
Returns:
包含任务执行结果的字典
"""
start_time = time.time()
logger.info(
f"[CELERY METADATA] Starting metadata extraction - end_user_id={end_user_id}, "
f"statements_count={len(statements)}, config_id={config_id}, language={language}"
)
async def _run() -> Dict[str, Any]:
from app.core.memory.storage_services.extraction_engine.knowledge_extraction.metadata_extractor import MetadataExtractor
from app.repositories.end_user_info_repository import EndUserInfoRepository
from app.repositories.end_user_repository import EndUserRepository
from app.services.memory_config_service import MemoryConfigService
# 1. 获取 LLM 配置(应用配置 → 工作空间配置兜底)并创建 LLM client
with get_db_context() as db:
end_user_uuid = uuid.UUID(end_user_id)
# 获取 workspace_id from end_user
end_user = EndUserRepository(db).get_by_id(end_user_uuid)
if not end_user:
return {"status": "FAILURE", "error": f"End user not found: {end_user_id}"}
workspace_id = end_user.workspace_id
config_service = MemoryConfigService(db)
memory_config = config_service.get_config_with_fallback(
memory_config_id=uuid.UUID(config_id) if config_id else None,
workspace_id=workspace_id,
)
if not memory_config:
return {"status": "FAILURE", "error": "No LLM config available (app + workspace fallback failed)"}
# 2. 创建 LLM client
from app.core.memory.utils.llm.llm_utils import MemoryClientFactory
factory = MemoryClientFactory(db)
if not memory_config.llm_id:
return {"status": "FAILURE", "error": "Memory config has no LLM model configured"}
llm_client = factory.get_llm_client(memory_config.llm_id)
# 2.5 读取已有元数据和别名,传给 extractor 作为上下文
existing_metadata = None
existing_aliases = None
try:
info = EndUserInfoRepository(db).get_by_end_user_id(end_user_uuid)
if info:
if info.meta_data:
existing_metadata = info.meta_data
existing_aliases = info.aliases if info.aliases else []
logger.info(f"[CELERY METADATA] 已读取已有元数据和别名aliases={existing_aliases}")
except Exception as e:
logger.warning(f"[CELERY METADATA] 读取已有数据失败(继续无上下文提取): {e}")
# 3. 提取元数据和别名(传入已有数据作为上下文)
extractor = MetadataExtractor(llm_client=llm_client, language=language)
extract_result = await extractor.extract_metadata(
statements,
existing_metadata=existing_metadata,
existing_aliases=existing_aliases,
)
if not extract_result:
logger.info(f"[CELERY METADATA] No metadata extracted for end_user_id={end_user_id}")
return {"status": "SUCCESS", "result": "no_metadata_extracted"}
user_metadata, aliases_to_add, aliases_to_remove = extract_result
logger.info(f"[CELERY METADATA] LLM 别名新增: {aliases_to_add}, 移除: {aliases_to_remove}")
# 4. 清洗元数据、覆盖写入元数据和别名
def clean_metadata(raw: dict) -> dict:
"""递归移除空字符串、空列表、空字典。"""
result = {}
for k, v in raw.items():
if v == "" or v == []:
continue
if isinstance(v, dict):
cleaned = clean_metadata(v)
if cleaned:
result[k] = cleaned
else:
result[k] = v
return result
raw_dict = user_metadata.model_dump(exclude_none=True) if user_metadata else {}
logger.info(f"[CELERY METADATA] LLM 输出完整元数据: {json.dumps(raw_dict, ensure_ascii=False)}")
cleaned = clean_metadata(raw_dict) if raw_dict else {}
logger.info(f"[CELERY METADATA] 清洗后元数据: {json.dumps(cleaned, ensure_ascii=False)}")
from datetime import datetime as dt, timezone as tz
now = dt.now(tz.utc).isoformat()
# 过滤别名中的占位名称,执行增量增删
_PLACEHOLDER_NAMES = {"用户", "", "user", "i"}
def _filter_aliases(aliases_list):
seen = set()
result = []
for a in aliases_list:
a_stripped = a.strip()
if a_stripped and a_stripped.lower() not in _PLACEHOLDER_NAMES and a_stripped.lower() not in seen:
result.append(a_stripped)
seen.add(a_stripped.lower())
return result
filtered_add = _filter_aliases(aliases_to_add)
filtered_remove = _filter_aliases(aliases_to_remove)
remove_lower = {a.lower() for a in filtered_remove}
with get_db_context() as db:
end_user_uuid = uuid.UUID(end_user_id)
info = EndUserInfoRepository(db).get_by_end_user_id(end_user_uuid)
end_user = EndUserRepository(db).get_by_id(end_user_uuid)
if info:
# 元数据覆盖写入
if cleaned:
existing_meta = info.meta_data if info.meta_data else {}
updated_at = dict(existing_meta.get("_updated_at", {}))
_update_timestamps(existing_meta, cleaned, updated_at, now)
final = dict(cleaned)
final["_updated_at"] = updated_at
info.meta_data = final
logger.info("[CELERY METADATA] 覆盖写入元数据")
# 别名增量增删:(已有 - remove) + add
old_aliases = info.aliases if info.aliases else []
# 先移除
merged = [a for a in old_aliases if a.strip().lower() not in remove_lower]
# 再追加(去重)
existing_lower = {a.strip().lower() for a in merged}
for a in filtered_add:
if a.lower() not in existing_lower:
merged.append(a)
existing_lower.add(a.lower())
if merged != old_aliases:
info.aliases = merged
# other_name 更新逻辑
if merged and (
not info.other_name
or info.other_name.strip().lower() in _PLACEHOLDER_NAMES
or info.other_name.strip().lower() in remove_lower
):
info.other_name = merged[0]
if end_user and merged and (
not end_user.other_name
or end_user.other_name.strip().lower() in _PLACEHOLDER_NAMES
or end_user.other_name.strip().lower() in remove_lower
):
end_user.other_name = merged[0]
logger.info(
f"[CELERY METADATA] 别名增量更新: {old_aliases} - {filtered_remove} + {filtered_add}{merged}"
)
else:
# 没有 end_user_info 记录,创建一条
from app.models.end_user_info_model import EndUserInfo
initial_aliases = filtered_add # 新记录只有 add没有 remove
first_alias = initial_aliases[0] if initial_aliases else ""
if first_alias or cleaned:
new_info = EndUserInfo(
end_user_id=end_user_uuid,
other_name=first_alias or "",
aliases=initial_aliases,
meta_data=cleaned if cleaned else None,
)
db.add(new_info)
if end_user and first_alias and (
not end_user.other_name or end_user.other_name.strip().lower() in _PLACEHOLDER_NAMES
):
end_user.other_name = first_alias
logger.info(f"[CELERY METADATA] 创建 end_user_info: other_name={first_alias}, aliases={initial_aliases}")
else:
return {"status": "SUCCESS", "result": "no_data_to_write"}
db.commit()
# 同步 PgSQL aliases 到 Neo4j 用户实体PgSQL 为权威源)
final_aliases = info.aliases if info else initial_aliases
if final_aliases:
try:
from app.repositories.neo4j.neo4j_connector import Neo4jConnector
neo4j_connector = Neo4jConnector()
cypher = """
MATCH (e:ExtractedEntity)
WHERE e.end_user_id = $end_user_id AND e.name IN ['用户', '', 'User', 'I']
SET e.aliases = $aliases
"""
await neo4j_connector.execute_query(
cypher, end_user_id=end_user_id, aliases=final_aliases
)
await neo4j_connector.close()
logger.info(f"[CELERY METADATA] Neo4j 用户实体 aliases 已同步: {final_aliases}")
except Exception as neo4j_err:
logger.warning(f"[CELERY METADATA] Neo4j aliases 同步失败(不影响主流程): {neo4j_err}")
return {"status": "SUCCESS", "result": "metadata_and_aliases_written"}
loop = None
try:
loop = set_asyncio_event_loop()
result = loop.run_until_complete(_run())
elapsed = time.time() - start_time
result["elapsed_time"] = elapsed
result["task_id"] = self.request.id
logger.info(f"[CELERY METADATA] Task completed - elapsed={elapsed:.2f}s, result={result.get('result')}")
return result
except Exception as e:
elapsed = time.time() - start_time
logger.error(f"[CELERY METADATA] Task failed - elapsed={elapsed:.2f}s, error={e}", exc_info=True)
return {
"status": "FAILURE",
"error": str(e),
"elapsed_time": elapsed,
"task_id": self.request.id,
}
finally:
if loop:
_shutdown_loop_gracefully(loop)
# unused task # unused task

View File

@@ -1496,6 +1496,32 @@ export const en = {
resetFeaturesTip: 'Please reconfigure the [Conversation Features - File Upload] settings', resetFeaturesTip: 'Please reconfigure the [Conversation Features - File Upload] settings',
logTitle: 'Description', logTitle: 'Description',
range: 'Range', range: 'Range',
body: 'BODY Parameter Example',
bodyRequestExample: `{
"message": "user message content",
// string, required, the conversation content entered by the user;
"conversation_id": "conversation_id",
// string, optional, session ID; for multi-turn conversations, pass the conversation_id from the previous response; omit on first request;
"user_id": "user_id",
// string, optional, end-user identifier to distinguish memory and sessions across users; recommended to pass your business system user ID;
"variables": {},
// object, optional (requires application configuration to take effect);
"stream": false,
// boolean, optional, whether to stream the response; defaults to false; when true, returns an SSE event stream;
"thinking": false,
// boolean, optional, whether to enable deep thinking; defaults to false (requires application configuration when true);
"files": [],
// array, optional, list of multimodal attachments (requires application configuration to take effect);
"version":"app_release_id"
// string, optional, application version ID; specify a historical release version ID, or omit to use the currently active version;
}`,
}, },
userMemory: { userMemory: {
userMemory: 'User Memory', userMemory: 'User Memory',
@@ -2239,6 +2265,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
addvariable: 'Chat Variables', addvariable: 'Chat Variables',
addChatVariable: 'Add Chat Variable', addChatVariable: 'Add Chat Variable',
editChatVariable: 'Edit Chat Variable', editChatVariable: 'Edit Chat Variable',
invalidJSON: 'Invalid JSON format',
config: { config: {
llm: { llm: {
@@ -2341,6 +2368,11 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
"eq": 'Is', "eq": 'Is',
"ne": 'Is Not', "ne": 'Is Not',
}, },
file: {
"empty": 'Not Exist',
"not_empty": 'Exists',
eq: 'All Are'
},
else_desc: 'Used to define the logic that should be executed when the if condition is not met.', else_desc: 'Used to define the logic that should be executed when the if condition is not met.',
unset: 'Condition Not Set', unset: 'Condition Not Set',
set: 'Set', set: 'Set',
@@ -2519,6 +2551,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
'document-extractor.file_selector': 'File variable', 'document-extractor.file_selector': 'File variable',
'list-operator.input_list': 'Input list', 'list-operator.input_list': 'Input list',
}, },
checkListHasErrors: 'Please resolve all issues in the checklist before publishing',
}, },
emotionEngine: { emotionEngine: {
emotionEngineConfig: 'Emotion Engine Configuration', emotionEngineConfig: 'Emotion Engine Configuration',

View File

@@ -831,6 +831,32 @@ export const zh = {
resetFeaturesTip: '请重新配置【对话功能-文件上传】功能', resetFeaturesTip: '请重新配置【对话功能-文件上传】功能',
logTitle: '描述', logTitle: '描述',
range: '范围', range: '范围',
body: 'BODY 参数示例',
bodyRequestExample: `{
"message": "用户消息内容",
// string必填用户输入的对话内容
"conversation_id": "conversation_id",
// string可选会话ID多轮对话时传上一次返回的conversation_id首次不传
"user_id": "user_id",
// string可选终端用户标识用于区分不同用户的记忆和会话建议传业务系统的用户ID
"variables": {},
// object可选需要应用配置才支持生效
"stream": false,
// boolean可选是否流式返回默认 falsetrue时返回SSE事件流
"thinking": false,
// boolean可选是否启用深度思考默认 falsetrue时需要应用配置才支持生效
"files": [],
// array可选多模态附件列表需要应用配置才支持生效
"version":"app_release_id"
//string可选应用版本ID指定历史发布版本ID不传则使用当前生效版本
}`,
}, },
table: { table: {
totalRecords: '共 {{total}} 条记录' totalRecords: '共 {{total}} 条记录'
@@ -2200,6 +2226,7 @@ export const zh = {
addvariable: '会话变量', addvariable: '会话变量',
addChatVariable: '添加会话变量', addChatVariable: '添加会话变量',
editChatVariable: '编辑会话变量', editChatVariable: '编辑会话变量',
invalidJSON: 'JSON 格式不正确',
config: { config: {
llm: { llm: {
@@ -2302,6 +2329,11 @@ export const zh = {
"eq": '是', "eq": '是',
"ne": '不是', "ne": '不是',
}, },
file: {
"empty": '不存在',
"not_empty": '存在',
eq: '全都是'
},
else_desc: '用于定义当 if 条件不满足时应执行的逻辑。', else_desc: '用于定义当 if 条件不满足时应执行的逻辑。',
unset: '条件未设置', unset: '条件未设置',
set: '已设置', set: '已设置',
@@ -2483,6 +2515,7 @@ export const zh = {
'document-extractor.file_selector': '文件变量', 'document-extractor.file_selector': '文件变量',
'list-operator.input_list': '输入变量', 'list-operator.input_list': '输入变量',
}, },
checkListHasErrors: '发布前确认检查清单中所有问题均已解决',
}, },
emotionEngine: { emotionEngine: {
emotionEngineConfig: '情感引擎配置', emotionEngineConfig: '情感引擎配置',

21
web/src/store/workflow.ts Normal file
View File

@@ -0,0 +1,21 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-10 18:11:19
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-10 18:11:19
*/
import { create } from 'zustand'
import type { NodeCheckResult } from '@/views/Workflow/components/CheckList'
interface WorkflowState {
checkResults: Record<string, NodeCheckResult[]>
setCheckResults: (appId: string, results: NodeCheckResult[]) => void
getCheckResults: (appId: string) => NodeCheckResult[]
}
export const useWorkflowStore = create<WorkflowState>((set, get) => ({
checkResults: {},
setCheckResults: (appId, results) =>
set(state => ({ checkResults: { ...state.checkResults, [appId]: results } })),
getCheckResults: (appId) => get().checkResults[appId] ?? [],
}))

View File

@@ -421,3 +421,6 @@ body {
.ant-picker-outlined:focus-within { .ant-picker-outlined:focus-within {
box-shadow: none; box-shadow: none;
} }
.ͼ1.cm-focused {
outline: none;
}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:29:29 * @Date: 2026-02-03 16:29:29
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-26 15:31:36 * @Last Modified time: 2026-04-10 18:09:56
*/ */
import { type FC, useState, useRef, useEffect } from 'react'; import { type FC, useState, useRef, useEffect } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -18,6 +18,7 @@ import ApiKeyConfigModal from './components/ApiKeyConfigModal';
import { getApiKeyList, getApiKeyStats, deleteApiKey } from '@/api/apiKey'; import { getApiKeyList, getApiKeyStats, deleteApiKey } from '@/api/apiKey';
import { maskApiKeys } from '@/utils/apiKeyReplacer' import { maskApiKeys } from '@/utils/apiKeyReplacer'
import RbCard from '@/components/RbCard/Card'; import RbCard from '@/components/RbCard/Card';
import CodeMirrorEditor from '@/components/CodeMirrorEditor'
/** /**
* API configuration page component * API configuration page component
@@ -155,6 +156,21 @@ const Api: FC<{ application: Application | null }> = ({ application }) => {
{t('common.copy')} {t('common.copy')}
</Button> </Button>
</Flex> </Flex>
<div className="rb:font-medium rb:mt-4!">
{t('application.body')}
</div>
<Flex align="start" justify="space-between" className="rb:text-[#5B6167] rb:mt-3! rb:py-2! rb:px-4! rb:bg-white rb-border rb:rounded-lg rb:leading-5">
<CodeMirrorEditor readOnly={true} value={t('application.bodyRequestExample')} />
<Button className="rb:px-2! rb:h-7! rb:group" onClick={() => handleCopy(t('application.bodyRequestExample'))}>
<div
className="rb:w-4 rb:h-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/copy.svg')] rb:group-hover:bg-[url('@/assets/images/copy_active.svg')]"
></div>
{t('common.copy')}
</Button>
</Flex>
</RbCard> </RbCard>
<RbCard <RbCard
title={() => (<Flex align="center"> title={() => (<Flex align="center">

View File

@@ -2,12 +2,13 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:29:41 * @Date: 2026-02-03 16:29:41
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-26 15:24:41 * @Last Modified time: 2026-04-10 17:02:07
*/ */
import { type FC, useState, useEffect, useRef } from 'react'; import { type FC, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import clsx from 'clsx'; import clsx from 'clsx';
import { Space, Input, Form, App, Flex } from 'antd'; import { Space, Input, Form, App, Flex } from 'antd';
import copy from 'copy-to-clipboard';
import Tag, { type TagProps } from './components/Tag' import Tag, { type TagProps } from './components/Tag'
import RbCard from '@/components/RbCard/Card' import RbCard from '@/components/RbCard/Card'
@@ -17,6 +18,7 @@ import ReleaseShareModal from './components/ReleaseShareModal'
import AppSharingModal from './components/AppSharingModal' import AppSharingModal from './components/AppSharingModal'
import type { Release, ReleaseModalRef, ReleaseShareModalRef, AppSharingModalRef } from './types' import type { Release, ReleaseModalRef, ReleaseShareModalRef, AppSharingModalRef } from './types'
import type { Application } from '@/views/ApplicationManagement/types' import type { Application } from '@/views/ApplicationManagement/types'
import { useWorkflowStore } from '@/store/workflow'
import Empty from '@/components/Empty' import Empty from '@/components/Empty'
import { formatDateTime } from '@/utils/format'; import { formatDateTime } from '@/utils/format';
import Markdown from '@/components/Markdown' import Markdown from '@/components/Markdown'
@@ -40,6 +42,7 @@ const heightClass = 'rb:max-h-[calc(100vh-140px)]'
const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refresh}) => { const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refresh}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { message } = App.useApp() const { message } = App.useApp()
const { getCheckResults } = useWorkflowStore()
const releaseModalRef = useRef<ReleaseModalRef>(null) const releaseModalRef = useRef<ReleaseModalRef>(null)
const releaseShareModalRef = useRef<ReleaseShareModalRef>(null) const releaseShareModalRef = useRef<ReleaseShareModalRef>(null)
const appSharingModalRef = useRef<AppSharingModalRef>(null) const appSharingModalRef = useRef<AppSharingModalRef>(null)
@@ -75,6 +78,10 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
if (!selectedVersion) return if (!selectedVersion) return
appExport(data.id, data.name, { release_id: selectedVersion.id}) appExport(data.id, data.name, { release_id: selectedVersion.id})
} }
const handleCopy = (id: string) => {
copy(id)
message.success(t('common.copySuccess'))
}
return ( return (
<Flex gap={12}> <Flex gap={12}>
<div className="rb:w-101 rb:h-full"> <div className="rb:w-101 rb:h-full">
@@ -102,7 +109,7 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
</Tag>} </Tag>}
</>} </>}
className={clsx("rb:hover:shadow-[0px_2px_8px_0px_rgba(0,0,0,0.2)]! rb:cursor-pointer rb:bg-white", { className={clsx("rb:hover:shadow-[0px_2px_8px_0px_rgba(0,0,0,0.2)]! rb:cursor-pointer rb:bg-white", {
'rb:border-[#171719]!': version.id === selectedVersion.id, 'rb:border! rb:border-[#171719]!': version.id === selectedVersion.id,
'rb:border-[#DFE4ED] ': version.id !== selectedVersion.id 'rb:border-[#DFE4ED] ': version.id !== selectedVersion.id
})} })}
headerType="borderless" headerType="borderless"
@@ -140,13 +147,30 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
<RbButton type="primary" ghost onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</RbButton> <RbButton type="primary" ghost onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</RbButton>
{data?.type !== 'multi_agent' && <RbButton type="primary" ghost onClick={() => appSharingModalRef.current?.handleOpen()}>{t('application.sharing')}</RbButton>} {data?.type !== 'multi_agent' && <RbButton type="primary" ghost onClick={() => appSharingModalRef.current?.handleOpen()}>{t('application.sharing')}</RbButton>}
</>} </>}
<RbButton type="primary" onClick={() => releaseModalRef.current?.handleOpen()}>{t('application.release')}</RbButton> <RbButton type="primary" onClick={async () => {
if (data?.type === 'workflow') {
const errors = getCheckResults(data.id)
if (errors.length) {
message.error(t('workflow.checkListHasErrors'))
return
}
}
releaseModalRef.current?.handleOpen()
}}>{t('application.release')}</RbButton>
</Space> </Space>
</Flex> </Flex>
{selectedVersion && {selectedVersion &&
<Flex gap={16} vertical className={`${heightClass} rb:overflow-y-auto`}> <Flex gap={16} vertical className={`${heightClass} rb:overflow-y-auto`}>
<RbCard <RbCard
title={t('application.VersionInformation')} title={() => <Flex>{t('application.VersionInformation')}
<Flex align="center" className="rb:text-[#5B6167] rb:text-[12px]">
(ID: {selectedVersion.id}
<div className="rb:size-4.5 rb:ml-1 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/common/copy_dark.svg')]"
onClick={() => handleCopy(selectedVersion.id)}
></div>
)
</Flex>
</Flex>}
headerType="borderless" headerType="borderless"
> >
<div className="rb:grid rb:grid-cols-3 rb:gap-4"> <div className="rb:grid rb:grid-cols-3 rb:gap-4">

View File

@@ -207,7 +207,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
</Flex>} </Flex>}
extra={application?.type === 'workflow' && source !== 'sharing' && activeTab === 'arrangement' extra={application?.type === 'workflow' && source !== 'sharing' && activeTab === 'arrangement'
? <Flex align="center" justify="end" gap={10} className="rb:h-8"> ? <Flex align="center" justify="end" gap={10} className="rb:h-8">
<CheckList workflowRef={workflowRef} /> <CheckList workflowRef={workflowRef} appId={application?.id ?? ''} />
<Popover content={t('application.features')} classNames={{ body: 'rb:py-0.5! rb:px-1! rb:rounded-[6px]! rb:text-[12px]!' }}> <Popover content={t('application.features')} classNames={{ body: 'rb:py-0.5! rb:px-1! rb:rounded-[6px]! rb:text-[12px]!' }}>
<div <div
className="rb:cursor-pointer rb:size-7.5 rb:border rb:border-[#EBEBEB] rb:hover:bg-[#F6F6F6] rb:rounded-[10px] rb:bg-[url('@/assets/images/workflow/features.svg')] rb:bg-size-[16px_16px] rb:bg-center rb:bg-no-repeat" className="rb:cursor-pointer rb:size-7.5 rb:border rb:border-[#EBEBEB] rb:hover:bg-[#F6F6F6] rb:rounded-[10px] rb:bg-[url('@/assets/images/workflow/features.svg')] rb:bg-size-[16px_16px] rb:bg-center rb:bg-no-repeat"

View File

@@ -2,9 +2,9 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 18:34:04 * @Date: 2026-02-03 18:34:04
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-31 15:35:13 * @Last Modified time: 2026-04-10 16:32:52
*/ */
import { type FC, useState } from 'react' import { type FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { Divider, Flex } from 'antd' import { Divider, Flex } from 'antd'
@@ -23,7 +23,6 @@ interface DataItem {
const ConversationMemory: FC = () => { const ConversationMemory: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { id } = useParams() const { id } = useParams()
const [total, setTotal] = useState(0)
return ( return (
<RbCard <RbCard
@@ -32,14 +31,12 @@ const ConversationMemory: FC = () => {
headerClassName="rb:min-h-[54px]! rb:pt-0! rb:mb-0!" headerClassName="rb:min-h-[54px]! rb:pt-0! rb:mb-0!"
bodyClassName="rb:p-4! rb:pt-0! rb:pb-1! rb:h-[calc(100%-54px)]!" bodyClassName="rb:p-4! rb:pt-0! rb:pb-1! rb:h-[calc(100%-54px)]!"
className="rb:h-full!" className="rb:h-full!"
extra={<div className="rb:text-[#5B6167] rb:leading-5">{t('userMemory.totalRagMemory')}: <span className="rb:font-medium rb:text-[#171719]">{total}</span></div>}
> >
<PageScrollList<DataItem> <PageScrollList<DataItem>
url={getRagContentUrl} url={getRagContentUrl}
query={{ end_user_id: id }} query={{ end_user_id: id }}
column={1} column={1}
gutter={0} gutter={0}
onTotalChange={setTotal}
renderItem={(item, index) => ( renderItem={(item, index) => (
<div> <div>
{index !== 0 && <Divider className="rb:mt-1! rb:mb-3! rb:ml-11!" />} {index !== 0 && <Divider className="rb:mt-1! rb:mb-3! rb:ml-11!" />}

View File

@@ -5,7 +5,7 @@
* @Last Modified time: 2026-04-08 11:05:34 * @Last Modified time: 2026-04-08 11:05:34
*/ */
import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react'; import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react';
import { Form, Input, Select, InputNumber, Button, Row, Col, Flex, Spin } from 'antd'; import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -124,7 +124,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
setFileList(list); setFileList(list);
} }
} else if (variable.type.includes('object') && variable.defaultValue) { } else if (variable.type.includes('object') && variable.defaultValue) {
form.setFieldValue('defaultValue', JSON.stringify(variable.defaultValue, null, 2)) form.setFieldValue('defaultValue', variable.defaultValue ? JSON.stringify(variable.defaultValue, null, 2) : undefined)
} }
} else { } else {
form.resetFields(); form.resetFields();
@@ -342,7 +342,19 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
</Form.Item> </Form.Item>
) )
: ( : (
<Form.Item name="defaultValue" label={t('workflow.config.parameter-extractor.default')}> <Form.Item
name="defaultValue"
label={t('workflow.config.parameter-extractor.default')}
rules={[
(type === 'object' || type === 'array[object]') ? {
validator: (_, value) => {
if (!value) return Promise.resolve();
try { JSON.parse(value); return Promise.resolve(); }
catch { return Promise.reject(t('workflow.invalidJSON')); }
}
} : {}
]}
>
{type === 'number' {type === 'number'
? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} /> ? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
: type === 'boolean' : type === 'boolean'

View File

@@ -1,4 +1,4 @@
import { type FC, useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef, type FC } from 'react'
import { Popover, Flex } from 'antd' import { Popover, Flex } from 'antd'
import { WarningFilled } from '@ant-design/icons' import { WarningFilled } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -8,17 +8,19 @@ import type { WorkflowRef } from '@/views/ApplicationConfig/types'
import { nodeLibrary } from '../../constant' import { nodeLibrary } from '../../constant'
import { getToolMethods } from '@/api/tools' import { getToolMethods } from '@/api/tools'
import RbDrawer from '@/components/RbDrawer' import RbDrawer from '@/components/RbDrawer'
import { useWorkflowStore } from '@/store/workflow'
interface CheckListProps { interface CheckListProps {
workflowRef: React.RefObject<WorkflowRef> workflowRef: React.RefObject<WorkflowRef>
appId: string
} }
interface CheckError { export interface CheckError {
key: string key: string
message: string message: string
} }
interface NodeCheckResult { export interface NodeCheckResult {
id: string id: string
name: string name: string
type: string type: string
@@ -112,10 +114,67 @@ function validateNode(type: string, config: Record<string, any>): CheckError[] {
return errors return errors
} }
const CheckList: FC<CheckListProps> = ({ workflowRef }) => { export async function runCheckOnGraph(
graph: import('@antv/x6').Graph,
t: (key: string) => string
): Promise<NodeCheckResult[]> {
const nodes = graph.getNodes()
const edges = graph.getEdges()
const targetIds = new Set<string>()
const childTargetIds = new Set<string>()
edges.forEach(e => {
targetIds.add(e.getTargetCellId())
const srcData = graph.getCellById(e.getSourceCellId())?.getData()
const tgtData = graph.getCellById(e.getTargetCellId())?.getData()
if (srcData?.cycle && tgtData?.cycle && srcData.cycle === tgtData.cycle) {
childTargetIds.add(e.getTargetCellId())
}
})
const checked: NodeCheckResult[] = []
for (const node of nodes) {
const data = node.getData()
if (!data || ['add-node', 'notes', 'cycle-start', 'break'].includes(data.type)) continue
const errors: CheckError[] = []
const isChildNode = !!data.cycle
const hasIncoming = isChildNode ? childTargetIds.has(node.id) : !['start', 'cycle-start'].includes(data.type) ? targetIds.has(node.id) : true
if (!hasIncoming) errors.push({ key: 'notConnected', message: t('workflow.notConnected') })
const configErrors = validateNode(data.type, data.config ?? {})
configErrors.forEach(e => {
errors.push({ key: e.key, message: `${t(`workflow.checkListErrors.${e.key}`)} ${t('workflow.cannotBeEmpty')}`.trim() })
})
if (data.type === 'tool') {
const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id
const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {}
if (toolId) {
try {
const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
const operation = toolParameters?.operation
const method = operation ? methods.find(m => m.name === operation) : methods[0]
if (method) {
method.parameters
.filter(p => p.required && (toolParameters[p.name] === undefined || toolParameters[p.name] === null || toolParameters[p.name] === ''))
.forEach(p => errors.push({ key: 'tool.tool_parameters', message: `${p.name} ${t('workflow.cannotBeEmpty')}` }))
}
} catch { /* ignore */ }
}
}
if (errors.length) {
checked.push({ id: node.id, name: data.name || t(`workflow.${data.type}`), type: data.type, icon: nodeIconMap[data.type] ?? '', errors })
}
}
return checked
}
const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [results, setResults] = useState<NodeCheckResult[]>([]) const { setCheckResults, getCheckResults } = useWorkflowStore()
const results = getCheckResults(appId)
const timerRef = useRef<ReturnType<typeof setTimeout>>() const timerRef = useRef<ReturnType<typeof setTimeout>>()
const runCheck = useCallback(async () => { const runCheck = useCallback(async () => {
@@ -195,7 +254,7 @@ const CheckList: FC<CheckListProps> = ({ workflowRef }) => {
const scheduleCheck = useCallback(() => { const scheduleCheck = useCallback(() => {
clearTimeout(timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(async () => { timerRef.current = setTimeout(async () => {
setResults(await runCheck()) setCheckResults(appId, await runCheck())
}, 500) }, 500)
}, [runCheck]) }, [runCheck])
@@ -211,7 +270,7 @@ const CheckList: FC<CheckListProps> = ({ workflowRef }) => {
} }
}, [workflowRef.current?.graphRef?.current]) }, [workflowRef.current?.graphRef?.current])
const handleOpen = () => { const handleOpen = () => {
setOpen(true) setOpen(true)
} }

View File

@@ -328,7 +328,7 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
}; };
const content = ( const content = (
<Flex vertical gap={16} className="rb:max-h-[300px] rb:overflow-y-auto rb:p-3" style={{ minWidth: `${nodeWidth}px` }}> <Flex vertical gap={16} className="rb:max-h-75 rb:overflow-y-auto rb:p-3" style={{ minWidth: `${nodeWidth}px` }}>
{nodeLibrary.map((category) => { {nodeLibrary.map((category) => {
const sourceNodeData = sourceNode?.getData(); const sourceNodeData = sourceNode?.getData();
const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop'); const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop');

View File

@@ -4,7 +4,7 @@
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-25 15:23:45 * @Last Modified time: 2026-03-25 15:23:45
*/ */
import { type FC } from 'react' import { useMemo, type FC } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Button, Select, Space, Divider, InputNumber, type SelectProps, Flex, Row, Col } from 'antd' import { Form, Button, Select, Space, Divider, InputNumber, type SelectProps, Flex, Row, Col } from 'antd'
@@ -15,7 +15,7 @@ import Editor from '../../Editor'
import { edgeAttrs, nodeWidth } from '../../../constant' import { edgeAttrs, nodeWidth } from '../../../constant'
import RbButton from '@/components/RbButton'; import RbButton from '@/components/RbButton';
import RadioGroupBtn from '../RadioGroupBtn' import RadioGroupBtn from '../RadioGroupBtn'
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../../../utils' import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../../../utils';
interface CaseListProps { interface CaseListProps {
value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; input_type?: string; }[] }>; value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; input_type?: string; }[] }>;
@@ -49,6 +49,34 @@ const operatorsObj: { [key: string]: SelectProps['options'] } = {
boolean: [ boolean: [
{ value: 'eq', label: 'workflow.config.if-else.boolean.eq' }, { value: 'eq', label: 'workflow.config.if-else.boolean.eq' },
{ value: 'ne', label: 'workflow.config.if-else.boolean.ne' }, { value: 'ne', label: 'workflow.config.if-else.boolean.ne' },
],
object: [
{ value: 'eq', label: 'workflow.config.if-else.boolean.eq' },
{ value: 'ne', label: 'workflow.config.if-else.boolean.ne' },
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
],
file: [
{ value: 'empty', label: 'workflow.config.if-else.file.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.file.not_empty' },
],
// TODO包含、不包含、全都是
'array[file]': [
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
// { value: 'eq', label: 'workflow.config.if-else.eq' },
// { value: 'contains', label: 'workflow.config.if-else.contains' },
// { value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
],
'array': [
{ value: 'contains', label: 'workflow.config.if-else.contains' },
{ value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
],
'array[object]': [
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
] ]
} }
@@ -247,6 +275,22 @@ const CaseList: FC<CaseListProps> = ({
form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'right'], undefined); form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'right'], undefined);
}; };
const filterNumberOptions = useMemo(() => {
const filterList: Suggestion[] = []
options.forEach(vo => {
if (vo.children && vo.children?.length > 0) {
filterList.push({
...vo,
children: vo.children.filter(child => child.dataType === 'number')
})
} else if (vo.dataType === 'number') {
filterList.push(vo)
}
})
return filterList
}, [options])
return ( return (
<> <>
<Form.List name={name}> <Form.List name={name}>
@@ -284,11 +328,15 @@ const CaseList: FC<CaseListProps> = ({
const currentCase = cases[caseIndex] || {}; const currentCase = cases[caseIndex] || {};
const currentExpression = currentCase.expressions?.[conditionIndex] || {}; const currentExpression = currentCase.expressions?.[conditionIndex] || {};
const currentOperator = currentExpression.operator; const currentOperator = currentExpression.operator;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty';
const leftFieldValue = currentExpression.left; const leftFieldValue = currentExpression.left;
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue); const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue);
const leftFieldType = leftFieldOption?.dataType; const leftFieldType = leftFieldOption?.dataType;
const operatorList = operatorsObj[leftFieldType || 'default'] || operatorsObj.default || []; const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]' || leftFieldType === 'array[file]';
const operatorList = leftFieldType && operatorsObj[leftFieldType]
? operatorsObj[leftFieldType]
: leftFieldType && leftFieldType?.includes('array')
? operatorsObj.array
: operatorsObj.default;
const inputType = leftFieldType === 'number' ? currentExpression.input_type : undefined; const inputType = leftFieldType === 'number' ? currentExpression.input_type : undefined;
return ( return (
<Flex key={conditionField.key} gap={4} align="start" className="rb:mb-2!"> <Flex key={conditionField.key} gap={4} align="start" className="rb:mb-2!">
@@ -312,7 +360,7 @@ const CaseList: FC<CaseListProps> = ({
<Col flex="1"> <Col flex="1">
<Form.Item name={[conditionField.name, 'operator']} noStyle> <Form.Item name={[conditionField.name, 'operator']} noStyle>
<Select <Select
options={operatorList.map(vo => ({ options={(operatorList ?? []).map(vo => ({
...vo, ...vo,
label: t(String(vo?.label || '')) label: t(String(vo?.label || ''))
}))} }))}
@@ -328,7 +376,9 @@ const CaseList: FC<CaseListProps> = ({
{!hideRightField && ( {!hideRightField && (
<div className="rb:py-1 rb:px-1.5"> <div className="rb:py-1 rb:px-1.5">
{leftFieldType === 'number' {leftFieldType === 'array[file]'
? <>TODO</>
: leftFieldType === 'number'
? <Flex align="center"> ? <Flex align="center">
<Form.Item name={[conditionField.name, 'input_type']} noStyle> <Form.Item name={[conditionField.name, 'input_type']} noStyle>
<Select <Select
@@ -345,24 +395,24 @@ const CaseList: FC<CaseListProps> = ({
{inputType === 'variable' {inputType === 'variable'
? <VariableSelect ? <VariableSelect
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType === 'number')} options={filterNumberOptions}
allowClear={false} allowClear={false}
variant="borderless" variant="borderless"
size="small" size="small"
/> />
: <InputNumber : <InputNumber
placeholder={t('common.pleaseEnter')} placeholder={t('common.pleaseEnter')}
variant="borderless" variant="borderless"
className="rb:w-full!" className="rb:w-full!"
onChange={(value) => form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'right'], value)} onChange={(value) => form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'right'], value)}
/> />
} }
</Form.Item> </Form.Item>
</Flex> </Flex>
: ( : (
<Form.Item name={[conditionField.name, 'right']} noStyle> <Form.Item name={[conditionField.name, 'right']} noStyle>
{leftFieldType === 'boolean' {['boolean', 'array[boolean]'].includes(leftFieldType as string)
? <RadioGroupBtn options={[ { value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" /> ? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
: <Editor options={options} size="small" type="input" /> : <Editor options={options} size="small" type="input" />
} }
</Form.Item> </Form.Item>

View File

@@ -1,4 +1,4 @@
import { type FC } from 'react' import { type FC, useMemo } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Button, Select, InputNumber, Input, Divider, type SelectProps, Flex, Space, Row, Col } from 'antd' import { Form, Button, Select, InputNumber, Input, Divider, type SelectProps, Flex, Space, Row, Col } from 'antd'
@@ -47,6 +47,18 @@ const operatorsObj: { [key: string]: SelectProps['options'] } = {
{ value: 'ne', label: 'workflow.config.if-else.boolean.ne' }, { value: 'ne', label: 'workflow.config.if-else.boolean.ne' },
{ value: 'empty', label: 'workflow.config.if-else.empty' }, { value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' }, { value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
],
// 为空、不为空
object: [
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
],
// 包含、不包含、为空、不为空
'array': [
{ value: 'contains', label: 'workflow.config.if-else.contains' },
{ value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
] ]
} }
@@ -81,6 +93,23 @@ const ConditionList: FC<CaseListProps> = ({
const currentValue = form.getFieldValue([parentName, 'logical_operator']); const currentValue = form.getFieldValue([parentName, 'logical_operator']);
form.setFieldValue([parentName, 'logical_operator'], currentValue === 'and' ? 'or' : 'and'); form.setFieldValue([parentName, 'logical_operator'], currentValue === 'and' ? 'or' : 'and');
}; };
const getNumVariable = useMemo(() => {
const filterList: Suggestion[] = []
options.forEach(variable => {
if (variable.dataType === 'number') {
filterList.push(variable)
} else if (variable.dataType === 'file') {
filterList.push({
...variable,
disabled: true,
children: variable.children?.filter(child => child.dataType === 'number')
})
}
})
return filterList
}, [options])
return ( return (
<> <>
<Form.List name={[parentName, 'expressions']}> <Form.List name={[parentName, 'expressions']}>
@@ -125,11 +154,19 @@ const ConditionList: FC<CaseListProps> = ({
const expressions = form.getFieldValue([parentName, 'expressions']) || []; const expressions = form.getFieldValue([parentName, 'expressions']) || [];
const currentExpression = expressions[index] || {}; const currentExpression = expressions[index] || {};
const currentOperator = currentExpression.operator; const currentOperator = currentExpression.operator;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty';
const leftFieldValue = currentExpression.left; const leftFieldValue = currentExpression.left;
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue); const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue);
const leftFieldType = leftFieldOption?.dataType; const leftFieldType = leftFieldOption?.dataType;
const operatorList = operatorsObj[leftFieldType || 'default'] || operatorsObj.default || []; const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || ['array[object]', 'object'].includes(leftFieldType as string);
const operatorList = leftFieldType && ['array[object]', 'object'].includes(leftFieldType)
? operatorsObj.object
: leftFieldType && ['array[boolean]', 'boolean'].includes(leftFieldType)
? operatorsObj.boolean
: leftFieldType && operatorsObj[leftFieldType]
? operatorsObj[leftFieldType]
: leftFieldType?.includes('array')
? operatorsObj.array
: operatorsObj.default
const inputType = leftFieldType === 'number' ? currentExpression.input_type : undefined; const inputType = leftFieldType === 'number' ? currentExpression.input_type : undefined;
return ( return (
<Flex <Flex
@@ -146,10 +183,11 @@ const ConditionList: FC<CaseListProps> = ({
<Form.Item name={[field.name, 'left']} noStyle> <Form.Item name={[field.name, 'left']} noStyle>
<VariableSelect <VariableSelect
options={options.filter(vo => options={options.filter(vo =>
vo.value.includes('sys.') || !['file', 'array[file]'].includes(vo.dataType) &&
(vo.value.includes('sys.') ||
vo.value.includes('conv.') || vo.value.includes('conv.') ||
vo.nodeData.type === 'loop' || vo.nodeData.type === 'loop' ||
(vo.nodeData.cycle && vo.nodeData.cycle === selectedNode?.id) (vo.nodeData.cycle && vo.nodeData.cycle === selectedNode?.id))
)} )}
size="small" size="small"
allowClear={false} allowClear={false}
@@ -163,7 +201,7 @@ const ConditionList: FC<CaseListProps> = ({
<Col flex="96px"> <Col flex="96px">
<Form.Item name={[field.name, 'operator']} noStyle> <Form.Item name={[field.name, 'operator']} noStyle>
<Select <Select
options={operatorList.map(vo => ({ options={(operatorList??[]).map(vo => ({
...vo, ...vo,
label: t(String(vo?.label || '')) label: t(String(vo?.label || ''))
}))} }))}
@@ -198,7 +236,7 @@ const ConditionList: FC<CaseListProps> = ({
? ( ? (
<VariableSelect <VariableSelect
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType === 'number')} options={getNumVariable}
allowClear={false} allowClear={false}
variant="borderless" variant="borderless"
size="small" size="small"
@@ -219,7 +257,7 @@ const ConditionList: FC<CaseListProps> = ({
: ( : (
<Form.Item name={[field.name, 'right']} noStyle> <Form.Item name={[field.name, 'right']} noStyle>
{leftFieldType === 'boolean' {leftFieldType === 'boolean'
? <RadioGroupBtn options={[ { value: true, label: 'True' }, { value: false, label: 'False' }]} /> ? <RadioGroupBtn options={[ { value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
: <Input variant="borderless" placeholder={t('common.pleaseEnter')} /> : <Input variant="borderless" placeholder={t('common.pleaseEnter')} />
} }
</Form.Item> </Form.Item>

View File

@@ -6,6 +6,7 @@ import VariableSelect from '../VariableSelect'
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
import RadioGroupBtn from '../RadioGroupBtn' import RadioGroupBtn from '../RadioGroupBtn'
import { getChildNodeVariables } from '../hooks/useVariableList' import { getChildNodeVariables } from '../hooks/useVariableList'
import CodeMirrorEditor from '@/components/CodeMirrorEditor';
interface CycleVar { interface CycleVar {
name: string; name: string;
@@ -28,11 +29,17 @@ const types = [
'string', 'string',
'number', 'number',
'boolean', 'boolean',
'object',
'array[string]', 'array[string]',
'array[number]', 'array[number]',
'array[boolean]', 'array[boolean]',
'array[object]' 'array[object]'
] ]
const object_placeholder = `# example
# {
# "name": "redbear",
# "age": 2
# }`
const CycleVarsList: FC<CycleVarsListProps> = ({ const CycleVarsList: FC<CycleVarsListProps> = ({
value = [], value = [],
@@ -144,6 +151,13 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
{ value: true, label: 'True' }, { value: true, label: 'True' },
{ value: false, label: 'False' }]} { value: false, label: 'False' }]}
/> />
: currentType === 'object'
? <CodeMirrorEditor
language="json"
placeholder={object_placeholder}
variant="outlined"
size="small"
/>
: ( : (
<Input.TextArea <Input.TextArea
placeholder={t('common.pleaseEnter')} placeholder={t('common.pleaseEnter')}

View File

@@ -38,12 +38,44 @@ const EditableTable: FC<EditableTableProps> = ({
...(typeOptions.length > 0 && { type: typeOptions[0].value }) ...(typeOptions.length > 0 && { type: typeOptions[0].value })
}); });
// Filter options based on boolean type if needed const namefilterOptions = useMemo(() => {
const booleanFilterOptions = useMemo(() => { const filterList: Suggestion[] = [];
return filterBooleanType options.forEach(vo => {
? options.filter(option => option.dataType !== 'boolean') if (vo.dataType === 'file') {
: options filterList.push({
}, [options, filterBooleanType]) ...vo,
disabled: true,
children: vo.children?.filter(child => child.dataType !== 'boolean')
})
} else if (vo.dataType !== 'array[file]') {
filterList.push(vo)
}
})
return filterList
}, [options])
const valueFilterOptions = (type?: string) => {
let filterOptions: Suggestion[] = []
options.forEach(vo => {
if (type === 'file' && vo.dataType === 'file') {
filterOptions.push({
...vo,
children: []
})
} else if (type === 'file' && vo.dataType === 'array[file]') {
filterOptions.push(vo)
} else if (vo.dataType === 'file') {
filterOptions.push({
...vo,
disabled: true
})
} else if (vo.dataType !== 'array[file]') {
filterOptions.push(vo)
}
})
return filterOptions
}
const getColumns = (remove: (index: number) => void): TableProps<TableRow>['columns'] => { const getColumns = (remove: (index: number) => void): TableProps<TableRow>['columns'] => {
const hasType = typeOptions.length > 0; const hasType = typeOptions.length > 0;
@@ -57,7 +89,7 @@ const EditableTable: FC<EditableTableProps> = ({
render: (_: any, __: TableRow, index: number) => ( render: (_: any, __: TableRow, index: number) => (
<Form.Item name={[index, 'name']} className={formClassName}> <Form.Item name={[index, 'name']} className={formClassName}>
<Editor <Editor
options={booleanFilterOptions.filter(option => !option.dataType.includes('file'))} options={namefilterOptions}
type="input" type="input"
className={contentClassName} className={contentClassName}
size={size} size={size}
@@ -105,9 +137,7 @@ const EditableTable: FC<EditableTableProps> = ({
> >
{(form) => { {(form) => {
const currentType = form.getFieldValue([...Array.isArray(parentName) ? parentName : [parentName], index, 'type']); const currentType = form.getFieldValue([...Array.isArray(parentName) ? parentName : [parentName], index, 'type']);
const filteredOptions = currentType === 'file' const filteredOptions = valueFilterOptions(currentType)
? booleanFilterOptions.filter(option => option.dataType.includes('file'))
: booleanFilterOptions.filter(option => !option.dataType.includes('file'));
return ( return (
<Form.Item name={[index, 'value']} className={formClassName}> <Form.Item name={[index, 'value']} className={formClassName}>

View File

@@ -4,7 +4,7 @@
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:17:06 * @Last Modified time: 2026-04-02 17:17:06
*/ */
import { type FC, useRef, useState } from "react"; import { type FC, useMemo, useRef, useState } from "react";
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Form, Row, Col, Select, Button, Divider, InputNumber, Switch, Input, Flex, Radio } from 'antd' import { Form, Row, Col, Select, Button, Divider, InputNumber, Switch, Input, Flex, Radio } from 'antd'
import { CaretDownOutlined, CaretRightOutlined, SettingOutlined } from '@ant-design/icons'; import { CaretDownOutlined, CaretRightOutlined, SettingOutlined } from '@ant-design/icons';
@@ -84,6 +84,64 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
setCollapsed((prev: boolean) => !prev) setCollapsed((prev: boolean) => !prev)
} }
const filterVariables = useMemo(() => {
const filterList: Suggestion[] = []
options.forEach(variable => {
if (['number', 'string'].includes(variable.dataType)) {
filterList.push(variable)
} else if (variable.dataType === 'file') {
filterList.push({
...variable,
disabled: true,
children: variable.children?.filter(child => ['number', 'string'].includes(child.dataType))
})
}
})
return filterList
}, [options])
const filterVariablesWithFile = useMemo(() => {
const filterList: Suggestion[] = []
options.forEach(variable => {
if (['number', 'string', 'file', 'array[file]'].includes(variable.dataType)) {
filterList.push(variable)
}
})
return filterList
}, [options])
const jsonRawFilterVariables = useMemo(() => {
const filterList: Suggestion[] = []
options.forEach(variable => {
if (['number', 'string', 'array[string]', 'array[number]'].includes(variable.dataType)) {
filterList.push(variable)
} else if (variable.dataType === 'file') {
filterList.push({
...variable,
disabled: true,
children: variable.children?.filter(child => ['number', 'string', 'file', 'array[string]', 'array[number]'].includes(child.dataType))
})
}
})
return filterList
}, [options])
const fileFilterVariables = useMemo(() => {
const filterList: Suggestion[] = []
options.forEach(variable => {
if (['array[file]'].includes(variable.dataType)) {
filterList.push(variable)
} else if (variable.dataType === 'file') {
filterList.push({
...variable,
children: []
})
}
})
return filterList
}, [options])
return ( return (
<> <>
<Flex align="center" justify="space-between" className="rb:mb-1!"> <Flex align="center" justify="space-between" className="rb:mb-1!">
@@ -117,7 +175,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<Form.Item name="url"> <Form.Item name="url">
<Editor <Editor
key="url" key="url"
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')} options={filterVariables}
variant="outlined" variant="outlined"
type="input" type="input"
size="small" size="small"
@@ -134,7 +192,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
size="small" size="small"
parentName="headers" parentName="headers"
title="HEADERS" title="HEADERS"
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')} options={filterVariables}
/> />
</Form.Item> </Form.Item>
@@ -143,7 +201,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
size="small" size="small"
parentName="params" parentName="params"
title="PARAMS" title="PARAMS"
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')} options={filterVariables}
/> />
</Form.Item> </Form.Item>
@@ -167,7 +225,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<EditableTable <EditableTable
size="small" size="small"
parentName={['body', 'data']} parentName={['body', 'data']}
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number' || vo.dataType.includes('file'))} options={filterVariablesWithFile}
typeOptions={[ typeOptions={[
{ label: 'text', value: 'text' }, { label: 'text', value: 'text' },
{ label: 'file', value: 'file' } { label: 'file', value: 'file' }
@@ -180,7 +238,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<EditableTable <EditableTable
size="small" size="small"
parentName={['body', 'data']} parentName={['body', 'data']}
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')} options={filterVariablesWithFile}
filterBooleanType={true} filterBooleanType={true}
/> />
</Form.Item> </Form.Item>
@@ -190,7 +248,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<MessageEditor <MessageEditor
key="json" key="json"
parentName={['body', 'data']} parentName={['body', 'data']}
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')} options={jsonRawFilterVariables}
isArray={false} isArray={false}
title="JSON" title="JSON"
titleVariant="borderless" titleVariant="borderless"
@@ -204,7 +262,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<MessageEditor <MessageEditor
key="raw" key="raw"
parentName={['body', 'data']} parentName={['body', 'data']}
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')} options={jsonRawFilterVariables}
isArray={false} isArray={false}
title="RAW TEXT" title="RAW TEXT"
titleVariant="borderless" titleVariant="borderless"
@@ -220,7 +278,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
<Editor <Editor
key={['body', 'data'].join('_')} key={['body', 'data'].join('_')}
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType.includes('file'))} options={fileFilterVariables}
type="input" type="input"
size="small" size="small"
height={28} height={28}

View File

@@ -163,25 +163,45 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
form.setFieldsValue(inititalValue) form.setFieldsValue(inititalValue)
} }
const getNumberOptions = useMemo(() => {
const list: Suggestion[] = []
// string -> string
// integer -> number
// number -> number
// boolean -> boolean【只能选true/false】
// array -> array[file]/array[object]/array[string]/array[number]/array[boolean]
// object -> object/file
const getFilterOptions = (type: string) => {
const filterList: Suggestion[] = [];
options.forEach(vo => { options.forEach(vo => {
if (vo.children && vo?.children?.length > 0) { if (vo.children && vo.children?.length > 0) {
const filterChild = vo.children.filter(child => child.dataType === 'number') const childOptions = vo.children?.filter(child => child.dataType === type || (type === 'integer' && child.dataType === 'number'))
if (filterChild.length > 0) { if (vo.dataType === type
list.push({ ...vo, disabled: vo.dataType !== 'number', children: filterChild }) || (type === 'integer' && vo.dataType === 'number')
} else if (vo.dataType === 'number') { || (type === 'array' && vo.dataType.includes(type))
list.push({ ...vo, children: [] }) || (type === 'object' && vo.dataType === 'object')
) {
filterList.push({
...vo,
children: childOptions
})
} else if (childOptions.length > 0) {
filterList.push({
...vo,
disabled: true,
children: childOptions
})
} }
} else if (vo.dataType === 'number') { } else if (vo.dataType === type
list.push({ ...vo }) || (type === 'integer' && vo.dataType === 'number')
|| (type === 'array' && vo.dataType.includes(type))
|| (type === 'object' && vo.dataType === 'object')) {
filterList.push(vo)
} }
}) })
console.log('options', options, list)
return list return filterList
}, [options]) }
return ( return (
<> <>
@@ -205,7 +225,7 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
<Form.Item <Form.Item
name={['tool_parameters', parameter.name]} name={['tool_parameters', parameter.name]}
label={<> label={<>
{parameter.name} {parameter.name} <span className="rb:text-[#5B6167] rb:mx-1">({parameter.type})</span>
<Tooltip title={parameter.description} placement="right"> <Tooltip title={parameter.description} placement="right">
<div className="rb:size-3 rb:ml-0.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/question.svg')]"></div> <div className="rb:size-3 rb:ml-0.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/question.svg')]"></div>
</Tooltip> </Tooltip>
@@ -220,21 +240,12 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
? <Select size="small" options={parameter.enum.map(vo => ({ value: vo, label: vo }))} placeholder={t('common.pleaseSelect')} /> ? <Select size="small" options={parameter.enum.map(vo => ({ value: vo, label: vo }))} placeholder={t('common.pleaseSelect')} />
: parameter.type === 'boolean' : parameter.type === 'boolean'
? <Switch size="small" /> ? <Switch size="small" />
: parameter.type === 'integer' || parameter.type === 'number'
? <Editor
variant="outlined"
type="input"
size="small"
height={28}
options={getNumberOptions}
placeholder={t('common.pleaseEnter')}
/>
: <Editor : <Editor
variant="outlined" variant="outlined"
type="input" type="input"
size="small" size="small"
height={28} height={28}
options={options} options={getFilterOptions(parameter.type)}
placeholder={t('common.pleaseEnter')} placeholder={t('common.pleaseEnter')}
/> />
} }

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:39:59 * @Date: 2026-02-03 15:39:59
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-08 14:10:40 * @Last Modified time: 2026-04-10 17:24:19
*/ */
import { type FC, useEffect, useState, useMemo } from "react"; import { type FC, useEffect, useState, useMemo } from "react";
import clsx from 'clsx' import clsx from 'clsx'
@@ -248,7 +248,7 @@ const Properties: FC<PropertiesProps> = ({
return null; return null;
})() : null; })() : null;
let filteredList = variableList.filter(variable => variable.dataType !== 'boolean'); let filteredList = variableList.filter(variable => !['boolean', 'object', 'array[boolean]'].includes(variable.dataType));
// If this LLM node is a child of iteration/loop, ensure parent variables are included // If this LLM node is a child of iteration/loop, ensure parent variables are included
if (parentLoopNode) { if (parentLoopNode) {
@@ -315,23 +315,103 @@ const Properties: FC<PropertiesProps> = ({
return filteredList; return filteredList;
} }
if (nodeType === 'knowledge-retrieval' || nodeType === 'parameter-extractor' && key !== 'prompt' || nodeType === 'memory-read' || nodeType === 'question-classifier') { if (nodeType === 'knowledge-retrieval') {
const allList = addParentIterationVars(variableList);
let filteredList: Suggestion[] = []
allList.forEach(variable => {
if (variable.dataType === 'string') {
filteredList.push(variable)
} else if (variable.dataType === 'file') {
filteredList.push({
...variable,
disabled: true,
children: variable.children.filter((child: Suggestion) => child.dataType === 'string')
})
}
})
return filteredList
}
if ((nodeType === 'parameter-extractor' && key === 'text')
|| (nodeType === 'question-classifier' && ['input_variable', 'categories'].includes(key as string))
) {
const allList = addParentIterationVars(variableList);
let filteredList: Suggestion[] = []
allList.forEach(variable => {
if (variable.dataType === 'string') {
filteredList.push(variable)
} else if (variable.dataType === 'file') {
filteredList.push({
...variable,
children: variable.children.filter((child: Suggestion) => child.dataType === 'string')
})
}
})
return filteredList
}
if ((nodeType === 'parameter-extractor' && key === 'prompt')
|| (nodeType === 'question-classifier' && key === 'user_supplement_prompt')
) {
const allList = addParentIterationVars(variableList);
let filteredList: Suggestion[] = []
allList.forEach(variable => {
if (['string', 'number'].includes(variable.dataType)) {
filteredList.push(variable)
} else if (variable.dataType === 'file') {
filteredList.push({
...variable,
disabled: true,
children: variable.children.filter((child: Suggestion) => ['string', 'number'].includes(child.dataType))
})
}
})
return filteredList
}
if (nodeType === 'memory-read') {
let filteredList = addParentIterationVars(variableList).filter(variable => variable.dataType === 'string'); let filteredList = addParentIterationVars(variableList).filter(variable => variable.dataType === 'string');
return filteredList; return filteredList;
} }
if (nodeType === 'memory-write') { if (nodeType === 'memory-write') {
let filteredList = addParentIterationVars(variableList).filter(variable => variable.dataType === 'string' || variable.dataType.includes('file')); const allList = addParentIterationVars(variableList);
return filteredList; let filteredList: Suggestion[] = []
allList.forEach(variable => {
if (['string', 'array[file]'].includes(variable.dataType)) {
filteredList.push(variable)
} else if (variable.dataType === 'file') {
filteredList.push({
...variable,
children: variable.children.filter((child: Suggestion) => child.dataType === 'string')
})
}
})
return filteredList
} }
if (nodeType === 'parameter-extractor' && key === 'prompt') { if (nodeType === 'parameter-extractor' && key === 'prompt') {
let filteredList = addParentIterationVars(variableList).filter(variable => variable.dataType === 'string' || variable.dataType === 'number'); let filteredList = addParentIterationVars(variableList).filter(variable => variable.dataType === 'string' || variable.dataType === 'number');
return filteredList; return filteredList;
} }
if (nodeType === 'iteration' && key === 'output' || nodeType === 'loop' && key === 'condition') {
if ((nodeType === 'iteration' && key === 'output')) {
if (!selectedNode) return []; if (!selectedNode) return [];
let filteredList = nodeType === 'iteration' let filteredList = variableList.filter(variable => variable.value.includes('sys.'))
? variableList.filter(variable => variable.value.includes('sys.')) const childVariables = getChildNodeVariables(selectedNode, graphRef);
: addParentIterationVars(variableList).filter(variable => variable.nodeData.type !== 'loop'); const existingKeys = new Set(filteredList.map(v => v.key));
childVariables.forEach(v => {
if (!existingKeys.has(v.key)) {
filteredList.push(v);
existingKeys.add(v.key);
}
});
return filteredList.filter(variable => variable.dataType !== 'array[file]');
}
if (nodeType === 'loop' && key === 'condition') {
if (!selectedNode) return [];
let filteredList = addParentIterationVars(variableList).filter(variable => variable.nodeData.type !== 'loop');
const childVariables = getChildNodeVariables(selectedNode, graphRef); const childVariables = getChildNodeVariables(selectedNode, graphRef);
const existingKeys = new Set(filteredList.map(v => v.key)); const existingKeys = new Set(filteredList.map(v => v.key));
@@ -348,6 +428,23 @@ const Properties: FC<PropertiesProps> = ({
return variableList.filter(variable => variable.dataType.includes('array')); return variableList.filter(variable => variable.dataType.includes('array'));
} }
if ((nodeType === 'if-else' && key === 'cases')) {
const allList = addParentIterationVars(variableList);
let filteredList: Suggestion[] = []
allList.forEach(variable => {
if (variable.dataType === 'file') {
filteredList.push({
...variable,
disabled: true,
})
} else {
filteredList.push(variable)
}
})
return filteredList
}
// For all other node types, add parent iteration variables if applicable // For all other node types, add parent iteration variables if applicable
let baseList = variableList; let baseList = variableList;
return addParentIterationVars(baseList); return addParentIterationVars(baseList);
@@ -848,7 +945,7 @@ const Properties: FC<PropertiesProps> = ({
options={getFilteredVariableList(selectedNode?.data?.type, key)} options={getFilteredVariableList(selectedNode?.data?.type, key)}
/> />
: config.type === 'editor' : config.type === 'editor'
? <Editor options={variableList} variant="outlined" size="small" placeholder={config.placeholder || t('common.pleaseEnter')} /> ? <Editor options={getFilteredVariableList(selectedNode?.data?.type, key)} variant="outlined" size="small" placeholder={config.placeholder || t('common.pleaseEnter')} />
: null : null
} }
</Form.Item> </Form.Item>