From a219f92410317516244f47024dcd6e1364eae3ba Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 9 Apr 2026 21:28:47 +0800 Subject: [PATCH] feat: productize webchat channel config --- .../mate/channel/service/ChannelService.java | 72 +++++++++++++++++++ .../channel/webchat/WebChatController.java | 40 ++++++++++- mateclaw-ui/public/icons/channels/webchat.svg | 5 ++ mateclaw-ui/src/i18n/locales/en-US.ts | 4 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 4 ++ mateclaw-ui/src/types/index.ts | 9 +++ mateclaw-ui/src/views/Channels.vue | 39 +++++++++- .../views/Settings/Models/ProviderCard.vue | 50 +++++++++++-- .../Models/modals/ManageModelsModal.vue | 51 +++++++++++-- 9 files changed, 256 insertions(+), 18 deletions(-) create mode 100644 mateclaw-ui/public/icons/channels/webchat.svg diff --git a/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java b/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java index 17aa46f2..8769d17d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java @@ -1,6 +1,8 @@ package vip.mate.channel.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -8,7 +10,10 @@ import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.repository.ChannelMapper; import vip.mate.exception.MateClawException; +import java.security.SecureRandom; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * 渠道业务服务 @@ -24,6 +29,8 @@ import java.util.List; public class ChannelService { private final ChannelMapper channelMapper; + private final ObjectMapper objectMapper; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); /** * 获取所有渠道列表 @@ -97,6 +104,9 @@ public class ChannelService { if (channel.getEnabled() == null) { channel.setEnabled(false); } + if ("webchat".equals(channel.getChannelType())) { + channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), null)); + } channelMapper.insert(channel); log.info("Created channel: {} (type={})", channel.getName(), channel.getChannelType()); return channel; @@ -107,6 +117,9 @@ public class ChannelService { */ public ChannelEntity updateChannel(ChannelEntity channel) { ChannelEntity existing = getChannel(channel.getId()); + if ("webchat".equals(channel.getChannelType())) { + channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), existing.getConfigJson())); + } channelMapper.updateById(channel); log.info("Updated channel: {}", existing.getName()); return channel; @@ -131,4 +144,63 @@ public class ChannelService { log.info("Channel {} {}", channel.getName(), enabled ? "enabled" : "disabled"); return channel; } + + private String enrichWebChatConfig(String incomingConfigJson, String existingConfigJson) { + Map incoming = parseConfig(incomingConfigJson); + Map existing = parseConfig(existingConfigJson); + + String existingApiKey = asNonBlankString(existing.get("api_key")); + incoming.put("api_key", existingApiKey != null ? existingApiKey : generateWebChatApiKey()); + + if (!incoming.containsKey("title") && existing.containsKey("title")) { + incoming.put("title", existing.get("title")); + } + if (!incoming.containsKey("placeholder") && existing.containsKey("placeholder")) { + incoming.put("placeholder", existing.get("placeholder")); + } + if (!incoming.containsKey("primary_color") && existing.containsKey("primary_color")) { + incoming.put("primary_color", existing.get("primary_color")); + } + if (!incoming.containsKey("welcome_message") && existing.containsKey("welcome_message")) { + incoming.put("welcome_message", existing.get("welcome_message")); + } + if (!incoming.containsKey("allowed_origins") && existing.containsKey("allowed_origins")) { + incoming.put("allowed_origins", existing.get("allowed_origins")); + } + + try { + return objectMapper.writeValueAsString(incoming); + } catch (Exception e) { + throw new MateClawException("WebChat 渠道配置序列化失败: " + e.getMessage()); + } + } + + private Map parseConfig(String configJson) { + if (configJson == null || configJson.isBlank()) { + return new LinkedHashMap<>(); + } + try { + return objectMapper.readValue(configJson, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("Failed to parse channel configJson: {}", e.getMessage()); + return new LinkedHashMap<>(); + } + } + + private String asNonBlankString(Object value) { + if (value == null) return null; + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + private String generateWebChatApiKey() { + byte[] random = new byte[18]; + SECURE_RANDOM.nextBytes(random); + StringBuilder sb = new StringBuilder("mc_webchat_"); + for (byte b : random) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } } 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 1c80c161..33fc3d16 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 @@ -1,5 +1,7 @@ package vip.mate.channel.webchat; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -44,6 +46,7 @@ public class WebChatController { private final AgentService agentService; private final ConversationService conversationService; private final ChatStreamTracker streamTracker; + private final ObjectMapper objectMapper; private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); @@ -156,9 +159,14 @@ public class WebChatController { if (channel == null) { return R.fail(401, "Invalid API Key"); } + JsonNode config = parseConfig(channel.getConfigJson()); return R.ok(Map.of( "channelName", channel.getName(), - "agentId", channel.getAgentId() != null ? channel.getAgentId() : 0 + "agentId", channel.getAgentId() != null ? channel.getAgentId() : 0, + "title", textOrDefault(config, "title", channel.getName()), + "placeholder", textOrDefault(config, "placeholder", "Type a message..."), + "primaryColor", textOrDefault(config, "primary_color", "#409eff"), + "welcomeMessage", textOrDefault(config, "welcome_message", "") )); } @@ -174,14 +182,40 @@ public class WebChatController { List channels = channelService.listChannelsByType("webchat"); for (ChannelEntity channel : channels) { if (!Boolean.TRUE.equals(channel.getEnabled())) continue; - String configJson = channel.getConfigJson(); - if (configJson != null && configJson.contains(apiKey)) { + JsonNode config = parseConfig(channel.getConfigJson()); + String configuredApiKey = textOrDefault(config, "api_key", null); + if (configuredApiKey != null && apiKey.equals(configuredApiKey)) { return channel; } } return null; } + private JsonNode parseConfig(String configJson) { + if (configJson == null || configJson.isBlank()) { + return objectMapper.createObjectNode(); + } + try { + return objectMapper.readTree(configJson); + } catch (Exception e) { + log.warn("[WebChat] Failed to parse configJson: {}", e.getMessage()); + return objectMapper.createObjectNode(); + } + } + + private String textOrDefault(JsonNode node, String fieldName, String defaultValue) { + if (node != null) { + JsonNode value = node.get(fieldName); + if (value != null && !value.isNull()) { + String text = value.asText(); + if (!text.isBlank()) { + return text; + } + } + } + return defaultValue; + } + private void sendErrorAndComplete(SseEmitter emitter, String message) { try { emitter.send(SseEmitter.event().name("error").data(Map.of("message", message))); diff --git a/mateclaw-ui/public/icons/channels/webchat.svg b/mateclaw-ui/public/icons/channels/webchat.svg new file mode 100644 index 00000000..ce8d3a93 --- /dev/null +++ b/mateclaw-ui/public/icons/channels/webchat.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 4aee82bc..cf3dd7c9 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1289,6 +1289,7 @@ export default { weixin: 'WeChat', qq: 'QQ', slack: 'Slack', + webchat: 'WebChat Embed', webhook: 'Webhook', }, tabs: { @@ -1326,6 +1327,9 @@ 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.', + 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.', jsonHint: 'Edit the complete JSON configuration directly. Switching to "Form" tab will sync automatically.', advanced: 'Advanced', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index e1032491..8ca5ce92 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1299,6 +1299,7 @@ export default { weixin: 'WeChat (微信)', qq: 'QQ', slack: 'Slack', + webchat: 'WebChat 嵌入', webhook: 'Webhook', }, tabs: { @@ -1336,6 +1337,9 @@ export default { authFailed: '授权失败', }, webHint: 'Web 渠道使用内置 SSE 通信,无需额外配置。', + webchatHint: 'WebChat 用于把 MateClaw 聊天挂件嵌入外部网站。请配置 API Key、标题和主题色,然后在网站中引入 WebChat SDK。', + webchatApiKeyGenerated: '保存后平台会自动生成 API Key。创建完成后返回此页面即可复制。', + webchatApiKeyReadOnly: '该 API Key 由平台自动生成并托管,你只能复制,不能手动修改。', webhookHint: 'Webhook 渠道配置请在下方「原始 JSON」标签页中编辑。', jsonHint: '直接编辑渠道的完整 JSON 配置。切换到「表单配置」标签页时会自动同步。', advanced: '高级配置', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 3183c90a..59b4bbce 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -304,6 +304,7 @@ export interface ChannelFieldDef { placeholder: string required?: boolean sensitive?: boolean + readOnly?: boolean tooltip?: string type: 'text' | 'password' | 'select' | 'switch' | 'number' options?: { label: string; value: string }[] @@ -371,6 +372,14 @@ export const CHANNEL_FIELD_DEFS: Record = { { key: 'app_token', label: 'App Token', placeholder: 'xapp-xxxxxxxxxxxx', required: true, sensitive: true, type: 'password', tooltip: 'Slack App-Level Token(需要 connections:write scope,在 Slack App → Basic Information → App-Level Tokens 生成)' }, { key: 'signing_secret', label: 'Signing Secret', placeholder: 'xxxxxxxxxxxxxxxx', sensitive: true, type: 'password', tooltip: 'Slack App Signing Secret(用于 Webhook 模式验证请求签名,Socket Mode 可选)' }, ], + webchat: [ + { key: 'api_key', label: 'API Key', placeholder: '保存后由平台自动生成', required: true, sensitive: true, readOnly: true, type: 'password', tooltip: '由平台自动生成的嵌入式 WebChat 渠道密钥,创建后可复制使用' }, + { key: 'title', label: '标题', placeholder: 'MateClaw', type: 'text', defaultValue: 'MateClaw', tooltip: '聊天面板顶部显示的标题' }, + { key: 'placeholder', label: '输入框占位文案', placeholder: 'Type a message...', type: 'text', defaultValue: 'Type a message...', tooltip: '输入框默认提示文案' }, + { key: 'primary_color', label: '主题色', placeholder: '#409eff', type: 'text', defaultValue: '#409eff', tooltip: '聊天气泡与头部使用的主色,建议使用十六进制颜色值' }, + { key: 'welcome_message', label: '欢迎语', placeholder: '你好,我可以帮你处理什么?', type: 'text', tooltip: '前端 SDK 初始化后可读取并展示的欢迎语(当前主要供配置接口返回)' }, + { key: 'allowed_origins', label: '允许嵌入域名', placeholder: 'https://example.com, https://app.example.com', type: 'text', tooltip: '预留给嵌入来源白名单校验的域名列表,多个域名用逗号分隔' }, + ], } // ==================== 流控制 ==================== diff --git a/mateclaw-ui/src/views/Channels.vue b/mateclaw-ui/src/views/Channels.vue index 4c6a4366..10bbbe4a 100644 --- a/mateclaw-ui/src/views/Channels.vue +++ b/mateclaw-ui/src/views/Channels.vue @@ -107,6 +107,7 @@ + @@ -228,9 +229,11 @@ :type="visibleFields[field.key] ? 'text' : 'password'" class="form-input" :placeholder="field.placeholder" + :readonly="field.readOnly" autocomplete="off" /> + @@ -273,6 +287,7 @@ type="number" class="form-input" :placeholder="field.placeholder" + :readonly="field.readOnly" /> @@ -282,7 +297,12 @@ type="text" class="form-input" :placeholder="field.placeholder" + :readonly="field.readOnly" /> + + + {{ editingChannel ? t('channels.webchatApiKeyReadOnly') : t('channels.webchatApiKeyGenerated') }} + @@ -314,6 +334,11 @@

{{ t('channels.webHint') }}

+ +
+

{{ t('channels.webchatHint') }}

+
+

{{ t('channels.webhookHint') }}

@@ -847,6 +872,15 @@ async function copyWebhookUrl() { } } +async function copyText(text: string) { + try { + await navigator.clipboard.writeText(text) + ElMessage.success(t('common.copied')) + } catch { + ElMessage.warning(t('channels.webhook.copyFailed')) + } +} + // ==================== 生命周期 ==================== onMounted(async () => { @@ -1154,7 +1188,7 @@ async function toggleChannel(channel: Channel) { // ==================== 渠道图标 ==================== -const CHANNEL_ICON_TYPES = ['web', 'dingtalk', 'feishu', 'wecom', 'weixin', 'telegram', 'discord', 'qq', 'slack', 'webhook'] +const CHANNEL_ICON_TYPES = ['web', 'dingtalk', 'feishu', 'wecom', 'weixin', 'telegram', 'discord', 'qq', 'slack', 'webchat', 'webhook'] function getChannelIconPath(type: string) { const name = CHANNEL_ICON_TYPES.includes(type) ? type : 'default' return `/icons/channels/${name}.svg` @@ -1295,6 +1329,9 @@ function getChannelIconPath(type: string) { .password-wrap .form-input { padding-right: 36px; } .eye-btn { position: absolute; right: 8px; background: none; border: none; cursor: pointer; color: var(--mc-text-tertiary); padding: 2px; display: flex; align-items: center; } .eye-btn:hover { color: var(--mc-text-primary); } +.copy-inline-btn { position: absolute; right: 8px; background: none; border: none; cursor: pointer; color: var(--mc-text-tertiary); padding: 2px; display: flex; align-items: center; } +.copy-inline-btn:hover { color: var(--mc-text-primary); } +.form-hint { font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.5; } /* 开关 */ .switch-wrap { display: flex; align-items: center; gap: 8px; height: 36px; } diff --git a/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue b/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue index c65a05cc..032445bc 100644 --- a/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue +++ b/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue @@ -3,12 +3,14 @@
- + + +

{{ provider.name }}

{{ provider.isCustom ? t('settings.model.custom') : t('settings.model.builtin') }} @@ -113,7 +115,41 @@ const { t } = useI18n()