feat(channel): position webchat as Web/API access with optional agentId & multi-session sessionId

Rename the webchat channel from "embed widget" to "Web / API access" (key
unchanged, docs/i18n only) and extend the backend SSE endpoint for pure
backend integration:

- WebChatRequest gains optional agentId (route one Key to multiple agents;
  rejected unless the agent shares the channel's workspace) and sessionId
  (one visitor, multiple isolated threads).
- sessionId is validated ([A-Za-z0-9_-]{1,64}) and only composed into the
  server-derived conversationId — raw conversationIds are never accepted, so
  the key+visitor namespace still bounds every thread.
- A `meta` SSE event echoes the effective sessionId/conversationId at stream
  start so callers can persist and re-address a thread.
- Memory stays attributed per visitor (api:<visitorId>), shared across that
  visitor's sessions.

All new fields are optional; omitting them reproduces the prior behaviour
byte-for-byte. Refs matevip/mateclaw#295.
This commit is contained in:
倪程伟 2026-06-08 23:45:58 +08:00 committed by matevip
parent 40a33e4ac7
commit a4f2980240
3 changed files with 55 additions and 11 deletions

View File

@ -73,14 +73,46 @@ public class WebChatController {
return emitter;
}
// Resolve the target agent: an explicit request agentId overrides the channel's
// bound agent, but must belong to the channel's workspace (anti privilege-escalation:
// a shared channel Key must not be able to drive arbitrary agents in other workspaces).
Long agentId = channel.getAgentId();
if (request.getAgentId() != null) {
var requested = agentService.getAgent(request.getAgentId());
if (requested == null) {
sendErrorAndComplete(emitter, "Requested agent not found");
return emitter;
}
if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null
&& !channel.getWorkspaceId().equals(requested.getWorkspaceId())) {
sendErrorAndComplete(emitter, "Requested agent does not belong to this channel's workspace");
return emitter;
}
agentId = request.getAgentId();
}
if (agentId == null) {
sendErrorAndComplete(emitter, "No agent configured for this WebChat channel");
return emitter;
}
final Long resolvedAgentId = agentId;
String visitorId = request.getVisitorId() != null ? request.getVisitorId() : UUID.randomUUID().toString();
String conversationId = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":" + visitorId;
// Optional sessionId lets one visitor hold multiple isolated threads. It is only ever
// composed into the server-derived conversationId (kept under the key+visitor namespace),
// never accepted as a raw conversationId so a caller can't reach another tenant's history.
String sessionId = request.getSessionId() != null ? request.getSessionId().trim() : null;
if (sessionId != null && !sessionId.isEmpty() && !sessionId.matches("[A-Za-z0-9_-]{1,64}")) {
sendErrorAndComplete(emitter, "Invalid sessionId (allowed: letters, digits, '-', '_', length 1-64)");
return emitter;
}
if (sessionId != null && sessionId.isEmpty()) {
sessionId = null;
}
final String effectiveSessionId = sessionId;
String conversationId = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":" + visitorId
+ (effectiveSessionId != null ? ":" + effectiveSessionId : "");
String message = request.getMessage() != null ? request.getMessage() : "";
if (message.isBlank()) {
@ -104,9 +136,9 @@ public class WebChatController {
sseExecutor.execute(() -> {
try {
// 创建或获取会话workspace agent 获取
var webAgent = agentService.getAgent(agentId);
var webAgent = agentService.getAgent(resolvedAgentId);
Long webWsId = webAgent != null ? webAgent.getWorkspaceId() : 1L;
var conv = conversationService.getOrCreateConversation(conversationId, agentId, "webchat:" + visitorId, webWsId);
var conv = conversationService.getOrCreateConversation(conversationId, resolvedAgentId, "webchat:" + visitorId, webWsId);
// 保存用户消息
conversationService.saveMessage(conversationId, "user", message, List.of());
@ -115,6 +147,12 @@ public class WebChatController {
streamTracker.register(conversationId);
streamTracker.attach(conversationId, emitter);
// Echo the effective session so the caller can persist it (especially when
// sessionId was omitted) and address the same thread on subsequent calls.
streamTracker.broadcast(conversationId, "meta",
"{\"sessionId\":" + escapeJson(effectiveSessionId)
+ ",\"conversationId\":" + escapeJson(conversationId) + "}");
// Accumulate the assistant reply so it can be persisted on stream completion.
// Pattern mirrors ChatController: always accumulate, only broadcast when the
// delta is not a persistence-only echo of content already streamed by inner nodes.
@ -132,7 +170,7 @@ public class WebChatController {
.withSender(null, "api", null);
String webchatOwnerKey = memoryOwnerResolver.resolve(webchatOrigin);
agentService.chatStructuredStream(agentId, message, conversationId, visitorId, null, webchatOrigin)
agentService.chatStructuredStream(resolvedAgentId, message, conversationId, visitorId, null, webchatOrigin)
.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
@ -165,7 +203,7 @@ public class WebChatController {
"completed", usage[0], usage[1], modelInfo[0], modelInfo[1]);
}
completionPublisher.publish(
agentId, conversationId, message, reply, "webchat", webchatOwnerKey);
resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey);
} catch (Exception persistErr) {
log.warn("[WebChat] Failed to persist assistant reply / publish event: {}",
persistErr.getMessage());
@ -289,5 +327,11 @@ public class WebChatController {
public static class WebChatRequest {
private String message;
private String visitorId;
/** Optional: route this call to a specific agent instead of the channel's bound agent.
* Must belong to the channel's workspace. */
private Long agentId;
/** Optional: open a distinct conversation thread for the same visitor.
* Composed into the server-derived conversationId; never used as a raw conversationId. */
private String sessionId;
}
}

View File

@ -3023,7 +3023,7 @@ export default {
feishu: 'Feishu / Lark app. One-click QR creation.',
dingtalk: 'DingTalk bot. One-click QR creation.',
web: 'Default browser channel. Always on.',
webchat: 'Embeddable chat widget for your site.',
webchat: 'Embed the chat widget, or call the key-authed backend SSE API directly.',
webhook: 'Generic HTTP inbound endpoint.',
},
},
@ -3052,7 +3052,7 @@ export default {
weixin: 'WeChat',
qq: 'QQ',
slack: 'Slack',
webchat: 'WebChat Embed',
webchat: 'Web / API',
webhook: 'Webhook',
},
tabs: {
@ -3094,7 +3094,7 @@ export default {
authFailed: 'Authorization failed',
},
webHint: 'Web channel uses built-in SSE communication, no additional configuration needed.',
webchatHint: 'WebChat embeds the MateClaw chat widget into external websites. Configure an API key, title, and primary color, then load the WebChat SDK on your site.',
webchatHint: 'Web / API access can embed the MateClaw chat widget into external websites, or be called as a pure backend SSE API. With the API key configured, the frontend can load the WebChat SDK; the backend can POST directly to /api/v1/channels/webchat/stream with header X-MC-Key and body {message, visitorId, optional sessionId/agentId}.',
webchatApiKeyGenerated: 'The platform will generate the API key after save. Reopen this channel to copy it.',
webchatApiKeyReadOnly: 'This API key is generated and managed by the platform. It can be copied, but not edited manually.',
webhookHint: 'Webhook channel configuration should be edited in the "Raw JSON" tab below.',

View File

@ -3123,7 +3123,7 @@ export default {
feishu: '飞书 / Lark 应用,扫码一键创建',
dingtalk: '钉钉机器人,扫码一键创建',
web: '默认浏览器渠道,始终可用',
webchat: '可嵌入网页的聊天小组件',
webchat: '可嵌网页挂件,也可作为带 Key 的后端 SSE API 直接调用',
webhook: '通用 HTTP 接入端点',
},
},
@ -3152,7 +3152,7 @@ export default {
weixin: '微信',
qq: 'QQ',
slack: 'Slack',
webchat: '网页嵌入',
webchat: 'Web / API 接入',
webhook: 'Webhook',
},
tabs: {
@ -3194,7 +3194,7 @@ export default {
authFailed: '授权失败',
},
webHint: 'Web 渠道使用内置 SSE 通信,无需额外配置。',
webchatHint: 'WebChat 用于把 MateClaw 聊天挂件嵌入外部网站。请配置 API Key、标题和主题色然后在网站中引入 WebChat SDK。',
webchatHint: 'Web / API 接入既可把 MateClaw 聊天挂件嵌入外部网站,也可作为纯后端 SSE 接口直接调用。配置 API Key 后,前端可引入 WebChat SDK后端可直接 POST /api/v1/channels/webchat/stream头带 X-MC-Key、体传 {message, visitorId, 可选 sessionId/agentId}。',
webchatApiKeyGenerated: '保存后平台会自动生成 API Key。创建完成后返回此页面即可复制。',
webchatApiKeyReadOnly: '该 API Key 由平台自动生成并托管,你只能复制,不能手动修改。',
webhookHint: 'Webhook 渠道配置请在下方「原始 JSON」标签页中编辑。',