Merge branch 'release/v0.3.0' into develop

* release/v0.3.0: (44 commits)
  Revert "fix(web): prompt editor"
  fix(web): prompt editor
  fix(prompt-optimizer): handle escaped quotes in JSON parsing
  fix(custom-tools): remove parameter coercion in custom tool base class
  fix(core): conditionally apply thinking parameters based on model support
  refactor(custom-tools): coerce query and request body parameters to schema types
  fix(prompt-optimizer): support list content type in prompt optimizer
  refactor(memory): unify user placeholder names and harden alias sync logic
  fix(rag): replace semicolon separators with newlines in Excel parser output
  fix(web): Compatible with Windows whitespace
  fix(memory): make PgSQL the single source of truth for user entity aliases
  refactor(rag): simplify Excel parsing logic and remove redundant chunk_token_num assignment
  fix(web): Hide error message when workflow node error message equals empty string
  ci(wechat-notify): add Sourcery summary extraction with Qwen fallback
  fix(http-request,embedding,naive): tighten form-data validation, reduce truncation length to 8000, and disable chunking for Excel
  fix(web): adjust the value of End User Name
  fix(http-request): support array and file variables in form-data files upload
  fix(web): change http body key name
  fix(web): header user name
  fix(web): calculate using the filtered breadcrumbs length
  ...

# Conflicts:
#	web/src/views/UserMemoryDetail/Neo4j.tsx
#	web/src/views/UserMemoryDetail/components/EndUserProfile.tsx
#	web/src/views/UserMemoryDetail/types.ts
This commit is contained in:
Mark
2026-04-15 19:31:38 +08:00
48 changed files with 702 additions and 452 deletions

View File

@@ -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<<EOF\n")
gh.write(summary + "\n")
gh.write("EOF\n")
PYEOF
# 4⃣ Fallback: 获取 commits + 通义千问总结
- name: Get Commits
if: steps.check.outputs.ok == 'true' && steps.sourcery.outputs.found == 'false'
run: |
curl -s \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
${{ github.event.pull_request.commits_url }} \
| jq -r '.[].commit.message' | head -n 20 > 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<<EOF\n")
gh.write(summary + "\n")
gh.write("EOF\n")
PYEOF
# 5⃣ 企业微信通知Markdown
- name: Notify WeChat
if: steps.check.outputs.ok == 'true'
env:
WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }}
BRANCH: ${{ github.event.pull_request.base.ref }}
AUTHOR: ${{ github.event.pull_request.user.login }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
SOURCERY_FOUND: ${{ steps.sourcery.outputs.found }}
SOURCERY_SUMMARY: ${{ steps.sourcery.outputs.summary }}
QWEN_SUMMARY: ${{ steps.qwen.outputs.summary }}
run: |
python3 << 'PYEOF'
import json, os, urllib.request
if os.environ.get("SOURCERY_FOUND") == "true":
label = "Summary by Sourcery"
summary = os.environ.get("SOURCERY_SUMMARY", "")
else:
label = "AI变更摘要"
summary = os.environ.get("QWEN_SUMMARY", "AI 摘要生成失败")
content = (
"## 🚀 Release 发布通知\n"
"> 📦 **分支**: " + 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

1
.gitignore vendored
View File

@@ -27,6 +27,7 @@ time.log
celerybeat-schedule.db celerybeat-schedule.db
search_results.json search_results.json
redbear-mem-metrics/ redbear-mem-metrics/
redbear-mem-benchmark/
pitch-deck/ pitch-deck/
api/migrations/versions api/migrations/versions

View File

@@ -124,10 +124,11 @@ async def get_prompt_opt(
skill=data.skill skill=data.skill
): ):
# chunk 是 prompt 的增量内容 # 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: except Exception as e:
yield f"event:error\ndata: {json.dumps( yield f"event:error\ndata: {json.dumps(
{"error": str(e)} {"error": str(e)},
ensure_ascii=False
)}\n\n" )}\n\n"
yield "event:end\ndata: {}\n\n" yield "event:end\ndata: {}\n\n"

View File

@@ -14,6 +14,7 @@ from dotenv import load_dotenv
from app.core.logging_config import get_agent_logger 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.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.extraction_orchestrator import ExtractionOrchestrator
from app.core.memory.storage_services.extraction_engine.knowledge_extraction.memory_summary import \ from app.core.memory.storage_services.extraction_engine.knowledge_extraction.memory_summary import \
memory_summary_generation memory_summary_generation
@@ -191,15 +192,37 @@ async def write(
if success: if success:
logger.info("Successfully saved all data to Neo4j") logger.info("Successfully saved all data to Neo4j")
# 使用 Celery 异步任务触发聚类(不阻塞主流程)
if all_entity_nodes: 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: try:
from app.tasks import run_incremental_clustering 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] new_entity_ids = [e.id for e in all_entity_nodes]
# 异步提交 Celery 任务
task = run_incremental_clustering.apply_async( task = run_incremental_clustering.apply_async(
kwargs={ kwargs={
"end_user_id": end_user_id, "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, "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, "embedding_model_id": str(memory_config.embedding_model_id) if memory_config.embedding_model_id else None,
}, },
# 设置任务优先级(低优先级,不影响主业务)
priority=3, priority=3,
) )
logger.info( 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)}" f"task_id={task.id}, end_user_id={end_user_id}, entity_count={len(new_entity_ids)}"
) )
except Exception as e: except Exception as e:
# 聚类任务提交失败不影响主流程
logger.error(f"[Clustering] 提交聚类任务失败(不影响主流程): {e}", exc_info=True) logger.error(f"[Clustering] 提交聚类任务失败(不影响主流程): {e}", exc_info=True)
break break

View File

