diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml new file mode 100644 index 00000000..bc67518b --- /dev/null +++ b/.github/workflows/release-notify-wechat.yml @@ -0,0 +1,157 @@ +name: Release Notify Workflow + +on: + pull_request: + types: [closed] + +jobs: + notify: + if: > + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.base.ref, 'release') + runs-on: ubuntu-latest + + steps: + # 防止 GitHub HEAD 未同步 + - run: sleep 3 + + # 1️⃣ 获取分支 HEAD + - name: Get HEAD + id: head + run: | + HEAD_SHA=$(curl -s \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + https://api.github.com/repos/${{ github.repository }}/git/ref/heads/${{ github.event.pull_request.base.ref }} \ + | jq -r '.object.sha') + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + + # 2️⃣ 判断是否最终PR + - name: Check Latest + id: check + run: | + if [ "${{ github.event.pull_request.merge_commit_sha }}" = "${{ steps.head.outputs.head_sha }}" ]; then + echo "ok=true" >> $GITHUB_OUTPUT + else + echo "ok=false" >> $GITHUB_OUTPUT + fi + + # 3️⃣ 尝试从 PR body 提取 Sourcery 摘要 + - name: Extract Sourcery Summary + if: steps.check.outputs.ok == 'true' + id: sourcery + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + python3 << 'PYEOF' + import os, re + + body = os.environ.get("PR_BODY", "") or "" + match = re.search( + r"## Summary by Sourcery\s*\n(.*?)(?=\n## |\Z)", + body, + re.DOTALL + ) + + if match: + summary = match.group(1).strip() + found = "true" + else: + summary = "" + found = "false" + + with open("sourcery_summary.txt", "w", encoding="utf-8") as f: + f.write(summary) + + with open(os.environ["GITHUB_OUTPUT"], "a") as gh: + gh.write(f"found={found}\n") + gh.write("summary< commits.txt + + - name: AI Summary (Qwen Fallback) + if: steps.check.outputs.ok == 'true' && steps.sourcery.outputs.found == 'false' + id: qwen + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + run: | + python3 << 'PYEOF' + import json, os, urllib.request + + with open("commits.txt", "r") as f: + commits = f.read().strip() + + prompt = "请用中文总结以下代码提交,输出3-5条要点,面向测试人员。直接输出编号列表,不要输出标题或前言:\n" + commits + payload = {"model": "qwen-plus", "input": {"prompt": prompt}} + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + + req = urllib.request.Request( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation", + data=data, + headers={ + "Authorization": "Bearer " + os.environ["DASHSCOPE_API_KEY"], + "Content-Type": "application/json" + } + ) + resp = urllib.request.urlopen(req) + result = json.loads(resp.read().decode()) + summary = result.get("output", {}).get("text", "AI 摘要生成失败") + + with open(os.environ["GITHUB_OUTPUT"], "a") as gh: + gh.write("summary< 📦 **分支**: " + os.environ["BRANCH"] + "\n" + "> 👤 **提交人**: " + os.environ["AUTHOR"] + "\n" + "> 📝 **标题**: " + os.environ["PR_TITLE"] + "\n\n" + "### 🧠 " + label + "\n" + + summary + "\n\n" + "---\n" + "🔗 [查看PR详情](" + os.environ["PR_URL"] + ")" + ) + payload = {"msgtype": "markdown", "markdown": {"content": content}} + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + os.environ["WECHAT_WEBHOOK"], + data=data, + headers={"Content-Type": "application/json"} + ) + resp = urllib.request.urlopen(req) + print(resp.read().decode()) + PYEOF diff --git a/.gitignore b/.gitignore index 0ec6822c..a1896da7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ time.log celerybeat-schedule.db search_results.json redbear-mem-metrics/ +redbear-mem-benchmark/ pitch-deck/ api/migrations/versions diff --git a/api/app/controllers/prompt_optimizer_controller.py b/api/app/controllers/prompt_optimizer_controller.py index 80f14cd3..b9fc697c 100644 --- a/api/app/controllers/prompt_optimizer_controller.py +++ b/api/app/controllers/prompt_optimizer_controller.py @@ -124,10 +124,11 @@ async def get_prompt_opt( skill=data.skill ): # chunk 是 prompt 的增量内容 - yield f"event:message\ndata: {json.dumps(chunk)}\n\n" + yield f"event:message\ndata: {json.dumps(chunk, ensure_ascii=False)}\n\n" except Exception as e: yield f"event:error\ndata: {json.dumps( - {"error": str(e)} + {"error": str(e)}, + ensure_ascii=False )}\n\n" yield "event:end\ndata: {}\n\n" diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index bae4643e..3b0ea1ee 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -14,6 +14,7 @@ from dotenv import load_dotenv from app.core.logging_config import get_agent_logger from app.core.memory.agent.utils.get_dialogs import get_chunked_dialogs +from app.core.memory.storage_services.extraction_engine.deduplication.deduped_and_disamb import _USER_PLACEHOLDER_NAMES from app.core.memory.storage_services.extraction_engine.extraction_orchestrator import ExtractionOrchestrator from app.core.memory.storage_services.extraction_engine.knowledge_extraction.memory_summary import \ memory_summary_generation @@ -191,15 +192,37 @@ async def write( if success: logger.info("Successfully saved all data to Neo4j") - # 使用 Celery 异步任务触发聚类(不阻塞主流程) if all_entity_nodes: + end_user_id = all_entity_nodes[0].end_user_id + + # Neo4j 写入完成后,用 PgSQL 权威 aliases 覆盖 Neo4j 用户实体 + try: + from app.repositories.end_user_info_repository import EndUserInfoRepository + if end_user_id: + with get_db_context() as db_session: + info = EndUserInfoRepository(db_session).get_by_end_user_id(uuid.UUID(end_user_id)) + pg_aliases = info.aliases if info and info.aliases else [] + if info is not None: + # 将 Python 侧占位名集合作为参数传入,避免 Cypher 硬编码 + placeholder_names = list(_USER_PLACEHOLDER_NAMES) + await neo4j_connector.execute_query( + """ + MATCH (e:ExtractedEntity) + WHERE e.end_user_id = $end_user_id AND toLower(e.name) IN $placeholder_names + SET e.aliases = $aliases + """, + end_user_id=end_user_id, aliases=pg_aliases, + placeholder_names=placeholder_names, + ) + logger.info(f"[AliasSync] Neo4j 用户实体 aliases 已用 PgSQL 权威源覆盖: {pg_aliases}") + except Exception as sync_err: + logger.warning(f"[AliasSync] PgSQL→Neo4j aliases 同步失败(不影响主流程): {sync_err}") + + # 使用 Celery 异步任务触发聚类(不阻塞主流程) try: from app.tasks import run_incremental_clustering - end_user_id = all_entity_nodes[0].end_user_id new_entity_ids = [e.id for e in all_entity_nodes] - - # 异步提交 Celery 任务 task = run_incremental_clustering.apply_async( kwargs={ "end_user_id": end_user_id, @@ -207,7 +230,6 @@ async def write( "llm_model_id": str(memory_config.llm_model_id) if memory_config.llm_model_id else None, "embedding_model_id": str(memory_config.embedding_model_id) if memory_config.embedding_model_id else None, }, - # 设置任务优先级(低优先级,不影响主业务) priority=3, ) logger.info( @@ -215,7 +237,6 @@ async def write( f"task_id={task.id}, end_user_id={end_user_id}, entity_count={len(new_entity_ids)}" ) except Exception as e: - # 聚类任务提交失败不影响主流程 logger.error(f"[Clustering] 提交聚类任务失败(不影响主流程): {e}", exc_info=True) break diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py index 7e0976fe..715f190c 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py @@ -82,51 +82,38 @@ def _merge_attribute(canonical: ExtractedEntityNode, ent: ExtractedEntityNode): canonical.connect_strength = next(iter(pair)) # 别名合并(去重保序,使用标准化工具) + # 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,去重合并时不修改 try: canonical_name = (getattr(canonical, "name", "") or "").strip() - incoming_name = (getattr(ent, "name", "") or "").strip() - - # 收集所有需要合并的别名 - all_aliases = [] - - # 1. 添加canonical现有的别名 - existing = getattr(canonical, "aliases", []) or [] - all_aliases.extend(existing) - - # 2. 添加incoming实体的名称(如果不同于canonical的名称) - if incoming_name and incoming_name != canonical_name: - all_aliases.append(incoming_name) - - # 3. 添加incoming实体的所有别名 - incoming = getattr(ent, "aliases", []) or [] - all_aliases.extend(incoming) - - # 4. 标准化并去重(优先使用alias_utils工具函数) - try: - from app.core.memory.utils.alias_utils import normalize_aliases - canonical.aliases = normalize_aliases(canonical_name, all_aliases) - except Exception: - # 如果导入失败,使用增强的去重逻辑 - seen_normalized = set() - unique_aliases = [] + if canonical_name.lower() not in _USER_PLACEHOLDER_NAMES: + incoming_name = (getattr(ent, "name", "") or "").strip() - for alias in all_aliases: - if not alias: - continue - - alias_stripped = str(alias).strip() - if not alias_stripped or alias_stripped == canonical_name: - continue - - # 标准化:转小写用于去重判断 - alias_normalized = alias_stripped.lower() - - if alias_normalized not in seen_normalized: - seen_normalized.add(alias_normalized) - unique_aliases.append(alias_stripped) + # 收集所有需要合并的别名,过滤掉用户占位名避免污染非用户实体 + all_aliases = list(getattr(canonical, "aliases", []) or []) + if incoming_name and incoming_name != canonical_name and incoming_name.lower() not in _USER_PLACEHOLDER_NAMES: + all_aliases.append(incoming_name) + all_aliases.extend( + a for a in (getattr(ent, "aliases", []) or []) + if a and a.strip().lower() not in _USER_PLACEHOLDER_NAMES + ) - # 排序并赋值 - canonical.aliases = sorted(unique_aliases) + try: + from app.core.memory.utils.alias_utils import normalize_aliases + canonical.aliases = normalize_aliases(canonical_name, all_aliases) + except Exception: + seen_normalized = set() + unique_aliases = [] + for alias in all_aliases: + if not alias: + continue + alias_stripped = str(alias).strip() + if not alias_stripped or alias_stripped == canonical_name: + continue + alias_normalized = alias_stripped.lower() + if alias_normalized not in seen_normalized: + seen_normalized.add(alias_normalized) + unique_aliases.append(alias_stripped) + canonical.aliases = sorted(unique_aliases) except Exception: pass @@ -733,66 +720,37 @@ def fuzzy_match( def _merge_entities_with_aliases(canonical: ExtractedEntityNode, losing: ExtractedEntityNode): - """ 模糊匹配中的实体合并。 + """模糊匹配中的实体合并(别名部分)。 - 合并策略: - 1. 保留canonical的主名称不变 - 2. 将losing的主名称添加为alias(如果不同) - 3. 合并两个实体的所有aliases - 4. 自动去重(case-insensitive)并排序 - - Args: - canonical: 规范实体(保留) - losing: 被合并实体(删除) - - Note: - 使用alias_utils.normalize_aliases进行标准化去重 + 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,跳过合并。 """ - # 获取规范实体的名称 canonical_name = (getattr(canonical, "name", "") or "").strip() + if canonical_name.lower() in _USER_PLACEHOLDER_NAMES: + return + losing_name = (getattr(losing, "name", "") or "").strip() - # 收集所有需要合并的别名 - all_aliases = [] - - # 1. 添加canonical现有的别名 - current_aliases = getattr(canonical, "aliases", []) or [] - all_aliases.extend(current_aliases) - - # 2. 添加losing实体的名称(如果不同于canonical的名称) + all_aliases = list(getattr(canonical, "aliases", []) or []) if losing_name and losing_name != canonical_name: all_aliases.append(losing_name) + all_aliases.extend(getattr(losing, "aliases", []) or []) - # 3. 添加losing实体的所有别名 - losing_aliases = getattr(losing, "aliases", []) or [] - all_aliases.extend(losing_aliases) - - # 4. 标准化并去重(使用标准化后的字符串进行去重) try: from app.core.memory.utils.alias_utils import normalize_aliases canonical.aliases = normalize_aliases(canonical_name, all_aliases) except Exception: - # 如果导入失败,使用增强的去重逻辑 - # 使用标准化后的字符串作为key进行去重 seen_normalized = set() unique_aliases = [] - for alias in all_aliases: if not alias: continue - alias_stripped = str(alias).strip() if not alias_stripped or alias_stripped == canonical_name: continue - - # 标准化:转小写用于去重判断 alias_normalized = alias_stripped.lower() - if alias_normalized not in seen_normalized: seen_normalized.add(alias_normalized) unique_aliases.append(alias_stripped) - - # 排序并赋值 canonical.aliases = sorted(unique_aliases) # ========== 主循环:遍历所有实体对进行模糊匹配 ========== diff --git a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py index 5636dcb5..75fc87d2 100644 --- a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py +++ b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py @@ -1391,18 +1391,18 @@ class ExtractionOrchestrator: """ 将本轮提取的用户别名同步到 end_user 和 end_user_info 表。 - 注意:此方法在 Neo4j 写入之前调用,因此不能依赖 Neo4j 作为别名的权威数据源。 - 改为直接使用内存中去重后的 entity_nodes 的 aliases,与 PgSQL 已有的 aliases 合并。 + PgSQL end_user_info.aliases 是用户别名的唯一权威源。 + 此方法仅将本轮 LLM 从对话中新提取的别名增量追加到 PgSQL, + 不再从 Neo4j 二层去重合并历史别名,避免脏数据反向污染 PgSQL。 策略: - 1. 从内存中的 entity_nodes 提取本轮用户别名(current_aliases) - 2. 从去重后的 entity_nodes 中提取完整别名(含 Neo4j 二层去重合并的历史别名) - 3. 从 PgSQL end_user_info 读取已有的 aliases(db_aliases) - 4. 合并 db_aliases + deduped_aliases + current_aliases,去重保序 - 5. 写回 PgSQL + 1. 从本轮对话原始发言中提取用户别名(current_aliases) + 2. 从 PgSQL end_user_info 读取已有的 aliases(db_aliases) + 3. 合并 db_aliases + current_aliases,去重保序 + 4. 写回 PgSQL Args: - entity_nodes: 去重后的实体节点列表(内存中,含二层去重合并结果) + entity_nodes: 去重后的实体节点列表(内存中) dialog_data_list: 对话数据列表 """ try: @@ -1418,11 +1418,6 @@ class ExtractionOrchestrator: # 1. 提取本轮对话的用户别名(保持 LLM 提取的原始顺序,不排序) current_aliases = self._extract_current_aliases(entity_nodes, dialog_data_list) - # 1.5 从去重后的 entity_nodes 中提取完整别名 - # 二层去重会将 Neo4j 中已有的历史别名合并到 entity_nodes 中, - # 这里提取出来确保 PgSQL 与 Neo4j 的别名保持同步 - deduped_aliases = self._extract_deduped_entity_aliases(entity_nodes) - # 1.6 从 Neo4j 查询已有的 AI 助手别名,作为额外的排除源 # (防止 LLM 未提取出 AI 助手实体时,AI 别名泄漏到用户别名中) neo4j_assistant_aliases = await self._fetch_neo4j_assistant_aliases(end_user_id) @@ -1434,19 +1429,12 @@ class ExtractionOrchestrator: ] if len(current_aliases) < before_count: logger.info(f"通过 Neo4j AI 助手别名排除了 {before_count - len(current_aliases)} 个误归属别名") - # 同样过滤 deduped_aliases - deduped_aliases = [ - a for a in deduped_aliases - if a.strip().lower() not in neo4j_assistant_aliases - ] - if not current_aliases and not deduped_aliases: + if not current_aliases: logger.debug(f"本轮未提取到用户别名,跳过同步: end_user_id={end_user_id}") return logger.info(f"本轮对话提取的 aliases: {current_aliases}") - if deduped_aliases: - logger.info(f"去重后实体的完整 aliases(含历史): {deduped_aliases}") # 2. 同步到数据库 end_user_uuid = uuid.UUID(end_user_id) @@ -1457,21 +1445,15 @@ class ExtractionOrchestrator: logger.warning(f"未找到 end_user_id={end_user_id} 的用户记录") return - # 3. 从 PgSQL 读取已有 aliases 并与本轮合并 + # 3. 从 PgSQL 读取已有 aliases 并与本轮新增合并 info = EndUserInfoRepository(db).get_by_end_user_id(end_user_uuid) db_aliases = (info.aliases if info and info.aliases else []) # 过滤掉占位名称 db_aliases = [a for a in db_aliases if a.strip().lower() not in self.USER_PLACEHOLDER_NAMES] - # 合并:已有 + 去重后完整别名 + 本轮新增,去重保序 + # 合并:PgSQL 已有 + 本轮新增,去重保序(不再合并 Neo4j 历史别名) merged_aliases = list(db_aliases) seen_lower = {a.strip().lower() for a in merged_aliases} - # 先合并去重后实体的完整别名(含 Neo4j 历史别名) - for alias in deduped_aliases: - if alias.strip().lower() not in seen_lower: - merged_aliases.append(alias) - seen_lower.add(alias.strip().lower()) - # 再合并本轮新提取的别名 for alias in current_aliases: if alias.strip().lower() not in seen_lower: merged_aliases.append(alias) @@ -1505,9 +1487,7 @@ class ExtractionOrchestrator: info.aliases = merged_aliases logger.info(f"同步合并后 aliases 到 end_user_info: {merged_aliases}") else: - first_alias = current_aliases[0].strip() if current_aliases else ( - deduped_aliases[0].strip() if deduped_aliases else "" - ) + first_alias = current_aliases[0].strip() if current_aliases else "" # 确保 first_alias 不是占位名称 if first_alias and first_alias.lower() not in self.USER_PLACEHOLDER_NAMES: db.add(EndUserInfo( diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index 1de4b120..89a7dcee 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -112,22 +112,23 @@ class RedBearModelFactory: params["stream_usage"] = True # 深度思考模式 is_streaming = bool(config.extra_params.get("streaming")) - if is_streaming and not config.is_omni: - if provider == ModelProvider.VOLCANO: - # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 - thinking_config: Dict[str, Any] = { - "type": "enabled" if config.deep_thinking else "disabled" - } - if config.deep_thinking and config.thinking_budget_tokens: - thinking_config["budget_tokens"] = config.thinking_budget_tokens - params["extra_body"] = {"thinking": thinking_config} - else: - # 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略 - model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) - model_kwargs["enable_thinking"] = config.deep_thinking - if config.deep_thinking and config.thinking_budget_tokens: - model_kwargs["thinking_budget"] = config.thinking_budget_tokens - params["model_kwargs"] = model_kwargs + if config.support_thinking: + if is_streaming and not config.is_omni: + if provider == ModelProvider.VOLCANO: + # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 + thinking_config: Dict[str, Any] = { + "type": "enabled" if config.deep_thinking else "disabled" + } + if config.deep_thinking and config.thinking_budget_tokens: + thinking_config["budget_tokens"] = config.thinking_budget_tokens + params["extra_body"] = {"thinking": thinking_config} + else: + # 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略 + model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) + model_kwargs["enable_thinking"] = config.deep_thinking + if config.deep_thinking and config.thinking_budget_tokens: + model_kwargs["thinking_budget"] = config.thinking_budget_tokens + params["model_kwargs"] = model_kwargs return params elif provider == ModelProvider.DASHSCOPE: params = { diff --git a/api/app/core/rag/app/naive.py b/api/app/core/rag/app/naive.py index 72272347..312216dd 100644 --- a/api/app/core/rag/app/naive.py +++ b/api/app/core/rag/app/naive.py @@ -672,10 +672,15 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, excel_parser = ExcelParser() if parser_config.get("html4excel") and parser_config.get("html4excel").lower() == "true": sections = [(_, "") for _ in excel_parser.html(binary, 12) if _] - parser_config["chunk_token_num"] = 0 else: sections = [(_, "") for _ in excel_parser(binary) if _] - parser_config["chunk_token_num"] = 12800 + callback(0.8, "Finish parsing.") + # Excel 每行直接作为一个 chunk,不经过 naive_merge 避免被 delimiter 拆分 + chunks = [s for s, _ in sections] + res.extend(tokenize_chunks(chunks, doc, is_english, None)) + res.extend(embed_res) + res.extend(url_res) + return res elif re.search(r"\.(txt|py|js|java|c|cpp|h|php|go|ts|sh|cs|kt|sql)$", filename, re.IGNORECASE): callback(0.1, "Start to parse.") diff --git a/api/app/core/rag/deepdoc/parser/excel_parser.py b/api/app/core/rag/deepdoc/parser/excel_parser.py index d66a21a8..c3999be9 100644 --- a/api/app/core/rag/deepdoc/parser/excel_parser.py +++ b/api/app/core/rag/deepdoc/parser/excel_parser.py @@ -232,14 +232,14 @@ class RAGExcelParser: t = str(ti[i].value) if i < len(ti) else "" t += (":" if t else "") + str(c.value) fields.append(t) - line = "; ".join(fields) + line = "\n".join(fields) if sheetname.lower().find("sheet") < 0: - line += " ——" + sheetname + line += "\n——" + sheetname res.append(line) else: # 只有表头的情况 if header_fields: - line = "; ".join(header_fields) + line = "\n".join(header_fields) if sheetname.lower().find("sheet") < 0: line += " ——" + sheetname res.append(line) diff --git a/api/app/core/rag/llm/embedding_model.py b/api/app/core/rag/llm/embedding_model.py index 22e35a15..59210054 100644 --- a/api/app/core/rag/llm/embedding_model.py +++ b/api/app/core/rag/llm/embedding_model.py @@ -50,7 +50,9 @@ class OpenAIEmbed(Base): def encode(self, texts: list): # OpenAI requires batch size <=16 batch_size = 16 - texts = [truncate(t, 8191) for t in texts] + # Use 8000 instead of 8191 to leave safety margin for tokenizer differences + # between cl100k_base (used by truncate) and the actual embedding model + texts = [truncate(t, 8000) for t in texts] ress = [] total_tokens = 0 for i in range(0, len(texts), batch_size): @@ -63,7 +65,7 @@ class OpenAIEmbed(Base): return np.array(ress), total_tokens def encode_queries(self, text): - res = self.client.embeddings.create(input=[truncate(text, 8191)], model=self.model_name, encoding_format="float",extra_body={"drop_params": True}) + res = self.client.embeddings.create(input=[truncate(text, 8000)], model=self.model_name, encoding_format="float",extra_body={"drop_params": True}) return np.array(res.data[0].embedding), self.total_token_count(res) @@ -79,6 +81,7 @@ class LocalAIEmbed(Base): def encode(self, texts: list): batch_size = 16 + texts = [truncate(t, 8000) for t in texts] ress = [] for i in range(0, len(texts), batch_size): res = self.client.embeddings.create(input=texts[i : i + batch_size], model=self.model_name) @@ -173,6 +176,7 @@ class XinferenceEmbed(Base): def encode(self, texts: list): batch_size = 16 + texts = [truncate(t, 8000) for t in texts] ress = [] total_tokens = 0 for i in range(0, len(texts), batch_size): @@ -188,7 +192,7 @@ class XinferenceEmbed(Base): def encode_queries(self, text): res = None try: - res = self.client.embeddings.create(input=[text], model=self.model_name) + res = self.client.embeddings.create(input=[truncate(text, 8000)], model=self.model_name) return np.array(res.data[0].embedding), self.total_token_count(res) except Exception as _e: log_exception(_e, res) diff --git a/api/app/core/workflow/nodes/http_request/config.py b/api/app/core/workflow/nodes/http_request/config.py index e1b84f0c..72474436 100644 --- a/api/app/core/workflow/nodes/http_request/config.py +++ b/api/app/core/workflow/nodes/http_request/config.py @@ -72,8 +72,9 @@ class HttpContentTypeConfig(BaseModel): @classmethod def validate_data(cls, v, info): content_type = info.data.get("content_type") - if content_type == HttpContentType.FROM_DATA and not isinstance(v, HttpFormData): - raise ValueError("When content_type is 'form-data', data must be of type HttpFormData") + if content_type == HttpContentType.FROM_DATA and ( + not isinstance(v, list) or not all(isinstance(item, HttpFormData) for item in v)): + raise ValueError("When content_type is 'form-data', data must be a list of HttpFormData") elif content_type in [HttpContentType.JSON] and not isinstance(v, str): raise ValueError("When content_type is JSON, data must be of type str") elif content_type in [HttpContentType.WWW_FORM] and not isinstance(v, dict): diff --git a/api/app/core/workflow/nodes/http_request/node.py b/api/app/core/workflow/nodes/http_request/node.py index 086bee4a..783c230b 100644 --- a/api/app/core/workflow/nodes/http_request/node.py +++ b/api/app/core/workflow/nodes/http_request/node.py @@ -260,17 +260,22 @@ class HttpRequestNode(BaseNode): )) case HttpContentType.FROM_DATA: data = {} - content["files"] = {} + files = [] for item in self.typed_config.body.data: + key = self._render_template(item.key, variable_pool) if item.type == "text": - data[self._render_template(item.key, variable_pool)] = self._render_template(item.value, - variable_pool) + data[key] = self._render_template(item.value, variable_pool) elif item.type == "file": - content["files"][self._render_template(item.key, variable_pool)] = ( - uuid.uuid4().hex, - await variable_pool.get_instance(item.value).get_content() - ) + file_instance = variable_pool.get_instance(item.value) + if isinstance(file_instance, ArrayVariable): + for v in file_instance.value: + if isinstance(v, FileVariable): + files.append((key, (uuid.uuid4().hex, await v.get_content()))) + elif isinstance(file_instance, FileVariable): + files.append((key, (uuid.uuid4().hex, await file_instance.get_content()))) content["data"] = data + if files: + content["files"] = files case HttpContentType.BINARY: content["files"] = [] file_instence = variable_pool.get_instance(self.typed_config.body.data) diff --git a/api/app/core/workflow/variable/variable_objects.py b/api/app/core/workflow/variable/variable_objects.py index 94f87287..2b849c94 100644 --- a/api/app/core/workflow/variable/variable_objects.py +++ b/api/app/core/workflow/variable/variable_objects.py @@ -84,7 +84,7 @@ class FileVariable(BaseVariable): total_bytes = 0 chunks = [] - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(follow_redirects=True) as client: async with client.stream("GET", self.value.url) as resp: resp.raise_for_status() async for chunk in resp.aiter_bytes(8192): diff --git a/api/app/repositories/implicit_emotions_storage_repository.py b/api/app/repositories/implicit_emotions_storage_repository.py index b6c40b40..b665924d 100644 --- a/api/app/repositories/implicit_emotions_storage_repository.py +++ b/api/app/repositories/implicit_emotions_storage_repository.py @@ -5,16 +5,9 @@ Implicit Emotions Storage Repository 事务由调用方控制,仓储层只使用 flush/refresh """ import logging -from datetime import date, datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Generator, Optional - -class TimeFilterUnavailableError(Exception): - """redis_client 不可用,无法执行时间轴筛选。 - - 调用方捕获此异常后可选择回退到 get_all_user_ids 进行全量处理。 - """ - import redis from sqlalchemy import exists, not_, select from sqlalchemy.orm import Session @@ -25,6 +18,13 @@ from app.models.implicit_emotions_storage_model import ImplicitEmotionsStorage logger = logging.getLogger(__name__) +class TimeFilterUnavailableError(Exception): + """redis_client 不可用,无法执行时间轴筛选。 + + 调用方捕获此异常后可选择回退到 get_all_user_ids 进行全量处理。 + """ + + class ImplicitEmotionsStorageRepository: """隐性记忆和情绪存储仓储类""" @@ -216,9 +216,7 @@ class ImplicitEmotionsStorageRepository: """ from sqlalchemy import String as SAString from sqlalchemy import cast - CST = timezone(timedelta(hours=8)) - now_cst = datetime.now(CST) - today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc).replace(tzinfo=None) + today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) tomorrow_start = today_start + timedelta(days=1) offset = 0 while True: diff --git a/api/app/repositories/neo4j/cypher_queries.py b/api/app/repositories/neo4j/cypher_queries.py index 4b5273ac..daf04bcb 100644 --- a/api/app/repositories/neo4j/cypher_queries.py +++ b/api/app/repositories/neo4j/cypher_queries.py @@ -93,6 +93,8 @@ SET e.name = CASE WHEN entity.name IS NOT NULL AND entity.name <> '' THEN entity END, e.statement_id = CASE WHEN entity.statement_id IS NOT NULL AND entity.statement_id <> '' THEN entity.statement_id ELSE e.statement_id END, e.aliases = CASE + // 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,知识抽取完全不写入 + WHEN entity.name IN ['用户', '我', 'User', 'I'] THEN e.aliases WHEN entity.aliases IS NOT NULL AND size(entity.aliases) > 0 THEN CASE WHEN e.aliases IS NULL THEN entity.aliases diff --git a/api/app/repositories/neo4j/neo4j_connector.py b/api/app/repositories/neo4j/neo4j_connector.py index ea8fa917..d20bf75f 100644 --- a/api/app/repositories/neo4j/neo4j_connector.py +++ b/api/app/repositories/neo4j/neo4j_connector.py @@ -77,11 +77,11 @@ class Neo4jConnector: """ await self.driver.close() - async def execute_query(self, query: str, json_format=False, **kwargs: Any) -> List[Dict[str, Any]]: + async def execute_query(self, cypher: str, json_format=False, **kwargs: Any) -> List[Dict[str, Any]]: """执行Cypher查询 Args: - query: Cypher查询语句 + cypher: Cypher查询语句 json_format: json格式化 **kwargs: 查询参数,将作为参数传递给Cypher查询 @@ -92,7 +92,7 @@ class Neo4jConnector: """ result = await self.driver.execute_query( - query, + cypher, database="neo4j", **kwargs ) diff --git a/api/app/repositories/user_repository.py b/api/app/repositories/user_repository.py index 2dd76b04..6874f9bf 100644 --- a/api/app/repositories/user_repository.py +++ b/api/app/repositories/user_repository.py @@ -297,6 +297,10 @@ def get_user_by_id(db: Session, user_id: uuid.UUID) -> Optional[User]: """根据ID获取用户""" return UserRepository(db).get_user_by_id(user_id) +def get_user_by_id_regardless_active(db: Session, user_id: uuid.UUID) -> Optional[User]: + """根据ID获取用户(不过滤 is_active,用于启用/禁用场景)""" + return db.query(User).filter(User.id == user_id).first() + def get_user_by_email(db: Session, email: str) -> Optional[User]: """根据邮箱获取用户""" return UserRepository(db).get_user_by_email(email) diff --git a/api/app/services/app_dsl_service.py b/api/app/services/app_dsl_service.py index 16aa8a3a..c527e4d3 100644 --- a/api/app/services/app_dsl_service.py +++ b/api/app/services/app_dsl_service.py @@ -73,15 +73,14 @@ class AppDslService: AppType.MULTI_AGENT: "multi_agent_config", AppType.WORKFLOW: "workflow" }.get(app.type, "config") - config_data = self._enrich_release_config(app.type, release.config or {}) + config_data = self._enrich_release_config(app.type, release.config or {}, release.default_model_config_id) dsl = {**meta, "app": app_meta, config_key: config_data} return yaml.dump(dsl, default_flow_style=False, allow_unicode=True), f"{release.name}_v{release.version_name}.yaml" - def _enrich_release_config(self, app_type: str, cfg: dict) -> dict: + def _enrich_release_config(self, app_type: str, cfg: dict, default_model_config_id=None) -> dict: if app_type == AppType.AGENT: enriched = {**cfg} - if "default_model_config_id" in cfg: - enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"]) + enriched["default_model_config_ref"] = self._model_ref(default_model_config_id) if "knowledge_retrieval" in cfg: enriched["knowledge_retrieval"] = self._enrich_knowledge_retrieval(cfg["knowledge_retrieval"]) if "tools" in cfg: @@ -91,8 +90,7 @@ class AppDslService: return enriched if app_type == AppType.MULTI_AGENT: enriched = {**cfg} - if "default_model_config_id" in cfg: - enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"]) + enriched["default_model_config_ref"] = self._model_ref(default_model_config_id) if "master_agent_id" in cfg: enriched["master_agent_ref"] = self._release_ref(cfg["master_agent_id"]) if "sub_agents" in cfg: diff --git a/api/app/services/emotion_analytics_service.py b/api/app/services/emotion_analytics_service.py index c226348e..9a215cd6 100644 --- a/api/app/services/emotion_analytics_service.py +++ b/api/app/services/emotion_analytics_service.py @@ -679,9 +679,9 @@ class EmotionAnalyticsService: # 查询用户的实体和标签 query = """ - MATCH (e:Entity) + MATCH (e:ExtractedEntity) WHERE e.end_user_id = $end_user_id - RETURN e.name as name, e.type as type + RETURN e.name as name, e.entity_type as type ORDER BY e.created_at DESC LIMIT 20 """ diff --git a/api/app/services/implicit_memory_service.py b/api/app/services/implicit_memory_service.py index 4bd11deb..7a186f33 100644 --- a/api/app/services/implicit_memory_service.py +++ b/api/app/services/implicit_memory_service.py @@ -34,6 +34,7 @@ from app.schemas.implicit_memory_schema import ( UserMemorySummary, ) from app.schemas.memory_config_schema import MemoryConfig +from app.services.memory_base_service import MIN_MEMORY_SUMMARY_COUNT from sqlalchemy.orm import Session logger = logging.getLogger(__name__) @@ -379,12 +380,59 @@ class ImplicitMemoryService: raise + def _build_empty_profile(self) -> dict: + """构建 MemorySummary 不足时返回的固定空白画像数据""" + now_ms = int(datetime.utcnow().timestamp() * 1000) + insufficient = "Insufficient data for analysis" + + def _empty_dimension(name: str) -> dict: + return { + "evidence": [insufficient], + "reasoning": f"No clear evidence found for {name} dimension", + "percentage": 0.0, + "dimension_name": name, + "confidence_level": 20, + } + + def _empty_category(name: str) -> dict: + return { + "evidence": [insufficient], + "percentage": 25.0, + "category_name": name, + "trending_direction": None, + } + + return { + "habits": [], + "portrait": { + "aesthetic": _empty_dimension("aesthetic"), + "creativity": _empty_dimension("creativity"), + "literature": _empty_dimension("literature"), + "technology": _empty_dimension("technology"), + "historical_trends": None, + "analysis_timestamp": now_ms, + "total_summaries_analyzed": 0, + }, + "preferences": [], + "interest_areas": { + "art": _empty_category("art"), + "tech": _empty_category("tech"), + "music": _empty_category("music"), + "lifestyle": _empty_category("lifestyle"), + "analysis_timestamp": now_ms, + "total_summaries_analyzed": 0, + }, + } + async def generate_complete_profile( self, user_id: str ) -> dict: """生成完整的用户画像(包含所有4个模块) + 需要该用户的 MemorySummary 节点数量 >= 5 才会真正调用 LLM 生成画像, + 否则返回固定的空白画像数据。 + Args: user_id: 用户ID @@ -394,6 +442,16 @@ class ImplicitMemoryService: logger.info(f"生成完整用户画像: user={user_id}") try: + # 前置检查:查询该用户有效的 MemorySummary 节点数量(排除孤立节点) + from app.services.memory_base_service import MemoryBaseService + base_service = MemoryBaseService() + memory_summary_count = await base_service.get_valid_memory_summary_count(user_id) + logger.info(f"用户 MemorySummary 节点数量: {memory_summary_count} (user={user_id})") + + if memory_summary_count < MIN_MEMORY_SUMMARY_COUNT: + logger.info(f"MemorySummary 数量不足 {MIN_MEMORY_SUMMARY_COUNT}(当前 {memory_summary_count}),返回空白画像: user={user_id}") + return self._build_empty_profile() + # 并行调用4个分析方法 preferences, portrait, interest_areas, habits = await asyncio.gather( self.get_preference_tags(user_id=user_id), diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py index bc647752..e615af8b 100644 --- a/api/app/services/memory_base_service.py +++ b/api/app/services/memory_base_service.py @@ -265,12 +265,50 @@ async def Translation_English(modid, text, fields=None): # 其他类型(数字、布尔值、None等):原样返回 else: return text +# 隐性记忆画像生成所需的最低 MemorySummary 节点数量 +MIN_MEMORY_SUMMARY_COUNT = 5 + + class MemoryBaseService: """记忆服务基类,提供共享的辅助方法""" def __init__(self): self.neo4j_connector = Neo4jConnector() + async def get_valid_memory_summary_count( + self, + end_user_id: str + ) -> int: + """获取用户有效的 MemorySummary 节点数量(排除孤立节点)。 + + 只统计存在 DERIVED_FROM_STATEMENT 关系的 MemorySummary 节点。 + + Args: + end_user_id: 终端用户ID + + Returns: + 有效 MemorySummary 节点数量 + """ + try: + query = """ + MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) + WHERE n.end_user_id = $end_user_id + RETURN count(DISTINCT n) as count + """ + result = await self.neo4j_connector.execute_query( + query, end_user_id=end_user_id + ) + count = result[0]["count"] if result and len(result) > 0 else 0 + logger.debug( + f"有效 MemorySummary 节点数量: {count} (end_user_id={end_user_id})" + ) + return count + except Exception as e: + logger.error( + f"获取有效 MemorySummary 数量失败: {str(e)}", exc_info=True + ) + return 0 + @staticmethod def parse_timestamp(timestamp_value) -> Optional[int]: """ diff --git a/api/app/services/prompt_optimizer_service.py b/api/app/services/prompt_optimizer_service.py index fde8c4f9..30901111 100644 --- a/api/app/services/prompt_optimizer_service.py +++ b/api/app/services/prompt_optimizer_service.py @@ -227,10 +227,20 @@ class PromptOptimizerService: content = getattr(chunk, "content", chunk) if not content: continue - buffer += content + if isinstance(content, str): + buffer += content + elif isinstance(content, list): + for _ in content: + buffer += _["text"] + else: + logger.error(f"Unsupported content type - {content}") + raise Exception("Unsupported content type") cache = buffer[:-20] + last_idx = 19 + while cache and cache[-1] == '\\' and last_idx > 0: + cache = buffer[:-last_idx] + last_idx -= 1 - # 尝试找到 "prompt": " 开始位置 if prompt_finished: continue @@ -272,7 +282,7 @@ class PromptOptimizerService: def parser_prompt_variables(prompt: str): try: pattern = r'\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}' - matches = re.findall(pattern, prompt) + matches = re.findall(pattern, str(prompt)) variables = list(set(matches)) return variables except Exception as e: diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 79f8fa05..fdc27115 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.logging_config import get_logger +from app.core.memory.storage_services.extraction_engine.deduplication.deduped_and_disamb import _USER_PLACEHOLDER_NAMES from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.repositories.conversation_repository import ConversationRepository @@ -21,7 +22,7 @@ from app.repositories.end_user_repository import EndUserRepository from app.repositories.neo4j.cypher_queries import Graph_Node_query from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_episodic_schema import EmotionSubject, EmotionType, type_mapping -from app.services.memory_base_service import MemoryBaseService +from app.services.memory_base_service import MemoryBaseService, MIN_MEMORY_SUMMARY_COUNT from app.services.memory_config_service import MemoryConfigService from app.services.memory_perceptual_service import MemoryPerceptualService from app.services.memory_short_service import ShortService @@ -477,7 +478,7 @@ class UserMemoryService: allowed_fields = {'other_name', 'aliases', 'meta_data'} # 用户占位名称黑名单,不允许作为 other_name 或出现在 aliases 中 - _user_placeholder_names = {'用户', '我', 'User', 'I'} + _user_placeholder_names = _USER_PLACEHOLDER_NAMES # 过滤 other_name:不允许设置为占位名称 if 'other_name' in update_data and update_data['other_name'] and update_data['other_name'].strip() in _user_placeholder_names: @@ -1504,7 +1505,7 @@ async def analytics_memory_types( 2. 工作记忆 (WORKING_MEMORY) = 会话数量(通过 ConversationRepository.get_conversation_by_user_id 获取) 3. 短期记忆 (SHORT_TERM_MEMORY) = /short_term 接口返回的问答对数量 4. 显性记忆 (EXPLICIT_MEMORY) = 情景记忆 + 语义记忆(通过 MemoryBaseService.get_explicit_memory_count 获取) - 5. 隐性记忆 (IMPLICIT_MEMORY) = Statement 节点数量的三分之一 + 5. 隐性记忆 (IMPLICIT_MEMORY) = MemorySummary 节点数量(需 >= MIN_MEMORY_SUMMARY_COUNT 才显示,否则为 0) 6. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取) 7. 情景记忆 (EPISODIC_MEMORY) = memory_summary(通过 MemoryBaseService.get_episodic_memory_count 获取) 8. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_memory_count 获取) @@ -1561,23 +1562,15 @@ async def analytics_memory_types( logger.warning(f"获取会话数量失败,工作记忆数量设为0: {str(e)}") work_count = 0 - # 获取隐性记忆数量(基于 Statement 节点数量的三分之一) + # 获取隐性记忆数量(基于有关联关系的 MemorySummary 节点数量,需 >= MIN_MEMORY_SUMMARY_COUNT 才计入) implicit_count = 0 if end_user_id: try: - # 查询 Statement 节点数量 - query = """ - MATCH (n:Statement) - WHERE n.end_user_id = $end_user_id - RETURN count(n) as count - """ - result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id) - statement_count = result[0]["count"] if result and len(result) > 0 else 0 - # 取三分之一作为隐性记忆数量 - implicit_count = round(statement_count / 3) - logger.debug(f"隐性记忆数量(Statement数量的1/3): {implicit_count} (Statement总数={statement_count}, end_user_id={end_user_id})") + memory_summary_count = await base_service.get_valid_memory_summary_count(end_user_id) + implicit_count = memory_summary_count if memory_summary_count >= MIN_MEMORY_SUMMARY_COUNT else 0 + logger.debug(f"隐性记忆数量(有效MemorySummary节点数): {implicit_count} (有效MemorySummary总数={memory_summary_count}, end_user_id={end_user_id})") except Exception as e: - logger.warning(f"获取Statement数量失败,隐性记忆数量设为0: {str(e)}") + logger.warning(f"获取MemorySummary数量失败,隐性记忆数量设为0: {str(e)}") implicit_count = 0 # 原有的基于行为习惯的统计方式(已注释) @@ -1643,7 +1636,7 @@ async def analytics_memory_types( "WORKING_MEMORY": work_count, # 工作记忆(基于会话数量) "SHORT_TERM_MEMORY": short_term_count, # 短期记忆(基于问答对数量) "EXPLICIT_MEMORY": explicit_count, # 显性记忆(情景记忆 + 语义记忆) - "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(Statement数量的1/3) + "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(MemorySummary节点数,需>=MIN_MEMORY_SUMMARY_COUNT) "EMOTIONAL_MEMORY": emotion_count, # 情绪记忆(使用情绪标签统计) "EPISODIC_MEMORY": episodic_count, # 情景记忆 "FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值) diff --git a/api/app/services/user_service.py b/api/app/services/user_service.py index 3122d282..43a58c5f 100644 --- a/api/app/services/user_service.py +++ b/api/app/services/user_service.py @@ -285,7 +285,7 @@ def activate_user(db: Session, user_id_to_activate: uuid.UUID, current_user: Use try: # 查找用户 business_logger.debug(f"查找待激活用户: {user_id_to_activate}") - db_user = user_repository.get_user_by_id(db, user_id=user_id_to_activate) + db_user = user_repository.get_user_by_id_regardless_active(db, user_id=user_id_to_activate) if not db_user: business_logger.warning(f"用户不存在: {user_id_to_activate}") raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND) diff --git a/web/src/components/Header/index.module.css b/web/src/components/Header/index.module.css index d39c91ec..525a2432 100644 --- a/web/src/components/Header/index.module.css +++ b/web/src/components/Header/index.module.css @@ -12,6 +12,14 @@ font-weight: 500; font-style: normal; } +.breadcrumbTitle { + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} .header :global(.ant-breadcrumb) { line-height: 31px; } diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index 49988223..23a89894 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -14,7 +14,7 @@ */ import { type FC, useRef, useState } from 'react'; -import { Layout, Dropdown, Breadcrumb, Flex } from 'antd'; +import { Layout, Dropdown, Breadcrumb, Flex, Tooltip } from 'antd'; import type { MenuProps, BreadcrumbProps } from 'antd'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; @@ -77,7 +77,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { { key: '1', icon: - {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(0, 2) : user.username?.[0]} + {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(-2) : user.username[0]} , label: (<>
{user.username}
@@ -135,28 +135,30 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { * - Disables navigation for the last breadcrumb item */ const formatBreadcrumbNames = () => { - return breadcrumbs.filter(item => item.type !== 'group').map((menu, index) => { + const filtered = breadcrumbs.filter(item => item.type !== 'group'); + return filtered.map((menu, index) => { + const label = menu.i18nKey ? t(menu.i18nKey) : menu.label; + const isLast = index === filtered.length - 1; const item: any = { - title: menu.i18nKey ? t(menu.i18nKey) : menu.label, + title: ( + + {label} + + ), }; - // If it's the last item, don't set path - if (index === breadcrumbs.length - 1) { - return item; + if (!isLast) { + if ((menu as any).onClick) { + item.onClick = (e: React.MouseEvent) => { + e.preventDefault(); + (menu as any).onClick(e); + }; + item.href = '#'; + } else if (menu.path && menu.path !== '#') { + item.path = menu.path; + } } - - // If has custom onClick, use onClick and set href to '#' to show pointer cursor - if ((menu as any).onClick) { - item.onClick = (e: React.MouseEvent) => { - e.preventDefault(); - (menu as any).onClick(e); - }; - item.href = '#'; - } else if (menu.path && menu.path !== '#') { - // Only set path when path is not '#' - item.path = menu.path; - } - + return item; }); } @@ -180,7 +182,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { > - {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(user.username.length, -2) : user.username[0]} + {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(-2) : user.username[0]} {user.username}
(); - console.log('chatVariables', chatVariables) - const handleClose = () => { setVisible(false); form.resetFields(); diff --git a/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx b/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx index cef88637..7f5f6c92 100644 --- a/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx +++ b/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 18:33:30 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-10 18:40:52 + * @Last Modified time: 2026-04-14 16:03:41 */ /** * End User Profile Component @@ -28,11 +28,11 @@ import Tag from '@/components/Tag'; * Component props */ interface EndUserProfileProps { - onDataLoaded?: (data: { other_name?: string; id: string }) => void; + onDataLoaded?: (data?: EndUser) => void; className?: string; } -const EndUserProfile = forwardRef(({ className }, ref) => { +const EndUserProfile = forwardRef(({ className, onDataLoaded }, ref) => { const { t } = useTranslation() const { id } = useParams() const endUserProfileModalRef = useRef(null) @@ -52,6 +52,7 @@ const EndUserProfile = forwardRef(({ cla const userData = res as EndUser setData(userData) setLoading(false) + onDataLoaded?.(userData as EndUser) }) .finally(() => { setLoading(false) diff --git a/web/src/views/UserMemoryDetail/components/InterestAreas.tsx b/web/src/views/UserMemoryDetail/components/InterestAreas.tsx index 91554880..4d9be5b5 100644 --- a/web/src/views/UserMemoryDetail/components/InterestAreas.tsx +++ b/web/src/views/UserMemoryDetail/components/InterestAreas.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 18:32:53 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-16 14:27:12 + * @Last Modified time: 2026-04-13 13:37:43 */ import { useEffect, useState, forwardRef, useImperativeHandle, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -93,7 +93,7 @@ const InterestAreas = forwardRef<{ handleRefresh: () => void; }>((_props, ref) = ref={chartRef} option={{ color: Colors, - grid: { top: 8, left: 38, right: 8, bottom: 24 }, + grid: { top: 14, left: 38, right: 8, bottom: 24 }, xAxis: { type: 'category', data: keys.map(k => t(`implicitDetail.${k}`)), diff --git a/web/src/views/UserMemoryDetail/types.ts b/web/src/views/UserMemoryDetail/types.ts index b085efde..4eb4fe5b 100644 --- a/web/src/views/UserMemoryDetail/types.ts +++ b/web/src/views/UserMemoryDetail/types.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:15 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-10 18:38:49 + * @Last Modified time: 2026-04-14 16:03:16 */ /** * User Memory Detail Types @@ -172,6 +172,7 @@ export interface EndUser { other_name: string; aliases: string | null; meta_data: Record; + id?: string; end_user_info_id: string; end_user_id: string; created_at: string; diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx index 5666f3ab..e4f62432 100644 --- a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx +++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-30 13:59:36 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-08 11:05:34 + * @Last Modified time: 2026-04-13 15:26:33 */ import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react'; import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd'; @@ -136,7 +136,7 @@ const ChatVariableModal = forwardRef { const defaultValue = Array.isArray(values.defaultValue) ? values.defaultValue.filter((v: any) => v !== undefined && v !== null && v !== '') - : values.type.includes('object') + : values.type.includes('object') && values.defaultValue ? JSON.parse(values.defaultValue) : values.defaultValue; refresh({ ...values, defaultValue }, editIndex); @@ -345,15 +345,16 @@ const ChatVariableModal = forwardRef { if (!value) return Promise.resolve(); try { JSON.parse(value); return Promise.resolve(); } catch { return Promise.reject(t('workflow.invalidJSON')); } } - } : {} - ]} + }] + : undefined + } > {type === 'number' ? diff --git a/web/src/views/Workflow/components/Chat/Runtime.tsx b/web/src/views/Workflow/components/Chat/Runtime.tsx index 68bdc452..4a5be793 100644 --- a/web/src/views/Workflow/components/Chat/Runtime.tsx +++ b/web/src/views/Workflow/components/Chat/Runtime.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-24 17:57:08 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 14:05:50 + * @Last Modified time: 2026-04-14 16:33:33 */ /* * Runtime Component @@ -161,8 +161,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({ children: ( {/* Display error message for failed nodes */} - - {item.error && + {vo.content?.error && vo.content?.error !== '' && @@ -219,11 +218,11 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
} - /** Copy value to clipboard and show success message */ - const handleCopy = (value: string) => { - copy(value) - message.success(t('common.copySuccess')) - } + /** Copy value to clipboard and show success message */ + const handleCopy = (value: string) => { + copy(value) + message.success(t('common.copySuccess')) + } return (
= ({
) :
- {item.error && + {item.error && item.error !== '' && } {renderChild(item.subContent)} diff --git a/web/src/views/Workflow/components/CheckList/index.tsx b/web/src/views/Workflow/components/CheckList/index.tsx index 636ae9a9..0256416a 100644 --- a/web/src/views/Workflow/components/CheckList/index.tsx +++ b/web/src/views/Workflow/components/CheckList/index.tsx @@ -79,7 +79,6 @@ const specialValidators: Record boolean> = { } function isEmpty(val: any): boolean { - console.log('validateNode isEmpty', val, val === undefined || val === null || val === '') if (val === undefined || val === null || val === '') return true if (Array.isArray(val)) return val.length === 0 return false @@ -98,7 +97,6 @@ function validateNode(type: string, config: Record): CheckError[] { const specialKey = `${type}.${field}` const specialValidator = specialValidators[specialKey] const isInvalid = specialValidator ? specialValidator(val) : isEmpty(val) - console.log('validateNode', val, specialKey, specialValidator, isEmpty(val)) if (isInvalid) errors.push({ key: specialKey, message: '' }) }) @@ -114,62 +112,6 @@ function validateNode(type: string, config: Record): CheckError[] { return errors } -export async function runCheckOnGraph( - graph: import('@antv/x6').Graph, - t: (key: string) => string -): Promise { - const nodes = graph.getNodes() - const edges = graph.getEdges() - const targetIds = new Set() - const childTargetIds = new Set() - edges.forEach(e => { - targetIds.add(e.getTargetCellId()) - const srcData = graph.getCellById(e.getSourceCellId())?.getData() - const tgtData = graph.getCellById(e.getTargetCellId())?.getData() - if (srcData?.cycle && tgtData?.cycle && srcData.cycle === tgtData.cycle) { - childTargetIds.add(e.getTargetCellId()) - } - }) - - const checked: NodeCheckResult[] = [] - for (const node of nodes) { - const data = node.getData() - if (!data || ['add-node', 'notes', 'cycle-start', 'break'].includes(data.type)) continue - - const errors: CheckError[] = [] - const isChildNode = !!data.cycle - const hasIncoming = isChildNode ? childTargetIds.has(node.id) : !['start', 'cycle-start'].includes(data.type) ? targetIds.has(node.id) : true - if (!hasIncoming) errors.push({ key: 'notConnected', message: t('workflow.notConnected') }) - - const configErrors = validateNode(data.type, data.config ?? {}) - configErrors.forEach(e => { - errors.push({ key: e.key, message: `${t(`workflow.checkListErrors.${e.key}`)} ${t('workflow.cannotBeEmpty')}`.trim() }) - }) - - if (data.type === 'tool') { - const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id - const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {} - if (toolId) { - try { - const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> - const operation = toolParameters?.operation - const method = operation ? methods.find(m => m.name === operation) : methods[0] - if (method) { - method.parameters - .filter(p => p.required && (toolParameters[p.name] === undefined || toolParameters[p.name] === null || toolParameters[p.name] === '')) - .forEach(p => errors.push({ key: 'tool.tool_parameters', message: `${p.name} ${t('workflow.cannotBeEmpty')}` })) - } - } catch { /* ignore */ } - } - } - - if (errors.length) { - checked.push({ id: node.id, name: data.name || t(`workflow.${data.type}`), type: data.type, icon: nodeIconMap[data.type] ?? '', errors }) - } - } - return checked -} - const CheckList: FC = ({ workflowRef, appId }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -222,7 +164,8 @@ const CheckList: FC = ({ workflowRef, appId }) => { if (data.type === 'tool') { const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {} - if (toolId) { + + if (typeof toolId === 'string') { try { const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> const operation = toolParameters?.operation @@ -251,21 +194,27 @@ const CheckList: FC = ({ workflowRef, appId }) => { return checked }, [workflowRef.current?.graphRef?.current, t]) + const scheduleCheckRef = useRef<() => void>() + const scheduleCheck = useCallback(() => { clearTimeout(timerRef.current) timerRef.current = setTimeout(async () => { setCheckResults(appId, await runCheck()) - }, 500) + }, 300) }, [runCheck]) + scheduleCheckRef.current = scheduleCheck + useEffect(() => { const graph = workflowRef.current?.graphRef?.current + console.log('graph') if (!graph) return - const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed'] - events.forEach(e => graph.on(e, scheduleCheck)) - scheduleCheck() + const handler = () => scheduleCheckRef.current?.() + const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed', 'edge:connected', 'edge:changed'] + events.forEach(e => graph.on(e, handler)) + scheduleCheckRef.current?.() return () => { - events.forEach(e => graph.off(e, scheduleCheck)) + events.forEach(e => graph.off(e, handler)) clearTimeout(timerRef.current) } }, [workflowRef.current?.graphRef?.current]) diff --git a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx index 5688342c..72e73220 100644 --- a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx +++ b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx @@ -48,17 +48,13 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ return ( - {data.isContext ? ( - 📄 - ) : data.group !== 'CONVERSATION' && !data.value.includes('conv') ? ( - - ) : } + {!data.isContext && data.group !== 'CONVERSATION' && !data.value.includes('conv') + ?
+ : null + } {!data.isContext && data.group !== 'CONVERSATION' && ( <> {!data.value.includes('conv') && <> @@ -73,7 +69,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ )} )} - {data.label} + {data.label} ); }; diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index f9537032..6d3b7a4f 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -2,12 +2,13 @@ * @Author: ZhaoYing * @Date: 2025-12-23 16:22:51 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 16:51:04 + * @Last Modified time: 2026-04-13 14:00:07 */ import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; import { Space, Flex } from 'antd'; +import clsx from 'clsx'; import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; import type { NodeProperties } from '../../../types' @@ -284,23 +285,24 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { ref={popupRef} data-autocomplete-popup="true" onMouseDown={(e) => e.preventDefault()} - className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" + className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" style={{ top: popupPosition.top, left: popupPosition.left, }} > -
+
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; - const nodeIcon = nodeOptions[0]?.nodeData?.icon; return ( -
- {nodeName !== 'undefined' && - {nodeIcon &&
} - {nodeName} - } +
+ {nodeName !== 'undefined' && +
+ {nodeName} +
+ } + {nodeOptions.map((option) => { const globalIndex = flatOptions.indexOf(option); const isExpanded = expandedParent?.key === option.key; @@ -310,14 +312,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { key={option.key} ref={(el) => { if (el) itemRefs.current.set(option.key, el); }} data-selected={selectedIndex === globalIndex} - className="rb:pl-6! rb:pr-3! rb:py-2!" + className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { + 'rb:bg-[#F6F6F6]': selectedIndex === globalIndex || isExpanded, + 'rb:cursor-not-allowed rb:opacity-65': option.disabled, + 'rb:cursor-pointer': !option.disabled, + })} align="center" justify="space-between" - style={{ - cursor: option.disabled ? 'not-allowed' : 'pointer', - background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white', - opacity: option.disabled ? 0.5 : 1, - }} onClick={() => { if (option.disabled) return; insertMention(option); @@ -337,17 +338,19 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { } }} > - {option.label && - {option.isContext ? '📄' : `{x}`} - {option.label} - } - - {option.dataType && {option.dataType}} - {hasChildren && } + {option.label && +
+ {`{x}`} {option.label} +
+ } + + {option.dataType && {option.dataType}} + {hasChildren &&
}
); })} +
); })} @@ -356,7 +359,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { {/* Child variables panel - floats to the left */} {expandedParent?.children?.length && (
= ({ options }) => { }} onMouseEnter={() => setExpandedParent(expandedParent)} > - {/* Header */} -
- +
+ {expandedParent.nodeData.name}.{expandedParent.label} {expandedParent.dataType} @@ -377,19 +379,20 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { !child.disabled && insertMention(child)} onMouseEnter={() => setSelectedIndex(childIndex)} > - {child.label} - {child.dataType && {child.dataType}} + + {`{x}`} {child.label} + + {child.dataType && {child.dataType}} ); })} diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index 996ae5dd..625a1b4d 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -31,6 +31,8 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { }; const labelRender = (value: string) => { const filterOption = variableList.find(vo => `{{${vo.value}}}` === value) + ?? variableList.flatMap(vo => vo.children ?? []).find(child => `{{${child.value}}}` === value) + ?? variableList.flatMap(vo => vo.children ?? []).flatMap((child: any) => child.children ?? []).find((grandchild: any) => `{{${grandchild.value}}}` === value) if (filterOption) { return ( diff --git a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx index 98f86ecf..e24d531d 100644 --- a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx +++ b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx @@ -30,6 +30,25 @@ const operationsObj = { ], } +const filterByDataType = (options: Suggestion[], dataType: string): Suggestion[] => + options.reduce((acc, vo) => { + if (vo.children?.length) { + const children = vo.children.reduce((cacc, child) => { + if (child.children?.length) { + const grandchildren = child.children.filter(gc => gc.dataType === dataType); + if (grandchildren.length) cacc.push({ ...child, children: grandchildren }); + } else if (child.dataType === dataType) { + cacc.push(child); + } + return cacc; + }, []); + if (children.length) acc.push({ ...vo, children }); + } else if (vo.dataType === dataType) { + acc.push(vo); + } + return acc; + }, []); + const AssignmentList: FC = ({ parentName, options = [], @@ -59,7 +78,9 @@ const AssignmentList: FC = ({ {fields.map(({ key, name, ...restField }) => { const variableSelector = form.getFieldValue([parentName, name, 'variable_selector']); - const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector); + const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector) + ?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === variableSelector) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === variableSelector); const dataType = selectedOption?.dataType; const operationOptions = dataType === 'number' ? operationsObj.number : operationsObj.default; @@ -119,7 +140,7 @@ const AssignmentList: FC = ({ {dataType === 'number' && operation === 'cover' ? vo.dataType === dataType) : options} + options={dataType ? filterByDataType(options, dataType) : options} size={size} className="rb:flex-1!" variant="filled" @@ -150,7 +171,7 @@ const AssignmentList: FC = ({ : vo.dataType === dataType) : options} + options={dataType ? filterByDataType(options, dataType) : options} size={size} className="rb:flex-1!" variant="filled" diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index 40353f64..f0a58517 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -329,7 +329,9 @@ const CaseList: FC = ({ const currentExpression = currentCase.expressions?.[conditionIndex] || {}; const currentOperator = currentExpression.operator; const leftFieldValue = currentExpression.left; - const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue); + const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue); const leftFieldType = leftFieldOption?.dataType; const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]' || leftFieldType === 'array[file]'; const operatorList = leftFieldType && operatorsObj[leftFieldType] diff --git a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx index ddf92971..3e9f3261 100644 --- a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx +++ b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx @@ -155,7 +155,9 @@ const ConditionList: FC = ({ const currentExpression = expressions[index] || {}; const currentOperator = currentExpression.operator; const leftFieldValue = currentExpression.left; - const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue); + const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue); const leftFieldType = leftFieldOption?.dataType; const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || ['array[object]', 'object'].includes(leftFieldType as string); const operatorList = leftFieldType && ['array[object]', 'object'].includes(leftFieldType) diff --git a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx index 0100707c..24cdc89a 100644 --- a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx +++ b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx @@ -62,14 +62,18 @@ const GroupVariableList: FC = ({ */ useEffect(() => { if (!isCanAdd && value[0]) { - const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0]); + const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0]) + ?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === value[0]) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === value[0]); if (firstVariable) { form.setFieldValue(['group_type', 'output'], firstVariable.dataType); } } else if (isCanAdd) { value.forEach((item: any, index: number) => { if (item?.value?.[0]) { - const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0]); + const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0]) + ?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === item.value[0]) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === item.value[0]); if (firstVariable) { form.setFieldValue(['group_type', index], firstVariable.dataType); } diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx index e0a27b47..e4b2cc29 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx @@ -85,9 +85,9 @@ const EditableTable: FC = ({ return [ { title: t('workflow.config.name'), - dataIndex: 'name', + dataIndex: 'key', render: (_: any, __: TableRow, index: number) => ( - + { - const value = e.target.value || e.target.value - form.setFieldValue(['body', 'data'], ['form-data', 'x-www-form-urlencoded'].includes(value) ? [{}] : undefined) + const handleChangeBodyContentType = () => { + form.setFieldValue(['body', 'data'], undefined) } // Handle error handling method change and update node ports accordingly diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index b28d7b4f..5523c06e 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:40:13 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-08 10:48:21 + * @Last Modified time: 2026-04-13 11:25:40 */ import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react' import { createPortal } from 'react-dom' @@ -190,20 +190,30 @@ const VariableSelect: FC = ({ {/* Trigger */}
setOpen(o => !o)} > {multiple ? ( selectedValues.length > 0 ? ( - + {selectedValues.map(v => { const s = suggestionMap.get(v); if (!s) return null; @@ -214,11 +224,11 @@ const VariableSelect: FC = ({ return ( - {!isConv && nd?.icon &&
} + {!isConv && nd?.icon &&
} {!isConv && nd?.name && {nd.name}{sep}} - + {parent ? <>{parent.label}{sep}{s.label} : s.label} = ({ ); })} - + ) : ( - {placeholder} + {placeholder} ) ) : selectedSuggestion ? (
- - {!isConversation && nodeData?.icon &&
} - {!isConversation && nodeData?.name && {nodeData.name}} - {!isConversation && nodeData?.name && {sep}} - + + {!isConversation && nodeData?.icon &&
} + {!isConversation && nodeData?.name && {nodeData.name}} + {!isConversation && nodeData?.name && {sep}} + {parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label} : selectedSuggestion.label} @@ -266,18 +278,19 @@ const VariableSelect: FC = ({ {open && createPortal(
-
- {Object.entries(filteredGroups).map(([nodeId, suggestions]) => { +
+ {Object.entries(filteredGroups).map(([nodeId, suggestions], index) => { const nd = suggestions[0].nodeData; return ( -
- - {nd.icon &&
} +
+
{nd.name} - +
{suggestions.map(s => { const isSelected = multiple ? selectedValues.includes(`{{${s.value}}}`) @@ -288,11 +301,9 @@ const VariableSelect: FC = ({ { if (el) itemRefs.current.set(s.key, el); }} - className={clsx("rb:pl-6! rb:pr-3! rb:py-1.25! rb:rounded-lg!", { - 'rb:bg-[#e6f4ff]': isSelected || isExpanded, - 'rb:bg-white rb:hover:bg-[#F6F6F6]!': !(isSelected || isExpanded), - 'rb:opacity-60': s.disabled, - 'rb:cursor-not-allowed': s.disabled, + className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { + 'rb:bg-[#F6F6F6]': isSelected || isExpanded, + 'rb:cursor-not-allowed rb:opacity-65': s.disabled, 'rb:cursor-pointer': !s.disabled, })} align="center" @@ -314,16 +325,15 @@ const VariableSelect: FC = ({ } }} > - +
{multiple && ( - + )} - {`{x}`} - {s.label} - - - {s.dataType && {s.dataType}} + {`{x}`} {s.label} +
+ + {s.dataType && {s.dataType}} {hasChildren &&
}
@@ -334,7 +344,7 @@ const VariableSelect: FC = ({ })} {Object.keys(filteredGroups).length === 0 && (
- {t('workflow.variableSelect.empty', '暂无变量')} + {t('workflow.variableSelect.empty')}
)}
@@ -346,18 +356,13 @@ const VariableSelect: FC = ({ {open && expandedParent?.children?.length && createPortal(
setExpandedParent(expandedParent)} > -
!expandedParent.disabled && handleSelect(expandedParent)} - > +
- - {expandedParent.nodeData.name}.{expandedParent.label} - + {expandedParent.nodeData.name}.{expandedParent.label} {expandedParent.dataType}
@@ -365,32 +370,27 @@ const VariableSelect: FC = ({ const isSelected = multiple ? selectedValues.includes(`{{${child.value}}}`) : `{{${child.value}}}` === value; - const hasGrandChildren = !!child.children?.length; return ( !child.disabled && handleSelect(child)} > - + {multiple && ( )} - {child.label} - - - {child.dataType && {child.dataType}} - {hasGrandChildren && } + {child.label} + + {child.dataType && {child.dataType}} + ); })} diff --git a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts index 3c4ea6f7..14dcced2 100644 --- a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts +++ b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-01-19 17:00:26 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-08 10:12:27 + * @Last Modified time: 2026-04-13 10:44:17 */ /** * useVariableList Hook @@ -414,7 +414,7 @@ export const useVariableList = ( const pd = parentLoop.getData(); const pid = pd.id; if (pd.type === 'loop') { - (pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'String', `${pid}.${cv.name}`, pd)); + (pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${pid}.${cv.name}`, pd)); } else if (pd.type === 'iteration' && pd.config.input.defaultValue) { let itemType = 'object'; const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue); diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index b5bc2d2e..f826edd9 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:39:59 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-10 17:24:19 + * @Last Modified time: 2026-04-13 10:44:19 */ import { type FC, useEffect, useState, useMemo } from "react"; import clsx from 'clsx' @@ -266,7 +266,7 @@ const Properties: FC = ({ key, label: cycleVar.name, type: 'variable', - dataType: cycleVar.type || 'String', + dataType: cycleVar.type || 'string', value: `${parentNodeId}.${cycleVar.name}`, nodeData: parentData, }); @@ -643,7 +643,7 @@ const Properties: FC = ({ key: contextKey, label: 'context', type: 'variable', - dataType: 'String', + dataType: 'string', value: `context`, nodeData: selectedNode.getData(), isContext: true, @@ -791,7 +791,7 @@ const Properties: FC = ({ key: `${selectedNode.id}_cycle_${cycleVar.name}`, label: cycleVar.name, type: 'variable', - dataType: cycleVar.type || 'String', + dataType: cycleVar.type || 'string', value: `${selectedNode.getData().id}.${cycleVar.name}`, nodeData: selectedNode.getData(), })); diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index f385acf3..535a4eb0 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 23:17:50 + * @Last Modified time: 2026-04-14 17:43:14 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -111,6 +111,7 @@ export const useWorkflowGraph = ({ graphRef.current.getNodes().forEach(node => { const data = node.getData() if (data?.type === 'if-else' || data?.type === 'question-classifier') { + console.log('chatVariables', chatVariables) node.setData({ ...data, chatVariables }, { silent: true }) } }) @@ -203,7 +204,7 @@ export const useWorkflowGraph = ({ ? Object.entries(group_variables as Record).map(([key, value]) => ({ key, value })) : group_variables } else if (type === 'http-request' && (key === 'headers' || key === 'params') && config[key] && typeof config[key] === 'object' && !Array.isArray(config[key]) && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { - nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([name, value]) => ({ name, value })) + nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([key, value]) => ({ key, value })) } else if (type === 'code' && key === 'code' && config[key] && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { try { nodeLibraryConfig.config[key].defaultValue = decodeURIComponent(atob(config[key] as string)) @@ -1022,24 +1023,39 @@ export const useWorkflowGraph = ({ graphRef.current.on('node:removed', blankClick) // When edge connected, bring connected nodes' ports to front - graphRef.current.on('edge:connected', ({ isNew }) => { - // Bring edge to front first, then bring child nodes above edges - // Parent (loop/iteration) nodes stay behind to avoid covering edges - // Reset any port hover state left from dragging + graphRef.current.on('edge:connected', ({ isNew, edge }) => { if (isNew) { - graphRef.current?.getNodes().forEach(node => { - if (!node.getData()?.cycle) node.toFront(); - }); - graphRef.current?.getEdges().forEach(edge => { - const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); - const targetCell = graphRef.current?.getCellById(edge.getTargetCellId()); - if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) { - edge.toFront(); - } - }); - graphRef.current?.getNodes().forEach(node => { - if (node.getData()?.cycle) node.toFront(); - }); + const sourceCellId = edge.getSourceCellId() + const targetCellId = edge.getTargetCellId() + const sourceCell = graphRef.current?.getCellById(sourceCellId); + const targetCell = graphRef.current?.getCellById(targetCellId); + + sourceCell?.toFront(); + targetCell?.toFront() + if (['loop', 'iteration'].includes(sourceCell?.getData()?.type)) { + graphRef.current?.getEdges().forEach(edge => { + const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); + const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId()); + if (edgeSourceCell?.getData()?.cycle === sourceCellId || edgeTargetCell?.getData()?.cycle === sourceCellId) { + edge.toFront(); + } + }); + graphRef.current?.getNodes().forEach(node => { + if (node.getData()?.cycle === sourceCellId) node.toFront(); + }); + } + if (['loop', 'iteration'].includes(targetCell?.getData()?.type)) { + graphRef.current?.getEdges().forEach(edge => { + const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); + const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId()); + if (edgeSourceCell?.getData()?.cycle === targetCellId || edgeTargetCell?.getData()?.cycle === targetCellId) { + edge.toFront(); + } + }); + graphRef.current?.getNodes().forEach(node => { + if (node.getData()?.cycle === targetCellId) node.toFront(); + }); + } } }); @@ -1184,9 +1200,6 @@ export const useWorkflowGraph = ({ }) || []; const edges = graphRef.current?.getEdges() || [] - - console.log('config', config) - const params = { ...config, features: featuresRef.current, @@ -1243,9 +1256,17 @@ export const useWorkflowGraph = ({ itemConfig[key] = {} if (value.length > 0) { value.forEach((vo: any) => { - itemConfig[key][vo.name] = vo.value + itemConfig[key][vo.key] = vo.value }) } + } else if (data.type === 'http-request' && key === 'body' && data.config[key] && 'defaultValue' in data.config[key]) { + const value = data.config[key].defaultValue + itemConfig[key] = value + if (value.content_type === 'json' && value.data && value.data !== '') { + itemConfig[key].data = value.data.replace(/\u00a0/g, ' ') + } else { + itemConfig[key].data = value.data + } } else if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') { itemConfig[key] = data.config[key].defaultValue } else if (key === 'knowledge_retrieval' && data.config[key] && 'defaultValue' in data.config[key]) {