初始化
This commit is contained in:
30
easyflow-commons/easyflow-common-chat-protocol/pom.xml
Normal file
30
easyflow-commons/easyflow-common-chat-protocol/pom.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-commons</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<name>easyflow-common-chat-protocol</name>
|
||||
<artifactId>easyflow-common-chat-protocol</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>fastjson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,12 @@
|
||||
package tech.easyflow.core.chat.protocol;
|
||||
|
||||
public enum ChatDomain {
|
||||
LLM,
|
||||
TOOL,
|
||||
SYSTEM,
|
||||
BUSINESS,
|
||||
WORKFLOW,
|
||||
INTERACTION,
|
||||
DEBUG
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package tech.easyflow.core.chat.protocol;
|
||||
|
||||
public class ChatEnvelope<T> {
|
||||
|
||||
private String protocol = "easyflow-chat";
|
||||
private String version = "1.1";
|
||||
|
||||
private ChatDomain domain;
|
||||
private ChatType type;
|
||||
|
||||
private String conversationId;
|
||||
private String messageId;
|
||||
private Integer index;
|
||||
|
||||
private T payload;
|
||||
private Object meta;
|
||||
|
||||
public String getProtocol() {
|
||||
return protocol;
|
||||
}
|
||||
|
||||
public void setProtocol(String protocol) {
|
||||
this.protocol = protocol;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public ChatDomain getDomain() {
|
||||
return domain;
|
||||
}
|
||||
|
||||
public void setDomain(ChatDomain domain) {
|
||||
this.domain = domain;
|
||||
}
|
||||
|
||||
public ChatType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(ChatType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getConversationId() {
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
public void setConversationId(String conversationId) {
|
||||
this.conversationId = conversationId;
|
||||
}
|
||||
|
||||
public String getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
public void setMessageId(String messageId) {
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
public Integer getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
public void setIndex(Integer index) {
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
public T getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(T payload) {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
public Object getMeta() {
|
||||
return meta;
|
||||
}
|
||||
|
||||
public void setMeta(Object meta) {
|
||||
this.meta = meta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package tech.easyflow.core.chat.protocol;
|
||||
|
||||
import tech.easyflow.core.chat.protocol.payload.MessageDeltaPayload;
|
||||
import tech.easyflow.core.chat.protocol.payload.ThinkingPayload;
|
||||
|
||||
public final class ChatEventFactory {
|
||||
|
||||
private ChatEventFactory() {
|
||||
}
|
||||
|
||||
public static ChatEnvelope<ThinkingPayload> thinking(String conversationId, String content) {
|
||||
ChatEnvelope<ThinkingPayload> e = base(conversationId);
|
||||
e.setDomain(ChatDomain.LLM);
|
||||
e.setType(ChatType.THINKING);
|
||||
|
||||
ThinkingPayload p = new ThinkingPayload();
|
||||
p.setContent(content);
|
||||
// p.setVisibility("hidden");
|
||||
|
||||
e.setPayload(p);
|
||||
return e;
|
||||
}
|
||||
|
||||
public static ChatEnvelope<MessageDeltaPayload> messageDelta(
|
||||
String conversationId,
|
||||
String delta
|
||||
) {
|
||||
ChatEnvelope<MessageDeltaPayload> e = base(conversationId);
|
||||
e.setDomain(ChatDomain.LLM);
|
||||
e.setType(ChatType.MESSAGE);
|
||||
|
||||
MessageDeltaPayload p = new MessageDeltaPayload();
|
||||
p.setDelta(delta);
|
||||
|
||||
e.setPayload(p);
|
||||
return e;
|
||||
}
|
||||
|
||||
public static ChatEnvelope<Void> done(String conversationId) {
|
||||
ChatEnvelope<Void> e = base(conversationId);
|
||||
e.setDomain(ChatDomain.SYSTEM);
|
||||
e.setType(ChatType.DONE);
|
||||
e.setPayload(null);
|
||||
return e;
|
||||
}
|
||||
|
||||
private static <T> ChatEnvelope<T> base(String conversationId) {
|
||||
ChatEnvelope<T> e = new ChatEnvelope<>();
|
||||
e.setConversationId(conversationId);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package tech.easyflow.core.chat.protocol;
|
||||
|
||||
public enum ChatType {
|
||||
THINKING,
|
||||
MESSAGE,
|
||||
TOOL_CALL,
|
||||
TOOL_RESULT,
|
||||
STATUS,
|
||||
ERROR,
|
||||
FORM_REQUEST,
|
||||
FORM_CANCEL,
|
||||
DONE
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package tech.easyflow.core.chat.protocol;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* 对话消息角色枚举
|
||||
*/
|
||||
public enum MessageRole {
|
||||
/**
|
||||
* 用户角色:用户发送的消息
|
||||
*/
|
||||
USER("user"),
|
||||
|
||||
/**
|
||||
* 助手角色:AI/机器人返回的消息
|
||||
*/
|
||||
ASSISTANT("assistant"),
|
||||
|
||||
/**
|
||||
* 系统角色:系统级提示、日志、状态消息
|
||||
*/
|
||||
SYSTEM("system"),
|
||||
|
||||
/**
|
||||
* 工具角色:工具自动发送/返回的消息
|
||||
*/
|
||||
TOOL("tool");
|
||||
|
||||
private final String value;
|
||||
|
||||
/**
|
||||
* 私有构造方法,绑定枚举常量与对应值
|
||||
* @param value 数据库存储/协议传输的字符串值
|
||||
*/
|
||||
MessageRole(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取枚举对应的字符串值(用于数据库持久化、协议传输)
|
||||
* @JsonValue 注解:Jackson 序列化时,自动返回该值(避免序列化出枚举名称)
|
||||
* @return 小写字符串(如 "user"、"assistant")
|
||||
*/
|
||||
@JsonValue
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package tech.easyflow.core.chat.protocol.payload;
|
||||
|
||||
public class ErrorPayload {
|
||||
private String code;
|
||||
private String message;
|
||||
private Boolean retryable;
|
||||
private Object detail;
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public Boolean getRetryable() {
|
||||
return retryable;
|
||||
}
|
||||
|
||||
public void setRetryable(Boolean retryable) {
|
||||
this.retryable = retryable;
|
||||
}
|
||||
|
||||
public Object getDetail() {
|
||||
return detail;
|
||||
}
|
||||
|
||||
public void setDetail(Object detail) {
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package tech.easyflow.core.chat.protocol.payload;
|
||||
|
||||
public class FormCancelPayload {
|
||||
private String formId;
|
||||
|
||||
public String getFormId() {
|
||||
return formId;
|
||||
}
|
||||
|
||||
public void setFormId(String formId) {
|
||||
this.formId = formId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tech.easyflow.core.chat.protocol.payload;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class FormRequestPayload {
|
||||
private String formId;
|
||||
private String title;
|
||||
private String description;
|
||||
private Map<String, Object> schema; // JSON Schema
|
||||
private Map<String, String> ui;
|
||||
|
||||
public String getFormId() {
|
||||
return formId;
|
||||
}
|
||||
|
||||
public void setFormId(String formId) {
|
||||
this.formId = formId;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Map<String, Object> getSchema() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
public void setSchema(Map<String, Object> schema) {
|
||||
this.schema = schema;
|
||||
}
|
||||
|
||||
public Map<String, String> getUi() {
|
||||
return ui;
|
||||
}
|
||||
|
||||
public void setUi(Map<String, String> ui) {
|
||||
this.ui = ui;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package tech.easyflow.core.chat.protocol.payload;
|
||||
|
||||
public class MessageDeltaPayload {
|
||||
|
||||
private String delta;
|
||||
|
||||
public String getDelta() {
|
||||
return delta;
|
||||
}
|
||||
|
||||
public void setDelta(String delta) {
|
||||
this.delta = delta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package tech.easyflow.core.chat.protocol.payload;
|
||||
|
||||
public class MessageFullPayload {
|
||||
private String content;
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package tech.easyflow.core.chat.protocol.payload;
|
||||
|
||||
public class StatusPayload {
|
||||
private String state;
|
||||
private String reason;
|
||||
|
||||
public String getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public void setState(String state) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package tech.easyflow.core.chat.protocol.payload;
|
||||
|
||||
public class ThinkingPayload {
|
||||
private String content;
|
||||
// private String visibility; // hidden | visible
|
||||
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package tech.easyflow.core.chat.protocol.payload;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class ToolCallPayload {
|
||||
|
||||
private String toolCallId;
|
||||
private String name;
|
||||
private Map<String, Object> arguments;
|
||||
|
||||
public String getToolCallId() {
|
||||
return toolCallId;
|
||||
}
|
||||
|
||||
public void setToolCallId(String toolCallId) {
|
||||
this.toolCallId = toolCallId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Map<String, Object> getArguments() {
|
||||
return arguments;
|
||||
}
|
||||
|
||||
public void setArguments(Map<String, Object> arguments) {
|
||||
this.arguments = arguments;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package tech.easyflow.core.chat.protocol.payload;
|
||||
|
||||
public class ToolResultPayload {
|
||||
private String toolCallId;
|
||||
private String status; // success | error
|
||||
private Object result;
|
||||
|
||||
public String getToolCallId() {
|
||||
return toolCallId;
|
||||
}
|
||||
|
||||
public void setToolCallId(String toolCallId) {
|
||||
this.toolCallId = toolCallId;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Object getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
public void setResult(Object result) {
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package tech.easyflow.core.chat.protocol.sse;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.common.util.SpringContextUtil;
|
||||
import tech.easyflow.core.chat.protocol.ChatEnvelope;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
|
||||
public class ChatSseEmitter {
|
||||
|
||||
private static final long DEFAULT_TIMEOUT = Duration.ofMinutes(5).toMillis();
|
||||
|
||||
private final SseEmitter emitter;
|
||||
|
||||
public ChatSseEmitter() {
|
||||
this(DEFAULT_TIMEOUT);
|
||||
}
|
||||
|
||||
public ChatSseEmitter(long timeoutMillis) {
|
||||
this.emitter = new SseEmitter(timeoutMillis);
|
||||
}
|
||||
|
||||
public SseEmitter getEmitter() {
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/** 发送普通 ChatEnvelope(event: message) */
|
||||
public void send(ChatEnvelope<?> envelope) {
|
||||
send("message", envelope);
|
||||
}
|
||||
|
||||
/** 发送 error 事件 */
|
||||
public void sendError(ChatEnvelope<?> envelope) {
|
||||
send("error", envelope);
|
||||
}
|
||||
|
||||
/** 发送 done 事件并关闭 */
|
||||
public void sendDone(ChatEnvelope<?> envelope) {
|
||||
send("done", envelope);
|
||||
complete();
|
||||
}
|
||||
|
||||
/** 🔥 新增:发送并立即关闭 */
|
||||
public void sendAndClose(ChatEnvelope<?> envelope) {
|
||||
send("message", envelope);
|
||||
ThreadPoolTaskExecutor threadPoolTaskExecutor = SpringContextUtil.getBean("sseThreadPool");
|
||||
threadPoolTaskExecutor.execute(() -> {
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
complete();
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 通知前端保存该消息 */
|
||||
public void sendMessageNeedSave(ChatEnvelope<?> envelope) {
|
||||
send("needSaveMessage", envelope);
|
||||
}
|
||||
|
||||
/** SSE 底层发送 */
|
||||
private void send(String event, ChatEnvelope<?> envelope) {
|
||||
try {
|
||||
String json = JSON.toJSONString(envelope);
|
||||
emitter.send(
|
||||
SseEmitter.event()
|
||||
.name(event)
|
||||
.data(json)
|
||||
);
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void complete() {
|
||||
emitter.complete();
|
||||
}
|
||||
|
||||
public void completeWithError(Throwable ex) {
|
||||
emitter.completeWithError(ex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package tech.easyflow.core.chat.protocol.sse;
|
||||
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.core.chat.protocol.ChatDomain;
|
||||
import tech.easyflow.core.chat.protocol.ChatEnvelope;
|
||||
import tech.easyflow.core.chat.protocol.ChatType;
|
||||
import tech.easyflow.core.chat.protocol.payload.ErrorPayload;
|
||||
|
||||
public class ChatSseUtil {
|
||||
|
||||
private static final long DEFAULT_TIMEOUT = 1000 * 60 * 2; // 2分钟
|
||||
|
||||
/**
|
||||
* 快速发送一次系统错误消息,并立即关闭 SSE
|
||||
*
|
||||
* @param conversationId 会话ID
|
||||
* @param message 错误提示信息
|
||||
* @param code 错误码,可选
|
||||
* @return SseEmitter
|
||||
*/
|
||||
public static SseEmitter sendSystemError(String conversationId, String message, String code) {
|
||||
ChatSseEmitter emitter = new ChatSseEmitter(DEFAULT_TIMEOUT);
|
||||
|
||||
ChatEnvelope<ErrorPayload> envelope = new ChatEnvelope<>();
|
||||
// envelope.setProtocol("easyflow-ai-chat");
|
||||
// envelope.setVersion("1.1");
|
||||
envelope.setDomain(ChatDomain.SYSTEM);
|
||||
envelope.setType(ChatType.ERROR);
|
||||
envelope.setConversationId(conversationId);
|
||||
|
||||
ErrorPayload payload = new ErrorPayload();
|
||||
payload.setMessage(message);
|
||||
payload.setCode(code != null ? code : "SYSTEM_ERROR");
|
||||
payload.setRetryable(false);
|
||||
|
||||
envelope.setPayload(payload);
|
||||
|
||||
// 发送并立即关闭
|
||||
emitter.sendAndClose(envelope);
|
||||
|
||||
return emitter.getEmitter();
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速发送一次系统错误消息(默认错误码)
|
||||
*/
|
||||
public static SseEmitter sendSystemError(String conversationId, String message) {
|
||||
return sendSystemError(conversationId, message, null);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 快速发送任意 ChatEnvelope 消息并立即关闭 SSE
|
||||
*
|
||||
* @param conversationId 会话ID
|
||||
* @param domain 消息 domain
|
||||
* @param type 消息 type
|
||||
* @param payload 消息 payload
|
||||
* @param meta 可选 meta 信息
|
||||
* @return SseEmitter 已经发送并关闭
|
||||
*/
|
||||
public static <T> SseEmitter sendAndClose(
|
||||
String conversationId,
|
||||
ChatDomain domain,
|
||||
ChatType type,
|
||||
T payload,
|
||||
Object meta
|
||||
) {
|
||||
ChatSseEmitter emitter = new ChatSseEmitter(DEFAULT_TIMEOUT);
|
||||
|
||||
ChatEnvelope<T> envelope = new ChatEnvelope<>();
|
||||
// envelope.setProtocol("easyflow-ai-chat");
|
||||
// envelope.setVersion("1.1");
|
||||
envelope.setConversationId(conversationId);
|
||||
envelope.setDomain(domain);
|
||||
envelope.setType(type);
|
||||
envelope.setPayload(payload);
|
||||
envelope.setMeta(meta);
|
||||
|
||||
// 发送并立即关闭
|
||||
emitter.sendAndClose(envelope);
|
||||
|
||||
return emitter.getEmitter();
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速发送任意 ChatEnvelope 消息,无 meta
|
||||
*/
|
||||
public static <T> SseEmitter sendAndClose(
|
||||
String conversationId,
|
||||
ChatDomain domain,
|
||||
ChatType type,
|
||||
T payload
|
||||
) {
|
||||
return sendAndClose(conversationId, domain, type, payload, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package tech.easyflow.core.chat.protocol.websocket;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import tech.easyflow.core.chat.protocol.ChatEnvelope;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ChatWebSocketSender {
|
||||
|
||||
private final WebSocketSession session;
|
||||
|
||||
public ChatWebSocketSender(WebSocketSession session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 ChatEnvelope 消息
|
||||
*/
|
||||
public void send(ChatEnvelope<?> envelope) throws IOException {
|
||||
checkOpen();
|
||||
String json = JSON.toJSONString(envelope);
|
||||
session.sendMessage(new TextMessage(json));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 error 消息
|
||||
*/
|
||||
public void sendError(ChatEnvelope<?> envelope) throws IOException {
|
||||
checkOpen();
|
||||
send(envelope); // 前端可根据 envelope.type 判断 error
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 done 并关闭 WebSocket
|
||||
*/
|
||||
public void sendDone(ChatEnvelope<?> envelope) throws IOException {
|
||||
send(envelope);
|
||||
close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 WebSocket
|
||||
*/
|
||||
public void close() throws IOException {
|
||||
if (session.isOpen()) {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 session 是否仍然打开
|
||||
*/
|
||||
private void checkOpen() throws IOException {
|
||||
if (!session.isOpen()) {
|
||||
throw new IOException("WebSocket session is closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user