fix(channel): apply message filters on card streaming paths

This commit is contained in:
matevip 2026-07-28 06:31:01 -04:00
parent a0f0e85eb6
commit 9bc4741aa7
6 changed files with 177 additions and 23 deletions

View File

@ -319,6 +319,23 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
}
}
/**
* 按渠道配置过滤外发文本不做平台分割
* <p>
* 卡片式流式渠道不经过 {@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.
*

View File

@ -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);
}
/**
* 只做内容过滤不做平台分割
* <p>
* 卡片式流式渠道钉钉 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();
}
// ==================== 过滤方法 ====================

View File

@ -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={}",

View File

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

View File

@ -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.
*
* <p>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 = """
<think>weighing the options</think>
Action: search
Action Input: {"q": "weather"}
Observation: sunny
<tool_call>{"name":"search"}</tool_call>
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("<think>weighing the options</think>"));
assertTrue(out.contains("Observation: sunny"));
assertTrue(out.contains("<tool_call>"));
}
@Test
@DisplayName("Each toggle acts independently")
void togglesAreIndependent() {
String thinkingOnly = new StubAdapter(
"{\"filter_thinking\":true,\"filter_tool_messages\":false}").filter(RAW);
assertTrue(!thinkingOnly.contains("<think>"), "thinking should be stripped");
assertTrue(thinkingOnly.contains("<tool_call>"), "tool markers should survive");
String toolOnly = new StubAdapter(
"{\"filter_thinking\":false,\"filter_tool_messages\":true}").filter(RAW);
assertTrue(toolOnly.contains("<think>"), "thinking should survive");
assertTrue(!toolOnly.contains("<tool_call>"), "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(" "));
}
}

View File

@ -397,10 +397,10 @@
</div>
</div>
<div class="form-group full-width section-divider">
<div v-if="supportsMessageFilter" class="form-group full-width section-divider">
<label class="section-label">{{ t('channels.messageFilter.title') }}</label>
</div>
<div class="form-grid">
<div v-if="supportsMessageFilter" class="form-grid">
<div class="form-group">
<label class="form-label">
{{ t('channels.messageFilter.filterThinking') }}
@ -633,6 +633,15 @@ const needsWebhookUrl = computed(() => {
return true
})
// Browser-rendered channels stream structured message parts over SSE and let
// the client decide what to draw (thinking panel, tool cards). They never go
// through the adapter's outbound text render path, which is the only place the
// message-filter config is read so the controls would be inert there.
const BROWSER_RENDERED_TYPES = ['web', 'webchat']
const supportsMessageFilter = computed(
() => !BROWSER_RENDERED_TYPES.includes(form.value.channelType || ''),
)
const isLocalhost = computed(() => {
const host = window.location.hostname
return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0'