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:
157
.github/workflows/release-notify-wechat.yml
vendored
Normal file
157
.github/workflows/release-notify-wechat.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -82,51 +82,38 @@ 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()
|
||||||
incoming_name = (getattr(ent, "name", "") or "").strip()
|
if canonical_name.lower() not in _USER_PLACEHOLDER_NAMES:
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
# 1. 添加canonical现有的别名
|
try:
|
||||||
existing = getattr(canonical, "aliases", []) or []
|
from app.core.memory.utils.alias_utils import normalize_aliases
|
||||||
all_aliases.extend(existing)
|
canonical.aliases = normalize_aliases(canonical_name, all_aliases)
|
||||||
|
except Exception:
|
||||||
# 2. 添加incoming实体的名称(如果不同于canonical的名称)
|
seen_normalized = set()
|
||||||
if incoming_name and incoming_name != canonical_name:
|
unique_aliases = []
|
||||||
all_aliases.append(incoming_name)
|
for alias in all_aliases:
|
||||||
|
if not alias:
|
||||||
# 3. 添加incoming实体的所有别名
|
continue
|
||||||
incoming = getattr(ent, "aliases", []) or []
|
alias_stripped = str(alias).strip()
|
||||||
all_aliases.extend(incoming)
|
if not alias_stripped or alias_stripped == canonical_name:
|
||||||
|
continue
|
||||||
# 4. 标准化并去重(优先使用alias_utils工具函数)
|
alias_normalized = alias_stripped.lower()
|
||||||
try:
|
if alias_normalized not in seen_normalized:
|
||||||
from app.core.memory.utils.alias_utils import normalize_aliases
|
seen_normalized.add(alias_normalized)
|
||||||
canonical.aliases = normalize_aliases(canonical_name, all_aliases)
|
unique_aliases.append(alias_stripped)
|
||||||
except Exception:
|
canonical.aliases = sorted(unique_aliases)
|
||||||
# 如果导入失败,使用增强的去重逻辑
|
|
||||||
seen_normalized = set()
|
|
||||||
unique_aliases = []
|
|
||||||
|
|
||||||
for alias in all_aliases:
|
|
||||||
if not alias:
|
|
||||||
continue
|
|
||||||
|
|
||||||
alias_stripped = str(alias).strip()
|
|
||||||
if not alias_stripped or alias_stripped == canonical_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 标准化:转小写用于去重判断
|
|
||||||
alias_normalized = alias_stripped.lower()
|
|
||||||
|
|
||||||
if alias_normalized not in seen_normalized:
|
|
||||||
seen_normalized.add(alias_normalized)
|
|
||||||
unique_aliases.append(alias_stripped)
|
|
||||||
|
|
||||||
# 排序并赋值
|
|
||||||
canonical.aliases = sorted(unique_aliases)
|
|
||||||
except Exception:
|
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)
|
||||||
|
|
||||||
# ========== 主循环:遍历所有实体对进行模糊匹配 ==========
|
# ========== 主循环:遍历所有实体对进行模糊匹配 ==========
|
||||||
|
|||||||
@@ -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 读取已有的 aliases(db_aliases)
|
||||||
3. 从 PgSQL end_user_info 读取已有的 aliases(db_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(
|
||||||
|
|||||||
@@ -112,22 +112,23 @@ 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 is_streaming and not config.is_omni:
|
if config.support_thinking:
|
||||||
if provider == ModelProvider.VOLCANO:
|
if is_streaming and not config.is_omni:
|
||||||
# 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数
|
if provider == ModelProvider.VOLCANO:
|
||||||
thinking_config: Dict[str, Any] = {
|
# 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数
|
||||||
"type": "enabled" if config.deep_thinking else "disabled"
|
thinking_config: Dict[str, Any] = {
|
||||||
}
|
"type": "enabled" if config.deep_thinking else "disabled"
|
||||||
if config.deep_thinking and config.thinking_budget_tokens:
|
}
|
||||||
thinking_config["budget_tokens"] = config.thinking_budget_tokens
|
if config.deep_thinking and config.thinking_budget_tokens:
|
||||||
params["extra_body"] = {"thinking": thinking_config}
|
thinking_config["budget_tokens"] = config.thinking_budget_tokens
|
||||||
else:
|
params["extra_body"] = {"thinking": thinking_config}
|
||||||
# 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略
|
else:
|
||||||
model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
|
# 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略
|
||||||
model_kwargs["enable_thinking"] = config.deep_thinking
|
model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
|
||||||
if config.deep_thinking and config.thinking_budget_tokens:
|
model_kwargs["enable_thinking"] = config.deep_thinking
|
||||||
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
|
if config.deep_thinking and config.thinking_budget_tokens:
|
||||||
params["model_kwargs"] = model_kwargs
|
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
|
||||||
|
params["model_kwargs"] = model_kwargs
|
||||||
return params
|
return params
|
||||||
elif provider == ModelProvider.DASHSCOPE:
|
elif provider == ModelProvider.DASHSCOPE:
|
||||||
params = {
|
params = {
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -227,10 +227,20 @@ class PromptOptimizerService:
|
|||||||
content = getattr(chunk, "content", chunk)
|
content = getattr(chunk, "content", chunk)
|
||||||
if not content:
|
if not content:
|
||||||
continue
|
continue
|
||||||
buffer += content
|
if isinstance(content, str):
|
||||||
|
buffer += content
|
||||||
|
elif isinstance(content, list):
|
||||||
|
for _ in content:
|
||||||
|
buffer += _["text"]
|
||||||
|
else:
|
||||||
|
logger.error(f"Unsupported content type - {content}")
|
||||||
|
raise Exception("Unsupported content type")
|
||||||
cache = buffer[:-20]
|
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:
|
||||||
|
|||||||
@@ -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 # 遗忘记忆(激活值低于阈值)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,26 +135,28 @@ 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) {
|
if ((menu as any).onClick) {
|
||||||
return item;
|
item.onClick = (e: React.MouseEvent) => {
|
||||||
}
|
e.preventDefault();
|
||||||
|
(menu as any).onClick(e);
|
||||||
// If has custom onClick, use onClick and set href to '#' to show pointer cursor
|
};
|
||||||
if ((menu as any).onClick) {
|
item.href = '#';
|
||||||
item.onClick = (e: React.MouseEvent) => {
|
} else if (menu.path && menu.path !== '#') {
|
||||||
e.preventDefault();
|
item.path = menu.path;
|
||||||
(menu as any).onClick(e);
|
}
|
||||||
};
|
|
||||||
item.href = '#';
|
|
||||||
} else if (menu.path && menu.path !== '#') {
|
|
||||||
// Only set path when path is not '#'
|
|
||||||
item.path = menu.path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return item;
|
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')]", {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '情感引擎配置',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}`)),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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%' }} />
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -219,11 +218,11 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copy value to clipboard and show success message */
|
/** Copy value to clipboard and show success message */
|
||||||
const handleCopy = (value: string) => {
|
const handleCopy = (value: string) => {
|
||||||
copy(value)
|
copy(value)
|
||||||
message.success(t('common.copySuccess'))
|
message.success(t('common.copySuccess'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,24 +1023,39 @@ 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);
|
||||||
graphRef.current?.getEdges().forEach(edge => {
|
const targetCell = graphRef.current?.getCellById(targetCellId);
|
||||||
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
|
||||||
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
sourceCell?.toFront();
|
||||||
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
|
targetCell?.toFront()
|
||||||
edge.toFront();
|
if (['loop', 'iteration'].includes(sourceCell?.getData()?.type)) {
|
||||||
}
|
graphRef.current?.getEdges().forEach(edge => {
|
||||||
});
|
const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||||
graphRef.current?.getNodes().forEach(node => {
|
const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||||
if (node.getData()?.cycle) node.toFront();
|
if (edgeSourceCell?.getData()?.cycle === sourceCellId || edgeTargetCell?.getData()?.cycle === sourceCellId) {
|
||||||
});
|
edge.toFront();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
graphRef.current?.getNodes().forEach(node => {
|
||||||
|
if (node.getData()?.cycle === sourceCellId) node.toFront();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (['loop', 'iteration'].includes(targetCell?.getData()?.type)) {
|
||||||
|
graphRef.current?.getEdges().forEach(edge => {
|
||||||
|
const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
|
||||||
|
const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId());
|
||||||
|
if (edgeSourceCell?.getData()?.cycle === targetCellId || edgeTargetCell?.getData()?.cycle === targetCellId) {
|
||||||
|
edge.toFront();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
graphRef.current?.getNodes().forEach(node => {
|
||||||
|
if (node.getData()?.cycle === targetCellId) node.toFront();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1184,9 +1200,6 @@ export const useWorkflowGraph = ({
|
|||||||
}) || [];
|
}) || [];
|
||||||
const edges = graphRef.current?.getEdges() || []
|
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]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user