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();
+ }
+}