mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +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.
|
* Approval notice rendering — primary implementation position.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -72,10 +72,34 @@ public final class ChannelMessageRenderer {
|
|||||||
boolean filterToolMessages,
|
boolean filterToolMessages,
|
||||||
String messageFormat,
|
String messageFormat,
|
||||||
int maxLength) {
|
int maxLength) {
|
||||||
if (content == null || content.isBlank()) {
|
String rendered = applyFilters(content, filterThinking, filterToolMessages);
|
||||||
|
|
||||||
|
if (rendered.isEmpty()) {
|
||||||
return List.of("");
|
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;
|
String rendered = content;
|
||||||
|
|
||||||
// 1. 过滤 thinking
|
// 1. 过滤 thinking
|
||||||
@ -89,14 +113,7 @@ public final class ChannelMessageRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 清理多余空行
|
// 3. 清理多余空行
|
||||||
rendered = rendered.replaceAll("\n{3,}", "\n\n").trim();
|
return rendered.replaceAll("\n{3,}", "\n\n").trim();
|
||||||
|
|
||||||
if (rendered.isEmpty()) {
|
|
||||||
return List.of("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 按平台限制分割
|
|
||||||
return truncateForPlatform(rendered, maxLength);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 过滤方法 ====================
|
// ==================== 过滤方法 ====================
|
||||||
|
|||||||
@ -361,15 +361,21 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
.blockLast(Duration.ofMinutes(5));
|
.blockLast(Duration.ofMinutes(5));
|
||||||
|
|
||||||
// Step 3: 完成
|
// Step 3: 完成
|
||||||
String finalContent = contentAccumulator.toString();
|
// The AI Card path never touches renderAndSend, so the channel's
|
||||||
if (finalContent.isBlank()) {
|
// message-filter config has to be applied here — otherwise
|
||||||
finalContent = "(无回复内容)";
|
// 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={}",
|
log.info("[dingtalk] AI Card streaming completed: outTrackId={}, contentLen={}",
|
||||||
outTrackId, finalContent.length());
|
outTrackId, cardContent.length());
|
||||||
return finalContent;
|
return rawContent.isBlank() ? cardContent : rawContent;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[dingtalk] AI Card streaming failed: outTrackId={}, error={}",
|
log.error("[dingtalk] AI Card streaming failed: outTrackId={}, error={}",
|
||||||
|
|||||||
@ -2635,8 +2635,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
.blockLast(Duration.ofMinutes(5));
|
.blockLast(Duration.ofMinutes(5));
|
||||||
|
|
||||||
String finalContent = accumulator.toString();
|
String finalContent = accumulator.toString();
|
||||||
if (finalContent.isBlank()) {
|
// Card streaming never touches renderAndSend, so the channel's
|
||||||
finalContent = "(无回复内容)";
|
// 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
|
// Strip any /api/v1/files/generated/{id} URLs out of the card
|
||||||
// text (replacing each with a "📎 filename" marker) AND send
|
// 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
|
// the user sees a broken-looking download link instead of the
|
||||||
// actual file. Cache-miss URLs fall back to the user-facing
|
// actual file. Cache-miss URLs fall back to the user-facing
|
||||||
// retry hint that GeneratedFileScrubber emits.
|
// retry hint that GeneratedFileScrubber emits.
|
||||||
String renderedContent = scrubAndSendAttachments(receiveId, finalContent);
|
String renderedContent = scrubAndSendAttachments(receiveId, cardContent);
|
||||||
streamingCardManager.finishCard(sessionKey, renderedContent);
|
streamingCardManager.finishCard(sessionKey, renderedContent);
|
||||||
log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
|
log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
|
||||||
sessionKey, renderedContent.length());
|
sessionKey, renderedContent.length());
|
||||||
return finalContent;
|
return finalContent.isBlank() ? cardContent : finalContent;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}",
|
log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}",
|
||||||
@ -2686,7 +2690,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
})
|
})
|
||||||
.blockLast(Duration.ofMinutes(5));
|
.blockLast(Duration.ofMinutes(5));
|
||||||
String finalContent = accumulator.toString();
|
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
|
String replyTarget = message.getReplyToken() != null
|
||||||
? message.getReplyToken()
|
? message.getReplyToken()
|
||||||
: (message.getChatId() != null ? message.getChatId() : message.getSenderId());
|
: (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 —
|
// Same scrub-and-upload hop as the streaming card finish path —
|
||||||
// a generated-file URL in plain text would otherwise reach the
|
// a generated-file URL in plain text would otherwise reach the
|
||||||
// user as a markdown link that opens to nothing useful in IM.
|
// 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);
|
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>
|
</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>
|
<label class="section-label">{{ t('channels.messageFilter.title') }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div v-if="supportsMessageFilter" class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
{{ t('channels.messageFilter.filterThinking') }}
|
{{ t('channels.messageFilter.filterThinking') }}
|
||||||
@ -633,6 +633,15 @@ const needsWebhookUrl = computed(() => {
|
|||||||
return true
|
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 isLocalhost = computed(() => {
|
||||||
const host = window.location.hostname
|
const host = window.location.hostname
|
||||||
return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0'
|
return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user