diff --git a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java index a8e34fea..a40f9a82 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java @@ -319,6 +319,23 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter { } } + /** + * 按渠道配置过滤外发文本(不做平台分割) + *

+ * 卡片式流式渠道不经过 {@link #renderAndSend}(它们自己管理消息长度和 + * 卡片更新节奏),如果不在流式收尾处调用本方法, + * {@code filter_thinking} / {@code filter_tool_messages} 两个开关 + * 在这些路径上就完全不生效。 + * + * @param content 原始文本 + * @return 过滤后的文本(入参为空时返回空串) + */ + protected String filterOutboundContent(String content) { + return ChannelMessageRenderer.applyFilters(content, + getConfigBoolean("filter_thinking", true), + getConfigBoolean("filter_tool_messages", true)); + } + /** * Approval notice rendering — primary implementation position. * diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRenderer.java index 4cdcf8f5..f9824bfc 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRenderer.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRenderer.java @@ -72,10 +72,34 @@ public final class ChannelMessageRenderer { boolean filterToolMessages, String messageFormat, int maxLength) { - if (content == null || content.isBlank()) { + String rendered = applyFilters(content, filterThinking, filterToolMessages); + + if (rendered.isEmpty()) { return List.of(""); } + // 按平台限制分割 + return truncateForPlatform(rendered, maxLength); + } + + /** + * 只做内容过滤,不做平台分割 + *

+ * 卡片式流式渠道(钉钉 AI Card、飞书 CardKit)自己管理长度限制, + * 但同样需要遵守渠道的消息过滤配置,因此把过滤部分单独暴露出来。 + * + * @param content 原始内容 + * @param filterThinking 是否过滤 thinking 标签 + * @param filterToolMessages 是否过滤工具调用信息 + * @return 过滤后的内容(入参为空时返回空串) + */ + public static String applyFilters(String content, + boolean filterThinking, + boolean filterToolMessages) { + if (content == null || content.isBlank()) { + return ""; + } + String rendered = content; // 1. 过滤 thinking @@ -89,14 +113,7 @@ public final class ChannelMessageRenderer { } // 3. 清理多余空行 - rendered = rendered.replaceAll("\n{3,}", "\n\n").trim(); - - if (rendered.isEmpty()) { - return List.of(""); - } - - // 4. 按平台限制分割 - return truncateForPlatform(rendered, maxLength); + return rendered.replaceAll("\n{3,}", "\n\n").trim(); } // ==================== 过滤方法 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java index 8144479e..86453c85 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java @@ -361,15 +361,21 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St .blockLast(Duration.ofMinutes(5)); // Step 3: 完成 - String finalContent = contentAccumulator.toString(); - if (finalContent.isBlank()) { - finalContent = "(无回复内容)"; + // The AI Card path never touches renderAndSend, so the channel's + // message-filter config has to be applied here — otherwise + // filter_thinking / filter_tool_messages are inert whenever AI + // Card mode is on. The unfiltered text is still what we return, + // so persistence keeps the model's original answer. + String rawContent = contentAccumulator.toString(); + String cardContent = filterOutboundContent(rawContent); + if (cardContent.isBlank()) { + cardContent = "(无回复内容)"; } - aiCardManager.finishCard(outTrackId, finalContent); + aiCardManager.finishCard(outTrackId, cardContent); log.info("[dingtalk] AI Card streaming completed: outTrackId={}, contentLen={}", - outTrackId, finalContent.length()); - return finalContent; + outTrackId, cardContent.length()); + return rawContent.isBlank() ? cardContent : rawContent; } catch (Exception e) { log.error("[dingtalk] AI Card streaming failed: outTrackId={}, error={}", diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 0d0f6299..8847a187 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -2635,8 +2635,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre .blockLast(Duration.ofMinutes(5)); String finalContent = accumulator.toString(); - if (finalContent.isBlank()) { - finalContent = "(无回复内容)"; + // Card streaming never touches renderAndSend, so the channel's + // message-filter config has to be applied here — otherwise + // filter_thinking / filter_tool_messages are inert on this path. + String cardContent = filterOutboundContent(finalContent); + if (cardContent.isBlank()) { + cardContent = "(无回复内容)"; } // Strip any /api/v1/files/generated/{id} URLs out of the card // text (replacing each with a "📎 filename" marker) AND send @@ -2645,11 +2649,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // the user sees a broken-looking download link instead of the // actual file. Cache-miss URLs fall back to the user-facing // retry hint that GeneratedFileScrubber emits. - String renderedContent = scrubAndSendAttachments(receiveId, finalContent); + String renderedContent = scrubAndSendAttachments(receiveId, cardContent); streamingCardManager.finishCard(sessionKey, renderedContent); log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}", sessionKey, renderedContent.length()); - return finalContent; + return finalContent.isBlank() ? cardContent : finalContent; } catch (Exception e) { log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}", @@ -2686,7 +2690,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre }) .blockLast(Duration.ofMinutes(5)); String finalContent = accumulator.toString(); - if (!finalContent.isBlank()) { + // sendMessage is called directly (rather than renderAndSend) because + // Feishu does its own card/text split and chunking, so the channel's + // message-filter config is applied explicitly here. + String outbound = filterOutboundContent(finalContent); + if (!outbound.isBlank()) { String replyTarget = message.getReplyToken() != null ? message.getReplyToken() : (message.getChatId() != null ? message.getChatId() : message.getSenderId()); @@ -2694,7 +2702,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // Same scrub-and-upload hop as the streaming card finish path — // a generated-file URL in plain text would otherwise reach the // user as a markdown link that opens to nothing useful in IM. - String renderedContent = scrubAndSendAttachments(replyTarget, finalContent); + String renderedContent = scrubAndSendAttachments(replyTarget, outbound); sendMessage(replyTarget, renderedContent); } } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelOutboundFilterTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelOutboundFilterTest.java new file mode 100644 index 00000000..05e29fc2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelOutboundFilterTest.java @@ -0,0 +1,97 @@ +package vip.mate.channel; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.model.ChannelEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Outbound message filtering: the {@code filter_thinking} / + * {@code filter_tool_messages} channel toggles. + * + *

Card-based streaming adapters (DingTalk AI Card, Feishu CardKit) never + * call {@code renderAndSend} — they own their own length handling — so they + * apply the filters through {@code filterOutboundContent}. These tests pin + * that helper's contract so those paths cannot silently go unfiltered again. + */ +class ChannelOutboundFilterTest { + + /** Minimal concrete adapter — only the config plumbing is under test. */ + private static class StubAdapter extends AbstractChannelAdapter { + StubAdapter(String configJson) { + super(entity(configJson), null, new ObjectMapper()); + } + + private static ChannelEntity entity(String configJson) { + ChannelEntity e = new ChannelEntity(); + e.setId(1L); + e.setName("stub"); + e.setChannelType("stub"); + e.setConfigJson(configJson); + return e; + } + + @Override protected void doStart() {} + @Override protected void doStop() {} + @Override public void sendMessage(String targetId, String content) {} + @Override public String getChannelType() { return "stub"; } + + String filter(String content) { return filterOutboundContent(content); } + } + + private static final String RAW = """ + weighing the options + Action: search + Action Input: {"q": "weather"} + Observation: sunny + {"name":"search"} + It will be sunny tomorrow."""; + + @Test + @DisplayName("filter_thinking + filter_tool_messages = true strips markers from outbound text") + void filtersBothWhenEnabled() { + String out = new StubAdapter("{\"filter_thinking\":true,\"filter_tool_messages\":true}").filter(RAW); + assertEquals("It will be sunny tomorrow.", out); + } + + @Test + @DisplayName("Filtering is on by default when the keys are absent") + void defaultsToFiltering() { + String out = new StubAdapter("{}").filter(RAW); + assertEquals("It will be sunny tomorrow.", out); + } + + @Test + @DisplayName("filter_* = false leaves the markers in place") + void keepsMarkersWhenDisabled() { + String out = new StubAdapter("{\"filter_thinking\":false,\"filter_tool_messages\":false}").filter(RAW); + assertTrue(out.contains("weighing the options")); + assertTrue(out.contains("Observation: sunny")); + assertTrue(out.contains("")); + } + + @Test + @DisplayName("Each toggle acts independently") + void togglesAreIndependent() { + String thinkingOnly = new StubAdapter( + "{\"filter_thinking\":true,\"filter_tool_messages\":false}").filter(RAW); + assertTrue(!thinkingOnly.contains(""), "thinking should be stripped"); + assertTrue(thinkingOnly.contains(""), "tool markers should survive"); + + String toolOnly = new StubAdapter( + "{\"filter_thinking\":false,\"filter_tool_messages\":true}").filter(RAW); + assertTrue(toolOnly.contains(""), "thinking should survive"); + assertTrue(!toolOnly.contains(""), "tool markers should be stripped"); + } + + @Test + @DisplayName("Blank / null input yields an empty string, never NPE") + void blankInputIsSafe() { + StubAdapter adapter = new StubAdapter("{}"); + assertEquals("", adapter.filter(null)); + assertEquals("", adapter.filter(" ")); + } +} diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index c1b9dc56..4ebff9f1 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -397,10 +397,10 @@ -

+
-
+