diff --git a/backend/app/core/redis_client.py b/backend/app/core/redis_client.py new file mode 100644 index 0000000..3ba8823 --- /dev/null +++ b/backend/app/core/redis_client.py @@ -0,0 +1,69 @@ +""" +Redis客户端 +""" +import redis +from app.core.config import settings +import logging + +logger = logging.getLogger(__name__) + +_redis_client = None + +def get_redis_client(): + """ + 获取Redis客户端(单例模式) + + Returns: + redis.Redis: Redis客户端实例,如果连接失败则返回None + """ + global _redis_client + + if _redis_client is not None: + try: + # 测试连接 + _redis_client.ping() + return _redis_client + except: + # 连接已断开,重新创建 + _redis_client = None + + try: + redis_url = getattr(settings, 'REDIS_URL', None) + if not redis_url: + logger.warning("REDIS_URL未配置,无法使用Redis缓存") + return None + + # 解析Redis URL: redis://host:port/db + if redis_url.startswith('redis://'): + redis_url = redis_url.replace('redis://', '') + + # 分离host:port和db + parts = redis_url.split('/') + host_port = parts[0] + db = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0 + + # 分离host和port + if ':' in host_port: + host, port = host_port.split(':') + port = int(port) + else: + host = host_port + port = 6379 + + _redis_client = redis.Redis( + host=host, + port=port, + db=db, + decode_responses=True, # 自动解码为字符串 + socket_connect_timeout=2, + socket_timeout=2 + ) + + # 测试连接 + _redis_client.ping() + logger.info(f"Redis连接成功: {host}:{port}/{db}") + return _redis_client + except Exception as e: + logger.warning(f"Redis连接失败: {str(e)},将使用内存缓存") + _redis_client = None + return None diff --git a/backend/app/services/workflow_engine.py b/backend/app/services/workflow_engine.py index a0dd351..71156cd 100644 --- a/backend/app/services/workflow_engine.py +++ b/backend/app/services/workflow_engine.py @@ -7,12 +7,14 @@ from collections import defaultdict, deque import json import logging import re +import time from app.services.llm_service import llm_service from app.services.condition_parser import condition_parser from app.services.data_transformer import data_transformer from app.core.exceptions import WorkflowExecutionError from app.core.database import SessionLocal from app.models.agent import Agent +from app.core.config import settings logger = logging.getLogger(__name__) @@ -121,6 +123,14 @@ class WorkflowEngine: # 如果有sourceHandle,使用它作为key if 'sourceHandle' in edge and edge['sourceHandle']: input_data[edge['sourceHandle']] = source_output + # 重要:即使有sourceHandle,也要保留记忆相关字段(conversation_history、user_profile、context) + # 这些字段应该始终传递到下游节点 + if isinstance(source_output, dict): + memory_fields = ['conversation_history', 'user_profile', 'context', 'memory'] + for field in memory_fields: + if field in source_output: + input_data[field] = source_output[field] + logger.info(f"[rjb] 保留记忆字段 {field} 到节点 {node_id} 的输入") else: # 否则合并所有输入 if isinstance(source_output, dict): @@ -141,6 +151,35 @@ class WorkflowEngine: input_data['input'] = source_output logger.info(f"[rjb] source_output不是字典,包装到input字段: input_data={input_data}") + # 重要:对于LLM节点和cache节点,如果输入中没有memory字段,尝试从所有已执行的节点中查找并合并记忆字段 + # 这样可以确保即使上游节点没有传递记忆信息,这些节点也能访问到记忆 + node_type = None + node = self.nodes.get(node_id) + if node: + node_type = node.get('type') + + # 对于LLM节点和cache节点(特别是cache-update),需要memory字段 + if node_type in ['llm', 'cache'] and 'memory' not in input_data: + # 从所有已执行的节点中查找memory字段 + for executed_node_id, node_output in self.node_outputs.items(): + if isinstance(node_output, dict): + # 检查是否有memory字段 + if 'memory' in node_output: + input_data['memory'] = node_output['memory'] + logger.info(f"[rjb] 为{node_type}节点 {node_id} 从节点 {executed_node_id} 获取memory字段") + break + # 或者检查是否有conversation_history等记忆字段 + elif 'conversation_history' in node_output: + # 构建memory对象 + memory = {} + for field in ['conversation_history', 'user_profile', 'context']: + if field in node_output: + memory[field] = node_output[field] + if memory: + input_data['memory'] = memory + logger.info(f"[rjb] 为{node_type}节点 {node_id} 从节点 {executed_node_id} 构建memory对象: {list(memory.keys())}") + break + # 如果input_data中没有query字段,尝试从所有已执行的节点中查找(特别是start节点) if 'query' not in input_data: # 优先查找start节点 @@ -387,7 +426,6 @@ class WorkflowEngine: node_type = node.get('type', 'unknown') node_id = node.get('id') - import time start_time = time.time() # 记录节点开始执行 @@ -437,16 +475,91 @@ class WorkflowEngine: # 检查是否有任何占位符 has_any_placeholder = bool(re.search(r'\{\{?\w+\}?\}', prompt)) - # 首先处理 {{variable}} 格式(模板节点常用) - double_brace_vars = re.findall(r'\{\{(\w+)\}\}', prompt) - for var_name in double_brace_vars: - if var_name in input_data: - # 替换 {{variable}} 为实际值 - value = input_data[var_name] - replacement = json_module.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value) - formatted_prompt = formatted_prompt.replace(f'{{{{{var_name}}}}}', replacement) + # 首先处理 {{variable}} 和 {{variable.path}} 格式(模板节点常用) + # 支持嵌套路径,如 {{memory.conversation_history}} + double_brace_vars = re.findall(r'\{\{([^}]+)\}\}', prompt) + for var_path in double_brace_vars: + # 尝试从input_data中获取值(支持嵌套路径) + value = self._get_nested_value(input_data, var_path) + + # 如果变量未找到,尝试常见的别名映射 + if value is None: + # user_input 可以映射到 query、input、USER_INPUT 等字段 + if var_path == 'user_input': + for alias in ['query', 'input', 'USER_INPUT', 'user_input', 'text', 'message', 'content']: + value = input_data.get(alias) + if value is not None: + # 如果值是字典,尝试从中提取字符串值 + if isinstance(value, dict): + for sub_key in ['query', 'input', 'text', 'message', 'content']: + if sub_key in value: + value = value[sub_key] + break + break + # output 可以映射到 right 字段(LLM节点的输出通常存储在right字段中) + elif var_path == 'output': + # 尝试从right字段中提取 + right_value = input_data.get('right') + logger.info(f"[rjb] LLM节点查找output变量: right_value类型={type(right_value)}, right_value={str(right_value)[:100] if right_value else None}") + if right_value is not None: + # 如果right是字符串,直接使用 + if isinstance(right_value, str): + value = right_value + logger.info(f"[rjb] LLM节点从right字段(字符串)提取output: {value[:100]}") + # 如果right是字典,尝试递归查找字符串值 + elif isinstance(right_value, dict): + # 尝试从right.right.right...中提取(处理嵌套的right字段) + current = right_value + depth = 0 + while isinstance(current, dict) and depth < 10: + if 'right' in current: + current = current['right'] + depth += 1 + if isinstance(current, str): + value = current + logger.info(f"[rjb] LLM节点从right字段(嵌套{depth}层)提取output: {value[:100]}") + break + else: + # 如果没有right字段,尝试其他可能的字段 + for key in ['content', 'text', 'message', 'output']: + if key in current and isinstance(current[key], str): + value = current[key] + logger.info(f"[rjb] LLM节点从right字段中找到{key}字段: {value[:100]}") + break + if value is not None: + break + break + if value is None: + logger.warning(f"[rjb] LLM节点无法从right字段中提取output,right结构: {str(right_value)[:200]}") + + if value is not None: + # 替换 {{variable}} 或 {{variable.path}} 为实际值 + # 特殊处理:如果是memory.conversation_history,格式化为易读的对话格式 + if var_path == 'memory.conversation_history' and isinstance(value, list): + # 将对话历史格式化为易读的文本格式 + formatted_history = [] + for msg in value: + role = msg.get('role', 'unknown') + content = msg.get('content', '') + if role == 'user': + formatted_history.append(f"用户:{content}") + elif role == 'assistant': + formatted_history.append(f"助手:{content}") + else: + formatted_history.append(f"{role}:{content}") + replacement = '\n'.join(formatted_history) if formatted_history else '(暂无对话历史)' + else: + # 其他情况使用JSON格式 + replacement = json_module.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value) + formatted_prompt = formatted_prompt.replace(f'{{{{{var_path}}}}}', replacement) + # 对于conversation_history,显示完整内容以便调试 + if var_path == 'memory.conversation_history': + logger.info(f"[rjb] LLM节点替换变量: {var_path} = {replacement[:500] if len(replacement) > 500 else replacement}") + else: + logger.info(f"[rjb] LLM节点替换变量: {var_path} = {str(replacement)[:200]}") else: has_unfilled_variables = True + logger.warning(f"[rjb] LLM节点变量未找到: {var_path}, input_data keys: {list(input_data.keys()) if isinstance(input_data, dict) else 'not dict'}") # 然后处理 {key} 格式 for key, value in input_data.items(): @@ -735,9 +848,50 @@ class WorkflowEngine: if mode == 'merge': # 合并所有上游节点的输出(使用展开后的数据) result = expanded_input.copy() - # 添加mapping的结果 + + # 重要:如果输入数据中包含conversation_history、user_profile、context等记忆字段,先构建memory对象 + memory_fields = ['conversation_history', 'user_profile', 'context'] + memory_data = {} + for field in memory_fields: + if field in expanded_input: + memory_data[field] = expanded_input[field] + + # 如果构建了memory对象,添加到result中 + if memory_data: + result['memory'] = memory_data + logger.info(f"[rjb] Transform节点 {node_id} 构建memory对象: {list(memory_data.keys())}") + + # 添加mapping的结果(mapping可能会覆盖memory字段) for key, value in processed_mapping.items(): - result[key] = value + # 如果mapping中的value是None或空字符串,且key是memory,尝试从expanded_input构建 + if key == 'memory' and (value is None or value == '' or value == '{{output}}'): + if memory_data: + result[key] = memory_data + logger.info(f"[rjb] Transform节点 {node_id} mapping中的memory为空,使用构建的memory对象") + elif 'memory' in expanded_input: + result[key] = expanded_input['memory'] + else: + result[key] = value + + # 确保记忆字段被保留(即使mapping覆盖了它们) + for field in memory_fields: + if field in expanded_input and field not in result: + result[field] = expanded_input[field] + # 如果memory字段是dict,也要检查其中的字段 + if 'memory' in expanded_input and isinstance(expanded_input['memory'], dict): + if 'memory' not in result: + result['memory'] = expanded_input['memory'].copy() + else: + # 合并memory字段 + if isinstance(result['memory'], dict): + result['memory'].update(expanded_input['memory']) + + logger.info(f"[rjb] Transform节点 {node_id} merge模式,结果keys: {list(result.keys())}") + if 'memory' in result and isinstance(result['memory'], dict): + if 'conversation_history' in result['memory']: + logger.info(f"[rjb] memory.conversation_history: {len(result['memory']['conversation_history'])} 条") + elif 'conversation_history' in result: + logger.info(f"[rjb] conversation_history: {len(result['conversation_history'])} 条") else: # 使用处理后的mapping进行转换(使用展开后的数据) result = data_transformer.transform_data( @@ -1663,7 +1817,6 @@ class WorkflowEngine: raise ValueError("Kafka节点需要配置topic") # 创建Kafka生产者(注意:kafka-python是同步的,需要在线程池中运行) - import asyncio from concurrent.futures import ThreadPoolExecutor def send_kafka_message(): @@ -1709,6 +1862,1931 @@ class WorkflowEngine: 'error': f'消息队列发送失败: {str(e)}' } + elif node_type == 'switch': + # Switch节点:多分支路由 + logger.info(f"[rjb] 执行Switch节点: node_id={node_id}, node_type={node_type}, input_data keys={list(input_data.keys()) if isinstance(input_data, dict) else 'not_dict'}") + node_data = node.get('data', {}) + field = node_data.get('field', '') + cases = node_data.get('cases', {}) + default_case = node_data.get('default', 'default') + logger.info(f"[rjb] Switch节点配置: field={field}, cases={cases}, default={default_case}") + + # 处理输入数据:尝试解析JSON字符串(递归处理所有字段) + def parse_json_recursively(data, merge_parsed=True): + """ + 递归解析JSON字符串 + + Args: + data: 要处理的数据 + merge_parsed: 是否将解析后的字典内容合并到父级(用于方便字段提取) + """ + import json + if isinstance(data, str): + # 如果是字符串,尝试解析为JSON + try: + parsed = json.loads(data) + # 如果解析成功,递归处理解析后的数据 + if isinstance(parsed, (dict, list)): + return parse_json_recursively(parsed, merge_parsed) + return parsed + except: + # 不是JSON,返回原字符串 + return data + elif isinstance(data, dict): + # 如果是字典,递归处理每个值 + result = {} + for key, value in data.items(): + parsed_value = parse_json_recursively(value, merge_parsed=False) + result[key] = parsed_value + # 如果merge_parsed为True且解析后的值是字典,将其内容合并到当前层级(方便字段提取) + if merge_parsed and isinstance(parsed_value, dict): + # 合并时避免覆盖已有的键 + for k, v in parsed_value.items(): + if k not in result: + result[k] = v + return result + elif isinstance(data, list): + # 如果是列表,递归处理每个元素 + return [parse_json_recursively(item, merge_parsed) for item in data] + else: + # 其他类型,直接返回 + return data + + processed_input = parse_json_recursively(input_data, merge_parsed=True) + + # 从处理后的输入数据中获取字段值 + field_value = self._get_nested_value(processed_input, field) + field_value_str = str(field_value) if field_value is not None else '' + + # 查找匹配的case + matched_case = default_case + if field_value_str in cases: + matched_case = cases[field_value_str] + elif field_value in cases: + matched_case = cases[field_value] + + # 记录详细的匹配信息(同时输出到控制台和数据库) + match_info = { + 'field': field, + 'field_value': field_value, + 'field_value_str': field_value_str, + 'matched_case': matched_case, + 'processed_input_keys': list(processed_input.keys()) if isinstance(processed_input, dict) else 'not_dict', + 'cases_keys': list(cases.keys()) + } + logger.info(f"[rjb] Switch节点匹配: node_id={node_id}, {match_info}") + if self.logger: + self.logger.info( + f"Switch节点匹配: field={field}, field_value={field_value}, matched_case={matched_case}", + node_id=node_id, + node_type=node_type, + data=match_info + ) + + exec_result = { + 'output': processed_input, + 'status': 'success', + 'branch': matched_case, + 'matched_value': field_value + } + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, {'branch': matched_case, 'value': field_value}, duration) + return exec_result + + elif node_type == 'merge': + # Merge节点:合并多个分支的数据流 + node_data = node.get('data', {}) + mode = node_data.get('mode', 'merge_all') # merge_all, merge_first, merge_last + strategy = node_data.get('strategy', 'array') # array, object, concat + + # 获取所有上游节点的输出(通过input_data中的特殊字段) + # 如果input_data包含多个分支的数据,合并它们 + merged_data = {} + + if strategy == 'array': + # 数组策略:将所有输入数据作为数组元素 + if isinstance(input_data, list): + merged_data = input_data + elif isinstance(input_data, dict): + # 如果包含多个分支数据,提取为数组 + branch_data = [] + for key, value in input_data.items(): + if not key.startswith('_'): + branch_data.append(value) + merged_data = branch_data if branch_data else [input_data] + else: + merged_data = [input_data] + + elif strategy == 'object': + # 对象策略:合并所有字段 + if isinstance(input_data, dict): + merged_data = input_data.copy() + else: + merged_data = {'data': input_data} + + elif strategy == 'concat': + # 连接策略:将所有数据连接为字符串 + if isinstance(input_data, list): + merged_data = '\n'.join(str(item) for item in input_data) + elif isinstance(input_data, dict): + merged_data = '\n'.join(f"{k}: {v}" for k, v in input_data.items() if not k.startswith('_')) + else: + merged_data = str(input_data) + + exec_result = {'output': merged_data, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, merged_data, duration) + return exec_result + + elif node_type == 'wait': + # Wait节点:等待条件满足 + node_data = node.get('data', {}) + wait_type = node_data.get('wait_type', 'condition') # condition, time, event + condition = node_data.get('condition', '') + timeout = node_data.get('timeout', 300) # 默认5分钟 + poll_interval = node_data.get('poll_interval', 5) # 默认5秒 + + if wait_type == 'condition': + # 等待条件满足 + start_wait = time.time() + while time.time() - start_wait < timeout: + try: + result = condition_parser.evaluate_condition(condition, input_data) + if result: + exec_result = {'output': input_data, 'status': 'success', 'waited': time.time() - start_wait} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, exec_result, duration) + return exec_result + except Exception as e: + logger.warning(f"Wait节点条件评估失败: {str(e)}") + + await asyncio.sleep(poll_interval) + + # 超时 + exec_result = { + 'output': input_data, + 'status': 'failed', + 'error': f'等待条件超时: {timeout}秒' + } + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, Exception("等待超时"), duration) + return exec_result + + elif wait_type == 'time': + # 等待固定时间 + wait_seconds = node_data.get('wait_seconds', 0) + await asyncio.sleep(wait_seconds) + exec_result = {'output': input_data, 'status': 'success', 'waited': wait_seconds} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, exec_result, duration) + return exec_result + + else: + # 其他类型暂不支持 + exec_result = {'output': input_data, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, exec_result, duration) + return exec_result + + elif node_type == 'json': + # JSON处理节点 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'parse') # parse, stringify, extract, validate + path = node_data.get('path', '') + schema = node_data.get('schema', {}) + + try: + if operation == 'parse': + # 解析JSON字符串 + if isinstance(input_data, str): + result = json_module.loads(input_data) + elif isinstance(input_data, dict) and 'data' in input_data: + # 如果包含data字段,尝试解析 + if isinstance(input_data['data'], str): + result = json_module.loads(input_data['data']) + else: + result = input_data['data'] + else: + result = input_data + + elif operation == 'stringify': + # 转换为JSON字符串 + result = json_module.dumps(input_data, ensure_ascii=False, indent=2) + + elif operation == 'extract': + # 使用JSONPath提取数据(简化实现) + if path and isinstance(input_data, dict): + # 简单的路径提取,支持 $.key 格式 + path = path.replace('$.', '').replace('$', '') + keys = path.split('.') + result = input_data + for key in keys: + if key.endswith('[*]'): + # 数组提取 + array_key = key[:-3] + if isinstance(result, dict) and array_key in result: + result = result[array_key] + elif isinstance(result, dict) and key in result: + result = result[key] + else: + result = None + break + else: + result = input_data + + elif operation == 'validate': + # JSON Schema验证(简化实现) + # 这里只做基本验证,完整实现需要使用jsonschema库 + if schema: + # 简单类型检查 + if 'type' in schema: + expected_type = schema['type'] + actual_type = type(input_data).__name__ + if expected_type == 'object' and actual_type != 'dict': + raise ValueError(f"期望类型 {expected_type},实际类型 {actual_type}") + elif expected_type == 'array' and actual_type != 'list': + raise ValueError(f"期望类型 {expected_type},实际类型 {actual_type}") + result = input_data + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'JSON处理失败: {str(e)}' + } + + elif node_type == 'text': + # 文本处理节点 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'split') # split, join, extract, replace, format + delimiter = node_data.get('delimiter', '\n') + regex = node_data.get('regex', '') + template = node_data.get('template', '') + + try: + # 获取输入文本 + input_text = input_data + if isinstance(input_data, dict): + # 尝试从字典中提取文本 + for key in ['text', 'content', 'message', 'input', 'output']: + if key in input_data and isinstance(input_data[key], str): + input_text = input_data[key] + break + if isinstance(input_text, dict): + input_text = str(input_text) + elif not isinstance(input_text, str): + input_text = str(input_text) + + if operation == 'split': + # 拆分文本 + result = input_text.split(delimiter) + + elif operation == 'join': + # 合并文本(需要输入是数组) + if isinstance(input_data, list): + result = delimiter.join(str(item) for item in input_data) + else: + result = input_text + + elif operation == 'extract': + # 使用正则表达式提取 + if regex: + import re + matches = re.findall(regex, input_text) + result = matches if len(matches) > 1 else (matches[0] if matches else '') + else: + result = input_text + + elif operation == 'replace': + # 替换文本 + old_text = node_data.get('old_text', '') + new_text = node_data.get('new_text', '') + if regex: + import re + result = re.sub(regex, new_text, input_text) + else: + result = input_text.replace(old_text, new_text) + + elif operation == 'format': + # 格式化文本(使用模板) + if template: + # 支持 {key} 格式的变量替换 + result = template + if isinstance(input_data, dict): + for key, value in input_data.items(): + result = result.replace(f'{{{key}}}', str(value)) + else: + result = result.replace('{value}', str(input_data)) + else: + result = input_text + + else: + result = input_text + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'文本处理失败: {str(e)}' + } + + elif node_type == 'cache': + # 缓存节点 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'get') # get, set, delete, clear + key = node_data.get('key', '') + ttl = node_data.get('ttl', 3600) # 默认1小时 + # 默认优先使用redis(如果配置了),否则memory + backend = node_data.get('backend') or ('redis' if getattr(settings, 'REDIS_URL', None) else 'memory') # redis, memory + default_value = node_data.get('default_value', '{}') + value_template = node_data.get('value', '') + + # 使用Redis作为持久化缓存(如果可用),否则使用内存缓存 + # 注意:内存缓存在单次执行会话内有效,跨执行不会保留 + use_redis = False + redis_client = None + # 默认尝试使用Redis(如果配置了),除非明确指定使用memory + if backend != 'memory': + try: + from app.core.redis_client import get_redis_client + redis_client = get_redis_client() + if redis_client: + use_redis = True + logger.info(f"[rjb] Cache节点 {node_id} 使用Redis缓存") + except Exception as e: + logger.warning(f"Redis不可用: {str(e)},使用内存缓存") + + # 内存缓存(单次执行会话内有效) + if not hasattr(self, '_cache_store'): + self._cache_store = {} + self._cache_timestamps = {} + + try: + # 替换key中的变量 + if isinstance(input_data, dict): + # 首先处理 {{variable}} 格式 + import re + double_brace_vars = re.findall(r'\{\{(\w+)\}\}', key) + for var_name in double_brace_vars: + if var_name in input_data: + key = key.replace(f'{{{{{var_name}}}}}', str(input_data[var_name])) + else: + # 如果变量不存在,使用默认值 + if var_name == 'user_id': + # 尝试从输入数据中提取user_id,如果没有则使用"default" + user_id = input_data.get('user_id') or input_data.get('USER_ID') or 'default' + key = key.replace(f'{{{{{var_name}}}}}', str(user_id)) + else: + key = key.replace(f'{{{{{var_name}}}}}', 'default') + + # 然后处理 {key} 格式 + for k, v in input_data.items(): + key = key.replace(f'{{{k}}}', str(v)) + + # 如果key中还有未替换的变量,使用默认值 + if '{' in key: + key = key.replace('{user_id}', 'default').replace('{{user_id}}', 'default') + # 清理其他未替换的变量 + key = re.sub(r'\{[^}]+\}', 'default', key) + + logger.info(f"[rjb] Cache节点 {node_id} 处理后的key: {key}") + + if operation == 'get': + # 获取缓存 + result = None + cache_hit = False + + if use_redis and redis_client: + # 从Redis获取 + try: + cached_data = redis_client.get(key) + if cached_data: + result = json_module.loads(cached_data) + cache_hit = True + except Exception as e: + logger.warning(f"从Redis获取缓存失败: {str(e)}") + + if result is None: + # 从内存缓存获取 + if key in self._cache_store: + # 检查是否过期 + if key in self._cache_timestamps: + if time.time() - self._cache_timestamps[key] > ttl: + # 过期,删除 + del self._cache_store[key] + del self._cache_timestamps[key] + else: + result = self._cache_store[key] + cache_hit = True + else: + result = self._cache_store[key] + cache_hit = True + + # 如果缓存未命中,使用default_value + if result is None: + try: + if isinstance(default_value, str): + result = json_module.loads(default_value) if default_value else {} + else: + result = default_value + except: + result = {} + cache_hit = False + logger.info(f"[rjb] Cache节点 {node_id} cache miss,使用default_value: {result}") + + # 合并输入数据和缓存结果 + output = input_data.copy() if isinstance(input_data, dict) else {} + if isinstance(result, dict): + output.update(result) + else: + output['memory'] = result + + exec_result = {'output': output, 'status': 'success', 'cache_hit': cache_hit, 'memory': result} + + elif operation == 'set': + # 设置缓存 + # 处理value模板 + if value_template: + # 处理模板语法 {{variable}} + import re + value_str = value_template + + # 替换 {{variable}} 格式的变量 + # 注意:只替换 memory.* 路径的变量,user_input、output、timestamp 等变量在Python表达式执行阶段处理 + template_vars = re.findall(r'\{\{(\w+(?:\.\w+)*)\}\}', value_str) + for var_path in template_vars: + # 跳过 user_input、output、timestamp 等变量,这些在Python表达式执行阶段处理 + if var_path in ['user_input', 'output', 'timestamp']: + continue + + # 支持嵌套路径,如 memory.conversation_history + var_parts = var_path.split('.') + var_value = input_data + try: + for part in var_parts: + if isinstance(var_value, dict) and part in var_value: + var_value = var_value[part] + else: + var_value = None + break + + if var_value is not None: + # 替换模板变量 + replacement = json_module.dumps(var_value, ensure_ascii=False) if isinstance(var_value, (dict, list)) else str(var_value) + value_str = value_str.replace(f'{{{{{var_path}}}}}', replacement) + else: + # 变量不存在,根据路径使用合适的默认值 + if 'conversation_history' in var_path: + value_str = value_str.replace(f'{{{{{var_path}}}}}', '[]') + elif 'user_profile' in var_path or 'context' in var_path: + value_str = value_str.replace(f'{{{{{var_path}}}}}', '{}') + else: + # 对于其他变量,保留原样,让Python表达式执行阶段处理 + pass + except Exception as e: + logger.warning(f"处理模板变量 {var_path} 失败: {str(e)}") + + # 替换 {key} 格式的变量(但不要替换 {{variable}} 格式的) + for k, v in input_data.items(): + placeholder = f'{{{k}}}' + # 确保不是 {{variable}} 格式 + if placeholder in value_str and f'{{{{{k}}}}}' not in value_str: + replacement = json_module.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else str(v) + value_str = value_str.replace(placeholder, replacement) + + # 解析处理后的value(可能是JSON字符串或Python表达式) + try: + # 尝试作为JSON解析 + value = json_module.loads(value_str) + except: + # 如果不是有效的JSON,尝试作为Python表达式执行(安全限制) + try: + # 准备安全的环境变量 + from datetime import datetime + memory = input_data.get('memory', {}) + if not isinstance(memory, dict): + memory = {} + + # 确保memory中有必要的字段 + if 'conversation_history' not in memory: + memory['conversation_history'] = [] + if 'user_profile' not in memory: + memory['user_profile'] = {} + if 'context' not in memory: + memory['context'] = {} + + # 获取user_input:优先从query或USER_INPUT获取 + user_input = input_data.get('query') or input_data.get('USER_INPUT') or input_data.get('user_input') or '' + + # 获取output:从right字段获取,如果是dict则提取right子字段 + output = input_data.get('right', '') + if isinstance(output, dict): + output = output.get('right', '') or output.get('content', '') or str(output) + if not output: + output = '' + + timestamp = datetime.now().isoformat() + + # 在Python表达式执行前,替换 {{user_input}}、{{output}}、{{timestamp}} + # 注意:模板中已经有引号了,所以需要转义字符串中的特殊字符,然后直接插入 + # 使用json.dumps来正确转义,但去掉外层的引号(因为模板中已经有引号了) + user_input_escaped = json_module.dumps(user_input, ensure_ascii=False)[1:-1] # 去掉首尾引号 + output_escaped = json_module.dumps(output, ensure_ascii=False)[1:-1] + timestamp_escaped = json_module.dumps(timestamp, ensure_ascii=False)[1:-1] + + value_str = value_str.replace('{{user_input}}', user_input_escaped) + value_str = value_str.replace('{{output}}', output_escaped) + value_str = value_str.replace('{{timestamp}}', timestamp_escaped) + + # 只允许基本的字典和列表操作 + safe_dict = { + 'memory': memory, + 'user_input': user_input, + 'output': output, + 'timestamp': timestamp + } + + logger.info(f"[rjb] Cache节点 {node_id} 执行value模板") + logger.info(f"[rjb] value_str前300字符: {value_str[:300]}") + logger.info(f"[rjb] user_input: {user_input[:50]}, output: {str(output)[:50]}, timestamp: {timestamp}") + value = eval(value_str, {"__builtins__": {}}, safe_dict) + logger.info(f"[rjb] Cache节点 {node_id} value模板执行成功,类型: {type(value)}") + if isinstance(value, dict): + logger.info(f"[rjb] keys: {list(value.keys())}") + if 'conversation_history' in value: + logger.info(f"[rjb] conversation_history: {len(value['conversation_history'])} 条") + if value['conversation_history']: + logger.info(f"[rjb] 第一条: {value['conversation_history'][0]}") + except Exception as e: + logger.error(f"Cache节点 {node_id} value模板执行失败: {str(e)}") + logger.error(f"value_str: {value_str[:500]}") + logger.error(f"safe_dict: {safe_dict}") + import traceback + logger.error(f"traceback: {traceback.format_exc()}") + # 如果都失败,使用原始输入数据 + value = input_data + else: + # 没有value模板,使用输入数据 + value = input_data + if isinstance(input_data, dict) and 'value' in input_data: + value = input_data['value'] + + # 存储到缓存 + if use_redis and redis_client: + try: + redis_client.setex(key, ttl, json_module.dumps(value, ensure_ascii=False)) + logger.info(f"[rjb] Cache节点 {node_id} 已存储到Redis: key={key}") + except Exception as e: + logger.warning(f"存储到Redis失败: {str(e)}") + + # 同时存储到内存缓存 + self._cache_store[key] = value + self._cache_timestamps[key] = time.time() + logger.info(f"[rjb] Cache节点 {node_id} 已存储: key={key}, value类型={type(value)}") + + exec_result = {'output': input_data, 'status': 'success', 'cached_value': value} + + elif operation == 'delete': + # 删除缓存 + if key in self._cache_store: + del self._cache_store[key] + if key in self._cache_timestamps: + del self._cache_timestamps[key] + exec_result = {'output': input_data, 'status': 'success'} + + elif operation == 'clear': + # 清空缓存 + self._cache_store.clear() + self._cache_timestamps.clear() + exec_result = {'output': input_data, 'status': 'success'} + + else: + exec_result = {'output': input_data, 'status': 'success'} + + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, exec_result.get('output'), duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'缓存操作失败: {str(e)}' + } + + elif node_type == 'vector_db': + # 向量数据库节点:向量存储、相似度搜索、RAG检索 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'search') # search, upsert, delete + collection = node_data.get('collection', 'default') + query_vector = node_data.get('query_vector', '') + top_k = node_data.get('top_k', 5) + + # 简化的内存向量存储实现(实际生产环境应使用ChromaDB、Pinecone等) + if not hasattr(self, '_vector_store'): + self._vector_store = {} + + try: + if operation == 'search': + # 向量相似度搜索(简化实现:使用余弦相似度) + if collection not in self._vector_store: + self._vector_store[collection] = [] + + # 获取查询向量 + if isinstance(query_vector, str): + # 尝试从input_data中获取 + query_vec = self._get_nested_value(input_data, query_vector.replace('{', '').replace('}', '')) + else: + query_vec = query_vector + + if not isinstance(query_vec, list): + # 如果输入数据包含embedding字段,使用它 + if isinstance(input_data, dict) and 'embedding' in input_data: + query_vec = input_data['embedding'] + else: + raise ValueError("无法获取查询向量") + + # 计算相似度并排序 + results = [] + for item in self._vector_store[collection]: + if 'vector' in item: + # 计算余弦相似度 + import math + vec1 = query_vec + vec2 = item['vector'] + if len(vec1) != len(vec2): + continue + + dot_product = sum(a * b for a, b in zip(vec1, vec2)) + magnitude1 = math.sqrt(sum(a * a for a in vec1)) + magnitude2 = math.sqrt(sum(a * a for a in vec2)) + + if magnitude1 == 0 or magnitude2 == 0: + similarity = 0 + else: + similarity = dot_product / (magnitude1 * magnitude2) + + results.append({ + 'id': item.get('id'), + 'text': item.get('text', ''), + 'metadata': item.get('metadata', {}), + 'similarity': similarity + }) + + # 按相似度排序并返回top_k + results.sort(key=lambda x: x['similarity'], reverse=True) + result = results[:top_k] + + elif operation == 'upsert': + # 插入或更新向量 + if collection not in self._vector_store: + self._vector_store[collection] = [] + + # 从输入数据中提取向量和文本 + vector = input_data.get('embedding') or input_data.get('vector') + text = input_data.get('text') or input_data.get('content', '') + metadata = input_data.get('metadata', {}) + doc_id = input_data.get('id') or f"doc_{len(self._vector_store[collection])}" + + # 查找是否已存在 + existing_index = None + for i, item in enumerate(self._vector_store[collection]): + if item.get('id') == doc_id: + existing_index = i + break + + doc_item = { + 'id': doc_id, + 'vector': vector, + 'text': text, + 'metadata': metadata + } + + if existing_index is not None: + self._vector_store[collection][existing_index] = doc_item + else: + self._vector_store[collection].append(doc_item) + + result = {'id': doc_id, 'status': 'upserted'} + + elif operation == 'delete': + # 删除向量 + if collection in self._vector_store: + doc_id = node_data.get('doc_id') or input_data.get('id') + if doc_id: + self._vector_store[collection] = [ + item for item in self._vector_store[collection] + if item.get('id') != doc_id + ] + result = {'id': doc_id, 'status': 'deleted'} + else: + # 删除整个集合 + del self._vector_store[collection] + result = {'collection': collection, 'status': 'deleted'} + else: + result = {'status': 'not_found'} + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'向量数据库操作失败: {str(e)}' + } + + elif node_type == 'log': + # 日志节点:记录日志、调试输出、性能监控 + node_data = node.get('data', {}) + level = node_data.get('level', 'info') # debug, info, warning, error + message = node_data.get('message', '') + include_data = node_data.get('include_data', True) + + try: + # 格式化消息 + if message: + # 替换变量 + if isinstance(input_data, dict): + for key, value in input_data.items(): + message = message.replace(f'{{{key}}}', str(value)) + + # 构建日志内容 + log_data = { + 'message': message or '节点执行', + 'node_id': node_id, + 'node_type': node_type, + 'timestamp': time.time() + } + + if include_data: + log_data['data'] = input_data + + # 记录日志 + log_message = f"[{node_id}] {log_data['message']}" + if include_data: + log_message += f" | 数据: {json_module.dumps(input_data, ensure_ascii=False)[:200]}" + + if level == 'debug': + logger.debug(log_message) + elif level == 'info': + logger.info(log_message) + elif level == 'warning': + logger.warning(log_message) + elif level == 'error': + logger.error(log_message) + else: + logger.info(log_message) + + # 如果使用执行日志记录器,也记录 + if self.logger: + self.logger.info(log_data['message'], node_id=node_id, node_type=node_type, data=input_data if include_data else None) + + exec_result = {'output': input_data, 'status': 'success', 'log': log_data} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, exec_result.get('output'), duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': input_data, + 'status': 'failed', + 'error': f'日志记录失败: {str(e)}' + } + + elif node_type == 'error_handler': + # 错误处理节点:捕获错误、错误重试、错误通知 + # 注意:这个节点需要特殊处理,因为它应该包装其他节点的执行 + # 这里我们实现一个简化版本,主要用于错误重试和通知 + node_data = node.get('data', {}) + retry_count = node_data.get('retry_count', 3) + retry_delay = node_data.get('retry_delay', 1000) # 毫秒 + on_error = node_data.get('on_error', 'notify') # notify, retry, stop + error_handler_workflow = node_data.get('error_handler_workflow', '') + + # 这个节点通常用于包装其他节点,但在这里我们只处理输入数据中的错误 + try: + # 检查输入数据中是否有错误 + if isinstance(input_data, dict) and input_data.get('status') == 'failed': + error = input_data.get('error', '未知错误') + + if on_error == 'retry' and retry_count > 0: + # 重试逻辑(这里简化处理,实际应该重新执行前一个节点) + logger.warning(f"错误处理节点检测到错误,将重试: {error}") + # 注意:实际重试需要重新执行前一个节点,这里只记录 + exec_result = { + 'output': input_data, + 'status': 'retry', + 'retry_count': retry_count, + 'error': error + } + elif on_error == 'notify': + # 通知错误(记录日志) + logger.error(f"错误处理节点捕获错误: {error}") + exec_result = { + 'output': input_data, + 'status': 'error_handled', + 'error': error, + 'notified': True + } + else: + # 停止执行 + exec_result = { + 'output': input_data, + 'status': 'failed', + 'error': error, + 'stopped': True + } + else: + # 没有错误,正常通过 + exec_result = {'output': input_data, 'status': 'success'} + + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, exec_result.get('output'), duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': input_data, + 'status': 'failed', + 'error': f'错误处理失败: {str(e)}' + } + + elif node_type == 'csv': + # CSV处理节点:CSV解析、生成、转换 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'parse') # parse, generate, convert + delimiter = node_data.get('delimiter', ',') + headers = node_data.get('headers', True) + encoding = node_data.get('encoding', 'utf-8') + + try: + import csv + import io + + if operation == 'parse': + # 解析CSV + csv_text = input_data + if isinstance(input_data, dict): + # 尝试从字典中提取CSV文本 + for key in ['csv', 'data', 'content', 'text']: + if key in input_data and isinstance(input_data[key], str): + csv_text = input_data[key] + break + + if not isinstance(csv_text, str): + csv_text = str(csv_text) + + # 解析CSV + csv_reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter) if headers else csv.reader(io.StringIO(csv_text), delimiter=delimiter) + + if headers: + result = list(csv_reader) + else: + # 没有表头,返回数组的数组 + result = list(csv_reader) + + elif operation == 'generate': + # 生成CSV + data = input_data + if isinstance(input_data, dict) and 'data' in input_data: + data = input_data['data'] + + if not isinstance(data, list): + data = [data] + + output = io.StringIO() + if data and isinstance(data[0], dict): + # 字典列表,使用DictWriter + fieldnames = data[0].keys() + writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=delimiter) + if headers: + writer.writeheader() + writer.writerows(data) + else: + # 数组列表,使用writer + writer = csv.writer(output, delimiter=delimiter) + if headers and data and isinstance(data[0], list): + # 假设第一行是表头 + writer.writerow(data[0]) + writer.writerows(data[1:]) + else: + writer.writerows(data) + + result = output.getvalue() + + elif operation == 'convert': + # 转换CSV格式(改变分隔符等) + csv_text = input_data + if isinstance(input_data, dict): + for key in ['csv', 'data', 'content']: + if key in input_data and isinstance(input_data[key], str): + csv_text = input_data[key] + break + + if not isinstance(csv_text, str): + csv_text = str(csv_text) + + # 读取并重新写入 + reader = csv.reader(io.StringIO(csv_text)) + output = io.StringIO() + writer = csv.writer(output, delimiter=delimiter) + writer.writerows(reader) + result = output.getvalue() + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'CSV处理失败: {str(e)}' + } + + elif node_type == 'object_storage': + # 对象存储节点:文件上传、下载、删除、列表 + node_data = node.get('data', {}) + provider = node_data.get('provider', 's3') # oss, s3, cos + operation = node_data.get('operation', 'upload') + bucket = node_data.get('bucket', '') + key = node_data.get('key', '') + file_data = node_data.get('file', '') + + try: + # 简化实现:实际生产环境需要使用boto3(AWS S3)、oss2(阿里云OSS)等 + # 这里提供一个接口框架,实际使用时需要安装相应的SDK + + if operation == 'upload': + # 上传文件 + if not file_data: + # 从input_data中获取文件数据 + if isinstance(input_data, dict): + file_data = input_data.get('file') or input_data.get('data') or input_data.get('content') + else: + file_data = input_data + + # 替换key中的变量 + if isinstance(input_data, dict): + for k, v in input_data.items(): + key = key.replace(f'{{{k}}}', str(v)) + + # 这里只是模拟上传,实际需要调用相应的SDK + logger.info(f"对象存储上传: provider={provider}, bucket={bucket}, key={key}") + result = { + 'provider': provider, + 'bucket': bucket, + 'key': key, + 'status': 'uploaded', + 'url': f"{provider}://{bucket}/{key}" # 模拟URL + } + + elif operation == 'download': + # 下载文件 + if isinstance(input_data, dict): + for k, v in input_data.items(): + key = key.replace(f'{{{k}}}', str(v)) + + logger.info(f"对象存储下载: provider={provider}, bucket={bucket}, key={key}") + # 这里只是模拟下载,实际需要调用相应的SDK + result = { + 'provider': provider, + 'bucket': bucket, + 'key': key, + 'status': 'downloaded', + 'data': '模拟文件内容' # 实际应该是文件内容 + } + + elif operation == 'delete': + # 删除文件 + if isinstance(input_data, dict): + for k, v in input_data.items(): + key = key.replace(f'{{{k}}}', str(v)) + + logger.info(f"对象存储删除: provider={provider}, bucket={bucket}, key={key}") + result = { + 'provider': provider, + 'bucket': bucket, + 'key': key, + 'status': 'deleted' + } + + elif operation == 'list': + # 列出文件 + prefix = node_data.get('prefix', '') + logger.info(f"对象存储列表: provider={provider}, bucket={bucket}, prefix={prefix}") + result = { + 'provider': provider, + 'bucket': bucket, + 'prefix': prefix, + 'files': [] # 实际应该是文件列表 + } + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'对象存储操作失败: {str(e)}。注意:实际使用需要安装相应的SDK(如boto3、oss2等)' + } + + elif node_type == 'slack': + # Slack节点:发送消息、创建频道、获取消息 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'send_message') + token = node_data.get('token', '') + channel = node_data.get('channel', '') + message = node_data.get('message', '') + attachments = node_data.get('attachments', []) + + try: + import httpx + + # 替换消息中的变量 + if isinstance(input_data, dict): + for key, value in input_data.items(): + message = message.replace(f'{{{key}}}', str(value)) + channel = channel.replace(f'{{{key}}}', str(value)) + + if operation == 'send_message': + # 发送消息 + url = 'https://slack.com/api/chat.postMessage' + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + payload = { + 'channel': channel, + 'text': message + } + if attachments: + payload['attachments'] = attachments + + # 注意:实际使用时需要安装httpx库,这里提供接口框架 + # async with httpx.AsyncClient() as client: + # response = await client.post(url, headers=headers, json=payload) + # result = response.json() + + # 模拟响应 + logger.info(f"Slack发送消息: channel={channel}, message={message[:50]}") + result = { + 'ok': True, + 'channel': channel, + 'ts': str(time.time()), + 'message': {'text': message} + } + + elif operation == 'create_channel': + # 创建频道 + url = 'https://slack.com/api/conversations.create' + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + payload = {'name': channel} + + logger.info(f"Slack创建频道: channel={channel}") + result = {'ok': True, 'channel': {'name': channel, 'id': f'C{int(time.time())}'}} + + elif operation == 'get_messages': + # 获取消息 + url = f'https://slack.com/api/conversations.history' + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + params = {'channel': channel} + + logger.info(f"Slack获取消息: channel={channel}") + result = {'ok': True, 'messages': []} + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'Slack操作失败: {str(e)}。注意:需要配置有效的Slack Token' + } + + elif node_type == 'dingtalk' or node_type == 'dingding': + # 钉钉节点:发送消息、创建群组、获取消息 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'send_message') + webhook_url = node_data.get('webhook_url', '') + access_token = node_data.get('access_token', '') + chat_id = node_data.get('chat_id', '') + message = node_data.get('message', '') + + try: + import httpx + + # 替换消息中的变量 + if isinstance(input_data, dict): + for key, value in input_data.items(): + message = message.replace(f'{{{key}}}', str(value)) + chat_id = chat_id.replace(f'{{{key}}}', str(value)) + + if operation == 'send_message': + # 发送消息(通过Webhook或API) + if webhook_url: + # 使用Webhook + payload = { + 'msgtype': 'text', + 'text': {'content': message} + } + # async with httpx.AsyncClient() as client: + # response = await client.post(webhook_url, json=payload) + # result = response.json() + logger.info(f"钉钉发送消息(Webhook): message={message[:50]}") + result = {'errcode': 0, 'errmsg': 'ok'} + else: + # 使用API + url = f'https://oapi.dingtalk.com/chat/send' + headers = { + 'Content-Type': 'application/json' + } + payload = { + 'access_token': access_token, + 'chatid': chat_id, + 'msg': { + 'msgtype': 'text', + 'text': {'content': message} + } + } + logger.info(f"钉钉发送消息(API): chat_id={chat_id}, message={message[:50]}") + result = {'errcode': 0, 'errmsg': 'ok'} + + elif operation == 'create_group': + # 创建群组 + url = 'https://oapi.dingtalk.com/chat/create' + headers = {'Content-Type': 'application/json'} + payload = { + 'access_token': access_token, + 'name': chat_id, + 'owner': node_data.get('owner', '') + } + logger.info(f"钉钉创建群组: name={chat_id}") + result = {'errcode': 0, 'errmsg': 'ok', 'chatid': f'chat_{int(time.time())}'} + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'钉钉操作失败: {str(e)}。注意:需要配置有效的Webhook URL或Access Token' + } + + elif node_type == 'wechat_work' or node_type == 'wecom': + # 企业微信节点:发送消息、创建群组、获取消息 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'send_message') + corp_id = node_data.get('corp_id', '') + corp_secret = node_data.get('corp_secret', '') + agent_id = node_data.get('agent_id', '') + chat_id = node_data.get('chat_id', '') + message = node_data.get('message', '') + + try: + import httpx + + # 替换消息中的变量 + if isinstance(input_data, dict): + for key, value in input_data.items(): + message = message.replace(f'{{{key}}}', str(value)) + chat_id = chat_id.replace(f'{{{key}}}', str(value)) + + if operation == 'send_message': + # 先获取access_token + token_url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken' + token_params = { + 'corpid': corp_id, + 'corpsecret': corp_secret + } + # async with httpx.AsyncClient() as client: + # token_response = await client.get(token_url, params=token_params) + # token_data = token_response.json() + # access_token = token_data.get('access_token') + + # 模拟获取token + access_token = 'mock_token' + + # 发送消息 + url = f'https://qyapi.weixin.qq.com/cgi-bin/message/send' + params = {'access_token': access_token} + payload = { + 'touser': chat_id or '@all', + 'msgtype': 'text', + 'agentid': agent_id, + 'text': {'content': message} + } + + logger.info(f"企业微信发送消息: chat_id={chat_id}, message={message[:50]}") + result = {'errcode': 0, 'errmsg': 'ok'} + + elif operation == 'create_group': + # 创建群组 + url = 'https://qyapi.weixin.qq.com/cgi-bin/appchat/create' + params = {'access_token': access_token} + payload = { + 'name': chat_id, + 'owner': node_data.get('owner', ''), + 'userlist': node_data.get('userlist', []) + } + logger.info(f"企业微信创建群组: name={chat_id}") + result = {'errcode': 0, 'errmsg': 'ok', 'chatid': f'chat_{int(time.time())}'} + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'企业微信操作失败: {str(e)}。注意:需要配置有效的Corp ID和Secret' + } + + elif node_type == 'sms': + # 短信节点(SMS):发送短信、批量发送、短信模板 + node_data = node.get('data', {}) + provider = node_data.get('provider', 'aliyun') # aliyun, tencent, twilio + operation = node_data.get('operation', 'send') + phone = node_data.get('phone', '') + template = node_data.get('template', '') + sign = node_data.get('sign', '') + access_key = node_data.get('access_key', '') + access_secret = node_data.get('access_secret', '') + + try: + # 替换模板中的变量 + if isinstance(input_data, dict): + for key, value in input_data.items(): + template = template.replace(f'{{{key}}}', str(value)) + phone = phone.replace(f'{{{key}}}', str(value)) + + if operation == 'send': + # 发送短信 + if provider == 'aliyun': + # 阿里云短信(需要安装alibabacloud-dysmsapi20170525) + logger.info(f"阿里云短信发送: phone={phone}, template={template[:50]}") + result = { + 'provider': 'aliyun', + 'phone': phone, + 'status': 'sent', + 'message_id': f'sms_{int(time.time())}' + } + elif provider == 'tencent': + # 腾讯云短信(需要安装tencentcloud-sdk-python) + logger.info(f"腾讯云短信发送: phone={phone}, template={template[:50]}") + result = { + 'provider': 'tencent', + 'phone': phone, + 'status': 'sent', + 'message_id': f'sms_{int(time.time())}' + } + elif provider == 'twilio': + # Twilio短信(需要安装twilio) + logger.info(f"Twilio短信发送: phone={phone}, template={template[:50]}") + result = { + 'provider': 'twilio', + 'phone': phone, + 'status': 'sent', + 'message_id': f'sms_{int(time.time())}' + } + else: + result = {'error': f'不支持的短信提供商: {provider}'} + + elif operation == 'batch_send': + # 批量发送 + phones = node_data.get('phones', []) + if isinstance(phones, str): + phones = [p.strip() for p in phones.split(',')] + + logger.info(f"批量发送短信: phones={len(phones)}, provider={provider}") + result = { + 'provider': provider, + 'phones': phones, + 'status': 'sent', + 'count': len(phones) + } + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'短信发送失败: {str(e)}。注意:需要安装相应的SDK(如alibabacloud-dysmsapi20170525、tencentcloud-sdk-python、twilio)' + } + + elif node_type == 'pdf': + # PDF处理节点:PDF解析、生成、合并、拆分 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'extract_text') # extract_text, generate, merge, split + pages = node_data.get('pages', '') + template = node_data.get('template', '') + + try: + # 注意:需要安装PyPDF2或pdfplumber库 + # pip install PyPDF2 pdfplumber + + if operation == 'extract_text': + # 提取文本 + pdf_data = input_data + if isinstance(input_data, dict): + pdf_data = input_data.get('pdf') or input_data.get('data') or input_data.get('file') + + # 这里只是接口框架,实际需要: + # from PyPDF2 import PdfReader + # reader = PdfReader(io.BytesIO(pdf_data)) + # text = "" + # for page in reader.pages: + # text += page.extract_text() + + logger.info(f"PDF提取文本: pages={pages}") + result = { + 'text': 'PDF文本提取结果(需要安装PyPDF2或pdfplumber)', + 'pages': pages or 'all' + } + + elif operation == 'generate': + # 生成PDF + content = input_data + if isinstance(input_data, dict): + content = input_data.get('content') or input_data.get('text') or input_data.get('data') + + # 这里只是接口框架,实际需要: + # from reportlab.pdfgen import canvas + # 或使用其他PDF生成库 + + logger.info(f"PDF生成: template={template}") + result = { + 'pdf': 'PDF生成结果(需要安装reportlab或其他PDF生成库)', + 'template': template + } + + elif operation == 'merge': + # 合并PDF + pdfs = input_data + if isinstance(input_data, dict): + pdfs = input_data.get('pdfs') or input_data.get('files') + + if not isinstance(pdfs, list): + pdfs = [pdfs] + + # 这里只是接口框架,实际需要: + # from PyPDF2 import PdfMerger + # merger = PdfMerger() + # for pdf in pdfs: + # merger.append(pdf) + # result_pdf = merger.write() + + logger.info(f"PDF合并: count={len(pdfs)}") + result = { + 'merged_pdf': '合并后的PDF(需要安装PyPDF2)', + 'count': len(pdfs) + } + + elif operation == 'split': + # 拆分PDF + pdf_data = input_data + if isinstance(input_data, dict): + pdf_data = input_data.get('pdf') or input_data.get('file') + + # 这里只是接口框架,实际需要: + # from PyPDF2 import PdfReader, PdfWriter + # reader = PdfReader(pdf_data) + # writer = PdfWriter() + # for page_num in range(start_page, end_page): + # writer.add_page(reader.pages[page_num]) + + logger.info(f"PDF拆分: pages={pages}") + result = { + 'split_pdfs': ['拆分后的PDF列表(需要安装PyPDF2)'], + 'pages': pages + } + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'PDF处理失败: {str(e)}。注意:需要安装PyPDF2或pdfplumber库(pip install PyPDF2 pdfplumber)' + } + + elif node_type == 'image': + # 图像处理节点:图像缩放、裁剪、格式转换、OCR识别 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'resize') # resize, crop, convert, ocr + width = node_data.get('width', 800) + height = node_data.get('height', 600) + format_type = node_data.get('format', 'png') + + try: + # 注意:需要安装Pillow库 + # pip install Pillow + # OCR需要安装pytesseract和tesseract-ocr + # pip install pytesseract + + image_data = input_data + if isinstance(input_data, dict): + image_data = input_data.get('image') or input_data.get('data') or input_data.get('file') + + if operation == 'resize': + # 缩放图像 + # 这里只是接口框架,实际需要: + # from PIL import Image + # import io + # img = Image.open(io.BytesIO(image_data)) + # img_resized = img.resize((width, height)) + # output = io.BytesIO() + # img_resized.save(output, format=format_type.upper()) + # result = output.getvalue() + + logger.info(f"图像缩放: {width}x{height}, format={format_type}") + result = { + 'image': '缩放后的图像数据(需要安装Pillow)', + 'width': width, + 'height': height, + 'format': format_type + } + + elif operation == 'crop': + # 裁剪图像 + x = node_data.get('x', 0) + y = node_data.get('y', 0) + crop_width = node_data.get('crop_width', width) + crop_height = node_data.get('crop_height', height) + + # 这里只是接口框架,实际需要: + # from PIL import Image + # img = Image.open(io.BytesIO(image_data)) + # img_cropped = img.crop((x, y, x + crop_width, y + crop_height)) + + logger.info(f"图像裁剪: ({x}, {y}, {crop_width}, {crop_height})") + result = { + 'image': '裁剪后的图像数据(需要安装Pillow)', + 'crop_box': (x, y, crop_width, crop_height) + } + + elif operation == 'convert': + # 格式转换 + target_format = node_data.get('target_format', format_type) + + # 这里只是接口框架,实际需要: + # from PIL import Image + # img = Image.open(io.BytesIO(image_data)) + # output = io.BytesIO() + # img.save(output, format=target_format.upper()) + # result = output.getvalue() + + logger.info(f"图像格式转换: {format_type} -> {target_format}") + result = { + 'image': f'转换后的图像数据(需要安装Pillow)', + 'format': target_format + } + + elif operation == 'ocr': + # OCR识别 + # 这里只是接口框架,实际需要: + # from PIL import Image + # import pytesseract + # img = Image.open(io.BytesIO(image_data)) + # text = pytesseract.image_to_string(img, lang='chi_sim+eng') + + logger.info(f"OCR识别") + result = { + 'text': 'OCR识别结果(需要安装pytesseract和tesseract-ocr)', + 'confidence': 0.95 + } + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'图像处理失败: {str(e)}。注意:需要安装Pillow库(pip install Pillow),OCR需要pytesseract和tesseract-ocr' + } + + elif node_type == 'excel': + # Excel处理节点:Excel读取、写入、格式转换、公式计算 + node_data = node.get('data', {}) + operation = node_data.get('operation', 'read') # read, write, convert, formula + sheet = node_data.get('sheet', 'Sheet1') + range_str = node_data.get('range', '') + format_type = node_data.get('format', 'xlsx') # xlsx, xls, csv + + try: + # 注意:需要安装openpyxl或pandas库 + # pip install openpyxl pandas + + if operation == 'read': + # 读取Excel + excel_data = input_data + if isinstance(input_data, dict): + excel_data = input_data.get('excel') or input_data.get('file') or input_data.get('data') + + # 这里只是接口框架,实际需要: + # import pandas as pd + # df = pd.read_excel(io.BytesIO(excel_data), sheet_name=sheet) + # if range_str: + # # 解析范围,如 "A1:C10" + # df = df.loc[range_start:range_end] + # result = df.to_dict('records') + + logger.info(f"Excel读取: sheet={sheet}, range={range_str}") + result = { + 'data': [{'列1': '值1', '列2': '值2'}], + 'sheet': sheet, + 'range': range_str + } + + elif operation == 'write': + # 写入Excel + data = input_data + if isinstance(input_data, dict): + data = input_data.get('data') or input_data.get('rows') + + if not isinstance(data, list): + data = [data] + + # 这里只是接口框架,实际需要: + # import pandas as pd + # df = pd.DataFrame(data) + # output = io.BytesIO() + # df.to_excel(output, sheet_name=sheet, index=False) + # result = output.getvalue() + + logger.info(f"Excel写入: sheet={sheet}, rows={len(data)}") + result = { + 'excel': '生成的Excel数据(需要安装openpyxl或pandas)', + 'sheet': sheet, + 'rows': len(data) + } + + elif operation == 'convert': + # 格式转换 + target_format = node_data.get('target_format', 'csv') + excel_data = input_data + if isinstance(input_data, dict): + excel_data = input_data.get('excel') or input_data.get('file') + + # 这里只是接口框架,实际需要: + # import pandas as pd + # df = pd.read_excel(io.BytesIO(excel_data)) + # if target_format == 'csv': + # result = df.to_csv(index=False) + # elif target_format == 'json': + # result = df.to_json(orient='records') + + logger.info(f"Excel格式转换: {format_type} -> {target_format}") + result = { + 'data': '转换后的数据(需要安装pandas)', + 'format': target_format + } + + elif operation == 'formula': + # 公式计算 + formula = node_data.get('formula', '') + data = input_data + if isinstance(input_data, dict): + data = input_data.get('data') + + # 这里只是接口框架,实际需要: + # import pandas as pd + # df = pd.DataFrame(data) + # # 使用eval或更安全的方式计算公式 + # result = df.eval(formula) + + logger.info(f"Excel公式计算: formula={formula}") + result = { + 'result': '公式计算结果(需要安装pandas)', + 'formula': formula + } + + else: + result = input_data + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'Excel处理失败: {str(e)}。注意:需要安装openpyxl或pandas库(pip install openpyxl pandas)' + } + + elif node_type == 'subworkflow': + # 子工作流节点:调用其他工作流 + node_data = node.get('data', {}) + workflow_id = node_data.get('workflow_id', '') + input_mapping = node_data.get('input_mapping', {}) + try: + # 将当前输入根据映射转换为子工作流输入 + sub_input = {} + if isinstance(input_mapping, dict): + for k, v in input_mapping.items(): + if isinstance(v, str) and isinstance(input_data, dict): + sub_input[k] = input_data.get(v) or input_data.get(v.strip('{}')) or v + else: + sub_input[k] = v + else: + sub_input = input_data + + # 实际调用子工作流的执行,这里简化为回传映射后的输入 + # TODO: 集成 WorkflowEngine 执行指定 workflow_id + result = { + 'workflow_id': workflow_id, + 'input': sub_input, + 'status': 'success', + 'note': '子工作流执行框架占位,需集成实际调用' + } + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'子工作流执行失败: {str(e)}' + } + + elif node_type == 'code': + # 代码执行节点:支持简单的Python/JavaScript片段执行(注意安全) + node_data = node.get('data', {}) + language = node_data.get('language', 'python') + code = node_data.get('code', '') + timeout = node_data.get('timeout', 30) + try: + if language.lower() == 'python': + # 受限执行环境 + local_vars = {'input_data': input_data, 'result': None} + exec(code, {'__builtins__': {}}, local_vars) # 注意:生产环境需更严格沙箱 + result = local_vars.get('result', local_vars.get('output', input_data)) + elif language.lower() == 'javascript': + # JS 执行需要外部运行时,这里仅占位 + result = { + 'status': 'not_implemented', + 'message': 'JavaScript执行需集成运行时' + } + else: + result = {'status': 'failed', 'error': f'不支持的语言: {language}'} + + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'代码执行失败: {str(e)}' + } + + elif node_type == 'oauth': + # OAuth 节点:获取/刷新 Token + node_data = node.get('data', {}) + provider = node_data.get('provider', 'google') + client_id = node_data.get('client_id', '') + client_secret = node_data.get('client_secret', '') + scopes = node_data.get('scopes', []) + try: + # 简化占位实现,返回模拟 token + token_data = { + 'access_token': f'mock_access_token_{provider}', + 'expires_in': 3600, + 'token_type': 'Bearer', + 'scope': scopes + } + exec_result = {'output': token_data, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, token_data, duration) + return exec_result + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'OAuth处理失败: {str(e)}' + } + + elif node_type == 'validator': + # 数据验证节点:基于简化的schema检查 + node_data = node.get('data', {}) + schema = node_data.get('schema', {}) + on_error = node_data.get('on_error', 'reject') # reject, continue, transform + try: + # 简单类型检查 + if 'type' in schema: + expected_type = schema['type'] + actual_type = type(input_data).__name__ + if expected_type == 'object' and not isinstance(input_data, dict): + raise ValueError(f'期望类型object,实际类型{actual_type}') + if expected_type == 'array' and not isinstance(input_data, list): + raise ValueError(f'期望类型array,实际类型{actual_type}') + result = input_data + exec_result = {'output': result, 'status': 'success'} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + except Exception as e: + if on_error == 'continue': + return {'output': input_data, 'status': 'success', 'warning': str(e)} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'数据验证失败: {str(e)}' + } + + elif node_type == 'batch': + # 批处理节点:数据分批处理 + node_data = node.get('data', {}) + batch_size = node_data.get('batch_size', 100) + mode = node_data.get('mode', 'split') # split, group, aggregate + wait_for_completion = node_data.get('wait_for_completion', True) + try: + data_list = input_data if isinstance(input_data, list) else [input_data] + if mode == 'split': + batches = [data_list[i:i + batch_size] for i in range(0, len(data_list), batch_size)] + result = batches + elif mode == 'group': + batches = [data_list[i:i + batch_size] for i in range(0, len(data_list), batch_size)] + result = batches + elif mode == 'aggregate': + result = { + 'count': len(data_list), + 'samples': data_list[:min(3, len(data_list))] + } + else: + result = data_list + exec_result = {'output': result, 'status': 'success', 'wait': wait_for_completion} + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_complete(node_id, node_type, result, duration) + return exec_result + except Exception as e: + if self.logger: + duration = int((time.time() - start_time) * 1000) + self.logger.log_node_error(node_id, node_type, e, duration) + return { + 'output': None, + 'status': 'failed', + 'error': f'批处理失败: {str(e)}' + } + elif node_type == 'output' or node_type == 'end': # 输出节点:返回最终结果 # 读取节点配置中的输出格式设置 @@ -1837,6 +3915,7 @@ class WorkflowEngine: else: # 未知节点类型 + logger.warning(f"[rjb] 未知节点类型: node_id={node_id}, node_type={node_type}, node keys={list(node.keys())}") return { 'output': input_data, 'status': 'success', @@ -1882,6 +3961,7 @@ class WorkflowEngine: while True: # 构建当前活跃的执行图 execution_order = self.build_execution_graph(active_edges) + logger.debug(f"[rjb] 当前执行图: {execution_order}, 活跃边数: {len(active_edges)}, 已执行节点: {executed_nodes}") # 找到下一个要执行的节点(未执行且入度为0) next_node_id = None @@ -1889,13 +3969,21 @@ class WorkflowEngine: if node_id not in executed_nodes: # 检查所有前置节点是否已执行 can_execute = True - for edge in active_edges: - if edge['target'] == node_id: - if edge['source'] not in executed_nodes: - can_execute = False - break + incoming_edges = [e for e in active_edges if e['target'] == node_id] + if not incoming_edges: + # 没有入边,可能是起始节点或孤立节点 + if node_id not in [n['id'] for n in self.nodes.values() if n.get('type') == 'start']: + # 不是起始节点,但有入边被过滤了,不应该执行 + logger.debug(f"[rjb] 节点 {node_id} 没有入边,跳过执行") + continue + for edge in incoming_edges: + if edge['source'] not in executed_nodes: + can_execute = False + logger.debug(f"[rjb] 节点 {node_id} 的前置节点 {edge['source']} 未执行,不能执行") + break if can_execute: next_node_id = node_id + logger.info(f"[rjb] 选择执行节点: {next_node_id}, 类型: {self.nodes[next_node_id].get('type')}, 入边数: {len(incoming_edges)}") break if not next_node_id: @@ -1933,7 +4021,7 @@ class WorkflowEngine: if node.get('type') == 'start': logger.info(f"[rjb] Start节点输出已保存: node_id={next_node_id}, output={output_value}, output_type={type(output_value)}") - # 如果是条件节点,根据分支结果过滤边 + # 如果是条件节点或Switch节点,根据分支结果过滤边 if node.get('type') == 'condition': branch = result.get('branch', 'false') logger.info(f"[rjb] 条件节点分支过滤: node_id={next_node_id}, branch={branch}") @@ -1958,7 +4046,84 @@ class WorkflowEngine: edges_to_keep.append(edge) active_edges = edges_to_keep - logger.info(f"[rjb] 条件节点过滤后: 保留{len(active_edges)}条边,移除{len(edges_to_remove)}条边") + + elif node.get('type') == 'switch': + branch = result.get('branch', 'default') + logger.info(f"[rjb] Switch节点分支过滤: node_id={next_node_id}, branch={branch}") + + # 记录过滤前的边信息 + edges_before = [e for e in active_edges if e['source'] == next_node_id] + logger.info(f"[rjb] Switch节点过滤前: 从节点出发的边有{len(edges_before)}条") + for edge in edges_before: + logger.info(f"[rjb] 边 {edge.get('id')}: sourceHandle={edge.get('sourceHandle')}, target={edge.get('target')}") + + # 移除不匹配的边 + edges_to_keep = [] + edges_removed_count = 0 + removed_source_nodes = set() # 记录被移除边的源节点 + + for edge in active_edges: + if edge['source'] == next_node_id: + # 这是从Switch节点出发的边 + edge_handle = edge.get('sourceHandle') + if edge_handle == branch: + # sourceHandle匹配分支,保留 + edges_to_keep.append(edge) + logger.info(f"[rjb] ✅ 保留边: {edge.get('id')} (sourceHandle={edge_handle} == branch={branch})") + else: + # sourceHandle不匹配,移除 + edges_removed_count += 1 + target_id = edge.get('target') + removed_source_nodes.add(target_id) # 记录目标节点(这些节点将不再可达) + logger.info(f"[rjb] ❌ 移除边: {edge.get('id')} (sourceHandle={edge_handle} != branch={branch}, target={target_id})") + else: + # 不是从Switch节点出发的边,保留 + edges_to_keep.append(edge) + + # 重要:移除那些指向被过滤节点的边(这些边来自被过滤的LLM节点) + # 例如:如果llm-question被过滤了,那么llm-question → merge-response的边也应该被移除 + additional_removed = 0 + for edge in list(edges_to_keep): # 使用list副本,因为我们要修改原列表 + if edge['source'] in removed_source_nodes: + # 这条边来自被过滤的节点,也应该被移除 + edges_to_keep.remove(edge) + additional_removed += 1 + logger.info(f"[rjb] ❌ 移除来自被过滤节点的边: {edge.get('id')} ({edge.get('source')} → {edge.get('target')})") + + edges_removed_count += additional_removed + + active_edges = edges_to_keep + filter_info = { + 'branch': branch, + 'edges_before': len(edges_before), + 'edges_kept': len([e for e in edges_to_keep if e['source'] == next_node_id]), + 'edges_removed': edges_removed_count + } + logger.info(f"[rjb] Switch节点过滤后: 保留{len(active_edges)}条边(其中从Switch节点出发的{filter_info['edges_kept']}条),移除{edges_removed_count}条边") + # 记录过滤后的活跃边 + remaining_switch_edges = [e for e in active_edges if e['source'] == next_node_id] + logger.info(f"[rjb] Switch节点过滤后剩余的边: {[e.get('id') + '->' + e.get('target') for e in remaining_switch_edges]}") + + # 重要:找出那些不再可达的节点(这些节点只通过被移除的边连接) + removed_targets = set() + for edge in edges_before: + if edge not in edges_to_keep: + target_id = edge.get('target') + removed_targets.add(target_id) + logger.info(f"[rjb] ❌ 节点 {target_id} 的边已被移除,该节点将不会被执行") + + # 关键修复:立即重新构建执行图,确保不再可达的节点不在执行图中 + # 这样在下次循环时,这些节点就不会被选择执行 + logger.info(f"[rjb] Switch节点过滤后,重新构建执行图(排除 {len(removed_targets)} 个不再可达的节点)") + + # 同时记录到数据库 + if self.logger: + self.logger.info( + f"Switch节点分支过滤: branch={branch}, 保留{filter_info['edges_kept']}条边,移除{edges_removed_count}条边", + node_id=next_node_id, + node_type='switch', + data=filter_info + ) # 如果是循环节点,跳过循环体的节点(循环体已在节点内部执行) if node.get('type') in ['loop', 'foreach']: diff --git a/backend/app/services/workflow_validator.py b/backend/app/services/workflow_validator.py index 929a37b..84eaaec 100644 --- a/backend/app/services/workflow_validator.py +++ b/backend/app/services/workflow_validator.py @@ -71,7 +71,7 @@ class WorkflowValidator: node_type = node.get('type') if not node_type: self.errors.append(f"节点 {node_id} 缺少类型") - elif node_type not in ['start', 'input', 'llm', 'condition', 'transform', 'output', 'end', 'default', 'loop', 'foreach', 'loop_end', 'agent', 'http', 'request', 'database', 'db', 'file', 'file_operation', 'schedule', 'delay', 'timer', 'webhook', 'email', 'mail', 'message_queue', 'mq', 'rabbitmq', 'kafka']: + elif node_type not in ['start', 'input', 'llm', 'condition', 'transform', 'output', 'end', 'default', 'loop', 'foreach', 'loop_end', 'agent', 'http', 'request', 'database', 'db', 'file', 'file_operation', 'schedule', 'delay', 'timer', 'webhook', 'email', 'mail', 'message_queue', 'mq', 'rabbitmq', 'kafka', 'switch', 'merge', 'wait', 'json', 'text', 'cache', 'vector_db', 'log', 'error_handler', 'csv', 'object_storage', 'slack', 'dingtalk', 'dingding', 'wechat_work', 'wecom', 'sms', 'pdf', 'image', 'excel', 'subworkflow', 'code', 'oauth', 'validator', 'batch']: self.warnings.append(f"节点 {node_id} 使用了未知类型: {node_type}") def _validate_edges(self): diff --git a/backend/scripts/generate_chat_agent.py b/backend/scripts/generate_chat_agent.py new file mode 100644 index 0000000..9893e37 --- /dev/null +++ b/backend/scripts/generate_chat_agent.py @@ -0,0 +1,549 @@ +#!/usr/bin/env python3 +""" +生成智能聊天Agent示例 +展示如何使用平台能力构建一个完整的聊天智能体,包含: +- 记忆管理(缓存节点) +- 意图识别(LLM节点) +- 多分支路由(Switch节点) +- 上下文传递(Transform节点) +- 多轮对话支持 +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy.orm import Session +from app.core.database import SessionLocal +from app.models.agent import Agent +from app.models.user import User +from datetime import datetime +import uuid + + +def generate_chat_agent(db: Session, user: User): + """生成智能聊天Agent - 完整示例""" + nodes = [] + edges = [] + + # ========== 1. 开始节点 ========== + start_node = { + "id": "start-1", + "type": "start", + "position": {"x": 50, "y": 400}, + "data": { + "label": "开始", + "output_format": "json" + } + } + nodes.append(start_node) + + # ========== 2. 查询记忆节点 ========== + query_memory_node = { + "id": "cache-query", + "type": "cache", + "position": {"x": 250, "y": 400}, + "data": { + "label": "查询记忆", + "operation": "get", + "key": "user_memory_{user_id}", + "default_value": '{"conversation_history": [], "user_profile": {}, "context": {}}' + } + } + nodes.append(query_memory_node) + + # ========== 3. 合并用户输入和记忆 ========== + merge_context_node = { + "id": "transform-merge", + "type": "transform", + "position": {"x": 450, "y": 400}, + "data": { + "label": "合并上下文", + "mode": "merge", + "mapping": { + "user_input": "{{query}}", + "memory": "{{output}}", + "timestamp": "{{timestamp}}" + } + } + } + nodes.append(merge_context_node) + + # ========== 4. 意图理解节点 ========== + intent_node = { + "id": "llm-intent", + "type": "llm", + "position": {"x": 650, "y": 400}, + "data": { + "label": "意图理解", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.3", + "max_tokens": "1000", + "prompt": """你是一个专业的对话意图分析助手。请分析用户的输入,识别用户的意图和情感。 + +用户输入:{{user_input}} +对话历史:{{memory.conversation_history}} +用户画像:{{memory.user_profile}} + +请以JSON格式输出分析结果: +{ + "intent": "意图类型(greeting/question/emotion/request/goodbye/other)", + "emotion": "情感状态(positive/neutral/negative)", + "keywords": ["关键词1", "关键词2"], + "topic": "话题主题", + "needs_response": true +} + +请确保输出是有效的JSON格式,不要包含其他文字。""" + } + } + nodes.append(intent_node) + + # ========== 5. Switch节点 - 根据意图分支 ========== + switch_node = { + "id": "switch-intent", + "type": "switch", + "position": {"x": 850, "y": 400}, + "data": { + "label": "意图路由", + "field": "intent", + "cases": { + "greeting": "greeting-handle", + "question": "question-handle", + "emotion": "emotion-handle", + "request": "request-handle", + "goodbye": "goodbye-handle" + }, + "default": "general-handle" + } + } + nodes.append(switch_node) + + # ========== 6. 问候处理分支 ========== + greeting_node = { + "id": "llm-greeting", + "type": "llm", + "position": {"x": 1050, "y": 200}, + "data": { + "label": "问候回复", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.7", + "max_tokens": "500", + "prompt": """你是一个温暖、友好的AI助手。用户向你打招呼,请用自然、亲切的方式回应。 + +用户输入:{{user_input}} +对话历史:{{memory.conversation_history}} + +请生成一个友好、自然的问候回复,长度控制在50字以内。直接输出回复内容,不要包含其他说明。""" + } + } + nodes.append(greeting_node) + + # ========== 7. 问题处理分支 ========== + question_node = { + "id": "llm-question", + "type": "llm", + "position": {"x": 1050, "y": 300}, + "data": { + "label": "问题回答", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.5", + "max_tokens": "2000", + "prompt": """你是一个知识渊博、乐于助人的AI助手。请回答用户的问题。 + +用户问题:{{user_input}} +对话历史:{{memory.conversation_history}} +意图分析:{{output}} + +请提供: +1. 直接、准确的答案 +2. 必要的解释和说明 +3. 如果问题不明确,友好地询问更多信息 + +请以自然、易懂的方式回答,长度控制在200字以内。直接输出回答内容。""" + } + } + nodes.append(question_node) + + # ========== 8. 情感处理分支 ========== + emotion_node = { + "id": "llm-emotion", + "type": "llm", + "position": {"x": 1050, "y": 400}, + "data": { + "label": "情感回应", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.8", + "max_tokens": "1000", + "prompt": """你是一个善解人意的AI助手。请根据用户的情感状态,给予适当的回应。 + +用户输入:{{user_input}} +情感状态:{{output.emotion}} +对话历史:{{memory.conversation_history}} + +请根据用户的情感: +- 如果是积极情感:给予鼓励和共鸣 +- 如果是消极情感:给予理解、安慰和支持 +- 如果是中性情感:给予关注和陪伴 + +请生成一个温暖、共情的回复,长度控制在150字以内。直接输出回复内容。""" + } + } + nodes.append(emotion_node) + + # ========== 9. 请求处理分支 ========== + request_node = { + "id": "llm-request", + "type": "llm", + "position": {"x": 1050, "y": 500}, + "data": { + "label": "请求处理", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.4", + "max_tokens": "1500", + "prompt": """你是一个专业的AI助手。用户提出了一个请求,请分析并回应。 + +用户请求:{{user_input}} +意图分析:{{output}} +对话历史:{{memory.conversation_history}} + +请: +1. 理解用户的请求内容 +2. 如果可以满足,说明如何满足 +3. 如果无法满足,友好地说明原因并提供替代方案 + +请以清晰、友好的方式回应,长度控制在200字以内。直接输出回复内容。""" + } + } + nodes.append(request_node) + + # ========== 10. 告别处理分支 ========== + goodbye_node = { + "id": "llm-goodbye", + "type": "llm", + "position": {"x": 1050, "y": 600}, + "data": { + "label": "告别回复", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.6", + "max_tokens": "300", + "prompt": """你是一个友好的AI助手。用户要结束对话,请给予温暖的告别。 + +用户输入:{{user_input}} +对话历史:{{memory.conversation_history}} + +请生成一个温暖、友好的告别回复,表达期待下次交流。长度控制在50字以内。直接输出回复内容。""" + } + } + nodes.append(goodbye_node) + + # ========== 11. 通用处理分支 ========== + general_node = { + "id": "llm-general", + "type": "llm", + "position": {"x": 1050, "y": 700}, + "data": { + "label": "通用回复", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.6", + "max_tokens": "1000", + "prompt": """你是一个友好、专业的AI助手。请回应用户的输入。 + +用户输入:{{user_input}} +对话历史:{{memory.conversation_history}} +意图分析:{{output}} + +请生成一个自然、有意义的回复,保持对话的连贯性。长度控制在150字以内。直接输出回复内容。""" + } + } + nodes.append(general_node) + + # ========== 12. Merge节点 - 合并所有分支结果 ========== + merge_response_node = { + "id": "merge-response", + "type": "merge", + "position": {"x": 1250, "y": 400}, + "data": { + "label": "合并回复", + "mode": "merge_first", + "strategy": "object" + } + } + nodes.append(merge_response_node) + + # ========== 13. 更新记忆节点 ========== + update_memory_node = { + "id": "cache-update", + "type": "cache", + "position": {"x": 1450, "y": 400}, + "data": { + "label": "更新记忆", + "operation": "set", + "key": "user_memory_{user_id}", + "value": '{"conversation_history": {{memory.conversation_history}} + [{"role": "user", "content": "{{user_input}}", "timestamp": "{{timestamp}}"}, {"role": "assistant", "content": "{{output}}", "timestamp": "{{timestamp}}"}], "user_profile": {{memory.user_profile}}, "context": {{memory.context}}}', + "ttl": 86400 + } + } + nodes.append(update_memory_node) + + # ========== 14. 格式化最终回复 ========== + format_response_node = { + "id": "llm-format", + "type": "llm", + "position": {"x": 1650, "y": 400}, + "data": { + "label": "格式化回复", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.3", + "max_tokens": "500", + "prompt": """请将以下回复内容格式化为最终输出。确保回复自然、流畅。 + +原始回复:{{output}} + +请直接输出格式化后的回复内容,不要包含其他说明或标记。如果原始回复已经是合适的格式,直接输出即可。""" + } + } + nodes.append(format_response_node) + + # ========== 15. 结束节点 ========== + end_node = { + "id": "end-1", + "type": "end", + "position": {"x": 1850, "y": 400}, + "data": { + "label": "结束", + "output_format": "text" + } + } + nodes.append(end_node) + + # ========== 连接边 ========== + # 开始 -> 查询记忆 + edges.append({ + "id": "e1", + "source": "start-1", + "target": "cache-query", + "sourceHandle": "right", + "targetHandle": "left" + }) + + # 查询记忆 -> 合并上下文 + edges.append({ + "id": "e2", + "source": "cache-query", + "target": "transform-merge", + "sourceHandle": "right", + "targetHandle": "left" + }) + + # 合并上下文 -> 意图理解 + edges.append({ + "id": "e3", + "source": "transform-merge", + "target": "llm-intent", + "sourceHandle": "right", + "targetHandle": "left" + }) + + # 意图理解 -> Switch路由 + edges.append({ + "id": "e4", + "source": "llm-intent", + "target": "switch-intent", + "sourceHandle": "right", + "targetHandle": "left" + }) + + # Switch -> 各分支处理节点 + edges.append({ + "id": "e5-greeting", + "source": "switch-intent", + "target": "llm-greeting", + "sourceHandle": "greeting-handle", + "targetHandle": "left" + }) + edges.append({ + "id": "e5-question", + "source": "switch-intent", + "target": "llm-question", + "sourceHandle": "question-handle", + "targetHandle": "left" + }) + edges.append({ + "id": "e5-emotion", + "source": "switch-intent", + "target": "llm-emotion", + "sourceHandle": "emotion-handle", + "targetHandle": "left" + }) + edges.append({ + "id": "e5-request", + "source": "switch-intent", + "target": "llm-request", + "sourceHandle": "request-handle", + "targetHandle": "left" + }) + edges.append({ + "id": "e5-goodbye", + "source": "switch-intent", + "target": "llm-goodbye", + "sourceHandle": "goodbye-handle", + "targetHandle": "left" + }) + edges.append({ + "id": "e5-general", + "source": "switch-intent", + "target": "llm-general", + "sourceHandle": "default", + "targetHandle": "left" + }) + + # 各分支 -> Merge节点 + edges.append({ + "id": "e6-greeting", + "source": "llm-greeting", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }) + edges.append({ + "id": "e6-question", + "source": "llm-question", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }) + edges.append({ + "id": "e6-emotion", + "source": "llm-emotion", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }) + edges.append({ + "id": "e6-request", + "source": "llm-request", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }) + edges.append({ + "id": "e6-goodbye", + "source": "llm-goodbye", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }) + edges.append({ + "id": "e6-general", + "source": "llm-general", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }) + + # Merge -> 更新记忆 + edges.append({ + "id": "e7", + "source": "merge-response", + "target": "cache-update", + "sourceHandle": "right", + "targetHandle": "left" + }) + + # 更新记忆 -> 格式化回复 + edges.append({ + "id": "e8", + "source": "cache-update", + "target": "llm-format", + "sourceHandle": "right", + "targetHandle": "left" + }) + + # 格式化回复 -> 结束 + edges.append({ + "id": "e9", + "source": "llm-format", + "target": "end-1", + "sourceHandle": "right", + "targetHandle": "left" + }) + + return { + "name": "智能聊天助手(完整示例)", + "description": """一个完整的聊天智能体示例,展示平台的核心能力: +- ✅ 记忆管理:使用缓存节点存储和查询对话历史 +- ✅ 意图识别:使用LLM节点分析用户意图 +- ✅ 多分支路由:使用Switch节点根据意图分发到不同处理分支 +- ✅ 上下文传递:使用Transform节点合并数据 +- ✅ 多轮对话:支持上下文记忆和连贯对话 +- ✅ 个性化回复:根据不同意图生成针对性回复 + +适用场景:情感陪聊、客服助手、智能问答等聊天场景。""", + "workflow_config": {"nodes": nodes, "edges": edges} + } + + +def main(): + """主函数:生成并保存Agent""" + db = SessionLocal() + try: + # 获取或创建测试用户 + user = db.query(User).filter(User.username == "admin").first() + if not user: + print("请先创建admin用户") + return + + # 生成Agent + agent_data = generate_chat_agent(db, user) + + # 检查是否已存在 + existing = db.query(Agent).filter( + Agent.name == agent_data["name"], + Agent.user_id == user.id + ).first() + + if existing: + print(f"Agent '{agent_data['name']}' 已存在,跳过创建") + return + + # 创建Agent + agent = Agent( + name=agent_data["name"], + description=agent_data["description"], + workflow_config=agent_data["workflow_config"], + user_id=user.id, + status="draft" + ) + db.add(agent) + db.commit() + db.refresh(agent) + + print(f"✅ 成功创建Agent: {agent.name} (ID: {agent.id})") + print(f" 节点数量: {len(agent_data['workflow_config']['nodes'])}") + print(f" 连接数量: {len(agent_data['workflow_config']['edges'])}") + print(f"\n📝 使用说明:") + print(f" 1. 在Agent管理页面找到 '{agent.name}'") + print(f" 2. 点击'设计'按钮进入工作流编辑器") + print(f" 3. 配置LLM节点的API密钥(如需要)") + print(f" 4. 点击'发布'按钮发布Agent") + print(f" 5. 点击'使用'按钮测试对话功能") + + except Exception as e: + print(f"❌ 创建Agent失败: {str(e)}") + import traceback + traceback.print_exc() + db.rollback() + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_nodes_all.py b/backend/tests/test_nodes_all.py new file mode 100644 index 0000000..61e4436 --- /dev/null +++ b/backend/tests/test_nodes_all.py @@ -0,0 +1,232 @@ +import asyncio +import pytest + +from app.services.workflow_engine import WorkflowEngine + + +def _engine_with(nodes, edges=None): + wf_data = {"nodes": nodes, "edges": edges or []} + return WorkflowEngine(workflow_id="wf_all", workflow_data=wf_data) + + +@pytest.mark.asyncio +async def test_switch_branch(): + node = { + "id": "sw1", + "type": "switch", + "data": {"field": "status", "cases": {"ok": "ok_handle"}, "default": "def"}, + } + engine = _engine_with([node]) + res = await engine.execute_node(node, {"status": "ok"}) + assert res["status"] == "success" + assert res["branch"] == "ok_handle" + + +@pytest.mark.asyncio +async def test_merge_array_strategy(): + node = {"id": "m1", "type": "merge", "data": {"strategy": "array"}} + engine = _engine_with([node]) + res = await engine.execute_node(node, {"a": 1, "b": 2}) + assert res["status"] == "success" + assert isinstance(res["output"], list) + assert len(res["output"]) == 2 + + +@pytest.mark.asyncio +async def test_wait_time_mode(): + node = { + "id": "w1", + "type": "wait", + "data": {"wait_type": "time", "wait_seconds": 0.01}, + } + engine = _engine_with([node]) + res = await engine.execute_node(node, {"ping": True}) + assert res["status"] == "success" + assert res["output"]["ping"] is True + + +@pytest.mark.asyncio +async def test_json_parse_and_extract(): + node = { + "id": "j1", + "type": "json", + "data": {"operation": "extract", "path": "$.data.value"}, + } + engine = _engine_with([node]) + res = await engine.execute_node(node, {"data": {"value": 42}}) + assert res["status"] == "success" + assert res["output"] == 42 + + +@pytest.mark.asyncio +async def test_text_split(): + node = { + "id": "t1", + "type": "text", + "data": {"operation": "split", "delimiter": ","}, + } + engine = _engine_with([node]) + res = await engine.execute_node(node, "a,b,c") + assert res["status"] == "success" + assert res["output"] == ["a", "b", "c"] + + +@pytest.mark.asyncio +async def test_cache_set_then_get(): + node_set = { + "id": "cset", + "type": "cache", + "data": {"operation": "set", "key": "k1", "ttl": 1}, + } + node_get = { + "id": "cget", + "type": "cache", + "data": {"operation": "get", "key": "k1", "ttl": 1}, + } + engine = _engine_with([node_set, node_get]) + await engine.execute_node(node_set, {"value": "v"}) + res_get = await engine.execute_node(node_get, {}) + assert res_get["status"] == "success" + assert res_get["output"] == "v" + assert res_get["cache_hit"] is True + + +@pytest.mark.asyncio +async def test_vector_db_upsert_search_delete(): + node = { + "id": "vec", + "type": "vector_db", + "data": {"operation": "upsert", "collection": "col"}, + } + engine = _engine_with([node]) + up = await engine.execute_node(node, {"embedding": [1.0, 0.0], "text": "hi"}) + assert up["status"] == "success" + + node_search = { + "id": "vecs", + "type": "vector_db", + "data": { + "operation": "search", + "collection": "col", + "query_vector": [1.0, 0.0], + "top_k": 1, + }, + } + res = await engine.execute_node(node_search, {}) + assert res["status"] == "success" + assert len(res["output"]) == 1 + + node_del = { + "id": "vecd", + "type": "vector_db", + "data": {"operation": "delete", "collection": "col"}, + } + del_res = await engine.execute_node(node_del, {}) + assert del_res["status"] == "success" + + +@pytest.mark.asyncio +async def test_log_basic(): + node = { + "id": "log1", + "type": "log", + "data": {"level": "info", "message": "hello {x}", "include_data": False}, + } + engine = _engine_with([node]) + res = await engine.execute_node(node, {"x": 1}) + assert res["status"] == "success" + assert res["log"]["message"].startswith("节点执行") or res["log"]["message"].startswith("hello") + + +@pytest.mark.asyncio +async def test_error_handler_notify(): + node = { + "id": "err1", + "type": "error_handler", + "data": {"on_error": "notify"}, + } + engine = _engine_with([node]) + res = await engine.execute_node(node, {"status": "failed", "error": "boom"}) + assert res["status"] == "error_handled" + assert res["error"] == "boom" + + +@pytest.mark.asyncio +async def test_csv_parse_and_generate(): + node_parse = { + "id": "csvp", + "type": "csv", + "data": {"operation": "parse", "delimiter": ",", "headers": True}, + } + engine = _engine_with([node_parse]) + csv_text = "a,b\n1,2\n" + res = await engine.execute_node(node_parse, csv_text) + assert res["status"] == "success" + assert res["output"][0]["a"] == "1" + + node_gen = { + "id": "csvg", + "type": "csv", + "data": {"operation": "generate", "delimiter": ",", "headers": True}, + } + res_gen = await engine.execute_node(node_gen, [{"a": 1, "b": 2}]) + assert res_gen["status"] == "success" + assert "a,b" in res_gen["output"] + + +@pytest.mark.asyncio +async def test_object_storage_upload_download(): + node_up = { + "id": "osup", + "type": "object_storage", + "data": { + "operation": "upload", + "provider": "s3", + "bucket": "bk", + "key": "file.txt", + }, + } + engine = _engine_with([node_up]) + res_up = await engine.execute_node(node_up, {"file": "data"}) + assert res_up["status"] == "success" + assert res_up["output"]["status"] == "uploaded" + + node_down = { + "id": "osdown", + "type": "object_storage", + "data": { + "operation": "download", + "provider": "s3", + "bucket": "bk", + "key": "file.txt", + }, + } + res_down = await engine.execute_node(node_down, {}) + assert res_down["status"] == "success" + assert res_down["output"]["status"] == "downloaded" + + +# 集成/外部依赖重的节点标记跳过,避免网络/编译/二进制依赖 +heavy_nodes = [ + "llm", + "agent", + "http", + "webhook", + "email", + "message_queue", + "database", + "file", + "pdf", + "image", + "excel", + "slack", + "dingtalk", + "wechat_work", + "sms", +] + + +@pytest.mark.skip(reason="重依赖/外部IO,保留集成测试") +@pytest.mark.asyncio +async def test_heavy_nodes_placeholder(): + assert True diff --git a/backend/tests/test_nodes_phase4.py b/backend/tests/test_nodes_phase4.py new file mode 100644 index 0000000..b5ad4c6 --- /dev/null +++ b/backend/tests/test_nodes_phase4.py @@ -0,0 +1,136 @@ +import pytest + +from app.services.workflow_engine import WorkflowEngine + + +def _make_engine_with_node(node): + """构造仅含单节点的工作流引擎""" + wf_data = {"nodes": [node], "edges": []} + return WorkflowEngine(workflow_id="wf_test", workflow_data=wf_data) + + +@pytest.mark.asyncio +async def test_subworkflow_mapping(): + node = { + "id": "sub-1", + "type": "subworkflow", + "data": { + "workflow_id": "child_wf", + "input_mapping": {"mapped": "source"}, + }, + } + engine = _make_engine_with_node(node) + result = await engine.execute_node(node, {"source": 123, "other": 1}) + assert result["status"] == "success" + assert result["output"]["workflow_id"] == "child_wf" + assert result["output"]["input"]["mapped"] == 123 + + +@pytest.mark.asyncio +async def test_code_python_success(): + node = { + "id": "code-1", + "type": "code", + "data": { + "language": "python", + "code": "result = input_data['x'] * 2", + }, + } + engine = _make_engine_with_node(node) + result = await engine.execute_node(node, {"x": 3}) + assert result["status"] == "success" + assert result["output"] == 6 + + +@pytest.mark.asyncio +async def test_code_unsupported_language(): + node = { + "id": "code-2", + "type": "code", + "data": {"language": "go", "code": "result = 1"}, + } + engine = _make_engine_with_node(node) + result = await engine.execute_node(node, {}) + assert result["status"] == "success" + assert "不支持的语言" in result["output"]["error"] + + +@pytest.mark.asyncio +async def test_oauth_mock_token(): + node = { + "id": "oauth-1", + "type": "oauth", + "data": {"provider": "google", "client_id": "id", "client_secret": "sec"}, + } + engine = _make_engine_with_node(node) + result = await engine.execute_node(node, {}) + assert result["status"] == "success" + token = result["output"] + assert token["access_token"].startswith("mock_access_token_google") + assert token["token_type"] == "Bearer" + + +@pytest.mark.asyncio +async def test_validator_reject_and_continue(): + # reject 分支 -> failed + node_reject = { + "id": "val-1", + "type": "validator", + "data": {"schema": {"type": "object"}, "on_error": "reject"}, + } + engine = _make_engine_with_node(node_reject) + res_reject = await engine.execute_node(node_reject, "bad_type") + assert res_reject["status"] == "failed" + + # continue 分支 -> success 且 warning + node_continue = { + "id": "val-2", + "type": "validator", + "data": {"schema": {"type": "object"}, "on_error": "continue"}, + } + engine = _make_engine_with_node(node_continue) + res_continue = await engine.execute_node(node_continue, "bad_type") + assert res_continue["status"] == "success" + assert "warning" in res_continue + + +@pytest.mark.asyncio +async def test_batch_split_group_aggregate(): + data = list(range(5)) + + # split + node_split = { + "id": "batch-1", + "type": "batch", + "data": {"batch_size": 2, "mode": "split"}, + } + engine = _make_engine_with_node(node_split) + res_split = await engine.execute_node(node_split, data) + assert res_split["status"] == "success" + assert res_split["output"][0] == [0, 1] + assert res_split["output"][1] == [2, 3] + assert res_split["output"][2] == [4] + + # group(同 split 逻辑) + node_group = { + "id": "batch-2", + "type": "batch", + "data": {"batch_size": 3, "mode": "group"}, + } + engine = _make_engine_with_node(node_group) + res_group = await engine.execute_node(node_group, data) + assert res_group["status"] == "success" + assert res_group["output"][0] == [0, 1, 2] + assert res_group["output"][1] == [3, 4] + + # aggregate + node_agg = { + "id": "batch-3", + "type": "batch", + "data": {"mode": "aggregate"}, + } + engine = _make_engine_with_node(node_agg) + res_agg = await engine.execute_node(node_agg, data) + assert res_agg["status"] == "success" + assert res_agg["output"]["count"] == 5 + assert res_agg["output"]["samples"][:2] == [0, 1] diff --git a/check_switch_logs.py b/check_switch_logs.py new file mode 100755 index 0000000..b0f8e55 --- /dev/null +++ b/check_switch_logs.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +查看Switch节点日志的专用脚本 +用于诊断Switch节点的分支过滤问题 +""" +import sys +import os +import json +from datetime import datetime + +# 添加项目路径 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'backend')) + +from app.core.database import SessionLocal +from app.models.execution import Execution +from app.models.execution_log import ExecutionLog + + +def format_json(data): + """格式化JSON数据""" + if isinstance(data, dict): + return json.dumps(data, ensure_ascii=False, indent=2) + return str(data) + + +def main(): + """主函数""" + db = SessionLocal() + + try: + # 获取最近的执行记录 + print("=" * 80) + print("查找最近的Agent执行记录...") + print("=" * 80) + + execution = db.query(Execution).filter( + Execution.agent_id.isnot(None) + ).order_by(Execution.created_at.desc()).first() + + if not execution: + print("❌ 没有找到执行记录") + return + + print(f"\n✅ 找到执行记录: {execution.id}") + print(f" 状态: {execution.status}") + print(f" 执行时间: {execution.execution_time}ms") + print(f" 创建时间: {execution.created_at}") + + # 获取执行日志 + print("\n" + "=" * 80) + print("Switch节点相关日志:") + print("=" * 80) + + logs = db.query(ExecutionLog).filter( + ExecutionLog.execution_id == execution.id + ).order_by(ExecutionLog.timestamp.asc()).all() + + if not logs: + print("❌ 没有找到执行日志") + return + + # 筛选Switch节点相关的日志 + switch_logs = [] + for log in logs: + if log.node_type == 'switch' or 'Switch' in log.message or '[rjb] Switch' in log.message: + switch_logs.append(log) + + if not switch_logs: + print("❌ 没有找到Switch节点相关的日志") + print("\n所有日志节点类型:") + node_types = set(log.node_type for log in logs if log.node_type) + for nt in sorted(node_types): + print(f" - {nt}") + return + + print(f"\n找到 {len(switch_logs)} 条Switch节点相关日志:\n") + + for i, log in enumerate(switch_logs, 1): + print(f"[{i}] {log.timestamp.strftime('%H:%M:%S.%f')[:-3]} [{log.level}]") + print(f" 节点: {log.node_id or '(无)'} ({log.node_type or '(无)'})") + print(f" 消息: {log.message}") + + if log.data: + print(f" 数据:") + data_str = format_json(log.data) + # 显示完整数据 + for line in data_str.split('\n'): + print(f" {line}") + + if log.duration: + print(f" 耗时: {log.duration}ms") + print() + + # 特别分析Switch节点的匹配和过滤过程 + print("=" * 80) + print("Switch节点执行流程分析:") + print("=" * 80) + + match_logs = [log for log in switch_logs if '匹配' in log.message] + filter_logs = [log for log in switch_logs if '过滤' in log.message] + + if match_logs: + print("\n📊 匹配阶段:") + for log in match_logs: + if log.data: + data = log.data + print(f" 节点 {log.node_id}:") + print(f" 字段: {data.get('field', 'N/A')}") + print(f" 字段值: {data.get('field_value', 'N/A')}") + print(f" 匹配的分支: {data.get('matched_case', 'N/A')}") + print(f" 处理后的输入键: {data.get('processed_input_keys', 'N/A')}") + + if filter_logs: + print("\n🔍 过滤阶段:") + for log in filter_logs: + if log.data: + data = log.data + print(f" 节点 {log.node_id}:") + print(f" 匹配的分支: {data.get('branch', 'N/A')}") + print(f" 过滤前边数: {data.get('edges_before', 'N/A')}") + print(f" 保留边数: {data.get('edges_kept', 'N/A')}") + print(f" 移除边数: {data.get('edges_removed', 'N/A')}") + + # 检查意图理解节点的输出 + print("\n" + "=" * 80) + print("意图理解节点输出分析:") + print("=" * 80) + + intent_logs = [log for log in logs if log.node_id and 'intent' in log.node_id.lower()] + if intent_logs: + for log in intent_logs: + if log.message == "节点执行完成" and log.data: + print(f"\n节点 {log.node_id} 的输出:") + output = log.data.get('output', {}) + print(format_json(output)) + else: + print("❌ 没有找到意图理解节点的日志") + + # 检查所有节点的输出(用于调试) + print("\n" + "=" * 80) + print("所有节点输出摘要:") + print("=" * 80) + + node_outputs = {} + for log in logs: + if log.message == "节点执行完成" and log.node_id: + node_outputs[log.node_id] = log.data.get('output', {}) + + for node_id, output in node_outputs.items(): + if isinstance(output, str) and len(output) > 100: + print(f"{node_id}: {output[:100]}...") + else: + print(f"{node_id}: {output}") + + except Exception as e: + print(f"❌ 错误: {str(e)}") + import traceback + traceback.print_exc() + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/debug_switch_node.py b/debug_switch_node.py new file mode 100644 index 0000000..68f2ec3 --- /dev/null +++ b/debug_switch_node.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +调试Switch节点的详细脚本 +""" +import sys +import os +import json + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'backend')) + +from app.core.database import SessionLocal +from app.models.execution import Execution +from app.models.agent import Agent + +def main(): + db = SessionLocal() + try: + # 获取最近的执行记录 + execution = db.query(Execution).filter( + Execution.agent_id.isnot(None) + ).order_by(Execution.created_at.desc()).first() + + if not execution: + print("❌ 没有找到执行记录") + return + + print(f"执行ID: {execution.id}") + print(f"状态: {execution.status}") + print() + + # 获取Agent配置 + agent = db.query(Agent).filter(Agent.id == execution.agent_id).first() + if not agent: + print("❌ 没有找到Agent") + return + + workflow_config = agent.workflow_config + nodes = workflow_config.get('nodes', []) + edges = workflow_config.get('edges', []) + + # 找到Switch节点 + switch_node = None + for node in nodes: + if node.get('type') == 'switch': + switch_node = node + break + + if not switch_node: + print("❌ 没有找到Switch节点") + return + + print("=" * 80) + print("Switch节点配置:") + print("=" * 80) + print(f"节点ID: {switch_node['id']}") + print(f"字段: {switch_node['data'].get('field')}") + print(f"Cases: {json.dumps(switch_node['data'].get('cases', {}), ensure_ascii=False, indent=2)}") + print(f"Default: {switch_node['data'].get('default')}") + print() + + # 找到从Switch节点出发的边 + print("=" * 80) + print("从Switch节点出发的边:") + print("=" * 80) + switch_edges = [e for e in edges if e.get('source') == switch_node['id']] + for edge in switch_edges: + print(f"边ID: {edge.get('id')}") + print(f" sourceHandle: {edge.get('sourceHandle')}") + print(f" target: {edge.get('target')}") + print() + + # 查看执行结果 + print("=" * 80) + print("执行结果中的节点输出:") + print("=" * 80) + if execution.output_data and 'node_results' in execution.output_data: + node_results = execution.output_data['node_results'] + if switch_node['id'] in node_results: + switch_result = node_results[switch_node['id']] + print(f"Switch节点输出: {json.dumps(switch_result, ensure_ascii=False, indent=2)}") + else: + print("❌ Switch节点没有输出结果") + else: + print("❌ 没有找到节点执行结果") + + # 检查哪些分支节点执行了 + print() + print("=" * 80) + print("执行了的分支节点:") + print("=" * 80) + if execution.output_data and 'node_results' in execution.output_data: + node_results = execution.output_data['node_results'] + for edge in switch_edges: + target_id = edge.get('target') + if target_id in node_results: + print(f"✅ {target_id} (sourceHandle: {edge.get('sourceHandle')})") + else: + print(f"❌ {target_id} (sourceHandle: {edge.get('sourceHandle')}) - 未执行") + + except Exception as e: + print(f"❌ 错误: {str(e)}") + import traceback + traceback.print_exc() + finally: + db.close() + +if __name__ == "__main__": + main() diff --git a/frontend/src/components/AgentChatPreview.vue b/frontend/src/components/AgentChatPreview.vue index 57b3085..8d00fb2 100644 --- a/frontend/src/components/AgentChatPreview.vue +++ b/frontend/src/components/AgentChatPreview.vue @@ -179,6 +179,7 @@ const inputMessage = ref('') const loading = ref(false) const messagesContainer = ref() let pollingInterval: any = null +let replyAdded = false // 标志位:防止重复添加回复 // 发送消息 const handleSendMessage = async () => { @@ -210,9 +211,17 @@ const handleSendMessage = async () => { const execution = response.data + // 重置标志位 + replyAdded = false + // 轮询执行状态 const checkStatus = async () => { try { + // 如果已经添加过回复,直接返回,避免重复添加 + if (replyAdded) { + return + } + // 获取详细执行状态(包含节点执行信息) const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`) const status = statusResponse.data @@ -225,6 +234,14 @@ const handleSendMessage = async () => { const exec = execResponse.data if (exec.status === 'completed') { + // 防止重复添加:如果已经添加过回复,直接返回 + if (replyAdded) { + return + } + + // 标记已添加回复 + replyAdded = true + // 提取Agent回复 let agentReply = '' if (exec.output_data) { @@ -268,6 +285,14 @@ const handleSendMessage = async () => { pollingInterval = null } } else if (exec.status === 'failed') { + // 防止重复添加:如果已经添加过回复,直接返回 + if (replyAdded) { + return + } + + // 标记已添加回复 + replyAdded = true + messages.value.push({ role: 'agent', content: `执行失败: ${exec.error_message || '未知错误'}`, @@ -290,6 +315,14 @@ const handleSendMessage = async () => { // 不需要做任何操作,等待下次轮询 } } catch (error: any) { + // 防止重复添加:如果已经添加过回复,直接返回 + if (replyAdded) { + return + } + + // 标记已添加回复 + replyAdded = true + messages.value.push({ role: 'agent', content: `获取执行结果失败: ${error.response?.data?.detail || error.message}`, diff --git a/frontend/src/components/WorkflowEditor/WorkflowEditor.vue b/frontend/src/components/WorkflowEditor/WorkflowEditor.vue index a77e2af..0250668 100644 --- a/frontend/src/components/WorkflowEditor/WorkflowEditor.vue +++ b/frontend/src/components/WorkflowEditor/WorkflowEditor.vue @@ -73,6 +73,18 @@ 应用模板 + + + 吸附 + + + + 泳道 + +
+ 泳道数 + +
@@ -117,16 +129,99 @@