@@ -82,50 +82,37 @@ def _merge_attribute(canonical: ExtractedEntityNode, ent: ExtractedEntityNode):
canonical.connect_strength = next(iter(pair)) canonical.connect_strength = next(iter(pair))
# 别名合并(去重保序,使用标准化工具) # 别名合并(去重保序,使用标准化工具)
# 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,去重合并时不修改
try: try:
canonical_name = (getattr(canonical, "name", "") or "").strip() canonical_name = (getattr(canonical, "name", "") or "").strip()
if canonical_name.lower() not in _USER_PLACEHOLDER_NAMES:
incoming_name = (getattr(ent, "name", "") or "").strip() incoming_name = (getattr(ent, "name", "") or "").strip()
# 收集所有需要合并的别名 # 收集所有需要合并的别名,过滤掉用户占位名避免污染非用户实体
all_aliases = [] all_aliases = list(getattr(canonical, "aliases", []) or [])
if incoming_name and incoming_name != canonical_name and incoming_name.lower() not in _USER_PLACEHOLDER_NAMES:
# 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) 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
)
# 3. 添加incoming实体的所有别名
incoming = getattr(ent, "aliases", []) or []
all_aliases.extend(incoming)
# 4. 标准化并去重优先使用alias_utils工具函数
try: try:
from app.core.memory.utils.alias_utils import normalize_aliases from app.core.memory.utils.alias_utils import normalize_aliases
canonical.aliases = normalize_aliases(canonical_name, all_aliases) canonical.aliases = normalize_aliases(canonical_name, all_aliases)
except Exception: except Exception:
# 如果导入失败,使用增强的去重逻辑
seen_normalized = set() seen_normalized = set()
unique_aliases = [] unique_aliases = []
for alias in all_aliases: for alias in all_aliases:
if not alias: if not alias:
continue continue
alias_stripped = str(alias).strip() alias_stripped = str(alias).strip()
if not alias_stripped or alias_stripped == canonical_name: if not alias_stripped or alias_stripped == canonical_name:
continue continue
# 标准化:转小写用于去重判断
alias_normalized = alias_stripped.lower() alias_normalized = alias_stripped.lower()
if alias_normalized not in seen_normalized: if alias_normalized not in seen_normalized:
seen_normalized.add(alias_normalized) seen_normalized.add(alias_normalized)
unique_aliases.append(alias_stripped) unique_aliases.append(alias_stripped)
# 排序并赋值
canonical.aliases = sorted(unique_aliases) canonical.aliases = sorted(unique_aliases)
except Exception: except Exception:
pass pass
@@ -733,66 +720,37 @@ def fuzzy_match(
def _merge_entities_with_aliases(canonical: ExtractedEntityNode, losing: ExtractedEntityNode): def _merge_entities_with_aliases(canonical: ExtractedEntityNode, losing: ExtractedEntityNode):
""" 模糊匹配中的实体合并。 """模糊匹配中的实体合并(别名部分)
合并策略: 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,跳过合并。
1. 保留canonical的主名称不变
2. 将losing的主名称添加为alias如果不同
3. 合并两个实体的所有aliases
4. 自动去重case-insensitive并排序
Args:
canonical: 规范实体(保留)
losing: 被合并实体(删除)
Note:
使用alias_utils.normalize_aliases进行标准化去重
""" """
# 获取规范实体的名称
canonical_name = (getattr(canonical, "name", "") or "").strip() canonical_name = (getattr(canonical, "name", "") or "").strip()
if canonical_name.lower() in _USER_PLACEHOLDER_NAMES:
return
losing_name = (getattr(losing, "name", "") or "").strip() losing_name = (getattr(losing, "name", "") or "").strip()
# 收集所有需要合并的别名 all_aliases = list(getattr(canonical, "aliases", []) or [])
all_aliases = []
# 1. 添加canonical现有的别名
current_aliases = getattr(canonical, "aliases", []) or []
all_aliases.extend(current_aliases)
# 2. 添加losing实体的名称如果不同于canonical的名称
if losing_name and losing_name != canonical_name: if losing_name and losing_name != canonical_name:
all_aliases.append(losing_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: try:
from app.core.memory.utils.alias_utils import normalize_aliases from app.core.memory.utils.alias_utils import normalize_aliases
canonical.aliases = normalize_aliases(canonical_name, all_aliases) canonical.aliases = normalize_aliases(canonical_name, all_aliases)
except Exception: except Exception:
# 如果导入失败,使用增强的去重逻辑
# 使用标准化后的字符串作为key进行去重
seen_normalized = set() seen_normalized = set()
unique_aliases = [] unique_aliases = []
for alias in all_aliases: for alias in all_aliases:
if not alias: if not alias:
continue continue
alias_stripped = str(alias).strip() alias_stripped = str(alias).strip()
if not alias_stripped or alias_stripped == canonical_name: if not alias_stripped or alias_stripped == canonical_name:
continue continue
# 标准化:转小写用于去重判断
alias_normalized = alias_stripped.lower() alias_normalized = alias_stripped.lower()
if alias_normalized not in seen_normalized: if alias_normalized not in seen_normalized:
seen_normalized.add(alias_normalized) seen_normalized.add(alias_normalized)
unique_aliases.append(alias_stripped) unique_aliases.append(alias_stripped)
# 排序并赋值
canonical.aliases = sorted(unique_aliases) canonical.aliases = sorted(unique_aliases)
# ========== 主循环:遍历所有实体对进行模糊匹配 ========== # ========== 主循环:遍历所有实体对进行模糊匹配 ==========

View File

@@ -1391,18 +1391,18 @@ class ExtractionOrchestrator:
""" """
将本轮提取的用户别名同步到 end_user 和 end_user_info 表。 将本轮提取的用户别名同步到 end_user 和 end_user_info 表。
注意:此方法在 Neo4j 写入之前调用,因此不能依赖 Neo4j 作为别名的权威数据源。 PgSQL end_user_info.aliases 是用户别名的唯一权威源。
改为直接使用内存中去重后的 entity_nodes 的 aliases与 PgSQL 已有的 aliases 合并。 此方法仅将本轮 LLM 从对话中新提取的别名增量追加到 PgSQL
不再从 Neo4j 二层去重合并历史别名,避免脏数据反向污染 PgSQL。
策略: 策略:
1. 从内存中的 entity_nodes 提取本轮用户别名current_aliases 1. 从本轮对话原始发言中提取用户别名current_aliases
2. 从去重后的 entity_nodes 中提取完整别名(含 Neo4j 二层去重合并的历史别名 2. 从 PgSQL end_user_info 读取已有的 aliasesdb_aliases
3. 从 PgSQL end_user_info 读取已有的 aliasesdb_aliases 3. 合并 db_aliases + current_aliases,去重保序
4. 合并 db_aliases + deduped_aliases + current_aliases去重保序 4. 写回 PgSQL
5. 写回 PgSQL
Args: Args:
entity_nodes: 去重后的实体节点列表(内存中,含二层去重合并结果 entity_nodes: 去重后的实体节点列表(内存中)
dialog_data_list: 对话数据列表 dialog_data_list: 对话数据列表
""" """
try: try:
@@ -1418,11 +1418,6 @@ class ExtractionOrchestrator:
# 1. 提取本轮对话的用户别名(保持 LLM 提取的原始顺序,不排序) # 1. 提取本轮对话的用户别名(保持 LLM 提取的原始顺序,不排序)
current_aliases = self._extract_current_aliases(entity_nodes, dialog_data_list) 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 助手别名,作为额外的排除源 # 1.6 从 Neo4j 查询已有的 AI 助手别名,作为额外的排除源
# (防止 LLM 未提取出 AI 助手实体时AI 别名泄漏到用户别名中) # (防止 LLM 未提取出 AI 助手实体时AI 别名泄漏到用户别名中)
neo4j_assistant_aliases = await self._fetch_neo4j_assistant_aliases(end_user_id) neo4j_assistant_aliases = await self._fetch_neo4j_assistant_aliases(end_user_id)
@@ -1434,19 +1429,12 @@ class ExtractionOrchestrator:
] ]
if len(current_aliases) < before_count: if len(current_aliases) < before_count:
logger.info(f"通过 Neo4j AI 助手别名排除了 {before_count - len(current_aliases)} 个误归属别名") 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}") logger.debug(f"本轮未提取到用户别名,跳过同步: end_user_id={end_user_id}")
return return
logger.info(f"本轮对话提取的 aliases: {current_aliases}") logger.info(f"本轮对话提取的 aliases: {current_aliases}")
if deduped_aliases:
logger.info(f"去重后实体的完整 aliases含历史: {deduped_aliases}")
# 2. 同步到数据库 # 2. 同步到数据库
end_user_uuid = uuid.UUID(end_user_id) end_user_uuid = uuid.UUID(end_user_id)
@@ -1457,21 +1445,15 @@ class ExtractionOrchestrator:
logger.warning(f"未找到 end_user_id={end_user_id} 的用户记录") logger.warning(f"未找到 end_user_id={end_user_id} 的用户记录")
return return
# 3. 从 PgSQL 读取已有 aliases 并与本轮合并 # 3. 从 PgSQL 读取已有 aliases 并与本轮新增合并
info = EndUserInfoRepository(db).get_by_end_user_id(end_user_uuid) info = EndUserInfoRepository(db).get_by_end_user_id(end_user_uuid)
db_aliases = (info.aliases if info and info.aliases else []) 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] 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) merged_aliases = list(db_aliases)
seen_lower = {a.strip().lower() for a in merged_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: for alias in current_aliases:
if alias.strip().lower() not in seen_lower: if alias.strip().lower() not in seen_lower:
merged_aliases.append(alias) merged_aliases.append(alias)
@@ -1505,9 +1487,7 @@ class ExtractionOrchestrator:
info.aliases = merged_aliases info.aliases = merged_aliases
logger.info(f"同步合并后 aliases 到 end_user_info: {merged_aliases}") logger.info(f"同步合并后 aliases 到 end_user_info: {merged_aliases}")
else: else:
first_alias = current_aliases[0].strip() if current_aliases else ( first_alias = current_aliases[0].strip() if current_aliases else ""
deduped_aliases[0].strip() if deduped_aliases else ""
)
# 确保 first_alias 不是占位名称 # 确保 first_alias 不是占位名称
if first_alias and first_alias.lower() not in self.USER_PLACEHOLDER_NAMES: if first_alias and first_alias.lower() not in self.USER_PLACEHOLDER_NAMES:
db.add(EndUserInfo( db.add(EndUserInfo(

View File

@@ -112,6 +112,7 @@ class RedBearModelFactory:
params["stream_usage"] = True params["stream_usage"] = True
# 深度思考模式 # 深度思考模式
is_streaming = bool(config.extra_params.get("streaming")) is_streaming = bool(config.extra_params.get("streaming"))
if config.support_thinking:
if is_streaming and not config.is_omni: if is_streaming and not config.is_omni:
if provider == ModelProvider.VOLCANO: if provider == ModelProvider.VOLCANO:
# 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数

View File

@@ -672,10 +672,15 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
excel_parser = ExcelParser() excel_parser = ExcelParser()
if parser_config.get("html4excel") and parser_config.get("html4excel").lower() == "true": if parser_config.get("html4excel") and parser_config.get("html4excel").lower() == "true":
sections = [(_, "") for _ in excel_parser.html(binary, 12) if _] sections = [(_, "") for _ in excel_parser.html(binary, 12) if _]
parser_config["chunk_token_num"] = 0
else: else:
sections = [(_, "") for _ in excel_parser(binary) if _] 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): 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.") callback(0.1, "Start to parse.")

View File

@@ -232,14 +232,14 @@ class RAGExcelParser:
t = str(ti[i].value) if i < len(ti) else "" t = str(ti[i].value) if i < len(ti) else ""
t += ("" if t else "") + str(c.value) t += ("" if t else "") + str(c.value)
fields.append(t) fields.append(t)
line = "; ".join(fields) line = "\n".join(fields)
if sheetname.lower().find("sheet") < 0: if sheetname.lower().find("sheet") < 0:
line += " ——" + sheetname line += "\n——" + sheetname
res.append(line) res.append(line)
else: else:
# 只有表头的情况 # 只有表头的情况
if header_fields: if header_fields:
line = "; ".join(header_fields) line = "\n".join(header_fields)
if sheetname.lower().find("sheet") < 0: if sheetname.lower().find("sheet") < 0:
line += " ——" + sheetname line += " ——" + sheetname
res.append(line) res.append(line)

View File

@@ -50,7 +50,9 @@ class OpenAIEmbed(Base):
def encode(self, texts: list): def encode(self, texts: list):
# OpenAI requires batch size <=16 # OpenAI requires batch size <=16
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 = [] ress = []
total_tokens = 0 total_tokens = 0
for i in range(0, len(texts), batch_size): for i in range(0, len(texts), batch_size):
@@ -63,7 +65,7 @@ class OpenAIEmbed(Base):
return np.array(ress), total_tokens return np.array(ress), total_tokens
def encode_queries(self, text): 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) return np.array(res.data[0].embedding), self.total_token_count(res)
@@ -79,6 +81,7 @@ class LocalAIEmbed(Base):
def encode(self, texts: list): def encode(self, texts: list):
batch_size = 16 batch_size = 16
texts = [truncate(t, 8000) for t in texts]
ress = [] ress = []
for i in range(0, len(texts), batch_size): for i in range(0, len(texts), batch_size):
res = self.client.embeddings.create(input=texts[i : i + batch_size], model=self.model_name) 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): def encode(self, texts: list):
batch_size = 16 batch_size = 16
texts = [truncate(t, 8000) for t in texts]
ress = [] ress = []
total_tokens = 0 total_tokens = 0
for i in range(0, len(texts), batch_size): for i in range(0, len(texts), batch_size):
@@ -188,7 +192,7 @@ class XinferenceEmbed(Base):
def encode_queries(self, text): def encode_queries(self, text):
res = None res = None
try: 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) return np.array(res.data[0].embedding), self.total_token_count(res)
except Exception as _e: except Exception as _e:
log_exception(_e, res) log_exception(_e, res)

View File

@@ -72,8 +72,9 @@ class HttpContentTypeConfig(BaseModel):
@classmethod @classmethod
def validate_data(cls, v, info): def validate_data(cls, v, info):
content_type = info.data.get("content_type") content_type = info.data.get("content_type")
if content_type == HttpContentType.FROM_DATA and not isinstance(v, HttpFormData): if content_type == HttpContentType.FROM_DATA and (
raise ValueError("When content_type is 'form-data', data must be of type HttpFormData") 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): elif content_type in [HttpContentType.JSON] and not isinstance(v, str):
raise ValueError("When content_type is JSON, data must be of type 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): elif content_type in [HttpContentType.WWW_FORM] and not isinstance(v, dict):

View File

@@ -260,17 +260,22 @@ class HttpRequestNode(BaseNode):
)) ))
case HttpContentType.FROM_DATA: case HttpContentType.FROM_DATA:
data = {} data = {}
content["files"] = {} files = []
for item in self.typed_config.body.data: for item in self.typed_config.body.data:
key = self._render_template(item.key, variable_pool)
if item.type == "text": if item.type == "text":
data[self._render_template(item.key, variable_pool)] = self._render_template(item.value, data[key] = self._render_template(item.value, variable_pool)
variable_pool)
elif item.type == "file": elif item.type == "file":
content["files"][self._render_template(item.key, variable_pool)] = ( file_instance = variable_pool.get_instance(item.value)
uuid.uuid4().hex, if isinstance(file_instance, ArrayVariable):
await variable_pool.get_instance(item.value).get_content() 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 content["data"] = data
if files:
content["files"] = files
case HttpContentType.BINARY: case HttpContentType.BINARY:
content["files"] = [] content["files"] = []
file_instence = variable_pool.get_instance(self.typed_config.body.data) file_instence = variable_pool.get_instance(self.typed_config.body.data)

View File

@@ -84,7 +84,7 @@ class FileVariable(BaseVariable):
total_bytes = 0 total_bytes = 0
chunks = [] 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: async with client.stream("GET", self.value.url) as resp:
resp.raise_for_status() resp.raise_for_status()
async for chunk in resp.aiter_bytes(8192): async for chunk in resp.aiter_bytes(8192):

View File

@@ -5,16 +5,9 @@ Implicit Emotions Storage Repository
事务由调用方控制,仓储层只使用 flush/refresh 事务由调用方控制,仓储层只使用 flush/refresh
""" """
import logging import logging
from datetime import date, datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Generator, Optional from typing import Generator, Optional
class TimeFilterUnavailableError(Exception):
"""redis_client 不可用,无法执行时间轴筛选。
调用方捕获此异常后可选择回退到 get_all_user_ids 进行全量处理。
"""
import redis import redis
from sqlalchemy import exists, not_, select from sqlalchemy import exists, not_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -25,6 +18,13 @@ from app.models.implicit_emotions_storage_model import ImplicitEmotionsStorage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TimeFilterUnavailableError(Exception):
"""redis_client 不可用,无法执行时间轴筛选。
调用方捕获此异常后可选择回退到 get_all_user_ids 进行全量处理。
"""
class ImplicitEmotionsStorageRepository: class ImplicitEmotionsStorageRepository:
"""隐性记忆和情绪存储仓储类""" """隐性记忆和情绪存储仓储类"""
@@ -216,9 +216,7 @@ class ImplicitEmotionsStorageRepository:
""" """
from sqlalchemy import String as SAString from sqlalchemy import String as SAString
from sqlalchemy import cast from sqlalchemy import cast
CST = timezone(timedelta(hours=8)) today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
now_cst = datetime.now(CST)
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc).replace(tzinfo=None)
tomorrow_start = today_start + timedelta(days=1) tomorrow_start = today_start + timedelta(days=1)
offset = 0 offset = 0
while True: while True:

