feat(platform): add workspace foundation and channel execution upgrades

This commit is contained in:
matevip 2026-04-09 10:29:16 +08:00
parent f6a3a1592a
commit 3d58a48eae
63 changed files with 4234 additions and 157 deletions

View File

@ -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> media;
public UserMessage(String textContent) {
this(textContent, new ArrayList<>(), Map.of());
}
private UserMessage(String textContent, Collection<Media> media, Map<String, Object> 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<Media> 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> media = new ArrayList<>();
private Map<String, Object> 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> 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<String, Object> 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);
}
}
}

View File

@ -244,6 +244,29 @@
</exclusions>
</dependency>
<!-- ===== Spring WebSocketTalk Mode ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- ===== Slack SDKSocket Mode + Web API ===== -->
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>slack-api-client</artifactId>
<version>1.44.2</version>
</dependency>
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>bolt-socket-mode</artifactId>
<version>1.44.2</version>
</dependency>
<dependency>
<groupId>org.glassfish.tyrus.bundles</groupId>
<artifactId>tyrus-standalone-client</artifactId>
<version>2.2.0</version>
</dependency>
<!-- ===== Spring Boot Test ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@ -44,6 +44,15 @@ public class AgentService {
.orderByDesc(AgentEntity::getCreateTime));
}
/**
* 按工作区列出 Agent
*/
public List<AgentEntity> listAgentsByWorkspace(Long workspaceId) {
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId)
.orderByDesc(AgentEntity::getCreateTime));
}
public AgentEntity getAgent(Long id) {
AgentEntity entity = agentMapper.selectById(id);
if (entity == null) {

View File

@ -33,7 +33,11 @@ public class AgentController {
@Operation(summary = "获取Agent列表")
@GetMapping
public R<List<AgentEntity>> list() {
public R<List<AgentEntity>> 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<AgentEntity> create(@RequestBody AgentEntity agent) {
public R<AgentEntity> 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));
}

View File

@ -49,6 +49,9 @@ public class AgentEntity {
/** 标签(逗号分隔) */
private String tags;
/** 所属工作区 ID默认 1 = default */
private Long workspaceId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -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())

View File

@ -63,7 +63,7 @@ public class ChannelManager {
/** 支持的渠道类型 */
private static final Set<String> 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);
};
}

View File

@ -31,7 +31,11 @@ public class ChannelController {
@Operation(summary = "获取渠道列表")
@GetMapping
public R<List<ChannelEntity>> list() {
public R<List<ChannelEntity>> 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<ChannelEntity> create(@RequestBody ChannelEntity channel) {
public R<ChannelEntity> 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));
}

View File

@ -119,6 +119,23 @@ public class ChannelWebhookController {
return ResponseEntity.ok("success");
}
@Operation(summary = "Slack Events API 回调")
@PostMapping("/slack")
public ResponseEntity<Map<String, Object>> slackWebhook(@RequestBody Map<String, Object> 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<ChannelAdapter> adapter = channelManager.getAdapterByType("slack");
if (adapter.isPresent() && adapter.get() instanceof vip.mate.channel.slack.SlackChannelAdapter slack) {
Map<String, Object> 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 ====================
/** 微信扫码深链接模板 */

View File

@ -40,6 +40,9 @@ public class ChannelEntity {
/** 渠道描述 */
private String description;
/** 所属工作区 ID默认 1 = default */
private Long workspaceId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -34,6 +34,16 @@ public class ChannelService {
.orderByDesc(ChannelEntity::getCreateTime));
}
/**
* 按工作区列出渠道
*/
public List<ChannelEntity> listChannelsByWorkspace(Long workspaceId) {
return channelMapper.selectList(new LambdaQueryWrapper<ChannelEntity>()
.eq(ChannelEntity::getWorkspaceId, workspaceId)
.orderByDesc(ChannelEntity::getEnabled)
.orderByDesc(ChannelEntity::getCreateTime));
}
/**
* 获取已启用的渠道列表ChannelManager 启动时使用
*/

View File

@ -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 渠道适配器
* <p>
* 通过 Socket Mode 接入 Slack支持
* - 频道消息自动 thread reply避免刷屏
* - DM 私聊
* - 审批命令识别复用 ChannelMessageRouter 的审批拦截
* - proactiveSend 主动推送
* <p>
* configJson 配置项
* - bot_token: Slack Bot OAuth Token (xoxb-...)
* - app_token: Slack App-Level Token (xapp-...) Socket Mode 必需
* - signing_secret: Slack Signing SecretWebhook 模式使用
*
* @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<String, String> 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<String, Object> handleWebhook(Map<String, Object> 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) -> <url|text>
result = result.replaceAll("\\[([^]]+)]\\(([^)]+)\\)", "<$2|$1>");
// # Header -> *Header*
result = result.replaceAll("(?m)^#{1,6}\\s+(.+)$", "*$1*");
return result;
}
}

View File

@ -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
* <p>
* 处理语音交互的完整循环
* 1. 接收前端音频 binary frame
* 2. STT 转文字
* 3. Agent 对话
* 4. TTS 合成音频
* 5. 推送音频 + 文字回前端
* <p>
* 前端初始化时发送 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<String, TalkSession> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> data) throws IOException {
if (session.isOpen()) {
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(data)));
}
}
}

View File

@ -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 渠道适配器
* <p>
* 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);
}
}

View File

@ -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 嵌入式对话接口
* <p>
* 独立于 ChatController使用 API Key 认证不依赖 JWT
* 供外部网站通过 JS SDK 嵌入 MateClaw 对话能力
* <p>
* 认证方式请求头 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<Map<String, Object>> 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<ChannelEntity> 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;
}
}

View File

@ -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()

View File

@ -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 配置
* <p>
* 注册 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("*");
}
}

View File

@ -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<String, Object> 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);

View File

