diff --git a/mateclaw-server/org/springframework/ai/chat/messages/UserMessage.java b/mateclaw-server/org/springframework/ai/chat/messages/UserMessage.java deleted file mode 100644 index a75d5f65..00000000 --- a/mateclaw-server/org/springframework/ai/chat/messages/UserMessage.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2023-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.chat.messages; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.springframework.ai.content.Media; -import org.springframework.ai.content.MediaContent; -import org.springframework.core.io.Resource; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; - -/** - * A message of the type 'user' passed as input Messages with the user role are from the - * end-user or developer. They represent questions, prompts, or any input that you want - * the generative to respond to. - */ -public class UserMessage extends AbstractMessage implements MediaContent { - - protected final List media; - - public UserMessage(String textContent) { - this(textContent, new ArrayList<>(), Map.of()); - } - - private UserMessage(String textContent, Collection media, Map metadata) { - super(MessageType.USER, textContent, metadata); - Assert.notNull(media, "media cannot be null"); - Assert.noNullElements(media, "media cannot have null elements"); - this.media = new ArrayList<>(media); - } - - public UserMessage(Resource resource) { - this(MessageUtils.readResource(resource)); - } - - @Override - public String toString() { - return "UserMessage{" + "content='" + getText() + '\'' + ", metadata=" + this.metadata + ", messageType=" - + this.messageType + '}'; - } - - @Override - @NonNull - public String getText() { - return this.textContent; - } - - @Override - public List getMedia() { - return this.media; - } - - public UserMessage copy() { - return new Builder().text(getText()).media(List.copyOf(getMedia())).metadata(Map.copyOf(getMetadata())).build(); - } - - public Builder mutate() { - return new Builder().text(getText()).media(List.copyOf(getMedia())).metadata(Map.copyOf(getMetadata())); - } - - public static Builder builder() { - return new Builder(); - } - - public static final class Builder { - - @Nullable - private String textContent; - - @Nullable - private Resource resource; - - private List media = new ArrayList<>(); - - private Map metadata = new HashMap<>(); - - public Builder text(String textContent) { - this.textContent = textContent; - return this; - } - - public Builder text(Resource resource) { - this.resource = resource; - return this; - } - - public Builder media(List media) { - this.media = media; - return this; - } - - public Builder media(@Nullable Media... media) { - if (media != null) { - this.media = Arrays.asList(media); - } - return this; - } - - public Builder metadata(Map metadata) { - this.metadata = metadata; - return this; - } - - public UserMessage build() { - if (StringUtils.hasText(this.textContent) && this.resource != null) { - throw new IllegalArgumentException("textContent and resource cannot be set at the same time"); - } - else if (this.resource != null) { - this.textContent = MessageUtils.readResource(this.resource); - } - return new UserMessage(this.textContent, this.media, this.metadata); - } - - } - -} diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml index 4a8bd2b0..c4b81143 100644 --- a/mateclaw-server/pom.xml +++ b/mateclaw-server/pom.xml @@ -244,6 +244,29 @@ + + + org.springframework.boot + spring-boot-starter-websocket + + + + + com.slack.api + slack-api-client + 1.44.2 + + + com.slack.api + bolt-socket-mode + 1.44.2 + + + org.glassfish.tyrus.bundles + tyrus-standalone-client + 2.2.0 + + org.springframework.boot diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 0906a119..7a60ac14 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -44,6 +44,15 @@ public class AgentService { .orderByDesc(AgentEntity::getCreateTime)); } + /** + * 按工作区列出 Agent + */ + public List listAgentsByWorkspace(Long workspaceId) { + return agentMapper.selectList(new LambdaQueryWrapper() + .eq(AgentEntity::getWorkspaceId, workspaceId) + .orderByDesc(AgentEntity::getCreateTime)); + } + public AgentEntity getAgent(Long id) { AgentEntity entity = agentMapper.selectById(id); if (entity == null) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java index e4bc5758..c8fd6bf5 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java @@ -33,7 +33,11 @@ public class AgentController { @Operation(summary = "获取Agent列表") @GetMapping - public R> list() { + public R> list( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + if (workspaceId != null) { + return R.ok(agentService.listAgentsByWorkspace(workspaceId)); + } return R.ok(agentService.listAgents()); } @@ -45,7 +49,12 @@ public class AgentController { @Operation(summary = "创建Agent") @PostMapping - public R create(@RequestBody AgentEntity agent) { + public R create( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @RequestBody AgentEntity agent) { + if (workspaceId != null) { + agent.setWorkspaceId(workspaceId); + } return R.ok(agentService.createAgent(agent)); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java index 7ec47038..3c1ce8f5 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java @@ -49,6 +49,9 @@ public class AgentEntity { /** 标签(逗号分隔) */ private String tags; + /** 所属工作区 ID(默认 1 = default) */ + private Long workspaceId; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java index 4020d6ce..ec9a69e5 100644 --- a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java +++ b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java @@ -162,6 +162,13 @@ public class AuthService { .eq(UserEntity::getUsername, username)); } + /** + * 根据 ID 查询用户 + */ + public UserEntity findById(Long userId) { + return userMapper.selectById(userId); + } + private String generateToken(UserEntity user) { return Jwts.builder() .subject(user.getUsername()) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index 67cffae1..2c4046d6 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -63,7 +63,7 @@ public class ChannelManager { /** 支持的渠道类型 */ private static final Set SUPPORTED_TYPES = Set.of( - "web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin" + "web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin", "slack", "webchat" ); /** @@ -406,6 +406,8 @@ public class ChannelManager { case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper); case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper); case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper); + case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper); + case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper); default -> throw new IllegalArgumentException("Unsupported channel type: " + type); }; } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java index e732012a..6f9a520b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java @@ -31,7 +31,11 @@ public class ChannelController { @Operation(summary = "获取渠道列表") @GetMapping - public R> list() { + public R> list( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + if (workspaceId != null) { + return R.ok(channelService.listChannelsByWorkspace(workspaceId)); + } return R.ok(channelService.listChannels()); } @@ -49,7 +53,12 @@ public class ChannelController { @Operation(summary = "创建渠道") @PostMapping - public R create(@RequestBody ChannelEntity channel) { + public R create( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @RequestBody ChannelEntity channel) { + if (workspaceId != null) { + channel.setWorkspaceId(workspaceId); + } return R.ok(channelService.createChannel(channel)); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java index c0ce254b..aa6e0dea 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java @@ -119,6 +119,23 @@ public class ChannelWebhookController { return ResponseEntity.ok("success"); } + @Operation(summary = "Slack Events API 回调") + @PostMapping("/slack") + public ResponseEntity> slackWebhook(@RequestBody Map payload) { + log.debug("[webhook] Slack callback received"); + // URL Verification challenge + if ("url_verification".equals(payload.get("type"))) { + return ResponseEntity.ok(Map.of("challenge", payload.getOrDefault("challenge", ""))); + } + Optional adapter = channelManager.getAdapterByType("slack"); + if (adapter.isPresent() && adapter.get() instanceof vip.mate.channel.slack.SlackChannelAdapter slack) { + Map result = slack.handleWebhook(payload); + return ResponseEntity.ok(result); + } + log.warn("[webhook] Slack channel not active, ignoring callback"); + return ResponseEntity.ok(Map.of("status", "channel_not_active")); + } + // ==================== 微信 iLink Bot ==================== /** 微信扫码深链接模板 */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/model/ChannelEntity.java b/mateclaw-server/src/main/java/vip/mate/channel/model/ChannelEntity.java index 77fee5e1..404a3661 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/model/ChannelEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/model/ChannelEntity.java @@ -40,6 +40,9 @@ public class ChannelEntity { /** 渠道描述 */ private String description; + /** 所属工作区 ID(默认 1 = default) */ + private Long workspaceId; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; 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 0f173458..78d2379b 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 @@ -34,6 +34,16 @@ public class ChannelService { .orderByDesc(ChannelEntity::getCreateTime)); } + /** + * 按工作区列出渠道 + */ + public List listChannelsByWorkspace(Long workspaceId) { + return channelMapper.selectList(new LambdaQueryWrapper() + .eq(ChannelEntity::getWorkspaceId, workspaceId) + .orderByDesc(ChannelEntity::getEnabled) + .orderByDesc(ChannelEntity::getCreateTime)); + } + /** * 获取已启用的渠道列表(ChannelManager 启动时使用) */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/slack/SlackChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/slack/SlackChannelAdapter.java new file mode 100644 index 00000000..255e1950 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/slack/SlackChannelAdapter.java @@ -0,0 +1,266 @@ +package vip.mate.channel.slack; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.slack.api.Slack; +import com.slack.api.bolt.App; +import com.slack.api.bolt.AppConfig; +import com.slack.api.bolt.socket_mode.SocketModeApp; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.model.event.MessageEvent; +import lombok.extern.slf4j.Slf4j; +import vip.mate.channel.AbstractChannelAdapter; +import vip.mate.channel.ChannelMessage; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.ExponentialBackoff; +import vip.mate.channel.model.ChannelEntity; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Slack 渠道适配器 + *

+ * 通过 Socket Mode 接入 Slack,支持: + * - 频道消息(自动 thread reply,避免刷屏) + * - DM 私聊 + * - 审批命令识别(复用 ChannelMessageRouter 的审批拦截) + * - proactiveSend 主动推送 + *

+ * configJson 配置项: + * - bot_token: Slack Bot OAuth Token (xoxb-...) + * - app_token: Slack App-Level Token (xapp-...) — Socket Mode 必需 + * - signing_secret: Slack Signing Secret(Webhook 模式使用) + * + * @author MateClaw Team + */ +@Slf4j +public class SlackChannelAdapter extends AbstractChannelAdapter { + + private Slack slack; + private App boltApp; + private SocketModeApp socketModeApp; + private String botUserId; + + /** 缓存 channel 消息的 thread_ts,确保同一频道对话在同一 thread 中回复 */ + private final ConcurrentHashMap threadTsCache = new ConcurrentHashMap<>(); + + public SlackChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper) { + super(channelEntity, messageRouter, objectMapper); + this.backoff = new ExponentialBackoff(3000, 60000, 2.0, -1); + } + + @Override + public String getChannelType() { + return "slack"; + } + + @Override + protected void doStart() { + String botToken = getConfigString("bot_token"); + String appToken = getConfigString("app_token"); + + if (botToken == null || botToken.isBlank()) { + throw new IllegalArgumentException("Slack bot_token is required"); + } + if (appToken == null || appToken.isBlank()) { + throw new IllegalArgumentException("Slack app_token is required (for Socket Mode)"); + } + + try { + // 初始化 Bolt App + AppConfig appConfig = AppConfig.builder() + .singleTeamBotToken(botToken) + .build(); + boltApp = new App(appConfig); + + // 注册消息事件处理 + boltApp.event(MessageEvent.class, (req, ctx) -> { + MessageEvent event = req.getEvent(); + processSlackMessage(event, botToken); + return ctx.ack(); + }); + + // 启动 Socket Mode + slack = Slack.getInstance(); + socketModeApp = new SocketModeApp(appToken, boltApp); + socketModeApp.startAsync(); + + // 获取 bot 自身的 user ID(用于过滤自己的消息) + try { + var authResult = slack.methods(botToken).authTest(r -> r); + if (authResult.isOk()) { + botUserId = authResult.getUserId(); + log.info("[slack] Bot user ID: {}", botUserId); + } + } catch (Exception e) { + log.warn("[slack] Failed to get bot user ID: {}", e.getMessage()); + } + + log.info("[slack] Socket Mode started for channel: {}", channelEntity.getName()); + } catch (Exception e) { + throw new RuntimeException("Failed to start Slack adapter: " + e.getMessage(), e); + } + } + + @Override + protected void doStop() { + try { + if (socketModeApp != null) { + socketModeApp.close(); + socketModeApp = null; + } + } catch (Exception e) { + log.warn("[slack] Error stopping Socket Mode: {}", e.getMessage()); + } + boltApp = null; + threadTsCache.clear(); + } + + /** + * 处理 Slack 消息事件 + */ + private void processSlackMessage(MessageEvent event, String botToken) { + // 忽略 bot 自身的消息 + if (event.getBotId() != null || (botUserId != null && botUserId.equals(event.getUser()))) { + return; + } + // 忽略 message_changed / message_deleted 等子类型 + if (event.getSubtype() != null) { + return; + } + + String text = event.getText(); + if (text == null || text.isBlank()) { + return; + } + + lastEventTimeMs.set(System.currentTimeMillis()); + + // 构建 conversationId + String channelId = event.getChannel(); + String channelType = event.getChannelType(); + String senderId = event.getUser(); + boolean isDM = "im".equals(channelType); + + String conversationId; + if (isDM) { + conversationId = "slack:dm:" + senderId; + } else { + conversationId = "slack:" + channelId; + } + + // 如果是频道消息,记住 thread_ts 用于后续回复到同一 thread + String threadTs = event.getThreadTs() != null ? event.getThreadTs() : event.getTs(); + if (!isDM) { + threadTsCache.put(conversationId, threadTs); + } + + // 清理 bot mention(<@U12345> 格式) + String cleanedText = text; + if (botUserId != null) { + cleanedText = cleanedText.replaceAll("<@" + botUserId + ">", "").trim(); + } + if (cleanedText.isBlank()) { + return; + } + + // 构建统一消息(使用 Builder 模式) + // bot prefix 过滤和清理由 AbstractChannelAdapter.onMessage() 统一处理 + ChannelMessage message = ChannelMessage.builder() + .messageId(event.getTs()) + .channelType("slack") + .senderId(senderId) + .senderName(senderId) + .chatId(channelId) + .content(cleanedText) + .contentType("text") + .contentParts(List.of()) + .timestamp(LocalDateTime.now()) + .replyToken(channelId) + .build(); + + // 转发给消息路由 + onMessage(message); + } + + @Override + public void sendMessage(String targetId, String content) { + String botToken = getConfigString("bot_token"); + if (botToken == null || content == null || content.isBlank()) { + return; + } + + try { + String channelId = targetId; + + // 转换 Markdown 为 Slack mrkdwn + String slackContent = convertToSlackMarkdown(content); + + // 查找 thread_ts 用于 thread reply + String threadTs = null; + for (var entry : threadTsCache.entrySet()) { + if (entry.getKey().contains(channelId)) { + threadTs = entry.getValue(); + break; + } + } + + final String finalThreadTs = threadTs; + ChatPostMessageResponse response = slack.methods(botToken).chatPostMessage(req -> { + var builder = req.channel(channelId).text(slackContent); + if (finalThreadTs != null) { + builder.threadTs(finalThreadTs); + } + return builder; + }); + + if (!response.isOk()) { + log.warn("[slack] Failed to send message: {}", response.getError()); + } + } catch (IOException | SlackApiException e) { + log.error("[slack] Error sending message to {}: {}", targetId, e.getMessage()); + } + } + + @Override + public boolean supportsProactiveSend() { + return true; + } + + @Override + public void proactiveSend(String targetId, String content) { + sendMessage(targetId, content); + } + + /** + * Webhook 回调处理(备用模式,Socket Mode 优先) + */ + public Map handleWebhook(Map payload) { + // URL Verification challenge + if ("url_verification".equals(payload.get("type"))) { + return Map.of("challenge", payload.getOrDefault("challenge", "")); + } + return Map.of("status", "ok"); + } + + /** + * 基础 Markdown -> Slack mrkdwn 转换 + */ + private String convertToSlackMarkdown(String markdown) { + if (markdown == null) return ""; + String result = markdown; + // **bold** -> *bold* + result = result.replaceAll("\\*\\*(.+?)\\*\\*", "*$1*"); + // [text](url) -> + result = result.replaceAll("\\[([^]]+)]\\(([^)]+)\\)", "<$2|$1>"); + // # Header -> *Header* + result = result.replaceAll("(?m)^#{1,6}\\s+(.+)$", "*$1*"); + return result; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java new file mode 100644 index 00000000..df06e207 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/TalkModeWebSocketHandler.java @@ -0,0 +1,207 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.AbstractWebSocketHandler; +import vip.mate.agent.AgentService; +import vip.mate.stt.SttService; +import vip.mate.tts.TtsService; +import vip.mate.workspace.conversation.ConversationService; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Talk Mode WebSocket Handler + *

+ * 处理语音交互的完整循环: + * 1. 接收前端音频 binary frame + * 2. STT 转文字 + * 3. Agent 对话 + * 4. TTS 合成音频 + * 5. 推送音频 + 文字回前端 + *

+ * 前端初始化时发送 JSON text frame 指定 agentId 和 conversationId: + * {"type":"init","agentId":1,"conversationId":"talk-xxx"} + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TalkModeWebSocketHandler extends AbstractWebSocketHandler { + + private final SttService sttService; + private final TtsService ttsService; + private final AgentService agentService; + private final ConversationService conversationService; + private final ObjectMapper objectMapper; + + private final ExecutorService executor = Executors.newCachedThreadPool(); + + /** 每个 WebSocket 会话的上下文 */ + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + private record TalkSession(Long agentId, String conversationId, String username) {} + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + log.info("[TalkMode] WebSocket connected: {}", session.getId()); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + String payload = message.getPayload(); + try { + Map data = objectMapper.readValue(payload, Map.class); + String type = (String) data.get("type"); + + if ("init".equals(type)) { + Long agentId = data.get("agentId") != null ? Long.valueOf(data.get("agentId").toString()) : null; + String conversationId = (String) data.getOrDefault("conversationId", "talk-" + session.getId()); + String username = (String) data.getOrDefault("username", "anonymous"); + + if (agentId == null) { + sendJson(session, Map.of("type", "error", "message", "agentId is required")); + return; + } + + sessions.put(session.getId(), new TalkSession(agentId, conversationId, username)); + sendJson(session, Map.of("type", "ready", "conversationId", conversationId)); + log.info("[TalkMode] Session initialized: agentId={}, conversationId={}", agentId, conversationId); + } + } catch (Exception e) { + log.warn("[TalkMode] Invalid text message: {}", e.getMessage()); + sendJson(session, Map.of("type", "error", "message", "Invalid message format")); + } + } + + @Override + protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) { + TalkSession talkSession = sessions.get(session.getId()); + if (talkSession == null) { + try { + sendJson(session, Map.of("type", "error", "message", "Session not initialized. Send init message first.")); + } catch (IOException e) { + log.warn("[TalkMode] Failed to send error: {}", e.getMessage()); + } + return; + } + + byte[] audioData = message.getPayload().array(); + log.info("[TalkMode] Received audio: {} bytes", audioData.length); + + // 异步处理:STT -> Agent -> TTS + executor.execute(() -> processAudio(session, talkSession, audioData)); + } + + private void processAudio(WebSocketSession session, TalkSession talkSession, byte[] audioData) { + try { + // 1. 通知前端进入处理状态 + sendJson(session, Map.of("type", "state", "state", "processing")); + + // 2. STT: 音频转文字 + Map sttResult = sttService.transcribe(audioData, "audio.webm", "audio/webm", null); + if (!Boolean.TRUE.equals(sttResult.get("success"))) { + sendJson(session, Map.of("type", "error", "message", "Speech recognition failed: " + sttResult.get("error"))); + sendJson(session, Map.of("type", "state", "state", "idle")); + return; + } + + String transcript = (String) sttResult.get("text"); + if (transcript == null || transcript.isBlank()) { + sendJson(session, Map.of("type", "state", "state", "idle")); + return; + } + + // 3. 推送转写结果 + sendJson(session, Map.of("type", "transcript", "text", transcript)); + + // 4. 保存用户消息 + conversationService.getOrCreateConversation( + talkSession.conversationId, talkSession.agentId, talkSession.username); + conversationService.saveMessage(talkSession.conversationId, "user", transcript, List.of()); + + // 5. Agent 对话(同步) + String reply = agentService.chat(talkSession.agentId, transcript, talkSession.conversationId); + if (reply == null || reply.isBlank()) { + reply = "Sorry, I couldn't generate a response."; + } + + // 6. 保存助手回复 + conversationService.saveMessage(talkSession.conversationId, "assistant", reply, List.of()); + + // 7. 推送文字回复 + sendJson(session, Map.of("type", "reply", "text", reply)); + + // 8. TTS: 文字转语音 + sendJson(session, Map.of("type", "state", "state", "speaking")); + Map ttsResult = ttsService.synthesize( + talkSession.conversationId, reply, null, null, null); + + if (Boolean.TRUE.equals(ttsResult.get("success"))) { + String audioUrl = (String) ttsResult.get("audioUrl"); + if (audioUrl != null) { + // 读取音频文件并通过 WebSocket 发送 + Path audioPath = Paths.get(audioUrl); + if (!audioPath.isAbsolute()) { + audioPath = Paths.get("data", "tts-output").resolve(audioUrl); + } + if (Files.exists(audioPath)) { + byte[] audioBytes = Files.readAllBytes(audioPath); + session.sendMessage(new BinaryMessage(audioBytes)); + log.info("[TalkMode] Sent TTS audio: {} bytes", audioBytes.length); + } else { + // 回退:发送音频 URL 让前端直接播放 + sendJson(session, Map.of("type", "tts_url", "url", audioUrl)); + } + } + } else { + log.warn("[TalkMode] TTS failed: {}", ttsResult.get("error")); + } + + // 9. 完成,回到空闲状态 + sendJson(session, Map.of("type", "state", "state", "idle")); + + } catch (Exception e) { + log.error("[TalkMode] Error processing audio: {}", e.getMessage(), e); + try { + sendJson(session, Map.of("type", "error", "message", e.getMessage())); + sendJson(session, Map.of("type", "state", "state", "idle")); + } catch (IOException ex) { + log.warn("[TalkMode] Failed to send error: {}", ex.getMessage()); + } + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + sessions.remove(session.getId()); + log.info("[TalkMode] WebSocket disconnected: {} (status={})", session.getId(), status); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) { + sessions.remove(session.getId()); + log.warn("[TalkMode] Transport error: {} - {}", session.getId(), exception.getMessage()); + } + + private void sendJson(WebSocketSession session, Map data) throws IOException { + if (session.isOpen()) { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(data))); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatChannelAdapter.java new file mode 100644 index 00000000..25844395 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatChannelAdapter.java @@ -0,0 +1,46 @@ +package vip.mate.channel.webchat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import vip.mate.channel.AbstractChannelAdapter; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; + +/** + * WebChat 渠道适配器 + *

+ * WebChat 是无状态的 HTTP/SSE 渠道,消息由 WebChatController 直接处理。 + * 此适配器仅用于 ChannelManager 的渠道注册和状态管理,不负责消息收发。 + * + * @author MateClaw Team + */ +@Slf4j +public class WebChatChannelAdapter extends AbstractChannelAdapter { + + public WebChatChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper) { + super(channelEntity, messageRouter, objectMapper); + } + + @Override + public String getChannelType() { + return "webchat"; + } + + @Override + protected void doStart() { + log.info("[webchat] WebChat channel ready: {} (API Key auth via WebChatController)", channelEntity.getName()); + } + + @Override + protected void doStop() { + log.info("[webchat] WebChat channel stopped: {}", channelEntity.getName()); + } + + @Override + public void sendMessage(String targetId, String content) { + // WebChat 通过 SSE 推送,不通过 adapter sendMessage + log.debug("[webchat] sendMessage ignored (SSE-driven): target={}", targetId); + } +} 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 new file mode 100644 index 00000000..ffc4b73d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -0,0 +1,210 @@ +package vip.mate.channel.webchat; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import vip.mate.agent.AgentService; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.service.ChannelService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.common.result.R; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * WebChat 嵌入式对话接口 + *

+ * 独立于 ChatController,使用 API Key 认证(不依赖 JWT)。 + * 供外部网站通过 JS SDK 嵌入 MateClaw 对话能力。 + *

+ * 认证方式:请求头 X-MC-Key 携带 API Key + * + * @author MateClaw Team + */ +@Tag(name = "WebChat 嵌入式对话") +@Slf4j +@RestController +@RequestMapping("/api/v1/channels/webchat") +@RequiredArgsConstructor +public class WebChatController { + + private final ChannelService channelService; + private final AgentService agentService; + private final ConversationService conversationService; + private final ChatStreamTracker streamTracker; + + private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); + + /** + * WebChat SSE 流式对话 + */ + @Operation(summary = "WebChat SSE 流式对话") + @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter chatStream( + @RequestHeader("X-MC-Key") String apiKey, + @RequestBody WebChatRequest request) { + + SseEmitter emitter = new SseEmitter(10 * 60 * 1000L); + + // 验证 API Key 并获取关联的 Channel 配置 + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + sendErrorAndComplete(emitter, "Invalid API Key"); + return emitter; + } + + Long agentId = channel.getAgentId(); + if (agentId == null) { + sendErrorAndComplete(emitter, "No agent configured for this WebChat channel"); + return emitter; + } + + String visitorId = request.getVisitorId() != null ? request.getVisitorId() : UUID.randomUUID().toString(); + String conversationId = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":" + visitorId; + String message = request.getMessage() != null ? request.getMessage() : ""; + + if (message.isBlank()) { + sendErrorAndComplete(emitter, "Message is required"); + return emitter; + } + + log.info("[WebChat] Stream: agentId={}, conversationId={}, visitor={}", agentId, conversationId, visitorId); + + // 注册 emitter 回调 + emitter.onCompletion(() -> log.debug("[WebChat] SSE completed: {}", conversationId)); + emitter.onTimeout(() -> { + log.debug("[WebChat] SSE timeout: {}", conversationId); + streamTracker.complete(conversationId); + }); + emitter.onError(e -> { + log.debug("[WebChat] SSE error: {} - {}", conversationId, e.getMessage()); + streamTracker.complete(conversationId); + }); + + sseExecutor.execute(() -> { + try { + // 创建或获取会话 + var conv = conversationService.getOrCreateConversation(conversationId, agentId, "webchat:" + visitorId); + + // 保存用户消息 + conversationService.saveMessage(conversationId, "user", message, List.of()); + + // 初始化 SSE 流跟踪 + streamTracker.register(conversationId); + streamTracker.attach(conversationId, emitter); + + // 调用 Agent 流式对话 + agentService.chatStructuredStream(agentId, message, conversationId, visitorId) + .doOnNext(delta -> { + if (delta.content() != null && !delta.content().isEmpty()) { + streamTracker.broadcast(conversationId, "content_delta", + "{\"text\":" + escapeJson(delta.content()) + "}"); + } + if (delta.thinking() != null && !delta.thinking().isEmpty()) { + streamTracker.broadcast(conversationId, "thinking_delta", + "{\"text\":" + escapeJson(delta.thinking()) + "}"); + } + }) + .doOnComplete(() -> { + streamTracker.broadcast(conversationId, "done", "{\"status\":\"completed\"}"); + streamTracker.complete(conversationId); + }) + .doOnError(e -> { + log.error("[WebChat] Stream error: {}", e.getMessage()); + streamTracker.broadcast(conversationId, "error", + "{\"message\":" + escapeJson(e.getMessage()) + "}"); + streamTracker.complete(conversationId); + }) + .subscribe(); + + } catch (Exception e) { + log.error("[WebChat] Error: {}", e.getMessage(), e); + try { + emitter.send(SseEmitter.event().name("error") + .data(Map.of("message", e.getMessage()))); + emitter.complete(); + } catch (IOException ex) { + emitter.completeWithError(ex); + } + } + }); + + return emitter; + } + + /** + * 获取 WebChat 配置(前端 SDK 初始化用) + */ + @Operation(summary = "获取 WebChat 配置") + @GetMapping("/config") + public R> getConfig(@RequestHeader("X-MC-Key") String apiKey) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + return R.ok(Map.of( + "channelName", channel.getName(), + "agentId", channel.getAgentId() != null ? channel.getAgentId() : 0 + )); + } + + // ==================== 内部方法 ==================== + + /** + * 通过 API Key 查找 WebChat 渠道 + */ + private ChannelEntity resolveChannel(String apiKey) { + if (apiKey == null || apiKey.isBlank()) { + return null; + } + 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)) { + return channel; + } + } + return null; + } + + private void sendErrorAndComplete(SseEmitter emitter, String message) { + try { + emitter.send(SseEmitter.event().name("error").data(Map.of("message", message))); + emitter.complete(); + } catch (IOException e) { + emitter.completeWithError(e); + } + } + + private String escapeJson(String value) { + if (value == null) return "null"; + return "\"" + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + + "\""; + } + + // ==================== 请求体 ==================== + + @lombok.Data + public static class WebChatRequest { + private String message; + private String visitorId; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java index 09bcae73..0a07e1d3 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java @@ -56,7 +56,9 @@ public class SecurityConfig { "/api/v1/chat/stream", "/api/v1/chat/*/stop", "/api/v1/setup/**", - "/api/v1/channels/webhook/**" + "/api/v1/channels/webhook/**", + "/api/v1/channels/webchat/**", + "/api/v1/talk/ws" ).permitAll() // 所有其他 API 接口需要认证 .requestMatchers("/api/**").authenticated() diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java new file mode 100644 index 00000000..3c3abeef --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/config/WebSocketConfig.java @@ -0,0 +1,29 @@ +package vip.mate.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import vip.mate.channel.web.TalkModeWebSocketHandler; + +/** + * WebSocket 配置 + *

+ * 注册 Talk Mode WebSocket 端点。 + * + * @author MateClaw Team + */ +@Configuration +@EnableWebSocket +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketConfigurer { + + private final TalkModeWebSocketHandler talkModeHandler; + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(talkModeHandler, "/api/v1/talk/ws") + .setAllowedOrigins("*"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java index 6b6beb0e..d2880408 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/BrowserUseTool.java @@ -37,6 +37,13 @@ public class BrowserUseTool { private static final boolean IS_WINDOWS = System.getProperty("os.name", "") .toLowerCase(Locale.ROOT).contains("win"); + /** SSE 推送器(用于将浏览器操作实时推送到前端) */ + private final vip.mate.channel.web.ChatStreamTracker streamTracker; + + public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker) { + this.streamTracker = streamTracker; + } + /** * 共享 Playwright 实例(Node.js 进程)。 * Playwright.create() 启动一个 Node.js 子进程,耗时 1-2 秒。 @@ -136,6 +143,32 @@ public class BrowserUseTool { } } + // ==================== Browser Event Broadcasting ==================== + + /** + * 向前端广播浏览器操作事件(通过 SSE) + */ + private void broadcastBrowserEvent(String action, boolean success, String url, String title, + String screenshot, long durationMs) { + String conversationId = ToolExecutionContext.conversationId(); + if (conversationId == null || streamTracker == null) { + return; + } + try { + java.util.Map eventData = new java.util.LinkedHashMap<>(); + eventData.put("action", action); + eventData.put("success", success); + if (url != null) eventData.put("url", url); + if (title != null) eventData.put("title", title); + if (screenshot != null) eventData.put("screenshot", screenshot); + eventData.put("durationMs", durationMs); + eventData.put("timestamp", System.currentTimeMillis()); + streamTracker.broadcastObject(conversationId, "browser_action", eventData); + } catch (Exception e) { + log.debug("[BrowserUse] Failed to broadcast event: {}", e.getMessage()); + } + } + // ==================== Action Handlers ==================== private String doStart(String sessionKey, boolean headed) { @@ -174,6 +207,7 @@ public class BrowserUseTool { long elapsed = System.currentTimeMillis() - startTime; log.info("[BrowserUse] Browser started successfully (headed={}) in {}ms", headed, elapsed); + broadcastBrowserEvent("start", true, null, null, null, elapsed); return ok("Browser started (headed=" + headed + ") in " + elapsed + "ms. Use action=open with url to navigate."); } @@ -294,9 +328,11 @@ public class BrowserUseTool { if (wasCdp) { log.info("[BrowserUse] Disconnected from CDP (Chrome keeps running at {})", cdpUrl); + broadcastBrowserEvent("stop", true, null, null, null, 0); return ok("Disconnected from CDP. Chrome process at " + cdpUrl + " keeps running."); } else { log.info("[BrowserUse] Browser stopped"); + broadcastBrowserEvent("stop", true, null, null, null, 0); return ok("Browser stopped and resources released"); } } @@ -327,6 +363,7 @@ public class BrowserUseTool { String currentUrl = page.url(); log.info("[BrowserUse] Opened: {} (title={})", currentUrl, title); + broadcastBrowserEvent("open", true, currentUrl, title, null, 0); JSONObject result = new JSONObject(); result.set("ok", true); @@ -443,6 +480,7 @@ public class BrowserUseTool { byte[] bytes = page.screenshot(opts); String base64 = Base64.getEncoder().encodeToString(bytes); log.info("[BrowserUse] Screenshot captured ({} bytes)", bytes.length); + broadcastBrowserEvent("screenshot", true, null, null, base64, 0); JSONObject result = new JSONObject(); result.set("ok", true); @@ -474,6 +512,7 @@ public class BrowserUseTool { String url = page.url(); log.info("[BrowserUse] Clicked: {} (page now: {})", selector, url); + broadcastBrowserEvent("click", true, url, title, null, 0); JSONObject result = new JSONObject(); result.set("ok", true); @@ -503,6 +542,7 @@ public class BrowserUseTool { page.fill(selector, text); log.info("[BrowserUse] Typed into: {} ({} chars)", selector, text.length()); + broadcastBrowserEvent("type", true, null, null, null, 0); JSONObject result = new JSONObject(); result.set("ok", true); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index c0965f31..d853d7a7 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -50,7 +50,11 @@ public class WikiController { @Operation(summary = "获取所有知识库") @GetMapping("/knowledge-bases") - public R> listKBs() { + public R> listKBs( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + if (workspaceId != null) { + return R.ok(kbService.listByWorkspace(workspaceId)); + } return R.ok(kbService.listAll()); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java index 9d83964c..5cff40cb 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java @@ -42,6 +42,9 @@ public class WikiKnowledgeBaseEntity { /** 原始材料数量 */ private Integer rawCount; + /** 所属工作区 ID(默认 1 = default) */ + private Long workspaceId; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java index 43daf60b..b227030b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java @@ -25,6 +25,78 @@ public class WikiContextService { private final WikiPageService pageService; private final WikiProperties properties; + /** + * 构建与用户消息相关的 Wiki 上下文(任务前知识注入) + *

+ * 从用户消息中提取关键词,匹配 Wiki 页面的标题和摘要, + * 注入 top-3 相关页面的完整内容到 system prompt 中。 + * + * @param agentId Agent ID + * @param userMessage 用户当前消息 + * @return 相关 Wiki 页面内容,如果没有匹配则返回空字符串 + */ + public String buildRelevantContext(Long agentId, String userMessage) { + if (!properties.isEnabled() || userMessage == null || userMessage.isBlank()) { + return buildWikiContext(agentId); + } + + List kbs = kbService.listByAgentId(agentId); + if (kbs.isEmpty()) { + return ""; + } + + // 从用户消息中提取关键词(简单分词:按非字母数字中文分割,过滤短词) + String[] keywords = userMessage.toLowerCase() + .replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", " ") + .trim() + .split("\\s+"); + + StringBuilder sb = new StringBuilder(); + sb.append("\n\n## Relevant Wiki Context\n\n"); + sb.append("The following wiki pages are relevant to the user's current question:\n\n"); + + int found = 0; + int maxChars = properties.getMaxContextChars(); + int totalChars = 0; + + for (WikiKnowledgeBaseEntity kb : kbs) { + if (found >= 3) break; + + List pages = pageService.listByKbIdWithContent(kb.getId()); + for (WikiPageEntity page : pages) { + if (found >= 3) break; + + // 计算匹配分数 + String titleLower = page.getTitle() != null ? page.getTitle().toLowerCase() : ""; + String summaryLower = page.getSummary() != null ? page.getSummary().toLowerCase() : ""; + int score = 0; + for (String kw : keywords) { + if (kw.length() < 2) continue; + if (titleLower.contains(kw)) score += 3; + if (summaryLower.contains(kw)) score += 1; + } + + if (score > 0) { + String content = page.getContent() != null ? page.getContent() : ""; + if (totalChars + content.length() > maxChars) { + content = content.substring(0, Math.max(0, maxChars - totalChars)) + "\n... (truncated)"; + } + sb.append("### [[").append(page.getTitle()).append("]] (`").append(page.getSlug()).append("`)\n\n"); + sb.append(content).append("\n\n---\n\n"); + totalChars += content.length(); + found++; + } + } + } + + if (found == 0) { + // 没有相关页面匹配,退回全量摘要模式 + return buildWikiContext(agentId); + } + + return sb.toString(); + } + /** * 构建指定 Agent 关联的 Wiki 上下文 * @@ -48,7 +120,8 @@ public class WikiContextService { sb.append("- `wiki_search_pages(agentId, query)` — full-text search across titles, summaries, and content\n"); sb.append("- `wiki_read_page(agentId, slug)` — read full page content with source file info\n"); sb.append("- `wiki_list_pages(agentId)` — list all pages with summaries\n"); - sb.append("- `wiki_trace_source(agentId, slug)` — find which original documents a page was generated from\n\n"); + sb.append("- `wiki_trace_source(agentId, slug)` — find which original documents a page was generated from\n"); + sb.append("- `wiki_create_page(agentId, title, content)` — create a new wiki page to save results, reports, or knowledge\n\n"); int totalChars = 0; int maxChars = properties.getMaxContextChars(); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index a2f6ddf4..a837e624 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -52,6 +52,16 @@ public class WikiKnowledgeBaseService { .orderByDesc(WikiKnowledgeBaseEntity::getUpdateTime)); } + /** + * 按工作区列出知识库 + */ + public List listByWorkspace(Long workspaceId) { + return kbMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiKnowledgeBaseEntity::getWorkspaceId, workspaceId) + .orderByDesc(WikiKnowledgeBaseEntity::getUpdateTime)); + } + /** * 获取 Agent 可访问的知识库:Agent 专属 KB + 公共 KB(agent_id IS NULL) */ diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index 25d5484c..7b74cbb6 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -174,6 +174,58 @@ public class WikiTool { .toString(); } + @Tool(description = """ + 在 Wiki 知识库中创建新页面。 + 用于保存任务执行结果、分析报告、会议纪要等有价值的信息。 + 内容使用 Markdown 格式。页面标识符 (slug) 会从标题自动生成。 + """) + public String wiki_create_page( + @ToolParam(description = "当前 Agent 的 ID") Long agentId, + @ToolParam(description = "页面标题") String title, + @ToolParam(description = "页面内容 (Markdown 格式)") String content) { + + if (title == null || title.isBlank()) { + return error("title is required"); + } + if (content == null || content.isBlank()) { + return error("content is required"); + } + + Long kbId = resolveKbId(agentId); + if (kbId == null) { + return error("No wiki knowledge base found for this agent. Create one first."); + } + + // 从标题生成 slug + String slug = title.toLowerCase() + .replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-") + .replaceAll("^-|-$", ""); + if (slug.isBlank()) { + slug = "page-" + System.currentTimeMillis(); + } + + // 检查 slug 是否已存在 + WikiPageEntity existing = pageService.getBySlug(kbId, slug); + if (existing != null) { + slug = slug + "-" + System.currentTimeMillis() % 10000; + } + + // 生成摘要(取前 200 字符) + String summary = content.length() > 200 ? content.substring(0, 200) + "..." : content; + + WikiPageEntity page = pageService.createPage(kbId, slug, title, content, summary, null); + + log.info("[WikiTool] Created page: {} (slug={}, kbId={})", title, slug, kbId); + + return JSONUtil.createObj() + .set("ok", true) + .set("message", "Page created successfully") + .set("title", page.getTitle()) + .set("slug", page.getSlug()) + .set("kbId", kbId) + .toString(); + } + /** * 通过 agentId 自动解析关联的知识库 ID *

diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 166d92d3..07f9d2b1 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -50,11 +50,21 @@ public class ConversationService { * 获取用户的会话列表(返回 VO,包含 agentName/agentIcon/status) */ public List listConversations(String username) { + return listConversations(username, null); + } + + /** + * 获取用户的会话列表(按工作区过滤) + */ + public List listConversations(String username, Long workspaceId) { // 同时返回当前用户的会话 和 定时任务(system)产生的会话 - List entities = conversationMapper.selectList( - new LambdaQueryWrapper() - .in(ConversationEntity::getUsername, username, SYSTEM_USER) - .orderByDesc(ConversationEntity::getLastActiveTime)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .in(ConversationEntity::getUsername, username, SYSTEM_USER) + .orderByDesc(ConversationEntity::getLastActiveTime); + if (workspaceId != null) { + wrapper.eq(ConversationEntity::getWorkspaceId, workspaceId); + } + List entities = conversationMapper.selectList(wrapper); if (entities.isEmpty()) { return List.of(); diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java index 6e7312bc..bdee3cf8 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java @@ -34,9 +34,11 @@ public class ConversationController { */ @Operation(summary = "获取会话列表") @GetMapping - public R> list(Authentication auth) { + public R> list( + Authentication auth, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { String username = auth != null ? auth.getName() : "anonymous"; - return R.ok(conversationService.listConversations(username)); + return R.ok(conversationService.listConversations(username, workspaceId)); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java index 71162f50..698c8700 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java @@ -42,6 +42,9 @@ public class ConversationEntity { /** 流状态:idle(空闲)/ running(生成中) */ private String streamStatus; + /** 所属工作区 ID(默认 1 = default) */ + private Long workspaceId; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java new file mode 100644 index 00000000..04bc48b1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSchemaMigration.java @@ -0,0 +1,85 @@ +package vip.mate.workspace.core.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * 工作区 Schema 迁移 + *

+ * 确保默认工作区(id=1, slug='default')存在。 + * 在 DatabaseBootstrapRunner (@Order(1)) 之后执行。 + * + * @author MateClaw Team + */ +@Slf4j +@Component +@Order(5) +@RequiredArgsConstructor +public class WorkspaceSchemaMigration implements ApplicationRunner { + + private final JdbcTemplate jdbcTemplate; + + @Override + public void run(ApplicationArguments args) { + ensureDefaultWorkspace(); + ensureDefaultWorkspaceMembership(); + } + + /** + * 确保默认工作区存在 + */ + private void ensureDefaultWorkspace() { + try { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM mate_workspace WHERE slug = 'default' AND deleted = 0", + Integer.class); + if (count != null && count > 0) { + return; + } + } catch (DataAccessException e) { + log.debug("mate_workspace table may not exist yet: {}", e.getMessage()); + return; + } + + try { + jdbcTemplate.update(""" + INSERT INTO mate_workspace (id, name, slug, description, owner_id, create_time, update_time, deleted) + VALUES (1, 'Default', 'default', '默认工作区', NULL, NOW(), NOW(), 0) + """); + log.info("Created default workspace (id=1, slug='default')"); + } catch (DataAccessException e) { + // 可能已存在(并发或 ID 冲突),忽略 + log.debug("Default workspace may already exist: {}", e.getMessage()); + } + } + + /** + * 确保所有现有用户都是默认工作区的成员 + */ + private void ensureDefaultWorkspaceMembership() { + try { + // 查找不在默认工作区中的用户 + int inserted = jdbcTemplate.update(""" + INSERT INTO mate_workspace_member (id, workspace_id, user_id, role, create_time, update_time, deleted) + SELECT u.id, 1, u.id, u.role, NOW(), NOW(), 0 + FROM mate_user u + WHERE u.deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM mate_workspace_member wm + WHERE wm.workspace_id = 1 AND wm.user_id = u.id AND wm.deleted = 0 + ) + """); + if (inserted > 0) { + log.info("Added {} existing user(s) to default workspace", inserted); + } + } catch (DataAccessException e) { + log.debug("Skipping default workspace membership init: {}", e.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java new file mode 100644 index 00000000..a95d1ffc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java @@ -0,0 +1,132 @@ +package vip.mate.workspace.core.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.core.model.WorkspaceMemberEntity; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.util.List; +import java.util.Map; + +/** + * 工作区管理接口 + * + * @author MateClaw Team + */ +@Tag(name = "工作区管理") +@RestController +@RequestMapping("/api/v1/workspaces") +@RequiredArgsConstructor +public class WorkspaceController { + + private final WorkspaceService workspaceService; + private final AuthService authService; + + // ==================== 工作区 CRUD ==================== + + @Operation(summary = "获取当前用户的工作区列表") + @GetMapping + public R> list(Authentication auth) { + Long userId = resolveUserId(auth); + return R.ok(workspaceService.listByUserId(userId)); + } + + @Operation(summary = "获取工作区详情") + @GetMapping("/{id}") + public R get(@PathVariable Long id) { + return R.ok(workspaceService.getById(id)); + } + + @Operation(summary = "创建工作区") + @PostMapping + public R create(@RequestBody WorkspaceEntity entity, Authentication auth) { + Long userId = resolveUserId(auth); + return R.ok(workspaceService.create(entity, userId)); + } + + @Operation(summary = "更新工作区") + @PutMapping("/{id}") + public R update(@PathVariable Long id, @RequestBody WorkspaceEntity entity, Authentication auth) { + Long userId = resolveUserId(auth); + workspaceService.requirePermission(id, userId, "owner"); + entity.setId(id); + return R.ok(workspaceService.update(entity)); + } + + @Operation(summary = "删除工作区") + @DeleteMapping("/{id}") + public R delete(@PathVariable Long id, Authentication auth) { + Long userId = resolveUserId(auth); + workspaceService.requirePermission(id, userId, "owner"); + workspaceService.delete(id); + return R.ok(); + } + + // ==================== 成员管理 ==================== + + @Operation(summary = "获取工作区成员列表") + @GetMapping("/{id}/members") + public R> listMembers(@PathVariable Long id) { + List members = workspaceService.listMembers(id); + // 填充用户名/昵称 + for (WorkspaceMemberEntity m : members) { + UserEntity user = authService.findById(m.getUserId()); + if (user != null) { + m.setUsername(user.getUsername()); + m.setNickname(user.getNickname()); + } + } + return R.ok(members); + } + + @Operation(summary = "添加工作区成员") + @PostMapping("/{id}/members") + public R addMember(@PathVariable Long id, + @RequestBody Map body, + Authentication auth) { + Long userId = resolveUserId(auth); + workspaceService.requirePermission(id, userId, "admin"); + Long targetUserId = Long.valueOf(body.get("userId").toString()); + String role = body.containsKey("role") ? body.get("role").toString() : "member"; + return R.ok(workspaceService.addMember(id, targetUserId, role)); + } + + @Operation(summary = "更新成员角色") + @PutMapping("/{id}/members/{memberId}") + public R updateMemberRole(@PathVariable Long id, + @PathVariable Long memberId, + @RequestBody Map body, + Authentication auth) { + Long userId = resolveUserId(auth); + workspaceService.requirePermission(id, userId, "admin"); + return R.ok(workspaceService.updateMemberRole(id, memberId, body.get("role"))); + } + + @Operation(summary = "移除工作区成员") + @DeleteMapping("/{id}/members/{memberId}") + public R removeMember(@PathVariable Long id, @PathVariable Long memberId, Authentication auth) { + Long userId = resolveUserId(auth); + workspaceService.requirePermission(id, userId, "admin"); + workspaceService.removeMember(id, memberId); + return R.ok(); + } + + // ==================== 工具方法 ==================== + + private Long resolveUserId(Authentication auth) { + String username = auth.getName(); + UserEntity user = authService.findByUsername(username); + if (user == null) { + throw new MateClawException("用户不存在: " + username); + } + return user.getId(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceEntity.java new file mode 100644 index 00000000..83cccdc1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceEntity.java @@ -0,0 +1,47 @@ +package vip.mate.workspace.core.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 工作区实体 + *

+ * 工作区是资源隔离的基本单元,Agent、Channel、Wiki、Conversation 都归属于某个工作区。 + * 系统自动创建 id=1 的默认工作区(default),单人部署无需感知。 + * + * @author MateClaw Team + */ +@Data +@TableName("mate_workspace") +public class WorkspaceEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** 工作区名称 */ + private String name; + + /** 工作区标识(URL 友好,唯一) */ + private String slug; + + /** 描述 */ + private String description; + + /** 拥有者用户 ID */ + private Long ownerId; + + /** 工作区级配置(JSON) */ + @TableField(value = "settings_json", updateStrategy = FieldStrategy.ALWAYS) + private String settingsJson; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceMemberEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceMemberEntity.java new file mode 100644 index 00000000..27f5c70c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceMemberEntity.java @@ -0,0 +1,48 @@ +package vip.mate.workspace.core.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 工作区成员实体 + *

+ * 关联用户与工作区,定义成员角色。 + * 角色:owner(全部权限)/ admin(管理资源)/ member(使用资源)/ viewer(只读) + * + * @author MateClaw Team + */ +@Data +@TableName("mate_workspace_member") +public class WorkspaceMemberEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** 工作区 ID */ + private Long workspaceId; + + /** 用户 ID */ + private Long userId; + + /** 角色:owner / admin / member / viewer */ + private String role; + + /** 用户名(非持久化,API 返回用) */ + @TableField(exist = false) + private String username; + + /** 昵称(非持久化,API 返回用) */ + @TableField(exist = false) + private String nickname; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/repository/WorkspaceMapper.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/repository/WorkspaceMapper.java new file mode 100644 index 00000000..890e60de --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/repository/WorkspaceMapper.java @@ -0,0 +1,14 @@ +package vip.mate.workspace.core.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.workspace.core.model.WorkspaceEntity; + +/** + * 工作区 Mapper + * + * @author MateClaw Team + */ +@Mapper +public interface WorkspaceMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/repository/WorkspaceMemberMapper.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/repository/WorkspaceMemberMapper.java new file mode 100644 index 00000000..280a09a2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/repository/WorkspaceMemberMapper.java @@ -0,0 +1,14 @@ +package vip.mate.workspace.core.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.workspace.core.model.WorkspaceMemberEntity; + +/** + * 工作区成员 Mapper + * + * @author MateClaw Team + */ +@Mapper +public interface WorkspaceMemberMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java new file mode 100644 index 00000000..3691a039 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java @@ -0,0 +1,209 @@ +package vip.mate.workspace.core.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.core.model.WorkspaceMemberEntity; +import vip.mate.workspace.core.repository.WorkspaceMapper; +import vip.mate.workspace.core.repository.WorkspaceMemberMapper; + +import java.util.List; + +/** + * 工作区业务服务 + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WorkspaceService { + + private final WorkspaceMapper workspaceMapper; + private final WorkspaceMemberMapper memberMapper; + + /** 默认工作区 slug */ + public static final String DEFAULT_SLUG = "default"; + + // ==================== 工作区 CRUD ==================== + + public List listAll() { + return workspaceMapper.selectList( + new LambdaQueryWrapper().orderByAsc(WorkspaceEntity::getCreateTime)); + } + + /** + * 查询用户可见的工作区列表(用户是其成员的所有工作区) + */ + public List listByUserId(Long userId) { + List memberships = memberMapper.selectList( + new LambdaQueryWrapper() + .eq(WorkspaceMemberEntity::getUserId, userId)); + if (memberships.isEmpty()) { + // 至少返回默认工作区 + WorkspaceEntity defaultWs = getBySlug(DEFAULT_SLUG); + return defaultWs != null ? List.of(defaultWs) : List.of(); + } + List wsIds = memberships.stream().map(WorkspaceMemberEntity::getWorkspaceId).toList(); + return workspaceMapper.selectBatchIds(wsIds); + } + + public WorkspaceEntity getById(Long id) { + WorkspaceEntity entity = workspaceMapper.selectById(id); + if (entity == null) { + throw new MateClawException("工作区不存在: " + id); + } + return entity; + } + + public WorkspaceEntity getBySlug(String slug) { + return workspaceMapper.selectOne( + new LambdaQueryWrapper() + .eq(WorkspaceEntity::getSlug, slug)); + } + + @Transactional + public WorkspaceEntity create(WorkspaceEntity entity, Long creatorUserId) { + // 验证 slug 唯一 + if (getBySlug(entity.getSlug()) != null) { + throw new MateClawException("工作区标识已存在: " + entity.getSlug()); + } + entity.setOwnerId(creatorUserId); + workspaceMapper.insert(entity); + + // 创建者自动成为 owner + WorkspaceMemberEntity member = new WorkspaceMemberEntity(); + member.setWorkspaceId(entity.getId()); + member.setUserId(creatorUserId); + member.setRole("owner"); + memberMapper.insert(member); + + log.info("Created workspace: {} (slug={}, owner={})", entity.getName(), entity.getSlug(), creatorUserId); + return entity; + } + + public WorkspaceEntity update(WorkspaceEntity entity) { + WorkspaceEntity existing = getById(entity.getId()); + // 不允许修改默认工作区的 slug + if (DEFAULT_SLUG.equals(existing.getSlug()) && !DEFAULT_SLUG.equals(entity.getSlug())) { + throw new MateClawException("不能修改默认工作区的标识"); + } + // 验证 slug 唯一性(如果修改了 slug) + if (entity.getSlug() != null && !entity.getSlug().equals(existing.getSlug())) { + if (getBySlug(entity.getSlug()) != null) { + throw new MateClawException("工作区标识已存在: " + entity.getSlug()); + } + } + workspaceMapper.updateById(entity); + return entity; + } + + public void delete(Long id) { + WorkspaceEntity existing = getById(id); + if (DEFAULT_SLUG.equals(existing.getSlug())) { + throw new MateClawException("不能删除默认工作区"); + } + workspaceMapper.deleteById(id); + log.info("Deleted workspace: {} (id={})", existing.getName(), id); + } + + // ==================== 成员管理 ==================== + + public List listMembers(Long workspaceId) { + return memberMapper.selectList( + new LambdaQueryWrapper() + .eq(WorkspaceMemberEntity::getWorkspaceId, workspaceId) + .orderByAsc(WorkspaceMemberEntity::getCreateTime)); + } + + public WorkspaceMemberEntity getMembership(Long workspaceId, Long userId) { + return memberMapper.selectOne( + new LambdaQueryWrapper() + .eq(WorkspaceMemberEntity::getWorkspaceId, workspaceId) + .eq(WorkspaceMemberEntity::getUserId, userId)); + } + + @Transactional + public WorkspaceMemberEntity addMember(Long workspaceId, Long userId, String role) { + // 验证工作区存在 + getById(workspaceId); + // 检查是否已是成员 + WorkspaceMemberEntity existing = getMembership(workspaceId, userId); + if (existing != null) { + throw new MateClawException("用户已经是该工作区的成员"); + } + WorkspaceMemberEntity member = new WorkspaceMemberEntity(); + member.setWorkspaceId(workspaceId); + member.setUserId(userId); + member.setRole(role != null ? role : "member"); + memberMapper.insert(member); + log.info("Added member to workspace: userId={}, workspaceId={}, role={}", userId, workspaceId, member.getRole()); + return member; + } + + public WorkspaceMemberEntity updateMemberRole(Long workspaceId, Long userId, String role) { + WorkspaceMemberEntity member = getMembership(workspaceId, userId); + if (member == null) { + throw new MateClawException("用户不是该工作区的成员"); + } + if ("owner".equals(member.getRole())) { + throw new MateClawException("不能修改工作区拥有者的角色"); + } + member.setRole(role); + memberMapper.updateById(member); + return member; + } + + public void removeMember(Long workspaceId, Long userId) { + WorkspaceMemberEntity member = getMembership(workspaceId, userId); + if (member == null) { + throw new MateClawException("用户不是该工作区的成员"); + } + if ("owner".equals(member.getRole())) { + throw new MateClawException("不能移除工作区拥有者"); + } + memberMapper.deleteById(member.getId()); + log.info("Removed member from workspace: userId={}, workspaceId={}", userId, workspaceId); + } + + // ==================== 权限检查 ==================== + + /** + * 检查用户是否有指定工作区的最低角色权限 + * + * @param workspaceId 工作区 ID + * @param userId 用户 ID + * @param minRole 最低角色要求:owner > admin > member > viewer + * @return true 如果用户有足够权限 + */ + public boolean hasPermission(Long workspaceId, Long userId, String minRole) { + WorkspaceMemberEntity member = getMembership(workspaceId, userId); + if (member == null) { + return false; + } + return roleLevel(member.getRole()) >= roleLevel(minRole); + } + + /** + * 断言用户有指定权限,否则抛异常 + */ + public void requirePermission(Long workspaceId, Long userId, String minRole) { + if (!hasPermission(workspaceId, userId, minRole)) { + throw new MateClawException("权限不足:需要 " + minRole + " 或更高角色"); + } + } + + private int roleLevel(String role) { + return switch (role) { + case "owner" -> 4; + case "admin" -> 3; + case "member" -> 2; + case "viewer" -> 1; + default -> 0; + }; + } +} diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index 756fc1fd..40370e30 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -1237,6 +1237,23 @@ VALUES (1000000008, 'WeChat', 'weixin', 1000000001, '', '{ }', FALSE, 'WeChat personal account channel (iLink Bot HTTP long polling). Get bot_token by scanning QR code to login, or enter existing token. Based on iLink Bot API, supports text, image, voice (ASR), file, and video messages', NOW(), NOW(), 0); +-- 9. Slack (disabled by default, requires bot_token / app_token) +MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) +KEY (id) +VALUES (1000000009, 'Slack Bot', 'slack', 1000000001, '', '{ + "bot_token": "", + "app_token": "", + "signing_secret": "", + "dm_policy": "open", + "group_policy": "mention", + "allow_from": [], + "deny_message": "Sorry, you do not have permission", + "filter_thinking": true, + "filter_tool_messages": true, + "message_format": "auto" +}', FALSE, + 'Slack channel (Socket Mode). Get Bot Token (xoxb-) and App-Level Token (xapp-) from Slack App settings, enable Socket Mode to start using.', NOW(), NOW(), 0); + -- ==================== Example Cron Jobs ==================== MERGE INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) KEY (id) diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index 000aa5b8..abbdb363 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -1243,6 +1243,23 @@ VALUES (1000000008, '微信', 'weixin', 1000000001, '', '{ }', FALSE, '微信个人号渠道(iLink Bot HTTP 长轮询)。通过扫描二维码登录获取 bot_token,或直接填入已有 token。基于 iLink Bot API,支持文本、图片、语音(ASR)、文件、视频消息', NOW(), NOW(), 0); +-- 9. Slack(默认禁用,需配置 bot_token / app_token) +MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted) +KEY (id) +VALUES (1000000009, 'Slack Bot', 'slack', 1000000001, '', '{ + "bot_token": "", + "app_token": "", + "signing_secret": "", + "dm_policy": "open", + "group_policy": "mention", + "allow_from": [], + "deny_message": "抱歉,您没有使用权限", + "filter_thinking": true, + "filter_tool_messages": true, + "message_format": "auto" +}', FALSE, + 'Slack 渠道(Socket Mode)。在 Slack App 后台获取 Bot Token(xoxb-)和 App-Level Token(xapp-),启用 Socket Mode 后即可使用。', NOW(), NOW(), 0); + -- ==================== 示例定时任务 ==================== MERGE INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted) KEY (id) diff --git a/mateclaw-server/src/main/resources/db/schema-mysql.sql b/mateclaw-server/src/main/resources/db/schema-mysql.sql index f2016e2c..8f5950c8 100644 --- a/mateclaw-server/src/main/resources/db/schema-mysql.sql +++ b/mateclaw-server/src/main/resources/db/schema-mysql.sql @@ -503,3 +503,64 @@ CREATE TABLE IF NOT EXISTS mate_wiki_page ( UNIQUE KEY uk_wiki_page_kb_slug (kb_id, slug), INDEX idx_wiki_page_kb (kb_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================= +-- 工作区表(Phase 2) +-- ============================================= + +-- 工作区 +CREATE TABLE IF NOT EXISTS mate_workspace ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + slug VARCHAR(64) NOT NULL, + description VARCHAR(256), + owner_id BIGINT, + settings_json TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_workspace_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 工作区成员 +CREATE TABLE IF NOT EXISTS mate_workspace_member ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'member', + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0, + INDEX idx_ws_member_workspace (workspace_id), + INDEX idx_ws_member_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 现有表增加 workspace_id 列(幂等) +-- MySQL 不支持 ADD COLUMN IF NOT EXISTS,使用存储过程处理 +DROP PROCEDURE IF EXISTS mate_add_workspace_id; +DELIMITER $$ +CREATE PROCEDURE mate_add_workspace_id() +BEGIN + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='mate_agent' AND COLUMN_NAME='workspace_id') THEN + ALTER TABLE mate_agent ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='mate_channel' AND COLUMN_NAME='workspace_id') THEN + ALTER TABLE mate_channel ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='mate_conversation' AND COLUMN_NAME='workspace_id') THEN + ALTER TABLE mate_conversation ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='mate_wiki_knowledge_base' AND COLUMN_NAME='workspace_id') THEN + ALTER TABLE mate_wiki_knowledge_base ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='mate_tool' AND COLUMN_NAME='workspace_id') THEN + ALTER TABLE mate_tool ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='mate_skill' AND COLUMN_NAME='workspace_id') THEN + ALTER TABLE mate_skill ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1; + END IF; +END$$ +DELIMITER ; + +CALL mate_add_workspace_id(); +DROP PROCEDURE IF EXISTS mate_add_workspace_id; diff --git a/mateclaw-server/src/main/resources/db/schema.sql b/mateclaw-server/src/main/resources/db/schema.sql index d30b08ab..d057307d 100644 --- a/mateclaw-server/src/main/resources/db/schema.sql +++ b/mateclaw-server/src/main/resources/db/schema.sql @@ -526,3 +526,42 @@ CREATE TABLE IF NOT EXISTS mate_wiki_page ( CONSTRAINT uk_wiki_page_kb_slug UNIQUE (kb_id, slug) ); CREATE INDEX IF NOT EXISTS idx_wiki_page_kb ON mate_wiki_page(kb_id); + +-- ============================================= +-- 工作区表(Phase 2) +-- ============================================= + +-- 工作区 +CREATE TABLE IF NOT EXISTS mate_workspace ( + id BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + slug VARCHAR(64) NOT NULL, + description VARCHAR(256), + owner_id BIGINT, + settings_json TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0, + CONSTRAINT uk_workspace_slug UNIQUE (slug) +); + +-- 工作区成员 +CREATE TABLE IF NOT EXISTS mate_workspace_member ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'member', + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_ws_member_workspace ON mate_workspace_member(workspace_id); +CREATE INDEX IF NOT EXISTS idx_ws_member_user ON mate_workspace_member(user_id); + +-- 现有表增加 workspace_id 列 +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; +ALTER TABLE mate_channel ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; +ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; +ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; diff --git a/mateclaw-ui/public/icons/channels/slack.svg b/mateclaw-ui/public/icons/channels/slack.svg new file mode 100644 index 00000000..738b8332 --- /dev/null +++ b/mateclaw-ui/public/icons/channels/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index a9afcfd4..3544d40b 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -7,12 +7,16 @@ export const http = axios.create({ timeout: 30000, }) -// 请求拦截器:注入 Token +// 请求拦截器:注入 Token + Workspace ID http.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } + const workspaceId = localStorage.getItem('mc-workspace-id') + if (workspaceId) { + config.headers['X-Workspace-Id'] = workspaceId + } return config }) @@ -375,3 +379,19 @@ export const wikiApi = { processKB: (kbId: number) => http.post(`/wiki/knowledge-bases/${kbId}/process`), getProcessingStatus: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/processing-status`), } + +// ==================== Workspace (Team) ==================== +export const workspaceTeamApi = { + list: () => http.get('/workspaces'), + get: (id: string | number) => http.get(`/workspaces/${id}`), + create: (data: any) => http.post('/workspaces', data), + update: (id: string | number, data: any) => http.put(`/workspaces/${id}`, data), + delete: (id: string | number) => http.delete(`/workspaces/${id}`), + listMembers: (id: string | number) => http.get(`/workspaces/${id}/members`), + addMember: (id: string | number, data: { userId: number; role?: string }) => + http.post(`/workspaces/${id}/members`, data), + updateMemberRole: (id: string | number, memberId: string | number, role: string) => + http.put(`/workspaces/${id}/members/${memberId}`, { role }), + removeMember: (id: string | number, memberId: string | number) => + http.delete(`/workspaces/${id}/members/${memberId}`), +} diff --git a/mateclaw-ui/src/components/chat/BrowserTimeline.vue b/mateclaw-ui/src/components/chat/BrowserTimeline.vue new file mode 100644 index 00000000..1b7a4217 --- /dev/null +++ b/mateclaw-ui/src/components/chat/BrowserTimeline.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/mateclaw-ui/src/components/chat/ChatInput.vue b/mateclaw-ui/src/components/chat/ChatInput.vue index 4414bcb7..4c17e5f7 100644 --- a/mateclaw-ui/src/components/chat/ChatInput.vue +++ b/mateclaw-ui/src/components/chat/ChatInput.vue @@ -146,6 +146,22 @@ + + + + + +