View File

@@ -93,6 +93,8 @@ SET e.name = CASE WHEN entity.name IS NOT NULL AND entity.name <> '' THEN entity
END, 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.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 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 WHEN entity.aliases IS NOT NULL AND size(entity.aliases) > 0
THEN CASE THEN CASE
WHEN e.aliases IS NULL THEN entity.aliases WHEN e.aliases IS NULL THEN entity.aliases

View File

@@ -77,11 +77,11 @@ class Neo4jConnector:
""" """
await self.driver.close() 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查询 """执行Cypher查询
Args: Args:
query: Cypher查询语句 cypher: Cypher查询语句
json_format: json格式化 json_format: json格式化
**kwargs: 查询参数将作为参数传递给Cypher查询 **kwargs: 查询参数将作为参数传递给Cypher查询
@@ -92,7 +92,7 @@ class Neo4jConnector:
""" """
result = await self.driver.execute_query( result = await self.driver.execute_query(
query, cypher,
database="neo4j", database="neo4j",
**kwargs **kwargs
) )

View File

@@ -297,6 +297,10 @@ def get_user_by_id(db: Session, user_id: uuid.UUID) -> Optional[User]:
"""根据ID获取用户""" """根据ID获取用户"""
return UserRepository(db).get_user_by_id(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]: def get_user_by_email(db: Session, email: str) -> Optional[User]:
"""根据邮箱获取用户""" """根据邮箱获取用户"""
return UserRepository(db).get_user_by_email(email) return UserRepository(db).get_user_by_email(email)

View File

@@ -73,15 +73,14 @@ class AppDslService:
AppType.MULTI_AGENT: "multi_agent_config", AppType.MULTI_AGENT: "multi_agent_config",
AppType.WORKFLOW: "workflow" AppType.WORKFLOW: "workflow"
}.get(app.type, "config") }.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} 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" 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: if app_type == AppType.AGENT:
enriched = {**cfg} enriched = {**cfg}
if "default_model_config_id" in cfg: enriched["default_model_config_ref"] = self._model_ref(default_model_config_id)
enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"])
if "knowledge_retrieval" in cfg: if "knowledge_retrieval" in cfg:
enriched["knowledge_retrieval"] = self._enrich_knowledge_retrieval(cfg["knowledge_retrieval"]) enriched["knowledge_retrieval"] = self._enrich_knowledge_retrieval(cfg["knowledge_retrieval"])
if "tools" in cfg: if "tools" in cfg:
@@ -91,8 +90,7 @@ class AppDslService:
return enriched return enriched
if app_type == AppType.MULTI_AGENT: if app_type == AppType.MULTI_AGENT:
enriched = {**cfg} enriched = {**cfg}
if "default_model_config_id" in cfg: enriched["default_model_config_ref"] = self._model_ref(default_model_config_id)
enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"])
if "master_agent_id" in cfg: if "master_agent_id" in cfg:
enriched["master_agent_ref"] = self._release_ref(cfg["master_agent_id"]) enriched["master_agent_ref"] = self._release_ref(cfg["master_agent_id"])
if "sub_agents" in cfg: if "sub_agents" in cfg:

View File

@@ -679,9 +679,9 @@ class EmotionAnalyticsService:
# 查询用户的实体和标签 # 查询用户的实体和标签
query = """ query = """
MATCH (e:Entity) MATCH (e:ExtractedEntity)
WHERE e.end_user_id = $end_user_id 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 ORDER BY e.created_at DESC
LIMIT 20 LIMIT 20
""" """

View File

@@ -34,6 +34,7 @@ from app.schemas.implicit_memory_schema import (
UserMemorySummary, UserMemorySummary,
) )
from app.schemas.memory_config_schema import MemoryConfig from app.schemas.memory_config_schema import MemoryConfig
from app.services.memory_base_service import MIN_MEMORY_SUMMARY_COUNT
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -379,12 +380,59 @@ class ImplicitMemoryService:
raise 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( async def generate_complete_profile(
self, self,
user_id: str user_id: str
) -> dict: ) -> dict:
"""生成完整的用户画像包含所有4个模块 """生成完整的用户画像包含所有4个模块
需要该用户的 MemorySummary 节点数量 >= 5 才会真正调用 LLM 生成画像,
否则返回固定的空白画像数据。
Args: Args:
user_id: 用户ID user_id: 用户ID
@@ -394,6 +442,16 @@ class ImplicitMemoryService:
logger.info(f"生成完整用户画像: user={user_id}") logger.info(f"生成完整用户画像: user={user_id}")
try: 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个分析方法 # 并行调用4个分析方法
preferences, portrait, interest_areas, habits = await asyncio.gather( preferences, portrait, interest_areas, habits = await asyncio.gather(
self.get_preference_tags(user_id=user_id), self.get_preference_tags(user_id=user_id),

View File

@@ -265,12 +265,50 @@ async def Translation_English(modid, text, fields=None):
# 其他类型数字、布尔值、None等原样返回 # 其他类型数字、布尔值、None等原样返回
else: else:
return text return text
# 隐性记忆画像生成所需的最低 MemorySummary 节点数量
MIN_MEMORY_SUMMARY_COUNT = 5
class MemoryBaseService: class MemoryBaseService:
"""记忆服务基类,提供共享的辅助方法""" """记忆服务基类,提供共享的辅助方法"""
def __init__(self): def __init__(self):
self.neo4j_connector = Neo4jConnector() 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 @staticmethod
def parse_timestamp(timestamp_value) -> Optional[int]: def parse_timestamp(timestamp_value) -> Optional[int]:
""" """

View File

@@ -227,10 +227,20 @@ class PromptOptimizerService:
content = getattr(chunk, "content", chunk) content = getattr(chunk, "content", chunk)
if not content: if not content:
continue continue
if isinstance(content, str):
buffer += content 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] 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: if prompt_finished:
continue continue
@@ -272,7 +282,7 @@ class PromptOptimizerService:
def parser_prompt_variables(prompt: str): def parser_prompt_variables(prompt: str):
try: try:
pattern = r'\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}' 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)) variables = list(set(matches))
return variables return variables
except Exception as e: except Exception as e:

View File

@@ -14,6 +14,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.logging_config import get_logger 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.core.memory.utils.llm.llm_utils import MemoryClientFactory
from app.db import get_db_context from app.db import get_db_context
from app.repositories.conversation_repository import ConversationRepository 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.cypher_queries import Graph_Node_query
from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.repositories.neo4j.neo4j_connector import Neo4jConnector
from app.schemas.memory_episodic_schema import EmotionSubject, EmotionType, type_mapping 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_config_service import MemoryConfigService
from app.services.memory_perceptual_service import MemoryPerceptualService from app.services.memory_perceptual_service import MemoryPerceptualService
from app.services.memory_short_service import ShortService from app.services.memory_short_service import ShortService
@@ -477,7 +478,7 @@ class UserMemoryService:
allowed_fields = {'other_name', 'aliases', 'meta_data'} allowed_fields = {'other_name', 'aliases', 'meta_data'}
# 用户占位名称黑名单,不允许作为 other_name 或出现在 aliases 中 # 用户占位名称黑名单,不允许作为 other_name 或出现在 aliases 中
_user_placeholder_names = {'用户', '', 'User', 'I'} _user_placeholder_names = _USER_PLACEHOLDER_NAMES
# 过滤 other_name不允许设置为占位名称 # 过滤 other_name不允许设置为占位名称
if 'other_name' in update_data and update_data['other_name'] and update_data['other_name'].strip() in _user_placeholder_names: 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 获取) 2. 工作记忆 (WORKING_MEMORY) = 会话数量(通过 ConversationRepository.get_conversation_by_user_id 获取)
3. 短期记忆 (SHORT_TERM_MEMORY) = /short_term 接口返回的问答对数量 3. 短期记忆 (SHORT_TERM_MEMORY) = /short_term 接口返回的问答对数量
4. 显性记忆 (EXPLICIT_MEMORY) = 情景记忆 + 语义记忆(通过 MemoryBaseService.get_explicit_memory_count 获取) 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 获取) 6. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取)
7. 情景记忆 (EPISODIC_MEMORY) = memory_summary通过 MemoryBaseService.get_episodic_memory_count 获取) 7. 情景记忆 (EPISODIC_MEMORY) = memory_summary通过 MemoryBaseService.get_episodic_memory_count 获取)
8. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_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)}") logger.warning(f"获取会话数量失败工作记忆数量设为0: {str(e)}")
work_count = 0 work_count = 0
# 获取隐性记忆数量(基于 Statement 节点数量的三分之一 # 获取隐性记忆数量(基于有关联关系的 MemorySummary 节点数量,需 >= MIN_MEMORY_SUMMARY_COUNT 才计入
implicit_count = 0 implicit_count = 0
if end_user_id: if end_user_id:
try: try:
# 查询 Statement 节点数量 memory_summary_count = await base_service.get_valid_memory_summary_count(end_user_id)
query = """ implicit_count = memory_summary_count if memory_summary_count >= MIN_MEMORY_SUMMARY_COUNT else 0
MATCH (n:Statement) logger.debug(f"隐性记忆数量有效MemorySummary节点数: {implicit_count} (有效MemorySummary总数={memory_summary_count}, end_user_id={end_user_id})")
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})")
except Exception as e: except Exception as e:
logger.warning(f"获取Statement数量失败隐性记忆数量设为0: {str(e)}") logger.warning(f"获取MemorySummary数量失败隐性记忆数量设为0: {str(e)}")
implicit_count = 0 implicit_count = 0
# 原有的基于行为习惯的统计方式(已注释) # 原有的基于行为习惯的统计方式(已注释)
@@ -1643,7 +1636,7 @@ async def analytics_memory_types(
"WORKING_MEMORY": work_count, # 工作记忆(基于会话数量) "WORKING_MEMORY": work_count, # 工作记忆(基于会话数量)
"SHORT_TERM_MEMORY": short_term_count, # 短期记忆(基于问答对数量) "SHORT_TERM_MEMORY": short_term_count, # 短期记忆(基于问答对数量)
"EXPLICIT_MEMORY": explicit_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, # 情绪记忆(使用情绪标签统计) "EMOTIONAL_MEMORY": emotion_count, # 情绪记忆(使用情绪标签统计)
"EPISODIC_MEMORY": episodic_count, # 情景记忆 "EPISODIC_MEMORY": episodic_count, # 情景记忆
"FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值) "FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值)

