mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
40a33e4ac7
commit
a4f2980240
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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」标签页中编辑。',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user