mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(webchat): expose agent-bound skill list to API-Key callers
GET /api/v1/channels/webchat/skills?agentId=<optional>&visitorId=<required> Headers: X-MC-Key + X-MC-Visitor-Token Downstream systems integrating via the webchat SSE endpoint have no way today to enumerate the skills a visitor can invoke — the existing GET /api/v1/skills is JWT + workspace-role gated, unreachable from the API-Key-authenticated webchat channel. Without a list, integrators can't render a slash picker UI; visitors have to know skill slugs by heart. The new endpoint mirrors the /stream auth chain (resolveChannel + verifyVisitorToken) and reuses AgentBindingResolver.getBoundSkillIds to scope visibility. Only enabled skills explicitly bound to the agent surface; agents with no explicit bindings return an empty list rather than inheriting the global pool (the agent config stays the source of truth for what surfaces in visitor UI). The agentId anti-escalation guard from /stream is reused verbatim — an explicit agentId must belong to the channel's workspace. Returns WebChatSkillView (id / name / nameZh / nameEn / description / icon). Deliberately omits SKILL.md content, configJson and securityScanResult: those never leave the admin console. Issue: #373
This commit is contained in:
parent
4f160b6ffb
commit
31c98e923d
@ -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)。返回的是<b>展示级元数据</b>——id、slug、本地化名、描述、
|
||||
* 图标,不暴露 SKILL.md 正文、config、安全扫描结果等内部字段。
|
||||
* <p>
|
||||
* 鉴权链跟 {@link #listSessions} 一致:API Key 解析 channel + visitorToken
|
||||
* HMAC 校验。{@code agentId} 可选,缺省回落到 channel 绑定的 agent;
|
||||
* 必须属于该 channel 的 workspace(沿用 {@code /stream} 的反越权路径)。
|
||||
* <p>
|
||||
* 可见范围 = 显式绑定到该 agent 的 enabled 技能。无显式绑定的 agent
|
||||
* (意为"用全局默认")返回空清单——visitor 看不到候选,但仍可走自然语言
|
||||
* 让 LLM 自行调 {@code load_skill}。
|
||||
*/
|
||||
@Operation(summary = "列出访客可见技能(供 slash picker UI)")
|
||||
@GetMapping("/skills")
|
||||
public R<List<WebChatSkillView>> 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<Long> boundIds = agentBindingResolver.getBoundSkillIds(resolvedAgentId);
|
||||
if (boundIds == null || boundIds.isEmpty()) {
|
||||
return R.ok(List.of());
|
||||
}
|
||||
List<vip.mate.skill.model.SkillEntity> 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 {
|
||||
|
||||
@ -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<List<WebChatSkillView>> r = controller.listSkills(
|
||||
API_KEY, tokenFor("v1"), null, "v1");
|
||||
|
||||
assertThat(r.getCode()).isEqualTo(200);
|
||||
List<WebChatSkillView> 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<List<WebChatSkillView>> 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<List<WebChatSkillView>> 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<List<WebChatSkillView>> r = controller.listSkills(
|
||||
"garbagekeyxyz12", tokenFor("v4"), null, "v4");
|
||||
|
||||
assertThat(r.getCode()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("missing/invalid visitor token → 401")
|
||||
void invalidVisitorToken() {
|
||||
R<List<WebChatSkillView>> 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<List<WebChatSkillView>> 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user