From a4f298024003be3aa19f5303fb40e6b90ba5447e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Mon, 8 Jun 2026 23:45:58 +0800 Subject: [PATCH] feat(channel): position webchat as Web/API access with optional agentId & multi-session sessionId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:), shared across that visitor's sessions. All new fields are optional; omitting them reproduces the prior behaviour byte-for-byte. Refs matevip/mateclaw#295. --- .../channel/webchat/WebChatController.java | 54 +++++++++++++++++-- mateclaw-ui/src/i18n/locales/en-US.ts | 6 +-- mateclaw-ui/src/i18n/locales/zh-CN.ts | 6 +-- 3 files changed, 55 insertions(+), 11 deletions(-) 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 0b16ddb3..1627af07 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 @@ -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 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; } } diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 7171fcad..d7c4b8df 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 7db288a5..0169a087 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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」标签页中编辑。',