feat: 工作流增加条件判断节点,重构部分UI
This commit is contained in:
@@ -75,6 +75,8 @@ public class TinyFlowConfigService {
|
||||
SearchDatacenterNodeParser searchDatacenterNodeParser = new SearchDatacenterNodeParser();
|
||||
// 工作流节点
|
||||
WorkflowNodeParser workflowNodeParser = new WorkflowNodeParser();
|
||||
// 条件判断节点
|
||||
ConditionNodeParser conditionNodeParser = new ConditionNodeParser();
|
||||
|
||||
chainParser.addNodeParser(docNodeParser.getNodeName(), docNodeParser);
|
||||
chainParser.addNodeParser(makeFileNodeParser.getNodeName(), makeFileNodeParser);
|
||||
@@ -84,6 +86,7 @@ public class TinyFlowConfigService {
|
||||
chainParser.addNodeParser(saveDaveParser.getNodeName(), saveDaveParser);
|
||||
chainParser.addNodeParser(searchDatacenterNodeParser.getNodeName(), searchDatacenterNodeParser);
|
||||
chainParser.addNodeParser(workflowNodeParser.getNodeName(), workflowNodeParser);
|
||||
chainParser.addNodeParser(conditionNodeParser.getNodeName(), conditionNodeParser);
|
||||
}
|
||||
|
||||
public void setSearchEngineProvider() {
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
package tech.easyflow.ai.node;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.easyagents.flow.core.chain.Chain;
|
||||
import com.easyagents.flow.core.chain.ChainException;
|
||||
import com.easyagents.flow.core.node.BaseNode;
|
||||
import com.easyagents.flow.core.util.JsConditionUtil;
|
||||
import com.easyagents.flow.core.util.StringUtil;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 条件判断节点:首个命中(if / else-if)语义。
|
||||
*/
|
||||
public class ConditionNode extends BaseNode {
|
||||
private static final Pattern TEMPLATE_PARAM_PATTERN = Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*}}");
|
||||
|
||||
private String branchMode = "first_match";
|
||||
private String defaultBranchId;
|
||||
private String defaultBranchLabel;
|
||||
private List<ConditionBranch> branches = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public Map<String, Object> execute(Chain chain) {
|
||||
if (branches == null || branches.isEmpty()) {
|
||||
throw new ChainException("条件判断节点未配置任何分支");
|
||||
}
|
||||
|
||||
ConditionBranch defaultBranch = resolveDefaultBranch();
|
||||
|
||||
for (ConditionBranch branch : branches) {
|
||||
if (branch == null) {
|
||||
continue;
|
||||
}
|
||||
if (defaultBranch != null && Objects.equals(defaultBranch.getId(), branch.getId())) {
|
||||
continue;
|
||||
}
|
||||
if (isBranchMatched(chain, branch)) {
|
||||
return buildMatchedResult(branch, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultBranch == null) {
|
||||
throw new ChainException("条件判断节点缺少默认分支,请检查配置");
|
||||
}
|
||||
|
||||
return buildMatchedResult(defaultBranch, true);
|
||||
}
|
||||
|
||||
private ConditionBranch resolveDefaultBranch() {
|
||||
if (branches == null || branches.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (StringUtil.hasText(defaultBranchId)) {
|
||||
for (ConditionBranch branch : branches) {
|
||||
if (branch != null && Objects.equals(defaultBranchId, branch.getId())) {
|
||||
return branch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (StringUtil.hasText(defaultBranchLabel)) {
|
||||
for (ConditionBranch branch : branches) {
|
||||
if (branch != null && Objects.equals(defaultBranchLabel, branch.getLabel())) {
|
||||
return branch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return branches.get(branches.size() - 1);
|
||||
}
|
||||
|
||||
private Map<String, Object> buildMatchedResult(ConditionBranch branch, boolean matchedByDefault) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("matchedBranchId", branch.getId());
|
||||
result.put("matchedBranchLabel", branch.getLabel());
|
||||
result.put("matchedByDefault", matchedByDefault);
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isBranchMatched(Chain chain, ConditionBranch branch) {
|
||||
if (branch == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String mode = StringUtil.getFirstWithText(branch.getMode(), "visual");
|
||||
if ("expression".equalsIgnoreCase(mode)) {
|
||||
return checkByExpression(chain, branch);
|
||||
}
|
||||
|
||||
return checkByVisualRules(chain, branch);
|
||||
}
|
||||
|
||||
private boolean checkByExpression(Chain chain, ConditionBranch branch) {
|
||||
String expression = branch.getExpression();
|
||||
if (StringUtil.noText(expression)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String resolvedExpression = resolveExpressionTemplate(expression, chain);
|
||||
return JsConditionUtil.eval(resolvedExpression, chain, new HashMap<>());
|
||||
} catch (Exception e) {
|
||||
throw new ChainException(String.format("条件分支表达式执行失败,分支[%s/%s]: %s",
|
||||
branch.getId(), branch.getLabel(), e.getMessage()), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveExpressionTemplate(String expression, Chain chain) {
|
||||
Matcher matcher = TEMPLATE_PARAM_PATTERN.matcher(expression);
|
||||
StringBuffer output = new StringBuffer();
|
||||
|
||||
while (matcher.find()) {
|
||||
String path = matcher.group(1) == null ? "" : matcher.group(1).trim();
|
||||
Object value = StringUtil.noText(path) ? null : chain.getState().resolveValue(path);
|
||||
matcher.appendReplacement(output, Matcher.quoteReplacement(toJsLiteral(value)));
|
||||
}
|
||||
matcher.appendTail(output);
|
||||
return output.toString();
|
||||
}
|
||||
|
||||
private String toJsLiteral(Object value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
if (value instanceof Number || value instanceof Boolean) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
if (value instanceof Character || value.getClass().isEnum()) {
|
||||
return JSON.toJSONString(String.valueOf(value));
|
||||
}
|
||||
return JSON.toJSONString(value);
|
||||
}
|
||||
|
||||
private boolean checkByVisualRules(Chain chain, ConditionBranch branch) {
|
||||
List<ConditionRule> rules = branch.getRules();
|
||||
if (rules == null || rules.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Boolean matched = null;
|
||||
for (ConditionRule rule : rules) {
|
||||
boolean ruleMatched = checkRule(chain, rule);
|
||||
if (matched == null) {
|
||||
matched = ruleMatched;
|
||||
continue;
|
||||
}
|
||||
|
||||
String joiner = StringUtil.getFirstWithText(rule.getJoiner(), "AND");
|
||||
if ("OR".equalsIgnoreCase(joiner)) {
|
||||
matched = matched || ruleMatched;
|
||||
} else {
|
||||
matched = matched && ruleMatched;
|
||||
}
|
||||
}
|
||||
|
||||
return Boolean.TRUE.equals(matched);
|
||||
}
|
||||
|
||||
private boolean checkRule(Chain chain, ConditionRule rule) {
|
||||
if (rule == null || StringUtil.noText(rule.getOperator())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Object leftValue = resolveValue(chain, rule.getLeftRef());
|
||||
String operator = rule.getOperator();
|
||||
|
||||
if ("isEmpty".equals(operator)) {
|
||||
return isEmpty(leftValue);
|
||||
}
|
||||
|
||||
if ("isNotEmpty".equals(operator)) {
|
||||
return !isEmpty(leftValue);
|
||||
}
|
||||
|
||||
Object rightValue = resolveRightValue(chain, rule);
|
||||
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
return equalsSmart(leftValue, rightValue);
|
||||
case "ne":
|
||||
return !equalsSmart(leftValue, rightValue);
|
||||
case "gt":
|
||||
return compareNumber(leftValue, rightValue, it -> it > 0);
|
||||
case "gte":
|
||||
return compareNumber(leftValue, rightValue, it -> it >= 0);
|
||||
case "lt":
|
||||
return compareNumber(leftValue, rightValue, it -> it < 0);
|
||||
case "lte":
|
||||
return compareNumber(leftValue, rightValue, it -> it <= 0);
|
||||
case "contains":
|
||||
return contains(leftValue, rightValue);
|
||||
case "notContains":
|
||||
return !contains(leftValue, rightValue);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Object resolveRightValue(Chain chain, ConditionRule rule) {
|
||||
if (rule == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ("ref".equals(rule.getRightType())) {
|
||||
return resolveValue(chain, rule.getRightRef());
|
||||
}
|
||||
|
||||
return rule.getRightValue();
|
||||
}
|
||||
|
||||
private Object resolveValue(Chain chain, String path) {
|
||||
if (chain == null || StringUtil.noText(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chain.getState().resolveValue(path);
|
||||
}
|
||||
|
||||
private boolean isEmpty(Object value) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value instanceof CharSequence) {
|
||||
return StringUtil.noText(value.toString());
|
||||
}
|
||||
|
||||
if (value instanceof Collection) {
|
||||
return ((Collection<?>) value).isEmpty();
|
||||
}
|
||||
|
||||
if (value instanceof Map) {
|
||||
return ((Map<?, ?>) value).isEmpty();
|
||||
}
|
||||
|
||||
if (value.getClass().isArray()) {
|
||||
return java.lang.reflect.Array.getLength(value) == 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean contains(Object leftValue, Object rightValue) {
|
||||
if (leftValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (leftValue instanceof String) {
|
||||
return rightValue != null && ((String) leftValue).contains(String.valueOf(rightValue));
|
||||
}
|
||||
|
||||
if (leftValue instanceof Collection) {
|
||||
Collection<?> collection = (Collection<?>) leftValue;
|
||||
for (Object item : collection) {
|
||||
if (equalsSmart(item, rightValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (leftValue.getClass().isArray()) {
|
||||
int length = java.lang.reflect.Array.getLength(leftValue);
|
||||
for (int i = 0; i < length; i++) {
|
||||
Object item = java.lang.reflect.Array.get(leftValue, i);
|
||||
if (equalsSmart(item, rightValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean compareNumber(Object leftValue, Object rightValue, java.util.function.IntPredicate compareFunc) {
|
||||
BigDecimal leftNumber = toBigDecimal(leftValue);
|
||||
BigDecimal rightNumber = toBigDecimal(rightValue);
|
||||
|
||||
if (leftNumber == null || rightNumber == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return compareFunc.test(leftNumber.compareTo(rightNumber));
|
||||
}
|
||||
|
||||
private boolean equalsSmart(Object leftValue, Object rightValue) {
|
||||
if (leftValue == null || rightValue == null) {
|
||||
return leftValue == rightValue;
|
||||
}
|
||||
|
||||
BigDecimal leftNumber = toBigDecimal(leftValue);
|
||||
BigDecimal rightNumber = toBigDecimal(rightValue);
|
||||
if (leftNumber != null && rightNumber != null) {
|
||||
return leftNumber.compareTo(rightNumber) == 0;
|
||||
}
|
||||
|
||||
if (leftValue instanceof Boolean || rightValue instanceof Boolean) {
|
||||
return toBoolean(leftValue) == toBoolean(rightValue);
|
||||
}
|
||||
|
||||
return Objects.equals(String.valueOf(leftValue), String.valueOf(rightValue));
|
||||
}
|
||||
|
||||
private boolean toBoolean(Object value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value instanceof Boolean) {
|
||||
return (Boolean) value;
|
||||
}
|
||||
|
||||
String stringValue = String.valueOf(value).trim();
|
||||
if (StringUtil.noText(stringValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return "true".equalsIgnoreCase(stringValue)
|
||||
|| "1".equals(stringValue)
|
||||
|| "yes".equalsIgnoreCase(stringValue)
|
||||
|| "y".equalsIgnoreCase(stringValue);
|
||||
}
|
||||
|
||||
private BigDecimal toBigDecimal(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Number) {
|
||||
return new BigDecimal(String.valueOf(value));
|
||||
}
|
||||
|
||||
String text = String.valueOf(value).trim();
|
||||
if (StringUtil.noText(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new BigDecimal(text);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String getBranchMode() {
|
||||
return branchMode;
|
||||
}
|
||||
|
||||
public void setBranchMode(String branchMode) {
|
||||
this.branchMode = branchMode;
|
||||
}
|
||||
|
||||
public String getDefaultBranchId() {
|
||||
return defaultBranchId;
|
||||
}
|
||||
|
||||
public void setDefaultBranchId(String defaultBranchId) {
|
||||
this.defaultBranchId = defaultBranchId;
|
||||
}
|
||||
|
||||
public String getDefaultBranchLabel() {
|
||||
return defaultBranchLabel;
|
||||
}
|
||||
|
||||
public void setDefaultBranchLabel(String defaultBranchLabel) {
|
||||
this.defaultBranchLabel = defaultBranchLabel;
|
||||
}
|
||||
|
||||
public List<ConditionBranch> getBranches() {
|
||||
return branches;
|
||||
}
|
||||
|
||||
public void setBranches(List<ConditionBranch> branches) {
|
||||
this.branches = branches;
|
||||
}
|
||||
|
||||
public static class ConditionBranch {
|
||||
private String id;
|
||||
private String label;
|
||||
private String mode;
|
||||
private String expression;
|
||||
private List<ConditionRule> rules = new ArrayList<>();
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public void setLabel(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(String mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public String getExpression() {
|
||||
return expression;
|
||||
}
|
||||
|
||||
public void setExpression(String expression) {
|
||||
this.expression = expression;
|
||||
}
|
||||
|
||||
public List<ConditionRule> getRules() {
|
||||
return rules;
|
||||
}
|
||||
|
||||
public void setRules(List<ConditionRule> rules) {
|
||||
this.rules = rules;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ConditionRule {
|
||||
private String id;
|
||||
private String joiner;
|
||||
private String leftRef;
|
||||
private String operator;
|
||||
private String rightType;
|
||||
private String rightValue;
|
||||
private String rightRef;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getJoiner() {
|
||||
return joiner;
|
||||
}
|
||||
|
||||
public void setJoiner(String joiner) {
|
||||
this.joiner = joiner;
|
||||
}
|
||||
|
||||
public String getLeftRef() {
|
||||
return leftRef;
|
||||
}
|
||||
|
||||
public void setLeftRef(String leftRef) {
|
||||
this.leftRef = leftRef;
|
||||
}
|
||||
|
||||
public String getOperator() {
|
||||
return operator;
|
||||
}
|
||||
|
||||
public void setOperator(String operator) {
|
||||
this.operator = operator;
|
||||
}
|
||||
|
||||
public String getRightType() {
|
||||
return rightType;
|
||||
}
|
||||
|
||||
public void setRightType(String rightType) {
|
||||
this.rightType = rightType;
|
||||
}
|
||||
|
||||
public String getRightValue() {
|
||||
return rightValue;
|
||||
}
|
||||
|
||||
public void setRightValue(String rightValue) {
|
||||
this.rightValue = rightValue;
|
||||
}
|
||||
|
||||
public String getRightRef() {
|
||||
return rightRef;
|
||||
}
|
||||
|
||||
public void setRightRef(String rightRef) {
|
||||
this.rightRef = rightRef;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package tech.easyflow.ai.node;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.easyagents.flow.core.parser.BaseNodeParser;
|
||||
import com.easyagents.flow.core.util.StringUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 条件判断节点解析器。
|
||||
*/
|
||||
public class ConditionNodeParser extends BaseNodeParser<ConditionNode> {
|
||||
|
||||
@Override
|
||||
protected ConditionNode doParse(JSONObject root, JSONObject data, JSONObject tinyflow) {
|
||||
ConditionNode node = new ConditionNode();
|
||||
node.setBranchMode(StringUtil.getFirstWithText(data.getString("branchMode"), "first_match"));
|
||||
node.setDefaultBranchId(data.getString("defaultBranchId"));
|
||||
node.setDefaultBranchLabel(data.getString("defaultBranchLabel"));
|
||||
|
||||
JSONArray branchesJson = data.getJSONArray("branches");
|
||||
List<ConditionNode.ConditionBranch> branches = branchesJson == null
|
||||
? Collections.emptyList()
|
||||
: branchesJson.toJavaList(ConditionNode.ConditionBranch.class);
|
||||
node.setBranches(branches);
|
||||
|
||||
if (branches.isEmpty()) {
|
||||
throw new RuntimeException("条件判断节点至少需要一个分支");
|
||||
}
|
||||
if (StringUtil.noText(node.getDefaultBranchId())) {
|
||||
throw new RuntimeException("条件判断节点必须配置默认分支");
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
public String getNodeName() {
|
||||
return "conditionNode";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package tech.easyflow.ai.node;
|
||||
|
||||
import com.easyagents.flow.core.chain.*;
|
||||
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class ConditionNodeTest {
|
||||
|
||||
@Test
|
||||
public void testFirstMatch() {
|
||||
ConditionNode node = new ConditionNode();
|
||||
|
||||
ConditionNode.ConditionBranch b1 = visualBranch("branch_a", "分支A",
|
||||
visualRule("ctx.score", "gte", "fixed", "80", null));
|
||||
ConditionNode.ConditionBranch b2 = visualBranch("branch_b", "分支B",
|
||||
visualRule("ctx.score", "gte", "fixed", "60", null));
|
||||
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||
|
||||
node.setBranches(Arrays.asList(b1, b2, def));
|
||||
node.setDefaultBranchId(def.getId());
|
||||
node.setDefaultBranchLabel(def.getLabel());
|
||||
|
||||
Chain chain = createChain(Map.of("ctx", Map.of("score", 90)));
|
||||
Map<String, Object> result = node.execute(chain);
|
||||
|
||||
Assert.assertEquals("branch_a", result.get("matchedBranchId"));
|
||||
Assert.assertEquals("分支A", result.get("matchedBranchLabel"));
|
||||
Assert.assertEquals(false, result.get("matchedByDefault"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDefaultBranchWhenNoMatch() {
|
||||
ConditionNode node = new ConditionNode();
|
||||
|
||||
ConditionNode.ConditionBranch b1 = visualBranch("branch_a", "分支A",
|
||||
visualRule("ctx.level", "eq", "fixed", "vip", null));
|
||||
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||
|
||||
node.setBranches(Arrays.asList(b1, def));
|
||||
node.setDefaultBranchId(def.getId());
|
||||
node.setDefaultBranchLabel(def.getLabel());
|
||||
|
||||
Chain chain = createChain(Map.of("ctx", Map.of("level", "normal")));
|
||||
Map<String, Object> result = node.execute(chain);
|
||||
|
||||
Assert.assertEquals("branch_default", result.get("matchedBranchId"));
|
||||
Assert.assertEquals("默认分支", result.get("matchedBranchLabel"));
|
||||
Assert.assertEquals(true, result.get("matchedByDefault"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testVisualOperators() {
|
||||
ConditionNode node = new ConditionNode();
|
||||
|
||||
ConditionNode.ConditionBranch b1 = visualBranch("branch_hit", "命中分支",
|
||||
visualRule("ctx.amount", "gt", "fixed", "100", null),
|
||||
visualRule("ctx.tags", "contains", "fixed", "vip", null),
|
||||
visualRule("ctx.title", "notContains", "fixed", "blacklist", null),
|
||||
visualRule("ctx.emptyText", "isEmpty", "fixed", "", null),
|
||||
visualRule("ctx.note", "isNotEmpty", "fixed", "", null));
|
||||
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||
|
||||
node.setBranches(Arrays.asList(b1, def));
|
||||
node.setDefaultBranchId(def.getId());
|
||||
node.setDefaultBranchLabel(def.getLabel());
|
||||
|
||||
Map<String, Object> ctx = new HashMap<>();
|
||||
ctx.put("amount", 120);
|
||||
ctx.put("tags", Arrays.asList("vip", "new"));
|
||||
ctx.put("title", "white-user");
|
||||
ctx.put("emptyText", "");
|
||||
ctx.put("note", "ok");
|
||||
|
||||
Chain chain = createChain(Map.of("ctx", ctx));
|
||||
Map<String, Object> result = node.execute(chain);
|
||||
Assert.assertEquals("branch_hit", result.get("matchedBranchId"));
|
||||
Assert.assertEquals(false, result.get("matchedByDefault"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testVisualRuleJoinerOr() {
|
||||
ConditionNode node = new ConditionNode();
|
||||
|
||||
ConditionNode.ConditionBranch b1 = visualBranch("branch_or", "OR 分支",
|
||||
visualRule("ctx.score", "gt", "fixed", "100", null, "AND"),
|
||||
visualRule("ctx.level", "eq", "fixed", "vip", null, "OR"));
|
||||
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||
|
||||
node.setBranches(Arrays.asList(b1, def));
|
||||
node.setDefaultBranchId(def.getId());
|
||||
node.setDefaultBranchLabel(def.getLabel());
|
||||
|
||||
Chain chain = createChain(Map.of("ctx", Map.of("score", 80, "level", "vip")));
|
||||
Map<String, Object> result = node.execute(chain);
|
||||
|
||||
Assert.assertEquals("branch_or", result.get("matchedBranchId"));
|
||||
Assert.assertEquals(false, result.get("matchedByDefault"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExpressionModeAndInvalidExpression() {
|
||||
ConditionNode node = new ConditionNode();
|
||||
|
||||
ConditionNode.ConditionBranch b1 = expressionBranch("branch_expr", "表达式分支",
|
||||
"{{amount}} >= 100 && {{level}} === 'vip'");
|
||||
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||
node.setBranches(Arrays.asList(b1, def));
|
||||
node.setDefaultBranchId(def.getId());
|
||||
node.setDefaultBranchLabel(def.getLabel());
|
||||
|
||||
Chain chain = createChain(Map.of("amount", 120, "level", "vip"));
|
||||
Map<String, Object> result = node.execute(chain);
|
||||
Assert.assertEquals("branch_expr", result.get("matchedBranchId"));
|
||||
|
||||
ConditionNode invalidNode = new ConditionNode();
|
||||
ConditionNode.ConditionBranch bad = expressionBranch("branch_bad", "异常分支", "amount >");
|
||||
invalidNode.setBranches(Arrays.asList(bad, def));
|
||||
invalidNode.setDefaultBranchId(def.getId());
|
||||
invalidNode.setDefaultBranchLabel(def.getLabel());
|
||||
|
||||
ChainException ex = Assert.assertThrows(ChainException.class, () -> invalidNode.execute(chain));
|
||||
Assert.assertTrue(ex.getMessage().contains("branch_bad"));
|
||||
Assert.assertTrue(ex.getMessage().contains("异常分支"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExpressionModeWithTemplatePath() {
|
||||
ConditionNode node = new ConditionNode();
|
||||
|
||||
ConditionNode.ConditionBranch b1 = expressionBranch(
|
||||
"branch_expr_tpl",
|
||||
"模板分支",
|
||||
"{{ctx.order.amount}} >= 10 && {{ctx.user.level}} === 'vip'"
|
||||
);
|
||||
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||
node.setBranches(Arrays.asList(b1, def));
|
||||
node.setDefaultBranchId(def.getId());
|
||||
node.setDefaultBranchLabel(def.getLabel());
|
||||
|
||||
Map<String, Object> ctx = new HashMap<>();
|
||||
ctx.put("order", Map.of("amount", 18));
|
||||
ctx.put("user", Map.of("level", "vip"));
|
||||
Chain chain = createChain(Map.of("ctx", ctx));
|
||||
|
||||
Map<String, Object> result = node.execute(chain);
|
||||
Assert.assertEquals("branch_expr_tpl", result.get("matchedBranchId"));
|
||||
Assert.assertEquals(false, result.get("matchedByDefault"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testManagedEdgeConditionRouting() {
|
||||
ConditionNode node = new ConditionNode();
|
||||
|
||||
ConditionNode.ConditionBranch b1 = visualBranch("branch_yes", "通过",
|
||||
visualRule("ctx.allow", "eq", "fixed", "true", null));
|
||||
ConditionNode.ConditionBranch def = defaultBranch("branch_default", "默认分支");
|
||||
node.setBranches(Arrays.asList(b1, def));
|
||||
node.setDefaultBranchId(def.getId());
|
||||
node.setDefaultBranchLabel(def.getLabel());
|
||||
|
||||
Chain chain = createChain(Map.of("ctx", Map.of("allow", true)));
|
||||
Map<String, Object> result = node.execute(chain);
|
||||
|
||||
Edge yesEdge = new Edge("e_yes");
|
||||
yesEdge.setCondition(new JsCodeCondition("matchedBranchId === 'branch_yes'"));
|
||||
Assert.assertTrue(yesEdge.getCondition().check(chain, yesEdge, result));
|
||||
|
||||
Edge noEdge = new Edge("e_no");
|
||||
noEdge.setCondition(new JsCodeCondition("matchedBranchId === 'branch_other'"));
|
||||
Assert.assertFalse(noEdge.getCondition().check(chain, noEdge, result));
|
||||
}
|
||||
|
||||
private static Chain createChain(Map<String, Object> memory) {
|
||||
Chain chain = new Chain(new ChainDefinition(), UUID.randomUUID().toString());
|
||||
chain.setChainStateRepository(new InMemoryChainStateRepository());
|
||||
if (memory != null && !memory.isEmpty()) {
|
||||
chain.getState().getMemory().putAll(memory);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static ConditionNode.ConditionBranch defaultBranch(String id, String label) {
|
||||
ConditionNode.ConditionBranch branch = new ConditionNode.ConditionBranch();
|
||||
branch.setId(id);
|
||||
branch.setLabel(label);
|
||||
branch.setMode("visual");
|
||||
branch.setRules(Collections.emptyList());
|
||||
return branch;
|
||||
}
|
||||
|
||||
private static ConditionNode.ConditionBranch expressionBranch(String id, String label, String expression) {
|
||||
ConditionNode.ConditionBranch branch = new ConditionNode.ConditionBranch();
|
||||
branch.setId(id);
|
||||
branch.setLabel(label);
|
||||
branch.setMode("expression");
|
||||
branch.setExpression(expression);
|
||||
branch.setRules(Collections.emptyList());
|
||||
return branch;
|
||||
}
|
||||
|
||||
private static ConditionNode.ConditionBranch visualBranch(
|
||||
String id, String label, ConditionNode.ConditionRule... rules) {
|
||||
ConditionNode.ConditionBranch branch = new ConditionNode.ConditionBranch();
|
||||
branch.setId(id);
|
||||
branch.setLabel(label);
|
||||
branch.setMode("visual");
|
||||
branch.setRules(Arrays.asList(rules));
|
||||
return branch;
|
||||
}
|
||||
|
||||
private static ConditionNode.ConditionRule visualRule(
|
||||
String leftRef, String operator, String rightType, String rightValue, String rightRef) {
|
||||
return visualRule(leftRef, operator, rightType, rightValue, rightRef, "AND");
|
||||
}
|
||||
|
||||
private static ConditionNode.ConditionRule visualRule(
|
||||
String leftRef, String operator, String rightType, String rightValue, String rightRef, String joiner) {
|
||||
ConditionNode.ConditionRule rule = new ConditionNode.ConditionRule();
|
||||
rule.setId(UUID.randomUUID().toString());
|
||||
rule.setJoiner(joiner);
|
||||
rule.setLeftRef(leftRef);
|
||||
rule.setOperator(operator);
|
||||
rule.setRightType(rightType);
|
||||
rule.setRightValue(rightValue);
|
||||
rule.setRightRef(rightRef);
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
Background,
|
||||
Controls,
|
||||
type Edge,
|
||||
type Handle,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
type Node,
|
||||
@@ -33,14 +32,14 @@
|
||||
import {onDestroy, onMount} from 'svelte';
|
||||
import {isInEditableElement} from '#components/utils/isInEditableElement';
|
||||
|
||||
const { onInit, ...rest } = $props();
|
||||
const { onInit }: { onInit: any; [key: string]: any } = $props();
|
||||
const svelteFlow = useSvelteFlow();
|
||||
|
||||
console.log('props', rest);
|
||||
onInit(svelteFlow);
|
||||
|
||||
let showEdgePanel = $state(false);
|
||||
let currentEdge = $state<Edge | null>(null);
|
||||
const asString = (value: unknown) => (value == null ? '' : String(value));
|
||||
|
||||
const { updateEdgeData } = useUpdateEdgeData();
|
||||
|
||||
@@ -122,7 +121,7 @@
|
||||
}
|
||||
|
||||
const fromNode = state.fromNode as Node;
|
||||
const fromHande = state.fromHandle as Handle;
|
||||
const fromHande = state.fromHandle as any;
|
||||
|
||||
const newNode = {
|
||||
position: { ...toNode.position }
|
||||
@@ -338,7 +337,31 @@
|
||||
showEdgePanel = true;
|
||||
currentEdge = e.edge;
|
||||
}}
|
||||
onbeforeconnect={(edge) => {
|
||||
onbeforeconnect={(edge: any) => {
|
||||
const sourceNode = edge.source ? getNode(edge.source) : null;
|
||||
const sourceHandle = edge.sourceHandle || '';
|
||||
const isConditionBranchEdge = sourceNode?.type === 'conditionNode'
|
||||
&& typeof sourceHandle === 'string'
|
||||
&& sourceHandle.startsWith('branch_');
|
||||
|
||||
if (isConditionBranchEdge) {
|
||||
const branchId = sourceHandle.slice(7);
|
||||
const branches = (sourceNode?.data?.branches || []) as Array<any>;
|
||||
const branch = branches.find((item) => item?.id === branchId);
|
||||
const branchLabel = branch?.label || '条件分支';
|
||||
return {
|
||||
...edge,
|
||||
id: genShortId(),
|
||||
data: {
|
||||
...((edge as any).data || {}),
|
||||
managedByConditionNode: true,
|
||||
branchId,
|
||||
branchLabel,
|
||||
condition: `matchedBranchId === '${branchId}'`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id:genShortId(),
|
||||
@@ -376,24 +399,43 @@
|
||||
<div>边属性设置</div>
|
||||
<div class="setting-title">边条件设置</div>
|
||||
<div class="setting-item">
|
||||
{#if currentEdge?.data?.managedByConditionNode}
|
||||
<div class="readonly-edge-settings">
|
||||
<div class="readonly-edge-branch">
|
||||
分支:{currentEdge?.data?.branchLabel || currentEdge?.data?.branchId || '-'}
|
||||
</div>
|
||||
<Textarea
|
||||
rows={3}
|
||||
style="width: 100%"
|
||||
disabled={true}
|
||||
value={asString(currentEdge?.data?.condition)}
|
||||
/>
|
||||
<div class="readonly-edge-tip">
|
||||
该连线由条件节点托管,条件表达式不可在边面板手动修改。
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder="请输入边条件"
|
||||
style="width: 100%"
|
||||
value={currentEdge?.data?.condition}
|
||||
value={asString(currentEdge?.data?.condition)}
|
||||
onchange={(e)=>{
|
||||
if (currentEdge){
|
||||
updateEdgeData(currentEdge.id, {
|
||||
condition: e.target?.value
|
||||
condition: (e.target as HTMLTextAreaElement)?.value || ''
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="setting-item" style="padding: 8px 0">
|
||||
<Button
|
||||
onclick={() => {
|
||||
deleteEdge(currentEdge?.id)
|
||||
if (currentEdge?.id) {
|
||||
deleteEdge(currentEdge.id)
|
||||
}
|
||||
showEdgePanel = false;
|
||||
}}
|
||||
>
|
||||
@@ -438,4 +480,20 @@
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.readonly-edge-settings {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.readonly-edge-branch {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.readonly-edge-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
sortNo: 200,
|
||||
description: '用于循环执行任务'
|
||||
},
|
||||
{
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4H10V10H4V4ZM14 4H20V10H14V4ZM4 14H10V20H4V14ZM14 14H20V20H14V14ZM10 7H14V9H10V7ZM7 10H9V14H7V10ZM15 10H17V14H15V10Z"></path></svg>',
|
||||
title: '条件判断',
|
||||
type: 'conditionNode',
|
||||
sortNo: 250,
|
||||
description: '根据参数值分流到不同分支'
|
||||
},
|
||||
{
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.7134 7.12811L20.4668 7.69379C20.2864 8.10792 19.7136 8.10792 19.5331 7.69379L19.2866 7.12811C18.8471 6.11947 18.0555 5.31641 17.0677 4.87708L16.308 4.53922C15.8973 4.35653 15.8973 3.75881 16.308 3.57612L17.0252 3.25714C18.0384 2.80651 18.8442 1.97373 19.2761 0.930828L19.5293 0.319534C19.7058 -0.106511 20.2942 -0.106511 20.4706 0.319534L20.7238 0.930828C21.1558 1.97373 21.9616 2.80651 22.9748 3.25714L23.6919 3.57612C24.1027 3.75881 24.1027 4.35653 23.6919 4.53922L22.9323 4.87708C21.9445 5.31641 21.1529 6.11947 20.7134 7.12811ZM9 2C13.0675 2 16.426 5.03562 16.9337 8.96494L19.1842 12.5037C19.3324 12.7367 19.3025 13.0847 18.9593 13.2317L17 14.071V17C17 18.1046 16.1046 19 15 19H13.001L13 22H4L4.00025 18.3061C4.00033 17.1252 3.56351 16.0087 2.7555 15.0011C1.65707 13.6313 1 11.8924 1 10C1 5.58172 4.58172 2 9 2ZM9 4C5.68629 4 3 6.68629 3 10C3 11.3849 3.46818 12.6929 4.31578 13.7499C5.40965 15.114 6.00036 16.6672 6.00025 18.3063L6.00013 20H11.0007L11.0017 17H15V12.7519L16.5497 12.0881L15.0072 9.66262L14.9501 9.22118C14.5665 6.25141 12.0243 4 9 4ZM19.4893 16.9929L21.1535 18.1024C22.32 16.3562 23 14.2576 23 12.0001C23 11.317 22.9378 10.6486 22.8186 10L20.8756 10.5C20.9574 10.9878 21 11.489 21 12.0001C21 13.8471 20.4436 15.5642 19.4893 16.9929Z"></path></svg>',
|
||||
title: '大模型',
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
import {createFloating} from '../utils/createFloating';
|
||||
import type {Placement} from '@floating-ui/dom';
|
||||
|
||||
const { children, floating, placement = 'bottom', onShow, onHide, syncWidth = false }:
|
||||
const { children, floating, placement = 'bottom', onShow, onHide, syncWidth = false, syncWidthMode = 'min' }:
|
||||
{
|
||||
children: Snippet,
|
||||
floating: Snippet,
|
||||
placement?: Placement,
|
||||
onShow?: () => void,
|
||||
onHide?: () => void,
|
||||
syncWidth?: boolean
|
||||
syncWidth?: boolean,
|
||||
syncWidthMode?: 'min' | 'equal'
|
||||
} = $props();
|
||||
|
||||
let triggerEl!: HTMLDivElement, contentEl!: HTMLDivElement;
|
||||
@@ -24,7 +25,8 @@
|
||||
placement,
|
||||
onShow,
|
||||
onHide,
|
||||
syncWidth
|
||||
syncWidth,
|
||||
syncWidthMode
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -44,4 +46,3 @@
|
||||
<div style="display: none; width: max-content; z-index: 99999; position: absolute;" bind:this={contentEl}>
|
||||
{@render floating() }
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import Select from './select.svelte';
|
||||
import FloatingTrigger from './floating-trigger.svelte';
|
||||
import Heading from './heading.svelte';
|
||||
import MenuButton from './menu-button.svelte';
|
||||
import MixedInput from './mixed-input.svelte';
|
||||
|
||||
export {
|
||||
Button,
|
||||
@@ -23,5 +24,6 @@ export {
|
||||
Select,
|
||||
FloatingTrigger,
|
||||
Heading,
|
||||
MenuButton
|
||||
MenuButton,
|
||||
MixedInput
|
||||
};
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
<!-- packages/tinyflow-ui/src/components/base/mixed-input.svelte -->
|
||||
<script lang="ts">
|
||||
import FloatingTrigger from './floating-trigger.svelte';
|
||||
import type {SelectItem} from '#types';
|
||||
import {nodeIcons} from '../../consts';
|
||||
|
||||
let {
|
||||
type = 'fixed',
|
||||
textValue = '',
|
||||
refValue = '',
|
||||
refOptions = [],
|
||||
placeholder = '请输入内容',
|
||||
onTypeChange,
|
||||
onTextChange,
|
||||
onRefChange,
|
||||
style = ''
|
||||
}: {
|
||||
type: 'fixed' | 'ref';
|
||||
textValue: string;
|
||||
refValue: string;
|
||||
refOptions: SelectItem[];
|
||||
onTypeChange?: (type: 'fixed' | 'ref') => void;
|
||||
onTextChange?: (val: string) => void;
|
||||
onRefChange?: (val: string) => void;
|
||||
placeholder?: string;
|
||||
style?: string;
|
||||
} = $props();
|
||||
|
||||
let floatingRef: any = $state();
|
||||
let hoveredItem: SelectItem | null = $state(null);
|
||||
let isOpen = $state(false);
|
||||
|
||||
let selectedItem = $derived.by(() => {
|
||||
let found: SelectItem | null = null;
|
||||
const findItem = (items: SelectItem[]) => {
|
||||
for (const it of items) {
|
||||
if (it.value === refValue) {
|
||||
found = it;
|
||||
}
|
||||
if (it.children) findItem(it.children);
|
||||
}
|
||||
};
|
||||
findItem(refOptions);
|
||||
return found;
|
||||
});
|
||||
|
||||
function closeMenu() {
|
||||
floatingRef?.hide();
|
||||
isOpen = false;
|
||||
hoveredItem = null;
|
||||
}
|
||||
|
||||
function handlerOnSelect(item: SelectItem) {
|
||||
if (item.selectable !== false) {
|
||||
onTypeChange?.('ref');
|
||||
onRefChange?.(item.value as string);
|
||||
closeMenu();
|
||||
} else {
|
||||
hoveredItem = item;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter(item: SelectItem) {
|
||||
if (item.children && item.children.length > 0) {
|
||||
hoveredItem = item;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderNestedItems(items: SelectItem[], depth = 0)}
|
||||
{#each items as item}
|
||||
<div class="tf-mixed-item-container" style="padding-left: {depth * 16}px">
|
||||
<button class="tf-mixed-item {item.children && item.children.length > 0 ? 'has-children' : ''}"
|
||||
onclick={() => handlerOnSelect(item)}>
|
||||
<div class="tf-mixed-label">
|
||||
<div class="tf-mixed-name-wrapper">
|
||||
{#if item.children && item.children.length > 0}
|
||||
<span class="tf-mixed-expand-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-mixed-name">{item.label}</span>
|
||||
</div>
|
||||
{#if item.dataType}
|
||||
<span class="tf-mixed-type">{item.dataType}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{#if item.children && item.children.length > 0}
|
||||
<div class="tf-mixed-item-children">
|
||||
{@render renderNestedItems(item.children, depth + 1)}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<div class="tf-mixed-input-root" {style}>
|
||||
<FloatingTrigger
|
||||
bind:this={floatingRef}
|
||||
onShow={() => isOpen = true}
|
||||
onHide={() => { isOpen = false; hoveredItem = null; }}
|
||||
syncWidth={true}
|
||||
syncWidthMode="min"
|
||||
placement="bottom"
|
||||
>
|
||||
<!-- Use identical structure to ensure zero visual jumping in box outline -->
|
||||
<div class="tf-mixed-wrapper {isOpen ? 'is-focus' : ''}">
|
||||
{#if type === 'ref'}
|
||||
<!-- Ref view: Styled identical to text view input box -->
|
||||
<div class="tf-mixed-box tf-mixed-ref-box">
|
||||
{#if selectedItem}
|
||||
<div class="tf-mixed-sel-val">
|
||||
{#if selectedItem.nodeType && nodeIcons[selectedItem.nodeType]}
|
||||
<span class="tf-mixed-val-icon">
|
||||
{@html nodeIcons[selectedItem.nodeType]}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-mixed-val-name">{selectedItem.displayLabel || selectedItem.label}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tf-mixed-placeholder">{placeholder}</div>
|
||||
{/if}
|
||||
<!-- Clickable area to trigger dropdown -->
|
||||
<button class="tf-mixed-trigger-btn" aria-label="选择变量" tabindex="-1">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M12 2L20.6603 7V17L12 22L3.33975 17V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Clear Button overlapping -->
|
||||
<button class="tf-mixed-clear-btn" onclick={(e) => { e.stopPropagation(); onTypeChange?.('fixed'); onRefChange?.(''); }} title="清空引用">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="tf-mixed-box tf-mixed-text-box">
|
||||
<input
|
||||
type="text"
|
||||
class="tf-mixed-native-input nopan nodrag"
|
||||
value={textValue}
|
||||
placeholder={placeholder}
|
||||
spellcheck="false"
|
||||
oninput={(e) => onTextChange?.((e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<!-- Trigger button embedded magically over the right edge! Bubbles event up to floating trigger! -->
|
||||
<button class="tf-mixed-trigger-btn" aria-label="选择变量" tabindex="-1">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M12 2L20.6603 7V17L12 22L3.33975 17V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet floating()}
|
||||
<div class="tf-mixed-dropdown nopan nodrag nowheel">
|
||||
<div class="tf-mixed-list tf-mixed-primary-list">
|
||||
{#each refOptions as item}
|
||||
<button class="tf-mixed-item {hoveredItem?.value === item.value ? 'active' : ''}"
|
||||
onmouseenter={() => handleMouseEnter(item)}
|
||||
onclick={() => handlerOnSelect(item)}>
|
||||
<span class="tf-mixed-item-icon">
|
||||
{#if item.icon}
|
||||
{@html item.icon}
|
||||
{:else if item.nodeType && nodeIcons[item.nodeType]}
|
||||
{@html nodeIcons[item.nodeType]}
|
||||
{:else if item.children && item.children.length > 0}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h18M3 12h18M3 17h18"></path></svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="tf-mixed-item-label">{item.label}</span>
|
||||
{#if item.children && item.children.length > 0}
|
||||
<span class="tf-mixed-item-arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hoveredItem && hoveredItem.children && hoveredItem.children.length > 0}
|
||||
<div class="tf-mixed-list tf-mixed-secondary-list">
|
||||
{@render renderNestedItems(hoveredItem.children)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</FloatingTrigger>
|
||||
</div>
|
||||
|
||||
<style lang="less">
|
||||
.tf-mixed-input-root {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* This forces the wrapper generated by floating-trigger to occupy full width */
|
||||
:global(.tf-mixed-input-root > div) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tf-mixed-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tf-mixed-box {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tf-mixed-ref-box {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0 48px 0 12px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tf-mixed-text-box {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0 48px 0 12px;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tf-mixed-wrapper.is-focus .tf-mixed-ref-box,
|
||||
.tf-mixed-wrapper.is-focus .tf-mixed-text-box,
|
||||
.tf-mixed-ref-box:hover,
|
||||
.tf-mixed-text-box:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
.tf-mixed-wrapper.is-focus .tf-mixed-ref-box,
|
||||
.tf-mixed-wrapper.is-focus .tf-mixed-text-box {
|
||||
border-color: #3b82f6;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(59,130,246,0.1);
|
||||
}
|
||||
|
||||
.tf-mixed-sel-val {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tf-mixed-val-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #3474ff;
|
||||
background: #cedafb;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
|
||||
:global(svg) { width: 12px; height: 12px; }
|
||||
}
|
||||
|
||||
.tf-mixed-val-name { color: #111827; font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; user-select: none; }
|
||||
.tf-mixed-placeholder {
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Trigger button base styling */
|
||||
.tf-mixed-trigger-btn {
|
||||
position: absolute;
|
||||
width: 26px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
border-radius: 0 5px 5px 0;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover { background: rgba(0,0,0,0.04); color: #64748b; }
|
||||
}
|
||||
|
||||
/* Keep the trigger in the same visual position in both text/ref modes */
|
||||
.tf-mixed-text-box .tf-mixed-trigger-btn {
|
||||
right: 1px;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
}
|
||||
|
||||
/* Match text mode exactly to avoid 1px horizontal jump after selecting ref */
|
||||
.tf-mixed-ref-box .tf-mixed-trigger-btn {
|
||||
right: 1px;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
}
|
||||
|
||||
.tf-mixed-clear-btn {
|
||||
position: absolute;
|
||||
right: 31px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: #e2e8f0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
padding: 3px;
|
||||
z-index: 10;
|
||||
}
|
||||
.tf-mixed-clear-btn:hover { background: #ef4444; color: #fff; }
|
||||
.tf-mixed-wrapper:hover .tf-mixed-clear-btn { opacity: 1; }
|
||||
|
||||
.tf-mixed-native-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.tf-mixed-native-input::placeholder {
|
||||
color: #9ca3af;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
/* Dropdown Styles matched perfectly to Select */
|
||||
.tf-mixed-dropdown {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
width: max-content;
|
||||
box-sizing: border-box;
|
||||
max-height: 480px;
|
||||
z-index: 99999;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.tf-mixed-list { display: flex; flex-direction: column; padding: 8px; overflow-y: auto; }
|
||||
.tf-mixed-primary-list { width: 100%; flex-shrink: 0; background: #f9fafb; }
|
||||
.tf-mixed-dropdown:has(.tf-mixed-secondary-list) .tf-mixed-primary-list { width: auto; min-width: 180px; }
|
||||
.tf-mixed-secondary-list { min-width: 220px; background: #fff; padding: 12px; border-left: 1px solid #f3f4f6; animation: slideIn 0.2s ease-out; box-sizing: border-box; }
|
||||
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
|
||||
|
||||
.tf-mixed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover { background: #f3f4f6; }
|
||||
&.active { background: #eff6ff; color: #2563eb; font-weight: 500; }
|
||||
&.has-children { background: #f8fafc; margin-bottom: 4px; &:hover { background: #f1f5f9; } }
|
||||
}
|
||||
|
||||
.tf-mixed-item-icon {
|
||||
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #3474ff; background: #cedafb; border-radius: 5px; padding: 3px; box-sizing: border-box;
|
||||
:global(svg) { width: 16px; height: 16px; }
|
||||
}
|
||||
.tf-mixed-item-label { flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tf-mixed-item-arrow { width: 14px; height: 14px; color: #9ca3af; }
|
||||
.tf-mixed-item-container { position: relative; width: 100%; }
|
||||
.tf-mixed-item-children { position: relative; width: 100%; &::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0; width: 1px; background: #f0f0f0; } }
|
||||
.tf-mixed-label { display: flex; justify-content: flex-start; align-items: center; width: 100%; gap: 8px; overflow: hidden;}
|
||||
.tf-mixed-name-wrapper { display: flex; align-items: center; gap: 6px; }
|
||||
.tf-mixed-expand-icon { width: 12px; height: 12px; color: currentColor; opacity: 0.6; }
|
||||
.tf-mixed-name { color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tf-mixed-type { background: rgba(0,0,0,0.06); color: #6b7280; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@
|
||||
multiple = false,
|
||||
placeholder,
|
||||
variant = 'default',
|
||||
showSelectedType = true,
|
||||
...rest
|
||||
}: {
|
||||
items: SelectItem[],
|
||||
@@ -20,6 +21,7 @@
|
||||
multiple?: boolean
|
||||
placeholder?: string
|
||||
variant?: 'default' | 'reference' | 'model'
|
||||
showSelectedType?: boolean
|
||||
[key: string]: any
|
||||
} = $props();
|
||||
|
||||
@@ -179,7 +181,13 @@
|
||||
{/snippet}
|
||||
|
||||
<div {...rest} class="tf-select {rest['class']}">
|
||||
<FloatingTrigger bind:this={triggerObject} onShow={() => isOpen = true} onHide={() => { isOpen = false; hoveredItem = null; }} syncWidth={true}>
|
||||
<FloatingTrigger
|
||||
bind:this={triggerObject}
|
||||
onShow={() => isOpen = true}
|
||||
onHide={() => { isOpen = false; hoveredItem = null; }}
|
||||
syncWidth={true}
|
||||
syncWidthMode={variant === 'default' ? 'equal' : 'min'}
|
||||
>
|
||||
<button class="tf-select-input nopan nodrag {isOpen ? 'active' : ''}" {...rest}>
|
||||
<div class="tf-select-input-value">
|
||||
{#each activeItemsState as item, index (`${index}_${item.value}`)}
|
||||
@@ -196,7 +204,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
|
||||
{#if variant === 'reference' && item.dataType}
|
||||
{#if variant === 'reference' && showSelectedType && item.dataType}
|
||||
<span class="tf-parameter-type">{item.dataType}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -213,7 +221,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
|
||||
{#if variant === 'reference' && item.dataType}
|
||||
{#if variant === 'reference' && showSelectedType && item.dataType}
|
||||
<span class="tf-parameter-type">{item.dataType}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import KnowledgeNode from './KnowledgeNode.svelte';
|
||||
import SearchEngineNode from './SearchEngineNode.svelte';
|
||||
import LoopNode from './LoopNode.svelte';
|
||||
import ConfirmNode from './ConfirmNode.svelte';
|
||||
import ConditionNode from './ConditionNode.svelte';
|
||||
|
||||
/**
|
||||
* @description 节点类型(en: node type)
|
||||
@@ -23,5 +24,6 @@ export const nodeTypes = {
|
||||
knowledgeNode: KnowledgeNode,
|
||||
searchEngineNode: SearchEngineNode,
|
||||
loopNode: LoopNode,
|
||||
conditionNode: ConditionNode,
|
||||
endNode: EndNode
|
||||
} as any as NodeTypes;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type FloatingOptions = {
|
||||
onShow?: () => void;
|
||||
onHide?: () => void;
|
||||
syncWidth?: boolean;
|
||||
syncWidthMode?: 'min' | 'equal';
|
||||
};
|
||||
|
||||
export type FloatingInstance = {
|
||||
@@ -43,7 +44,8 @@ export const createFloating = ({
|
||||
showArrow,
|
||||
onShow,
|
||||
onHide,
|
||||
syncWidth = false
|
||||
syncWidth = false,
|
||||
syncWidthMode = 'min'
|
||||
}: FloatingOptions): FloatingInstance => {
|
||||
if (typeof trigger === 'string') {
|
||||
const triggerEl = document.querySelector(trigger);
|
||||
@@ -89,9 +91,17 @@ export const createFloating = ({
|
||||
...(showArrow ? [arrow({ element: arrowElement })] : []),
|
||||
...(syncWidth ? [size({
|
||||
apply({ rects, elements }) {
|
||||
if (syncWidthMode === 'equal') {
|
||||
Object.assign(elements.floating.style, {
|
||||
minWidth: `${rects.reference.width}px`,
|
||||
width: `${rects.reference.width}px`,
|
||||
minWidth: `${rects.reference.width}px`
|
||||
});
|
||||
} else {
|
||||
Object.assign(elements.floating.style, {
|
||||
width: '',
|
||||
minWidth: `${rects.reference.width}px`
|
||||
});
|
||||
}
|
||||
}
|
||||
})] : [])
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ export const componentName = 'tinyflow-component';
|
||||
export const nodeIcons: Record<string, string> = {
|
||||
startNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path></svg>',
|
||||
loopNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>',
|
||||
conditionNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4H10V10H4V4ZM14 4H20V10H14V4ZM4 14H10V20H4V14ZM14 14H20V20H14V14ZM10 7H14V9H10V7ZM7 10H9V14H7V10ZM15 10H17V14H15V10Z"></path></svg>',
|
||||
llmNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.7134 7.12811L20.4668 7.69379C20.2864 8.10792 19.7136 8.10792 19.5331 7.69379L19.2866 7.12811C18.8471 6.11947 18.0555 5.31641 17.0677 4.87708L16.308 4.53922C15.8973 4.35653 15.8973 3.75881 16.308 3.57612L17.0252 3.25714C18.0384 2.80651 18.8442 1.97373 19.2761 0.930828L19.5293 0.319534C19.7058 -0.106511 20.2942 -0.106511 20.4706 0.319534L20.7238 0.930828C21.1558 1.97373 21.9616 2.80651 22.9748 3.25714L23.6919 3.57612C24.1027 3.75881 24.1027 4.35653 23.6919 4.53922L22.9323 4.87708C21.9445 5.31641 21.1529 6.11947 20.7134 7.12811ZM9 2C13.0675 2 16.426 5.03562 16.9337 8.96494L19.1842 12.5037C19.3324 12.7367 19.3025 13.0847 18.9593 13.2317L17 14.071V17C17 18.1046 16.1046 19 15 19H13.001L13 22H4L4.00025 18.3061C4.00033 17.1252 3.56351 16.0087 2.7555 15.0011C1.65707 13.6313 1 11.8924 1 10C1 5.58172 4.58172 2 9 2ZM9 4C5.68629 4 3 6.68629 3 10C3 11.3849 3.46818 12.6929 4.31578 13.7499C5.40965 15.114 6.00036 16.6672 6.00025 18.3063L6.00013 20H11.0007L11.0017 17H15V12.7519L16.5497 12.0881L15.0072 9.66262L14.9501 9.22118C14.5665 6.25141 12.0243 4 9 4ZM19.4893 16.9929L21.1535 18.1024C22.32 16.3562 23 14.2576 23 12.0001C23 11.317 22.9378 10.6486 22.8186 10L20.8756 10.5C20.9574 10.9878 21 11.489 21 12.0001C21 13.8471 20.4436 15.5642 19.4893 16.9929Z"></path></svg>',
|
||||
knowledgeNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z"></path></svg>',
|
||||
searchEngineNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z"></path></svg>',
|
||||
|
||||
Reference in New Issue
Block a user