diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index b758a121..1b2ed83c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -75,6 +75,8 @@ public class WebChatController { private final WebChatFileService fileService; private final WebChatTokenRevocationService tokenRevocationService; private final vip.mate.audit.service.AuditEventService auditService; + private final vip.mate.llm.routing.AgentBindingResolver agentBindingResolver; + private final vip.mate.skill.repository.SkillMapper skillMapper; /** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */ static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L; @@ -310,6 +312,68 @@ public class WebChatController { )); } + /** + * 列出访客在当前 channel 上可见的技能清单(供下游集成方实现 "/" slash + * picker UI)。返回的是展示级元数据——id、slug、本地化名、描述、 + * 图标,不暴露 SKILL.md 正文、config、安全扫描结果等内部字段。 + *

+ * 鉴权链跟 {@link #listSessions} 一致:API Key 解析 channel + visitorToken + * HMAC 校验。{@code agentId} 可选,缺省回落到 channel 绑定的 agent; + * 必须属于该 channel 的 workspace(沿用 {@code /stream} 的反越权路径)。 + *

+ * 可见范围 = 显式绑定到该 agent 的 enabled 技能。无显式绑定的 agent + * (意为"用全局默认")返回空清单——visitor 看不到候选,但仍可走自然语言 + * 让 LLM 自行调 {@code load_skill}。 + */ + @Operation(summary = "列出访客可见技能(供 slash picker UI)") + @GetMapping("/skills") + public R> listSkills( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam(required = false) Long agentId, + @RequestParam String visitorId) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + // Resolve the target agent: same anti-escalation rule as /stream — an + // explicit agentId must belong to the channel's workspace. + Long resolvedAgentId = channel.getAgentId(); + if (agentId != null) { + var requested = agentService.getAgent(agentId); + if (requested == null) { + return R.fail(404, "Requested agent not found"); + } + if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null + && !channel.getWorkspaceId().equals(requested.getWorkspaceId())) { + return R.fail(403, "Requested agent does not belong to this channel's workspace"); + } + resolvedAgentId = agentId; + } + if (resolvedAgentId == null) { + return R.ok(List.of()); + } + // Bound-skill IDs is null when the agent has no explicit binding (meaning + // "use global defaults"); treat that as "no candidates surfaced to the + // picker" so the agent config stays the source of truth for visitor UI. + java.util.Set boundIds = agentBindingResolver.getBoundSkillIds(resolvedAgentId); + if (boundIds == null || boundIds.isEmpty()) { + return R.ok(List.of()); + } + List skills = skillMapper.selectBatchIds(boundIds); + return R.ok(skills.stream() + .filter(s -> Boolean.TRUE.equals(s.getEnabled())) + // Stable order: by slug asc, fall back to id for ties (e.g. null slug). + .sorted(java.util.Comparator.comparing( + s -> s.getName() != null ? s.getName() : "", + java.util.Comparator.nullsFirst(String::compareToIgnoreCase))) + .map(WebChatSkillView::from) + .toList()); + } + /** Cap on how many empty (message_count = 0) threads one visitor may hold on a * channel at once. Guards against pathologic clients churning placeholder * sessions without ever sending a message. */ @@ -1385,6 +1449,35 @@ public class WebChatController { private String streamStatus; } + /** + * Display-level view of a skill surfaced to webchat visitors for the slash + * picker UI. Carries only the fields a UI needs to render a row — id (for + * logging / debugging), localised name, description, icon. Deliberately + * omits SKILL.md content, config JSON, scan results and other internal + * columns: those never leave the admin console. + */ + @lombok.Data + public static class WebChatSkillView { + private Long id; + /** Immutable slug the LLM takes as {@code load_skill(name=…)}; this is what the slash picker must splice into the directive text. */ + private String name; + private String nameZh; + private String nameEn; + private String description; + private String icon; + + static WebChatSkillView from(vip.mate.skill.model.SkillEntity s) { + WebChatSkillView v = new WebChatSkillView(); + v.id = s.getId(); + v.name = s.getName(); + v.nameZh = s.getNameZh(); + v.nameEn = s.getNameEn(); + v.description = s.getDescription(); + v.icon = s.getIcon(); + return v; + } + } + /** Body for {@code POST /sessions} — explicitly create an empty thread. */ @lombok.Data public static class WebChatCreateSessionRequest { diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java new file mode 100644 index 00000000..d552c2bf --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSkillListTest.java @@ -0,0 +1,191 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatSkillView; +import vip.mate.common.result.R; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end coverage for {@code GET /api/v1/channels/webchat/skills} — the + * visitor-facing skill catalogue that downstream integrators use to build a + * slash picker UI. Verifies the auth chain (API Key + visitorToken HMAC), the + * agent workspace anti-escalation guard, and the bound+enabled filtering that + * decides which skills surface to a visitor. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_skills_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatSkillListTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_300_001L; + private static final long AGENT_ID = 9_300_011L; + private static final long OTHER_WORKSPACE_AGENT_ID = 9_300_012L; + private static final long SKILL_ENABLED_A = 9_300_101L; + private static final long SKILL_ENABLED_B = 9_300_102L; + private static final long SKILL_DISABLED = 9_300_103L; + + @Autowired private WebChatController controller; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + // Wipe + re-seed. Bindings + channel + agent + skills. + jdbc.update("DELETE FROM mate_agent_skill WHERE agent_id = ?", AGENT_ID); + jdbc.update("DELETE FROM mate_agent_skill WHERE agent_id = ?", OTHER_WORKSPACE_AGENT_ID); + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id IN (?, ?, ?)", + AGENT_ID, OTHER_WORKSPACE_AGENT_ID, SKILL_ENABLED_A); + for (long id : new long[]{SKILL_ENABLED_A, SKILL_ENABLED_B, SKILL_DISABLED}) { + jdbc.update("DELETE FROM mate_skill WHERE id = ?", id); + } + + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-skills-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + // Agent in a different workspace — must not be reachable through this channel. + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-skills-other-ws-agent', 'react', '', 10, TRUE, 999, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + OTHER_WORKSPACE_AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + + // Three skills: two enabled (different slugs / display names), one disabled. + // The bound+enabled filter should keep A + B and drop the disabled one. + jdbc.update("MERGE INTO mate_skill (id, name, name_zh, name_en, description, icon, " + + "skill_type, enabled, builtin, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'beta-skill', 'Beta', 'Beta (EN)', 'B desc', 'b-emoji', " + + "'custom', TRUE, FALSE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + SKILL_ENABLED_B); + jdbc.update("MERGE INTO mate_skill (id, name, name_zh, name_en, description, icon, " + + "skill_type, enabled, builtin, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'alpha-skill', 'Alpha', 'Alpha (EN)', 'A desc', 'a-emoji', " + + "'custom', TRUE, FALSE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + SKILL_ENABLED_A); + jdbc.update("MERGE INTO mate_skill (id, name, name_zh, name_en, description, icon, " + + "skill_type, enabled, builtin, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'disabled-skill', 'Disabled', 'Disabled (EN)', 'D desc', 'd-emoji', " + + "'custom', FALSE, FALSE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + SKILL_DISABLED); + + // Bind all three to AGENT_ID. Binding rows themselves are enabled; the + // SkillEntity.enabled flag is what the controller filters on. + for (long sid : new long[]{SKILL_ENABLED_A, SKILL_ENABLED_B, SKILL_DISABLED}) { + jdbc.update("MERGE INTO mate_agent_skill (id, agent_id, skill_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + sid * 10, AGENT_ID, sid); + } + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + @Test + @DisplayName("happy path: bound + enabled skills surface, sorted by slug; disabled ones dropped") + void listReturnsBoundEnabledSorted() { + R> r = controller.listSkills( + API_KEY, tokenFor("v1"), null, "v1"); + + assertThat(r.getCode()).isEqualTo(200); + List data = r.getData(); + assertThat(data).hasSize(2); + // Sorted by slug: alpha-skill, beta-skill. + assertThat(data.get(0).getName()).isEqualTo("alpha-skill"); + assertThat(data.get(0).getNameZh()).isEqualTo("Alpha"); + assertThat(data.get(0).getDescription()).isEqualTo("A desc"); + assertThat(data.get(1).getName()).isEqualTo("beta-skill"); + // The disabled one must NOT surface. + assertThat(data).noneMatch(s -> "disabled-skill".equals(s.getName())); + } + + @Test + @DisplayName("explicit agentId matching channel workspace works") + void explicitAgentIdSameWorkspace() { + R> r = controller.listSkills( + API_KEY, tokenFor("v2"), AGENT_ID, "v2"); + + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData()).hasSize(2); + } + + @Test + @DisplayName("explicit agentId in a different workspace → 403 (anti-escalation)") + void explicitAgentIdDifferentWorkspace() { + R> r = controller.listSkills( + API_KEY, tokenFor("v3"), OTHER_WORKSPACE_AGENT_ID, "v3"); + + assertThat(r.getCode()).isEqualTo(403); + assertThat(r.getData()).isNull(); + } + + @Test + @DisplayName("invalid API Key → 401") + void invalidApiKey() { + R> r = controller.listSkills( + "garbagekeyxyz12", tokenFor("v4"), null, "v4"); + + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("missing/invalid visitor token → 401") + void invalidVisitorToken() { + R> r = controller.listSkills( + API_KEY, "not-a-valid-token", null, "v5"); + + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("agent with no explicit bindings returns empty (fall-through to natural language)") + void noBindingsReturnsEmpty() { + // Agent with no rows in mate_agent_skill. Use a fresh agent ID that + // doesn't share the seeded bindings. + long lonelyAgent = 9_300_099L; + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-skills-lonely', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + lonelyAgent); + // Repoint the channel to this agent so the default-agent path resolves + // it without needing the explicit agentId parameter. + jdbc.update("UPDATE mate_channel SET agent_id = ? WHERE id = ?", lonelyAgent, CHANNEL_ID); + + R> r = controller.listSkills( + API_KEY, tokenFor("v6"), null, "v6"); + + assertThat(r.getCode()).isEqualTo(200); + // null bound IDs → controller returns empty rather than surfacing every + // enabled skill in the workspace (visitor UI should be agent-scoped). + assertThat(r.getData()).isEmpty(); + } +}