mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(platform): add workspace foundation and channel execution upgrades
This commit is contained in:
parent
f6a3a1592a
commit
3d58a48eae
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -244,6 +244,29 @@
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Spring WebSocket(Talk Mode) ===== -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Slack SDK(Socket 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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -49,6 +49,9 @@ public class AgentEntity {
|
||||
/** 标签(逗号分隔) */
|
||||
private String tags;
|
||||
|
||||
/** 所属工作区 ID(默认 1 = default) */
|
||||
private Long workspaceId;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
/** 微信扫码深链接模板 */
|
||||
|
||||
@ -40,6 +40,9 @@ public class ChannelEntity {
|
||||
/** 渠道描述 */
|
||||
private String description;
|
||||
|
||||
/** 所属工作区 ID(默认 1 = default) */
|
||||
private Long workspaceId;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -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 启动时使用)
|
||||
*/
|
||||
|
||||
@ -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 Secret(Webhook 模式使用)
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
public class SlackChannelAdapter extends AbstractChannelAdapter {
|
||||
|
||||
private Slack slack;
|
||||
private App boltApp;
|
||||
private SocketModeApp socketModeApp;
|
||||
private String botUserId;
|
||||
|
||||
/** 缓存 channel 消息的 thread_ts,确保同一频道对话在同一 thread 中回复 */
|
||||
private final ConcurrentHashMap<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;
|
||||
}
|
||||
}
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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("*");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -42,6 +42,9 @@ public class WikiKnowledgeBaseEntity {
|
||||
/** 原始材料数量 */
|
||||
private Integer rawCount;
|
||||
|
||||
/** 所属工作区 ID(默认 1 = default) */
|
||||
private Long workspaceId;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 + 公共 KB(agent_id IS NULL)
|
||||
*/
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package vip.mate.workspace.core.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 工作区实体
|
||||
* <p>
|
||||
* 工作区是资源隔离的基本单元,Agent、Channel、Wiki、Conversation 都归属于某个工作区。
|
||||
* 系统自动创建 id=1 的默认工作区(default),单人部署无需感知。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_workspace")
|
||||
public class WorkspaceEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** 工作区名称 */
|
||||
private String name;
|
||||
|
||||
/** 工作区标识(URL 友好,唯一) */
|
||||
private String slug;
|
||||
|
||||
/** 描述 */
|
||||
private String description;
|
||||
|
||||
/** 拥有者用户 ID */
|
||||
private Long ownerId;
|
||||
|
||||
/** 工作区级配置(JSON) */
|
||||
@TableField(value = "settings_json", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String settingsJson;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -1243,6 +1243,23 @@ VALUES (1000000008, '微信', 'weixin', 1000000001, '', '{
|
||||
}', FALSE,
|
||||
'微信个人号渠道(iLink Bot HTTP 长轮询)。通过扫描二维码登录获取 bot_token,或直接填入已有 token。基于 iLink Bot API,支持文本、图片、语音(ASR)、文件、视频消息', NOW(), NOW(), 0);
|
||||
|
||||
-- 9. Slack(默认禁用,需配置 bot_token / app_token)
|
||||
MERGE INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_json, enabled, description, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000000009, 'Slack Bot', 'slack', 1000000001, '', '{
|
||||
"bot_token": "",
|
||||
"app_token": "",
|
||||
"signing_secret": "",
|
||||
"dm_policy": "open",
|
||||
"group_policy": "mention",
|
||||
"allow_from": [],
|
||||
"deny_message": "抱歉,您没有使用权限",
|
||||
"filter_thinking": true,
|
||||
"filter_tool_messages": true,
|
||||
"message_format": "auto"
|
||||
}', FALSE,
|
||||
'Slack 渠道(Socket Mode)。在 Slack App 后台获取 Bot Token(xoxb-)和 App-Level Token(xapp-),启用 Socket Mode 后即可使用。', NOW(), NOW(), 0);
|
||||
|
||||
-- ==================== 示例定时任务 ====================
|
||||
MERGE INTO mate_cron_job (id, name, cron_expression, timezone, agent_id, task_type, trigger_message, request_body, enabled, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
6
mateclaw-ui/public/icons/channels/slack.svg
Normal file
6
mateclaw-ui/public/icons/channels/slack.svg
Normal 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 |
@ -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}`),
|
||||
}
|
||||
|
||||
221
mateclaw-ui/src/components/chat/BrowserTimeline.vue
Normal file
221
mateclaw-ui/src/components/chat/BrowserTimeline.vue
Normal 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>
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
465
mateclaw-ui/src/components/chat/TalkMode.vue
Normal file
465
mateclaw-ui/src/components/chat/TalkMode.vue
Normal 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>
|
||||
137
mateclaw-ui/src/components/workspace/WorkspaceSwitcher.vue
Normal file
137
mateclaw-ui/src/components/workspace/WorkspaceSwitcher.vue
Normal 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>
|
||||
@ -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) {
|
||||
|
||||
@ -37,6 +37,8 @@ export type SSEEventType =
|
||||
| 'async_task_completed'
|
||||
// TTS 事件
|
||||
| 'tts_ready'
|
||||
// 浏览器执行事件
|
||||
| 'browser_action'
|
||||
|
||||
export interface SSEEvent {
|
||||
type: SSEEventType
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) ====================
|
||||
|
||||
61
mateclaw-ui/src/stores/useWorkspaceStore.ts
Normal file
61
mateclaw-ui/src/stores/useWorkspaceStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@ -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 可选)' },
|
||||
],
|
||||
}
|
||||
|
||||
// ==================== 流控制 ====================
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
251
mateclaw-ui/src/views/Security/Members/index.vue
Normal file
251
mateclaw-ui/src/views/Security/Members/index.vue
Normal 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">×</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>
|
||||
@ -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()
|
||||
|
||||
19
mateclaw-webchat/package.json
Normal file
19
mateclaw-webchat/package.json
Normal 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
664
mateclaw-webchat/pnpm-lock.yaml
generated
Normal 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
|
||||
355
mateclaw-webchat/src/index.ts
Normal file
355
mateclaw-webchat/src/index.ts
Normal 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">×</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 }
|
||||
}
|
||||
13
mateclaw-webchat/tsconfig.json
Normal file
13
mateclaw-webchat/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
mateclaw-webchat/vite.config.ts
Normal file
20
mateclaw-webchat/vite.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user