diff --git a/api/app/controllers/app_controller.py b/api/app/controllers/app_controller.py index 2300f148..f55ea5b5 100644 --- a/api/app/controllers/app_controller.py +++ b/api/app/controllers/app_controller.py @@ -60,14 +60,14 @@ def list_apps( """ workspace_id = current_user.current_workspace_id service = app_service.AppService(db) - + # 当 ids 存在且不为 None 时,根据 ids 获取应用 if ids is not None: app_ids = [id.strip() for id in ids.split(',') if id.strip()] items_orm = app_service.get_apps_by_ids(db, app_ids, workspace_id) items = [service._convert_to_schema(app, workspace_id) for app in items_orm] return success(data=items) - + # 正常分页查询 items_orm, total = app_service.list_apps( db, diff --git a/api/app/controllers/emotion_controller.py b/api/app/controllers/emotion_controller.py index 90465c95..7f0cb91b 100644 --- a/api/app/controllers/emotion_controller.py +++ b/api/app/controllers/emotion_controller.py @@ -30,7 +30,7 @@ from sqlalchemy.orm import Session api_logger = get_api_logger() router = APIRouter( - prefix="/memory/emotion", + prefix="/memory/emotion-memory", tags=["Emotion Analysis"], dependencies=[Depends(get_current_user)] # 所有路由都需要认证 ) diff --git a/api/app/controllers/memory_forget_controller.py b/api/app/controllers/memory_forget_controller.py index e8a5732f..ca628d0c 100644 --- a/api/app/controllers/memory_forget_controller.py +++ b/api/app/controllers/memory_forget_controller.py @@ -39,7 +39,7 @@ from app.services.memory_forget_service import MemoryForgetService api_logger = get_api_logger() router = APIRouter( - prefix="/memory/forget", + prefix="/memory/forget-memory", tags=["Memory Forgetting Engine"], dependencies=[Depends(get_current_user)] # 所有路由都需要认证 ) diff --git a/api/app/core/memory/src/search.py b/api/app/core/memory/src/search.py index 11df8166..ae2b9cfa 100644 --- a/api/app/core/memory/src/search.py +++ b/api/app/core/memory/src/search.py @@ -842,7 +842,7 @@ async def run_hybrid_search( if search_type in ["keyword", "hybrid"]: # Keyword-based search - logger.info("Starting keyword search...") + logger.info("[PERF] Starting keyword search...") keyword_start = time.time() keyword_task = asyncio.create_task( search_graph( @@ -856,7 +856,7 @@ async def run_hybrid_search( if search_type in ["embedding", "hybrid"]: # Embedding-based search - logger.info("Starting embedding search...") + logger.info("[PERF] Starting embedding search...") embedding_start = time.time() # 从数据库读取嵌入器配置(按 ID)并构建 RedBearModelConfig @@ -872,13 +872,13 @@ async def run_hybrid_search( type="llm" ) config_load_time = time.time() - config_load_start - logger.info(f"Config loading took {config_load_time:.4f}s") + logger.info(f"[PERF] Config loading took {config_load_time:.4f}s") # Init embedder embedder_init_start = time.time() embedder = OpenAIEmbedderClient(model_config=rb_config) embedder_init_time = time.time() - embedder_init_start - logger.info(f"Embedder init took {embedder_init_time:.4f}s") + logger.info(f"[PERF] Embedder init took {embedder_init_time:.4f}s") embedding_task = asyncio.create_task( search_graph_by_embedding( @@ -895,7 +895,7 @@ async def run_hybrid_search( keyword_results = await keyword_task keyword_latency = time.time() - keyword_start latency_metrics["keyword_search_latency"] = round(keyword_latency, 4) - logger.info(f"Keyword search completed in {keyword_latency:.4f}s") + logger.info(f"[PERF] Keyword search completed in {keyword_latency:.4f}s") if search_type == "keyword": results = keyword_results else: @@ -905,7 +905,7 @@ async def run_hybrid_search( embedding_results = await embedding_task embedding_latency = time.time() - embedding_start latency_metrics["embedding_search_latency"] = round(embedding_latency, 4) - logger.info(f"Embedding search completed in {embedding_latency:.4f}s") + logger.info(f"[PERF] Embedding search completed in {embedding_latency:.4f}s") if search_type == "embedding": results = embedding_results else: @@ -922,17 +922,21 @@ async def run_hybrid_search( # Apply two-stage reranking with ACTR activation calculation rerank_start = time.time() - logger.info("Using two-stage reranking with ACTR activation") + logger.info("[PERF] Using two-stage reranking with ACTR activation") # 加载遗忘引擎配置 + config_start = time.time() try: pc = get_pipeline_config(memory_config) forgetting_cfg = pc.forgetting_engine except Exception as e: logger.debug(f"Failed to load forgetting config, using defaults: {e}") forgetting_cfg = ForgettingEngineConfig() + config_time = time.time() - config_start + logger.info(f"[PERF] Forgetting config loading took {config_time:.4f}s") # 统一使用激活度重排序(两阶段:检索 + ACTR计算) + rerank_compute_start = time.time() reranked_results = rerank_with_activation( keyword_results=keyword_results, embedding_results=embedding_results, @@ -941,10 +945,12 @@ async def run_hybrid_search( forgetting_config=forgetting_cfg, activation_boost_factor=activation_boost_factor, ) + rerank_compute_time = time.time() - rerank_compute_start + logger.info(f"[PERF] Rerank computation took {rerank_compute_time:.4f}s") rerank_latency = time.time() - rerank_start latency_metrics["reranking_latency"] = round(rerank_latency, 4) - logger.info(f"Reranking completed in {rerank_latency:.4f}s") + logger.info(f"[PERF] Total reranking completed in {rerank_latency:.4f}s") # Optional: apply reranker placeholder if enabled via config reranked_results = apply_reranker_placeholder(reranked_results, query_text) @@ -985,8 +991,10 @@ async def run_hybrid_search( else: results["latency_metrics"] = latency_metrics - logger.info(f"Total search completed in {total_latency:.4f}s") - logger.info(f"Latency breakdown: {latency_metrics}") + logger.info(f"[PERF] ===== SEARCH PERFORMANCE SUMMARY =====") + 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] =========================================") # Sanitize results: drop large/unused fields _remove_keys_recursive(results, ["name_embedding"]) # drop entity name embeddings from outputs diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py index c72b9a1f..7e75fd2d 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py @@ -1,6 +1,7 @@ import asyncio +import json from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Tuple from uuid import uuid4 from app.core.logging_config import get_memory_logger @@ -28,6 +29,118 @@ class MemorySummaryResponse(RobustLLMResponse): ) +async def generate_title_and_type_for_summary( + content: str, + llm_client +) -> Tuple[str, str]: + """ + 为MemorySummary生成标题和类型 + + 此方法应该在创建MemorySummary节点时调用,生成title和type + + Args: + content: Summary的内容文本 + llm_client: LLM客户端实例 + + Returns: + (标题, 类型)元组 + """ + from app.core.memory.utils.prompt.prompt_utils import render_episodic_title_and_type_prompt + + # 定义有效的类型集合 + VALID_TYPES = { + "conversation", # 对话 + "project_work", # 项目/工作 + "learning", # 学习 + "decision", # 决策 + "important_event" # 重要事件 + } + DEFAULT_TYPE = "conversation" # 默认类型 + + try: + if not content: + logger.warning("content为空,无法生成标题和类型") + return ("空内容", DEFAULT_TYPE) + + # 1. 渲染Jinja2提示词模板 + prompt = await render_episodic_title_and_type_prompt(content) + + # 2. 调用LLM生成标题和类型 + messages = [ + {"role": "user", "content": prompt} + ] + + response = await llm_client.chat(messages=messages) + + # 3. 解析LLM响应 + content_response = response.content + if isinstance(content_response, list): + if len(content_response) > 0: + if isinstance(content_response[0], dict): + text = content_response[0].get('text', content_response[0].get('content', str(content_response[0]))) + full_response = str(text) + else: + full_response = str(content_response[0]) + else: + full_response = "" + elif isinstance(content_response, dict): + full_response = str(content_response.get('text', content_response.get('content', str(content_response)))) + else: + full_response = str(content_response) if content_response is not None else "" + + # 4. 解析JSON响应 + try: + # 尝试从响应中提取JSON + # 移除可能的markdown代码块标记 + json_str = full_response.strip() + if json_str.startswith("```json"): + json_str = json_str[7:] + if json_str.startswith("```"): + json_str = json_str[3:] + if json_str.endswith("```"): + json_str = json_str[:-3] + json_str = json_str.strip() + + result_data = json.loads(json_str) + title = result_data.get("title", "未知标题") + episodic_type_raw = result_data.get("type", DEFAULT_TYPE) + + # 5. 校验和归一化类型 + # 将类型转换为小写并去除空格 + episodic_type_normalized = str(episodic_type_raw).lower().strip() + + # 检查是否在有效类型集合中 + if episodic_type_normalized in VALID_TYPES: + episodic_type = episodic_type_normalized + else: + # 尝试映射常见的中文类型到英文 + type_mapping = { + "对话": "conversation", + "项目": "project_work", + "工作": "project_work", + "项目/工作": "project_work", + "学习": "learning", + "决策": "decision", + "重要事件": "important_event", + "事件": "important_event" + } + episodic_type = type_mapping.get(episodic_type_raw, DEFAULT_TYPE) + logger.warning( + f"LLM返回的类型 '{episodic_type_raw}' 不在有效集合中," + f"已归一化为 '{episodic_type}'" + ) + + logger.info(f"成功生成标题和类型: title={title}, type={episodic_type}") + return (title, episodic_type) + + except json.JSONDecodeError: + logger.error(f"无法解析LLM响应为JSON: {full_response}") + return ("解析失败", DEFAULT_TYPE) + + except Exception as e: + logger.error(f"生成标题和类型时出错: {str(e)}", exc_info=True) + return ("错误", DEFAULT_TYPE) + async def _process_chunk_summary( dialog: DialogData, chunk, @@ -63,10 +176,9 @@ async def _process_chunk_summary( title = None episodic_type = None try: - from app.services.user_memory_service import UserMemoryService - title, episodic_type = await UserMemoryService.generate_title_and_type_for_summary( + title, episodic_type = await generate_title_and_type_for_summary( content=summary_text, - end_user_id=dialog.group_id + llm_client=llm_client ) logger.info(f"Generated title and type for MemorySummary: title={title}, type={episodic_type}") except Exception as e: diff --git a/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py b/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py index 913874f1..5722769a 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py +++ b/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py @@ -8,14 +8,16 @@ Classes: AccessHistoryManager: 访问历史管理器,提供并发安全的访问记录和一致性检查 """ +import asyncio import logging -from typing import List, Dict, Any, Optional, Tuple from datetime import datetime from enum import Enum +from typing import Any, Dict, List, Optional, Tuple +from app.core.memory.storage_services.forgetting_engine.actr_calculator import ( + ACTRCalculator, +) from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.core.memory.storage_services.forgetting_engine.actr_calculator import ACTRCalculator - logger = logging.getLogger(__name__) @@ -188,30 +190,43 @@ class AccessHistoryManager: Returns: List[Dict[str, Any]]: 成功更新的节点列表 """ + import time + batch_start = time.time() + if current_time is None: current_time = datetime.now() + # PERFORMANCE FIX: Process all nodes in parallel instead of sequentially + tasks = [] + for node_id in node_ids: + task = self.record_access( + node_id=node_id, + node_label=node_label, + group_id=group_id, + current_time=current_time + ) + tasks.append(task) + + # Execute all tasks in parallel + task_results = await asyncio.gather(*tasks, return_exceptions=True) + + # Collect successful results and count failures results = [] failed_count = 0 - for node_id in node_ids: - try: - updated_node = await self.record_access( - node_id=node_id, - node_label=node_label, - group_id=group_id, - current_time=current_time - ) - results.append(updated_node) - except Exception as e: + for node_id, result in zip(node_ids, task_results): + if isinstance(result, Exception): failed_count += 1 logger.warning( - f"批量访问记录失败: {node_label}[{node_id}], 错误: {str(e)}" + f"批量访问记录失败: {node_label}[{node_id}], 错误: {str(result)}" ) + else: + results.append(result) + batch_duration = time.time() - batch_start logger.info( - f"批量访问记录完成: 成功 {len(results)}/{len(node_ids)}, " - f"失败 {failed_count}" + f"[PERF] 批量访问记录完成: 成功 {len(results)}/{len(node_ids)}, " + f"失败 {failed_count}, 耗时 {batch_duration:.4f}s" ) return results @@ -531,7 +546,10 @@ class AccessHistoryManager: Dict[str, Any]: 更新数据,包含所有需要更新的字段 """ access_history = node_data.get('access_history') or [] - importance_score = node_data.get('importance_score', 0.5) + # Handle None importance_score - default to 0.5 + importance_score = node_data.get('importance_score') + if importance_score is None: + importance_score = 0.5 # 追加新的访问时间 new_access_history = access_history + [current_time_iso] @@ -620,34 +638,52 @@ class AccessHistoryManager: new_version = current_version + 1 # 步骤2:使用乐观锁更新节点 - # 只有当版本号匹配时才更新 - update_query = f""" - MATCH (n:{node_label} {{id: $node_id}}) - """ + # 根据节点类型构建完整的查询语句 + content_field_map = { + 'Statement': 'n.statement as statement', + 'MemorySummary': 'n.content as content', + 'ExtractedEntity': 'null as content_placeholder' # 占位符,后续会被过滤 + } + + # 显式检查节点类型,不支持的类型抛出错误 + if node_label not in content_field_map: + raise ValueError( + f"Unsupported node_label: {node_label}. " + f"Supported labels are: {list(content_field_map.keys())}" + ) + + content_field = content_field_map[node_label] + + # 构建 WHERE 子句 + where_conditions = [] if group_id: - update_query += " WHERE n.group_id = $group_id" + where_conditions.append("n.group_id = $group_id") # 添加版本检查 if current_version > 0: - update_query += " AND n.version = $current_version" + where_conditions.append("n.version = $current_version") else: - # 如果节点没有版本号,检查是否为首次更新 - update_query += " AND (n.version IS NULL OR n.version = 0)" + where_conditions.append("(n.version IS NULL OR n.version = 0)") - update_query += """ + where_clause = " AND ".join(where_conditions) if where_conditions else "true" + + # 构建完整的更新查询 + update_query = f""" + MATCH (n:{node_label} {{id: $node_id}}) + WHERE {where_clause} SET n.activation_value = $activation_value, n.access_history = $access_history, n.last_access_time = $last_access_time, n.access_count = $access_count, n.version = $new_version RETURN n.id as id, - n.statement as statement, n.activation_value as activation_value, n.access_history as access_history, n.last_access_time as last_access_time, n.access_count as access_count, n.importance_score as importance_score, - n.version as version + n.version as version, + {content_field} """ update_params = { @@ -671,7 +707,11 @@ class AccessHistoryManager: f"Expected version {current_version}, but node was modified by another transaction." ) - return dict(updated_node) + # 转换为字典并移除占位符字段 + result_dict = dict(updated_node) + result_dict.pop('content_placeholder', None) + + return result_dict # 执行事务 try: diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py index f1802166..ccd8d2ca 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py @@ -260,17 +260,32 @@ class ForgettingStrategy: ) # 生成标题和类型(使用LLM) - from app.services.user_memory_service import UserMemoryService + from app.core.memory.storage_services.extraction_engine.knowledge_extraction.memory_summary import generate_title_and_type_for_summary + + # 获取 LLM 客户端 + llm_client = None + if config_id is not None and db is not None: + try: + llm_client = await self._get_llm_client(db, config_id) + except Exception as e: + logger.warning(f"获取 LLM 客户端失败: {str(e)}") + + # 生成标题和类型 try: - title, episodic_type = await UserMemoryService.generate_title_and_type_for_summary( - content=summary_text, - end_user_id=group_id - ) - logger.info(f"成功为MemorySummary生成标题和类型: title={title}, type={episodic_type}") + if llm_client is not None: + title, episodic_type = await generate_title_and_type_for_summary( + content=summary_text, + llm_client=llm_client + ) + logger.info(f"成功为MemorySummary生成标题和类型: title={title}, type={episodic_type}") + else: + logger.warning("LLM 客户端不可用,使用默认标题和类型") + title = "未命名" + episodic_type = "conversation" except Exception as e: logger.error(f"生成标题和类型失败,使用默认值: {str(e)}") title = "未命名" - episodic_type = "其他" + episodic_type = "conversation" # 计算继承的激活值和重要性(取较高值) inherited_activation = max(statement_activation, entity_activation) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index e3d634d8..67689935 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -3,13 +3,11 @@ 基于 LangGraph 的工作流执行引擎。 """ - -# import uuid import datetime import logging +import uuid from typing import Any -from langchain_core.messages import HumanMessage from langgraph.graph.state import CompiledStateGraph from app.core.workflow.graph_builder import GraphBuilder @@ -55,6 +53,12 @@ class WorkflowExecutor: self.edges = workflow_config.get("edges", []) self.execution_config = workflow_config.get("execution_config", {}) + self.checkpoint_config = { + "configurable": { + "thread_id": uuid.uuid4(), + } + } + def _prepare_initial_state(self, input_data: dict[str, Any]) -> WorkflowState: """准备初始状态(注入系统变量和会话变量) @@ -95,7 +99,7 @@ class WorkflowExecutor: case VariableType.ARRAY_NUMBER | VariableType.ARRAY_OBJECT | VariableType.ARRAY_BOOLEAN | VariableType.ARRAY_STRING: conversation_vars[var_name] = [] input_variables = input_data.get("variables") or {} # Start 节点的自定义变量 - + conversation_vars = conversation_vars | input_data.get("conv", {}) # 构建分层的变量结构 variables = { "sys": { @@ -110,7 +114,7 @@ class WorkflowExecutor: } return { - "messages": [HumanMessage(content=user_message)], + "messages": [('user', user_message)], "variables": variables, "node_outputs": {}, "runtime_vars": {}, # 运行时节点变量(简化版,供快速访问) @@ -196,6 +200,28 @@ class WorkflowExecutor: logger.info(f"[前缀分析] 与 End 相邻且被引用的节点: {adjacent_and_referenced}") return prefixes, adjacent_and_referenced + def _build_final_output(self, result, elapsed_time): + node_outputs = result.get("node_outputs", {}) + final_output = self._extract_final_output(node_outputs) + token_usage = self._aggregate_token_usage(node_outputs) + conversation_id = None + for node_id, node_output in node_outputs.items(): + if node_output.get("node_type") == "start": + conversation_id = node_output.get("output", {}).get("conversation_id") + break + + return { + "status": "completed", + "output": final_output, + "node_outputs": node_outputs, + "messages": result.get("messages", []), + "conversation_id": conversation_id, + "elapsed_time": elapsed_time, + "token_usage": token_usage, + "error": result.get("error"), + "variables": result.get("variables", {}), + } + def build_graph(self, stream=False) -> CompiledStateGraph: """构建 LangGraph @@ -236,40 +262,16 @@ class WorkflowExecutor: # 3. 执行工作流 try: - result = await graph.ainvoke(initial_state) + + result = await graph.ainvoke(initial_state, config=self.checkpoint_config) # 计算耗时 end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() - # 提取节点输出(现在包含 start 和 end 节点) - node_outputs = result.get("node_outputs", {}) - - # 提取最终输出(从最后一个非 start/end 节点) - final_output = self._extract_final_output(node_outputs) - - # 聚合 token 使用情况 - token_usage = self._aggregate_token_usage(node_outputs) - - # 提取 conversation_id(从 start 节点输出) - conversation_id = None - for node_id, node_output in node_outputs.items(): - if node_output.get("node_type") == "start": - conversation_id = node_output.get("output", {}).get("conversation_id") - break - logger.info(f"工作流执行完成: execution_id={self.execution_id}, elapsed_time={elapsed_time:.2f}s") - return { - "status": "completed", - "output": final_output, - "node_outputs": node_outputs, - "messages": result.get("messages", []), - "conversation_id": conversation_id, - "elapsed_time": elapsed_time, - "token_usage": token_usage, - "error": result.get("error") - } + return self._build_final_output(result, elapsed_time) except Exception as e: # 计算耗时(即使失败也记录) @@ -331,11 +333,11 @@ class WorkflowExecutor: # 3. Execute workflow try: chunk_count = 0 - final_state = None async for event in graph.astream( initial_state, stream_mode=["updates", "debug", "custom"], # Use updates + debug + custom mode + config=self.checkpoint_config ): # event should be a tuple: (mode, data) # But let's handle both cases @@ -411,12 +413,11 @@ class WorkflowExecutor: elif mode == "updates": # Handle state updates - store final state logger.debug(f"[UPDATES] 收到 state 更新 from {list(data.keys())}") - final_state = data # 计算耗时 end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() - + result = graph.get_state(self.checkpoint_config).values logger.info( f"Workflow execution completed (streaming), " f"total chunks: {chunk_count}, elapsed: {elapsed_time:.2f}s" @@ -425,12 +426,7 @@ class WorkflowExecutor: # 发送 workflow_end 事件 yield { "event": "workflow_end", - "data": { - "execution_id": self.execution_id, - "status": "completed", - "elapsed_time": elapsed_time, - "timestamp": end_time.isoformat() - } + "data": self._build_final_output(result, elapsed_time) } except Exception as e: diff --git a/api/app/core/workflow/graph_builder.py b/api/app/core/workflow/graph_builder.py index 69ed3b6a..b75b867e 100644 --- a/api/app/core/workflow/graph_builder.py +++ b/api/app/core/workflow/graph_builder.py @@ -4,6 +4,7 @@ from typing import Any from langgraph.graph.state import CompiledStateGraph, StateGraph from langgraph.graph import START, END +from langgraph.checkpoint.memory import InMemorySaver from app.core.workflow.expression_evaluator import evaluate_condition from app.core.workflow.nodes import WorkflowState, NodeFactory @@ -249,4 +250,5 @@ class GraphBuilder: self.graph = StateGraph(WorkflowState) self.add_nodes() self.add_edges() # 添加边必须在添加节点之后 - return self.graph.compile() + checkpointer = InMemorySaver() + return self.graph.compile(checkpointer=checkpointer) diff --git a/api/app/core/workflow/nodes/assigner/node.py b/api/app/core/workflow/nodes/assigner/node.py index 7b9d645b..96f68ce8 100644 --- a/api/app/core/workflow/nodes/assigner/node.py +++ b/api/app/core/workflow/nodes/assigner/node.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) class AssignerNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = AssignerNodeConfig(**self.config) + self.typed_config: AssignerNodeConfig | None = None async def execute(self, state: WorkflowState) -> Any: """ @@ -28,6 +28,7 @@ class AssignerNode(BaseNode): None or the result of the assignment operation. """ # Initialize a variable pool for accessing conversation, node, and system variables + self.typed_config = AssignerNodeConfig(**self.config) logger.info(f"节点 {self.node_id} 开始执行") pool = VariablePool(state) for assignment in self.typed_config.assignments: diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index 727f7391..e3bf36c9 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -25,7 +25,7 @@ class WorkflowState(TypedDict): The state object passed between nodes in a workflow, containing messages, variables, node outputs, etc. """ # List of messages (append mode) - messages: Annotated[list[AnyMessage], add] + messages: Annotated[list[tuple[str, str]], add] # Set of loop node IDs, used for assigning values in loop nodes cycle_nodes: list @@ -203,6 +203,7 @@ class BaseNode(ABC): # 返回包装后的输出和运行时变量 return { **wrapped_output, + "variables": state["variables"], "runtime_vars": { self.node_id: runtime_var }, @@ -355,6 +356,7 @@ class BaseNode(ABC): # Build complete state update (including node_outputs, runtime_vars, and final streaming buffer) state_update = { **final_output, + "variables": state["variables"], "runtime_vars": { self.node_id: runtime_var }, diff --git a/api/app/core/workflow/nodes/cycle_graph/node.py b/api/app/core/workflow/nodes/cycle_graph/node.py index fb062f39..1659395e 100644 --- a/api/app/core/workflow/nodes/cycle_graph/node.py +++ b/api/app/core/workflow/nodes/cycle_graph/node.py @@ -30,7 +30,6 @@ class CycleGraphNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config: LoopNodeConfig | IterationNodeConfig | None = None self.cycle_nodes = list() # Nodes belonging to this cycle self.cycle_edges = list() # Edges connecting nodes within the cycle diff --git a/api/app/core/workflow/nodes/http_request/node.py b/api/app/core/workflow/nodes/http_request/node.py index 2e5de796..141cba79 100644 --- a/api/app/core/workflow/nodes/http_request/node.py +++ b/api/app/core/workflow/nodes/http_request/node.py @@ -32,7 +32,7 @@ class HttpRequestNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = HttpRequestNodeConfig(**self.config) + self.typed_config: HttpRequestNodeConfig | None = None def _build_timeout(self) -> Timeout: """ @@ -181,6 +181,7 @@ class HttpRequestNode(BaseNode): - dict: Serialized HttpRequestNodeOutput on success - str: Branch identifier (e.g. "ERROR") when branching is enabled """ + self.typed_config = HttpRequestNodeConfig(**self.config) async with httpx.AsyncClient( verify=self.typed_config.verify_ssl, timeout=self._build_timeout(), diff --git a/api/app/core/workflow/nodes/if_else/node.py b/api/app/core/workflow/nodes/if_else/node.py index 8c6d222f..41f1138b 100644 --- a/api/app/core/workflow/nodes/if_else/node.py +++ b/api/app/core/workflow/nodes/if_else/node.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) class IfElseNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = IfElseNodeConfig(**self.config) + self.typed_config: IfElseNodeConfig | None= None @staticmethod def _evaluate(operator, instance: CompareOperatorInstance) -> Any: @@ -109,6 +109,7 @@ class IfElseNode(BaseNode): Returns: str: The matched branch identifier, e.g., 'CASE1', 'CASE2', ..., used for node transitions. """ + self.typed_config = IfElseNodeConfig(**self.config) expressions = self.evaluate_conditional_edge_expressions(state) # TODO: 变量类型及文本类型解析 for i in range(len(expressions)): diff --git a/api/app/core/workflow/nodes/jinja_render/node.py b/api/app/core/workflow/nodes/jinja_render/node.py index 70993573..822f1918 100644 --- a/api/app/core/workflow/nodes/jinja_render/node.py +++ b/api/app/core/workflow/nodes/jinja_render/node.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) class JinjaRenderNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = JinjaRenderNodeConfig(**self.config) + self.typed_config: JinjaRenderNodeConfig | None = None async def execute(self, state: WorkflowState) -> Any: """ @@ -34,6 +34,7 @@ class JinjaRenderNode(BaseNode): RuntimeError: If Jinja2 template rendering fails due to invalid template syntax or missing variables. """ + self.typed_config = JinjaRenderNodeConfig(**self.config) render = TemplateRenderer(strict=False) context = {} diff --git a/api/app/core/workflow/nodes/knowledge/config.py b/api/app/core/workflow/nodes/knowledge/config.py index 9d307216..5475636e 100644 --- a/api/app/core/workflow/nodes/knowledge/config.py +++ b/api/app/core/workflow/nodes/knowledge/config.py @@ -44,8 +44,8 @@ class KnowledgeRetrievalNodeConfig(BaseNodeConfig): description="Knowledge base config" ) - reranker_id: UUID = Field( - default="", + reranker_id: UUID | None = Field( + default=None, description="Reranker top k" ) diff --git a/api/app/core/workflow/nodes/knowledge/node.py b/api/app/core/workflow/nodes/knowledge/node.py index 061328e1..221ca079 100644 --- a/api/app/core/workflow/nodes/knowledge/node.py +++ b/api/app/core/workflow/nodes/knowledge/node.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) class KnowledgeRetrievalNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = KnowledgeRetrievalNodeConfig(**self.config) + self.typed_config: KnowledgeRetrievalNodeConfig | None = None @staticmethod def _build_kb_filter(kb_ids: list[uuid.UUID], permission: knowledge_model.PermissionType): @@ -171,6 +171,7 @@ class KnowledgeRetrievalNode(BaseNode): Raises: RuntimeError: If no valid knowledge base is found or access is denied. """ + self.typed_config = KnowledgeRetrievalNodeConfig(**self.config) query = self._render_template(self.typed_config.query, state) with get_db_read() as db: knowledge_bases = self.typed_config.knowledge_bases diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index 5fb86ae2..6395d3b8 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -68,7 +68,7 @@ class LLMNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = LLMNodeConfig(**self.config) + self.typed_config: LLMNodeConfig | None = None def _render_context(self, message, state): context = f"{self._render_template(self.typed_config.context, state)}" @@ -164,6 +164,7 @@ class LLMNode(BaseNode): Returns: LLM 响应消息 """ + self.typed_config = LLMNodeConfig(**self.config) llm, prompt_or_messages = self._prepare_llm(state, True) logger.info(f"节点 {self.node_id} 开始执行 LLM 调用(非流式)") diff --git a/api/app/core/workflow/nodes/memory/node.py b/api/app/core/workflow/nodes/memory/node.py index 0d1b1fb4..f1c99ddb 100644 --- a/api/app/core/workflow/nodes/memory/node.py +++ b/api/app/core/workflow/nodes/memory/node.py @@ -10,9 +10,10 @@ from app.services.memory_agent_service import MemoryAgentService class MemoryReadNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = MemoryReadNodeConfig(**self.config) + self.typed_config: MemoryReadNodeConfig | None = None async def execute(self, state: WorkflowState) -> Any: + self.typed_config = MemoryReadNodeConfig(**self.config) with get_db_read() as db: workspace_id = self.get_variable('sys.workspace_id', state) end_user_id = self.get_variable("sys.user_id", state) diff --git a/api/app/core/workflow/nodes/parameter_extractor/node.py b/api/app/core/workflow/nodes/parameter_extractor/node.py index 84d61aa9..ec58d96c 100644 --- a/api/app/core/workflow/nodes/parameter_extractor/node.py +++ b/api/app/core/workflow/nodes/parameter_extractor/node.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) class ParameterExtractorNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = ParameterExtractorNodeConfig(**self.config) + self.typed_config: ParameterExtractorNodeConfig | None = None @staticmethod def _get_prompt(): @@ -145,6 +145,7 @@ class ParameterExtractorNode(BaseNode): Raises: BusinessException: If LLM output cannot be parsed as valid JSON. """ + self.typed_config = ParameterExtractorNodeConfig(**self.config) llm = self._get_llm_instance() system_prompt, user_prompt = self._get_prompt() diff --git a/api/app/core/workflow/nodes/question_classifier/node.py b/api/app/core/workflow/nodes/question_classifier/node.py index b0f2c28d..aee72eda 100644 --- a/api/app/core/workflow/nodes/question_classifier/node.py +++ b/api/app/core/workflow/nodes/question_classifier/node.py @@ -21,8 +21,8 @@ class QuestionClassifierNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = QuestionClassifierNodeConfig(**self.config) - self.category_to_case_map = self._build_category_case_map() + self.typed_config: QuestionClassifierNodeConfig | None = None + self.category_to_case_map = {} def _get_llm_instance(self) -> RedBearLLM: """获取LLM实例""" @@ -67,6 +67,8 @@ class QuestionClassifierNode(BaseNode): async def execute(self, state: WorkflowState) -> dict: """执行问题分类""" + self.typed_config = QuestionClassifierNodeConfig(**self.config) + self.category_to_case_map = self._build_category_case_map() question = self.typed_config.input_variable supplement_prompt = self.typed_config.user_supplement_prompt or "" categories = self.typed_config.categories or [] diff --git a/api/app/core/workflow/nodes/start/node.py b/api/app/core/workflow/nodes/start/node.py index 7c3a2fca..69560422 100644 --- a/api/app/core/workflow/nodes/start/node.py +++ b/api/app/core/workflow/nodes/start/node.py @@ -7,6 +7,7 @@ Start 节点实现 import logging from typing import Any +from app.core.workflow.nodes.base_config import VariableType from app.core.workflow.nodes.base_node import BaseNode, WorkflowState from app.core.workflow.nodes.start.config import StartNodeConfig @@ -34,7 +35,7 @@ class StartNode(BaseNode): super().__init__(node_config, workflow_config) # 解析并验证配置 - self.typed_config = StartNodeConfig(**self.config) + self.typed_config: StartNodeConfig | None = None async def execute(self, state: WorkflowState) -> dict[str, Any]: """执行 start 节点业务逻辑 @@ -47,6 +48,7 @@ class StartNode(BaseNode): Returns: 包含系统参数、会话变量和自定义变量的字典 """ + self.typed_config = StartNodeConfig(**self.config) logger.info(f"节点 {self.node_id} (Start) 开始执行") # 创建变量池实例(在方法内复用) @@ -113,6 +115,18 @@ class StartNode(BaseNode): logger.debug( f"变量 '{var_name}' 使用默认值: {var_def.default}" ) + else: + match var_def.type: + case VariableType.STRING: + processed[var_name] = "" + case VariableType.NUMBER: + processed[var_name] = 0 + case VariableType.OBJECT: + processed[var_name] = {} + case VariableType.BOOLEAN: + processed[var_name] = False + case VariableType.ARRAY_NUMBER | VariableType.ARRAY_OBJECT | VariableType.ARRAY_BOOLEAN | VariableType.ARRAY_STRING: + processed[var_name] = [] return processed diff --git a/api/app/core/workflow/nodes/tool/node.py b/api/app/core/workflow/nodes/tool/node.py index 496812a6..3e79b075 100644 --- a/api/app/core/workflow/nodes/tool/node.py +++ b/api/app/core/workflow/nodes/tool/node.py @@ -19,10 +19,11 @@ class ToolNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = ToolNodeConfig(**self.config) + self.typed_config: ToolNodeConfig | None = None async def execute(self, state: WorkflowState) -> dict[str, Any]: """执行工具""" + self.typed_config = ToolNodeConfig(**self.config) # 获取租户ID和用户ID tenant_id = self.get_variable("sys.tenant_id", state) user_id = self.get_variable("sys.user_id", state) diff --git a/api/app/core/workflow/nodes/variable_aggregator/node.py b/api/app/core/workflow/nodes/variable_aggregator/node.py index e6cbf75b..5bff8e33 100644 --- a/api/app/core/workflow/nodes/variable_aggregator/node.py +++ b/api/app/core/workflow/nodes/variable_aggregator/node.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) class VariableAggregatorNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = VariableAggregatorNodeConfig(**self.config) + self.typed_config: VariableAggregatorNodeConfig | None = None @staticmethod def _get_express(variable_string: str) -> Any: @@ -37,6 +37,7 @@ class VariableAggregatorNode(BaseNode): - str: In non-group mode, returns the first non-None variable value. - dict: In group mode, returns a mapping of group_name -> first non-None variable value. """ + self.typed_config = VariableAggregatorNodeConfig(**self.config) if not self.typed_config.group: # -------------------------- # Non-group mode diff --git a/api/app/repositories/neo4j/graph_search.py b/api/app/repositories/neo4j/graph_search.py index 1549ef86..80756793 100644 --- a/api/app/repositories/neo4j/graph_search.py +++ b/api/app/repositories/neo4j/graph_search.py @@ -66,24 +66,38 @@ async def _update_activation_values_batch( max_retries=max_retries ) - # 提取节点ID列表 - node_ids = [node.get('id') for node in nodes if node.get('id')] + # 提取节点ID列表并去重(保持原始顺序) + seen_ids = set() + unique_node_ids = [] + for node in nodes: + node_id = node.get('id') + if node_id and node_id not in seen_ids: + seen_ids.add(node_id) + unique_node_ids.append(node_id) - if not node_ids: + if not unique_node_ids: logger.warning(f"批量更新激活值:没有有效的节点ID") return nodes + + # 记录去重信息(仅针对具有有效 ID 的节点) + id_nodes_count = sum(1 for n in nodes if n.get("id")) + if len(unique_node_ids) < id_nodes_count: + logger.info( + f"批量更新激活值:检测到重复节点,具有有效ID的节点数量={id_nodes_count}, " + f"去重后唯一ID数量={len(unique_node_ids)}" + ) # 批量记录访问 try: updated_nodes = await access_manager.record_batch_access( - node_ids=node_ids, + node_ids=unique_node_ids, node_label=node_label, group_id=group_id ) logger.info( f"批量更新激活值成功: {node_label}, " - f"更新数量={len(updated_nodes)}/{len(node_ids)}" + f"更新数量={len(updated_nodes)}/{len(unique_node_ids)}" ) return updated_nodes @@ -153,19 +167,38 @@ async def _update_search_results_activation( original_nodes = results[key] updated_nodes = update_result - # 创建 ID 到原始节点的映射(用于快速查找 score) - original_map = {node.get('id'): node for node in original_nodes if node.get('id')} + # 创建 ID 到更新节点的映射(用于快速查找激活值数据) + updated_map = {node.get('id'): node for node in updated_nodes if node.get('id')} - # 合并数据:激活值来自更新结果,score 来自原始结果 + # 合并数据:保留所有原始节点(包括重复的),用更新后的激活值数据填充 merged_nodes = [] - for updated_node in updated_nodes: - node_id = updated_node.get('id') - if node_id and node_id in original_map: - # 保留原始的 score 字段 - original_score = original_map[node_id].get('score') - if original_score is not None: - updated_node['score'] = original_score - merged_nodes.append(updated_node) + for original_node in original_nodes: + node_id = original_node.get('id') + if node_id and node_id in updated_map: + # 从原始节点开始,用更新后的激活值数据覆盖 + merged_node = original_node.copy() + + # 更新激活值相关字段 + activation_fields = { + 'activation_value', + 'access_history', + 'last_access_time', + 'access_count', + 'importance_score', + 'version', + 'statement', # Statement 节点的内容字段 + 'content' # MemorySummary 节点的内容字段 + } + + # 只更新激活值相关字段,保留原始节点的其他字段 + for field in activation_fields: + if field in updated_map[node_id]: + merged_node[field] = updated_map[node_id][field] + + merged_nodes.append(merged_node) + else: + # 如果没有更新数据,保留原始节点 + merged_nodes.append(original_node) updated_results[key] = merged_nodes else: diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index acea60b7..569684d5 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -15,6 +15,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session +from app.celery_app import celery_app from app.core.error_codes import BizCode from app.core.exceptions import BusinessException from app.core.logging_config import get_business_logger @@ -22,6 +23,7 @@ from app.core.rag.nlp.search import knowledge_retrieval from app.models import AgentConfig, ModelApiKey, ModelConfig from app.repositories.tool_repository import ToolRepository from app.schemas.prompt_schema import PromptMessageRole, render_prompt_message +from app.services import task_service from app.services.langchain_tool_server import Search from app.services.memory_agent_service import MemoryAgentService from app.services.model_parameter_merger import ModelParameterMerger @@ -101,6 +103,14 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str user_rag_memory_id=user_rag_memory_id ) ) + task = celery_app.send_task( + "app.core.memory.agent.read_message", + args=[end_user_id, question, [], "1", config_id, storage_type, user_rag_memory_id] + ) + result = task_service.get_task_memory_read_result(task.id) + status = result.get("status") + logger.info(f"读取任务状态:{status}") + finally: db.close() logger.info(f'用户ID:Agent:{end_user_id}') diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 10f53ed7..2d78d796 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -456,23 +456,36 @@ class MemoryAgentService: client = MultiServerMCPClient(mcp_config) async with client.session('data_flow') as session: + session_start = time.time() logger.debug("Connected to MCP Server: data_flow") + + tools_start = time.time() tools = await load_mcp_tools(session) + tools_time = time.time() - tools_start + logger.info(f"[PERF] MCP tools loading took: {tools_time:.4f}s") + outputs = [] intermediate_outputs = [] seen_intermediates = set() # Track seen intermediate outputs to avoid duplicates # Pass memory_config to the graph workflow + graph_start = time.time() async with make_read_graph(group_id, tools, search_switch, group_id, group_id, memory_config=memory_config, storage_type=storage_type, user_rag_memory_id=user_rag_memory_id) as graph: + graph_init_time = time.time() - graph_start + logger.info(f"[PERF] Graph initialization took: {graph_init_time:.4f}s") + start = time.time() config = {"configurable": {"thread_id": group_id}} workflow_errors = [] # Track errors from workflow - + + event_count = 0 async for event in graph.astream( {"messages": history, "memory_config": memory_config, "errors": []}, stream_mode="values", config=config ): + event_count += 1 + event_start = time.time() messages = event.get('messages') # Capture any errors from the state if event.get('errors'): @@ -525,9 +538,15 @@ class MemoryAgentService: pass except Exception as e: logger.debug(f"Failed to extract intermediate output: {e}") + + event_time = time.time() - event_start + logger.info(f"[PERF] Event {event_count} processing took: {event_time:.4f}s") workflow_duration = time.time() - start - logger.info(f"Read graph workflow completed in {workflow_duration}s") + session_duration = time.time() - session_start + logger.info(f"[PERF] Read graph workflow completed in {workflow_duration}s") + logger.info(f"[PERF] Total session duration: {session_duration:.4f}s") + logger.info(f"[PERF] Total events processed: {event_count}") # Extract final answer final_answer = "" for messages in outputs: @@ -1186,8 +1205,8 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An ValueError: 当终端用户不存在或应用未发布时 """ from app.models.app_release_model import AppRelease - from app.models.end_user_model import EndUser from app.models.data_config_model import DataConfig + from app.models.end_user_model import EndUser from sqlalchemy import select logger.info(f"Getting connected config for end_user: {end_user_id}") @@ -1266,8 +1285,8 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) 对于查询失败的用户,value 包含 error 字段 """ from app.models.app_release_model import AppRelease - from app.models.end_user_model import EndUser from app.models.data_config_model import DataConfig + from app.models.end_user_model import EndUser from sqlalchemy import select logger.info(f"Batch getting connected configs for {len(end_user_ids)} end users") diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py index 8eae3c42..6f844ae9 100644 --- a/api/app/services/memory_base_service.py +++ b/api/app/services/memory_base_service.py @@ -9,6 +9,7 @@ from typing import Optional from app.core.logging_config import get_logger from app.repositories.neo4j.neo4j_connector import Neo4jConnector +from app.services.emotion_analytics_service import EmotionAnalyticsService logger = get_logger(__name__) @@ -109,3 +110,188 @@ class MemoryBaseService: except Exception as e: logger.error(f"提取情景记忆情绪时出错: {str(e)}", exc_info=True) return None + + async def get_episodic_memory_count( + self, + end_user_id: Optional[str] = None + ) -> int: + """ + 获取情景记忆数量 + + 查询 MemorySummary 节点的数量。 + + Args: + end_user_id: 可选的终端用户ID,用于过滤特定用户的节点 + + Returns: + 情景记忆的数量 + """ + try: + if end_user_id: + query = """ + MATCH (n:MemorySummary) + WHERE n.group_id = $group_id + RETURN count(n) as count + """ + result = await self.neo4j_connector.execute_query(query, group_id=end_user_id) + else: + query = """ + MATCH (n:MemorySummary) + RETURN count(n) as count + """ + result = await self.neo4j_connector.execute_query(query) + + count = result[0]["count"] if result and len(result) > 0 else 0 + logger.debug(f"情景记忆数量: {count} (end_user_id={end_user_id})") + return count + + except Exception as e: + logger.error(f"获取情景记忆数量时出错: {str(e)}", exc_info=True) + return 0 + + async def get_explicit_memory_count( + self, + end_user_id: Optional[str] = None + ) -> int: + """ + 获取显性记忆数量 + + 显性记忆 = 情景记忆(MemorySummary)+ 语义记忆(ExtractedEntity with is_explicit_memory=true) + + Args: + end_user_id: 可选的终端用户ID,用于过滤特定用户的节点 + + Returns: + 显性记忆的数量 + """ + try: + # 1. 获取情景记忆数量 + episodic_count = await self.get_episodic_memory_count(end_user_id) + + # 2. 获取语义记忆数量(ExtractedEntity 且 is_explicit_memory = true) + if end_user_id: + semantic_query = """ + MATCH (e:ExtractedEntity) + WHERE e.group_id = $group_id AND e.is_explicit_memory = true + RETURN count(e) as count + """ + semantic_result = await self.neo4j_connector.execute_query( + semantic_query, + group_id=end_user_id + ) + else: + semantic_query = """ + MATCH (e:ExtractedEntity) + WHERE e.is_explicit_memory = true + RETURN count(e) as count + """ + semantic_result = await self.neo4j_connector.execute_query(semantic_query) + + semantic_count = semantic_result[0]["count"] if semantic_result and len(semantic_result) > 0 else 0 + + # 3. 计算总数 + explicit_count = episodic_count + semantic_count + logger.debug( + f"显性记忆数量: {explicit_count} " + f"(情景={episodic_count}, 语义={semantic_count}, end_user_id={end_user_id})" + ) + return explicit_count + + except Exception as e: + logger.error(f"获取显性记忆数量时出错: {str(e)}", exc_info=True) + return 0 + + async def get_emotional_memory_count( + self, + end_user_id: Optional[str] = None, + statement_count_fallback: int = 0 + ) -> int: + """ + 获取情绪记忆数量 + + 通过 EmotionAnalyticsService 获取情绪标签统计总数。 + 如果获取失败或没有指定 end_user_id,使用 statement_count_fallback 作为后备。 + + Args: + end_user_id: 可选的终端用户ID + statement_count_fallback: 后备方案的数量(通常是 statement 节点数量) + + Returns: + 情绪记忆的数量 + """ + try: + if end_user_id: + emotion_service = EmotionAnalyticsService() + + emotion_data = await emotion_service.get_emotion_tags( + end_user_id=end_user_id, + emotion_type=None, + start_date=None, + end_date=None, + limit=10 + ) + emotion_count = emotion_data.get("total_count", 0) + logger.debug(f"情绪记忆数量: {emotion_count} (end_user_id={end_user_id})") + return emotion_count + else: + # 如果没有指定 end_user_id,使用后备方案 + logger.debug(f"情绪记忆数量: {statement_count_fallback} (使用后备方案)") + return statement_count_fallback + + except Exception as e: + logger.warning(f"获取情绪记忆数量失败,使用后备方案: {str(e)}") + return statement_count_fallback + + async def get_forget_memory_count( + self, + end_user_id: Optional[str] = None, + forgetting_threshold: float = 0.3 + ) -> int: + """ + 获取遗忘记忆数量 + + 统计激活值低于遗忘阈值的节点数量(low_activation_nodes)。 + 查询范围包括:Statement、ExtractedEntity、MemorySummary、Chunk 节点。 + + Args: + end_user_id: 可选的终端用户ID,用于过滤特定用户的节点 + forgetting_threshold: 遗忘阈值,默认 0.3 + + Returns: + 遗忘记忆的数量(激活值低于阈值的节点数) + """ + try: + # 构建查询语句 + query = """ + MATCH (n) + WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary OR n:Chunk) + """ + + if end_user_id: + query += " AND n.group_id = $group_id" + + query += """ + RETURN sum(CASE WHEN n.activation_value IS NOT NULL AND n.activation_value < $threshold THEN 1 ELSE 0 END) as low_activation_nodes + """ + + # 设置查询参数 + params = {'threshold': forgetting_threshold} + if end_user_id: + params['group_id'] = end_user_id + + # 执行查询 + result = await self.neo4j_connector.execute_query(query, **params) + + # 提取结果 + forget_count = result[0]['low_activation_nodes'] if result and len(result) > 0 else 0 + forget_count = forget_count or 0 # 处理 None 值 + + logger.debug( + f"遗忘记忆数量: {forget_count} " + f"(threshold={forgetting_threshold}, end_user_id={end_user_id})" + ) + return forget_count + + except Exception as e: + logger.error(f"获取遗忘记忆数量时出错: {str(e)}", exc_info=True) + return 0 diff --git a/api/app/services/memory_episodic_service.py b/api/app/services/memory_episodic_service.py index e8bb0bfc..12eeff6e 100644 --- a/api/app/services/memory_episodic_service.py +++ b/api/app/services/memory_episodic_service.py @@ -401,5 +401,5 @@ class MemoryEpisodicService(MemoryBaseService): raise -# 创建全局服务实例 +# 创建全局服务实例(供控制器层使用) memory_episodic_service = MemoryEpisodicService() diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index bfb05d47..67a6ab2c 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -15,6 +15,7 @@ from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.repositories.end_user_repository import EndUserRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector +from app.services.memory_base_service import MemoryBaseService from app.services.memory_config_service import MemoryConfigService from pydantic import BaseModel, Field from sqlalchemy.orm import Session @@ -1195,17 +1196,18 @@ async def analytics_memory_types( end_user_id: Optional[str] = None ) -> List[Dict[str, Any]]: """ - 统计8种记忆类型的数量和百分比 + 统计9种记忆类型的数量和百分比 计算规则: 1. 感知记忆 (PERCEPTUAL_MEMORY) = statement + entity 2. 工作记忆 (WORKING_MEMORY) = chunk + entity 3. 短期记忆 (SHORT_TERM_MEMORY) = chunk 4. 长期记忆 (LONG_TERM_MEMORY) = entity - 5. 显性记忆 (EXPLICIT_MEMORY) = 1/2 * entity + 5. 显性记忆 (EXPLICIT_MEMORY) = 情景记忆 + 语义记忆(通过 MemoryBaseService.get_explicit_memory_count 获取) 6. 隐性记忆 (IMPLICIT_MEMORY) = 1/3 * entity - 7. 情绪记忆 (EMOTIONAL_MEMORY) = statement - 8. 情景记忆 (EPISODIC_MEMORY) = memory_summary + 7. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取) + 8. 情景记忆 (EPISODIC_MEMORY) = memory_summary(通过 MemoryBaseService.get_episodic_memory_count 获取) + 9. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_memory_count 获取) Args: db: 数据库会话 @@ -1230,13 +1232,16 @@ async def analytics_memory_types( - IMPLICIT_MEMORY: 隐性记忆 - EMOTIONAL_MEMORY: 情绪记忆 - EPISODIC_MEMORY: 情景记忆 + - FORGET_MEMORY: 遗忘记忆 """ - # 定义需要查询的节点类型 + # 初始化基础服务 + base_service = MemoryBaseService() + + # 定义需要查询的基础节点类型 node_types = { "Statement": "Statement", "Entity": "ExtractedEntity", - "Chunk": "Chunk", - "MemorySummary": "MemorySummary" + "Chunk": "Chunk" } # 存储每种节点类型的计数 @@ -1266,18 +1271,45 @@ async def analytics_memory_types( statement_count = node_counts.get("Statement", 0) entity_count = node_counts.get("Entity", 0) chunk_count = node_counts.get("Chunk", 0) - memory_summary_count = node_counts.get("MemorySummary", 0) - # 按规则计算8种记忆类型的数量(使用英文枚举作为key) + # 获取用户的遗忘阈值配置 + forgetting_threshold = 0.3 # 默认值 + if end_user_id: + try: + from app.services.memory_agent_service import get_end_user_connected_config + from app.core.memory.storage_services.forgetting_engine.config_utils import load_actr_config_from_db + + # 获取用户关联的 config_id + connected_config = get_end_user_connected_config(end_user_id, db) + config_id = connected_config.get('memory_config_id') + + if config_id: + # 从数据库加载配置 + config = load_actr_config_from_db(db, config_id) + forgetting_threshold = config.get('forgetting_threshold', 0.3) + logger.debug(f"使用用户配置的遗忘阈值: {forgetting_threshold} (end_user_id={end_user_id}, config_id={config_id})") + else: + logger.debug(f"用户未关联配置,使用默认遗忘阈值: {forgetting_threshold} (end_user_id={end_user_id})") + except Exception as e: + logger.warning(f"获取用户遗忘阈值配置失败,使用默认值 {forgetting_threshold}: {str(e)}") + + # 使用 MemoryBaseService 的共享方法获取特殊记忆类型的数量 + episodic_count = await base_service.get_episodic_memory_count(end_user_id) + explicit_count = await base_service.get_explicit_memory_count(end_user_id) + emotion_count = await base_service.get_emotional_memory_count(end_user_id, statement_count) + forget_count = await base_service.get_forget_memory_count(end_user_id, forgetting_threshold) + + # 按规则计算9种记忆类型的数量(使用英文枚举作为key) memory_counts = { "PERCEPTUAL_MEMORY": statement_count + entity_count, # 感知记忆 "WORKING_MEMORY": chunk_count + entity_count, # 工作记忆 "SHORT_TERM_MEMORY": chunk_count, # 短期记忆 "LONG_TERM_MEMORY": entity_count, # 长期记忆 - "EXPLICIT_MEMORY": entity_count // 2, # 显性记忆 (1/2 entity) + "EXPLICIT_MEMORY": explicit_count, # 显性记忆(情景记忆 + 语义记忆) "IMPLICIT_MEMORY": entity_count // 3, # 隐性记忆 (1/3 entity) - "EMOTIONAL_MEMORY": statement_count, # 情绪记忆 - "EPISODIC_MEMORY": memory_summary_count # 情景记忆 + "EMOTIONAL_MEMORY": emotion_count, # 情绪记忆(使用情绪标签统计) + "EPISODIC_MEMORY": episodic_count, # 情景记忆 + "FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值) } # 计算总数 diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index 68d6279b..7d3c784f 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -491,6 +491,17 @@ class WorkflowService: ) end_user_id = str(new_end_user.id) + executions = self.execution_repo.get_by_conversation_id(conversation_id=conversation_id_uuid) + + for exec_res in executions: + if exec_res.status == "completed": + last_state = exec_res.output_data + if isinstance(last_state, dict): + variables = last_state.get("variables", {}) + conv_vars = variables.get("conv", {}) + input_data["conv"] = conv_vars + break + result = await execute_workflow( workflow_config=workflow_config_dict, input_data=input_data, @@ -504,7 +515,7 @@ class WorkflowService: self.update_execution_status( execution.execution_id, "completed", - output_data=result.get("node_outputs", {}) + output_data=result ) else: self.update_execution_status( @@ -517,6 +528,7 @@ class WorkflowService: return { "execution_id": execution.execution_id, "status": result.get("status"), + "variables": result.get("variables"), "output": result.get("output"), # 最终输出(字符串) "output_data": result.get("node_outputs", {}), # 所有节点输出(详细数据) "conversation_id": result.get("conversation_id"), # 所有节点输出(详细数据)payload., # 会话 ID @@ -617,6 +629,16 @@ class WorkflowService: original_user_id=payload.user_id # Save original user_id to other_id ) end_user_id = str(new_end_user.id) + executions = self.execution_repo.get_by_conversation_id(conversation_id=conversation_id_uuid) + + for exec_res in executions: + if exec_res.status == "completed": + last_state = exec_res.output_data + if isinstance(last_state, dict): + variables = last_state.get("variables", {}) + conv_vars = variables.get("conv", {}) + input_data["conv"] = conv_vars + break # 调用流式执行(executor 会发送 workflow_start 和 workflow_end 事件) async for event in self._run_workflow_stream( @@ -827,6 +849,23 @@ class WorkflowService: user_id=user_id ): # 直接转发事件(executor 已经返回正确格式) + if event.get("event") == "workflow_end": + + status = event.get("data", {}).get("status") + if status == "completed": + self.update_execution_status( + execution_id, + "completed", + output_data=event.get("data") + ) + elif status == "failed": + self.update_execution_status( + execution_id, + "failed", + output_data=event.get("data") + ) + else: + logger.error(f"unexpect workflow run status, status: {status}") yield event except Exception as e: diff --git a/web/src/api/memory.ts b/web/src/api/memory.ts index 3c0fe6fa..0ac14451 100644 --- a/web/src/api/memory.ts +++ b/web/src/api/memory.ts @@ -117,26 +117,26 @@ export const getRagContent = (end_user_id: string) => { } // 情感分布分析 export const getWordCloud = (group_id: string) => { - return request.post(`/memory/emotion/wordcloud`, { group_id, limit: 20 }) + return request.post(`/memory/emotion-memory/wordcloud`, { group_id, limit: 20 }) } // 高频情绪关键词 export const getEmotionTags = (group_id: string) => { - return request.post(`/memory/emotion/tags`, { group_id, limit: 20 }) + return request.post(`/memory/emotion-memory/tags`, { group_id, limit: 20 }) } // 情绪健康指数 export const getEmotionHealth = (group_id: string) => { - return request.post(`/memory/emotion/health`, { group_id, limit: 20 }) + return request.post(`/memory/emotion-memory/health`, { group_id, limit: 20 }) } // 个性化建议 export const getEmotionSuggestions = (group_id: string) => { - return request.post(`/memory/emotion/suggestions`, { group_id, limit: 20 }) + return request.post(`/memory/emotion-memory/suggestions`, { group_id, limit: 20 }) } export const analyticsRefresh = (end_user_id: string) => { return request.post('/memory-storage/analytics/generate_cache', { end_user_id }) } // 遗忘 export const getForgetStats = (group_id: string) => { - return request.get(`/memory/forget/stats`, { group_id }) + return request.get(`/memory/forget-memory/stats`, { group_id }) } // 隐性记忆-偏好 export const getImplicitPreferences = (end_user_id: string) => { @@ -176,10 +176,10 @@ export const getPerceptualTimeline = (end_user: string) => { } // 情景记忆-总览 export const getEpisodicOverview = (data: { end_user_id: string; time_range: string; episodic_type: string; } ) => { - return request.post(`/memory-storage/classifications/episodic-memory`, data) + return request.post(`/memory/episodic-memory/overview`, data) } export const getEpisodicDetail = (data: { end_user_id: string; summary_id: string; } ) => { - return request.post(`/memory-storage/classifications/episodic-memory-details`, data) + return request.post(`/memory/episodic-memory/details`, data) } // 关系演化 export const getRelationshipEvolution = (data: { id: string; label: string; } ) => { @@ -190,10 +190,10 @@ export const getTimelineMemories = (data: { id: string; label: string; }) => { return request.get(`/memory-storage/memory_space/timeline_memories`, data) } export const getExplicitMemory = (end_user_id: string) => { - return request.post(`/memory-storage/classifications/explicit-memory`, { end_user_id }) + return request.post(`/memory/explicit-memory/overview`, { end_user_id }) } export const getExplicitMemoryDetails = (data: { end_user_id: string, memory_id: string; }) => { - return request.post(`/memory-storage/classifications/explicit-memory-details`, data) + return request.post(`/memory/explicit-memory/details`, data) } export const getConversations = (end_user: string) => { return request.get(`/memory/work/${end_user}/conversations`) @@ -205,7 +205,7 @@ export const getConversationDetail = (end_user: string, conversation_id: string) return request.get(`/memory/work/${end_user}/detail`, { conversation_id }) } export const forgetTrigger = (data: { max_merge_batch_size: number; min_days_since_access: number; end_user_id: string;}) => { - return request.post(`/memory/forget/trigger`, data) + return request.post(`/memory/forget-memory/trigger`, data) } /*************** end 用户记忆 相关接口 ******************************/ @@ -229,11 +229,11 @@ export const deleteMemoryConfig = (config_id: number) => { } // 遗忘引擎-获取配置 export const getMemoryForgetConfig = (config_id: number | string) => { - return request.get('/memory/forget/read_config', { config_id }) + return request.get('/memory/forget-memory/read_config', { config_id }) } // 遗忘引擎-更新配置 export const updateMemoryForgetConfig = (values: ForgetConfigForm) => { - return request.post('/memory/forget/update_config', values) + return request.post('/memory/forget-memory/update_config', values) } // 记忆萃取引擎-获取配置 export const getMemoryExtractionConfig = (config_id: number | string) => { diff --git a/web/src/assets/images/menu/spaceConfig.svg b/web/src/assets/images/menu/spaceConfig.svg new file mode 100644 index 00000000..bcfeae12 --- /dev/null +++ b/web/src/assets/images/menu/spaceConfig.svg @@ -0,0 +1,17 @@ + + + 模型 (1) + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/menu/spaceConfig_active.svg b/web/src/assets/images/menu/spaceConfig_active.svg new file mode 100644 index 00000000..41b25689 --- /dev/null +++ b/web/src/assets/images/menu/spaceConfig_active.svg @@ -0,0 +1,17 @@ + + + 模型 (1) + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/userMemory/goto.svg b/web/src/assets/images/userMemory/goto.svg new file mode 100644 index 00000000..a66e2011 --- /dev/null +++ b/web/src/assets/images/userMemory/goto.svg @@ -0,0 +1,19 @@ + + + 编组 13备份 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/components/CustomSelect/index.tsx b/web/src/components/CustomSelect/index.tsx index 97ca4e4b..e9ccce74 100644 --- a/web/src/components/CustomSelect/index.tsx +++ b/web/src/components/CustomSelect/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef, type FC, type Key } from 'react'; +import { useEffect, useState, type FC, type Key } from 'react'; import { Select } from 'antd' import type { SelectProps, DefaultOptionType } from 'antd/es/select' import { useTranslation } from 'react-i18next'; @@ -26,7 +26,7 @@ interface CustomSelectProps extends Omit { disabled?: boolean; style?: React.CSSProperties; className?: string; - filterOption?: (inputValue: string, option: DefaultOptionType) => boolean; + filterOption?: (inputValue: string, option?: DefaultOptionType) => boolean; } interface OptionType { [key: string]: Key | string | number; @@ -48,44 +48,27 @@ const CustomSelect: FC = ({ }) => { const { t } = useTranslation(); const [options, setOptions] = useState([]); - // 创建防抖定时器引用 - const debounceRef = useRef(); - - // 防抖搜索函数 - const handleSearch = useCallback((value?: string) => { - // 清除之前的定时器 - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - - // 设置新的定时器 - debounceRef.current = window.setTimeout(() => { - request.get>(url, {...params, [optionFilterProp]: value}).then((res) => { - const data = res; - setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []); - }); - }, 300); // 300毫秒防抖延迟 - }, [url, params, optionFilterProp]); + // 默认模糊搜索函数 + const defaultFilterOption = (inputValue: string, option?: DefaultOptionType) => { + if (!option || !inputValue) return true; + const label = String(option.children || option.label || ''); + return label.toLowerCase().includes(inputValue.toLowerCase()); + }; // 组件挂载时获取初始数据 useEffect(() => { - handleSearch(); - - // 组件卸载时清除定时器 - return () => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - }; - }, [url, handleSearch]); + request.get>(url, params).then((res) => { + const data = res; + setOptions(Array.isArray(data) ? data || [] : Array.isArray(data?.items) ? data.items || [] : []); + }); + }, []); return ( + - - + ((_props, ref) => { const { t } = useTranslation(); @@ -88,8 +89,8 @@ const TimeToolModal = forwardRef((_props, ref) => { } }) .then(res => { - const response = res as { data: CurrentTimeObj } - setTimestampFormat(response.data.datetime) + const response = res as { data: string } + setTimestampFormat(response.data) }) } const handleChangeFormatType = () => { @@ -149,7 +150,7 @@ const TimeToolModal = forwardRef((_props, ref) => { - + diff --git a/web/src/views/ToolManagement/constant.ts b/web/src/views/ToolManagement/constant.ts index 1e30bafa..6763a140 100644 --- a/web/src/views/ToolManagement/constant.ts +++ b/web/src/views/ToolManagement/constant.ts @@ -10,10 +10,10 @@ export const InnerConfigData: Record = { }, JsonTool: { features: [ - 'jsonFormat', - 'jsonGzip', - 'jsonCheck', - 'jsonConversion' + 'jsonParse', + 'jsonInsert', + 'jsonReplace', + 'jsonDelete' ], eg: '{"name":"工具","tool_class":"内置"}' }, diff --git a/web/src/views/ToolManagement/types.ts b/web/src/views/ToolManagement/types.ts index 6fd4e439..aa97db66 100644 --- a/web/src/views/ToolManagement/types.ts +++ b/web/src/views/ToolManagement/types.ts @@ -130,6 +130,7 @@ export interface ExecuteData { ensure_ascii?: boolean; sort_keys?: boolean; input_data?: string; + json_path?: string; } } export interface CustomToolModalRef { diff --git a/web/src/views/UserMemory/components/ConfigModal.tsx b/web/src/views/UserMemory/components/ConfigModal.tsx deleted file mode 100644 index 86ea8f19..00000000 --- a/web/src/views/UserMemory/components/ConfigModal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { forwardRef, useImperativeHandle, useState } from 'react'; -import { Form, App } from 'antd'; -import { useTranslation } from 'react-i18next'; - -import type { ConfigModalData, ConfigModalRef } from '../types' -import { getWorkspaceModels, updateWorkspaceModels } from '@/api/workspaces' -import { getModelListUrl } from '@/api/models' -import CustomSelect from '@/components/CustomSelect' -import RbModal from '@/components/RbModal' - -const ConfigModal = forwardRef((_props, ref) => { - const { t } = useTranslation(); - const { message } = App.useApp(); - const [visible, setVisible] = useState(false); - const [form] = Form.useForm(); - const [loading, setLoading] = useState(false) - - const values = Form.useWatch([], form); - - // 封装取消方法,添加关闭弹窗逻辑 - const handleClose = () => { - setVisible(false); - form.resetFields(); - setLoading(false) - }; - - const handleOpen = () => { - getWorkspaceModels().then((res) => { - const { llm, embedding, rerank } = res as ConfigModalData - form.setFieldsValue({ - llm, - embedding, - rerank - }) - }) - setVisible(true); - }; - // 封装保存方法,添加提交逻辑 - const handleSave = () => { - form - .validateFields() - .then(() => { - setLoading(true) - updateWorkspaceModels(values) - .then(() => { - setLoading(false) - handleClose() - message.success(t('common.updateSuccess')) - }) - .catch(() => { - setLoading(false) - }); - - handleClose() - }) - .catch((err) => { - console.log('err', err) - }); - } - - // 暴露给父组件的方法 - useImperativeHandle(ref, () => ({ - handleOpen, - handleClose - })); - - return ( - -
- - - - - - - - - -
-
- ); -}); - -export default ConfigModal; \ No newline at end of file diff --git a/web/src/views/UserMemory/index.tsx b/web/src/views/UserMemory/index.tsx index 7065f036..064b55be 100644 --- a/web/src/views/UserMemory/index.tsx +++ b/web/src/views/UserMemory/index.tsx @@ -1,56 +1,28 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom' -import { Row, Col, Radio, Button, List, Skeleton, Space } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import type { RadioChangeEvent } from 'antd'; -import { AppstoreOutlined, MenuOutlined } from '@ant-design/icons'; +import { Row, Col, List, Skeleton } from 'antd'; import Empty from '@/components/Empty' -import type { Data, ConfigModalRef } from './types' -import totalNum from '@/assets/images/memory/totalNum.svg' -import onlineNum from '@/assets/images/memory/onlineNum.svg' -import Table from '@/components/Table' -import { getTotalEndUsers, userMemoryListUrl, getUserMemoryList } from '@/api/memory'; -import ConfigModal from './components/ConfigModal'; +import type { Data } from './types' +import { getUserMemoryList } from '@/api/memory'; import { useUser } from '@/store/user' +import RbCard from '@/components/RbCard/Card' +import SearchInput from '@/components/SearchInput'; -const bgList = [ - 'linear-gradient( 180deg, #F1F6FE 0%, #FBFDFF 100%)', - 'linear-gradient( 180deg, #F1F9FE 0%, #FBFDFF 100%)', - 'linear-gradient( 180deg, #FEFBF7 0%, #FBFDFF 100%)', - 'linear-gradient( 180deg, #F1F9FE 0%, #FBFDFF 100%)', -] - -const countList = [ - 'total_num', 'online_num', -] -const IconList: Record = { - total_num: totalNum, - online_num: onlineNum, -} export default function UserMemory() { const { t } = useTranslation(); const navigate = useNavigate() const { storageType } = useUser() - const configModalRef = useRef(null) const [loading, setLoading] = useState(false); const [data, setData] = useState([]); - const [countData, setCountData] = useState>({}); - const [layout, setLayout] = useState<'card' | 'list'>('card'); + const [search, setSearch] = useState(undefined); // 获取数据 useEffect(() => { - getCountData() getData() }, []); - // 用户记忆统计 - const getCountData = () => { - getTotalEndUsers().then((res) => { - setCountData(res as Record || {}) - }) - } const getData = () => { setLoading(true) getUserMemoryList().then((res) => { @@ -60,7 +32,6 @@ export default function UserMemory() { setLoading(false) }) } - console.log('storageType', storageType) const handleViewDetail = (id: string | number) => { switch (storageType) { case 'neo4j': @@ -70,112 +41,77 @@ export default function UserMemory() { navigate(`/user-memory/${id}`) } } - const handleChangeLayout = (e: RadioChangeEvent) => { - const type = e.target.value - setLayout(type) + const handleViewMemoryConfig = () => { + navigate(`/memory`) } - // 表格列配置 - const columns: ColumnsType = [ - { - title: t('userMemory.user'), - dataIndex: 'end_user', - key: 'end_user', - render: (value) => value?.other_name && value?.other_name !== '' ? value?.other_name : value?.id || '-' - }, - { - title: t('userMemory.knowledgeEntryCount'), - dataIndex: 'memory_num', - key: 'memory_num', - render: (value) => value?.total || 0 - }, - { - title: t('common.operation'), - key: 'action', - render: (_, record) => ( - - ), - }, - ]; + + const filterData = useMemo(() => { + if (search && search.trim() !== '') { + return data.filter((item) => { + const { end_user } = item as Data; + const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id + return name?.includes(search) + }) + } + + return data + }, [search, data]) return (
- {countList.map(key => ( - -
-
- {countData[key] || 0}{key === 'avgInteractionTime' ? 's' : ''} - -
-
{t(`userMemory.${key}`)}
-
- - ))} - - - - - - - - + + setSearch(value)} + style={{ width: '100%' }} + />
- {layout === 'card' && - <> - {loading ? - - : data.length > 0 ? ( - { - const { end_user, memory_num } = item as Data; - const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id - return ( - -
+ : filterData.length > 0 ? ( + { + const { end_user, memory_num, memory_config } = item as Data; + const name = end_user?.other_name && end_user?.other_name !== '' ? end_user?.other_name : end_user?.id + return ( + + {name[0]}
} + title={name || '-'} + extra={
handleViewDetail(end_user.id)} - > -
-
{name[0]}
-
- {name || '-'}
-
-
-
-
-
{memory_num.total || 0}
-
{t(`userMemory.knowledgeEntryCount`)}
-
-
+ >
} + > +
+
{t('userMemory.capacity')}
+
{memory_num?.total || 0} {t('userMemory.memoryNum')}
+
+
+
{t('userMemory.type')}
+
{t(`userMemory.${item.type || 'person'}`)}
-
- ) - }} - /> - ) : } - - } - {layout === 'list' && - +
+
+ {t('userMemory.memory_config_name')} +
+
+
{memory_config?.memory_config_name || '-'}
+
+ + + ) + }} + /> + ) : } - ); } \ No newline at end of file diff --git a/web/src/views/UserMemory/types.ts b/web/src/views/UserMemory/types.ts index 696b1694..927cf778 100644 --- a/web/src/views/UserMemory/types.ts +++ b/web/src/views/UserMemory/types.ts @@ -17,13 +17,10 @@ export interface Data { entity: number; } }, + memory_config: { + memory_config_id: string; + memory_config_name: string; + }, + type: string; name?: string; -} -export interface ConfigModalData { - llm: string; - embedding: string; - rerank: string; -} -export interface ConfigModalRef { - handleOpen: () => void; } \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/components/EmotionLine.tsx b/web/src/views/UserMemoryDetail/components/EmotionLine.tsx index c62fbfb9..68664d39 100644 --- a/web/src/views/UserMemoryDetail/components/EmotionLine.tsx +++ b/web/src/views/UserMemoryDetail/components/EmotionLine.tsx @@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next' import ReactEcharts from 'echarts-for-react'; import Empty from '@/components/Empty' import Loading from '@/components/Empty/Loading' -import type { Emotion } from './GraphDetail' -import { format } from 'echarts'; +import type { Emotion } from '../pages/GraphDetail' interface EmotionLineProps { chartData: Emotion[]; diff --git a/web/src/views/UserMemoryDetail/components/InteractionBar.tsx b/web/src/views/UserMemoryDetail/components/InteractionBar.tsx index 0db33b6f..60c977fd 100644 --- a/web/src/views/UserMemoryDetail/components/InteractionBar.tsx +++ b/web/src/views/UserMemoryDetail/components/InteractionBar.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import ReactEcharts from 'echarts-for-react' import Empty from '@/components/Empty' import Loading from '@/components/Empty/Loading' -import type { Interaction } from './GraphDetail' +import type { Interaction } from '../pages/GraphDetail' interface InteractionBarProps { chartData: Interaction[]; diff --git a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx index 07095fe4..d12c3e57 100644 --- a/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx +++ b/web/src/views/UserMemoryDetail/components/RelationshipNetwork.tsx @@ -1,19 +1,18 @@ import React, { type FC, useEffect, useState, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useParams } from 'react-router-dom' +import { useParams, useNavigate } from 'react-router-dom' import { Col, Row, Space, Button } from 'antd' import dayjs from 'dayjs' import RbCard from '@/components/RbCard/Card' import ReactEcharts from 'echarts-for-react' import detailEmpty from '@/assets/images/userMemory/detail_empty.png' -import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties, GraphDetailRef } from '../types' +import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties } from '../types' import { getMemorySearchEdges, } from '@/api/memory' import Empty from '@/components/Empty' import Tag from '@/components/Tag' -import GraphDetail from '../components/GraphDetail' const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048'] const RelationshipNetwork:FC = () => { @@ -26,7 +25,7 @@ const RelationshipNetwork:FC = () => { const [categories, setCategories] = useState<{ name: string }[]>([]) const [selectedNode, setSelectedNode] = useState(null) // const [fullScreen, setFullScreen] = useState(false) - const graphDetailRef = useRef(null) + const navigate = useNavigate() console.log('categories', categories) // 关系网络 @@ -133,15 +132,14 @@ const RelationshipNetwork:FC = () => { } }, [nodes]) - // const handleFullScreen = () => { - // setFullScreen(prev => !prev) - // } - - console.log('selectedNode', selectedNode) - const handleViewAll = () => { if (!selectedNode) return - graphDetailRef.current?.handleOpen(selectedNode) + const params = new URLSearchParams({ + nodeId: selectedNode.id, + nodeLabel: selectedNode.label, + nodeName: selectedNode.name || '' + }) + navigate(`/user-memory/detail/${id}/GRAPH?${params.toString()}`) } return ( @@ -336,8 +334,6 @@ const RelationshipNetwork:FC = () => { - - ) } diff --git a/web/src/views/UserMemoryDetail/components/GraphDetail.tsx b/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx similarity index 50% rename from web/src/views/UserMemoryDetail/components/GraphDetail.tsx rename to web/src/views/UserMemoryDetail/pages/GraphDetail.tsx index aed795f5..47efce76 100644 --- a/web/src/views/UserMemoryDetail/components/GraphDetail.tsx +++ b/web/src/views/UserMemoryDetail/pages/GraphDetail.tsx @@ -1,16 +1,17 @@ -import { useState, forwardRef, useImperativeHandle, useMemo } from 'react' +import { useState, forwardRef, useImperativeHandle, useMemo, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useSearchParams } from 'react-router-dom' import { Row, Col, Tabs, Space, Skeleton } from 'antd' import { getRelationshipEvolution, getTimelineMemories } from '@/api/memory' import type { Node, GraphDetailRef } from '../types' -import RbDrawer from '@/components/RbDrawer' import RbCard from '@/components/RbCard/Card' -import EmotionLine from './EmotionLine' +import EmotionLine from '../components/EmotionLine' import { formatDateTime } from '@/utils/format' import Tag from '@/components/Tag' -import InteractionBar from './InteractionBar' +import InteractionBar from '../components/InteractionBar' import Empty from '@/components/Empty' +import PageHeader from '../components/PageHeader' export interface Emotion { emotion_intensity: number; @@ -35,7 +36,7 @@ interface Timeline { const GraphDetail = forwardRef((_props, ref) => { const { t } = useTranslation() - const [open, setOpen] = useState(false); + const [searchParams] = useSearchParams() const [vo, setVo] = useState(null) const [loading, setLoading] = useState(false) const [emotionData, setEmotionData] = useState([]) @@ -43,14 +44,23 @@ const GraphDetail = forwardRef((_props, ref) => { const [activeTab, setActiveTab] = useState('timelines_memory') const [timelineLoading, setTimelineLoading] = useState(false) const [timelineMemories, setTimelineMemories] = useState({ timelines_memory: [], MemorySummary: [], Statement: [], ExtractedEntity: []}) + useEffect(() => { + const nodeId = searchParams.get('nodeId') + const nodeLabel = searchParams.get('nodeLabel') + const nodeName = searchParams.get('nodeName') + + if (nodeId && nodeLabel) { + const nodeFromUrl = { + id: nodeId, + label: nodeLabel, + name: nodeName || nodeLabel + } + handleOpen(nodeFromUrl as Node) + } + }, [searchParams]) - const handleCancel = () => { - setVo(null) - setOpen(false) - } const handleOpen = (vo: Node) => { setActiveTab('timelines_memory') - setOpen(true) setVo(vo) getRelationshipEvolutionData(vo) getTimelineMemoriesData(vo) @@ -85,56 +95,57 @@ const GraphDetail = forwardRef((_props, ref) => { }, [activeTab, timelineMemories]) return ( - -
{t('userMemory.relationshipEvolution')}
- - -
- - - - - - - + <> + +
+
{t('userMemory.relationshipEvolution')}
+ + +
+ + + + + + + -
{t('userMemory.timelineMemories')}
- - ({ - label: t(`userMemory.${key}`), - key - }))} - onChange={(key: string) => setActiveTab(key)} - /> - {timelineLoading - ? - : !activeContent || activeContent.length === 0 - ? - : - {activeContent.map((vo, index) => ( - -
{formatDateTime(vo.created_at)}
- {vo.type} -
- ))} -
- } +
{t('userMemory.timelineMemories')}
+ + ({ + label: t(`userMemory.${key}`), + key + }))} + onChange={(key: string) => setActiveTab(key)} + /> + {timelineLoading + ? + : !activeContent || activeContent.length === 0 + ? + : + {activeContent.map((vo, index) => ( + +
{formatDateTime(vo.created_at)}
+ {vo.type} +
+ ))} +
+ } - -
- + +
+ + ) }) export default GraphDetail \ No newline at end of file diff --git a/web/src/views/UserMemoryDetail/pages/index.tsx b/web/src/views/UserMemoryDetail/pages/index.tsx index 8f5ee146..f5b1a937 100644 --- a/web/src/views/UserMemoryDetail/pages/index.tsx +++ b/web/src/views/UserMemoryDetail/pages/index.tsx @@ -1,7 +1,7 @@ import { type FC, useEffect, useState, useMemo, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { Dropdown, Space, Button } from 'antd' +import { Dropdown, Button } from 'antd' import PageHeader from '../components/PageHeader' import StatementDetail from './StatementDetail' @@ -16,6 +16,7 @@ import { getEndUserProfile, } from '@/api/memory' import refreshIcon from '@/assets/images/refresh_hover.svg' +import GraphDetail from './GraphDetail' const Detail: FC = () => { const { t } = useTranslation() @@ -47,6 +48,10 @@ const Detail: FC = () => { forgetDetailRef.current?.handleRefresh() } + if (type === 'GRAPH') { + return + } + return (
{
{nodeLibrary.map((category, categoryIndex) => { const filteredNodes = category.nodes.filter(nodeType => - nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start' + nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'iteration' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start' ); if (filteredNodes.length === 0) return null; diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index dac91b68..40b4b8ec 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -33,7 +33,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { y: cycleStartBBox.y, data: { type: 'add-node', - label: '添加节点', + label: t('workflow.addNode'), icon: '+', parentId: node.id, cycle: data.id, @@ -61,7 +61,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { }, }, }, - zIndex: 3 + zIndex: 10 }); } } @@ -97,7 +97,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { y: centerY, data: { type: 'add-node', - label: '添加节点', + label: t('workflow.addNode'), icon: '+', parentId: node.id, cycle: data.id, @@ -128,7 +128,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { }, }, }, - zIndex: 3 + zIndex: 10 } graph.addEdge(edgeConfig) diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx index 9a644438..9d9225e8 100644 --- a/web/src/views/Workflow/components/PortClickHandler.tsx +++ b/web/src/views/Workflow/components/PortClickHandler.tsx @@ -151,11 +151,11 @@ const PortClickHandler: React.FC = ({ graph }) => { let filteredNodes; if (isChildOfLoop) { - // Use same filtering as AddNode for child nodes of loop + // Use same filtering as AddNode for child nodes of loop, but allow break filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type)); } else if (isChildOfIteration) { - // Filter out loop and iteration nodes for children of iteration nodes - filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'break', 'cycle-start', 'iteration'].includes(nodeType.type)); + // Filter out loop and iteration nodes for children of iteration nodes, but allow break + filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type)); } else { // Original filtering for non-loop child nodes filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'break', 'cycle-start'].includes(nodeType.type)); diff --git a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx index 97f28668..494e4342 100644 --- a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx +++ b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx @@ -60,7 +60,7 @@ const AssignmentList: FC = ({ > vo.nodeData.type === 'loop' || vo.value.includes('conv.'))} popupMatchSelectWidth={false} onChange={() => { form.setFieldValue([parentName, name, 'operation'], undefined); diff --git a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx index 69ed2030..6fa47421 100644 --- a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx @@ -1,17 +1,19 @@ import { type FC } from 'react'; import { useTranslation } from 'react-i18next'; -import { Input, Button, Form, Space } from 'antd'; -import { PlusOutlined, CopyOutlined, DeleteOutlined, ExpandOutlined } from '@ant-design/icons'; +import { Button, Form, Space } from 'antd'; +import { DeleteOutlined } from '@ant-design/icons'; import { Graph, Node } from '@antv/x6'; -import type { PortMetadata } from '@antv/x6/lib/model/port'; +import Editor from '../../Editor'; +import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' interface CategoryListProps { parentName: string; + options: Suggestion[]; selectedNode?: Node | null; graphRef?: React.MutableRefObject; } -const CategoryList: FC = ({ parentName, selectedNode, graphRef }) => { +const CategoryList: FC = ({ parentName, selectedNode, graphRef, options }) => { const { t } = useTranslation(); const form = Form.useFormInstance(); const formValues = Form.useWatch([parentName], form); @@ -167,9 +169,9 @@ const CategoryList: FC = ({ parentName, selectedNode, graphRe name={[name, 'class_name']} noStyle > -
diff --git a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx index 8fbebeda..d809fec5 100644 --- a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx +++ b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx @@ -1,6 +1,6 @@ import { type FC } from 'react' import { useTranslation } from 'react-i18next'; -import { Form, Button, Select, Row, Col, InputNumber, Radio, type SelectProps } from 'antd' +import { Form, Button, Select, Row, Col, InputNumber, Radio, Input, type SelectProps } from 'antd' import { DeleteOutlined } from '@ant-design/icons'; import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' @@ -114,7 +114,7 @@ const ConditionList: FC = ({
vo.value.includes('sys.') || vo.value.includes('conv.') || vo.nodeData.type === 'loop')} size="small" allowClear={false} popupMatchSelectWidth={false} @@ -186,7 +186,7 @@ const ConditionList: FC = ({ True False - : + : } diff --git a/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx b/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx index c05cce25..4d436af0 100644 --- a/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx @@ -1,6 +1,6 @@ import { type FC } from 'react' import { useTranslation } from 'react-i18next'; -import { Form, Button, Select, Row, Col, Input } from 'antd' +import { Form, Select, Row, Col, Input } from 'antd' import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import VariableSelect from '../VariableSelect' @@ -36,7 +36,6 @@ const CycleVarsList: FC = ({ value = [], options, parentName, - onChange, selectedNode, graphRef }) => { @@ -139,12 +138,17 @@ const CycleVarsList: FC = ({ {currentInputType === 'variable' ? ( { + const currentType = value?.[index]?.type; + if (!currentType) return true; + + return option.dataType === currentType + })} /> ) : ( diff --git a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx index 2b2db0f7..61cdd7b0 100644 --- a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx +++ b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx @@ -18,8 +18,22 @@ const GroupVariableList: FC = ({ isCanAdd = false }) => { const { t } = useTranslation(); + const form = Form.useFormInstance(); + const value = form.getFieldValue(name) || []; + + console.log('GroupVariableList', value) if (!isCanAdd) { + // Filter options based on first variable's dataType if value exists + let filteredOptions = options; + if (value.length > 0) { + const firstVariableValue = value[0]; + const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue); + if (firstVariable) { + filteredOptions = options.filter(opt => opt.dataType === firstVariable.dataType); + } + } + return (
@@ -38,7 +52,7 @@ const GroupVariableList: FC = ({ > @@ -77,7 +91,18 @@ const GroupVariableList: FC = ({ > { + const currentGroupValue = value[name]?.value || []; + if (currentGroupValue.length > 0) { + const firstVariableValue = currentGroupValue[0]; + const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue); + if (firstVariable) { + return options.filter(opt => opt.dataType === firstVariable.dataType); + } + } + return options; + })() + } mode="multiple" /> diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx index bbb3238d..5823c1d8 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx @@ -90,7 +90,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
- + vo.dataType === 'string' || vo.dataType === 'number')} variant="outlined" /> @@ -144,7 +144,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an vo.dataType === 'string' || vo.dataType === 'number')} filterBooleanType={true} /> @@ -154,7 +154,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an vo.dataType === 'string' || vo.dataType === 'number')} isArray={false} title="JSON" /> diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index b92475d7..5f0f1f0b 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -91,6 +91,7 @@ const VariableSelect: FC = ({ showSearch allowClear={allowClear} filterOption={(input, option) => { + if (input === '/') return true; if (option?.options) { return option.label?.toLowerCase().includes(input.toLowerCase()) || option.options.some((opt: any) => diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 765fd207..2903b2c9 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -22,6 +22,7 @@ import ConditionList from './ConditionList' import CycleVarsList from './CycleVarsList' import AssignmentList from './AssignmentList' import ToolConfig from './ToolConfig' +// import { calculateVariableList } from './utils/variableListCalculator' interface PropertiesProps { selectedNode?: Node | null; @@ -338,112 +339,35 @@ const Properties: FC = ({ const parentLoopNode = getParentLoopNode(selectedNode.id); console.log('childNodeIds', selectedNode, childNodeIds) - const allRelevantNodeIds = [...allPreviousNodeIds, ...childNodeIds]; + let allRelevantNodeIds = [...allPreviousNodeIds, ...childNodeIds]; - // Add parent loop/iteration node variables if current node is a child + // Add variables from nodes preceding the parent loop/iteration node if current node is a child if (parentLoopNode) { - const parentData = parentLoopNode.getData(); - const parentNodeId = parentLoopNode.getData().id; - - if (parentData.type === 'loop') { - const cycleVars = parentData.cycle_vars || []; - cycleVars.forEach((cycleVar: any) => { - const key = `${parentNodeId}_cycle_${cycleVar.name}`; - if (!addedKeys.has(key)) { - addedKeys.add(key); - variableList.push({ - key, - label: cycleVar.name, - type: 'variable', - dataType: cycleVar.type || 'String', - value: `${parentNodeId}.${cycleVar.name}`, - nodeData: parentData, - }); - } - }); - } else if (parentData.type === 'iteration') { - // Add item and index variables for iteration parent - const itemKey = `${parentNodeId}_item`; - const indexKey = `${parentNodeId}_index`; - - if (!addedKeys.has(itemKey)) { - addedKeys.add(itemKey); - variableList.push({ - key: itemKey, - label: 'item', - type: 'variable', - dataType: 'Object', - value: `${parentNodeId}.item`, - nodeData: parentData, - }); - } - - if (!addedKeys.has(indexKey)) { - addedKeys.add(indexKey); - variableList.push({ - key: indexKey, - label: 'index', - type: 'variable', - dataType: 'Number', - value: `${parentNodeId}.index`, - nodeData: parentData, - }); - } - } - - // Check if parent loop/iteration is connected to http-request via ERROR connection - if (parentData.type === 'loop' || parentData.type === 'iteration') { - const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id); - parentPreviousNodeIds.forEach(prevNodeId => { - const prevNode = nodes.find(n => n.id === prevNodeId); - if (!prevNode) return; - - const prevNodeData = prevNode.getData(); - if (prevNodeData.type === 'http-request') { - // Check if connected via ERROR connection point - const errorEdges = edges.filter(edge => { - return edge.getTargetCellId() === parentLoopNode.id && - edge.getSourceCellId() === prevNodeId && - edge.getSourcePortId() === 'ERROR' - }); - - if (errorEdges.length > 0) { - const errorMessageKey = `${prevNodeData.id}_error_message`; - const errorTypeKey = `${prevNodeData.id}_error_type`; - - if (!addedKeys.has(errorMessageKey)) { - addedKeys.add(errorMessageKey); - variableList.push({ - key: errorMessageKey, - label: 'error_message', - type: 'variable', - dataType: 'string', - value: `${prevNodeData.id}.error_message`, - nodeData: prevNodeData, - }); - } - - if (!addedKeys.has(errorTypeKey)) { - addedKeys.add(errorTypeKey); - variableList.push({ - key: errorTypeKey, - label: 'error_type', - type: 'variable', - dataType: 'string', - value: `${prevNodeData.id}.error_type`, - nodeData: prevNodeData, - }); - } - } - } - }); - } - - // Add variables from nodes preceding the parent loop/iteration node const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id); allRelevantNodeIds.push(...parentPreviousNodeIds); } + + + // Add conversation variables from global config + const conversationVariables = workflowConfig?.variables || []; + + conversationVariables.forEach((variable: any) => { + const key = `CONVERSATION_${variable.name}`; + if (!addedKeys.has(key)) { + addedKeys.add(key); + variableList.push({ + key, + label: variable.name, + type: 'variable', + dataType: variable.type, + value: `conv.${variable.name}`, + nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, + group: 'CONVERSATION' + }); + } + }); + allRelevantNodeIds.forEach(nodeId => { const node = nodes.find(n => n.id === nodeId); if (!node) return; @@ -496,7 +420,7 @@ const Properties: FC = ({ key: llmKey, label: 'output', type: 'variable', - dataType: 'String', + dataType: 'string', value: `${dataNodeId}.output`, nodeData: nodeData, }); @@ -565,6 +489,17 @@ const Properties: FC = ({ const groupVariables = nodeData.config.group_variables.defaultValue || []; groupVariables?.forEach((groupVar: any) => { if (!groupVar || !groupVar.key) return; + + // Determine dataType from first variable in the group + let groupDataType = 'string'; + if (groupVar.value && Array.isArray(groupVar.value) && groupVar.value.length > 0) { + const firstVariableValue = groupVar.value[0]; + const firstVariable = variableList.find(v => `{{${v.value}}}` === firstVariableValue); + if (firstVariable) { + groupDataType = firstVariable.dataType; + } + } + const groupVarKey = `${dataNodeId}_${groupVar.key}`; if (!addedKeys.has(groupVarKey)) { addedKeys.add(groupVarKey); @@ -572,14 +507,26 @@ const Properties: FC = ({ key: groupVarKey, label: groupVar.key, type: 'variable', - dataType: 'string', + dataType: groupDataType, value: `${dataNodeId}.${groupVar.key}`, nodeData: nodeData, }); } }); } else { - // If group=false, add output variable + // If group=false, add output variable with type from first group_variable + const groupVariables = nodeData.config.group_variables.defaultValue || []; + const firstVariable = groupVariables[0]; + let outputDataType: string = 'any'; + if (firstVariable) { + const filterVo = [...variableList].find(v => { + return `{{${v.value}}}` === firstVariable + }) + if (filterVo) { + outputDataType = filterVo?.dataType + } + } + const varAggregatorKey = `${dataNodeId}_output`; if (!addedKeys.has(varAggregatorKey)) { addedKeys.add(varAggregatorKey); @@ -587,7 +534,7 @@ const Properties: FC = ({ key: varAggregatorKey, label: 'output', type: 'variable', - dataType: 'string', + dataType: outputDataType, value: `${dataNodeId}.output`, nodeData: nodeData, }); @@ -684,21 +631,20 @@ const Properties: FC = ({ nodeData: nodeData, }); } - if (!addedKeys.has(outputKey)) { - addedKeys.add(outputKey); - variableList.push({ - key: outputKey, - label: 'output', - type: 'variable', - dataType: 'string', - value: `${dataNodeId}.output`, - nodeData: nodeData, - }); - } + // if (!addedKeys.has(outputKey)) { + // addedKeys.add(outputKey); + // variableList.push({ + // key: outputKey, + // label: 'output', + // type: 'variable', + // dataType: 'string', + // value: `${dataNodeId}.output`, + // nodeData: nodeData, + // }); + // } break case 'iteration': const iterationOutputKey = `${dataNodeId}_output`; - const iterationItemKey = `${dataNodeId}_item`; if (!addedKeys.has(iterationOutputKey)) { addedKeys.add(iterationOutputKey); // Get the data type from the output configuration, default to string @@ -715,22 +661,11 @@ const Properties: FC = ({ key: iterationOutputKey, label: 'output', type: 'variable', - dataType: outputDataType, + dataType: `array[${outputDataType}]`, value: `${dataNodeId}.output`, nodeData: nodeData, }); } - if (!addedKeys.has(iterationItemKey)) { - addedKeys.add(iterationItemKey); - variableList.push({ - key: iterationItemKey, - label: 'item', - type: 'variable', - dataType: 'string', - value: `${dataNodeId}.item`, - nodeData: nodeData, - }); - } break case 'loop': const cycleVars = nodeData.config.cycle_vars.defaultValue || []; @@ -760,47 +695,337 @@ const Properties: FC = ({ key: toolDataKey, label: 'data', type: 'variable', - dataType: 'object', + dataType: 'string', value: `${dataNodeId}.data`, nodeData: nodeData, }); } break + case 'memory-read': + const memoryReadAnswerKey = `${dataNodeId}_answer`; + const memoryReadIntermediateOutputs = `${dataNodeId}_intermediate_outputs`; + if (!addedKeys.has(memoryReadAnswerKey)) { + addedKeys.add(memoryReadAnswerKey); + variableList.push({ + key: memoryReadAnswerKey, + label: 'answer', + type: 'variable', + dataType: 'string', + value: `${dataNodeId}.answer`, + nodeData: nodeData, + }); + } + if (!addedKeys.has(memoryReadIntermediateOutputs)) { + addedKeys.add(memoryReadIntermediateOutputs); + variableList.push({ + key: memoryReadIntermediateOutputs, + label: 'intermediate_outputs', + type: 'variable', + dataType: 'array[object]', + value: `${dataNodeId}.intermediate_outputs`, + nodeData: nodeData, + }); + } + break } }); - // Add conversation variables from global config - const conversationVariables = workflowConfig?.variables || []; - - conversationVariables.forEach((variable: any) => { - const key = `CONVERSATION_${variable.name}`; - if (!addedKeys.has(key)) { - addedKeys.add(key); - variableList.push({ - key, - label: variable.name, - type: 'variable', - dataType: variable.type, - value: `conv.${variable.name}`, - nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' }, - group: 'CONVERSATION' + + // Add parent loop/iteration node variables if current node is a child + if (parentLoopNode) { + const parentData = parentLoopNode.getData(); + const parentNodeId = parentLoopNode.getData().id; + + if (parentData.type === 'loop') { + const cycleVars = parentData.cycle_vars || []; + cycleVars.forEach((cycleVar: any) => { + const key = `${parentNodeId}_cycle_${cycleVar.name}`; + if (!addedKeys.has(key)) { + addedKeys.add(key); + variableList.push({ + key, + label: cycleVar.name, + type: 'variable', + dataType: cycleVar.type || 'String', + value: `${parentNodeId}.${cycleVar.name}`, + nodeData: parentData, + }); + } + }); + } else if (parentData.type === 'iteration') { + // Add item and index variables for iteration parent only if input has value + if (parentData.config.input.defaultValue) { + const itemKey = `${parentNodeId}_item`; + const indexKey = `${parentNodeId}_index`; + + // Determine item dataType from input variable + let itemDataType = 'object'; + const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue); + console.log('itemDataType defaultValue', parentData.config.input.defaultValue, variableList, inputVariable) + if (inputVariable && inputVariable.dataType.startsWith('array[')) { + itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1'); + console.log('itemDataType', itemDataType) + } + + + if (!addedKeys.has(itemKey)) { + addedKeys.add(itemKey); + variableList.push({ + key: itemKey, + label: 'item', + type: 'variable', + dataType: itemDataType, + value: `${parentNodeId}.item`, + nodeData: parentData, + }); + } + + if (!addedKeys.has(indexKey)) { + addedKeys.add(indexKey); + variableList.push({ + key: indexKey, + label: 'index', + type: 'variable', + dataType: 'number', + value: `${parentNodeId}.index`, + nodeData: parentData, + }); + } + } + } + + // Check if parent loop/iteration is connected to http-request via ERROR connection + if (parentData.type === 'loop' || parentData.type === 'iteration') { + const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id); + parentPreviousNodeIds.forEach(prevNodeId => { + const prevNode = nodes.find(n => n.id === prevNodeId); + if (!prevNode) return; + + const prevNodeData = prevNode.getData(); + if (prevNodeData.type === 'http-request') { + // Check if connected via ERROR connection point + const errorEdges = edges.filter(edge => { + return edge.getTargetCellId() === parentLoopNode.id && + edge.getSourceCellId() === prevNodeId && + edge.getSourcePortId() === 'ERROR' + }); + + if (errorEdges.length > 0) { + const errorMessageKey = `${prevNodeData.id}_error_message`; + const errorTypeKey = `${prevNodeData.id}_error_type`; + + if (!addedKeys.has(errorMessageKey)) { + addedKeys.add(errorMessageKey); + variableList.push({ + key: errorMessageKey, + label: 'error_message', + type: 'variable', + dataType: 'string', + value: `${prevNodeData.id}.error_message`, + nodeData: prevNodeData, + }); + } + + if (!addedKeys.has(errorTypeKey)) { + addedKeys.add(errorTypeKey); + variableList.push({ + key: errorTypeKey, + label: 'error_type', + type: 'variable', + dataType: 'string', + value: `${prevNodeData.id}.error_type`, + nodeData: prevNodeData, + }); + } + } + } }); } - }); + } return variableList; }, [selectedNode, graphRef, workflowConfig?.variables]); // Filter out boolean type variables for loop and llm nodes - const getFilteredVariableList = (nodeType?: string) => { - if (nodeType === 'loop' || nodeType === 'llm') { - return variableList.filter(variable => variable.dataType !== 'boolean'); + const getFilteredVariableList = (nodeType?: string, key?: string) => { + // Check if current node is a child of iteration node + const parentIterationNode = selectedNode ? (() => { + const nodes = graphRef.current?.getNodes() || []; + const nodeData = selectedNode.getData(); + const cycle = nodeData?.cycle; + + if (cycle) { + const parentNode = nodes.find(n => n.getData().id === cycle); + if (parentNode) { + const parentData = parentNode.getData(); + if (parentData?.type === 'iteration') { + return parentNode; + } + } + } + return null; + })() : null; + + // Helper function to add parent iteration variables + const addParentIterationVars = (filteredList: any[]) => { + if (parentIterationNode) { + const parentData = parentIterationNode.getData(); + const parentNodeId = parentData.id; + + if (parentData.config?.input?.defaultValue) { + const itemKey = `${parentNodeId}_item`; + const indexKey = `${parentNodeId}_index`; + + const existingItemVar = filteredList.find(v => v.key === itemKey); + const existingIndexVar = filteredList.find(v => v.key === indexKey); + + if (!existingItemVar) { + // Determine item dataType from input variable + let itemDataType = 'object'; + const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue); + if (inputVariable && inputVariable.dataType.startsWith('array[')) { + itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1'); + } + + filteredList.push({ + key: itemKey, + label: 'item', + type: 'variable', + dataType: itemDataType, + value: `${parentNodeId}.item`, + nodeData: parentData, + }); + } + + if (!existingIndexVar) { + filteredList.push({ + key: indexKey, + label: 'index', + type: 'variable', + dataType: 'number', + value: `${parentNodeId}.index`, + nodeData: parentData, + }); + } + } + } + return filteredList; + }; + + if (nodeType === 'llm') { + // For LLM nodes that are children of iteration or loop nodes, include parent variables + const parentLoopNode = selectedNode ? (() => { + const nodes = graphRef.current?.getNodes() || []; + const nodeData = selectedNode.getData(); + const cycle = nodeData?.cycle; + + if (cycle) { + const parentNode = nodes.find(n => n.getData().id === cycle); + if (parentNode) { + const parentData = parentNode.getData(); + if (parentData?.type === 'loop' || parentData?.type === 'iteration') { + return parentNode; + } + } + } + return null; + })() : null; + + let filteredList = variableList.filter(variable => variable.dataType !== 'boolean'); + + // If this LLM node is a child of iteration/loop, ensure parent variables are included + if (parentLoopNode) { + const parentData = parentLoopNode.getData(); + const parentNodeId = parentData.id; + + // Ensure parent loop/iteration variables are included + if (parentData.type === 'loop') { + const cycleVars = parentData.cycle_vars || []; + cycleVars.forEach((cycleVar: any) => { + const key = `${parentNodeId}_cycle_${cycleVar.name}`; + const existingVar = filteredList.find(v => v.key === key); + if (!existingVar && cycleVar.name && cycleVar.type !== 'boolean') { + filteredList.push({ + key, + label: cycleVar.name, + type: 'variable', + dataType: cycleVar.type || 'String', + value: `${parentNodeId}.${cycleVar.name}`, + nodeData: parentData, + }); + } + }); + } else if (parentData.type === 'iteration') { + // Add item and index variables for iteration parent + if (parentData.config?.input?.defaultValue) { + const itemKey = `${parentNodeId}_item`; + const indexKey = `${parentNodeId}_index`; + + const existingItemVar = filteredList.find(v => v.key === itemKey); + const existingIndexVar = filteredList.find(v => v.key === indexKey); + + if (!existingItemVar) { + // Determine item dataType from input variable + let itemDataType = 'object'; + const inputVariable = variableList.find(v => `{{${v.value}}}` === parentData.config.input.defaultValue); + if (inputVariable && inputVariable.dataType.startsWith('array[')) { + itemDataType = inputVariable.dataType.replace(/^array\[(.+)\]$/, '$1'); + } + + filteredList.push({ + key: itemKey, + label: 'item', + type: 'variable', + dataType: itemDataType, + value: `${parentNodeId}.item`, + nodeData: parentData, + }); + } + + if (!existingIndexVar) { + filteredList.push({ + key: indexKey, + label: 'index', + type: 'variable', + dataType: 'Number', + value: `${parentNodeId}.index`, + nodeData: parentData, + }); + } + } + } + } + + return filteredList; } - return variableList; + if (nodeType === 'knowledge-retrieval' || nodeType === 'parameter-extractor' && key !== 'prompt' || nodeType === 'memory-read' || nodeType === 'memory-write' || nodeType === 'question-classifier') { + let filteredList = variableList.filter(variable => variable.dataType === 'string'); + return addParentIterationVars(filteredList); + } + if (nodeType === 'parameter-extractor' && key === 'prompt') { + let filteredList = variableList.filter(variable => variable.dataType === 'string' || variable.dataType === 'number'); + return addParentIterationVars(filteredList); + } + if (nodeType === 'iteration' && key === 'output') { + return variableList.filter(variable => variable.value.includes('sys.')); + } + if (nodeType === 'iteration') { + return variableList.filter(variable => variable.dataType.includes('array')); + } + if (nodeType === 'loop' && key === 'condition') { + let filteredList = variableList.filter(variable => variable.nodeData.type !== 'loop'); + return addParentIterationVars(filteredList); + } + + // For all other node types, add parent iteration variables if applicable + let baseList = variableList; + return addParentIterationVars(baseList); }; + // const defaultVariableList = calculateVariableList(selectedNode as Node, graphRef, workflowConfig ) + console.log('values', values) - console.log('variableList', variableList, selectedNode?.data) + // console.log('variableList', variableList, defaultVariableList) return (
@@ -901,11 +1126,10 @@ const Properties: FC = ({ }); } } - return ( variable.nodeData?.type !== 'knowledge-retrieval')} parentName={key} /> @@ -915,7 +1139,12 @@ const Properties: FC = ({ if (selectedNode?.data?.type === 'end' && key === 'output') { return ( - + variable.nodeData?.type !== 'knowledge-retrieval')} + /> ) } @@ -943,7 +1172,7 @@ const Properties: FC = ({ isArray={!!config.isArray} parentName={key} enableJinja2={config.enableJinja2 as boolean} - options={getFilteredVariableList(selectedNode?.data?.type)} + options={getFilteredVariableList(selectedNode?.data?.type, key)} /> ) @@ -964,7 +1193,7 @@ const Properties: FC = ({ @@ -976,7 +1205,7 @@ const Properties: FC = ({ @@ -989,7 +1218,7 @@ const Properties: FC = ({ - + ) @@ -999,7 +1228,7 @@ const Properties: FC = ({ ) @@ -1013,9 +1242,9 @@ const Properties: FC = ({ if (config.filterLoopIterationVars) { const loopIterationVars: Suggestion[] = []; - return [...getFilteredVariableList(selectedNode?.data?.type), ...loopIterationVars]; + return [...getFilteredVariableList(selectedNode?.data?.type, key), ...loopIterationVars]; } - return getFilteredVariableList(selectedNode?.data?.type); + return getFilteredVariableList(selectedNode?.data?.type, key); })() } /> @@ -1060,7 +1289,7 @@ const Properties: FC = ({ ? { - const baseVariableList = getFilteredVariableList(selectedNode?.data?.type); + const baseVariableList = getFilteredVariableList(selectedNode?.data?.type, key); // Apply filtering if specified in config if (config.filterNodeTypes || config.filterVariableNames) { return baseVariableList.filter(variable => { @@ -1068,7 +1297,7 @@ const Properties: FC = ({ (Array.isArray(config.filterNodeTypes) && config.filterNodeTypes.includes(variable.nodeData?.type)); const variableNameMatch = !config.filterVariableNames || (Array.isArray(config.filterVariableNames) && config.filterVariableNames.includes(variable.label)); - return nodeTypeMatch && variableNameMatch; + return nodeTypeMatch || variableNameMatch; }); } // Filter child nodes for iteration output @@ -1085,7 +1314,7 @@ const Properties: FC = ({ }); return baseVariableList.filter(variable => - childNodes.some(node => node.id === variable.nodeData?.id) + childNodes.some(node => node.id === variable.nodeData?.id) || selectedNode?.data?.type === 'iteration' && key === 'output' && variable.value.includes('sys.') ); } return baseVariableList; @@ -1095,7 +1324,12 @@ const Properties: FC = ({ : config.type === 'switch' ? { form.setFieldValue('group_variables', []) } : undefined} /> : config.type === 'categoryList' - ? + ? : config.type === 'conditionList' ? = ({ value: `${selectedNode.getData().id}.${cycleVar.name}`, nodeData: selectedNode.getData(), })); - return [...variableList.filter(variable => { - // Keep conversation variables - if (variable.group === 'CONVERSATION') return true; - // Keep sys variables from start nodes - if (variable.nodeData?.type === 'start' && variable.value?.startsWith('sys.')) return true; - // Keep variables from non-start nodes - if (variable.nodeData?.type !== 'start' && variable.nodeData?.type !== 'http-request' && variable.dataType !== 'boolean') return true; - // Filter out custom variables from start nodes - return false; - }), ...cycleVarSuggestions]; - })() - } + + return [...getFilteredVariableList(selectedNode?.data?.type, key), ...cycleVarSuggestions]; + })()} selectedNode={selectedNode} graphRef={graphRef} addBtnText={t('workflow.config.addCase')} diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index ca44f2df..323f5649 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -270,7 +270,7 @@ export const nodeLibrary: NodeLibrary[] = [ config: { input: { type: 'variableList', - filterNodeTypes: ['knowledge-retrieval'], + filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop'], filterVariableNames: ['message'] }, parallel: { @@ -334,8 +334,7 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - { - type: "assigner", icon: assignerIcon, + { type: "assigner", icon: assignerIcon, config: { assignments: { type: 'assignmentList', @@ -656,4 +655,114 @@ export const graphNodeLibrary: Record = { items: [{ group: 'left' }], }, } +} + + +export interface OutputVariable { + default?: Array<{ + name: string; + type: string; + }>; + define?: string[]; + sys?: Array<{ + name: string; + type: string; + }>; + error?: Array<{ + name: string; + type: string; + }>; +} +export const outputVariable: { [key: string]: OutputVariable } = { + start: { + sys: [ + { name: "message", type: "string" }, + { name: "conversation_id", type: "string" }, + { name: "execution_id", type: "string", }, + { name: "workspace_id", type: "string" }, + { name: "user_id", type: "string" }, + ], + define: ['variables'] + }, + end: { + }, + llm: { + default: [ + { name: "output", type: "string" }, + ] + }, + 'knowledge-retrieval': { + default: [ + { name: "output", type: "array[object]" }, + ] + }, + 'parameter-extractor': { + default: [ + { name: "__is_success", type: "number" }, + { name: "__reason", type: "string" }, + ], + define: ['params'] + }, + 'memory-read': { + default: [ + { name: "answer", type: "string" }, + { name: "intermediate_outputs", type: "array[object]" }, + ], + }, + 'memory-write': { + + }, + 'if-else': { + + }, + 'question-classifier': { + default: [ + { name: "class_name", type: "string" }, + // { name: "output", type: "string" }, + ], + }, + 'iteration': { + default: [ + // { name: "item", type: "string" }, // 仅内部使用 + { name: "output", type: "array[string]" }, + ], + }, + 'loop': { + define: ['cycle_vars'] + }, + 'cycle-start': { + + }, + 'break': { + + }, + 'var-aggregator': { + // default: [ + // { name: "output", type: "string" }, + // ], + define: ['group_variables'] + }, + 'assigner': { + + }, + 'http-request': { + default: [ + { name: "body", type: "string" }, + { name: "status_code", type: "number" }, + ], + error: [ + { name: "error_message", type: "string" }, + { name: "error_type", type: "string" }, + ] + }, + 'tool': { + default: [ + { name: "data", type: "string" }, + ], + }, + 'jinja-render': { + default: [ + { name: "output", type: "string" }, + ], + }, } \ No newline at end of file