节点类型

-
-
+ + + +
+
+ 展开全部 + 收起全部 +
+ + + +
+
+ + {{ nodeType.label }} +
+
+ +
+
+
+
+
+ + +
@@ -139,6 +234,7 @@ @dragover.prevent="handleDragOver" @dragenter.prevent="handleDragEnter" > +

节点配置

- - - - - - - - - - + + + + + + + + + + + + + + + + + + +
+
+ 快速模板 + + + + + + + + + + + + + + + + + + + + + + + + 套用 +
+
+ 变量插入 + + + + + + + + 插入 + +
+
+ @@ -699,9 +873,7 @@ - - - + @@ -1188,35 +1360,199 @@ - 保存配置 - 复制节点 - 删除节点 +
+ 保存配置 + 复制节点 + 删除节点 +
-
+
+ + + + + + + + + 默认30秒 + + + + + 失败时重试次数 + + + + + + + + + + + + + + + + + + + + + + + + + + 节点测试
+ + +
+ + + + + + + 保存 + + + + 应用 + + + + 删除 + + +
+
+ + +
+ + + + 从上游填充 + + + + 格式化 + + + + 清空 + + + + + + + + + + +
输入数据将作为节点的输入参数 + {{ testInputError }}
+ @@ -1224,25 +1560,67 @@ +
- - {{ nodeTestResult.status === 'success' ? '✓ 成功' : '✗ 失败' }} - - - {{ nodeTestResult.execution_time }}ms - +
+ + + + {{ nodeTestResult.status === 'success' ? '测试成功' : '测试失败' }} + + + + {{ nodeTestResult.execution_time }}ms + +
+
+ + + 复制 + + + + 导出 + +
- -
- - {{ nodeTestResult.error_message || '测试失败' }} + + +
+ +
{{ formattedTestOutput }}
+
+ + + +
+
+
+ + +
+ + 输出类型: {{ getOutputType(nodeTestResult.output) }} + + + 字段数: {{ Object.keys(nodeTestResult.output).length }} + + + 长度: {{ nodeTestResult.output.length }} 字符 +
@@ -1359,7 +1737,7 @@ import { Controls } from '@vue-flow/controls' import { MiniMap } from '@vue-flow/minimap' import type { Node, Edge, NodeClickEvent, EdgeClickEvent, Connection, Viewport } from '@vue-flow/core' import { ElMessage, ElMessageBox } from 'element-plus' -import { Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay, InfoFilled, WarningFilled, Rank, ArrowDown, Sort, Grid, Operation, Document, Search } from '@element-plus/icons-vue' +import { Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay, InfoFilled, WarningFilled, Rank, ArrowDown, Sort, Grid, Operation, Document, Search, Timer, Box, Edit, Picture, Download, Delete, CircleCheck, CircleClose, Aim } from '@element-plus/icons-vue' import { useWorkflowStore } from '@/stores/workflow' import api from '@/api' import type { WorkflowNode, WorkflowEdge } from '@/types' @@ -1388,6 +1766,9 @@ const draggedNodeType = ref(null) const testingNode = ref(false) const nodeDetailVisible = ref(false) const currentExecutionId = ref(null) +const snapEnabled = ref(true) +const showSwimlanes = ref(false) +const laneCount = ref(3) // 节点复制相关 const copiedNode = ref(null) @@ -1396,6 +1777,45 @@ const copiedNode = ref(null) const nodeTestInput = ref('{}') const nodeTestOutput = ref('') const nodeTestResult = ref(null) +const testInputTemplate = ref('') +const testInputError = ref('') +const selectedTestCaseId = ref('') +const nodeTestCases = ref>({}) +const testCaseStorageKey = computed(() => `workflow_node_test_cases_${props.workflowId || 'default'}`) +const currentNodeTestCases = computed(() => { + const id = selectedNode.value?.id + if (!id) return [] + return nodeTestCases.value[id] || [] +}) + +// 格式化测试输出 +const formattedTestOutput = computed(() => { + if (!nodeTestResult.value || nodeTestResult.value.status !== 'success') return '' + try { + const output = nodeTestResult.value.output + if (typeof output === 'string') { + // 尝试解析为JSON + try { + const parsed = JSON.parse(output) + return JSON.stringify(parsed, null, 2) + } catch { + return output + } + } else if (typeof output === 'object' && output !== null) { + return JSON.stringify(output, null, 2) + } else { + return String(output) + } + } catch { + return String(nodeTestResult.value.output) + } +}) + +// 检查是否有上游节点 +const hasUpstreamNodes = computed(() => { + if (!selectedNode.value) return false + return edges.value.some(e => e.target === selectedNode.value?.id) +}) // 节点模板相关 const nodeTemplates = ref([]) @@ -1688,7 +2108,8 @@ const { zoomIn: vueFlowZoomIn, zoomOut: vueFlowZoomOut, setViewport, - getViewport + getViewport, + fitView } = vueFlowInstance // 保存状态 @@ -1697,6 +2118,121 @@ const hasChanges = ref(false) const lastSavedData = ref('') const savingInterval = ref(null) +// 撤销/重做状态 +type WorkflowSnapshot = { + nodes: Node[] + edges: Edge[] + selectedNodeId: string | null + selectedEdgeId: string | null + viewport: Viewport | null +} +const history = ref([]) +const redoStack = ref([]) +const isRestoringHistory = ref(false) +const historyInitialized = ref(false) +const HISTORY_LIMIT = 50 + +const transientNodeDataKeys = ['executionStatus', 'executionClass', 'errorMessage', 'errorType'] +const sanitizeNodeData = (data: Record = {}) => { + const cloned = JSON.parse(JSON.stringify(data || {})) + transientNodeDataKeys.forEach(key => { + if (key in cloned) { + delete cloned[key] + } + }) + return cloned +} + +const snapshotEquals = (a: WorkflowSnapshot, b: WorkflowSnapshot) => { + const coreA = JSON.stringify({ nodes: a.nodes, edges: a.edges }) + const coreB = JSON.stringify({ nodes: b.nodes, edges: b.edges }) + return coreA === coreB && a.selectedNodeId === b.selectedNodeId && a.selectedEdgeId === b.selectedEdgeId +} + +const createSnapshot = (): WorkflowSnapshot => { + const viewport = typeof getViewport === 'function' ? getViewport() : null + const nodesSnapshot = nodes.value.map(node => { + const cloned = JSON.parse(JSON.stringify(node)) + cloned.data = sanitizeNodeData(node.data) + return cloned + }) + const edgesSnapshot = edges.value.map(edge => JSON.parse(JSON.stringify(edge))) + return { + nodes: nodesSnapshot, + edges: edgesSnapshot, + selectedNodeId: selectedNode.value?.id || null, + selectedEdgeId: selectedEdge.value?.id || null, + viewport: viewport ? { ...viewport } : null + } +} + +const resetHistory = () => { + history.value = [createSnapshot()] + redoStack.value = [] + historyInitialized.value = true +} + +const pushHistory = (reason = 'update', { markDirty = true, force = false } = {}) => { + if (isRestoringHistory.value) return + if (!historyInitialized.value && !force) return + const snapshot = createSnapshot() + const last = history.value[history.value.length - 1] + if (last && snapshotEquals(last, snapshot)) { + return + } + history.value.push(snapshot) + if (history.value.length > HISTORY_LIMIT) { + history.value.shift() + } + if (markDirty) { + hasChanges.value = true + } + redoStack.value = [] + console.debug('[WorkflowEditor] history push:', reason, 'size:', history.value.length) +} + +const restoreSnapshot = async (snapshot: WorkflowSnapshot) => { + isRestoringHistory.value = true + nodes.value = snapshot.nodes.map(n => ({ ...n, data: sanitizeNodeData(n.data) })) + edges.value = snapshot.edges.map(e => ({ ...e })) + selectedNode.value = snapshot.selectedNodeId ? nodes.value.find(n => n.id === snapshot.selectedNodeId) || null : null + selectedEdge.value = snapshot.selectedEdgeId ? edges.value.find(e => e.id === snapshot.selectedEdgeId) || null : null + if (snapshot.viewport && typeof setViewport === 'function') { + setViewport({ ...snapshot.viewport }, { duration: 0 }) + } + await nextTick() + checkChanges() + isRestoringHistory.value = false +} + +const undo = async () => { + if (!historyInitialized.value || history.value.length <= 1) { + ElMessage.info('没有可撤销的操作') + return + } + const current = history.value.pop() + const previous = history.value[history.value.length - 1] + if (!current || !previous) { + return + } + redoStack.value.push(current) + await restoreSnapshot(previous) +} + +const redo = async () => { + if (!historyInitialized.value || redoStack.value.length === 0) { + ElMessage.info('没有可重做的操作') + return + } + const snapshot = redoStack.value.pop() + if (!snapshot) return + history.value.push(snapshot) + if (history.value.length > HISTORY_LIMIT) { + history.value.shift() + } + await restoreSnapshot(snapshot) +} + // 缩放状态 const currentZoom = ref(1) @@ -1720,7 +2256,32 @@ const customNodeTypes = { llm: markRaw(LLMNode), template: markRaw(LLMNode), // template也使用LLM节点 condition: markRaw(ConditionNode), + switch: markRaw(DefaultNode), // Switch节点使用默认节点(支持多个输出handle) + merge: markRaw(DefaultNode), // Merge节点使用默认节点 + wait: markRaw(DefaultNode), // Wait节点使用默认节点 transform: markRaw(DefaultNode), // 转换节点使用默认节点 + json: markRaw(DefaultNode), // JSON处理节点使用默认节点 + text: markRaw(DefaultNode), // 文本处理节点使用默认节点 + cache: markRaw(DefaultNode), // 缓存节点使用默认节点 + vector_db: markRaw(DefaultNode), // 向量数据库节点使用默认节点 + log: markRaw(DefaultNode), // 日志节点使用默认节点 + error_handler: markRaw(DefaultNode), // 错误处理节点使用默认节点 + csv: markRaw(DefaultNode), // CSV处理节点使用默认节点 + object_storage: markRaw(DefaultNode), // 对象存储节点使用默认节点 + slack: markRaw(DefaultNode), // Slack节点使用默认节点 + dingtalk: markRaw(DefaultNode), // 钉钉节点使用默认节点 + dingding: markRaw(DefaultNode), // 钉钉节点别名 + wechat_work: markRaw(DefaultNode), // 企业微信节点使用默认节点 + wecom: markRaw(DefaultNode), // 企业微信节点别名 + sms: markRaw(DefaultNode), // 短信节点使用默认节点 + pdf: markRaw(DefaultNode), // PDF处理节点使用默认节点 + image: markRaw(DefaultNode), // 图像处理节点使用默认节点 + excel: markRaw(DefaultNode), // Excel处理节点使用默认节点 + subworkflow: markRaw(DefaultNode), // 子工作流节点使用默认节点 + code: markRaw(DefaultNode), // 代码执行节点使用默认节点 + oauth: markRaw(DefaultNode), // OAuth节点使用默认节点 + validator: markRaw(DefaultNode), // 数据验证节点使用默认节点 + batch: markRaw(DefaultNode), // 批处理节点使用默认节点 loop: markRaw(DefaultNode), // 循环节点使用默认节点 foreach: markRaw(DefaultNode), // foreach也使用默认节点 agent: markRaw(DefaultNode), // Agent节点使用默认节点 @@ -1741,25 +2302,419 @@ const customNodeTypes = { // 节点类型定义 const nodeTypes = [ - { type: 'start', label: '开始', icon: 'Play' }, - { type: 'input', label: '输入', icon: 'Upload' }, - { type: 'llm', label: 'LLM', icon: 'ChatDotRound' }, - { type: 'template', label: '模板', icon: 'Document' }, - { type: 'condition', label: '条件', icon: 'Switch' }, - { type: 'transform', label: '转换', icon: 'Refresh' }, - { type: 'loop', label: '循环', icon: 'RefreshRight' }, - { type: 'agent', label: 'Agent', icon: 'User' }, - { type: 'http', label: 'HTTP请求', icon: 'Connection' }, - { type: 'database', label: '数据库', icon: 'Connection' }, - { type: 'file', label: '文件操作', icon: 'Document' }, - { type: 'schedule', label: '定时任务', icon: 'Clock' }, - { type: 'webhook', label: 'Webhook', icon: 'Link' }, - { type: 'email', label: '邮件', icon: 'Message' }, - { type: 'message_queue', label: '消息队列', icon: 'Connection' }, - { type: 'output', label: '输出', icon: 'Download' }, - { type: 'end', label: '结束', icon: 'CircleCheck' } + { type: 'start', label: '开始', icon: 'Play', category: '基础' }, + { type: 'input', label: '输入', icon: 'Upload', category: '基础' }, + { type: 'llm', label: 'LLM', icon: 'ChatDotRound', category: 'AI' }, + { type: 'template', label: '模板', icon: 'Document', category: 'AI' }, + { type: 'condition', label: '条件', icon: 'Switch', category: '逻辑' }, + { type: 'switch', label: 'Switch', icon: 'Operation', category: '逻辑' }, + { type: 'merge', label: 'Merge', icon: 'Connection', category: '逻辑' }, + { type: 'wait', label: '等待', icon: 'Timer', category: '逻辑' }, + { type: 'transform', label: '转换', icon: 'Refresh', category: '数据' }, + { type: 'json', label: 'JSON处理', icon: 'Document', category: '数据' }, + { type: 'text', label: '文本处理', icon: 'Edit', category: '数据' }, + { type: 'cache', label: '缓存', icon: 'Box', category: '数据' }, + { type: 'vector_db', label: '向量数据库', icon: 'Connection', category: 'AI' }, + { type: 'log', label: '日志', icon: 'Document', category: '系统' }, + { type: 'error_handler', label: '错误处理', icon: 'Warning', category: '逻辑' }, + { type: 'csv', label: 'CSV处理', icon: 'Document', category: '数据' }, + { type: 'object_storage', label: '对象存储', icon: 'Box', category: '数据' }, + { type: 'pdf', label: 'PDF处理', icon: 'Document', category: '数据' }, + { type: 'image', label: '图像处理', icon: 'Picture', category: '数据' }, + { type: 'excel', label: 'Excel处理', icon: 'Document', category: '数据' }, + { type: 'subworkflow', label: '子工作流', icon: 'Operation', category: '逻辑' }, + { type: 'code', label: '代码执行', icon: 'Edit', category: '逻辑' }, + { type: 'oauth', label: 'OAuth', icon: 'Document', category: '网络' }, + { type: 'validator', label: '数据验证', icon: 'Document', category: '数据' }, + { type: 'batch', label: '批处理', icon: 'Grid', category: '逻辑' }, + { type: 'loop', label: '循环', icon: 'RefreshRight', category: '逻辑' }, + { type: 'agent', label: 'Agent', icon: 'User', category: 'AI' }, + { type: 'http', label: 'HTTP请求', icon: 'Connection', category: '网络' }, + { type: 'database', label: '数据库', icon: 'Connection', category: '数据' }, + { type: 'file', label: '文件操作', icon: 'Document', category: '数据' }, + { type: 'schedule', label: '定时任务', icon: 'Clock', category: '系统' }, + { type: 'webhook', label: 'Webhook', icon: 'Link', category: '网络' }, + { type: 'email', label: '邮件', icon: 'Message', category: '通信' }, + { type: 'slack', label: 'Slack', icon: 'Message', category: '通信' }, + { type: 'dingtalk', label: '钉钉', icon: 'Message', category: '通信' }, + { type: 'wechat_work', label: '企业微信', icon: 'Message', category: '通信' }, + { type: 'sms', label: '短信', icon: 'Message', category: '通信' }, + { type: 'message_queue', label: '消息队列', icon: 'Connection', category: '通信' }, + { type: 'output', label: '输出', icon: 'Download', category: '基础' }, + { type: 'end', label: '结束', icon: 'CircleCheck', category: '基础' } ] +// 节点搜索和筛选相关 +const nodeSearchKeyword = ref('') +const selectedCategory = ref('') +const canvasNodeSearchKeyword = ref('') + +// 节点分类 +const nodeCategories = computed(() => { + const categories = new Set(nodeTypes.map(nt => nt.category)) + return Array.from(categories).map(cat => ({ + value: cat, + label: cat + })) +}) + +// 折叠控制 +const toolboxActiveNames = ref([]) +watch(nodeCategories, (cats) => { + toolboxActiveNames.value = cats.map(c => c.value) +}, { immediate: true }) + +// 分组节点(含搜索与可选分类过滤) +const groupedNodeTypes = computed(() => { + const keyword = nodeSearchKeyword.value?.toLowerCase() || '' + const categoryFilter = selectedCategory.value || '' + + return nodeCategories.value + .filter(cat => !categoryFilter || cat.value === categoryFilter) + .map(cat => { + let items = nodeTypes.filter(nt => nt.category === cat.value) + if (keyword) { + items = items.filter(nt => + nt.label.toLowerCase().includes(keyword) || + nt.type.toLowerCase().includes(keyword) || + (nt.category && nt.category.toLowerCase().includes(keyword)) + ) + } + return { category: cat.value, label: cat.label, items } + }) +}) + +// 过滤画布节点 +const filteredCanvasNodes = computed(() => { + if (!canvasNodeSearchKeyword.value) { + return nodes.value + } + + const keyword = canvasNodeSearchKeyword.value.toLowerCase() + return nodes.value.filter(node => + (node.data?.label || '').toLowerCase().includes(keyword) || + node.id.toLowerCase().includes(keyword) || + node.type.toLowerCase().includes(keyword) + ) +}) + +// 获取节点图标 +const getNodeIcon = (nodeType: string) => { + const nodeTypeDef = nodeTypes.find(nt => nt.type === nodeType) + return nodeTypeDef?.icon || 'Document' +} + +// 折叠控制 +const handleExpandAllGroups = () => { + toolboxActiveNames.value = nodeCategories.value.map(c => c.value) +} +const handleCollapseAllGroups = () => { + toolboxActiveNames.value = [] +} + +// 配置标签页 +const configActiveTab = ref('basic') + +// 可用变量与字符串字段(用于插入占位符) +const availableVariables = computed(() => { + // 从上游节点输出动态推断可用变量 + if (!selectedNode.value) return ['input', 'text', 'route', 'USER_INPUT'] + + const vars = new Set(['input', 'text', 'route', 'USER_INPUT', 'query', 'message', 'content']) + + // 查找上游节点 + const upstreamEdges = edges.value.filter(e => e.target === selectedNode.value?.id) + upstreamEdges.forEach(edge => { + const upstreamNode = nodes.value.find(n => n.id === edge.source) + if (upstreamNode) { + // 根据上游节点类型推断可能的输出字段 + const nodeType = upstreamNode.type + if (nodeType === 'llm' || nodeType === 'template') { + vars.add('output') + vars.add('response') + } else if (nodeType === 'http') { + vars.add('response') + vars.add('data') + vars.add('body') + } else if (nodeType === 'json') { + vars.add('data') + vars.add('result') + } else if (nodeType === 'transform') { + vars.add('result') + // 从mapping中提取目标字段 + const mapping = upstreamNode.data?.mapping || {} + Object.keys(mapping).forEach(key => vars.add(key)) + } else if (nodeType === 'code') { + vars.add('result') + vars.add('output') + } else if (nodeType === 'vector_db') { + vars.add('results') + vars.add('similarity') + } else if (nodeType === 'cache') { + vars.add('value') + vars.add('cached') + } + + // 从上游节点的data中提取可能的输出字段 + const upstreamData = upstreamNode.data || {} + Object.keys(upstreamData).forEach(key => { + if (typeof upstreamData[key] === 'string' && key !== 'label') { + vars.add(key) + } + }) + } + }) + + return Array.from(vars).sort() +}) + +const laneOverlayStyle = computed(() => { + if (!showSwimlanes.value) return {} + const count = Math.max(1, laneCount.value || 1) + return { + backgroundImage: 'linear-gradient(90deg, rgba(64,158,255,0.06) 0, rgba(64,158,255,0.06) 50%, transparent 50%, transparent 100%)', + backgroundSize: `${100 / count}% 100%`, + } +}) + +const variableInsertField = ref('') +const variableToInsert = ref('') +const availableStringFields = computed(() => { + const data = selectedNode.value?.data || {} + return Object.keys(data).filter(k => typeof (data as any)[k] === 'string') +}) + +// 判断节点是否有超时配置 +const hasTimeoutConfig = computed(() => { + const nodeType = selectedNode.value?.type + return ['http', 'webhook', 'database', 'file', 'object_storage', 'slack', 'dingtalk', 'wechat_work', 'sms'].includes(nodeType || '') +}) + +// 判断节点是否有重试配置 +const hasRetryConfig = computed(() => { + const nodeType = selectedNode.value?.type + return ['http', 'webhook', 'database', 'file', 'object_storage', 'llm', 'template'].includes(nodeType || '') +}) + +const insertVariable = (field: string, variable: string) => { + if (!selectedNode.value || !field || !variable) return + const val = selectedNode.value.data?.[field] + const newVal = (val || '') + `{${variable}}` + selectedNode.value.data[field] = newVal +} + +// 快速模板 +const templateSelection = ref('') +const applyTemplate = () => { + if (!selectedNode.value) return + const t = selectedNode.value.type + if (!templateSelection.value) return + // HTTP模板 + if (t === 'http') { + if (templateSelection.value === 'http_get') { + selectedNode.value.data.method = 'GET' + selectedNode.value.data.url = 'https://httpbin.org/get' + selectedNode.value.data.headers = '{}' + selectedNode.value.data.timeout = 10 + } else if (templateSelection.value === 'http_post_json') { + selectedNode.value.data.method = 'POST' + selectedNode.value.data.url = 'https://httpbin.org/post' + selectedNode.value.data.headers = '{ "Content-Type": "application/json" }' + selectedNode.value.data.body = '{ "text": "{text}" }' + selectedNode.value.data.timeout = 10 + } + } + // LLM模板 + if (t === 'llm') { + if (templateSelection.value === 'llm_summary') { + selectedNode.value.data.provider = selectedNode.value.data.provider || 'deepseek' + selectedNode.value.data.model = selectedNode.value.data.model || 'deepseek-chat' + selectedNode.value.data.prompt = '请总结以下内容,100字以内:{text}' + selectedNode.value.data.temperature = 0.5 + } else if (templateSelection.value === 'llm_translate') { + selectedNode.value.data.prompt = '请把下列内容翻译成英文:{text}' + selectedNode.value.data.temperature = 0.3 + } + } + // OAuth模板 + if (t === 'oauth') { + if (templateSelection.value === 'oauth_google') { + selectedNode.value.data.provider = 'google' + selectedNode.value.data.scopes = ['openid', 'profile', 'email'] + } else if (templateSelection.value === 'oauth_github') { + selectedNode.value.data.provider = 'github' + selectedNode.value.data.scopes = ['read:user', 'user:email'] + } + } + // JSON处理模板 + if (t === 'json') { + if (templateSelection.value === 'json_parse') { + selectedNode.value.data.operation = 'parse' + selectedNode.value.data.path = '' + } else if (templateSelection.value === 'json_extract') { + selectedNode.value.data.operation = 'extract' + selectedNode.value.data.path = '$.data.items[*]' + } + } + // 文本处理模板 + if (t === 'text') { + if (templateSelection.value === 'text_split') { + selectedNode.value.data.operation = 'split' + selectedNode.value.data.delimiter = '\n' + } else if (templateSelection.value === 'text_format') { + selectedNode.value.data.operation = 'format' + selectedNode.value.data.template = 'Hello {name}, your value is {value}' + } + } + // 缓存模板 + if (t === 'cache') { + if (templateSelection.value === 'cache_get') { + selectedNode.value.data.operation = 'get' + selectedNode.value.data.key = 'cache_{key}' + selectedNode.value.data.ttl = 3600 + } else if (templateSelection.value === 'cache_set') { + selectedNode.value.data.operation = 'set' + selectedNode.value.data.key = 'cache_{key}' + selectedNode.value.data.ttl = 3600 + } + } + // 向量数据库模板 + if (t === 'vector_db') { + if (templateSelection.value === 'vector_search') { + selectedNode.value.data.operation = 'search' + selectedNode.value.data.collection = 'documents' + selectedNode.value.data.top_k = 5 + } else if (templateSelection.value === 'vector_upsert') { + selectedNode.value.data.operation = 'upsert' + selectedNode.value.data.collection = 'documents' + } + } + // HTTP扩展模板 + if (t === 'http') { + if (templateSelection.value === 'http_put') { + selectedNode.value.data.method = 'PUT' + selectedNode.value.data.url = 'https://httpbin.org/put' + selectedNode.value.data.headers = '{ "Content-Type": "application/json" }' + selectedNode.value.data.body = '{ "id": "{id}", "data": "{data}" }' + selectedNode.value.data.timeout = 10 + } else if (templateSelection.value === 'http_delete') { + selectedNode.value.data.method = 'DELETE' + selectedNode.value.data.url = 'https://httpbin.org/delete' + selectedNode.value.data.headers = '{}' + selectedNode.value.data.timeout = 10 + } + } + // LLM扩展模板 + if (t === 'llm') { + if (templateSelection.value === 'llm_extract') { + selectedNode.value.data.provider = selectedNode.value.data.provider || 'deepseek' + selectedNode.value.data.model = selectedNode.value.data.model || 'deepseek-chat' + selectedNode.value.data.prompt = '请从以下文本中提取关键信息(JSON格式):{text}' + selectedNode.value.data.temperature = 0.3 + } else if (templateSelection.value === 'llm_classify') { + selectedNode.value.data.provider = selectedNode.value.data.provider || 'deepseek' + selectedNode.value.data.model = selectedNode.value.data.model || 'deepseek-chat' + selectedNode.value.data.prompt = '请将以下内容分类为:正面/中性/负面。内容:{text}' + selectedNode.value.data.temperature = 0.2 + } + } + // 通信节点模板 + if (t === 'slack' && templateSelection.value === 'slack_send') { + selectedNode.value.data.operation = 'send_message' + selectedNode.value.data.channel = '#general' + selectedNode.value.data.message = '通知:{message}' + } + if (t === 'dingtalk' && templateSelection.value === 'dingtalk_send') { + selectedNode.value.data.operation = 'send_message' + selectedNode.value.data.message = '通知:{message}' + } + if (t === 'wechat_work' && templateSelection.value === 'wechat_send') { + selectedNode.value.data.operation = 'send_message' + selectedNode.value.data.message = '通知:{message}' + } + + templateSelection.value = '' + ElMessage.success('模板已应用') +} + +// 定位到节点 +const handleFocusNode = (nodeId: string) => { + const node = nodes.value.find(n => n.id === nodeId) + if (!node) return + + // 选中节点 + selectedNode.value = node + + // 定位到节点(居中显示) + const viewport = getViewport() + if (viewport) { + const nodeX = node.position.x + const nodeY = node.position.y + + // 计算视口中心位置 + const canvasElement = document.querySelector('.editor-canvas') + if (canvasElement) { + const rect = canvasElement.getBoundingClientRect() + const centerX = rect.width / 2 + const centerY = rect.height / 2 + + // 设置视口,使节点居中 + setViewport({ + x: centerX - nodeX * viewport.zoom, + y: centerY - nodeY * viewport.zoom, + zoom: viewport.zoom + }, { duration: 300 }) + } + } + + ElMessage.success(`已定位到节点: ${node.data?.label || node.id}`) +} + +// 定位所有节点(适应视图) +const handleFocusAllNodes = () => { + if (nodes.value.length === 0) { + ElMessage.warning('画布中没有节点') + return + } + + // 使用Vue Flow的fitView功能 + if (fitView) { + fitView({ padding: 0.2, duration: 300 }) + ElMessage.success('已定位所有节点') + } else { + // 手动计算并设置视口 + const minX = Math.min(...nodes.value.map(n => n.position.x)) + const maxX = Math.max(...nodes.value.map(n => n.position.x)) + const minY = Math.min(...nodes.value.map(n => n.position.y)) + const maxY = Math.max(...nodes.value.map(n => n.position.y)) + + const centerX = (minX + maxX) / 2 + const centerY = (minY + maxY) / 2 + const width = maxX - minX + const height = maxY - minY + + const canvasElement = document.querySelector('.editor-canvas') + if (canvasElement) { + const rect = canvasElement.getBoundingClientRect() + const canvasWidth = rect.width + const canvasHeight = rect.height + + // 计算合适的缩放比例 + const scaleX = canvasWidth / (width + 200) + const scaleY = canvasHeight / (height + 200) + const zoom = Math.min(scaleX, scaleY, 1.2) + + setViewport({ + x: canvasWidth / 2 - centerX * zoom, + y: canvasHeight / 2 - centerY * zoom, + zoom + }, { duration: 300 }) + + ElMessage.success('已定位所有节点') + } + } +} + // 拖拽开始 const handleDragStart = (event: DragEvent, nodeType: any) => { @@ -1910,6 +2865,163 @@ const handleDrop = (event: DragEvent) => { body_type: 'text', attachments: [] } : {}), + // Switch节点默认配置 + ...(nodeType === 'switch' ? { + field: 'status', + cases: {}, + default: 'default' + } : {}), + // Merge节点默认配置 + ...(nodeType === 'merge' ? { + mode: 'merge_all', + strategy: 'array' + } : {}), + // Wait节点默认配置 + ...(nodeType === 'wait' ? { + wait_type: 'condition', + condition: '', + timeout: 300, + poll_interval: 5 + } : {}), + // JSON处理节点默认配置 + ...(nodeType === 'json' ? { + operation: 'parse', + path: '', + schema: {} + } : {}), + // 文本处理节点默认配置 + ...(nodeType === 'text' ? { + operation: 'split', + delimiter: '\n', + regex: '', + template: '' + } : {}), + // 缓存节点默认配置 + ...(nodeType === 'cache' ? { + operation: 'get', + key: '', + ttl: 3600, + backend: 'memory' + } : {}), + // 向量数据库节点默认配置 + ...(nodeType === 'vector_db' ? { + operation: 'search', + collection: 'default', + query_vector: '', + top_k: 5 + } : {}), + // 日志节点默认配置 + ...(nodeType === 'log' ? { + level: 'info', + message: '节点执行', + include_data: true + } : {}), + // 错误处理节点默认配置 + ...(nodeType === 'error_handler' ? { + retry_count: 3, + retry_delay: 1000, + on_error: 'notify', + error_handler_workflow: '' + } : {}), + // CSV处理节点默认配置 + ...(nodeType === 'csv' ? { + operation: 'parse', + delimiter: ',', + headers: true, + encoding: 'utf-8' + } : {}), + // 对象存储节点默认配置 + ...(nodeType === 'object_storage' ? { + provider: 's3', + operation: 'upload', + bucket: '', + key: '', + file: '' + } : {}), + // Slack节点默认配置 + ...(nodeType === 'slack' ? { + operation: 'send_message', + token: '', + channel: '', + message: '', + attachments: [] + } : {}), + // 钉钉节点默认配置 + ...(nodeType === 'dingtalk' || nodeType === 'dingding' ? { + operation: 'send_message', + webhook_url: '', + access_token: '', + chat_id: '', + message: '' + } : {}), + // 企业微信节点默认配置 + ...(nodeType === 'wechat_work' || nodeType === 'wecom' ? { + operation: 'send_message', + corp_id: '', + corp_secret: '', + agent_id: '', + chat_id: '', + message: '' + } : {}), + // 短信节点默认配置 + ...(nodeType === 'sms' ? { + provider: 'aliyun', + operation: 'send', + phone: '', + template: '', + sign: '', + access_key: '', + access_secret: '' + } : {}), + // PDF处理节点默认配置 + ...(nodeType === 'pdf' ? { + operation: 'extract_text', + pages: '', + template: '' + } : {}), + // 图像处理节点默认配置 + ...(nodeType === 'image' ? { + operation: 'resize', + width: 800, + height: 600, + format: 'png' + } : {}), + // Excel处理节点默认配置 + ...(nodeType === 'excel' ? { + operation: 'read', + sheet: 'Sheet1', + range: '', + format: 'xlsx' + } : {}), + // 子工作流节点默认配置 + ...(nodeType === 'subworkflow' ? { + workflow_id: '', + input_mapping: {} + } : {}), + // 代码执行节点默认配置 + ...(nodeType === 'code' ? { + language: 'python', + code: "result = input_data", + timeout: 30 + } : {}), + // OAuth节点默认配置 + ...(nodeType === 'oauth' ? { + provider: 'google', + client_id: '', + client_secret: '', + scopes: [] + } : {}), + // 数据验证节点默认配置 + ...(nodeType === 'validator' ? { + schema: {}, + on_error: 'reject' + } : {}), + // 批处理节点默认配置 + ...(nodeType === 'batch' ? { + batch_size: 100, + mode: 'split', + wait_for_completion: true + } : {}), // 结束节点默认配置 ...(nodeType === 'end' || nodeType === 'output' ? { output_format: 'text' // 默认纯文本格式,适合对话场景 @@ -1933,6 +3045,8 @@ const handleDrop = (event: DragEvent) => { console.log('添加节点:', newNode) addNodes([newNode]) + pushHistory('add node') + hasChanges.value = true draggedNodeType.value = null } @@ -1952,6 +3066,7 @@ const onNodeClick = (event: NodeClickEvent) => { nodeTestInput.value = getDefaultTestInput(event.node.type) nodeTestOutput.value = '' nodeTestResult.value = null + selectedTestCaseId.value = '' } // 节点双击 - 显示执行详情 @@ -1993,18 +3108,333 @@ const delayAlertTitle = computed(() => { const getDefaultTestInput = (nodeType: string): string => { const defaults: Record = { 'start': {}, - 'llm': { input: '测试输入', query: '测试查询' }, - 'condition': { value: 10, threshold: 5 }, - 'transform': { data: { name: '测试', value: 100 } }, - 'end': { result: '测试结果' }, - 'input': { text: '测试文本' }, - 'output': { message: '测试消息' } + 'input': { text: '测试文本', input: '测试输入' }, + 'llm': { input: '请总结以下内容', text: '这是一段需要处理的文本内容...' }, + 'template': { input: '请处理用户请求', context: '相关上下文' }, + 'condition': { value: 10, threshold: 5, status: 'active' }, + 'switch': { status: 'success', route: 'default', value: 'test' }, + 'merge': { branch1: { data: 'data1' }, branch2: { data: 'data2' } }, + 'wait': { condition: 'ready', status: 'pending' }, + 'transform': { data: { name: '测试', value: 100 }, items: [{ id: 1, name: 'item1' }] }, + 'json': { json: '{"data": {"items": [{"id": 1}]}}', path: '$.data.items[*]' }, + 'text': { text: '这是需要处理的文本\n包含多行内容', delimiter: '\n' }, + 'cache': { key: 'test_key', value: 'test_value' }, + 'vector_db': { query: '搜索查询', query_vector: '[0.1, 0.2, 0.3]' }, + 'http': { url: 'https://api.example.com/data', method: 'GET', params: { page: 1 } }, + 'database': { query: 'SELECT * FROM table', params: {} }, + 'file': { file_path: '/path/to/file.txt', content: '文件内容' }, + 'webhook': { method: 'POST', body: { event: 'test' } }, + 'email': { to: 'test@example.com', subject: '测试邮件', body: '邮件内容' }, + 'slack': { channel: '#general', message: '测试消息' }, + 'dingtalk': { message: '测试消息', chat_id: 'chat123' }, + 'wechat_work': { message: '测试消息', chat_id: 'chat123' }, + 'sms': { phone: '13800138000', template: '验证码:{code}', code: '123456' }, + 'log': { message: '测试日志', level: 'info' }, + 'error_handler': { error: { message: '测试错误', code: 'TEST_ERROR' } }, + 'csv': { csv_data: 'name,age\nJohn,30\nJane,25', delimiter: ',' }, + 'object_storage': { bucket: 'my-bucket', key: 'files/test.txt', file: 'base64data' }, + 'pdf': { pdf_data: 'base64encodedpdf', pages: '1-10' }, + 'image': { image_data: 'base64encodedimage', width: 800, height: 600 }, + 'excel': { excel_data: 'base64encodedexcel', sheet: 'Sheet1' }, + 'subworkflow': { workflow_id: 'workflow123', input_mapping: { param1: 'value1' } }, + 'code': { input_data: { value: 10 }, code: 'return input_data["value"] * 2' }, + 'oauth': { provider: 'google', scopes: ['openid', 'profile'] }, + 'validator': { data: { name: 'test', age: 25 }, schema: {} }, + 'batch': { items: [{ id: 1 }, { id: 2 }, { id: 3 }], batch_size: 2 }, + 'end': { result: '测试结果', output: '最终输出' }, + 'output': { message: '测试消息', data: { test: 'value' } } } const defaultData = defaults[nodeType] || { input: '测试输入', data: { test: 'value' } } return JSON.stringify(defaultData, null, 2) } +// 从上游节点填充测试数据 +const fillFromUpstream = () => { + if (!selectedNode.value) return + + const upstreamEdges = edges.value.filter(e => e.target === selectedNode.value?.id) + if (upstreamEdges.length === 0) { + ElMessage.warning('当前节点没有上游节点') + return + } + + // 获取第一个上游节点的示例输出 + const upstreamNode = nodes.value.find(n => n.id === upstreamEdges[0].source) + if (!upstreamNode) return + + // 根据上游节点类型生成示例数据 + const exampleData = generateExampleDataForNode(upstreamNode) + nodeTestInput.value = JSON.stringify(exampleData, null, 2) + testInputError.value = '' + ElMessage.success('已从上游节点填充测试数据') +} + +// 根据节点类型生成示例数据 +const generateExampleDataForNode = (node: Node): any => { + const nodeType = node.type + const nodeData = node.data || {} + + switch (nodeType) { + case 'llm': + case 'template': + return { + output: '这是LLM生成的回复内容', + response: '这是完整的响应', + tokens: 100 + } + case 'http': + return { + response: { + status: 200, + data: { result: 'success', items: [{ id: 1, name: '测试' }] }, + headers: {} + }, + body: { result: 'success' } + } + case 'json': + return { + data: { items: [{ id: 1, value: 'test' }], total: 1 }, + result: { extracted: 'value' } + } + case 'transform': + return { + result: nodeData.mapping ? Object.keys(nodeData.mapping).reduce((acc, key) => { + acc[key] = `示例值_${key}` + return acc + }, {} as any) : { transformed: 'data' } + } + case 'code': + return { + output: { result: '代码执行结果' }, + result: '处理后的数据' + } + case 'vector_db': + return { + results: [ + { id: '1', text: '相关文档1', similarity: 0.95 }, + { id: '2', text: '相关文档2', similarity: 0.88 } + ], + similarity: 0.95 + } + case 'cache': + return { + value: '缓存的值', + cached: true + } + case 'switch': + return { + status: 'success', + route: 'default' + } + case 'merge': + return { + merged: [{ branch1: 'data1' }, { branch2: 'data2' }] + } + default: + return { + output: '节点输出', + data: { test: 'value' } + } + } +} + +// 格式化测试输入 +const formatTestInput = () => { + try { + const parsed = JSON.parse(nodeTestInput.value || '{}') + nodeTestInput.value = JSON.stringify(parsed, null, 2) + testInputError.value = '' + ElMessage.success('格式化成功') + } catch (error: any) { + testInputError.value = 'JSON格式错误: ' + error.message + ElMessage.error('JSON格式错误,无法格式化') + } +} + +// 清空测试输入 +const clearTestInput = () => { + nodeTestInput.value = '{}' + testInputError.value = '' +} + +// 应用测试输入模板 +const applyTestInputTemplate = (template: string) => { + if (!selectedNode.value) return + + let templateData: any = {} + + switch (template) { + case 'empty': + templateData = {} + break + case 'basic': + templateData = { input: '测试输入' } + break + case 'full': + templateData = { + input: '测试输入', + query: '测试查询', + data: { test: 'value', number: 123 }, + options: { enabled: true } + } + break + case 'llm': + templateData = { + input: '请总结以下内容', + text: '这是一段需要总结的长文本内容...', + context: '相关上下文信息' + } + break + case 'http': + templateData = { + url: 'https://api.example.com/data', + method: 'GET', + params: { page: 1, limit: 10 } + } + break + case 'json': + templateData = { + json: '{"data": {"items": [{"id": 1, "name": "测试"}]}}', + path: '$.data.items[*]' + } + break + } + + nodeTestInput.value = JSON.stringify(templateData, null, 2) + testInputTemplate.value = '' + testInputError.value = '' +} + +// 复制测试结果 +const copyTestResult = () => { + if (!nodeTestResult.value || nodeTestResult.value.status !== 'success') return + + const text = formattedTestOutput.value || String(nodeTestResult.value.output) + navigator.clipboard.writeText(text).then(() => { + ElMessage.success('已复制到剪贴板') + }).catch(() => { + ElMessage.error('复制失败') + }) +} + +// 导出测试结果 +const downloadTestResult = () => { + if (!nodeTestResult.value || nodeTestResult.value.status !== 'success') return + + const data = { + node_id: selectedNode.value?.id, + node_type: selectedNode.value?.type, + node_name: selectedNode.value?.data?.label, + test_input: JSON.parse(nodeTestInput.value || '{}'), + test_output: nodeTestResult.value.output, + execution_time: nodeTestResult.value.execution_time, + timestamp: new Date().toISOString() + } + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `test_result_${selectedNode.value?.id}_${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) + ElMessage.success('测试结果已导出') +} + +// 获取输出类型 +const getOutputType = (output: any): string => { + if (output === null) return 'null' + if (Array.isArray(output)) return 'array' + if (typeof output === 'object') return 'object' + return typeof output +} + +// 验证测试输入 +watch(nodeTestInput, (value) => { + if (!value || value.trim() === '') { + testInputError.value = '' + return + } + + try { + JSON.parse(value) + testInputError.value = '' + } catch (error: any) { + testInputError.value = 'JSON格式错误: ' + error.message + } +}) + +// 测试用例存储 +const persistTestCases = () => { + localStorage.setItem(testCaseStorageKey.value, JSON.stringify(nodeTestCases.value)) +} + +const loadTestCases = () => { + try { + const stored = localStorage.getItem(testCaseStorageKey.value) + if (stored) { + nodeTestCases.value = JSON.parse(stored) + } + } catch (error) { + console.warn('加载测试用例失败', error) + } +} + +const saveCurrentTestCase = async () => { + if (!selectedNode.value) { + ElMessage.warning('请先选择节点') + return + } + try { + JSON.parse(nodeTestInput.value || '{}') + } catch (error: any) { + ElMessage.error('输入数据不是有效的JSON,无法保存用例') + return + } + try { + const { value } = await ElMessageBox.prompt('请输入测试用例名称', '保存测试用例', { + inputValue: selectedNode.value.data?.label || '测试用例' + }) + const nodeId = selectedNode.value.id + const cases = nodeTestCases.value[nodeId] || [] + const newCase = { + id: `case_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + name: value, + data: nodeTestInput.value + } + nodeTestCases.value[nodeId] = [...cases, newCase] + selectedTestCaseId.value = newCase.id + persistTestCases() + ElMessage.success('测试用例已保存') + } catch (error) { + // 用户取消不提示 + } +} + +const applySelectedTestCase = () => { + if (!selectedNode.value || !selectedTestCaseId.value) return + const nodeId = selectedNode.value.id + const cases = nodeTestCases.value[nodeId] || [] + const c = cases.find(i => i.id === selectedTestCaseId.value) + if (c) { + nodeTestInput.value = c.data + testInputError.value = '' + ElMessage.success('已应用测试用例') + } +} + +const deleteSelectedTestCase = () => { + if (!selectedNode.value || !selectedTestCaseId.value) return + const nodeId = selectedNode.value.id + const cases = nodeTestCases.value[nodeId] || [] + const next = cases.filter(i => i.id !== selectedTestCaseId.value) + nodeTestCases.value[nodeId] = next + selectedTestCaseId.value = '' + persistTestCases() + ElMessage.success('已删除测试用例') +} + // 边点击 const onEdgeClick = (event: EdgeClickEvent) => { console.log('Edge clicked:', event.edge) @@ -2038,6 +3468,14 @@ const onPaneClick = () => { const onNodesChange = (changes: any[]) => { // 处理节点变化 console.log('Nodes changed:', changes) + if (isRestoringHistory.value) { + return + } + const hasStructuralChange = changes.some(change => change.type !== 'select' && (!('dragging' in change) || change.dragging === false)) + if (hasStructuralChange && !isProcessingRemoteOperation.value) { + pushHistory('nodes change') + hasChanges.value = true + } // 如果正在处理远程操作,不发送协作消息 if (isProcessingRemoteOperation.value) { @@ -2081,6 +3519,14 @@ const onNodesChange = (changes: any[]) => { const onEdgesChange = (changes: any[]) => { // 处理边变化 console.log('Edges changed:', changes) + if (isRestoringHistory.value) { + return + } + const hasStructuralChange = changes.some(change => change.type === 'add' || change.type === 'remove') + if (hasStructuralChange && !isProcessingRemoteOperation.value) { + pushHistory('edges change') + hasChanges.value = true + } // 处理边的选中状态变化 for (const change of changes) { @@ -2221,6 +3667,8 @@ const onConnect = (connection: Connection) => { } } addEdges([newEdge]) + pushHistory('connect nodes') + hasChanges.value = true ElMessage.success('节点已连接') // 发送协作操作 @@ -2492,14 +3940,33 @@ const handleKeyDown = (event: KeyboardEvent) => { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return } + + const isCtrlOrMeta = event.ctrlKey || event.metaKey + const key = typeof event.key === 'string' ? event.key.toLowerCase() : '' + + // Ctrl+Z 撤销 / Ctrl+Shift+Z 或 Ctrl+Y 重做 + if (isCtrlOrMeta && key === 'z') { + event.preventDefault() + if (event.shiftKey) { + redo() + } else { + undo() + } + return + } + if (isCtrlOrMeta && key === 'y') { + event.preventDefault() + redo() + return + } // Ctrl+C 复制 - if (event.ctrlKey && event.key === 'c' && selectedNode.value) { + if (isCtrlOrMeta && key === 'c' && selectedNode.value) { handleCopyNode() event.preventDefault() } // Ctrl+V 粘贴 - if (event.ctrlKey && event.key === 'v' && copiedNode.value) { + if (isCtrlOrMeta && key === 'v' && copiedNode.value) { handlePasteNodeFromButton() event.preventDefault() } @@ -2535,27 +4002,27 @@ const handleKeyDown = (event: KeyboardEvent) => { } } // Ctrl+S 保存 - if (event.ctrlKey && event.key === 's') { + if (isCtrlOrMeta && key === 's') { event.preventDefault() handleSave() } // Ctrl+0 重置缩放 - if (event.ctrlKey && event.key === '0') { + if (isCtrlOrMeta && key === '0') { event.preventDefault() resetZoom() } // Ctrl+Plus 放大 - if (event.ctrlKey && (event.key === '+' || event.key === '=')) { + if (isCtrlOrMeta && (key === '+' || key === '=')) { event.preventDefault() zoomIn() } // Ctrl+Minus 缩小 - if (event.ctrlKey && event.key === '-') { + if (isCtrlOrMeta && key === '-') { event.preventDefault() zoomOut() } // Ctrl+L 自动布局 - if (event.ctrlKey && event.key === 'l') { + if (isCtrlOrMeta && key === 'l') { event.preventDefault() handleAutoLayout() } @@ -3504,6 +4971,8 @@ onMounted(async () => { // 加载节点模板列表 loadNodeTemplates() + // 加载测试用例 + loadTestCases() // 初始化视口 const viewport = getViewport() @@ -3727,8 +5196,8 @@ onUnmounted(() => { } .node-toolbox { - width: 180px; - min-width: 180px; + width: 220px; + min-width: 220px; background: #fff; border-right: 1px solid #ddd; padding: 10px; @@ -3746,11 +5215,31 @@ onUnmounted(() => { color: #333; } +.node-search { + margin-bottom: 10px; +} + +.node-filters { + margin-bottom: 10px; +} + +.node-filters :deep(.el-radio-group) { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.node-filters :deep(.el-radio-button__inner) { + padding: 4px 8px; + font-size: 12px; +} + .node-list { display: flex; flex-direction: column; gap: 6px; flex: 1; + min-height: 0; } .node-item { @@ -3777,6 +5266,75 @@ onUnmounted(() => { opacity: 0.7; } +.empty-nodes { + padding: 20px 0; + text-align: center; +} + +/* 画布节点搜索区域 */ +.canvas-node-search { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #e4e7ed; +} + +.canvas-node-search .search-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.canvas-node-search h4 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: #303133; +} + +.canvas-node-list { + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +} + +.canvas-node-item { + padding: 6px 8px; + border: 1px solid #e4e7ed; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + font-size: 12px; +} + +.canvas-node-item:hover { + background: #f5f7fa; + border-color: #409eff; +} + +.canvas-node-item.is-selected { + background: #ecf5ff; + border-color: #409eff; + font-weight: 500; +} + +.canvas-node-item .node-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-canvas-nodes { + padding: 10px 0; + text-align: center; +} + .editor-canvas { flex: 1; position: relative; @@ -3801,14 +5359,15 @@ onUnmounted(() => { } .config-panel { - width: 300px; - min-width: 300px; + width: 420px; + min-width: 380px; background: #fff; border-left: 1px solid #ddd; - padding: 15px; + padding: 15px 20px 15px 15px; /* 右侧预留滚动条空间,避免按钮被遮挡 */ overflow-y: auto; overflow-x: hidden; flex-shrink: 0; + box-sizing: border-box; } .config-panel h3 { @@ -4199,4 +5758,188 @@ onUnmounted(() => { padding: 40px 0; text-align: center; } + +/* 节点测试区域样式 */ +.test-input-toolbar { + display: flex; + align-items: center; + margin-bottom: 8px; + gap: 8px; +} + +.test-input-hint { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + font-size: 12px; + color: #909399; +} + +.test-input-hint .error-text { + color: #f56c6c; + margin-left: 8px; +} + +.input-error textarea { + border-color: #f56c6c !important; +} + +.test-result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding: 8px 12px; + background: #f5f7fa; + border-radius: 4px; +} + +.test-status-group { + display: flex; + align-items: center; + gap: 12px; +} + +.test-status { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + font-size: 14px; +} + +.test-status.success { + color: #67c23a; +} + +.test-status.error { + color: #f56c6c; +} + +.test-time { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: #909399; +} + +.test-result-actions { + display: flex; + gap: 8px; +} + +.test-result-content { + border: 1px solid #e4e7ed; + border-radius: 4px; + overflow: hidden; + margin-top: 8px; +} + +.json-viewer { + margin: 0; + padding: 12px; + background: #fafafa; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 13px; + line-height: 1.6; + color: #303133; + overflow-x: auto; + white-space: pre; +} + +.test-error-detail { + padding: 12px; +} + +.error-trace { + margin-top: 12px; + font-size: 12px; +} + +.error-trace details { + cursor: pointer; +} + +.error-trace summary { + margin-bottom: 8px; + color: #606266; + font-weight: 500; +} + +.error-trace pre { + margin: 8px 0 0 0; + padding: 8px; + background: #f5f7fa; + border-radius: 4px; + font-size: 11px; + color: #f56c6c; + overflow-x: auto; +} + +.test-stats { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +/* 配置按钮布局 */ +.config-actions { + display: flex; + gap: 8px; + width: 100%; + flex-wrap: wrap; +} + +.config-actions .el-button { + flex: 1 1 0; + min-width: 100px; +} + +/* 快速模板 & 变量插入 */ +.quick-actions { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + margin-bottom: 16px; + background: #f8f9fb; + border: 1px solid #ebeef5; + border-radius: 8px; +} + +.quick-item { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.quick-label { + font-weight: 600; + color: #303133; +} + +.test-case-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.swimlane-overlay { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} + +.lane-config { + display: flex; + align-items: center; + gap: 6px; + margin-left: 8px; +} diff --git a/test_memory_functionality.py b/test_memory_functionality.py new file mode 100755 index 0000000..d01c5f4 --- /dev/null +++ b/test_memory_functionality.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +测试记忆功能 +参考工作流调用测试总结.txt的测试方法 +""" +import sys +sys.path.insert(0, 'backend') + +from app.core.database import SessionLocal +from app.models.agent import Agent +from app.models.execution import Execution +from app.models.execution_log import ExecutionLog +import json +from datetime import datetime + +def test_memory_functionality(): + """测试记忆功能""" + db = SessionLocal() + try: + # 获取智能聊天助手Agent + agent = db.query(Agent).filter( + Agent.name == '智能聊天助手(完整示例)' + ).first() + + if not agent: + print("❌ 未找到'智能聊天助手(完整示例)'Agent") + return + + print(f"✅ 找到Agent: {agent.name} (ID: {agent.id})") + print("="*80) + + # 获取最近的两次执行(应该对应两次对话) + executions = db.query(Execution).filter( + Execution.agent_id == agent.id + ).order_by(Execution.created_at.desc()).limit(2).all() + + if len(executions) < 2: + print(f"⚠️ 只找到 {len(executions)} 次执行,需要至少2次执行来测试记忆功能") + print("请先进行两次对话测试") + return + + print(f"\n找到 {len(executions)} 次执行记录") + print("="*80) + + # 分析每次执行 + for i, exec_record in enumerate(reversed(executions), 1): # 按时间正序 + print(f"\n{'='*80}") + print(f"执行 {i}: {exec_record.id}") + print(f"输入: {exec_record.input_data}") + print(f"时间: {exec_record.created_at}") + print(f"状态: {exec_record.status}") + + # 检查关键节点的数据流转 + nodes_to_check = [ + ('cache-query', '查询记忆'), + ('transform-merge', '合并上下文'), + ('llm-question', '问题回答'), + ('cache-update', '更新记忆'), + ('llm-format', '格式化回复'), + ('end-1', '最终输出') + ] + + for node_id, label in nodes_to_check: + # 查找节点执行完成的日志 + log = db.query(ExecutionLog).filter( + ExecutionLog.execution_id == exec_record.id, + ExecutionLog.node_id == node_id, + ExecutionLog.message.like(f'节点 {node_id}%执行完成') + ).first() + + if not log: + # 如果没有执行完成的日志,查找开始执行的日志 + log = db.query(ExecutionLog).filter( + ExecutionLog.execution_id == exec_record.id, + ExecutionLog.node_id == node_id, + ExecutionLog.message.like(f'节点 {node_id}%开始执行') + ).first() + + if log and log.data: + data_key = 'output' if '执行完成' in log.message else 'input' + data = log.data.get(data_key, {}) + + print(f"\n{label} ({node_id}):") + + if isinstance(data, dict): + print(f" keys: {list(data.keys())}") + + # 检查memory字段 + if 'memory' in data: + memory = data['memory'] + if isinstance(memory, dict): + print(f" ✅ memory存在,keys: {list(memory.keys())}") + if 'conversation_history' in memory: + history = memory['conversation_history'] + if isinstance(history, list): + print(f" ✅ conversation_history: {len(history)} 条") + if history: + print(f" 最新一条: {history[-1].get('content', '')[:50]}") + else: + print(f" ❌ conversation_history不是list: {type(history)}") + # 检查conversation_history字段(可能在顶层) + elif 'conversation_history' in data: + history = data['conversation_history'] + if isinstance(history, list): + print(f" ✅ conversation_history在顶层: {len(history)} 条") + if history: + print(f" 最新一条: {history[-1].get('content', '')[:50]}") + + # 对于end节点,检查最终输出 + if node_id == 'end-1' and 'output' in data: + output = data['output'] + if isinstance(output, str): + print(f" ✅ 最终输出: {output[:200]}") + # 检查是否包含名字 + if '老七' in output: + print(f" ✅ 输出中包含名字'老七'") + else: + print(f" ❌ 输出中不包含名字'老七'") + elif isinstance(data, str): + print(f" 输出类型: str, 内容: {data[:200]}") + if '老七' in data: + print(f" ✅ 输出中包含名字'老七'") + else: + print(f" ❌ 输出中不包含名字'老七'") + else: + print(f"\n{label} ({node_id}): ❌ 未找到执行日志") + + # 检查最终输出 + if exec_record.output_data: + output_data = exec_record.output_data + if isinstance(output_data, dict): + result = output_data.get('result', '') + if isinstance(result, str): + print(f"\n最终结果: {result[:200]}") + if '老七' in result: + print(f"✅ 最终结果中包含名字'老七'") + else: + print(f"❌ 最终结果中不包含名字'老七'") + + # 检查Redis中的记忆数据 + print(f"\n{'='*80}") + print("检查Redis中的记忆数据:") + try: + from app.core.redis_client import get_redis_client + redis_client = get_redis_client() + if redis_client: + keys = redis_client.keys('user_memory_*') + if keys: + for key in keys: + value = redis_client.get(key) + if value: + try: + memory_data = json.loads(value) + if 'conversation_history' in memory_data: + history = memory_data['conversation_history'] + print(f" ✅ {key}: {len(history)} 条对话记录") + if history: + print(f" 最新一条: {history[-1].get('content', '')[:50]}") + except: + print(f" ⚠️ {key}: 无法解析JSON") + else: + print(" ❌ Redis中没有找到记忆数据") + else: + print(" ⚠️ Redis客户端不可用") + except Exception as e: + print(f" ⚠️ 检查Redis失败: {str(e)}") + + print(f"\n{'='*80}") + print("测试完成") + + finally: + db.close() + +if __name__ == '__main__': + test_memory_functionality() diff --git a/test_output_variable_extraction.py b/test_output_variable_extraction.py new file mode 100644 index 0000000..499dbee --- /dev/null +++ b/test_output_variable_extraction.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +测试output变量提取逻辑 +""" +import sys +sys.path.insert(0, 'backend') + +from app.services.workflow_engine import WorkflowEngine + +# 模拟llm-format节点的输入数据 +input_data = { + 'right': { + 'right': { + 'right': '是的,我记得!根据我们之前的对话,你告诉我你的名字叫"老七"。我会在本次对话中记住这个名字,以便更好地为你提供帮助。如果你希望我用其他称呼,也可以随时告诉我。', + 'query': '你还记得我的名字吗?' + }, + 'memory': { + 'conversation_history': [], + 'user_profile': {}, + 'context': {} + }, + 'query': '你还记得我的名字吗?' + }, + 'memory': { + 'conversation_history': [], + 'user_profile': {}, + 'context': {} + }, + 'query': '你还记得我的名字吗?' +} + +# 创建WorkflowEngine实例 +engine = WorkflowEngine("test", {"nodes": [], "edges": []}) + +# 测试_get_nested_value方法 +print("测试_get_nested_value方法:") +value1 = engine._get_nested_value(input_data, 'output') +print(f" _get_nested_value(input_data, 'output'): {value1}") + +# 测试output变量提取逻辑 +print("\n测试output变量提取逻辑:") +right_value = input_data.get('right') +print(f" right_value类型: {type(right_value)}") +print(f" right_value: {str(right_value)[:100]}") + +if right_value is not None: + if isinstance(right_value, str): + value = right_value + print(f" ✅ 从right字段(字符串)提取: {value[:100]}") + elif isinstance(right_value, dict): + current = right_value + depth = 0 + while isinstance(current, dict) and depth < 10: + if 'right' in current: + current = current['right'] + depth += 1 + if isinstance(current, str): + value = current + print(f" ✅ 从right字段(嵌套{depth}层)提取: {value[:100]}") + break + else: + break + else: + print(f" ❌ 无法提取字符串值") + +print("\n测试完成") diff --git a/可新增节点类型建议.md b/可新增节点类型建议.md new file mode 100644 index 0000000..21aa649 --- /dev/null +++ b/可新增节点类型建议.md @@ -0,0 +1,1117 @@ +# 可新增节点类型建议 + + + +## 📊 当前已实现的节点类型 + + + +### 基础节点 + +- ✅ start(开始) + +- ✅ input(输入) + +- ✅ output(输出) + +- ✅ end(结束) + + + +### AI节点 + +- ✅ llm(LLM调用) + +- ✅ template(模板) + +- ✅ agent(Agent调用) + + + +### 逻辑节点 + +- ✅ condition(条件判断) + +- ✅ loop(循环) + + + +### 数据节点 + +- ✅ transform(数据转换) + +- ✅ database(数据库操作) + +- ✅ file(文件操作) + + + +### 网络节点 + +- ✅ http(HTTP请求) + +- ✅ webhook(Webhook) + + + +### 系统节点 + +- ✅ schedule(定时任务) + +- ✅ delay/timer(延迟/定时器) + + + +### 通信节点 + +- ✅ email(邮件) + +- ✅ message_queue(消息队列) + + + +--- + + + +## 🆕 建议新增的节点类型(按优先级排序) + + + +### 🔴 高优先级(核心功能增强) + + + +#### 1. **Switch节点(多分支路由)** + +- **类型**: `switch` + +- **分类**: 逻辑 + +- **功能**: 根据多个条件值进行多分支路由(类似编程语言的switch/case) + +- **配置**: + +  ```json + +  { + +    "field": "status", + +    "cases": { + +      "success": "success_handle", + +      "failed": "error_handle", + +      "pending": "wait_handle" + +    }, + +    "default": "default_handle" + +  } + +  ``` + +- **使用场景**: 多状态处理、多条件分支、路由分发 + +- **参考**: n8n的Switch节点 + + + +#### 2. **Merge节点(合并分支)** + +- **类型**: `merge` + +- **分类**: 逻辑 + +- **功能**: 合并多个分支的数据流 + +- **配置**: + +  ```json + +  { + +    "mode": "merge_all",  // merge_all, merge_first, merge_last + +    "strategy": "array"    // array, object, concat + +  } + +  ``` + +- **使用场景**: 并行处理后的结果合并、多数据源汇总 + +- **参考**: n8n的Merge节点 + + + +#### 3. **Wait节点(等待条件)** + +- **类型**: `wait` + +- **分类**: 逻辑 + +- **功能**: 等待特定条件满足后再继续执行 + +- **配置**: + +  ```json + +  { + +    "wait_type": "condition",  // condition, time, event + +    "condition": "{status} == 'ready'", + +    "timeout": 300, + +    "poll_interval": 5 + +  } + +  ``` + +- **使用场景**: 等待异步任务完成、等待外部事件、条件等待 + +- **参考**: Make.com的Wait节点 + + + +#### 4. **JSON处理节点** + +- **类型**: `json` + +- **分类**: 数据 + +- **功能**: JSON解析、构建、转换、验证 + +- **配置**: + +  ```json + +  { + +    "operation": "parse",  // parse, stringify, extract, validate + +    "path": "$.data.items[*]", + +    "schema": {} + +  } + +  ``` + +- **使用场景**: API响应处理、数据提取、JSON格式转换 + +- **参考**: n8n的JSON节点 + + + +#### 5. **文本处理节点** + +- **类型**: `text` + +- **分类**: 数据 + +- **功能**: 文本拆分、合并、提取、替换、格式化 + +- **配置**: + +  ```json + +  { + +    "operation": "split",  // split, join, extract, replace, format + +    "delimiter": "\n", + +    "regex": "\\d+", + +    "template": "Hello {name}" + +  } + +  ``` + +- **使用场景**: 文本清洗、格式化、提取关键信息 + +- **参考**: Zapier的Text Formatter + + + +#### 6. **缓存节点** + +- **类型**: `cache` + +- **分类**: 数据 + +- **功能**: 数据缓存、缓存读取、缓存更新 + +- **配置**: + +  ```json + +  { + +    "operation": "get",  // get, set, delete, clear + +    "key": "user_{user_id}", + +    "ttl": 3600, + +    "backend": "redis"  // redis, memory + +  } + +  ``` + +- **使用场景**: 减少重复计算、提高性能、临时数据存储 + +- **参考**: n8n的Cache节点 + + + +--- + + + +### 🟡 中优先级(功能扩展) + + + +#### 7. **向量数据库节点** + +- **类型**: `vector_db` + +- **分类**: AI + +- **功能**: 向量存储、相似度搜索、RAG检索 + +- **配置**: + +  ```json + +  { + +    "operation": "search",  // search, upsert, delete + +    "collection": "documents", + +    "query_vector": "{embedding}", + +    "top_k": 5 + +  } + +  ``` + +- **使用场景**: RAG应用、语义搜索、知识库检索 + +- **参考**: LangChain的VectorStore + + + +#### 8. **Slack/钉钉/企业微信节点** + +- **类型**: `slack` / `dingtalk` / `wechat_work` + +- **分类**: 通信 + +- **功能**: 发送消息、创建频道、获取消息 + +- **配置**: + +  ```json + +  { + +    "operation": "send_message", + +    "channel": "#general", + +    "message": "Hello {user}", + +    "attachments": [] + +  } + +  ``` + +- **使用场景**: 团队通知、工作流通知、协作沟通 + +- **参考**: Zapier的Slack集成 + + + +#### 9. **短信节点(SMS)** + +- **类型**: `sms` + +- **分类**: 通信 + +- **功能**: 发送短信、批量发送、短信模板 + +- **配置**: + +  ```json + +  { + +    "provider": "aliyun",  // aliyun, tencent, twilio + +    "phone": "{phone_number}", + +    "template": "验证码:{code}", + +    "sign": "公司名称" + +  } + +  ``` + +- **使用场景**: 验证码发送、通知提醒、营销短信 + +- **参考**: n8n的SMS节点 + + + +#### 10. **对象存储节点(S3/OSS)** + +- **类型**: `object_storage` + +- **分类**: 数据 + +- **功能**: 文件上传、下载、删除、列表 + +- **配置**: + +  ```json + +  { + +    "provider": "oss",  // oss, s3, cos + +    "operation": "upload", + +    "bucket": "my-bucket", + +    "key": "files/{filename}", + +    "file": "{file_data}" + +  } + +  ``` + +- **使用场景**: 文件存储、大文件处理、静态资源管理 + +- **参考**: n8n的S3节点 + + + +#### 11. **CSV处理节点** + +- **类型**: `csv` + +- **分类**: 数据 + +- **功能**: CSV解析、生成、转换 + +- **配置**: + +  ```json + +  { + +    "operation": "parse",  // parse, generate, convert + +    "delimiter": ",", + +    "headers": true, + +    "encoding": "utf-8" + +  } + +  ``` + +- **使用场景**: 数据导入导出、报表生成、批量处理 + +- **参考**: n8n的CSV节点 + + + +#### 12. **PDF处理节点** + +- **类型**: `pdf` + +- **分类**: 数据 + +- **功能**: PDF解析、生成、合并、拆分 + +- **配置**: + +  ```json + +  { + +    "operation": "extract_text",  // extract_text, generate, merge, split + +    "pages": "1-10", + +    "template": "report_template.html" + +  } + +  ``` + +- **使用场景**: 文档处理、报告生成、数据提取 + +- **参考**: Zapier的PDF工具 + + + +#### 13. **图像处理节点** + +- **类型**: `image` + +- **分类**: 数据 + +- **功能**: 图像缩放、裁剪、格式转换、OCR识别 + +- **配置**: + +  ```json + +  { + +    "operation": "resize",  // resize, crop, convert, ocr + +    "width": 800, + +    "height": 600, + +    "format": "png" + +  } + +  ``` + +- **使用场景**: 图片处理、OCR识别、图像分析 + +- **参考**: n8n的Image节点 + + + +#### 14. **错误处理节点(Try-Catch)** + +- **类型**: `error_handler` + +- **分类**: 逻辑 + +- **功能**: 捕获错误、错误重试、错误通知 + +- **配置**: + +  ```json + +  { + +    "retry_count": 3, + +    "retry_delay": 1000, + +    "on_error": "notify", + +    "error_handler_workflow": "error_workflow_id" + +  } + +  ``` + +- **使用场景**: 错误处理、重试机制、异常通知 + +- **参考**: Make.com的错误处理 + + + +#### 15. **日志节点** + +- **类型**: `log` + +- **分类**: 系统 + +- **功能**: 记录日志、调试输出、性能监控 + +- **配置**: + +  ```json + +  { + +    "level": "info",  // debug, info, warning, error + +    "message": "Processing: {data}", + +    "include_data": true + +  } + +  ``` + +- **使用场景**: 调试、监控、审计日志 + +- **参考**: n8n的Log节点 + + + +--- + + + +### 🟢 低优先级(高级功能) + + + +#### 16. **子工作流节点(Subworkflow)** + +- **类型**: `subworkflow` + +- **分类**: 逻辑 + +- **功能**: 调用其他工作流、工作流复用 + +- **配置**: + +  ```json + +  { + +    "workflow_id": "workflow_123", + +    "input_mapping": { + +      "param1": "{value1}", + +      "param2": "{value2}" + +    } + +  } + +  ``` + +- **使用场景**: 模块化设计、工作流复用、复杂任务拆分 + +- **参考**: n8n的Subworkflow节点 + + + +#### 17. **代码执行节点(Code/Function)** + +- **类型**: `code` + +- **分类**: 逻辑 + +- **功能**: 执行自定义代码(Python/JavaScript) + +- **配置**: + +  ```json + +  { + +    "language": "python",  // python, javascript + +    "code": "result = input_data['value'] * 2\nreturn {'output': result}", + +    "timeout": 30 + +  } + +  ``` + +- **使用场景**: 复杂计算、自定义逻辑、快速原型 + +- **参考**: n8n的Code节点 + + + +#### 18. **API认证节点(OAuth)** + +- **类型**: `oauth` + +- **分类**: 网络 + +- **功能**: OAuth认证、Token管理、自动刷新 + +- **配置**: + +  ```json + +  { + +    "provider": "google",  // google, github, custom + +    "client_id": "xxx", + +    "client_secret": "xxx", + +    "scopes": ["read", "write"] + +  } + +  ``` + +- **使用场景**: 第三方API集成、安全认证 + +- **参考**: Zapier的OAuth集成 + + + +#### 19. **数据验证节点** + +- **类型**: `validator` + +- **分类**: 数据 + +- **功能**: 数据格式验证、类型检查、规则验证 + +- **配置**: + + ```json + + { + + "schema": { + + "type": "object", + + "properties": { + + "email": {"type": "string", "format": "email"}, + + "age": {"type": "number", "minimum": 0, "maximum": 150} + + }, + + "required": ["email"] + + }, + + "on_error": "reject" // reject, continue, transform + + } + + ``` + +- **使用场景**: 数据质量保证、API输入验证、数据清洗 + +- **参考**: JSON Schema验证 + + + +#### 20. **Excel处理节点** + +- **类型**: `excel` + +- **分类**: 数据 + +- **功能**: Excel读取、写入、格式转换、公式计算 + +- **配置**: + + ```json + + { + + "operation": "read", // read, write, convert, formula + + "sheet": "Sheet1", + + "range": "A1:C10", + + "format": "xlsx" // xlsx, xls, csv + + } + + ``` + +- **使用场景**: 报表处理、数据分析、批量导入导出 + +- **参考**: n8n的Spreadsheet节点 + + + +#### 21. **XML处理节点** + +- **类型**: `xml` + +- **分类**: 数据 + +- **功能**: XML解析、生成、转换、XPath查询 + +- **配置**: + + ```json + + { + + "operation": "parse", // parse, generate, convert, xpath + + "xpath": "/root/item[@id='1']", + + "encoding": "utf-8" + + } + + ``` + +- **使用场景**: XML数据交换、配置文件处理、数据提取 + +- **参考**: n8n的XML节点 + + + +#### 22. **日期时间处理节点** + +- **类型**: `datetime` + +- **分类**: 数据 + +- **功能**: 日期格式化、时区转换、日期计算、解析 + +- **配置**: + + ```json + + { + + "operation": "format", // format, parse, add, subtract, convert_timezone + + "format": "YYYY-MM-DD HH:mm:ss", + + "timezone": "Asia/Shanghai", + + "value": "{timestamp}" + + } + + ``` + +- **使用场景**: 时间戳转换、时区处理、日期计算 + +- **参考**: Moment.js / Day.js + + + +#### 23. **数学运算节点** + +- **类型**: `math` + +- **分类**: 数据 + +- **功能**: 数学计算、统计、聚合、公式求值 + +- **配置**: + + ```json + + { + + "operation": "calculate", // calculate, sum, average, max, min, formula + + "formula": "{a} + {b} * {c}", + + "precision": 2 + + } + + ``` + +- **使用场景**: 数值计算、统计分析、数据聚合 + +- **参考**: n8n的Math节点 + + + +#### 24. **变量设置节点** + +- **类型**: `set_variable` + +- **分类**: 逻辑 + +- **功能**: 设置工作流变量、变量作用域管理 + +- **配置**: + + ```json + + { + + "variables": { + + "user_name": "{input.name}", + + "timestamp": "{now()}", + + "counter": "{counter + 1}" + + }, + + "scope": "workflow" // workflow, execution, global + + } + + ``` + +- **使用场景**: 状态管理、计数器、临时变量存储 + +- **参考**: Make.com的Set Variable节点 + + + +#### 25. **变量获取节点** + +- **类型**: `get_variable` + +- **分类**: 逻辑 + +- **功能**: 获取工作流变量、变量查询 + +- **配置**: + + ```json + + { + + "variable_name": "user_name", + + "default_value": "Unknown", + + "scope": "workflow" + + } + + ``` + +- **使用场景**: 变量读取、状态查询、条件判断 + +- **参考**: Make.com的Get Variable节点 + + + +#### 26. **批处理节点** + +- **类型**: `batch` + +- **分类**: 逻辑 + +- **功能**: 数据分批处理、批量操作、批处理控制 + +- **配置**: + + ```json + + { + + "batch_size": 100, + + "mode": "split", // split, group, aggregate + + "wait_for_completion": true + + } + + ``` + +- **使用场景**: 大数据处理、批量API调用、性能优化 + +- **参考**: n8n的Split In Batches节点 + + + +#### 27. **去重节点** + +- **类型**: `deduplicate` + +- **分类**: 数据 + +- **功能**: 数据去重、唯一性检查、重复项过滤 + +- **配置**: + + ```json + + { + + "key": "id", // 去重字段 + + "method": "first", // first, last, all + + "case_sensitive": true + + } + + ``` + +- **使用场景**: 数据清洗、去重处理、唯一性保证 + +- **参考**: n8n的Remove Duplicates节点 + + + +#### 28. **排序节点** + +- **类型**: `sort` + +- **分类**: 数据 + +- **功能**: 数据排序、多字段排序、自定义排序规则 + +- **配置**: + + ```json + + { + + "fields": [ + + {"field": "priority", "order": "desc"}, + + {"field": "created_at", "order": "asc"} + + ], + + "type": "number" // number, string, date + + } + + ``` + +- **使用场景**: 数据排序、优先级处理、列表整理 + +- **参考**: n8n的Sort节点 + + + +#### 29. **过滤节点** + +- **类型**: `filter` + +- **分类**: 数据 + +- **功能**: 数据过滤、条件筛选、数据过滤规则 + +- **配置**: + + ```json + + { + + "conditions": [ + + {"field": "status", "operator": "equals", "value": "active"}, + + {"field": "age", "operator": "greater_than", "value": 18} + + ], + + "logic": "AND" // AND, OR + + } + + ``` + +- **使用场景**: 数据筛选、条件过滤、数据清洗 + +- **参考**: n8n的Filter节点 + + + +#### 30. **聚合节点** + +- **类型**: `aggregate` + +- **分类**: 数据 + +- **功能**: 数据聚合、分组统计、汇总计算 + +- **配置**: + + ```json + + { + + "group_by": ["category", "status"], + + "aggregations": { + + "total": "sum(amount)", + + "count": "count()", + + "average": "avg(price)" + + } + + } + + ``` + +- **使用场景**: 数据统计、报表生成、数据分析 + +- **参考**: SQL的GROUP BY + + + +--- + +## 📋 实施建议 + +### 开发优先级 + +1. **第一阶段(核心功能)**: Switch、Merge、Wait、JSON处理、文本处理、缓存 +2. **第二阶段(常用功能)**: 向量数据库、日志、错误处理、CSV处理、对象存储 +3. **第三阶段(扩展功能)**: 通信节点(Slack/钉钉/企业微信/短信)、PDF/图像处理、Excel处理 +4. **第四阶段(高级功能)**: 子工作流、代码执行、OAuth、数据验证、批处理 + +### 技术实现要点 + +1. **节点注册机制**: 统一的节点注册和发现机制 +2. **配置验证**: 每个节点需要配置Schema验证 +3. **错误处理**: 统一的错误处理和重试机制 +4. **性能优化**: 异步执行、批量处理、缓存机制 +5. **可扩展性**: 插件化架构,支持自定义节点 + +### 测试建议 + +1. **单元测试**: 每个节点的核心功能 +2. **集成测试**: 节点之间的数据流 +3. **性能测试**: 大数据量、并发场景 +4. **用户体验测试**: UI交互、错误提示、文档 + +--- + +## 📚 参考资源 + +- **n8n**: https://docs.n8n.io/nodes/ +- **Make.com**: https://www.make.com/en/help +- **Zapier**: https://zapier.com/apps +- **LangChain**: https://python.langchain.com/docs/modules/data_connection/ +- **Airflow**: https://airflow.apache.org/docs/ + +--- + +## ✅ 总结 + +本文档列出了**30个建议新增的节点类型**,分为三个优先级: + +- **高优先级(6个)**: 核心功能增强,建议优先实现 +- **中优先级(9个)**: 功能扩展,提升系统能力 +- **低优先级(15个)**: 高级功能,满足复杂场景需求 + +建议根据实际业务需求和开发资源,按优先级逐步实现这些节点类型,持续完善工作流编辑器的功能。 \ No newline at end of file diff --git a/启动说明.md b/启动说明(红头).md similarity index 100% rename from 启动说明.md rename to 启动说明(红头).md diff --git a/智能体聊天助手记忆存储说明.md b/智能体聊天助手记忆存储说明.md new file mode 100644 index 0000000..3054034 --- /dev/null +++ b/智能体聊天助手记忆存储说明.md @@ -0,0 +1,296 @@ +# 智能体聊天助手记忆存储说明 + +## 一、数据存储位置 + +### 1. 主要存储:Redis + +智能体聊天助手使用 **Redis** 作为记忆数据的持久化存储后端。 + +- **存储键名格式**:`user_memory_{user_id}` + - 例如:`user_memory_default`、`user_memory_12345` +- **存储位置**:Redis 数据库(默认 DB 0) +- **数据格式**:JSON 字符串 + +### 2. 备用存储:内存缓存 + +如果 Redis 不可用,系统会回退到**内存缓存**(Memory Cache)。 + +⚠️ **重要提示**:内存缓存只在**单次执行会话内有效**,执行结束后数据会丢失,无法跨会话保留。 + +### 3. 存储结构 + +每个用户的记忆数据包含以下字段: + +```json +{ + "conversation_history": [ + { + "role": "user", + "content": "我的名字叫老七", + "timestamp": "2024-01-01T10:00:00" + }, + { + "role": "assistant", + "content": "好的,我记住了你的名字是老七。", + "timestamp": "2024-01-01T10:00:01" + } + ], + "user_profile": { + // 用户画像信息(可扩展) + }, + "context": { + // 上下文信息(可扩展) + } +} +``` + +## 二、数据大小限制 + +### 1. Redis 存储限制 + +- **单条记录大小**: + - Redis 理论上单个 key 的值最大可达 **512MB**(默认配置) + - 实际使用中,受 Redis 服务器配置的 `maxmemory` 限制 + - 当前系统:**无硬编码限制**(取决于 Redis 服务器配置) + +- **对话历史累积**: + - 对话历史会**不断累积**,没有自动截断机制 + - 每次对话会添加 2 条记录(用户消息 + 助手回复) + - 假设每条消息平均 200 字(约 600 字节),1000 轮对话约 1.2MB + +### 2. 实际使用情况 + +根据当前系统检查: +- 当前用户记忆 key 数量:**1 个** +- 示例 key 大小:**约 5.73 KB**(20 条对话历史) +- Redis 已使用内存:**2.52 MB** +- Redis 最大内存限制:**无限制**(取决于服务器配置) + +### 3. 建议的容量规划 + +| 对话轮数 | 预估大小 | 说明 | +|---------|---------|------| +| 100 轮 | ~120 KB | 适合短期对话 | +| 500 轮 | ~600 KB | 适合中期对话 | +| 1000 轮 | ~1.2 MB | 适合长期对话 | +| 5000 轮 | ~6 MB | 需要监控内存使用 | +| 10000 轮 | ~12 MB | 建议实施截断策略 | + +## 三、数据持久化与丢失风险 + +### 1. 数据持久化机制 + +#### Redis 持久化(推荐) + +- **持久化方式**:取决于 Redis 配置 + - **RDB**:定期快照,默认开启 + - **AOF**:追加日志,可选开启 +- **Docker 卷持久化**: + - 使用 `redis_data` 卷存储数据 + - 即使容器重启,数据也会保留 + - 数据存储在 Docker 卷中,位置:`/var/lib/docker/volumes/redis_data` + +#### 内存缓存(不持久化) + +- 数据仅存在于进程内存中 +- 进程重启后数据丢失 +- 仅用于 Redis 不可用时的临时回退 + +### 2. 数据丢失风险分析 + +| 场景 | 数据是否丢失 | 说明 | +|------|------------|------| +| Redis 正常重启 | ❌ 不丢失 | 数据已持久化到磁盘 | +| Docker 容器重启 | ❌ 不丢失 | 数据存储在 Docker 卷中 | +| Redis 数据卷被删除 | ✅ **会丢失** | 需要重新创建卷 | +| 超过 TTL 时间 | ✅ **会过期** | 默认 24 小时后过期 | +| Redis 服务器故障 | ⚠️ 取决于持久化配置 | 如果持久化配置不当可能丢失 | +| 使用内存缓存时 | ✅ **会丢失** | 每次执行后丢失 | + +### 3. TTL(生存时间)设置 + +当前配置: +- **TTL**:**86400 秒**(24 小时) +- **位置**:`cache-update` 节点的 `ttl` 配置 +- **默认值**:如果未配置,默认 3600 秒(1 小时) + +⚠️ **重要**:如果用户在 24 小时内没有新的对话,记忆数据会**自动过期删除**。 + +## 四、配置说明 + +### 1. Redis 配置 + +**环境变量**(`docker-compose.dev.yml`): +```yaml +REDIS_URL=redis://redis:6379/0 +``` + +**配置文件**(`backend/.env`): +```env +REDIS_URL=redis://localhost:6379/0 +``` + +### 2. Cache 节点配置 + +**查询记忆节点**(`cache-query`): +```python +{ + "id": "cache-query", + "type": "cache", + "data": { + "operation": "get", + "key": "user_memory_{user_id}", + "default_value": '{"conversation_history": [], "user_profile": {}, "context": {}}' + } +} +``` + +**更新记忆节点**(`cache-update`): +```python +{ + "id": "cache-update", + "type": "cache", + "data": { + "operation": "set", + "key": "user_memory_{user_id}", + "value": '{"conversation_history": {{memory.conversation_history}} + [...], ...}', + "ttl": 86400 # 24小时 + } +} +``` + +## 五、优化建议 + +### 1. 对话历史截断策略 + +如果对话历史过长,建议实施截断策略: + +**方案 A:保留最近 N 条** +```python +# 在 cache-update 节点中,限制 conversation_history 长度 +conversation_history = memory.conversation_history[-100:] # 只保留最近100条 +``` + +**方案 B:按时间截断** +```python +# 只保留最近7天的对话 +from datetime import datetime, timedelta +cutoff_date = (datetime.now() - timedelta(days=7)).isoformat() +conversation_history = [ + msg for msg in memory.conversation_history + if msg.get('timestamp', '') > cutoff_date +] +``` + +**方案 C:智能摘要** +```python +# 将旧对话历史压缩为摘要 +# 使用 LLM 节点生成摘要,保留关键信息 +``` + +### 2. 增加 TTL 时间 + +如果需要更长的记忆保留时间,可以修改 TTL: + +```python +"ttl": 604800 # 7天 +"ttl": 2592000 # 30天 +"ttl": 0 # 永不过期(不推荐,可能导致内存溢出) +``` + +### 3. 监控 Redis 内存使用 + +定期检查 Redis 内存使用情况: + +```bash +# 进入 Redis 容器 +docker exec -it aiagent-redis-1 redis-cli + +# 查看内存信息 +INFO memory + +# 查看所有用户记忆 key +KEYS user_memory_* + +# 查看特定 key 的大小 +MEMORY USAGE user_memory_default +``` + +### 4. 数据备份策略 + +**定期备份 Redis 数据**: + +```bash +# 备份 Redis 数据 +docker exec aiagent-redis-1 redis-cli SAVE +docker cp aiagent-redis-1:/data/dump.rdb ./backup/dump_$(date +%Y%m%d).rdb +``` + +**恢复 Redis 数据**: + +```bash +# 恢复 Redis 数据 +docker cp ./backup/dump_20240101.rdb aiagent-redis-1:/data/dump.rdb +docker restart aiagent-redis-1 +``` + +## 六、常见问题 + +### Q1: 数据会丢失吗? + +**A**: +- 如果使用 Redis 且配置了持久化:**不会丢失**(除非数据卷被删除或超过 TTL) +- 如果使用内存缓存:**会丢失**(每次执行后丢失) + +### Q2: 可以存储多少对话? + +**A**: +- 理论上:取决于 Redis 服务器内存限制 +- 实际建议:**1000-5000 轮对话**(约 1-6 MB) +- 超过 10000 轮建议实施截断策略 + +### Q3: 如何延长记忆保留时间? + +**A**: +- 修改 `cache-update` 节点的 `ttl` 配置 +- 设置为更大的值(如 2592000 = 30 天) +- 或设置为 0(永不过期,需谨慎) + +### Q4: 如何清理特定用户的记忆? + +**A**: +```bash +# 通过 Redis CLI +docker exec -it aiagent-redis-1 redis-cli DEL user_memory_{user_id} + +# 或通过工作流添加 delete 操作节点 +``` + +### Q5: 多个用户的数据会互相影响吗? + +**A**: +- **不会**,每个用户使用独立的 key:`user_memory_{user_id}` +- 数据完全隔离 + +## 七、总结 + +### 当前配置 + +- ✅ **存储位置**:Redis(持久化) +- ✅ **TTL**:24 小时 +- ✅ **数据格式**:JSON(包含对话历史、用户画像、上下文) +- ✅ **大小限制**:无硬编码限制(取决于 Redis 配置) +- ⚠️ **数据丢失风险**:低(除非数据卷被删除或超过 TTL) + +### 建议 + +1. **短期使用**(< 1000 轮对话):当前配置足够 +2. **长期使用**(> 5000 轮对话):建议实施对话历史截断策略 +3. **生产环境**:建议定期备份 Redis 数据,监控内存使用 +4. **高可用场景**:考虑 Redis 主从复制或集群模式 + +--- + +**文档版本**:v1.0 +**最后更新**:2024年 +**维护人员**:AI Assistant diff --git a/智能体聊天助手记忆问题修复.md b/智能体聊天助手记忆问题修复.md new file mode 100644 index 0000000..e70b2de --- /dev/null +++ b/智能体聊天助手记忆问题修复.md @@ -0,0 +1,344 @@ +# 智能体聊天助手记忆问题修复文档 + +## 问题描述 + +智能聊天助手无法记住用户信息,具体表现为: +1. 第一次对话:用户输入 "我的名字叫老七" +2. 第二次对话:用户输入 "你还记得我的名字吗?" +3. **预期结果**:助手应该回答 "是的,我记得你叫老七" +4. **实际结果**:助手无法记住用户名字,或者回答错误 + +**额外问题**:助手有时会回复两次相同的消息。 + +## 问题分析 + +### 工作流结构 + +智能聊天助手的工作流包含以下关键节点: +1. **开始节点** (`start-1`) - 接收用户输入 +2. **查询记忆节点** (`cache-query`) - 从Redis查询对话历史 +3. **合并上下文节点** (`transform-merge`) - 合并用户输入和记忆 +4. **意图理解节点** (`llm-intent`) - 分析用户意图 +5. **意图路由节点** (`switch-intent`) - 根据意图分发到不同分支 +6. **问题回答节点** (`llm-question`) - 生成回答 +7. **合并回复节点** (`merge-response`) - 合并各分支结果 +8. **更新记忆节点** (`cache-update`) - 更新对话历史到Redis +9. **格式化回复节点** (`llm-format`) - 格式化最终回复 +10. **结束节点** (`end-1`) - 返回最终结果 + +### 根本原因 + +经过深入调试,发现以下问题: + +#### 1. Cache节点数据存储问题 +- **问题**:`cache-update` 节点的 `value_template` 中,`{{user_input}}`、`{{output}}`、`{{timestamp}}` 等变量在Python表达式执行时被当作字符串 `"null"` 处理 +- **原因**:变量替换逻辑在Python表达式执行之后,导致变量未正确替换 + +#### 2. Cache节点数据读取问题 +- **问题**:`cache-query` 节点的 `default_value` 中,`conversation_history` 初始化为 `null`,导致后续 `null + [...]` 操作失败 +- **原因**:JSON解析后,空值被解析为 `None`,而不是空列表 `[]` + +#### 3. Transform节点数据丢失问题 +- **问题**:`transform-merge` 节点的 `mapping` 中,`{{output}}` 映射到 `memory` 字段,但 `cache-query` 的输出结构不包含完整的 `memory` 对象 +- **原因**:`cache-query` 返回的数据结构是 `{"right": {...}}`,而 `transform-merge` 期望的是完整的 `memory` 对象 + +#### 4. 边连接导致的数据丢失问题 +- **问题**:`cache-query → transform-merge` 的边设置了 `sourceHandle='right'`,导致只有 `right` 字段被传递,其他内存相关字段丢失 +- **原因**:`get_node_input` 方法在处理 `sourceHandle` 时,只传递了指定字段,没有保留内存相关字段 + +#### 5. LLM节点变量替换问题 +- **问题1**:LLM节点的prompt模板中,`{{user_input}}` 和 `{{memory.conversation_history}}` 无法正确替换 +- **原因**:变量替换逻辑不支持嵌套路径(如 `{{memory.conversation_history}}`) +- **问题2**:`{{output}}` 变量无法从嵌套的 `right.right.right` 结构中提取 +- **原因**:变量提取逻辑只检查顶层字段,没有递归查找 + +#### 6. 前端重复回复问题 +- **问题**:助手有时会回复两次相同的消息 +- **原因**:轮询逻辑中,`checkStatus` 函数在状态为 `completed` 时可能被多次调用,导致重复添加消息 + +## 修复方案 + +### 1. 修复Cache节点的变量替换逻辑 + +**文件**:`backend/app/services/workflow_engine.py` + +**修改位置**:`execute_node` 方法中的 `cache` 节点处理逻辑 + +**关键修改**: +```python +# 在Python表达式执行之前,先替换变量 +if '{{user_input}}' in value_template: + user_input_value = input_data.get('user_input') or input_data.get('query') or input_data.get('input') or input_data.get('USER_INPUT') or '' + user_input_escaped = json_module.dumps(user_input_value, ensure_ascii=False)[1:-1] # 移除外层引号 + value_template = value_template.replace('{{user_input}}', user_input_escaped) + +if '{{output}}' in value_template: + output_value = self._extract_output_value(input_data) + output_escaped = json_module.dumps(output_value, ensure_ascii=False)[1:-1] # 移除外层引号 + value_template = value_template.replace('{{output}}', output_escaped) + +if '{{timestamp}}' in value_template: + timestamp_value = input_data.get('timestamp') or datetime.now().isoformat() + timestamp_escaped = json_module.dumps(timestamp_value, ensure_ascii=False)[1:-1] # 移除外层引号 + value_template = value_template.replace('{{timestamp}}', timestamp_escaped) +``` + +**说明**: +- 使用 `json.dumps()[1:-1]` 来正确转义字符串,同时移除外层引号 +- 在Python表达式执行之前完成变量替换 + +### 2. 修复Cache节点的默认值初始化 + +**文件**:`backend/app/services/workflow_engine.py` + +**修改位置**:`execute_node` 方法中的 `cache-query` 处理逻辑 + +**关键修改**: +```python +# 确保default_value中的conversation_history初始化为空列表 +default_value_str = node_data.get('default_value', '{}') +try: + default_value = json_module.loads(default_value_str) + # 确保conversation_history是列表而不是null + if 'conversation_history' not in default_value or default_value.get('conversation_history') is None: + default_value['conversation_history'] = [] +except: + default_value = {"conversation_history": [], "user_profile": {}, "context": {}} +``` + +### 3. 修复Transform节点的Memory字段构建 + +**文件**:`backend/app/services/workflow_engine.py` + +**修改位置**:`execute_node` 方法中的 `transform` 节点处理逻辑(`merge` 模式) + +**关键修改**: +```python +# 如果memory字段为空,尝试从顶层字段构建 +if key == 'memory' and (value is None or value == '' or value == '{{output}}'): + # 尝试从expanded_input中构建memory对象 + memory = {} + for field in ['conversation_history', 'user_profile', 'context']: + if field in expanded_input: + memory[field] = expanded_input[field] + if memory: + result[key] = memory + else: + # 如果还是找不到,保留原有的memory字段(如果有) + if 'memory' in expanded_input: + result[key] = expanded_input['memory'] +else: + result[key] = value +``` + +### 4. 修复边连接导致的数据丢失 + +**文件**:`backend/app/services/workflow_engine.py` + +**修改位置**:`get_node_input` 方法 + +**关键修改**: +```python +# 即使有sourceHandle,也要保留内存相关字段 +if edge.get('sourceHandle'): + input_data[edge['sourceHandle']] = source_output + # 显式保留内存相关字段 + if isinstance(source_output, dict): + for field in ['conversation_history', 'user_profile', 'context', 'memory']: + if field in source_output: + input_data[field] = source_output[field] +else: + # ... 原有逻辑 +``` + +### 5. 修复LLM节点的嵌套路径变量支持 + +**文件**:`backend/app/services/workflow_engine.py` + +**修改位置**:`execute_node` 方法中的 `llm` 节点处理逻辑(变量替换部分) + +**关键修改**: +```python +# 支持嵌套路径,如 {{memory.conversation_history}} +if '.' in var_name: + value = self._get_nested_value(input_data, var_name) +else: + # 原有逻辑:检查别名和直接字段 + value = input_data.get(var_name) + if value is None: + # 检查别名 + aliases = { + 'user_input': ['query', 'input', 'USER_INPUT', 'user_input'], + 'output': ['result', 'response', 'text', 'content'] + } + for alias_key, alias_list in aliases.items(): + if var_name == alias_key: + for alias in alias_list: + value = input_data.get(alias) + if value is not None: + break +``` + +**特殊处理**:`{{memory.conversation_history}}` 格式化 +```python +# 如果是conversation_history,格式化为可读格式 +if var_name == 'memory.conversation_history' and isinstance(value, list): + formatted_history = [] + for msg in value: + role = msg.get('role', 'unknown') + content = msg.get('content', '') + if role == 'user': + formatted_history.append(f"用户: {content}") + elif role == 'assistant': + formatted_history.append(f"助手: {content}") + value = '\n'.join(formatted_history) if formatted_history else '无对话历史' +``` + +### 6. 修复{{output}}变量的递归提取 + +**文件**:`backend/app/services/workflow_engine.py` + +**修改位置**:`execute_node` 方法中的 `llm` 节点处理逻辑(`{{output}}` 变量处理) + +**关键修改**: +```python +# 特殊处理output变量:递归查找right字段 +if var_path == 'output': + right_value = input_data.get('right') + if right_value: + # 递归查找字符串值 + def extract_string_from_right(obj, depth=0): + if isinstance(obj, str): + return obj + elif isinstance(obj, dict): + # 优先检查常见字段 + for key in ['content', 'text', 'message', 'output']: + if key in obj and isinstance(obj[key], str): + return obj[key] + # 递归查找right字段 + if 'right' in obj: + return extract_string_from_right(obj['right'], depth + 1) + return None + + value = extract_string_from_right(right_value) + if value: + logger.info(f"[rjb] LLM节点从right字段提取output: {value[:100]}") +``` + +### 7. 修复前端重复回复问题 + +**文件**:`frontend/src/components/AgentChatPreview.vue` + +**修改位置**:`handleSendMessage` 方法中的 `checkStatus` 函数 + +**关键修改**: +```typescript +// 添加标志位,防止重复添加回复 +let replyAdded = false + +const checkStatus = async () => { + try { + // 如果已经添加过回复,直接返回 + if (replyAdded) { + return + } + + // ... 获取执行状态 ... + + if (exec.status === 'completed') { + // 防止重复添加 + if (replyAdded) { + return + } + + // 标记已添加回复 + replyAdded = true + + // 添加回复消息 + messages.value.push({ + role: 'agent', + content: agentReply || '执行完成', + timestamp: Date.now() + }) + + // ... 其他逻辑 ... + } + } catch (error) { + // 同样添加防重复逻辑 + if (replyAdded) { + return + } + replyAdded = true + // ... 错误处理 ... + } +} + +// 每次发送新消息时重置标志位 +replyAdded = false +``` + +## 测试验证 + +### 测试步骤 + +1. **第一次对话**: + - 输入:`我的名字叫老七` + - 预期:助手正常回复,并将信息存储到记忆 + +2. **第二次对话**: + - 输入:`你还记得我的名字吗?` + - 预期:助手回答 `是的,我记得你叫老七` + +3. **验证重复回复**: + - 观察是否只收到一次回复 + +### 测试结果 + +✅ **记忆功能**:正常工作,能正确记住用户名字 +✅ **重复回复**:已修复,每次对话只回复一次 + +## 关键代码文件 + +1. **后端核心逻辑**: + - `backend/app/services/workflow_engine.py` - 工作流执行引擎 + - `backend/scripts/generate_chat_agent.py` - 智能聊天助手工作流定义 + +2. **前端组件**: + - `frontend/src/components/AgentChatPreview.vue` - 聊天预览组件 + +3. **测试脚本**: + - `test_memory_functionality.py` - 记忆功能测试脚本 + - `test_output_variable_extraction.py` - 输出变量提取测试脚本 + +## 修复时间线 + +1. **问题发现**:用户报告无法记住名字 +2. **初步分析**:检查工作流定义和节点配置 +3. **深入调试**:添加详细日志,追踪数据流 +4. **修复Cache节点**:修复数据存储和读取逻辑 +5. **修复Transform节点**:修复数据合并逻辑 +6. **修复LLM节点**:修复变量替换和嵌套路径支持 +7. **修复前端**:修复重复回复问题 +8. **测试验证**:确认所有问题已解决 + +## 经验总结 + +1. **数据流追踪**:在复杂工作流中,需要仔细追踪数据在每个节点之间的传递 +2. **变量替换时机**:变量替换必须在表达式执行之前完成 +3. **数据结构一致性**:确保上下游节点对数据结构的期望一致 +4. **边界情况处理**:注意处理 `null`、空值、嵌套结构等边界情况 +5. **前端防重复**:轮询逻辑中需要添加防重复机制 + +## 后续优化建议 + +1. **统一数据结构**:定义统一的数据结构规范,避免节点间数据格式不一致 +2. **增强日志**:添加更详细的调试日志,方便问题排查 +3. **单元测试**:为关键节点添加单元测试,确保修复的稳定性 +4. **性能优化**:优化轮询频率,减少不必要的API调用 +5. **错误处理**:增强错误处理机制,提供更友好的错误提示 + +--- + +**修复完成时间**:2024年(根据实际时间填写) +**修复人员**:AI Assistant +**文档版本**:v1.0 diff --git a/聊天智能体示例.json b/聊天智能体示例.json new file mode 100644 index 0000000..6f13e15 --- /dev/null +++ b/聊天智能体示例.json @@ -0,0 +1,374 @@ +{ + "name": "智能聊天助手(完整示例)", + "description": "一个完整的聊天智能体示例,展示平台的核心能力:\n- ✅ 记忆管理:使用缓存节点存储和查询对话历史\n- ✅ 意图识别:使用LLM节点分析用户意图\n- ✅ 多分支路由:使用Switch节点根据意图分发到不同处理分支\n- ✅ 上下文传递:使用Transform节点合并数据\n- ✅ 多轮对话:支持上下文记忆和连贯对话\n- ✅ 个性化回复:根据不同意图生成针对性回复\n\n适用场景:情感陪聊、客服助手、智能问答等聊天场景。", + "workflow_config": { + "nodes": [ + { + "id": "start-1", + "type": "start", + "position": { + "x": 50, + "y": 400 + }, + "data": { + "label": "开始", + "output_format": "json" + } + }, + { + "id": "cache-query", + "type": "cache", + "position": { + "x": 250, + "y": 400 + }, + "data": { + "label": "查询记忆", + "operation": "get", + "key": "user_memory_{user_id}", + "default_value": "{\"conversation_history\": [], \"user_profile\": {}, \"context\": {}}" + } + }, + { + "id": "transform-merge", + "type": "transform", + "position": { + "x": 450, + "y": 400 + }, + "data": { + "label": "合并上下文", + "mode": "merge", + "mapping": { + "user_input": "{{query}}", + "memory": "{{output}}", + "timestamp": "{{timestamp}}" + } + } + }, + { + "id": "llm-intent", + "type": "llm", + "position": { + "x": 650, + "y": 400 + }, + "data": { + "label": "意图理解", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.3", + "max_tokens": "1000", + "prompt": "你是一个专业的对话意图分析助手。请分析用户的输入,识别用户的意图和情感。\n\n用户输入:{{user_input}}\n对话历史:{{memory.conversation_history}}\n用户画像:{{memory.user_profile}}\n\n请以JSON格式输出分析结果:\n{\n \"intent\": \"意图类型(greeting/question/emotion/request/goodbye/other)\",\n \"emotion\": \"情感状态(positive/neutral/negative)\",\n \"keywords\": [\"关键词1\", \"关键词2\"],\n \"topic\": \"话题主题\",\n \"needs_response\": true\n}\n\n请确保输出是有效的JSON格式,不要包含其他文字。" + } + }, + { + "id": "switch-intent", + "type": "switch", + "position": { + "x": 850, + "y": 400 + }, + "data": { + "label": "意图路由", + "field": "intent", + "cases": { + "greeting": "greeting-handle", + "question": "question-handle", + "emotion": "emotion-handle", + "request": "request-handle", + "goodbye": "goodbye-handle" + }, + "default": "general-handle" + } + }, + { + "id": "llm-greeting", + "type": "llm", + "position": { + "x": 1050, + "y": 200 + }, + "data": { + "label": "问候回复", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.7", + "max_tokens": "500", + "prompt": "你是一个温暖、友好的AI助手。用户向你打招呼,请用自然、亲切的方式回应。\n\n用户输入:{{user_input}}\n对话历史:{{memory.conversation_history}}\n\n请生成一个友好、自然的问候回复,长度控制在50字以内。直接输出回复内容,不要包含其他说明。" + } + }, + { + "id": "llm-question", + "type": "llm", + "position": { + "x": 1050, + "y": 300 + }, + "data": { + "label": "问题回答", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.5", + "max_tokens": "2000", + "prompt": "你是一个知识渊博、乐于助人的AI助手。请回答用户的问题。\n\n用户问题:{{user_input}}\n对话历史:{{memory.conversation_history}}\n意图分析:{{output}}\n\n请提供:\n1. 直接、准确的答案\n2. 必要的解释和说明\n3. 如果问题不明确,友好地询问更多信息\n\n请以自然、易懂的方式回答,长度控制在200字以内。直接输出回答内容。" + } + }, + { + "id": "llm-emotion", + "type": "llm", + "position": { + "x": 1050, + "y": 400 + }, + "data": { + "label": "情感回应", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.8", + "max_tokens": "1000", + "prompt": "你是一个善解人意的AI助手。请根据用户的情感状态,给予适当的回应。\n\n用户输入:{{user_input}}\n情感状态:{{output.emotion}}\n对话历史:{{memory.conversation_history}}\n\n请根据用户的情感:\n- 如果是积极情感:给予鼓励和共鸣\n- 如果是消极情感:给予理解、安慰和支持\n- 如果是中性情感:给予关注和陪伴\n\n请生成一个温暖、共情的回复,长度控制在150字以内。直接输出回复内容。" + } + }, + { + "id": "llm-request", + "type": "llm", + "position": { + "x": 1050, + "y": 500 + }, + "data": { + "label": "请求处理", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.4", + "max_tokens": "1500", + "prompt": "你是一个专业的AI助手。用户提出了一个请求,请分析并回应。\n\n用户请求:{{user_input}}\n意图分析:{{output}}\n对话历史:{{memory.conversation_history}}\n\n请:\n1. 理解用户的请求内容\n2. 如果可以满足,说明如何满足\n3. 如果无法满足,友好地说明原因并提供替代方案\n\n请以清晰、友好的方式回应,长度控制在200字以内。直接输出回复内容。" + } + }, + { + "id": "llm-goodbye", + "type": "llm", + "position": { + "x": 1050, + "y": 600 + }, + "data": { + "label": "告别回复", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.6", + "max_tokens": "300", + "prompt": "你是一个友好的AI助手。用户要结束对话,请给予温暖的告别。\n\n用户输入:{{user_input}}\n对话历史:{{memory.conversation_history}}\n\n请生成一个温暖、友好的告别回复,表达期待下次交流。长度控制在50字以内。直接输出回复内容。" + } + }, + { + "id": "llm-general", + "type": "llm", + "position": { + "x": 1050, + "y": 700 + }, + "data": { + "label": "通用回复", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.6", + "max_tokens": "1000", + "prompt": "你是一个友好、专业的AI助手。请回应用户的输入。\n\n用户输入:{{user_input}}\n对话历史:{{memory.conversation_history}}\n意图分析:{{output}}\n\n请生成一个自然、有意义的回复,保持对话的连贯性。长度控制在150字以内。直接输出回复内容。" + } + }, + { + "id": "merge-response", + "type": "merge", + "position": { + "x": 1250, + "y": 400 + }, + "data": { + "label": "合并回复", + "mode": "merge_first", + "strategy": "object" + } + }, + { + "id": "cache-update", + "type": "cache", + "position": { + "x": 1450, + "y": 400 + }, + "data": { + "label": "更新记忆", + "operation": "set", + "key": "user_memory_{user_id}", + "value": "{\"conversation_history\": {{memory.conversation_history}} + [{\"role\": \"user\", \"content\": \"{{user_input}}\", \"timestamp\": \"{{timestamp}}\"}, {\"role\": \"assistant\", \"content\": \"{{output}}\", \"timestamp\": \"{{timestamp}}\"}], \"user_profile\": {{memory.user_profile}}, \"context\": {{memory.context}}}", + "ttl": 86400 + } + }, + { + "id": "llm-format", + "type": "llm", + "position": { + "x": 1650, + "y": 400 + }, + "data": { + "label": "格式化回复", + "provider": "deepseek", + "model": "deepseek-chat", + "temperature": "0.3", + "max_tokens": "500", + "prompt": "请将以下回复内容格式化为最终输出。确保回复自然、流畅。\n\n原始回复:{{output}}\n\n请直接输出格式化后的回复内容,不要包含其他说明或标记。如果原始回复已经是合适的格式,直接输出即可。" + } + }, + { + "id": "end-1", + "type": "end", + "position": { + "x": 1850, + "y": 400 + }, + "data": { + "label": "结束", + "output_format": "text" + } + } + ], + "edges": [ + { + "id": "e1", + "source": "start-1", + "target": "cache-query", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e2", + "source": "cache-query", + "target": "transform-merge", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e3", + "source": "transform-merge", + "target": "llm-intent", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e4", + "source": "llm-intent", + "target": "switch-intent", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e5-greeting", + "source": "switch-intent", + "target": "llm-greeting", + "sourceHandle": "greeting-handle", + "targetHandle": "left" + }, + { + "id": "e5-question", + "source": "switch-intent", + "target": "llm-question", + "sourceHandle": "question-handle", + "targetHandle": "left" + }, + { + "id": "e5-emotion", + "source": "switch-intent", + "target": "llm-emotion", + "sourceHandle": "emotion-handle", + "targetHandle": "left" + }, + { + "id": "e5-request", + "source": "switch-intent", + "target": "llm-request", + "sourceHandle": "request-handle", + "targetHandle": "left" + }, + { + "id": "e5-goodbye", + "source": "switch-intent", + "target": "llm-goodbye", + "sourceHandle": "goodbye-handle", + "targetHandle": "left" + }, + { + "id": "e5-general", + "source": "switch-intent", + "target": "llm-general", + "sourceHandle": "default", + "targetHandle": "left" + }, + { + "id": "e6-greeting", + "source": "llm-greeting", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e6-question", + "source": "llm-question", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e6-emotion", + "source": "llm-emotion", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e6-request", + "source": "llm-request", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e6-goodbye", + "source": "llm-goodbye", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e6-general", + "source": "llm-general", + "target": "merge-response", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e7", + "source": "merge-response", + "target": "cache-update", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e8", + "source": "cache-update", + "target": "llm-format", + "sourceHandle": "right", + "targetHandle": "left" + }, + { + "id": "e9", + "source": "llm-format", + "target": "end-1", + "sourceHandle": "right", + "targetHandle": "left" + } + ] + } +} \ No newline at end of file diff --git a/聊天智能体示例说明.md b/聊天智能体示例说明.md new file mode 100644 index 0000000..72a3db6 --- /dev/null +++ b/聊天智能体示例说明.md @@ -0,0 +1,337 @@ +# 智能聊天Agent完整示例说明 + +## 📋 概述 + +这是一个完整的聊天智能体示例,展示了如何使用平台的核心能力构建一个功能完善的聊天助手。该示例包含了记忆管理、意图识别、多分支路由、上下文传递等核心功能。 + +## 🎯 功能特性 + +### ✅ 核心能力展示 + +1. **记忆管理** + - 使用缓存节点存储对话历史 + - 支持用户画像和上下文信息 + - 自动更新记忆内容 + +2. **意图识别** + - 使用LLM节点分析用户意图 + - 识别情感状态 + - 提取关键词和话题 + +3. **多分支路由** + - 使用Switch节点根据意图分发 + - 支持6种不同场景的处理分支 + - 默认分支处理未知意图 + +4. **上下文传递** + - 使用Transform节点合并数据 + - 保持对话历史的连贯性 + - 支持多轮对话 + +5. **个性化回复** + - 根据不同意图生成针对性回复 + - 考虑用户情感状态 + - 保持对话风格一致 + +## 🔄 工作流结构 + +``` +开始节点 + ↓ +查询记忆(Cache节点) + ↓ +合并上下文(Transform节点) + ↓ +意图理解(LLM节点) + ↓ +意图路由(Switch节点) + ├─→ 问候处理(greeting) + ├─→ 问题回答(question) + ├─→ 情感回应(emotion) + ├─→ 请求处理(request) + ├─→ 告别回复(goodbye) + └─→ 通用回复(default) + ↓ +合并回复(Merge节点) + ↓ +更新记忆(Cache节点) + ↓ +格式化回复(LLM节点) + ↓ +结束节点 +``` + +## 📊 节点详细说明 + +### 1. 开始节点(start-1) +- **功能**: 接收用户输入 +- **输入格式**: JSON格式,包含 `query` 字段 +- **输出**: 将用户输入传递给后续节点 + +### 2. 查询记忆节点(cache-query) +- **类型**: Cache节点 +- **操作**: `get` - 获取用户记忆 +- **Key**: `user_memory_{user_id}` +- **默认值**: 空记忆结构 +- **功能**: 从缓存中读取用户的对话历史和画像信息 + +### 3. 合并上下文节点(transform-merge) +- **类型**: Transform节点 +- **模式**: `merge` - 合并模式 +- **功能**: 将用户输入、记忆数据、时间戳合并为完整上下文 + +### 4. 意图理解节点(llm-intent) +- **类型**: LLM节点 +- **模型**: DeepSeek Chat +- **功能**: + - 分析用户输入 + - 识别意图类型(greeting/question/emotion/request/goodbye/other) + - 识别情感状态(positive/neutral/negative) + - 提取关键词和话题 +- **输出格式**: JSON + ```json + { + "intent": "意图类型", + "emotion": "情感状态", + "keywords": ["关键词"], + "topic": "话题主题", + "needs_response": true + } + ``` + +### 5. 意图路由节点(switch-intent) +- **类型**: Switch节点 +- **功能**: 根据意图类型路由到不同的处理分支 +- **分支**: + - `greeting` → 问候处理 + - `question` → 问题回答 + - `emotion` → 情感回应 + - `request` → 请求处理 + - `goodbye` → 告别回复 + - `default` → 通用回复 + +### 6. 各分支处理节点(llm-*) +- **类型**: LLM节点 +- **功能**: 根据不同意图生成针对性的回复 +- **特点**: + - 问候分支:友好、亲切 + - 问题分支:准确、详细 + - 情感分支:共情、温暖 + - 请求分支:专业、清晰 + - 告别分支:温暖、期待 + - 通用分支:自然、连贯 + +### 7. 合并回复节点(merge-response) +- **类型**: Merge节点 +- **模式**: `merge_first` - 合并第一个结果 +- **功能**: 将各分支的回复结果合并 + +### 8. 更新记忆节点(cache-update) +- **类型**: Cache节点 +- **操作**: `set` - 设置记忆 +- **功能**: + - 将本次对话添加到历史记录 + - 更新用户画像(如需要) + - 保存上下文信息 +- **TTL**: 86400秒(24小时) + +### 9. 格式化回复节点(llm-format) +- **类型**: LLM节点 +- **功能**: 对最终回复进行格式化和优化 +- **输出**: 自然、流畅的文本回复 + +### 10. 结束节点(end-1) +- **功能**: 返回最终回复 +- **输出格式**: 纯文本 + +## 🚀 使用方法 + +### 方法一:使用生成脚本(推荐) + +```bash +cd backend/scripts +python3 generate_chat_agent.py +``` + +脚本会自动创建Agent,包含完整的工作流配置。 + +### 方法二:手动创建 + +1. **进入Agent管理页面** + - 点击"创建Agent"按钮 + - 填写名称和描述 + +2. **进入工作流编辑器** + - 点击"设计"按钮 + - 使用节点工具箱添加节点 + - 按照工作流结构连接节点 + +3. **配置节点** + - **LLM节点**: 配置API密钥、模型、Prompt + - **Cache节点**: 配置缓存Key和操作 + - **Switch节点**: 配置路由规则 + - **Transform节点**: 配置数据映射 + +4. **测试Agent** + - 点击"测试"按钮 + - 输入测试消息 + - 查看执行结果 + +5. **发布Agent** + - 点击"发布"按钮 + - Agent状态变为"已发布" + - 可以开始使用 + +## 📝 配置要点 + +### 1. LLM节点配置 + +- **Provider**: 选择AI模型提供商(如DeepSeek、OpenAI) +- **Model**: 选择具体模型(如deepseek-chat、gpt-3.5-turbo) +- **Temperature**: + - 意图识别:0.3(需要准确性) + - 情感回应:0.8(需要创造性) + - 问题回答:0.5(平衡准确性和灵活性) +- **Max Tokens**: 根据回复长度需求设置 +- **Prompt**: 明确角色、任务、输出格式要求 + +### 2. Cache节点配置 + +- **Operation**: + - `get`: 查询记忆 + - `set`: 更新记忆 +- **Key**: 使用用户ID确保记忆隔离 +- **TTL**: 设置合适的过期时间(如24小时) + +### 3. Switch节点配置 + +- **Field**: 指定用于路由的字段(如`intent`) +- **Cases**: 配置各分支的路由规则 +- **Default**: 配置默认分支 + +### 4. Transform节点配置 + +- **Mode**: 选择合并模式(`merge`) +- **Mapping**: 配置数据映射规则 +- **变量引用**: 使用`{{variable}}`引用上游数据 + +## 🎨 自定义扩展 + +### 1. 添加新的意图分支 + +1. 在Switch节点中添加新的case +2. 创建对应的LLM处理节点 +3. 连接Switch节点和处理节点 +4. 连接处理节点到Merge节点 + +### 2. 增强记忆功能 + +- 添加用户画像更新逻辑 +- 实现长期记忆和短期记忆分离 +- 添加记忆检索和总结功能 + +### 3. 添加外部工具 + +- 集成知识库查询 +- 添加天气、新闻等外部API +- 实现文件处理功能 + +### 4. 优化回复质量 + +- 添加回复质量评估节点 +- 实现多候选回复生成和选择 +- 添加回复风格控制 + +## 🔍 测试示例 + +### 测试用例1:问候 +``` +输入: "你好" +预期: 友好的问候回复 +``` + +### 测试用例2:问题 +``` +输入: "今天天气怎么样?" +预期: 尝试回答问题或说明无法获取天气信息 +``` + +### 测试用例3:情感表达 +``` +输入: "我今天心情不太好" +预期: 共情、安慰的回复 +``` + +### 测试用例4:请求 +``` +输入: "帮我写一首诗" +预期: 生成诗歌或说明能力范围 +``` + +### 测试用例5:告别 +``` +输入: "再见" +预期: 温暖的告别回复 +``` + +## ⚠️ 注意事项 + +1. **API密钥配置** + - 确保所有LLM节点都配置了有效的API密钥 + - 检查API配额和限制 + +2. **记忆管理** + - Cache节点使用内存缓存,重启后会丢失 + - 生产环境建议使用Redis等持久化缓存 + +3. **性能优化** + - 减少不必要的LLM调用 + - 优化Prompt长度 + - 合理设置Token限制 + +4. **错误处理** + - 添加错误处理节点 + - 配置重试机制 + - 提供友好的错误提示 + +## 📚 相关文档 + +- [创建Agent经验总结](./创建Agent经验.md) +- [工作流节点类型说明](./可新增节点类型建议.md) +- [Agent使用说明](./Agent使用说明.md) + +## 🎯 适用场景 + +- ✅ 情感陪聊助手 +- ✅ 客服机器人 +- ✅ 智能问答系统 +- ✅ 对话式AI应用 +- ✅ 个性化聊天助手 + +## 💡 最佳实践 + +1. **Prompt设计** + - 明确角色定位 + - 明确输出格式 + - 提供示例和上下文 + +2. **工作流设计** + - 保持流程清晰 + - 合理使用分支 + - 避免过度复杂 + +3. **记忆管理** + - 定期清理过期记忆 + - 控制记忆大小 + - 保护用户隐私 + +4. **测试验证** + - 覆盖各种场景 + - 测试边界情况 + - 验证回复质量 + +--- + +**创建时间**: 2024年 +**版本**: 1.0 +**作者**: AI Agent Platform