From e62f823140410a6a6504c63bbff75fd381eb0922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=80=E4=B8=83?= Date: Tue, 2 Jun 2026 14:56:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E3=80=81cron=20=E6=A0=BC=E5=BC=8F=E9=97=AE=E9=A2=98=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20Skill=20=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: SubscribeTriggerCodeactTool cron 示例从5位改为6位,匹配 Spring CronTrigger 格式 (#53) - fix: BaseReplyCodeactTool 新增 channel 等上下文字段注入到 extensions - feat: Skill 导入支持冲突策略(覆盖/跳过/重命名) - feat: Skill 导入返回 SkillPackageImportResult 支持逐文件结果 - feat: SkillExchangeService 支持按 skillGroupId 查询 - feat: 经验控制台前端增加 skillGroupId 编辑和 skill 名称链接 - test: 新增 BaseReplyCodeactToolTest Co-Authored-By: Claude Opus 4.6 --- assistant-agent-autoconfigure/pom.xml | 2 +- assistant-agent-common/pom.xml | 2 +- assistant-agent-core/pom.xml | 2 +- assistant-agent-evaluation/pom.xml | 2 +- assistant-agent-extensions/pom.xml | 2 +- .../experience/hook/FastIntentReactHook.java | 4 +- .../reply/tools/BaseReplyCodeactTool.java | 23 ++++- .../tools/SubscribeTriggerCodeactTool.java | 8 +- .../reply/tools/BaseReplyCodeactToolTest.java | 84 +++++++++++++++++++ assistant-agent-management/pom.xml | 2 +- .../controller/SkillExchangeController.java | 12 ++- .../InMemorySkillExchangeService.java | 59 ++++++++++++- .../model/SkillImportConflictStrategy.java | 20 +++++ .../model/SkillPackageImportResult.java | 60 +++++++++++++ .../management/spi/SkillExchangeService.java | 18 +++- .../main/resources/static/exp-console/app.js | 58 +++++++++++-- .../InMemorySkillExchangeServiceTest.java | 66 +++++++++++++++ assistant-agent-prompt-builder/pom.xml | 2 +- assistant-agent-start/pom.xml | 2 +- pom.xml | 2 +- 20 files changed, 397 insertions(+), 33 deletions(-) create mode 100644 assistant-agent-extensions/src/test/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactToolTest.java create mode 100644 assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillImportConflictStrategy.java diff --git a/assistant-agent-autoconfigure/pom.xml b/assistant-agent-autoconfigure/pom.xml index a4b817f5..e8f278c7 100644 --- a/assistant-agent-autoconfigure/pom.xml +++ b/assistant-agent-autoconfigure/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.6 + 0.2.7 assistant-agent-autoconfigure diff --git a/assistant-agent-common/pom.xml b/assistant-agent-common/pom.xml index d3adf286..a09c6fcc 100644 --- a/assistant-agent-common/pom.xml +++ b/assistant-agent-common/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.6 + 0.2.7 assistant-agent-common diff --git a/assistant-agent-core/pom.xml b/assistant-agent-core/pom.xml index f650f713..3e852ee7 100644 --- a/assistant-agent-core/pom.xml +++ b/assistant-agent-core/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.6 + 0.2.7 assistant-agent-core diff --git a/assistant-agent-evaluation/pom.xml b/assistant-agent-evaluation/pom.xml index 670dc282..55833b5c 100644 --- a/assistant-agent-evaluation/pom.xml +++ b/assistant-agent-evaluation/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.6 + 0.2.7 assistant-agent-evaluation diff --git a/assistant-agent-extensions/pom.xml b/assistant-agent-extensions/pom.xml index 54e43525..3d491b3e 100644 --- a/assistant-agent-extensions/pom.xml +++ b/assistant-agent-extensions/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.6 + 0.2.7 assistant-agent-extensions diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/hook/FastIntentReactHook.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/hook/FastIntentReactHook.java index feb69272..564b9835 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/hook/FastIntentReactHook.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/experience/hook/FastIntentReactHook.java @@ -244,11 +244,11 @@ private ExperienceQueryContext buildQueryContext(OverAllState state, RunnableCon } if (state != null) { state.value("tenant_id", String.class).ifPresent(context::setTenantId); - state.value("user_id", String.class).ifPresent(context::setTenantId); + state.value("user_id", String.class).ifPresent(context::setUserId); } if (config != null) { config.metadata("tenant_id").ifPresent(id -> context.setTenantId(String.valueOf(id))); - config.metadata("user_id").ifPresent(id -> context.setTenantId(String.valueOf(id))); + config.metadata("user_id").ifPresent(id -> context.setUserId(String.valueOf(id))); } return context; } diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactTool.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactTool.java index 11fc38c2..38208ae1 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactTool.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactTool.java @@ -51,6 +51,10 @@ public class BaseReplyCodeactTool implements ReplyCodeactTool { private static final Logger log = LoggerFactory.getLogger(BaseReplyCodeactTool.class); + private static final String USER_ID_METADATA_KEY = "user_id"; + private static final String TRACE_ID_METADATA_KEY = "trace_id"; + private static final String CHANNEL_TYPE_METADATA_KEY = "channel_type"; + private static final String CHANNEL_ID_METADATA_KEY = "channel_id"; private final String toolName; @@ -480,12 +484,23 @@ private ChannelExecutionContext buildExecutionContext(ToolContext toolContext) { log.warn("BaseReplyCodeactTool#buildExecutionContext - reason=未能获取sessionId(threadId), toolName={}", toolName); } - // 从 metadata 获取 userId 和 traceId - ToolContextHelper.getFromMetadata(toolContext, "user_id").ifPresent(builder::userId); - ToolContextHelper.getFromMetadata(toolContext, "trace_id").ifPresent(builder::traceId); + // 从 metadata 获取基础上下文字段 + ToolContextHelper.getFromMetadata(toolContext, USER_ID_METADATA_KEY).ifPresent(builder::userId); + ToolContextHelper.getFromMetadata(toolContext, TRACE_ID_METADATA_KEY).ifPresent(builder::traceId); + + // 将 channel 相关信息注入 extensions,供 reply 渠道实现自行读取。 + copyMetadataToExtension(toolContext, builder, CHANNEL_TYPE_METADATA_KEY); + copyMetadataToExtension(toolContext, builder, CHANNEL_ID_METADATA_KEY); return builder.build(); } -} + private void copyMetadataToExtension(ToolContext toolContext, ChannelExecutionContext.Builder builder, String key) { + ToolContextHelper.getFromMetadata(toolContext, key).ifPresent(value -> { + builder.extension(key, value); + log.debug("BaseReplyCodeactTool#buildExecutionContext - reason=注入metadata到extensions, toolName={}, key={}", + toolName, key); + }); + } +} diff --git a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/trigger/tools/SubscribeTriggerCodeactTool.java b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/trigger/tools/SubscribeTriggerCodeactTool.java index 5325838c..6c93bea3 100644 --- a/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/trigger/tools/SubscribeTriggerCodeactTool.java +++ b/assistant-agent-extensions/src/main/java/com/alibaba/assistant/agent/extension/trigger/tools/SubscribeTriggerCodeactTool.java @@ -172,7 +172,7 @@ private ToolDefinition buildToolDefinition() { }, "schedule_value": { "type": "string", - "description": "调度值:CRON模式为cron表达式;FIXED_DELAY/FIXED_RATE模式为毫秒数(如3分钟=180000);ONE_TIME模式为ISO时间戳" + "description": "调度值:CRON模式为6位cron表达式(秒 分 时 日 月 周,例如每天凌晨2点为 0 0 2 * * *);FIXED_DELAY/FIXED_RATE模式为毫秒数(如3分钟=180000);ONE_TIME模式为ISO时间戳" }, "condition_function": { "type": "string", @@ -225,7 +225,7 @@ private CodeactToolDefinition buildCodeactDefinition() { .addParameter(ParameterNode.builder() .name("schedule_value") .type(ParameterType.STRING) - .description("调度值:CRON模式为cron表达式;FIXED_DELAY/FIXED_RATE模式为毫秒数(如3分钟=180000);ONE_TIME模式为ISO时间戳") + .description("调度值:CRON模式为6位cron表达式(秒 分 时 日 月 周,例如每天凌晨2点为 0 0 2 * * *);FIXED_DELAY/FIXED_RATE模式为毫秒数(如3分钟=180000);ONE_TIME模式为ISO时间戳") .required(true) .build()) .addParameter(ParameterNode.builder() @@ -267,11 +267,11 @@ private CodeactToolMetadata buildCodeactMetadata() { result = trigger_tools.subscribe_trigger( name="daily_task", schedule_mode="CRON", - schedule_value="0 0 * * *", + schedule_value="0 0 0 * * *", execute_function="print('Daily execution')" ) print(f"Trigger ID: {result['trigger_id']}") - """, "创建一个每天午夜执行的触发器,并打印触发器 ID")) + """, "创建一个每天午夜执行的触发器(cron格式为6位:秒 分 时 日 月 周),并打印触发器 ID")) .addFewShot(new CodeExample("创建一个3分钟后提醒的触发器", """ # 3分钟 = 3 * 60 * 1000 = 180000毫秒 delay_ms = 3 * 60 * 1000 # 分钟 * 秒 * 毫秒 diff --git a/assistant-agent-extensions/src/test/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactToolTest.java b/assistant-agent-extensions/src/test/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactToolTest.java new file mode 100644 index 00000000..4a12f559 --- /dev/null +++ b/assistant-agent-extensions/src/test/java/com/alibaba/assistant/agent/extension/reply/tools/BaseReplyCodeactToolTest.java @@ -0,0 +1,84 @@ +package com.alibaba.assistant.agent.extension.reply.tools; + +import com.alibaba.assistant.agent.common.tools.ReplyCodeactTool; +import com.alibaba.assistant.agent.extension.reply.config.ReplyToolConfig; +import com.alibaba.assistant.agent.extension.reply.model.ChannelExecutionContext; +import com.alibaba.assistant.agent.extension.reply.model.ParameterSchema; +import com.alibaba.assistant.agent.extension.reply.model.ReplyResult; +import com.alibaba.assistant.agent.extension.reply.spi.ReplyChannelDefinition; +import com.alibaba.cloud.ai.graph.RunnableConfig; +import com.alibaba.cloud.ai.graph.agent.tools.ToolContextConstants; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class BaseReplyCodeactToolTest { + + @Test + void shouldCopyChannelMetadataIntoExecutionContextExtensions() { + AtomicReference capturedContext = new AtomicReference<>(); + BaseReplyCodeactTool tool = new BaseReplyCodeactTool( + "send_dingtalk_card", + "Send a DingTalk card", + new CapturingReplyChannel(capturedContext), + new ReplyToolConfig(), + null, + ReplyCodeactTool.ReplyChannelType.PRIMARY); + + RunnableConfig runnableConfig = RunnableConfig.builder() + .threadId("session_123") + .addMetadata("user_id", "user_456") + .addMetadata("trace_id", "trace_789") + .addMetadata("channel_type", "DINGTALK_GROUP") + .addMetadata("channel_id", "cid_001") + .build(); + Map contextMap = new HashMap<>(); + contextMap.put(ToolContextConstants.AGENT_CONFIG_CONTEXT_KEY, runnableConfig); + + tool.call("{}", new ToolContext(contextMap)); + + ChannelExecutionContext context = capturedContext.get(); + assertNotNull(context); + assertEquals("session_123", context.getSessionId()); + assertEquals("user_456", context.getUserId()); + assertEquals("trace_789", context.getTraceId()); + assertEquals("DINGTALK_GROUP", context.getExtension("channel_type")); + assertEquals("cid_001", context.getExtension("channel_id")); + } + + private static final class CapturingReplyChannel implements ReplyChannelDefinition { + + private final AtomicReference capturedContext; + + private CapturingReplyChannel(AtomicReference capturedContext) { + this.capturedContext = capturedContext; + } + + @Override + public String getChannelCode() { + return "DINGTALK"; + } + + @Override + public String getDescription() { + return "test"; + } + + @Override + public ParameterSchema getSupportedParameters() { + return null; + } + + @Override + public ReplyResult execute(ChannelExecutionContext context, Map params) { + capturedContext.set(context); + return ReplyResult.success("ok"); + } + } +} diff --git a/assistant-agent-management/pom.xml b/assistant-agent-management/pom.xml index cc6099c8..1aec1721 100644 --- a/assistant-agent-management/pom.xml +++ b/assistant-agent-management/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.6 + 0.2.7 assistant-agent-management diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/SkillExchangeController.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/SkillExchangeController.java index cb926340..13e614e0 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/SkillExchangeController.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/controller/SkillExchangeController.java @@ -3,6 +3,7 @@ import com.alibaba.assistant.agent.extension.experience.model.ExperienceType; import com.alibaba.assistant.agent.management.internal.SkillPackageParser; import com.alibaba.assistant.agent.management.model.ExperienceVO; +import com.alibaba.assistant.agent.management.model.SkillImportConflictStrategy; import com.alibaba.assistant.agent.management.model.SkillPackage; import com.alibaba.assistant.agent.management.model.SkillPackageImportResult; import com.alibaba.assistant.agent.management.spi.SkillExchangeService; @@ -51,10 +52,15 @@ public ResponseEntity preview(@RequestBody Map bod } @PostMapping("/import-package") - public ResponseEntity importPackage(@RequestParam("file") MultipartFile file) throws IOException { + public ResponseEntity importPackage( + @RequestParam("file") MultipartFile file, + @RequestParam(name = "conflictStrategy", required = false) SkillImportConflictStrategy conflictStrategy) throws IOException { SkillPackage pkg = packageParser.parseAuto(file.getInputStream()); - SkillPackageImportResult result = service.importSkillPackage(pkg); - return ResponseEntity.status(HttpStatus.CREATED).body(result); + SkillPackageImportResult result = service.importSkillPackage(pkg, conflictStrategy); + // 检测到同名冲突且未指定策略时返回 200(含 conflict 字段);正常导入返回 201 + HttpStatus status = result.hasConflict() && result.getImportedId() == null + ? HttpStatus.OK : HttpStatus.CREATED; + return ResponseEntity.status(status).body(result); } @PostMapping("/preview-package") diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeService.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeService.java index 877b0306..412d0797 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeService.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeService.java @@ -9,6 +9,7 @@ import com.alibaba.assistant.agent.extension.experience.model.ReferenceEntry; import com.alibaba.assistant.agent.extension.experience.spi.ExperienceRepository; import com.alibaba.assistant.agent.management.model.ExperienceVO; +import com.alibaba.assistant.agent.management.model.SkillImportConflictStrategy; import com.alibaba.assistant.agent.management.model.SkillPackage; import com.alibaba.assistant.agent.management.model.SkillPackageImportResult; import com.alibaba.assistant.agent.management.spi.ReferenceSummarizer; @@ -239,7 +240,13 @@ public ExperienceVO previewSkillImport(String skillMarkdown) { @Override public SkillPackageImportResult importSkillPackage(SkillPackage skillPackage) { - PreparedSkillImport prepared = prepareSkillPackageImport(skillPackage); + return importSkillPackage(skillPackage, null); + } + + @Override + public SkillPackageImportResult importSkillPackage(SkillPackage skillPackage, + SkillImportConflictStrategy conflictStrategy) { + PreparedSkillImport prepared = prepareSkillPackageImport(skillPackage, conflictStrategy); if (prepared.reactExperience == null) { return prepared.result; } @@ -259,7 +266,8 @@ public SkillPackageImportResult importSkillPackage(SkillPackage skillPackage) { @Override public SkillPackageImportResult previewSkillPackageImport(SkillPackage skillPackage) { - PreparedSkillImport prepared = prepareSkillPackageImport(skillPackage); + // 预览阶段以 KEEP_BOTH 策略生成预览(不实际落库),冲突信息由 prepareSkillPackageImport 回填 + PreparedSkillImport prepared = prepareSkillPackageImport(skillPackage, SkillImportConflictStrategy.KEEP_BOTH); if (prepared.reactExperience == null) { return prepared.result; } @@ -271,7 +279,8 @@ public SkillPackageImportResult previewSkillPackageImport(SkillPackage skillPack return prepared.result; } - private PreparedSkillImport prepareSkillPackageImport(SkillPackage skillPackage) { + private PreparedSkillImport prepareSkillPackageImport(SkillPackage skillPackage, + SkillImportConflictStrategy conflictStrategy) { SkillPackageImportResult result = new SkillPackageImportResult(); if (!skillPackage.hasSkillMd()) { result.addWarning("No SKILL.md found in package"); @@ -279,11 +288,44 @@ private PreparedSkillImport prepareSkillPackageImport(SkillPackage skillPackage) } Experience existingReact = findExistingReactExperienceByPackage(skillPackage).orElse(null); - Map existingDescriptionsByHash = buildDescriptionCache(existingReact); + // 提前解析 frontmatter 中的 name,回退用 react 经验名做同名查找 Experience reactExperience = parseSkillMarkdown(skillPackage.getSkillMdContent()); enrichExperienceFromPackage(reactExperience, skillPackage); + if (existingReact == null) { + existingReact = findExistingReactExperienceByName(reactExperience.getName()).orElse(null); + } + + // 同名冲突时始终回传 conflict 信息;仅当调用方未指定策略时才跳过落库 + if (existingReact != null) { + String existingToolId = null; + List related = existingReact.getRelatedExperiences(); + if (related != null) { + for (String relatedId : related) { + if (relatedId != null && relatedId.startsWith("cli:")) { + existingToolId = relatedId; + break; + } + } + } + result.setConflict(new SkillPackageImportResult.ConflictInfo( + existingReact.getId(), existingReact.getName(), existingToolId)); + if (conflictStrategy == null) { + return new PreparedSkillImport(result, null, null); + } + } + + Map existingDescriptionsByHash = buildDescriptionCache(existingReact); + + // REPLACE 策略:复用已存在经验的 ID,使 repository.save 走更新路径 + if (existingReact != null && conflictStrategy == SkillImportConflictStrategy.REPLACE) { + reactExperience.setId(existingReact.getId()); + if (existingReact.getCreatedAt() != null) { + reactExperience.setCreatedAt(existingReact.getCreatedAt()); + } + } + List references = new ArrayList<>(); List assets = new ArrayList<>(); buildReferencesAndAssets(skillPackage, references, assets, existingDescriptionsByHash); @@ -319,6 +361,15 @@ private Optional findExistingReactExperienceByPackage(SkillPackage s .findFirst(); } + private Optional findExistingReactExperienceByName(String name) { + if (name == null || name.isBlank()) { + return Optional.empty(); + } + return repository.findAllByType(ExperienceType.REACT).stream() + .filter(e -> name.equalsIgnoreCase(e.getName())) + .findFirst(); + } + private Map buildDescriptionCache(Experience existing) { Map cache = new HashMap<>(); if (existing == null) { diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillImportConflictStrategy.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillImportConflictStrategy.java new file mode 100644 index 00000000..a6a72423 --- /dev/null +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillImportConflictStrategy.java @@ -0,0 +1,20 @@ +package com.alibaba.assistant.agent.management.model; + +/** + * 同名 skill 导入时的冲突处理策略。 + * + *
    + *
  • {@link #REPLACE}:复用已存在经验的 ID,原地更新内容、references、assets、关联工具等
  • + *
  • {@link #KEEP_BOTH}:忽略已存在经验,生成全新 ID 写入
  • + *
+ * + *

当未指定策略(参数为 {@code null})时,导入流程在检测到同名 REACT 经验后将不落库, + * 而是把已存在经验信息回填到 {@code SkillPackageImportResult.conflict} 中,由调用方 + * (管理后台前端)提示用户选择处理方式。 + */ +public enum SkillImportConflictStrategy { + + REPLACE, + + KEEP_BOTH +} diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillPackageImportResult.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillPackageImportResult.java index 100b370f..f2e2a81d 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillPackageImportResult.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/model/SkillPackageImportResult.java @@ -59,9 +59,57 @@ public class SkillPackageImportResult { */ private List warnings = new ArrayList<>(); + /** + * 同名冲突信息。当导入时检测到已有同名 REACT skill 且调用方未指定冲突策略时设置。 + * 此时 {@link #importedId} 为空,前端应根据该字段提示用户选择「替换」或「保留两者」。 + */ + private ConflictInfo conflict; + public SkillPackageImportResult() { } + /** + * 同名冲突详情 + */ + public static class ConflictInfo { + private String existingId; + private String existingName; + private String existingToolId; + + public ConflictInfo() { + } + + public ConflictInfo(String existingId, String existingName, String existingToolId) { + this.existingId = existingId; + this.existingName = existingName; + this.existingToolId = existingToolId; + } + + public String getExistingId() { + return existingId; + } + + public void setExistingId(String existingId) { + this.existingId = existingId; + } + + public String getExistingName() { + return existingName; + } + + public void setExistingName(String existingName) { + this.existingName = existingName; + } + + public String getExistingToolId() { + return existingToolId; + } + + public void setExistingToolId(String existingToolId) { + this.existingToolId = existingToolId; + } + } + /** * 被跳过的文件信息 */ @@ -187,4 +235,16 @@ public boolean hasSkippedFiles() { public boolean hasWarnings() { return warnings != null && !warnings.isEmpty(); } + + public ConflictInfo getConflict() { + return conflict; + } + + public void setConflict(ConflictInfo conflict) { + this.conflict = conflict; + } + + public boolean hasConflict() { + return conflict != null; + } } diff --git a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/SkillExchangeService.java b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/SkillExchangeService.java index b92bebf7..f5cc762a 100644 --- a/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/SkillExchangeService.java +++ b/assistant-agent-management/src/main/java/com/alibaba/assistant/agent/management/spi/SkillExchangeService.java @@ -2,6 +2,7 @@ import com.alibaba.assistant.agent.extension.experience.model.ExperienceType; import com.alibaba.assistant.agent.management.model.ExperienceVO; +import com.alibaba.assistant.agent.management.model.SkillImportConflictStrategy; import com.alibaba.assistant.agent.management.model.SkillPackage; import com.alibaba.assistant.agent.management.model.SkillPackageImportResult; @@ -40,7 +41,22 @@ public interface SkillExchangeService { * @return 导入结果,包含处理/跳过的文件明细 */ default SkillPackageImportResult importSkillPackage(SkillPackage skillPackage) { - // 默认实现:退化为仅导入 SKILL.md 文本 + return importSkillPackage(skillPackage, null); + } + + /** + * 导入 skill 包,并指定同名冲突的处理策略。 + * + *

策略为 {@code null} 时,若检测到已存在同名 REACT skill,则不落库, + * 在结果的 {@code conflict} 字段中回传已存在经验信息,由调用方提示用户决策。 + * + * @param skillPackage 解析后的 skill 包 + * @param conflictStrategy 冲突策略;可为 {@code null} + * @return 导入结果 + */ + default SkillPackageImportResult importSkillPackage(SkillPackage skillPackage, + SkillImportConflictStrategy conflictStrategy) { + // 默认实现:退化为仅导入 SKILL.md 文本(不区分冲突策略) SkillPackageImportResult result = new SkillPackageImportResult(); if (skillPackage.hasSkillMd()) { String id = importSkill(skillPackage.getSkillMdContent()); diff --git a/assistant-agent-management/src/main/resources/static/exp-console/app.js b/assistant-agent-management/src/main/resources/static/exp-console/app.js index 15a5ca45..2416a73d 100644 --- a/assistant-agent-management/src/main/resources/static/exp-console/app.js +++ b/assistant-agent-management/src/main/resources/static/exp-console/app.js @@ -141,6 +141,10 @@ const ExpConsole = (() => { toastSourceSyncFailed: 'Sync failed: {message}', toastSourceSyncPartial: 'Sync finished with {count} issue(s).', toastSkillImported: 'SKILL imported successfully.', + skillConflictTitle: 'Duplicate skill detected', + skillConflictMessage: 'A skill named "{name}" already exists. Replace it or keep both?', + skillConflictReplace: 'Replace existing', + skillConflictKeepBoth: 'Keep both', toastCopied: 'Copied to clipboard.', toastNameRequired: 'Name is required.', toastContentRequired: 'Content is required.', @@ -275,6 +279,10 @@ const ExpConsole = (() => { toastSourceSyncFailed: '同步失败: {message}', toastSourceSyncPartial: '同步完成,但有 {count} 个问题。', toastSkillImported: 'SKILL 导入成功。', + skillConflictTitle: '检测到同名 Skill', + skillConflictMessage: '已存在同名 Skill「{name}」,请选择处理方式:替换旧的还是保留两者?', + skillConflictReplace: '替换旧的', + skillConflictKeepBoth: '保留两者', toastCopied: '已复制到剪贴板。', toastNameRequired: '名称不能为空。', toastContentRequired: '内容不能为空。', @@ -519,10 +527,11 @@ const ExpConsole = (() => { return apiFetch('/skills/preview', { method: 'POST', body: { content } }); }, - importSkillPackage(file) { + importSkillPackage(file, conflictStrategy) { const formData = new FormData(); formData.append('file', file); - return apiFetchRaw('/skills/import-package', { method: 'POST', body: formData }); + const qs = conflictStrategy ? ('?conflictStrategy=' + encodeURIComponent(conflictStrategy)) : ''; + return apiFetchRaw('/skills/import-package' + qs, { method: 'POST', body: formData }); }, previewSkillPackage(file) { @@ -1821,6 +1830,37 @@ const ExpConsole = (() => { openModal(t('importExperienceModal'), false); } + function promptSkillConflict(existingName) { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;z-index:10000;'; + const safeName = escapeHtml(existingName || ''); + overlay.innerHTML = ` +

+ `; + const cleanup = (val) => { + document.body.removeChild(overlay); + resolve(val); + }; + overlay.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-choice]'); + if (!btn) return; + const choice = btn.dataset.choice; + cleanup(choice === 'cancel' ? null : choice); + }); + document.body.appendChild(overlay); + }); + } + async function submitSkillImport() { if (currentImportMode === 'upload') { if (!selectedFile) { @@ -1828,16 +1868,22 @@ const ExpConsole = (() => { return; } try { - // Directly call /import-package — preserves references/assets and creates - // tool experience alongside react experience. - const result = await api.importSkillPackage(selectedFile); + // First attempt: no strategy. Backend may report a duplicate-name conflict. + let result = await api.importSkillPackage(selectedFile); + if (result && result.conflict && !result.importedId) { + const choice = await promptSkillConflict(result.conflict.existingName); + if (!choice) { + return; // user cancelled — keep modal open + } + result = await api.importSkillPackage(selectedFile, choice); + } renderImportResult(result); if (result.warnings && result.warnings.length) { showToast(result.warnings.join('; '), 'error'); } showToast(t('toastImportSuccess') || 'Import successful', 'success'); closeSkillImport(); - // Refresh list + open detail drawer on the newly created react experience + // Refresh list + open detail drawer on the newly created/updated react experience if (result.importedId) { await loadExperiences(); setTimeout(() => { openEditModal(result.importedId); }, 200); diff --git a/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeServiceTest.java b/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeServiceTest.java index f93de53b..84f2dbe4 100644 --- a/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeServiceTest.java +++ b/assistant-agent-management/src/test/java/com/alibaba/assistant/agent/management/internal/InMemorySkillExchangeServiceTest.java @@ -3,6 +3,7 @@ import com.alibaba.assistant.agent.extension.experience.internal.InMemoryExperienceRepository; import com.alibaba.assistant.agent.extension.experience.model.Experience; import com.alibaba.assistant.agent.extension.experience.model.ExperienceType; +import com.alibaba.assistant.agent.management.model.SkillImportConflictStrategy; import com.alibaba.assistant.agent.management.model.SkillPackage; import com.alibaba.assistant.agent.management.model.SkillPackageImportResult; import org.junit.jupiter.api.Test; @@ -12,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class InMemorySkillExchangeServiceTest { @@ -113,4 +115,68 @@ void shouldImportReactAndToolExperiencesForCliBoundSkill() { assertNotNull(toolExperiences.get(0).getReferences()); assertNotNull(toolExperiences.get(0).getAssets()); } + + @Test + void shouldReportConflictWhenSameNameSkillExistsAndStrategyIsNull() { + InMemoryExperienceRepository repository = new InMemoryExperienceRepository(); + InMemorySkillExchangeService service = new InMemorySkillExchangeService(repository); + + SkillPackage pkg = newSimpleSkillPackage(); + SkillPackageImportResult first = service.importSkillPackage(pkg); + assertNotNull(first.getImportedId()); + + SkillPackageImportResult second = service.importSkillPackage(newSimpleSkillPackage()); + assertNull(second.getImportedId(), "duplicate import should not save"); + assertTrue(second.hasConflict()); + assertEquals(first.getImportedId(), second.getConflict().getExistingId()); + assertEquals(1, repository.findAllByType(ExperienceType.REACT).size(), + "no new react experience should be persisted on conflict"); + } + + @Test + void shouldReplaceExistingReactExperienceWhenStrategyIsReplace() { + InMemoryExperienceRepository repository = new InMemoryExperienceRepository(); + InMemorySkillExchangeService service = new InMemorySkillExchangeService(repository); + + SkillPackageImportResult first = service.importSkillPackage(newSimpleSkillPackage()); + String originalId = first.getImportedId(); + assertNotNull(originalId); + + SkillPackageImportResult second = service.importSkillPackage( + newSimpleSkillPackage(), SkillImportConflictStrategy.REPLACE); + assertEquals(originalId, second.getImportedId(), "replace should reuse existing id"); + assertEquals(1, repository.findAllByType(ExperienceType.REACT).size()); + } + + @Test + void shouldKeepBothWhenStrategyIsKeepBoth() { + InMemoryExperienceRepository repository = new InMemoryExperienceRepository(); + InMemorySkillExchangeService service = new InMemorySkillExchangeService(repository); + + SkillPackageImportResult first = service.importSkillPackage(newSimpleSkillPackage()); + String originalId = first.getImportedId(); + assertNotNull(originalId); + + SkillPackageImportResult second = service.importSkillPackage( + newSimpleSkillPackage(), SkillImportConflictStrategy.KEEP_BOTH); + assertNotNull(second.getImportedId()); + assertEquals(2, repository.findAllByType(ExperienceType.REACT).size()); + } + + private static SkillPackage newSimpleSkillPackage() { + SkillPackage pkg = new SkillPackage(); + pkg.setSkillMdContent(""" + --- + name: Demo Skill + description: a demo skill + --- + + # Demo Skill + + body + """); + pkg.setName("demo-skill"); + pkg.setVersion("0.0.1"); + return pkg; + } } diff --git a/assistant-agent-prompt-builder/pom.xml b/assistant-agent-prompt-builder/pom.xml index 3e02067f..c75e8fa5 100644 --- a/assistant-agent-prompt-builder/pom.xml +++ b/assistant-agent-prompt-builder/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.6 + 0.2.7 assistant-agent-prompt-builder diff --git a/assistant-agent-start/pom.xml b/assistant-agent-start/pom.xml index 277f7b16..2a3d7836 100644 --- a/assistant-agent-start/pom.xml +++ b/assistant-agent-start/pom.xml @@ -6,7 +6,7 @@ com.alibaba.agent.assistant assistant-agent - 0.2.6 + 0.2.7 assistant-agent-start diff --git a/pom.xml b/pom.xml index 7fbd30e5..c5db2f4a 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.alibaba.agent.assistant assistant-agent - 0.2.6 + 0.2.7 pom