mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(channel): apply message filters on card streaming paths
This commit is contained in:
parent
a0f0e85eb6
commit
9bc4741aa7
@ -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.
|
||||
*
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
// ==================== 过滤方法 ====================
|
||||
|
||||
@ -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={}",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(" "));
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user