View File

@@ -285,7 +285,7 @@ def activate_user(db: Session, user_id_to_activate: uuid.UUID, current_user: Use
try: try:
# 查找用户 # 查找用户
business_logger.debug(f"查找待激活用户: {user_id_to_activate}") 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: if not db_user:
business_logger.warning(f"用户不存在: {user_id_to_activate}") business_logger.warning(f"用户不存在: {user_id_to_activate}")
raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND) raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND)

View File

@@ -12,6 +12,14 @@
font-weight: 500; font-weight: 500;
font-style: normal; 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) { .header :global(.ant-breadcrumb) {
line-height: 31px; line-height: 31px;
} }

View File

@@ -14,7 +14,7 @@
*/ */
import { type FC, useRef, useState } from 'react'; 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 type { MenuProps, BreadcrumbProps } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
@@ -77,7 +77,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
{ {
key: '1', key: '1',
icon: <Flex align="center" justify="center" className="rb:size-10 rb:rounded-xl rb:bg-[#155EEF] rb:text-white"> icon: <Flex align="center" justify="center" className="rb:size-10 rb:rounded-xl rb:bg-[#155EEF] rb:text-white">
{/[\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]}
</Flex>, </Flex>,
label: (<> label: (<>
<div className="rb:text-[#212332] rb:leading-5">{user.username}</div> <div className="rb:text-[#212332] rb:leading-5">{user.username}</div>
@@ -135,17 +135,19 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
* - Disables navigation for the last breadcrumb item * - Disables navigation for the last breadcrumb item
*/ */
const formatBreadcrumbNames = () => { 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 = { const item: any = {
title: menu.i18nKey ? t(menu.i18nKey) : menu.label, title: (
<Tooltip title={label} placement="bottom">
<span className={styles.breadcrumbTitle}>{label}</span>
</Tooltip>
),
}; };
// If it's the last item, don't set path if (!isLast) {
if (index === breadcrumbs.length - 1) {
return item;
}
// If has custom onClick, use onClick and set href to '#' to show pointer cursor
if ((menu as any).onClick) { if ((menu as any).onClick) {
item.onClick = (e: React.MouseEvent) => { item.onClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -153,9 +155,9 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
}; };
item.href = '#'; item.href = '#';
} else if (menu.path && menu.path !== '#') { } else if (menu.path && menu.path !== '#') {
// Only set path when path is not '#'
item.path = menu.path; item.path = menu.path;
} }
}
return item; return item;
}); });
@@ -180,7 +182,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => {
> >
<Flex align="center" className="rb:cursor-pointer rb:font-medium"> <Flex align="center" className="rb:cursor-pointer rb:font-medium">
<Flex align="center" justify="center" className="rb:size-8 rb:rounded-xl rb:bg-[#155EEF] rb:text-white rb:mr-2!"> <Flex align="center" justify="center" className="rb:size-8 rb:rounded-xl rb:bg-[#155EEF] rb:text-white rb:mr-2!">
{/[\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]}
</Flex> </Flex>
<span className="rb:text-[#212332] rb:text-[12px] rb:leading-4 rb:mr-1">{user.username}</span> <span className="rb:text-[#212332] rb:text-[12px] rb:leading-4 rb:mr-1">{user.username}</span>
<div className={clsx("rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')]", { <div className={clsx("rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')]", {

View File

@@ -116,7 +116,7 @@ export const en = {
prompt: 'Prompt Engineering', prompt: 'Prompt Engineering',
skills: 'Skill Library', skills: 'Skill Library',
workbench: 'Workbench', workbench: 'Workbench',
memoryRelated: 'Memory-Related', memoryRelated: 'Memory Hub',
advancedSettings: 'Advanced Settings', advancedSettings: 'Advanced Settings',
promptHistory: 'My history', promptHistory: 'My history',
platformManagement: 'Platform Management', platformManagement: 'Platform Management',
@@ -2559,6 +2559,9 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
'list-operator.input_list': 'Input list', 'list-operator.input_list': 'Input list',
}, },
checkListHasErrors: 'Please resolve all issues in the checklist before publishing', checkListHasErrors: 'Please resolve all issues in the checklist before publishing',
variableSelect: {
empty: 'No variables available',
},
}, },
emotionEngine: { emotionEngine: {
emotionEngineConfig: 'Emotion Engine Configuration', emotionEngineConfig: 'Emotion Engine Configuration',

View File

@@ -116,7 +116,7 @@ export const zh = {
prompt: '提示词工程', prompt: '提示词工程',
skills: '技能库', skills: '技能库',
workbench: '工作台', workbench: '工作台',
memoryRelated: '记忆相关', memoryRelated: '记忆中枢',
advancedSettings: '高级设置', advancedSettings: '高级设置',
promptHistory: '我的历史', promptHistory: '我的历史',
platformManagement: '平台管理', platformManagement: '平台管理',
@@ -2523,6 +2523,9 @@ export const zh = {
'list-operator.input_list': '输入变量', 'list-operator.input_list': '输入变量',
}, },
checkListHasErrors: '发布前确认检查清单中所有问题均已解决', checkListHasErrors: '发布前确认检查清单中所有问题均已解决',
variableSelect: {
empty: '暂无变量',
},
}, },
emotionEngine: { emotionEngine: {
emotionEngineConfig: '情感引擎配置', emotionEngineConfig: '情感引擎配置',

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-03-05 * @Date: 2026-03-05
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 16:58:10 * @Last Modified time: 2026-04-13 15:13:36
*/ */
import { forwardRef, useImperativeHandle, useState } from 'react'; import { forwardRef, useImperativeHandle, useState } from 'react';
import { Button, Form, Input, Flex, App } from 'antd'; import { Button, Form, Input, Flex, App } from 'antd';
@@ -36,8 +36,6 @@ const OpenStatementSettingModal = forwardRef<OpenStatementSettingModalRef, OpenS
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [form] = Form.useForm<FeaturesConfigForm['opening_statement']>(); const [form] = Form.useForm<FeaturesConfigForm['opening_statement']>();
console.log('chatVariables', chatVariables)
const handleClose = () => { const handleClose = () => {
setVisible(false); setVisible(false);
form.resetFields(); form.resetFields();

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 18:33:30 * @Date: 2026-02-03 18:33:30
* @Last Modified by: ZhaoYing * @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 * End User Profile Component
@@ -28,11 +28,11 @@ import Tag from '@/components/Tag';
* Component props * Component props
*/ */
interface EndUserProfileProps { interface EndUserProfileProps {
onDataLoaded?: (data: { other_name?: string; id: string }) => void; onDataLoaded?: (data?: EndUser) => void;
className?: string; className?: string;
} }
const EndUserProfile = forwardRef<EndUserProfileRef, EndUserProfileProps>(({ className }, ref) => { const EndUserProfile = forwardRef<EndUserProfileRef, EndUserProfileProps>(({ className, onDataLoaded }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { id } = useParams() const { id } = useParams()
const endUserProfileModalRef = useRef<EndUserProfileModalRef>(null) const endUserProfileModalRef = useRef<EndUserProfileModalRef>(null)
@@ -52,6 +52,7 @@ const EndUserProfile = forwardRef<EndUserProfileRef, EndUserProfileProps>(({ cla
const userData = res as EndUser const userData = res as EndUser
setData(userData) setData(userData)
setLoading(false) setLoading(false)
onDataLoaded?.(userData as EndUser)
}) })
.finally(() => { .finally(() => {
setLoading(false) setLoading(false)

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 18:32:53 * @Date: 2026-02-03 18:32:53
* @Last Modified by: ZhaoYing * @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 { useEffect, useState, forwardRef, useImperativeHandle, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -93,7 +93,7 @@ const InterestAreas = forwardRef<{ handleRefresh: () => void; }>((_props, ref) =
ref={chartRef} ref={chartRef}
option={{ option={{
color: Colors, color: Colors,
grid: { top: 8, left: 38, right: 8, bottom: 24 }, grid: { top: 14, left: 38, right: 8, bottom: 24 },
xAxis: { xAxis: {
type: 'category', type: 'category',
data: keys.map(k => t(`implicitDetail.${k}`)), data: keys.map(k => t(`implicitDetail.${k}`)),

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 17:57:15 * @Date: 2026-02-03 17:57:15
* @Last Modified by: ZhaoYing * @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 * User Memory Detail Types
@@ -172,6 +172,7 @@ export interface EndUser {
other_name: string; other_name: string;
aliases: string | null; aliases: string | null;
meta_data: Record<string, string>; meta_data: Record<string, string>;
id?: string;
end_user_info_id: string; end_user_info_id: string;
end_user_id: string; end_user_id: string;
created_at: string; created_at: string;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-30 13:59:36 * @Date: 2025-12-30 13:59:36
* @Last Modified by: ZhaoYing * @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 { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react';
import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd'; import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd';
@@ -136,7 +136,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
form.validateFields().then((values) => { form.validateFields().then((values) => {
const defaultValue = Array.isArray(values.defaultValue) const defaultValue = Array.isArray(values.defaultValue)
? values.defaultValue.filter((v: any) => v !== undefined && v !== null && v !== '') ? values.defaultValue.filter((v: any) => v !== undefined && v !== null && v !== '')
: values.type.includes('object') : values.type.includes('object') && values.defaultValue
? JSON.parse(values.defaultValue) ? JSON.parse(values.defaultValue)
: values.defaultValue; : values.defaultValue;
refresh({ ...values, defaultValue }, editIndex); refresh({ ...values, defaultValue }, editIndex);
@@ -345,15 +345,16 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
<Form.Item <Form.Item
name="defaultValue" name="defaultValue"
label={t('workflow.config.parameter-extractor.default')} label={t('workflow.config.parameter-extractor.default')}
rules={[ rules={(type === 'object' || type === 'array[object]')
(type === 'object' || type === 'array[object]') ? { ? [{
validator: (_, value) => { validator: (_, value) => {
if (!value) return Promise.resolve(); if (!value) return Promise.resolve();
try { JSON.parse(value); return Promise.resolve(); } try { JSON.parse(value); return Promise.resolve(); }
catch { return Promise.reject(t('workflow.invalidJSON')); } catch { return Promise.reject(t('workflow.invalidJSON')); }
} }
} : {} }]
]} : undefined
}
> >
{type === 'number' {type === 'number'
? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} /> ? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-24 17:57:08 * @Date: 2026-02-24 17:57:08
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 14:05:50 * @Last Modified time: 2026-04-14 16:33:33
*/ */
/* /*
* Runtime Component * Runtime Component
@@ -161,8 +161,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
children: ( children: (
<Flex gap={8} vertical> <Flex gap={8} vertical>
{/* Display error message for failed nodes */} {/* Display error message for failed nodes */}
{vo.content?.error && vo.content?.error !== '' &&
{item.error &&
<RbAlert color="orange" className="rb:pb-0!"> <RbAlert color="orange" className="rb:pb-0!">
<Flex vertical className="rb:w-full!"> <Flex vertical className="rb:w-full!">
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
@@ -269,7 +268,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
</div> </div>
) )
: <div className="rb:mb-4"> : <div className="rb:mb-4">
{item.error && {item.error && item.error !== '' &&
<RbAlert color="orange" className="rb:pb-0! rb:mb-2!"><Markdown content={item.error} /></RbAlert> <RbAlert color="orange" className="rb:pb-0! rb:mb-2!"><Markdown content={item.error} /></RbAlert>
} }
{renderChild(item.subContent)} {renderChild(item.subContent)}

View File

@@ -79,7 +79,6 @@ const specialValidators: Record<string, (val: any) => boolean> = {
} }
function isEmpty(val: any): 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 (val === undefined || val === null || val === '') return true
if (Array.isArray(val)) return val.length === 0 if (Array.isArray(val)) return val.length === 0
return false return false
@@ -98,7 +97,6 @@ function validateNode(type: string, config: Record<string, any>): CheckError[] {
const specialKey = `${type}.${field}` const specialKey = `${type}.${field}`
const specialValidator = specialValidators[specialKey] const specialValidator = specialValidators[specialKey]
const isInvalid = specialValidator ? specialValidator(val) : isEmpty(val) const isInvalid = specialValidator ? specialValidator(val) : isEmpty(val)
console.log('validateNode', val, specialKey, specialValidator, isEmpty(val))
if (isInvalid) errors.push({ key: specialKey, message: '' }) if (isInvalid) errors.push({ key: specialKey, message: '' })
}) })
@@ -114,62 +112,6 @@ function validateNode(type: string, config: Record<string, any>): CheckError[] {
return errors return errors
} }
export async function runCheckOnGraph(
graph: import('@antv/x6').Graph,
t: (key: string) => string
): Promise<NodeCheckResult[]> {
const nodes = graph.getNodes()
const edges = graph.getEdges()
const targetIds = new Set<string>()
const childTargetIds = new Set<string>()
edges.forEach(e => {
targetIds.add(e.getTargetCellId())
const srcData = graph.getCellById(e.getSourceCellId())?.getData()
const tgtData = graph.getCellById(e.getTargetCellId())?.getData()
if (srcData?.cycle && tgtData?.cycle && srcData.cycle === tgtData.cycle) {
childTargetIds.add(e.getTargetCellId())
}
})
const checked: NodeCheckResult[] = []
for (const node of nodes) {
const data = node.getData()
if (!data || ['add-node', 'notes', 'cycle-start', 'break'].includes(data.type)) continue
const errors: CheckError[] = []
const isChildNode = !!data.cycle
const hasIncoming = isChildNode ? childTargetIds.has(node.id) : !['start', 'cycle-start'].includes(data.type) ? targetIds.has(node.id) : true
if (!hasIncoming) errors.push({ key: 'notConnected', message: t('workflow.notConnected') })
const configErrors = validateNode(data.type, data.config ?? {})
configErrors.forEach(e => {
errors.push({ key: e.key, message: `${t(`workflow.checkListErrors.${e.key}`)} ${t('workflow.cannotBeEmpty')}`.trim() })
})
if (data.type === 'tool') {
const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id
const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {}
if (toolId) {
try {
const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
const operation = toolParameters?.operation
const method = operation ? methods.find(m => m.name === operation) : methods[0]
if (method) {
method.parameters
.filter(p => p.required && (toolParameters[p.name] === undefined || toolParameters[p.name] === null || toolParameters[p.name] === ''))
.forEach(p => errors.push({ key: 'tool.tool_parameters', message: `${p.name} ${t('workflow.cannotBeEmpty')}` }))
}
} catch { /* ignore */ }
}
}
if (errors.length) {
checked.push({ id: node.id, name: data.name || t(`workflow.${data.type}`), type: data.type, icon: nodeIconMap[data.type] ?? '', errors })
}
}
return checked
}
const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => { const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -222,7 +164,8 @@ const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
if (data.type === 'tool') { if (data.type === 'tool') {
const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id
const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {} const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {}
if (toolId) {
if (typeof toolId === 'string') {
try { try {
const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
const operation = toolParameters?.operation const operation = toolParameters?.operation
@@ -251,21 +194,27 @@ const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
return checked return checked
}, [workflowRef.current?.graphRef?.current, t]) }, [workflowRef.current?.graphRef?.current, t])
const scheduleCheckRef = useRef<() => void>()
const scheduleCheck = useCallback(() => { const scheduleCheck = useCallback(() => {
clearTimeout(timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(async () => { timerRef.current = setTimeout(async () => {
setCheckResults(appId, await runCheck()) setCheckResults(appId, await runCheck())
}, 500) }, 300)
}, [runCheck]) }, [runCheck])
scheduleCheckRef.current = scheduleCheck
useEffect(() => { useEffect(() => {
const graph = workflowRef.current?.graphRef?.current const graph = workflowRef.current?.graphRef?.current
console.log('graph')
if (!graph) return if (!graph) return
const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed'] const handler = () => scheduleCheckRef.current?.()
events.forEach(e => graph.on(e, scheduleCheck)) const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed', 'edge:connected', 'edge:changed']
scheduleCheck() events.forEach(e => graph.on(e, handler))
scheduleCheckRef.current?.()
return () => { return () => {
events.forEach(e => graph.off(e, scheduleCheck)) events.forEach(e => graph.off(e, handler))
clearTimeout(timerRef.current) clearTimeout(timerRef.current)
} }
}, [workflowRef.current?.graphRef?.current]) }, [workflowRef.current?.graphRef?.current])

View File

@@ -48,17 +48,13 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
return ( return (
<span <span
onClick={handleClick} onClick={handleClick}
className={clsx('rb:border rb:rounded-md rb:bg-white rb:text-[10px] rb:inline-flex rb:items-center rb:py-0 rb:px-1.5 rb:mx-0.5 rb:cursor-pointer', { className="rb-border rb:rounded-md rb:bg-white rb:text-[10px] rb:text-[#212332] rb:h-5! rb:inline-flex rb:items-center rb:p-1 rb:mx-px rb:cursor-pointer"
'rb:border-[#171719]': isSelected,
'rb:border-[#DFE4ED]': !isSelected
})}
contentEditable={false} contentEditable={false}
> >
{data.isContext ? ( {!data.isContext && data.group !== 'CONVERSATION' && !data.value.includes('conv')
<span style={{ fontSize: '12px', marginRight: '4px' }}>📄</span> ? <div className={`rb:size-3 rb:mr-1 rb:bg-cover ${data.nodeData?.icon}`} />
) : data.group !== 'CONVERSATION' && !data.value.includes('conv') ? ( : null
<span className={`rb:size-4 rb:mr-1 rb:bg-cover rb:inline-block rb:flex-shrink-0 ${data.nodeData?.icon}`} /> }
) : <span className="rb:inline-block rb:h-4"></span>}
{!data.isContext && data.group !== 'CONVERSATION' && ( {!data.isContext && data.group !== 'CONVERSATION' && (
<> <>
{!data.value.includes('conv') && <> {!data.value.includes('conv') && <>
@@ -73,7 +69,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
)} )}
</> </>
)} )}
<span className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1 rb:text-[#171719]">{data.label}</span> <span className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1">{data.label}</span>
</span> </span>
); );
}; };

View File

@@ -2,12 +2,13 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51 * @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing * @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 { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 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 { $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 { Space, Flex } from 'antd';
import clsx from 'clsx';
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
import type { NodeProperties } from '../../../types' import type { NodeProperties } from '../../../types'
@@ -284,23 +285,24 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
ref={popupRef} ref={popupRef}
data-autocomplete-popup="true" data-autocomplete-popup="true"
onMouseDown={(e) => e.preventDefault()} 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={{ style={{
top: popupPosition.top, top: popupPosition.top,
left: popupPosition.left, left: popupPosition.left,
}} }}
> >
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto"> <div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
<Flex vertical gap={12}> <Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
const nodeIcon = nodeOptions[0]?.nodeData?.icon;
return ( return (
<div key={nodeId}> <div key={nodeId} className="rb:text-[12px]">
{nodeName !== 'undefined' && <Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]"> {nodeName !== 'undefined' &&
{nodeIcon && <div className={`rb:size-3 rb:bg-cover ${nodeIcon}`} />} <div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
{nodeName} {nodeName}
</Flex>} </div>
}
<Flex vertical gap={2}>
{nodeOptions.map((option) => { {nodeOptions.map((option) => {
const globalIndex = flatOptions.indexOf(option); const globalIndex = flatOptions.indexOf(option);
const isExpanded = expandedParent?.key === option.key; const isExpanded = expandedParent?.key === option.key;
@@ -310,14 +312,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
key={option.key} key={option.key}
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }} ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
data-selected={selectedIndex === globalIndex} 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" align="center"
justify="space-between" justify="space-between"
style={{
cursor: option.disabled ? 'not-allowed' : 'pointer',
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
opacity: option.disabled ? 0.5 : 1,
}}
onClick={() => { onClick={() => {
if (option.disabled) return; if (option.disabled) return;
insertMention(option); insertMention(option);
@@ -337,17 +338,19 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
} }
}} }}
> >
{option.label && <Space size={4}> {option.label &&
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : `{x}`}</span> <div className="rb:font-medium">
<span>{option.label}</span> <span className="rb:text-[#155EEF]">{`{x}`}</span> {option.label}
</Space>} </div>
<Space size={4}> }
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>} <Space size={2}>
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>} {option.dataType && <span>{option.dataType}</span>}
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
</Space> </Space>
</Flex> </Flex>
); );
})} })}
</Flex>
</div> </div>
); );
})} })}
@@ -356,7 +359,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
{/* Child variables panel - floats to the left */} {/* Child variables panel - floats to the left */}
{expandedParent?.children?.length && ( {expandedParent?.children?.length && (
<div <div
className="rb:absolute rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" className="rb:absolute rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] 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={{ style={{
top: childPanelTop, top: childPanelTop,
right: 'calc(100% + 8px)', right: 'calc(100% + 8px)',
@@ -364,9 +367,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
}} }}
onMouseEnter={() => setExpandedParent(expandedParent)} onMouseEnter={() => setExpandedParent(expandedParent)}
> >
{/* Header */} <div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
<div className="rb:px-3 rb:py-2 rb:text-[12px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0]"> <Flex justify="space-between" align="center" gap={8}>
<Flex justify="space-between" align="center">
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span> <span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
<span>{expandedParent.dataType}</span> <span>{expandedParent.dataType}</span>
</Flex> </Flex>
@@ -377,19 +379,20 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
<Flex <Flex
key={child.key} key={child.key}
data-selected={selectedIndex === childIndex} data-selected={selectedIndex === childIndex}
className="rb:px-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 === childIndex,
'rb:cursor-not-allowed rb:opacity-65': child.disabled,
'rb:cursor-pointer': !child.disabled,
})}
align="center" align="center"
justify="space-between" justify="space-between"
style={{
cursor: child.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === childIndex ? '#f0f8ff' : 'white',
opacity: child.disabled ? 0.5 : 1,
}}
onClick={() => !child.disabled && insertMention(child)} onClick={() => !child.disabled && insertMention(child)}
onMouseEnter={() => setSelectedIndex(childIndex)} onMouseEnter={() => setSelectedIndex(childIndex)}
> >
<span>{child.label}</span> <span className="rb:font-medium">
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>} <span className="rb:text-[#155EEF]">{`{x}`}</span> {child.label}
</span>
{child.dataType && <span>{child.dataType}</span>}
</Flex> </Flex>
); );
})} })}

