package vip.mate.channel;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 渠道消息渲染器
*
* 渠道消息渲染设计:
* - 过滤 thinking 标签和工具调用信息
* - 按平台字数限制分割长消息
* - 保持代码块完整性(不在 ``` 中间切割)
*
* @author MateClaw Team
*/
public final class ChannelMessageRenderer {
private ChannelMessageRenderer() {}
/** 各平台消息字数限制 */
public static final Map PLATFORM_LIMITS = Map.of(
"telegram", 4096,
"discord", 2000,
"dingtalk", 20000,
"feishu", 10000,
"wecom", 2048,
"qq", 4096,
"weixin", 4096
);
/** 匹配 ... 标签(含嵌套内容) */
private static final Pattern THINK_PATTERN = Pattern.compile(
"[\\s\\S]*?", Pattern.CASE_INSENSITIVE);
/** 匹配 ... */
private static final Pattern TOOL_CALL_PATTERN = Pattern.compile(
"[\\s\\S]*?", Pattern.CASE_INSENSITIVE);
/** 匹配 ... */
private static final Pattern TOOL_RESULT_PATTERN = Pattern.compile(
"[\\s\\S]*?", Pattern.CASE_INSENSITIVE);
/** 匹配 ReAct 格式的中间步骤行:Action: / Action Input: / Observation: */
private static final Pattern REACT_STEP_PATTERN = Pattern.compile(
"(?m)^(Action|Action Input|Observation):.*$");
/** 代码块围栏标记 */
private static final String CODE_FENCE = "```";
/** 代码块围栏最大额外开销:开启 "```lang\n" + 关闭 "\n```" ≈ 最长语言标识20字符 + 固定8字符 */
private static final int CODE_FENCE_OVERHEAD = 30;
/** 分割时的安全余量(为代码块关闭/开启标记留空间) */
private static final int SAFETY_MARGIN = 100 + CODE_FENCE_OVERHEAD;
// ==================== 核心 API ====================
/**
* 综合渲染:过滤 + 分割
*
* @param content 原始内容
* @param filterThinking 是否过滤 thinking 标签
* @param filterToolMessages 是否过滤工具调用信息
* @param messageFormat 消息格式(暂留扩展,当前不做转换)
* @param maxLength 平台字数限制
* @return 分割后的消息段列表
*/
public static List renderForChannel(String content,
boolean filterThinking,
boolean filterToolMessages,
String messageFormat,
int maxLength) {
if (content == null || content.isBlank()) {
return List.of("");
}
String rendered = content;
// 1. 过滤 thinking
if (filterThinking) {
rendered = stripThinking(rendered);
}
// 2. 过滤工具调用
if (filterToolMessages) {
rendered = stripToolCalls(rendered);
}
// 3. 清理多余空行
rendered = rendered.replaceAll("\n{3,}", "\n\n").trim();
if (rendered.isEmpty()) {
return List.of("");
}
// 4. 按平台限制分割
return truncateForPlatform(rendered, maxLength);
}
// ==================== 过滤方法 ====================
/**
* 移除 <think>...</think> 标签及其内容
*/
public static String stripThinking(String content) {
if (content == null) return "";
return THINK_PATTERN.matcher(content).replaceAll("").trim();
}
/**
* 移除工具调用信息:
* - <tool_call>...</tool_call>
* - <tool_result>...</tool_result>
* - Action: / Action Input: / Observation: 行(ReAct 格式)
*/
public static String stripToolCalls(String content) {
if (content == null) return "";
String result = content;
result = TOOL_CALL_PATTERN.matcher(result).replaceAll("");
result = TOOL_RESULT_PATTERN.matcher(result).replaceAll("");
result = REACT_STEP_PATTERN.matcher(result).replaceAll("");
return result.trim();
}
// ==================== 分割方法 ====================
/**
* 按平台字数限制分割消息,保持代码块完整性
*
* @param content 内容
* @param maxLength 最大长度
* @return 分割后的消息段列表
*/
public static List truncateForPlatform(String content, int maxLength) {
if (content == null || content.isEmpty()) {
return List.of("");
}
if (content.length() <= maxLength) {
return List.of(content);
}
List segments = new ArrayList<>();
int effectiveMax = maxLength - SAFETY_MARGIN;
if (effectiveMax <= 0) {
effectiveMax = maxLength;
}
int pos = 0;
boolean inCodeBlock = false;
String codeBlockLang = ""; // 记录代码块语言标识
while (pos < content.length()) {
int remaining = content.length() - pos;
if (remaining <= maxLength) {
// 剩余内容不超限,直接作为最后一段
String lastSegment = content.substring(pos);
if (inCodeBlock) {
lastSegment = CODE_FENCE + codeBlockLang + "\n" + lastSegment;
}
segments.add(lastSegment);
break;
}
// 在 effectiveMax 范围内寻找最佳切割点
int cutPoint = findCutPoint(content, pos, effectiveMax);
String chunk = content.substring(pos, cutPoint);
// 如果上一段结束时在代码块内,本段开头需要重新打开
if (inCodeBlock) {
chunk = CODE_FENCE + codeBlockLang + "\n" + chunk;
}
// 统计本段中的代码块围栏数量,更新状态
CodeBlockState state = analyzeCodeFences(chunk, inCodeBlock, codeBlockLang);
inCodeBlock = state.inCodeBlock;
codeBlockLang = state.lang;
// 如果本段结束时仍在代码块内,需要关闭
if (inCodeBlock) {
chunk = chunk + "\n" + CODE_FENCE;
}
segments.add(chunk);
pos = cutPoint;
}
return segments;
}
// ==================== 内部辅助 ====================
/**
* 在 [start, start + maxLen] 范围内寻找最佳切割点
* 优先在换行符处切割;如果找不到,硬切
*/
private static int findCutPoint(String content, int start, int maxLen) {
int end = Math.min(start + maxLen, content.length());
// 从 end 往前找最近的换行符
for (int i = end - 1; i > start + maxLen / 2; i--) {
if (content.charAt(i) == '\n') {
return i + 1; // 包含换行符
}
}
// 找不到合适的换行符,硬切
return end;
}
/**
* 分析文本中的代码块围栏,返回结束时的状态
*/
private static CodeBlockState analyzeCodeFences(String text, boolean initiallyInBlock, String initialLang) {
boolean inBlock = initiallyInBlock;
String lang = initialLang;
int idx = 0;
while (idx < text.length()) {
int fencePos = text.indexOf(CODE_FENCE, idx);
if (fencePos == -1) break;
if (!inBlock) {
// 进入代码块,尝试提取语言标识
int lineEnd = text.indexOf('\n', fencePos);
if (lineEnd == -1) lineEnd = text.length();
lang = text.substring(fencePos + CODE_FENCE.length(), lineEnd).trim();
if (!lang.isEmpty() && !lang.matches("[a-zA-Z0-9+#_.-]+")) {
lang = ""; // 无效的语言标识
}
inBlock = true;
} else {
// 退出代码块
inBlock = false;
lang = "";
}
idx = fencePos + CODE_FENCE.length();
}
return new CodeBlockState(inBlock, lang);
}
private record CodeBlockState(boolean inCodeBlock, String lang) {}
}