@ -50,7 +50,11 @@ public class WikiController {
@Operation(summary = "获取所有知识库")
@GetMapping("/knowledge-bases")
public R<List<WikiKnowledgeBaseEntity>> listKBs() {
public R<List<WikiKnowledgeBaseEntity>> listKBs(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
if (workspaceId != null) {
return R.ok(kbService.listByWorkspace(workspaceId));
}
return R.ok(kbService.listAll());
}

View File

@ -42,6 +42,9 @@ public class WikiKnowledgeBaseEntity {
/** 原始材料数量 */
private Integer rawCount;
/** 所属工作区 ID默认 1 = default */
private Long workspaceId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -25,6 +25,78 @@ public class WikiContextService {
private final WikiPageService pageService;
private final WikiProperties properties;
/**
* 构建与用户消息相关的 Wiki 上下文任务前知识注入
* <p>
* 从用户消息中提取关键词匹配 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<WikiKnowledgeBaseEntity> 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<WikiPageEntity> 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();

View File

@ -52,6 +52,16 @@ public class WikiKnowledgeBaseService {
.orderByDesc(WikiKnowledgeBaseEntity::getUpdateTime));
}
/**
* 按工作区列出知识库
*/
public List<WikiKnowledgeBaseEntity> listByWorkspace(Long workspaceId) {
return kbMapper.selectList(
new LambdaQueryWrapper<WikiKnowledgeBaseEntity>()
.eq(WikiKnowledgeBaseEntity::getWorkspaceId, workspaceId)
.orderByDesc(WikiKnowledgeBaseEntity::getUpdateTime));
}
/**
* 获取 Agent 可访问的知识库Agent 专属 KB + 公共 KBagent_id IS NULL
*/

View File

@ -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
* <p>

View File

@ -50,11 +50,21 @@ public class ConversationService {
* 获取用户的会话列表返回 VO包含 agentName/agentIcon/status
*/
public List<ConversationVO> listConversations(String username) {
return listConversations(username, null);
}
/**
* 获取用户的会话列表按工作区过滤
*/
public List<ConversationVO> listConversations(String username, Long workspaceId) {
// 同时返回当前用户的会话 定时任务system产生的会话
List<ConversationEntity> entities = conversationMapper.selectList(
new LambdaQueryWrapper<ConversationEntity>()
.in(ConversationEntity::getUsername, username, SYSTEM_USER)
.orderByDesc(ConversationEntity::getLastActiveTime));
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
.in(ConversationEntity::getUsername, username, SYSTEM_USER)
.orderByDesc(ConversationEntity::getLastActiveTime);
if (workspaceId != null) {
wrapper.eq(ConversationEntity::getWorkspaceId, workspaceId);
}
List<ConversationEntity> entities = conversationMapper.selectList(wrapper);
if (entities.isEmpty()) {
return List.of();

View File

@ -34,9 +34,11 @@ public class ConversationController {
*/
@Operation(summary = "获取会话列表")
@GetMapping
public R<List<ConversationVO>> list(Authentication auth) {
public R<List<ConversationVO>> 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));
}
/**

View File

@ -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;

View File

@ -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 迁移
* <p>
* 确保默认工作区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());
}
}
}

View File

@ -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<WorkspaceEntity>> list(Authentication auth) {
Long userId = resolveUserId(auth);
return R.ok(workspaceService.listByUserId(userId));
}
@Operation(summary = "获取工作区详情")
@GetMapping("/{id}")
public R<WorkspaceEntity> get(@PathVariable Long id) {
return R.ok(workspaceService.getById(id));
}
@Operation(summary = "创建工作区")
@PostMapping
public R<WorkspaceEntity> create(@RequestBody WorkspaceEntity entity, Authentication auth) {
Long userId = resolveUserId(auth);
return R.ok(workspaceService.create(entity, userId));
}
@Operation(summary = "更新工作区")
@PutMapping("/{id}")
public R<WorkspaceEntity> 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<Void> 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<List<WorkspaceMemberEntity>> listMembers(@PathVariable Long id) {
List<WorkspaceMemberEntity> 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<WorkspaceMemberEntity> addMember(@PathVariable Long id,
@RequestBody Map<String, Object> 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<WorkspaceMemberEntity> updateMemberRole(@PathVariable Long id,
@PathVariable Long memberId,
@RequestBody Map<String, String> 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<Void> 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();
}
}

View File

@ -0,0 +1,47 @@
package vip.mate.workspace.core.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 工作区实体
* <p>
* 工作区是资源隔离的基本单元AgentChannelWikiConversation 都归属于某个工作区
* 系统自动创建 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;
}

View File

@ -0,0 +1,48 @@
package vip.mate.workspace.core.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 工作区成员实体
* <p>
* 关联用户与工作区定义成员角色
* 角色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;
}

View File

@ -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<WorkspaceEntity> {
}

View File

@ -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<WorkspaceMemberEntity> {
}

View File

@ -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<WorkspaceEntity> listAll() {
return workspaceMapper.selectList(
new LambdaQueryWrapper<WorkspaceEntity>().orderByAsc(WorkspaceEntity::getCreateTime));
}
/**
* 查询用户可见的工作区列表用户是其成员的所有工作区
*/
public List<WorkspaceEntity> listByUserId(Long userId) {
List<WorkspaceMemberEntity> memberships = memberMapper.selectList(
new LambdaQueryWrapper<WorkspaceMemberEntity>()
.eq(WorkspaceMemberEntity::getUserId, userId));
if (memberships.isEmpty()) {
// 至少返回默认工作区
WorkspaceEntity defaultWs = getBySlug(DEFAULT_SLUG);
return defaultWs != null ? List.of(defaultWs) : List.of();
}
List<Long> 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<WorkspaceEntity>()
.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<WorkspaceMemberEntity> listMembers(Long workspaceId) {
return memberMapper.selectList(
new LambdaQueryWrapper<WorkspaceMemberEntity>()
.eq(WorkspaceMemberEntity::getWorkspaceId, workspaceId)
.orderByAsc(WorkspaceMemberEntity::getCreateTime));
}
public WorkspaceMemberEntity getMembership(Long workspaceId, Long userId) {
return memberMapper.selectOne(
new LambdaQueryWrapper<WorkspaceMemberEntity>()
.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;
};
}
}

View File

@ -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)

View File

@ -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 Tokenxoxb-)和 App-Level Tokenxapp-),启用 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)

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,6 @@
<svg width="126" height="126" viewBox="0 0 126 126" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.5 79.4C26.5 86.7 20.6 92.6 13.3 92.6C6 92.6 0.1 86.7 0.1 79.4C0.1 72.1 6 66.2 13.3 66.2H26.5V79.4ZM33.1 79.4C33.1 72.1 39 66.2 46.3 66.2C53.6 66.2 59.5 72.1 59.5 79.4V112.4C59.5 119.7 53.6 125.6 46.3 125.6C39 125.6 33.1 119.7 33.1 112.4V79.4Z" fill="#E01E5A"/>
<path d="M46.3 26.4C39 26.4 33.1 20.5 33.1 13.2C33.1 5.9 39 0 46.3 0C53.6 0 59.5 5.9 59.5 13.2V26.4H46.3ZM46.3 33.1C53.6 33.1 59.5 39 59.5 46.3C59.5 53.6 53.6 59.5 46.3 59.5H13.2C5.9 59.5 0 53.6 0 46.3C0 39 5.9 33.1 13.2 33.1H46.3Z" fill="#36C5F0"/>
<path d="M99.2 46.3C99.2 39 105.1 33.1 112.4 33.1C119.7 33.1 125.6 39 125.6 46.3C125.6 53.6 119.7 59.5 112.4 59.5H99.2V46.3ZM92.6 46.3C92.6 53.6 86.7 59.5 79.4 59.5C72.1 59.5 66.2 53.6 66.2 46.3V13.2C66.2 5.9 72.1 0 79.4 0C86.7 0 92.6 5.9 92.6 13.2V46.3Z" fill="#2EB67D"/>
<path d="M79.4 99.2C86.7 99.2 92.6 105.1 92.6 112.4C92.6 119.7 86.7 125.6 79.4 125.6C72.1 125.6 66.2 119.7 66.2 112.4V99.2H79.4ZM79.4 92.6C72.1 92.6 66.2 86.7 66.2 79.4C66.2 72.1 72.1 66.2 79.4 66.2H112.5C119.8 66.2 125.7 72.1 125.7 79.4C125.7 86.7 119.8 92.6 112.5 92.6H79.4Z" fill="#ECB22E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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}`),
}

View File

@ -0,0 +1,221 @@
<template>
<div v-if="actions.length > 0" class="browser-timeline">
<div class="bt-header" @click="expanded = !expanded">
<span class="bt-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="2" y1="9" x2="22" y2="9"/>
<circle cx="6" cy="6" r="0.5" fill="currentColor"/>
<circle cx="9" cy="6" r="0.5" fill="currentColor"/>
</svg>
</span>
<span class="bt-title">{{ t('browser.timeline.title', { count: actions.length }) }}</span>
<svg class="bt-chevron" :class="{ open: expanded }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<Transition name="slide">
<div v-if="expanded" class="bt-actions">
<div
v-for="(action, idx) in actions"
:key="idx"
class="bt-action"
:class="{ 'bt-action--failed': !action.success }"
>
<span class="bt-action-dot" :class="actionClass(action.action)"></span>
<span class="bt-action-label">{{ action.action }}</span>
<span v-if="action.url" class="bt-action-url" :title="action.url">{{ truncateUrl(action.url) }}</span>
<span v-if="action.title" class="bt-action-title">{{ action.title }}</span>
<span v-if="action.durationMs > 0" class="bt-action-duration">{{ action.durationMs }}ms</span>
</div>
<!-- Screenshot preview -->
<div v-if="latestScreenshot" class="bt-screenshot">
<img :src="'data:image/png;base64,' + latestScreenshot" alt="Browser screenshot" />
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
export interface BrowserAction {
action: string
success: boolean
url?: string
title?: string
screenshot?: string
durationMs: number
timestamp: number
}
const props = defineProps<{
actions: BrowserAction[]
}>()
const expanded = ref(true)
const latestScreenshot = computed(() => {
for (let i = props.actions.length - 1; i >= 0; i--) {
if (props.actions[i].screenshot) {
return props.actions[i].screenshot
}
}
return null
})
function actionClass(action: string) {
switch (action) {
case 'start': return 'bt-dot--start'
case 'stop': return 'bt-dot--stop'
case 'open': return 'bt-dot--open'
case 'click': return 'bt-dot--click'
case 'type': return 'bt-dot--type'
case 'screenshot': return 'bt-dot--screenshot'
default: return ''
}
}
function truncateUrl(url: string) {
try {
const u = new URL(url)
const path = u.pathname.length > 30 ? u.pathname.slice(0, 30) + '...' : u.pathname
return u.hostname + path
} catch {
return url.length > 50 ? url.slice(0, 50) + '...' : url
}
}
</script>
<style scoped>
.browser-timeline {
margin: 8px 0;
border: 1px solid var(--el-border-color-lighter, #ebeef5);
border-radius: 8px;
overflow: hidden;
font-size: 13px;
}
.bt-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--el-fill-color-light, #f5f7fa);
cursor: pointer;
user-select: none;
}
.bt-header:hover {
background: var(--el-fill-color, #f0f2f5);
}
.bt-icon {
display: flex;
opacity: 0.6;
}
.bt-title {
flex: 1;
font-weight: 500;
color: var(--el-text-color-regular, #606266);
}
.bt-chevron {
transition: transform 0.2s;
opacity: 0.5;
}
.bt-chevron.open {
transform: rotate(180deg);
}
.bt-actions {
padding: 4px 0;
}
.bt-action {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 16px;
color: var(--el-text-color-regular, #606266);
}
.bt-action--failed {
color: var(--el-color-danger, #f56c6c);
}
.bt-action-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--el-color-info, #909399);
}
.bt-dot--start { background: var(--el-color-success, #67c23a); }
.bt-dot--stop { background: var(--el-color-danger, #f56c6c); }
.bt-dot--open { background: var(--el-color-primary, #409eff); }
.bt-dot--click { background: var(--el-color-warning, #e6a23c); }
.bt-dot--type { background: #9b59b6; }
.bt-dot--screenshot { background: #1abc9c; }
.bt-action-label {
font-weight: 500;
min-width: 64px;
font-family: monospace;
}
.bt-action-url {
color: var(--el-color-primary, #409eff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.bt-action-title {
color: var(--el-text-color-secondary, #909399);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.bt-action-duration {
color: var(--el-text-color-secondary, #909399);
font-size: 12px;
flex-shrink: 0;
}
.bt-screenshot {
padding: 8px 12px;
border-top: 1px solid var(--el-border-color-extra-light, #f2f6fc);
}
.bt-screenshot img {
width: 100%;
max-height: 300px;
object-fit: contain;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter, #ebeef5);
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.2s ease;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
max-height: 0;
}
</style>

View File

@ -146,6 +146,22 @@
</svg>
</button>
<!-- Talk Mode 按钮 -->
<button
v-if="enableTalkMode"
type="button"
class="action-btn talk-btn"
:disabled="disabled || loading"
@click="emit('talk')"
:title="t('talk.title')"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</button>
<!-- 发送/停止/中断按钮 -->
<button
type="button"
@ -227,6 +243,8 @@ interface Props {
queuedMessage?: QueuedMessage | null
/** 排队消息总数 */
queueSize?: number
/** 是否启用 Talk Mode 按钮 */
enableTalkMode?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -243,6 +261,7 @@ const props = withDefaults(defineProps<Props>(), {
streamPhase: 'idle',
queuedMessage: null,
queueSize: 0,
enableTalkMode: false,
})
const emit = defineEmits<{
@ -254,6 +273,7 @@ const emit = defineEmits<{
'attachment-remove': [storedName: string]
approve: [pendingId: string]
deny: [pendingId: string]
talk: []
}>()
const { t } = useI18n()
@ -563,6 +583,11 @@ defineExpose({
cursor: not-allowed;
}
.talk-btn:hover:not(:disabled) {
color: var(--mc-primary, #D97757);
background: var(--mc-primary-light, rgba(217, 119, 87, 0.08));
}
.send-btn {
background: var(--mc-primary, #D97757);
color: white;

View File

@ -117,6 +117,9 @@
</Transition>
</div>
<!-- 浏览器执行时间线 -->
<BrowserTimeline v-if="browserActionsMeta.length" :actions="browserActionsMeta" />
<!-- 工具审批面板 -->
<div v-if="pendingApproval" class="approval-section" :class="approvalSeverityClass">
<div class="approval-header">
@ -325,6 +328,8 @@ import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
import { http } from '@/api'
import TypingCursor from './TypingCursor.vue'
import BrowserTimeline from './BrowserTimeline.vue'
import type { BrowserAction } from './BrowserTimeline.vue'
import type { Message, ChatAttachment, ToolCallMeta, PlanMeta } from '@/types'
import type { ChatErrorInfo } from '@/types/chatError'
@ -590,6 +595,10 @@ const toolCallsMeta = computed<ToolCallMeta[]>(() => {
return props.message.metadata?.toolCalls || []
})
const browserActionsMeta = computed<BrowserAction[]>(() => {
return props.message.metadata?.browserActions || []
})
const planMeta = computed<PlanMeta | undefined>(() => {
return props.message.metadata?.plan
})

View File

@ -0,0 +1,465 @@
<template>
<Transition name="talk-fade">
<div v-if="visible" class="talk-overlay">
<!-- Header -->
<div class="talk-header">
<span class="talk-title">{{ t('talk.title') }}</span>
<button class="talk-close" @click="$emit('close')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Visualizer -->
<div class="talk-visualizer">
<div class="talk-pulse" :class="stateClass">
<div class="talk-pulse-ring"></div>
<div class="talk-pulse-ring talk-pulse-ring--2"></div>
<div class="talk-pulse-core">
<svg v-if="state === 'idle'" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<svg v-else-if="state === 'listening'" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="pulse-anim">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/>
</svg>
<svg v-else-if="state === 'processing'" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
<path d="M21 12a9 9 0 11-6.219-8.56"/>
</svg>
<svg v-else width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
</div>
</div>
<div class="talk-state-label">{{ stateLabel }}</div>
</div>
<!-- Transcript -->
<div class="talk-transcript">
<div v-for="(msg, i) in transcript" :key="i" class="talk-msg" :class="'talk-msg--' + msg.role">
<span class="talk-msg-role">{{ msg.role === 'user' ? t('talk.you') : t('talk.ai') }}</span>
<span class="talk-msg-text">{{ msg.text }}</span>
</div>
</div>
<!-- Push-to-Talk button -->
<div class="talk-controls">
<button
class="talk-ptt"
:class="{ active: state === 'listening' }"
:disabled="state === 'processing' || state === 'speaking'"
@mousedown="startListening"
@mouseup="stopListening"
@touchstart.prevent="startListening"
@touchend.prevent="stopListening"
>
{{ state === 'listening' ? t('talk.releaseToSend') : t('talk.holdToTalk') }}
</button>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
const { t } = useI18n()
const props = defineProps<{
visible: boolean
agentId: string | number | null
conversationId?: string
}>()
const emit = defineEmits<{
close: []
}>()
type TalkState = 'idle' | 'listening' | 'processing' | 'speaking'
const state = ref<TalkState>('idle')
const transcript = ref<Array<{ role: 'user' | 'assistant'; text: string }>>([])
let ws: WebSocket | null = null
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
let audioContext: AudioContext | null = null
const stateClass = computed(() => 'talk-state--' + state.value)
const stateLabel = computed(() => {
switch (state.value) {
case 'idle': return t('talk.ready')
case 'listening': return t('talk.listening')
case 'processing': return t('talk.processing')
case 'speaking': return t('talk.speaking')
default: return ''
}
})
onMounted(() => {
connectWebSocket()
})
onBeforeUnmount(() => {
disconnectWebSocket()
})
function connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const token = localStorage.getItem('token')
const wsUrl = `${protocol}//${location.host}/api/v1/talk/ws${token ? '?token=' + token : ''}`
ws = new WebSocket(wsUrl)
ws.onopen = () => {
// Send init message
ws?.send(JSON.stringify({
type: 'init',
agentId: props.agentId,
conversationId: props.conversationId || 'talk-' + Date.now(),
username: localStorage.getItem('username') || 'anonymous',
}))
}
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
// Binary = TTS audio
playAudio(event.data)
return
}
try {
const data = JSON.parse(event.data)
switch (data.type) {
case 'ready':
state.value = 'idle'
break
case 'state':
state.value = data.state as TalkState
break
case 'transcript':
transcript.value.push({ role: 'user', text: data.text })
break
case 'reply':
transcript.value.push({ role: 'assistant', text: data.text })
break
case 'tts_url':
playAudioUrl(data.url)
break
case 'error':
ElMessage.error(data.message || t('talk.connectionError'))
state.value = 'idle'
break
}
} catch {
// ignore non-JSON frames
}
}
ws.onclose = () => {
state.value = 'idle'
}
}
function disconnectWebSocket() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
ws?.close()
ws = null
audioContext?.close()
audioContext = null
}
async function startListening() {
if (state.value !== 'idle') return
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
audioChunks = []
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' })
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
}
}
mediaRecorder.onstop = async () => {
// Stop all tracks
stream.getTracks().forEach(t => t.stop())
if (audioChunks.length === 0) {
state.value = 'idle'
return
}
// Combine chunks and send
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
const arrayBuffer = await audioBlob.arrayBuffer()
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(arrayBuffer)
state.value = 'processing'
} else {
state.value = 'idle'
}
}
mediaRecorder.start()
state.value = 'listening'
} catch {
ElMessage.error(t('talk.micError'))
state.value = 'idle'
}
}
function stopListening() {
if (state.value !== 'listening' || !mediaRecorder) return
mediaRecorder.stop()
}
async function playAudio(blob: Blob) {
try {
if (!audioContext) {
audioContext = new AudioContext()
}
const arrayBuffer = await blob.arrayBuffer()
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
const source = audioContext.createBufferSource()
source.buffer = audioBuffer
source.connect(audioContext.destination)
source.onended = () => {
state.value = 'idle'
}
source.start(0)
state.value = 'speaking'
} catch {
ElMessage.warning(t('talk.playbackError'))
state.value = 'idle'
}
}
function playAudioUrl(url: string) {
const audio = new Audio(url)
audio.onended = () => { state.value = 'idle' }
audio.onerror = () => { state.value = 'idle' }
audio.play().catch(() => { state.value = 'idle' })
}
</script>
<style scoped>
.talk-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--el-bg-color, #fff);
display: flex;
flex-direction: column;
align-items: center;
}
.talk-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
}
.talk-title {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary, #303133);
}
.talk-close {
background: none;
border: none;
cursor: pointer;
color: var(--el-text-color-regular, #606266);
padding: 4px;
border-radius: 6px;
}
.talk-close:hover {
background: var(--el-fill-color-light, #f5f7fa);
}
.talk-visualizer {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
}
.talk-pulse {
position: relative;
width: 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.talk-pulse-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--el-color-primary, #409eff);
opacity: 0.2;
}
.talk-state--listening .talk-pulse-ring {
animation: pulse-ring 1.5s ease-out infinite;
opacity: 0.4;
}
.talk-state--listening .talk-pulse-ring--2 {
animation-delay: 0.5s;
}
.talk-state--speaking .talk-pulse-ring {
animation: pulse-ring 2s ease-out infinite;
border-color: var(--el-color-success, #67c23a);
opacity: 0.3;
}
.talk-pulse-core {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--el-fill-color-light, #f5f7fa);
display: flex;
align-items: center;
justify-content: center;
color: var(--el-color-primary, #409eff);
transition: all 0.3s;
}
.talk-state--listening .talk-pulse-core {
background: var(--el-color-primary-light-9, #ecf5ff);
color: var(--el-color-primary, #409eff);
}
.talk-state--processing .talk-pulse-core {
background: var(--el-color-warning-light-9, #fdf6ec);
color: var(--el-color-warning, #e6a23c);
}
.talk-state--speaking .talk-pulse-core {
background: var(--el-color-success-light-9, #f0f9eb);
color: var(--el-color-success, #67c23a);
}
.talk-state-label {
font-size: 14px;
color: var(--el-text-color-secondary, #909399);
font-weight: 500;
}
.talk-transcript {
width: 100%;
max-width: 600px;
max-height: 200px;
overflow-y: auto;
padding: 0 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.talk-msg {
display: flex;
gap: 8px;
font-size: 14px;
line-height: 1.5;
}
.talk-msg-role {
font-weight: 600;
min-width: 32px;
color: var(--el-text-color-secondary, #909399);
}
.talk-msg--user .talk-msg-role { color: var(--el-color-primary, #409eff); }
.talk-msg--assistant .talk-msg-role { color: var(--el-color-success, #67c23a); }
.talk-msg-text {
color: var(--el-text-color-primary, #303133);
}
.talk-controls {
padding: 32px;
}
.talk-ptt {
width: 200px;
height: 56px;
border-radius: 28px;
border: 2px solid var(--el-color-primary, #409eff);
background: transparent;
color: var(--el-color-primary, #409eff);
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
user-select: none;
-webkit-user-select: none;
}
.talk-ptt:hover:not(:disabled) {
background: var(--el-color-primary-light-9, #ecf5ff);
}
.talk-ptt.active {
background: var(--el-color-primary, #409eff);
color: white;
transform: scale(1.05);
}
.talk-ptt:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 0.4; }
100% { transform: scale(1.6); opacity: 0; }
}
.pulse-anim {
animation: pulse-icon 1s ease-in-out infinite;
}
@keyframes pulse-icon {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.talk-fade-enter-active,
.talk-fade-leave-active {
transition: opacity 0.3s;
}
.talk-fade-enter-from,
.talk-fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,137 @@
<template>
<div class="workspace-switcher" :class="{ collapsed }">
<el-dropdown
v-if="workspaces.length > 0"
trigger="click"
:teleported="true"
@command="onSwitch"
>
<button class="ws-trigger" :title="collapsed ? currentLabel : ''">
<span class="ws-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
</span>
<span v-if="!collapsed" class="ws-name">{{ currentLabel }}</span>
<svg v-if="!collapsed" class="ws-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="ws in workspaces"
:key="ws.id"
:command="ws.id"
:class="{ 'is-active': ws.id === currentWorkspaceId }"
>
<span class="ws-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
</span>
{{ ws.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
defineProps<{
collapsed?: boolean
}>()
const store = useWorkspaceStore()
const workspaces = computed(() => store.workspaces)
const currentWorkspaceId = computed(() => store.currentWorkspaceId)
const currentLabel = computed(() => store.currentWorkspace?.name || 'Workspace')
onMounted(() => {
store.fetchWorkspaces()
})
function onSwitch(id: number) {
store.switchWorkspace(id)
// Reload current page data by emitting event
window.dispatchEvent(new CustomEvent('workspace-changed', { detail: { workspaceId: id } }))
}
</script>
<style scoped>
.workspace-switcher {
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter, #ebeef5);
}
.workspace-switcher.collapsed {
padding: 8px 4px;
display: flex;
justify-content: center;
}
.ws-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border: none;
border-radius: 6px;
background: var(--el-fill-color-light, #f5f7fa);
color: var(--el-text-color-primary, #303133);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.2s;
}
.ws-trigger:hover {
background: var(--el-fill-color, #f0f2f5);
}
.collapsed .ws-trigger {
width: 36px;
height: 36px;
padding: 0;
justify-content: center;
}
.ws-icon {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.7;
}
.ws-name {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-chevron {
flex-shrink: 0;
opacity: 0.5;
}
.ws-menu-icon {
display: inline-flex;
margin-right: 6px;
opacity: 0.6;
}
:deep(.is-active) {
color: var(--el-color-primary);
font-weight: 600;
}
</style>

View File

@ -385,6 +385,31 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
})
// ===== Browser 执行事件 =====
stream.on('browser_action', (data) => {
if (currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value)
if (msg) {
const metadata = parseMetadata((msg as any).metadata)
const browserActions = [...(metadata?.browserActions || [])]
browserActions.push({
action: data.action,
success: data.success,
url: data.url,
title: data.title,
screenshot: data.screenshot,
durationMs: data.durationMs,
timestamp: data.timestamp || Date.now()
})
updateMessage(currentAssistantId.value, {
...msg,
metadata: { ...metadata, browserActions }
} as any)
}
}
})
stream.on('phase', (data) => {
const phase = data.phase as StreamPhase
if (phase) {

View File

@ -37,6 +37,8 @@ export type SSEEventType =
| 'async_task_completed'
// TTS 事件
| 'tts_ready'
// 浏览器执行事件
| 'browser_action'
export interface SSEEvent {
type: SSEEventType

View File

@ -88,14 +88,19 @@ const markedInstance = new Marked({
// 配置 DOMPurify — 允许 Markdown + 代码块复制按钮的标签和属性
const purifyConfig = {
ADD_ATTR: ['target', 'rel', 'class', 'data-code', 'data-echarts-option', 'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd', 'x', 'y', 'width', 'height', 'rx', 'ry', 'points'],
ADD_ATTR: ['target', 'rel', 'class', 'data-code', 'data-echarts-option', 'data-wiki-title', 'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd', 'x', 'y', 'width', 'height', 'rx', 'ry', 'points'],
ADD_TAGS: ['input', 'button', 'svg', 'path', 'rect', 'polyline', 'circle', 'line', 'span'],
}
export function useMarkdownRenderer() {
function renderMarkdown(content: string): string {
if (!content) return ''
const rawHtml = markedInstance.parse(content) as string
// 将 [[Wiki Link]] 转换为可点击的 Wiki 引用链接
const withWikiLinks = content.replace(
/\[\[([^\]]+)\]\]/g,
'<a class="wiki-link" href="#" data-wiki-title="$1" onclick="window.dispatchEvent(new CustomEvent(\'wiki-link-click\',{detail:{title:\'$1\'}}));return false">$1</a>'
)
const rawHtml = markedInstance.parse(withWikiLinks) as string
return DOMPurify.sanitize(rawHtml, purifyConfig)
}

View File

@ -606,6 +606,46 @@ export default {
toolGuard: 'Tool Guard',
fileGuard: 'File Guard',
auditLogs: 'Audit Logs',
members: 'Members',
},
members: {
title: 'Workspace Members',
desc: 'Manage members and roles for the current workspace.',
addMember: 'Add Member',
noMembers: 'No members found.',
loading: 'Loading...',
columns: {
user: 'User',
role: 'Role',
joined: 'Joined',
actions: 'Actions',
},
roles: {
owner: 'Owner',
admin: 'Admin',
member: 'Member',
viewer: 'Viewer',
},
addDialog: {
title: 'Add Member',
userId: 'User ID',
userIdPlaceholder: 'Enter user ID',
role: 'Role',
},
actions: {
remove: 'Remove',
cancel: 'Cancel',
confirm: 'Confirm',
},
messages: {
addSuccess: 'Member added successfully',
addFailed: 'Failed to add member',
updateSuccess: 'Role updated successfully',
updateFailed: 'Failed to update role',
removeConfirm: 'Are you sure you want to remove this member?',
removeSuccess: 'Member removed',
removeFailed: 'Failed to remove member',
},
},
toolGuard: {
title: 'Tool Guard',
@ -1081,6 +1121,7 @@ export default {
wecom: 'WeChat Work',
weixin: 'WeChat',
qq: 'QQ',
slack: 'Slack',
webhook: 'Webhook',
},
tabs: {
@ -1296,4 +1337,31 @@ export default {
skip: 'Skip',
back: 'Back',
},
talk: {
title: 'Talk Mode',
ready: 'Ready',
listening: 'Listening...',
processing: 'Thinking...',
speaking: 'Speaking...',
holdToTalk: 'Hold to talk',
releaseToSend: 'Release to send',
you: 'You',
ai: 'AI',
micError: 'Cannot access microphone',
connectionError: 'Connection error',
playbackError: 'Audio playback failed',
},
browser: {
timeline: {
title: 'Browser ({count} actions)',
},
actions: {
start: 'start',
stop: 'stop',
open: 'open',
click: 'click',
type: 'type',
screenshot: 'screenshot',
},
},
} as const

View File

@ -606,6 +606,46 @@ export default {
toolGuard: '工具防护',
fileGuard: '文件防护',
auditLogs: '审计日志',
members: '成员管理',
},
members: {
title: '工作区成员',
desc: '管理当前工作区的成员和角色。',
addMember: '添加成员',
noMembers: '暂无成员。',
loading: '加载中...',
columns: {
user: '用户',
role: '角色',
joined: '加入时间',
actions: '操作',
},
roles: {
owner: '所有者',
admin: '管理员',
member: '成员',
viewer: '查看者',
},
addDialog: {
title: '添加成员',
userId: '用户 ID',
userIdPlaceholder: '输入用户 ID',
role: '角色',
},
actions: {
remove: '移除',
cancel: '取消',
confirm: '确认',
},
messages: {
addSuccess: '成员添加成功',
addFailed: '添加成员失败',
updateSuccess: '角色更新成功',
updateFailed: '更新角色失败',
removeConfirm: '确定要移除该成员吗?',
removeSuccess: '成员已移除',
removeFailed: '移除成员失败',
},
},
toolGuard: {
title: '工具防护',
@ -1091,6 +1131,7 @@ export default {
wecom: 'WeChat Work (企业微信)',
weixin: 'WeChat (微信)',
qq: 'QQ',
slack: 'Slack',
webhook: 'Webhook',
},
tabs: {
@ -1306,4 +1347,31 @@ export default {
skip: '跳过',
back: '返回',
},
talk: {
title: '语音模式',
ready: '就绪',
listening: '收听中...',
processing: '思考中...',
speaking: '回复中...',
holdToTalk: '按住说话',
releaseToSend: '松开发送',
you: '你',
ai: 'AI',
micError: '无法访问麦克风',
connectionError: '连接错误',
playbackError: '音频播放失败',
},
browser: {
timeline: {
title: '浏览器({count} 个操作)',
},
actions: {
start: '启动',
stop: '停止',
open: '打开',
click: '点击',
type: '输入',
screenshot: '截图',
},
},
} as const

View File

@ -157,6 +157,12 @@ const router = createRouter({
component: () => import('@/views/Security/AuditLogs/index.vue'),
meta: { title: 'Security - Audit Logs' },
},
{
path: 'members',
name: 'SecurityMembers',
component: () => import('@/views/Security/Members/index.vue'),
meta: { title: 'Security - Members' },
},
],
},
// ==================== Redirects (backward compatibility) ====================

View File

@ -0,0 +1,61 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { workspaceTeamApi } from '@/api/index'
export interface Workspace {
id: number
name: string
slug: string
description?: string
ownerId?: number
settingsJson?: string
createTime?: string
updateTime?: string
}
export const useWorkspaceStore = defineStore('workspace', () => {
const workspaces = ref<Workspace[]>([])
const currentWorkspaceId = ref<number | null>(
Number(localStorage.getItem('mc-workspace-id')) || null
)
const loading = ref(false)
const currentWorkspace = computed(() =>
workspaces.value.find((ws) => ws.id === currentWorkspaceId.value) || workspaces.value[0] || null
)
async function fetchWorkspaces() {
loading.value = true
try {
const res: any = await workspaceTeamApi.list()
workspaces.value = res.data || []
// If no workspace selected or selected workspace not in list, default to first
if (
!currentWorkspaceId.value ||
!workspaces.value.find((ws) => ws.id === currentWorkspaceId.value)
) {
if (workspaces.value.length > 0) {
switchWorkspace(workspaces.value[0].id)
}
}
} catch (e) {
console.warn('Failed to fetch workspaces:', e)
} finally {
loading.value = false
}
}
function switchWorkspace(id: number) {
currentWorkspaceId.value = id
localStorage.setItem('mc-workspace-id', String(id))
}
return {
workspaces,
currentWorkspaceId,
currentWorkspace,
loading,
fetchWorkspaces,
switchWorkspace,
}
})

View File

@ -140,6 +140,16 @@ export interface MessageMetadata {
runningToolName?: string
/** 服务端警告列表 */
warnings?: string[]
/** 浏览器执行操作记录 */
browserActions?: Array<{
action: string
success: boolean
url?: string
title?: string
screenshot?: string
durationMs: number
timestamp: number
}>
}
export interface MessageContentPart {
@ -356,6 +366,11 @@ export const CHANNEL_FIELD_DEFS: Record<string, ChannelFieldDef[]> = {
{ key: 'markdown_enabled', label: 'Markdown 消息', placeholder: '', type: 'switch', defaultValue: true, tooltip: '发送消息时使用 Markdown 格式(部分场景下 QQ 可能不支持,可关闭回退到纯文本)' },
{ key: 'max_reconnect_attempts', label: '最大重连次数', placeholder: '100', type: 'number', defaultValue: 100, tooltip: 'WebSocket 断线后最大重连次数' },
],
slack: [
{ key: 'bot_token', label: 'Bot Token', placeholder: 'xoxb-xxxxxxxxxxxx-xxxxxxxxxxxx', required: true, sensitive: true, type: 'password', tooltip: 'Slack Bot User OAuth Token在 Slack App → OAuth & Permissions 获取)' },
{ 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 可选)' },
],
}
// ==================== 流控制 ====================

View File

@ -7,10 +7,11 @@ const SOURCE_LABELS: Record<string, string> = {
wecom: '企业微信',
weixin: '微信',
qq: 'QQ',
slack: 'Slack',
cron: '定时任务',
}
const ICON_CHANNELS = ['web', 'feishu', 'dingtalk', 'telegram', 'discord', 'wecom', 'weixin', 'qq', 'cron']
const ICON_CHANNELS = ['web', 'feishu', 'dingtalk', 'telegram', 'discord', 'wecom', 'weixin', 'qq', 'slack', 'cron']
export function channelIconUrl(source?: string): string {
const key = source || 'web'

View File

@ -101,6 +101,7 @@
<option value="wecom">{{ t('channels.types.wecom') }}</option>
<option value="weixin">{{ t('channels.types.weixin') }}</option>
<option value="qq">{{ t('channels.types.qq') }}</option>
<option value="slack">{{ t('channels.types.slack') }}</option>
<option value="webhook">{{ t('channels.types.webhook') }}</option>
</select>
</div>
@ -1148,7 +1149,7 @@ async function toggleChannel(channel: Channel) {
// ==================== ====================
const CHANNEL_ICON_TYPES = ['web', 'dingtalk', 'feishu', 'wecom', 'weixin', 'telegram', 'discord', 'qq', 'webhook']
const CHANNEL_ICON_TYPES = ['web', 'dingtalk', 'feishu', 'wecom', 'weixin', 'telegram', 'discord', 'qq', 'slack', 'webhook']
function getChannelIconPath(type: string) {
const name = CHANNEL_ICON_TYPES.includes(type) ? type : 'default'
return `/icons/channels/${name}.svg`

View File

@ -186,8 +186,18 @@
@attachment-remove="removeAttachment"
@approve="handleApprove"
@deny="handleDeny"
:enable-talk-mode="!!selectedAgentId"
@talk="showTalkMode = true"
/>
</div>
<!-- Talk Mode 覆盖层 -->
<TalkMode
:visible="showTalkMode"
:agent-id="selectedAgentId"
:conversation-id="currentConversationId"
@close="showTalkMode = false"
/>
</div>
</template>
@ -206,8 +216,12 @@ import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo,
import MessageList from '@/components/chat/MessageList.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
import TalkMode from '@/components/chat/TalkMode.vue'
import { useEChartsRenderer } from '@/composables/useEChartsRenderer'
// ============ Talk Mode ============
const showTalkMode = ref(false)
// ============ ============
const isMobile = ref(false)
const convPanelOpen = ref(false)
@ -1549,4 +1563,6 @@ function handleCodeCopy(e: MouseEvent) {
font-size: 14px;
}
}
</style>

View File

@ -47,6 +47,12 @@ const sections = computed(() => [
label: t('security.sections.auditLogs'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
},
{
id: 'members',
path: '/security/members',
label: t('security.sections.members', 'Members'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
},
])
function isActive(path: string) {

View File

@ -0,0 +1,251 @@
<template>
<div class="settings-section">
<div class="section-header">
<div>
<h2 class="section-title">{{ t('security.members.title') }}</h2>
<p class="section-desc">{{ t('security.members.desc') }}</p>
</div>
<button class="btn-primary" @click="showAddDialog = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('security.members.addMember') }}
</button>
</div>
<!-- Members Table -->
<div class="rules-table-wrapper">
<div v-if="loading" class="empty-state">{{ t('security.members.loading') }}</div>
<div v-else-if="members.length === 0" class="empty-state">{{ t('security.members.noMembers') }}</div>
<table v-else class="rules-table">
<thead>
<tr>
<th>{{ t('security.members.columns.user') }}</th>
<th>{{ t('security.members.columns.role') }}</th>
<th>{{ t('security.members.columns.joined') }}</th>
<th style="width: 80px;">{{ t('security.members.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="member in members" :key="member.id">
<td>
<div class="member-info">
<div class="member-avatar">{{ (member.username || member.userId + '').charAt(0).toUpperCase() }}</div>
<div class="member-detail">
<span class="member-name">{{ member.nickname || member.username || ('User #' + member.userId) }}</span>
<span v-if="member.username && member.nickname" class="member-username">@{{ member.username }}</span>
</div>
</div>
</td>
<td>
<select
:value="member.role"
@change="updateRole(member, ($event.target as HTMLSelectElement).value)"
:disabled="member.role === 'owner'"
class="config-select"
>
<option value="owner" disabled>{{ t('security.members.roles.owner') }}</option>
<option value="admin">{{ t('security.members.roles.admin') }}</option>
<option value="member">{{ t('security.members.roles.member') }}</option>
<option value="viewer">{{ t('security.members.roles.viewer') }}</option>
</select>
</td>
<td class="date-cell">{{ formatDate(member.createTime) }}</td>
<td>
<div class="action-btns">
<button
v-if="member.role !== 'owner'"
class="action-btn danger"
@click="removeMember(member)"
:title="t('security.members.actions.remove')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add Member Dialog -->
<Teleport to="body">
<div v-if="showAddDialog" class="modal-overlay" @click.self="showAddDialog = false">
<div class="modal">
<div class="modal-header">
<h3>{{ t('security.members.addDialog.title') }}</h3>
<button class="modal-close" @click="showAddDialog = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-grid" style="grid-template-columns: 1fr;">
<div class="form-group">
<label>{{ t('security.members.addDialog.userId') }}</label>
<input v-model.number="newMemberUserId" type="number" class="form-input" :placeholder="t('security.members.addDialog.userIdPlaceholder')" />
</div>
<div class="form-group">
<label>{{ t('security.members.addDialog.role') }}</label>
<select v-model="newMemberRole" class="form-input">
<option value="admin">{{ t('security.members.roles.admin') }}</option>
<option value="member">{{ t('security.members.roles.member') }}</option>
<option value="viewer">{{ t('security.members.roles.viewer') }}</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showAddDialog = false">{{ t('security.members.actions.cancel') }}</button>
<button class="btn-primary" @click="addMember" :disabled="!newMemberUserId">{{ t('security.members.actions.confirm') }}</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { workspaceTeamApi } from '@/api/index'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
const { t } = useI18n()
interface Member {
id: number
workspaceId: number
userId: number
username?: string
nickname?: string
role: string
createTime: string
}
const store = useWorkspaceStore()
const members = ref<Member[]>([])
const loading = ref(false)
const showAddDialog = ref(false)
const newMemberUserId = ref<number | null>(null)
const newMemberRole = ref('member')
onMounted(() => {
fetchMembers()
})
async function fetchMembers() {
const wsId = store.currentWorkspaceId
if (!wsId) return
loading.value = true
try {
const res: any = await workspaceTeamApi.listMembers(wsId)
members.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
async function addMember() {
const wsId = store.currentWorkspaceId
if (!wsId || !newMemberUserId.value) return
try {
await workspaceTeamApi.addMember(wsId, { userId: newMemberUserId.value, role: newMemberRole.value })
ElMessage.success(t('security.members.messages.addSuccess'))
showAddDialog.value = false
newMemberUserId.value = null
newMemberRole.value = 'member'
fetchMembers()
} catch (e: any) {
ElMessage.error(t('security.members.messages.addFailed'))
}
}
async function updateRole(member: Member, role: string) {
const wsId = store.currentWorkspaceId
if (!wsId) return
try {
await workspaceTeamApi.updateMemberRole(wsId, member.userId, role)
member.role = role
ElMessage.success(t('security.members.messages.updateSuccess'))
} catch {
ElMessage.error(t('security.members.messages.updateFailed'))
}
}
async function removeMember(member: Member) {
const wsId = store.currentWorkspaceId
if (!wsId) return
if (!confirm(t('security.members.messages.removeConfirm'))) return
try {
await workspaceTeamApi.removeMember(wsId, member.userId)
ElMessage.success(t('security.members.messages.removeSuccess'))
fetchMembers()
} catch {
ElMessage.error(t('security.members.messages.removeFailed'))
}
}
function formatDate(dateStr: string) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString()
}
</script>
<style>
@import '../shared.css';
</style>
<style scoped>
.member-info {
display: flex;
align-items: center;
gap: 10px;
}
.member-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(217, 119, 87, 0.12);
color: var(--mc-primary, #D97757);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.member-detail {
display: flex;
flex-direction: column;
gap: 1px;
}
.member-name {
font-weight: 500;
color: var(--mc-text-primary);
font-size: 14px;
}
.member-username {
font-size: 12px;
color: var(--mc-text-tertiary);
}
.date-cell {
color: var(--mc-text-tertiary);
font-size: 13px;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
}
</style>

View File

@ -33,6 +33,9 @@
</button>
</div>
<!-- 工作区切换 -->
<WorkspaceSwitcher :collapsed="sidebarCollapsed" />
<!-- 导航菜单 -->
<nav class="sidebar-nav">
<template v-for="group in navGroups" :key="group.key">
@ -125,6 +128,7 @@ import type { ThemeMode } from '@/stores/useThemeStore'
import { http, setupApi } from '@/api/index'
import OnboardingWizard from '@/views/Onboarding/OnboardingWizard.vue'
import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue'
const router = useRouter()
const route = useRoute()

View File

@ -0,0 +1,19 @@
{
"name": "@mateclaw/webchat",
"version": "1.0.0",
"description": "MateClaw WebChat embeddable widget",
"type": "module",
"main": "dist/mateclaw-webchat.umd.js",
"module": "dist/mateclaw-webchat.es.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"dev": "vite",
"build": "vite build && cp -r dist/ ../mateclaw-server/src/main/resources/static/webchat/",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^6.3.5",
"typescript": "^5.8.3"
}
}

664
mateclaw-webchat/pnpm-lock.yaml generated Normal file
View File

@ -0,0 +1,664 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
typescript:
specifier: ^5.8.3
version: 5.9.3
vite:
specifier: ^6.3.5
version: 6.4.2
packages:
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.12':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.12':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.12':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.12':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.12':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.12':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.12':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.12':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.12':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.12':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.12':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.12':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.12':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.12':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.12':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.12':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.12':
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.12':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.12':
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.12':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.12':
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.12':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.12':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.12':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.12':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@rollup/rollup-android-arm-eabi@4.60.1':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.60.1':
resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.60.1':
resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.60.1':
resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.60.1':
resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.1':
resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.60.1':
resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.60.1':
resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.1':
resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.1':
resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.1':
resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
postcss@8.5.9:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.60.1:
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
vite@6.4.2:
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
jiti: '>=1.21.0'
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
snapshots:
'@esbuild/aix-ppc64@0.25.12':
optional: true
'@esbuild/android-arm64@0.25.12':
optional: true
'@esbuild/android-arm@0.25.12':
optional: true
'@esbuild/android-x64@0.25.12':
optional: true
'@esbuild/darwin-arm64@0.25.12':
optional: true
'@esbuild/darwin-x64@0.25.12':
optional: true
'@esbuild/freebsd-arm64@0.25.12':
optional: true
'@esbuild/freebsd-x64@0.25.12':
optional: true
'@esbuild/linux-arm64@0.25.12':
optional: true
'@esbuild/linux-arm@0.25.12':
optional: true
'@esbuild/linux-ia32@0.25.12':
optional: true
'@esbuild/linux-loong64@0.25.12':
optional: true
'@esbuild/linux-mips64el@0.25.12':
optional: true
'@esbuild/linux-ppc64@0.25.12':
optional: true
'@esbuild/linux-riscv64@0.25.12':
optional: true
'@esbuild/linux-s390x@0.25.12':
optional: true
'@esbuild/linux-x64@0.25.12':
optional: true
'@esbuild/netbsd-arm64@0.25.12':
optional: true
'@esbuild/netbsd-x64@0.25.12':
optional: true
'@esbuild/openbsd-arm64@0.25.12':
optional: true
'@esbuild/openbsd-x64@0.25.12':
optional: true
'@esbuild/openharmony-arm64@0.25.12':
optional: true
'@esbuild/sunos-x64@0.25.12':
optional: true
'@esbuild/win32-arm64@0.25.12':
optional: true
'@esbuild/win32-ia32@0.25.12':
optional: true
'@esbuild/win32-x64@0.25.12':
optional: true
'@rollup/rollup-android-arm-eabi@4.60.1':
optional: true
'@rollup/rollup-android-arm64@4.60.1':
optional: true
'@rollup/rollup-darwin-arm64@4.60.1':
optional: true
'@rollup/rollup-darwin-x64@4.60.1':
optional: true
'@rollup/rollup-freebsd-arm64@4.60.1':
optional: true
'@rollup/rollup-freebsd-x64@4.60.1':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-arm64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-loong64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-x64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-x64-musl@4.60.1':
optional: true
'@rollup/rollup-openbsd-x64@4.60.1':
optional: true
'@rollup/rollup-openharmony-arm64@4.60.1':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.1':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.1':
optional: true
'@rollup/rollup-win32-x64-gnu@4.60.1':
optional: true
'@rollup/rollup-win32-x64-msvc@4.60.1':
optional: true
'@types/estree@1.0.8': {}
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
'@esbuild/android-arm': 0.25.12
'@esbuild/android-arm64': 0.25.12
'@esbuild/android-x64': 0.25.12
'@esbuild/darwin-arm64': 0.25.12
'@esbuild/darwin-x64': 0.25.12
'@esbuild/freebsd-arm64': 0.25.12
'@esbuild/freebsd-x64': 0.25.12
'@esbuild/linux-arm': 0.25.12
'@esbuild/linux-arm64': 0.25.12
'@esbuild/linux-ia32': 0.25.12
'@esbuild/linux-loong64': 0.25.12
'@esbuild/linux-mips64el': 0.25.12
'@esbuild/linux-ppc64': 0.25.12
'@esbuild/linux-riscv64': 0.25.12
'@esbuild/linux-s390x': 0.25.12
'@esbuild/linux-x64': 0.25.12
'@esbuild/netbsd-arm64': 0.25.12
'@esbuild/netbsd-x64': 0.25.12
'@esbuild/openbsd-arm64': 0.25.12
'@esbuild/openbsd-x64': 0.25.12
'@esbuild/openharmony-arm64': 0.25.12
'@esbuild/sunos-x64': 0.25.12
'@esbuild/win32-arm64': 0.25.12
'@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
fsevents@2.3.3:
optional: true
nanoid@3.3.11: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
postcss@8.5.9:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.60.1:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.1
'@rollup/rollup-android-arm64': 4.60.1
'@rollup/rollup-darwin-arm64': 4.60.1
'@rollup/rollup-darwin-x64': 4.60.1
'@rollup/rollup-freebsd-arm64': 4.60.1
'@rollup/rollup-freebsd-x64': 4.60.1
'@rollup/rollup-linux-arm-gnueabihf': 4.60.1
'@rollup/rollup-linux-arm-musleabihf': 4.60.1
'@rollup/rollup-linux-arm64-gnu': 4.60.1
'@rollup/rollup-linux-arm64-musl': 4.60.1
'@rollup/rollup-linux-loong64-gnu': 4.60.1
'@rollup/rollup-linux-loong64-musl': 4.60.1
'@rollup/rollup-linux-ppc64-gnu': 4.60.1
'@rollup/rollup-linux-ppc64-musl': 4.60.1
'@rollup/rollup-linux-riscv64-gnu': 4.60.1
'@rollup/rollup-linux-riscv64-musl': 4.60.1
'@rollup/rollup-linux-s390x-gnu': 4.60.1
'@rollup/rollup-linux-x64-gnu': 4.60.1
'@rollup/rollup-linux-x64-musl': 4.60.1
'@rollup/rollup-openbsd-x64': 4.60.1
'@rollup/rollup-openharmony-arm64': 4.60.1
'@rollup/rollup-win32-arm64-msvc': 4.60.1
'@rollup/rollup-win32-ia32-msvc': 4.60.1
'@rollup/rollup-win32-x64-gnu': 4.60.1
'@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3
source-map-js@1.2.1: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
typescript@5.9.3: {}
vite@6.4.2:
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.9
rollup: 4.60.1
tinyglobby: 0.2.16
optionalDependencies:
fsevents: 2.3.3

View File

@ -0,0 +1,355 @@
/**
* MateClaw WebChat Embeddable Chat Widget
*
* Usage:
* <script src="https://your-server/mateclaw-webchat.umd.js"></script>
* <script>
* MateClawWebChat.init({ apiKey: 'your-key', server: 'https://your-server' })
* </script>
*/
export interface WebChatConfig {
/** API Key (from MateClaw channel config) */
apiKey: string
/** MateClaw server URL (e.g., https://your-server.com) */
server: string
/** Widget position */
position?: 'bottom-right' | 'bottom-left'
/** Primary color (hex) */
primaryColor?: string
/** Widget title */
title?: string
/** Placeholder text */
placeholder?: string
}
interface Message {
role: 'user' | 'assistant'
content: string
}
const DEFAULT_CONFIG: Partial<WebChatConfig> = {
position: 'bottom-right',
primaryColor: '#409eff',
title: 'MateClaw',
placeholder: 'Type a message...',
}
let config: WebChatConfig
let container: HTMLDivElement
let visitorId: string
let messages: Message[] = []
let isOpen = false
let isStreaming = false
export function init(userConfig: WebChatConfig) {
config = { ...DEFAULT_CONFIG, ...userConfig }
visitorId = localStorage.getItem('mc-webchat-visitor') || generateId()
localStorage.setItem('mc-webchat-visitor', visitorId)
injectStyles()
createWidget()
}
function generateId(): string {
return 'v_' + Math.random().toString(36).substring(2, 10) + Date.now().toString(36)
}
function injectStyles() {
const style = document.createElement('style')
style.textContent = `
.mc-webchat-bubble {
position: fixed;
${config.position === 'bottom-left' ? 'left: 20px' : 'right: 20px'};
bottom: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: ${config.primaryColor};
color: white;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
transition: transform 0.2s;
}
.mc-webchat-bubble:hover { transform: scale(1.1); }
.mc-webchat-panel {
position: fixed;
${config.position === 'bottom-left' ? 'left: 20px' : 'right: 20px'};
bottom: 88px;
width: 380px;
height: 520px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
display: flex;
flex-direction: column;
z-index: 99999;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.mc-webchat-header {
padding: 14px 16px;
background: ${config.primaryColor};
color: white;
font-weight: 600;
font-size: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.mc-webchat-close {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 18px;
padding: 0 4px;
opacity: 0.8;
}
.mc-webchat-close:hover { opacity: 1; }
.mc-webchat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.mc-webchat-msg {
max-width: 85%;
padding: 8px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.mc-webchat-msg--user {
align-self: flex-end;
background: ${config.primaryColor};
color: white;
border-bottom-right-radius: 4px;
}
.mc-webchat-msg--assistant {
align-self: flex-start;
background: #f0f2f5;
color: #333;
border-bottom-left-radius: 4px;
}
.mc-webchat-input-area {
padding: 10px 12px;
border-top: 1px solid #eee;
display: flex;
gap: 8px;
}
.mc-webchat-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 14px;
outline: none;
}
.mc-webchat-input:focus { border-color: ${config.primaryColor}; }
.mc-webchat-send {
width: 36px;
height: 36px;
border-radius: 50%;
background: ${config.primaryColor};
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.mc-webchat-send:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 480px) {
.mc-webchat-panel { width: calc(100vw - 24px); left: 12px; right: 12px; bottom: 80px; height: 60vh; }
}
`
document.head.appendChild(style)
}
function createWidget() {
container = document.createElement('div')
container.id = 'mc-webchat-root'
// Bubble button
const bubble = document.createElement('button')
bubble.className = 'mc-webchat-bubble'
bubble.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`
bubble.onclick = () => togglePanel()
container.appendChild(bubble)
document.body.appendChild(container)
}
function togglePanel() {
isOpen = !isOpen
const existing = container.querySelector('.mc-webchat-panel')
if (isOpen && !existing) {
createPanel()
} else if (!isOpen && existing) {
existing.remove()
}
}
function createPanel() {
const panel = document.createElement('div')
panel.className = 'mc-webchat-panel'
panel.innerHTML = `
<div class="mc-webchat-header">
<span>${config.title}</span>
<button class="mc-webchat-close">&times;</button>
</div>
<div class="mc-webchat-messages" id="mc-messages"></div>
<div class="mc-webchat-input-area">
<input class="mc-webchat-input" placeholder="${config.placeholder}" id="mc-input" />
<button class="mc-webchat-send" id="mc-send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
`
panel.querySelector('.mc-webchat-close')!.addEventListener('click', togglePanel)
const input = panel.querySelector('#mc-input') as HTMLInputElement
const sendBtn = panel.querySelector('#mc-send') as HTMLButtonElement
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage(input.value)
}
})
sendBtn.addEventListener('click', () => sendMessage(input.value))
container.appendChild(panel)
// Render existing messages
renderMessages()
}
function renderMessages() {
const el = document.getElementById('mc-messages')
if (!el) return
el.innerHTML = ''
messages.forEach((msg) => {
const div = document.createElement('div')
div.className = `mc-webchat-msg mc-webchat-msg--${msg.role}`
div.textContent = msg.content
el.appendChild(div)
})
el.scrollTop = el.scrollHeight
}
function appendMessage(role: 'user' | 'assistant', content: string) {
messages.push({ role, content })
const el = document.getElementById('mc-messages')
if (!el) return
const div = document.createElement('div')
div.className = `mc-webchat-msg mc-webchat-msg--${role}`
div.textContent = content
el.appendChild(div)
el.scrollTop = el.scrollHeight
return div
}
function updateLastAssistant(content: string) {
const el = document.getElementById('mc-messages')
if (!el) return
const last = el.querySelector('.mc-webchat-msg--assistant:last-child')
if (last) {
last.textContent = content
el.scrollTop = el.scrollHeight
}
}
async function sendMessage(text: string) {
text = text.trim()
if (!text || isStreaming) return
const input = document.getElementById('mc-input') as HTMLInputElement
if (input) input.value = ''
appendMessage('user', text)
isStreaming = true
// Create assistant message placeholder
appendMessage('assistant', '...')
let fullContent = ''
try {
const response = await fetch(`${config.server}/api/v1/channels/webchat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-MC-Key': config.apiKey,
Accept: 'text/event-stream',
},
body: JSON.stringify({ message: text, visitorId }),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('No response body')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.slice(5).trim()
if (!data) continue
try {
const parsed = JSON.parse(data)
if (parsed.text) {
fullContent += parsed.text
updateLastAssistant(fullContent)
}
} catch {
// not JSON, might be raw text
}
} else if (line.startsWith('event:')) {
const eventType = line.slice(6).trim()
if (eventType === 'done') {
break
}
}
}
}
// Update the stored message
if (messages.length > 0) {
messages[messages.length - 1].content = fullContent || '(no response)'
}
} catch (e: any) {
updateLastAssistant(`Error: ${e.message}`)
if (messages.length > 0) {
messages[messages.length - 1].content = `Error: ${e.message}`
}
} finally {
isStreaming = false
}
}
// Auto-export for UMD
if (typeof window !== 'undefined') {
;(window as any).MateClawWebChat = { init }
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist",
"declaration": true,
"declarationDir": "dist",
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MateClawWebChat',
formats: ['es', 'umd'],
fileName: (format) => `mateclaw-webchat.${format}.js`,
},
rollupOptions: {
output: {
assetFileNames: 'mateclaw-webchat.[ext]',
},
},
cssCodeSplit: false,
minify: 'esbuild',
},
})