View File

@@ -31,6 +31,8 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
}; };
const labelRender = (value: string) => { const labelRender = (value: string) => {
const filterOption = variableList.find(vo => `{{${vo.value}}}` === value) 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) { if (filterOption) {
return ( return (

View File

@@ -30,6 +30,25 @@ const operationsObj = {
], ],
} }
const filterByDataType = (options: Suggestion[], dataType: string): Suggestion[] =>
options.reduce<Suggestion[]>((acc, vo) => {
if (vo.children?.length) {
const children = vo.children.reduce<Suggestion[]>((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<AssignmentListProps> = ({ const AssignmentList: FC<AssignmentListProps> = ({
parentName, parentName,
options = [], options = [],
@@ -59,7 +78,9 @@ const AssignmentList: FC<AssignmentListProps> = ({
<Flex gap={10} vertical> <Flex gap={10} vertical>
{fields.map(({ key, name, ...restField }) => { {fields.map(({ key, name, ...restField }) => {
const variableSelector = form.getFieldValue([parentName, name, 'variable_selector']); 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 dataType = selectedOption?.dataType;
const operationOptions = dataType === 'number' ? operationsObj.number : operationsObj.default; const operationOptions = dataType === 'number' ? operationsObj.number : operationsObj.default;
@@ -119,7 +140,7 @@ const AssignmentList: FC<AssignmentListProps> = ({
{dataType === 'number' && operation === 'cover' {dataType === 'number' && operation === 'cover'
? <VariableSelect ? <VariableSelect
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options} options={dataType ? filterByDataType(options, dataType) : options}
size={size} size={size}
className="rb:flex-1!" className="rb:flex-1!"
variant="filled" variant="filled"
@@ -150,7 +171,7 @@ const AssignmentList: FC<AssignmentListProps> = ({
</> </>
: <VariableSelect : <VariableSelect
placeholder={t('common.pleaseSelect')} placeholder={t('common.pleaseSelect')}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options} options={dataType ? filterByDataType(options, dataType) : options}
size={size} size={size}
className="rb:flex-1!" className="rb:flex-1!"
variant="filled" variant="filled"

View File

@@ -329,7 +329,9 @@ const CaseList: FC<CaseListProps> = ({
const currentExpression = currentCase.expressions?.[conditionIndex] || {}; const currentExpression = currentCase.expressions?.[conditionIndex] || {};
const currentOperator = currentExpression.operator; const currentOperator = currentExpression.operator;
const leftFieldValue = currentExpression.left; 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 leftFieldType = leftFieldOption?.dataType;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]' || leftFieldType === 'array[file]'; const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]' || leftFieldType === 'array[file]';
const operatorList = leftFieldType && operatorsObj[leftFieldType] const operatorList = leftFieldType && operatorsObj[leftFieldType]

View File

@@ -155,7 +155,9 @@ const ConditionList: FC<CaseListProps> = ({
const currentExpression = expressions[index] || {}; const currentExpression = expressions[index] || {};
const currentOperator = currentExpression.operator; const currentOperator = currentExpression.operator;
const leftFieldValue = currentExpression.left; 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 leftFieldType = leftFieldOption?.dataType;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || ['array[object]', 'object'].includes(leftFieldType as string); const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || ['array[object]', 'object'].includes(leftFieldType as string);
const operatorList = leftFieldType && ['array[object]', 'object'].includes(leftFieldType) const operatorList = leftFieldType && ['array[object]', 'object'].includes(leftFieldType)

View File

@@ -62,14 +62,18 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
*/ */
useEffect(() => { useEffect(() => {
if (!isCanAdd && value[0]) { 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) { if (firstVariable) {
form.setFieldValue(['group_type', 'output'], firstVariable.dataType); form.setFieldValue(['group_type', 'output'], firstVariable.dataType);
} }
} else if (isCanAdd) { } else if (isCanAdd) {
value.forEach((item: any, index: number) => { value.forEach((item: any, index: number) => {
if (item?.value?.[0]) { 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) { if (firstVariable) {
form.setFieldValue(['group_type', index], firstVariable.dataType); form.setFieldValue(['group_type', index], firstVariable.dataType);
} }

View File

@@ -85,9 +85,9 @@ const EditableTable: FC<EditableTableProps> = ({
return [ return [
{ {
title: t('workflow.config.name'), title: t('workflow.config.name'),
dataIndex: 'name', dataIndex: 'key',
render: (_: any, __: TableRow, index: number) => ( render: (_: any, __: TableRow, index: number) => (
<Form.Item name={[index, 'name']} className={formClassName}> <Form.Item name={[index, 'key']} className={formClassName}>
<Editor <Editor
options={namefilterOptions} options={namefilterOptions}
type="input" type="input"

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-09 18:35:43 * @Date: 2026-02-09 18:35:43
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:17:06 * @Last Modified time: 2026-04-14 17:36:53
*/ */
import { type FC, useMemo, useRef, useState } from "react"; import { type FC, useMemo, useRef, useState } from "react";
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -35,9 +35,8 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
form.setFieldsValue({ auth }) form.setFieldsValue({ auth })
} }
const handleChangeBodyContentType = (e: any) => { const handleChangeBodyContentType = () => {
const value = e.target.value || e.target.value form.setFieldValue(['body', 'data'], undefined)
form.setFieldValue(['body', 'data'], ['form-data', 'x-www-form-urlencoded'].includes(value) ? [{}] : undefined)
} }
// Handle error handling method change and update node ports accordingly // Handle error handling method change and update node ports accordingly

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:40:13 * @Date: 2026-02-03 15:40:13
* @Last Modified by: ZhaoYing * @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 { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@@ -190,20 +190,30 @@ const VariableSelect: FC<VariableSelectProps> = ({
{/* Trigger */} {/* Trigger */}
<div <div
className={clsx( className={clsx(
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-md rb:px-2 rb:transition-colors', 'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-lg rb:px-2 rb:transition-colors', {
variant === 'filled' && 'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none', 'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none': variant === 'filled',
variant === 'outlined' && 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white', 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white': variant === 'outlined',
variant === 'outlined' && open && 'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]', 'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]': variant === 'outlined' && open,
variant === 'borderless' && 'rb:border-none rb:shadow-none rb:bg-transparent', 'rb:border-none rb:shadow-none rb:bg-transparent': variant === 'borderless',
multiple && size === 'small' ? 'rb:min-h-7 rb:py-0.75' : multiple ? 'rb:min-h-8 rb:py-1' : size === 'small' ? 'rb:h-7 rb:text-[10px]' : size === 'large' ? 'rb:h-10' : 'rb:h-8 rb:text-[12px]', 'rb:text-[12px]': size === 'small',
!multiple && (size === 'small' ? 'rb:text-[12px]' : 'rb:text-[12px]'), 'rb:text-[14px]': size !== 'small',
},
multiple && size === 'small'
? 'rb:min-h-7 rb:py-0.75'
: multiple
? 'rb:min-h-8 rb:py-1'
: size === 'small'
? 'rb:h-7 rb:text-[10px]'
: size === 'large'
? 'rb:h-10'
: 'rb:h-8 rb:text-[12px]',
className className
)} )}
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
> >
{multiple ? ( {multiple ? (
selectedValues.length > 0 ? ( selectedValues.length > 0 ? (
<span className="rb:flex rb:flex-wrap rb:gap-1 rb:flex-1 rb:min-w-0"> <Flex wrap gap={4} className="rb:flex-1! rb:min-w-0">
{selectedValues.map(v => { {selectedValues.map(v => {
const s = suggestionMap.get(v); const s = suggestionMap.get(v);
if (!s) return null; if (!s) return null;
@@ -214,11 +224,11 @@ const VariableSelect: FC<VariableSelectProps> = ({
return ( return (
<span <span
key={v} key={v}
className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full" className="rb-border rb:rounded-md rb:bg-white rb:text-[10px] rb:text-[#212332] rb:h-5! rb:inline-flex rb:items-center rb:p-1 rb:cursor-pointer"
> >
{!isConv && nd?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nd.icon}`} />} {!isConv && nd?.icon && <div className={`rb:size-3 rb:bg-cover ${nd.icon}`} />}
{!isConv && nd?.name && <span className="rb:text-[#5B6167]">{nd.name}{sep}</span>} {!isConv && nd?.name && <span className="rb:text-[#5B6167]">{nd.name}{sep}</span>}
<span className="rb:text-[#171719]"> <span>
{parent ? <>{parent.label}{sep}{s.label}</> : s.label} {parent ? <>{parent.label}{sep}{s.label}</> : s.label}
</span> </span>
<span <span
@@ -228,17 +238,19 @@ const VariableSelect: FC<VariableSelectProps> = ({
</span> </span>
); );
})} })}
</span> </Flex>
) : ( ) : (
<span className="rb:text-[#bfbfbf] rb:flex-1 rb:text-[12px]">{placeholder}</span> <span className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1">{placeholder}</span>
) )
) : selectedSuggestion ? ( ) : selectedSuggestion ? (
<div className="rb:flex rb:flex-1 rb:min-w-0 rb:max-w-full"> <div className="rb:flex rb:flex-1 rb:min-w-0 rb:max-w-full">
<span className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full rb:overflow-hidden"> <span
{!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nodeData.icon}`} />} className="rb-border rb:rounded-md rb:bg-white rb:text-[10px] rb:text-[#212332] rb:h-5! rb:inline-flex rb:items-center rb:p-1 rb:cursor-pointer"
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167] rb:shrink rb:min-w-0 rb:truncate rb:max-w-[40%]">{nodeData.name}</span>} >
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167]">{sep}</span>} {!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:bg-cover rb:mr-1 ${nodeData.icon}`} />}
<span className="rb:text-[#171719] rb:shrink rb:min-w-0 rb:truncate"> {!isConversation && nodeData?.name && <span className="rb:shrink rb:min-w-0 rb:truncate rb:max-w-[40%]">{nodeData.name}</span>}
{!isConversation && nodeData?.name && <span>{sep}</span>}
<span className="rb:shrink rb:min-w-0 rb:truncate">
{parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label}</> : selectedSuggestion.label} {parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label}</> : selectedSuggestion.label}
</span> </span>
</span> </span>
@@ -266,18 +278,19 @@ const VariableSelect: FC<VariableSelectProps> = ({
{open && createPortal( {open && createPortal(
<div <div
ref={dropdownRef} ref={dropdownRef}
className="rb:fixed rb:z-9999 rb:bg-white rb:text-[14px] rb:rounded-lg rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)] rb:p-1" className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto 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: dropdownPos.top, left: dropdownPos.left, minWidth: dropdownPos.width }} style={{ top: dropdownPos.top, left: dropdownPos.left, minWidth: dropdownPos.width }}
> >
<div className="rb:min-w-70 rb:max-h-60 rb:overflow-y-auto rb:py-1"> <div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
{Object.entries(filteredGroups).map(([nodeId, suggestions]) => { {Object.entries(filteredGroups).map(([nodeId, suggestions], index) => {
const nd = suggestions[0].nodeData; const nd = suggestions[0].nodeData;
return ( return (
<div key={nodeId}> <div key={nodeId} className={clsx("rb:text-[12px]", {
<Flex align="center" gap={4} className="rb:px-3! rb:py-1.25! rb:text-[12px] rb:text-[#5B6167]"> 'rb:mt-3': index !== 0
{nd.icon && <div className={`rb:size-4 rb:bg-cover ${nd.icon}`} />} })}>
<div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
{nd.name} {nd.name}
</Flex> </div>
{suggestions.map(s => { {suggestions.map(s => {
const isSelected = multiple const isSelected = multiple
? selectedValues.includes(`{{${s.value}}}`) ? selectedValues.includes(`{{${s.value}}}`)
@@ -288,11 +301,9 @@ const VariableSelect: FC<VariableSelectProps> = ({
<Flex <Flex
key={s.key} key={s.key}
ref={(el) => { if (el) itemRefs.current.set(s.key, el); }} ref={(el) => { if (el) itemRefs.current.set(s.key, el); }}
className={clsx("rb:pl-6! rb:pr-3! rb:py-1.25! rb:rounded-lg!", { 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-[#e6f4ff]': isSelected || isExpanded, 'rb:bg-[#F6F6F6]': isSelected || isExpanded,
'rb:bg-white rb:hover:bg-[#F6F6F6]!': !(isSelected || isExpanded), 'rb:cursor-not-allowed rb:opacity-65': s.disabled,
'rb:opacity-60': s.disabled,
'rb:cursor-not-allowed': s.disabled,
'rb:cursor-pointer': !s.disabled, 'rb:cursor-pointer': !s.disabled,
})} })}
align="center" align="center"
@@ -314,16 +325,15 @@ const VariableSelect: FC<VariableSelectProps> = ({
} }
}} }}
> >
<Space size={4}> <div className="rb:font-medium">
{multiple && ( {multiple && (
<Checkbox checked={isSelected} /> <Checkbox checked={isSelected} className="rb:mr-2!" />
)} )}
<span className="rb:text-[#155EEF]">{`{x}`}</span> <span className="rb:text-[#155EEF]">{`{x}`}</span> {s.label}
<span>{s.label}</span> </div>
</Space>
<Space size={4} className="rb:text-[#5B6167] rb:text-[12px]">
{s.dataType && <span>{s.dataType}</span>}
<Space size={2}>
{s.dataType && <span>{s.dataType}</span>}
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>} {hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
</Space> </Space>
</Flex> </Flex>
@@ -334,7 +344,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
})} })}
{Object.keys(filteredGroups).length === 0 && ( {Object.keys(filteredGroups).length === 0 && (
<div className="rb:px-3 rb:py-4 rb:text-center rb:text-[#bfbfbf] rb:text-[14px]"> <div className="rb:px-3 rb:py-4 rb:text-center rb:text-[#bfbfbf] rb:text-[14px]">
{t('workflow.variableSelect.empty', '暂无变量')} {t('workflow.variableSelect.empty')}
</div> </div>
)} )}
</div> </div>
@@ -346,18 +356,13 @@ const VariableSelect: FC<VariableSelectProps> = ({
{open && expandedParent?.children?.length && createPortal( {open && expandedParent?.children?.length && createPortal(
<div <div
id="variable-select-child-panel" id="variable-select-child-panel"
className="rb:fixed rb:z-9999 rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:text-[14px] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] 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: childPanelPos.top, right: childPanelPos.right }} style={{ top: childPanelPos.top, right: childPanelPos.right }}
onMouseEnter={() => setExpandedParent(expandedParent)} onMouseEnter={() => setExpandedParent(expandedParent)}
> >
<div <div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
className="rb:px-3 rb:py-2 rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0] rb:cursor-pointer rb:hover:bg-[#f0f8ff]"
onClick={() => !expandedParent.disabled && handleSelect(expandedParent)}
>
<Flex justify="space-between" align="center" gap={8}> <Flex justify="space-between" align="center" gap={8}>
<Flex align="center" gap={6}>
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span> <span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
</Flex>
<span>{expandedParent.dataType}</span> <span>{expandedParent.dataType}</span>
</Flex> </Flex>
</div> </div>
@@ -365,32 +370,27 @@ const VariableSelect: FC<VariableSelectProps> = ({
const isSelected = multiple const isSelected = multiple
? selectedValues.includes(`{{${child.value}}}`) ? selectedValues.includes(`{{${child.value}}}`)
: `{{${child.value}}}` === value; : `{{${child.value}}}` === value;
const hasGrandChildren = !!child.children?.length;
return ( return (
<Flex <Flex
key={child.key} key={child.key}
className={clsx("rb:px-3! rb:py-2! rb:hover:bg-[#f0f8ff]!", { 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-[#f0f8ff]': isSelected, 'rb:bg-[#F6F6F6]': isSelected,
'rb:white': !isSelected 'rb:cursor-not-allowed rb:opacity-65': child.disabled,
'rb:cursor-pointer': !child.disabled,
})} })}
align="center" align="center"
justify="space-between" justify="space-between"
style={{
cursor: child.disabled ? 'not-allowed' : 'pointer',
opacity: child.disabled ? 0.5 : 1,
}}
onClick={() => !child.disabled && handleSelect(child)} onClick={() => !child.disabled && handleSelect(child)}
> >
<Flex align="center" gap={6}> <Flex align="center" gap={8}>
{multiple && ( {multiple && (
<Checkbox checked={isSelected} /> <Checkbox checked={isSelected} />
)} )}
<span>{child.label}</span> <span className="rb:font-medium">{child.label}</span>
</Flex>
<Flex align="center" gap={4}>
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
{hasGrandChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>}
</Flex> </Flex>
<Space size={2}>
{child.dataType && <span>{child.dataType}</span>}
</Space>
</Flex> </Flex>
); );
})} })}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-01-19 17:00:26 * @Date: 2026-01-19 17:00:26
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-08 10:12:27 * @Last Modified time: 2026-04-13 10:44:17
*/ */
/** /**
* useVariableList Hook * useVariableList Hook
@@ -414,7 +414,7 @@ export const useVariableList = (
const pd = parentLoop.getData(); const pd = parentLoop.getData();
const pid = pd.id; const pid = pd.id;
if (pd.type === 'loop') { 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) { } else if (pd.type === 'iteration' && pd.config.input.defaultValue) {
let itemType = 'object'; let itemType = 'object';
const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue); const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue);

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:39:59 * @Date: 2026-02-03 15:39:59
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-10 17:24:19 * @Last Modified time: 2026-04-13 10:44:19
*/ */
import { type FC, useEffect, useState, useMemo } from "react"; import { type FC, useEffect, useState, useMemo } from "react";
import clsx from 'clsx' import clsx from 'clsx'
@@ -266,7 +266,7 @@ const Properties: FC<PropertiesProps> = ({
key, key,
label: cycleVar.name, label: cycleVar.name,
type: 'variable', type: 'variable',
dataType: cycleVar.type || 'String', dataType: cycleVar.type || 'string',
value: `${parentNodeId}.${cycleVar.name}`, value: `${parentNodeId}.${cycleVar.name}`,
nodeData: parentData, nodeData: parentData,
}); });
@@ -643,7 +643,7 @@ const Properties: FC<PropertiesProps> = ({
key: contextKey, key: contextKey,
label: 'context', label: 'context',
type: 'variable', type: 'variable',
dataType: 'String', dataType: 'string',
value: `context`, value: `context`,
nodeData: selectedNode.getData(), nodeData: selectedNode.getData(),
isContext: true, isContext: true,
@@ -791,7 +791,7 @@ const Properties: FC<PropertiesProps> = ({
key: `${selectedNode.id}_cycle_${cycleVar.name}`, key: `${selectedNode.id}_cycle_${cycleVar.name}`,
label: cycleVar.name, label: cycleVar.name,
type: 'variable', type: 'variable',
dataType: cycleVar.type || 'String', dataType: cycleVar.type || 'string',
value: `${selectedNode.getData().id}.${cycleVar.name}`, value: `${selectedNode.getData().id}.${cycleVar.name}`,
nodeData: selectedNode.getData(), nodeData: selectedNode.getData(),
})); }));

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48 * @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing * @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 { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6';
import { register } from '@antv/x6-react-shape'; import { register } from '@antv/x6-react-shape';
@@ -111,6 +111,7 @@ export const useWorkflowGraph = ({
graphRef.current.getNodes().forEach(node => { graphRef.current.getNodes().forEach(node => {
const data = node.getData() const data = node.getData()
if (data?.type === 'if-else' || data?.type === 'question-classifier') { if (data?.type === 'if-else' || data?.type === 'question-classifier') {
console.log('chatVariables', chatVariables)
node.setData({ ...data, chatVariables }, { silent: true }) node.setData({ ...data, chatVariables }, { silent: true })
} }
}) })
@@ -203,7 +204,7 @@ export const useWorkflowGraph = ({
? Object.entries(group_variables as Record<string, any>).map(([key, value]) => ({ key, value })) ? Object.entries(group_variables as Record<string, any>).map(([key, value]) => ({ key, value }))
: group_variables : 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]) { } 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]) { } else if (type === 'code' && key === 'code' && config[key] && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
try { try {
nodeLibraryConfig.config[key].defaultValue = decodeURIComponent(atob(config[key] as string)) nodeLibraryConfig.config[key].defaultValue = decodeURIComponent(atob(config[key] as string))
@@ -1022,25 +1023,40 @@ export const useWorkflowGraph = ({
graphRef.current.on('node:removed', blankClick) graphRef.current.on('node:removed', blankClick)
// When edge connected, bring connected nodes' ports to front // When edge connected, bring connected nodes' ports to front
graphRef.current.on('edge:connected', ({ isNew }) => { graphRef.current.on('edge:connected', ({ isNew, edge }) => {
// 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
if (isNew) { if (isNew) {
graphRef.current?.getNodes().forEach(node => { const sourceCellId = edge.getSourceCellId()
if (!node.getData()?.cycle) node.toFront(); 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 => { graphRef.current?.getEdges().forEach(edge => {
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId()); const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId());
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) { if (edgeSourceCell?.getData()?.cycle === sourceCellId || edgeTargetCell?.getData()?.cycle === sourceCellId) {
edge.toFront(); edge.toFront();
} }
}); });
graphRef.current?.getNodes().forEach(node => { graphRef.current?.getNodes().forEach(node => {
if (node.getData()?.cycle) node.toFront(); 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();
});
}
}
}); });
// During edge dragging, manually detect port hover since the dragging edge blocks mouse events // During edge dragging, manually detect port hover since the dragging edge blocks mouse events
@@ -1184,9 +1200,6 @@ export const useWorkflowGraph = ({
}) || []; }) || [];
const edges = graphRef.current?.getEdges() || [] const edges = graphRef.current?.getEdges() || []
console.log('config', config)
const params = { const params = {
...config, ...config,
features: featuresRef.current, features: featuresRef.current,
@@ -1243,9 +1256,17 @@ export const useWorkflowGraph = ({
itemConfig[key] = {} itemConfig[key] = {}
if (value.length > 0) { if (value.length > 0) {
value.forEach((vo: any) => { 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') { } else if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') {
itemConfig[key] = data.config[key].defaultValue itemConfig[key] = data.config[key].defaultValue
} else if (key === 'knowledge_retrieval' && data.config[key] && 'defaultValue' in data.config[key]) { } else if (key === 'knowledge_retrieval' && data.config[key] && 'defaultValue' in data.config[key]) {