From 046080d6aa26c320fd44ed3050abd8f29809b14a Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 3 Aug 2026 05:56:16 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20thinking=20display=20overhaul=20?= =?UTF-8?q?=E2=80=94=20live=20think-tag=20extraction,=20real=20durations,?= =?UTF-8?q?=20default=20visibility,=20reconnect=20and=20team-note=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/graph/NodeStreamingChatHelper.java | 118 +++++++++---- .../agent/graph/ThinkTagStreamExtractor.java | 129 ++++++++++++++ .../channel/web/AgentStreamAccumulator.java | 5 + .../mate/system/model/SystemSettingsDTO.java | 7 + .../system/service/SystemSettingService.java | 27 ++- .../team/service/TeamAnnounceService.java | 12 +- .../conversation/ConversationService.java | 6 +- .../graph/ThinkTagStreamExtractorTest.java | 167 ++++++++++++++++++ .../team/service/TeamAnnounceServiceTest.java | 12 +- .../src/components/chat/MessageBubble.vue | 86 +++++++-- .../src/components/chat/MessageList.vue | 16 ++ .../src/components/chat/TeamAnnouncePanel.vue | 115 ++++++++++++ .../src/components/chat/ThinkingSegment.vue | 94 +++++++++- mateclaw-ui/src/composables/chat/useChat.ts | 27 ++- mateclaw-ui/src/composables/chat/useStream.ts | 20 +++ mateclaw-ui/src/i18n/locales/en-US.ts | 16 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 16 ++ .../src/stores/useSystemSettingsStore.ts | 14 +- mateclaw-ui/src/types/index.ts | 9 + mateclaw-ui/src/utils/messageReconcile.ts | 21 ++- mateclaw-ui/src/views/ChatConsole.vue | 3 + .../src/views/Settings/System/index.vue | 14 ++ 22 files changed, 854 insertions(+), 80 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/graph/ThinkTagStreamExtractor.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/ThinkTagStreamExtractorTest.java create mode 100644 mateclaw-ui/src/components/chat/TeamAnnouncePanel.vue diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index ba5ecd35..1e69bdea 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -14,6 +14,7 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti import vip.mate.channel.web.ChatStreamTracker; import vip.mate.llm.chatmodel.AssistantThinkingRelay; import vip.mate.llm.chatmodel.ReasoningContentCache; +import vip.mate.llm.chatmodel.ThinkingLevelHolder; import reactor.core.Disposable; @@ -1182,6 +1183,41 @@ public class NodeStreamingChatHelper { )); } + // Inline tag extraction: models without structured reasoning + // stream their reasoning inside ... in the content + // channel. Split those spans off live so the stream the user watches + // matches what persistence later stores (raw tags used to leak into + // content_delta and only disappear after a reload). + ThinkTagStreamExtractor thinkExtractor = new ThinkTagStreamExtractor(); + + // Shared handling for a thinking delta, regardless of origin + // (structured reasoningContent metadata or inline-tag extraction). + Consumer onThinkingDelta = thinkingDelta -> { + // First-token signaling fires for thinking too — UI + // shows "thinking" activity before any content streams. + if (broadcast && streamTracker != null + && firstTokenSignaled.compareAndSet(false, true)) { + streamTracker.markFirstTokenReceived(conversationId); + } + // First thinking delta opens the thinking phase. We + // emit the start lazily (on first delta) rather than + // before subscription so models that never produce + // thinking don't ghost-pair an empty segment. + if (broadcast && thinkingAccum.length() == 0 + && thinkingStartEmitted.compareAndSet(false, true)) { + streamTracker.broadcastObject(conversationId, "thinking_start", Map.of( + "phase", phase != null ? phase : "", + "timestamp", System.currentTimeMillis() + )); + } + thinkingAccum.append(thinkingDelta); + // thinkingLevel=off 时不广播 thinking(模型仍可能产生,但前端不展示) + boolean suppressThinking = "off".equalsIgnoreCase(ThinkingLevelHolder.get()); + if (broadcast && !suppressThinking) { + broadcastDelta(conversationId, "thinking_delta", thinkingDelta); + } + }; + CountDownLatch latch = new CountDownLatch(1); Disposable subscription = chatModel.stream(prompt) @@ -1198,8 +1234,29 @@ public class NodeStreamingChatHelper { return; } - // 1. 提取 content delta - String contentDelta = msg.getText(); + // 1. 拆分本 chunk 的通道:出现结构化 reasoningContent 即关闭 + // 内联标签提取(此类模型不会再用 包裹思考,正文里的 + // 字面标签是真实内容)。 + String nativeThinking = extractReasoningContent(msg); + if (nativeThinking != null && !nativeThinking.isEmpty()) { + thinkExtractor.disable(); + } + String rawContent = msg.getText(); + String contentDelta = rawContent; + String tagThinking = null; + if (rawContent != null && !rawContent.isEmpty()) { + var split = thinkExtractor.feed(rawContent); + contentDelta = split.content(); + tagThinking = split.thinking(); + } + + // 2. 标签提取的 thinking 先处理:形如 "…answer" 的 + // chunk 里思考先于正文出现。 + if (tagThinking != null && !tagThinking.isEmpty()) { + onThinkingDelta.accept(tagThinking); + } + + // 3. content delta(已剥离 内文本) if (contentDelta != null && !contentDelta.isEmpty()) { // First content delta closes the thinking phase if one // was open, and arms first-token heartbeat relaxation. @@ -1220,43 +1277,19 @@ public class NodeStreamingChatHelper { } } - // 2. 提取 thinking delta. Do not cancel the stream for + // 4. 结构化 thinking delta. Do not cancel the stream for // repeated thinking phrases: some models emit repetitive // internal planning while still making valid tool progress. - String thinkingDelta = extractReasoningContent(msg); - if (thinkingDelta != null && !thinkingDelta.isEmpty()) { - // First-token signaling fires for thinking too — UI - // shows "thinking" activity before any content streams. - if (broadcast && streamTracker != null - && firstTokenSignaled.compareAndSet(false, true)) { - streamTracker.markFirstTokenReceived(conversationId); - } - // First thinking delta opens the thinking phase. We - // emit the start lazily (on first delta) rather than - // before subscription so models that never produce - // thinking don't ghost-pair an empty segment. - if (broadcast && thinkingAccum.length() == 0 - && thinkingStartEmitted.compareAndSet(false, true)) { - streamTracker.broadcastObject(conversationId, "thinking_start", Map.of( - "phase", phase != null ? phase : "", - "timestamp", System.currentTimeMillis() - )); - } - thinkingAccum.append(thinkingDelta); - // thinkingLevel=off 时不广播 thinking(模型仍可能产生,但前端不展示) - boolean suppressThinking = "off".equalsIgnoreCase( - vip.mate.llm.chatmodel.ThinkingLevelHolder.get()); - if (broadcast && !suppressThinking) { - broadcastDelta(conversationId, "thinking_delta", thinkingDelta); - } + if (nativeThinking != null && !nativeThinking.isEmpty()) { + onThinkingDelta.accept(nativeThinking); } - // 3. 累积 tool calls(处理分片) + // 5. 累积 tool calls(处理分片) if (msg.hasToolCalls()) { accumulateToolCalls(msg.getToolCalls(), toolCallAccumulators); } - // 4. Thinking-only no-progress guard. MUST run after both + // 6. Thinking-only no-progress guard. MUST run after both // content delta and tool call accumulation, otherwise a // chunk that carries thinking AND a tool_call together // (some Anthropic / DeepSeek-thinking responses do this) @@ -1281,7 +1314,7 @@ public class NodeStreamingChatHelper { return; } - // 5. Content-repetition guard. Some reasoning-mode models + // 7. Content-repetition guard. Some reasoning-mode models // (qwen3.6, deepseek-r1) get stuck in a "Wait, I should X // → 写答案 → Wait, I should Y → 写同一份答案 → ..." loop // and emit the same final-answer paragraph dozens of times @@ -1311,7 +1344,7 @@ public class NodeStreamingChatHelper { } } - // 4. 提取 token usage(通常最后一个 chunk 携带完整 usage) + // 8. 提取 token usage(通常最后一个 chunk 携带完整 usage) if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) { var usage = chatResponse.getMetadata().getUsage(); if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) { @@ -1375,6 +1408,7 @@ public class NodeStreamingChatHelper { "returning stopped partial result: conversationId={}", phase, contentAccum.length(), thinkingAccum.length(), toolCallAccumulators.size(), conversationId); + drainThinkExtractor(thinkExtractor, contentAccum, thinkingAccum); return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators, promptTokens.get(), completionTokens.get(), cacheReadTokens.get(), cacheWriteTokens.get(), @@ -1396,6 +1430,11 @@ public class NodeStreamingChatHelper { return buildErrorResult("LLM 调用被中断", conversationId, phase); } + // Stream is over (complete, error, or disposed by a guard) — drain the + // extractor's held-back tail so the accumulators are complete before + // any assembly or emptiness check below. + drainThinkExtractor(thinkExtractor, contentAccum, thinkingAccum); + Throwable error = errorRef.get(); if (error != null) { boolean hasAccumulatedContent = !contentAccum.isEmpty() || !toolCallAccumulators.isEmpty(); @@ -2455,6 +2494,19 @@ public class NodeStreamingChatHelper { // ==================== 标签 fallback 解析 ==================== + /** Flush the streaming extractor's held-back tail into the accumulators. */ + private static void drainThinkExtractor(ThinkTagStreamExtractor extractor, + StringBuilder contentAccum, + StringBuilder thinkingAccum) { + var rest = extractor.flush(); + if (!rest.content().isEmpty()) { + contentAccum.append(rest.content()); + } + if (!rest.thinking().isEmpty()) { + thinkingAccum.append(rest.thinking()); + } + } + private record ThinkExtracted(String thinking, String content) {} /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/ThinkTagStreamExtractor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/ThinkTagStreamExtractor.java new file mode 100644 index 00000000..81b93187 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/ThinkTagStreamExtractor.java @@ -0,0 +1,129 @@ +package vip.mate.agent.graph; + +/** + * Incremental extractor that routes inline {@code ...} spans + * out of a streamed content channel and into a thinking channel, chunk by + * chunk. Models without structured reasoning support emit their reasoning + * inline in the content stream; without live extraction the raw tags reach + * the user during streaming and only disappear after the persisted (cleaned) + * message is reloaded. + *

+ * A tag may be split across chunk boundaries ({@code "abcxyz"}). The extractor holds back a chunk tail that is a proper + * prefix of the next expected tag (at most {@code .length() - 1} + * characters) and re-examines it with the following chunk, so the hold-back + * buffer is O(1). Call {@link #flush()} once the stream ends to drain that + * tail: in text mode it is returned as content, inside an unclosed + * {@code } it is returned as thinking — matching the post-stream + * fallback parser's semantics for unterminated tags. + *

+ * Not thread-safe. One instance per streamed LLM call; Reactor serializes + * {@code doOnNext} so no synchronization is needed. + */ +final class ThinkTagStreamExtractor { + + /** Split result of one {@link #feed} / {@link #flush} call; fields are never null. */ + record Extracted(String content, String thinking) { + static final Extracted EMPTY = new Extracted("", ""); + } + + private static final String OPEN_TAG = ""; + private static final String CLOSE_TAG = ""; + + /** Carry-over between chunks: a chunk tail that may still become a tag. */ + private final StringBuilder pending = new StringBuilder(); + private boolean insideThink; + private boolean disabled; + + /** + * Turn extraction off for the rest of the stream. Called when structured + * reasoning content shows up — such a model never tag-wraps its thinking, + * so any literal tag text in the answer is real content. Thinking already + * extracted stays extracted; a held-back tail is returned as content on + * the next {@link #feed} / {@link #flush}. + */ + void disable() { + disabled = true; + } + + /** Split one content chunk into its content and thinking parts. */ + Extracted feed(String chunk) { + if (chunk == null || chunk.isEmpty()) { + return Extracted.EMPTY; + } + if (disabled) { + if (pending.isEmpty()) { + return new Extracted(chunk, ""); + } + String held = pending.toString(); + pending.setLength(0); + return new Extracted(held + chunk, ""); + } + pending.append(chunk); + String buf = pending.toString(); + pending.setLength(0); + + StringBuilder content = new StringBuilder(); + StringBuilder thinking = new StringBuilder(); + int i = 0; + while (i < buf.length()) { + String tag = insideThink ? CLOSE_TAG : OPEN_TAG; + StringBuilder out = insideThink ? thinking : content; + int idx = buf.indexOf(tag, i); + if (idx >= 0) { + out.append(buf, i, idx); + i = idx + tag.length(); + insideThink = !insideThink; + } else { + int hold = holdbackStart(buf, i, tag); + out.append(buf, i, hold); + pending.append(buf, hold, buf.length()); + break; + } + } + return new Extracted(content.toString(), thinking.toString()); + } + + /** + * Drain the held-back tail once the stream is over. Inside an unclosed + * {@code } the remainder counts as thinking, otherwise as content. + */ + Extracted flush() { + if (pending.isEmpty()) { + return Extracted.EMPTY; + } + String rest = pending.toString(); + pending.setLength(0); + return insideThink ? new Extracted("", rest) : new Extracted(rest, ""); + } + + /** + * Smallest index {@code s >= from} such that {@code buf[s..)} is a + * non-empty proper prefix of {@code tag}; {@code buf.length()} when the + * tail cannot start a tag. Only the last {@code tag.length() - 1} chars + * can qualify — a full tag would have been found by {@code indexOf}. + */ + private static int holdbackStart(String buf, int from, String tag) { + int len = buf.length(); + int earliest = Math.max(from, len - tag.length() + 1); + for (int s = earliest; s < len; s++) { + if (isProperPrefixOfTag(buf, s, tag)) { + return s; + } + } + return len; + } + + private static boolean isProperPrefixOfTag(String buf, int start, String tag) { + int n = buf.length() - start; + if (n <= 0 || n >= tag.length()) { + return false; + } + for (int k = 0; k < n; k++) { + if (buf.charAt(start + k) != tag.charAt(k)) { + return false; + } + } + return true; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java b/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java index 4cc6ba2d..748d979c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/AgentStreamAccumulator.java @@ -343,6 +343,7 @@ public final class AgentStreamAccumulator { && toolName.equals(seg.get("toolName"))); if (matches) { seg.put("status", "completed"); + seg.put("endTimestamp", System.currentTimeMillis()); seg.put("toolResult", data.getOrDefault("result", "")); seg.put("toolSuccess", data.getOrDefault("success", true)); break; @@ -388,6 +389,9 @@ public final class AgentStreamAccumulator { seg.put("id", type.substring(0, 2) + "-" + segCounter++); seg.put("type", type); seg.put("status", "running"); + // Wall-clock bounds let history replays show the real per-segment + // duration (e.g. "thought for 12s") instead of estimating from length. + seg.put("timestamp", System.currentTimeMillis()); return seg; } @@ -404,6 +408,7 @@ public final class AgentStreamAccumulator { for (var seg : segments) { if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) { seg.put("status", "completed"); + seg.put("endTimestamp", System.currentTimeMillis()); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java index 109b9de1..50c02d33 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java @@ -8,6 +8,13 @@ public class SystemSettingsDTO { private String language; private Boolean streamEnabled; private Boolean debugMode; + /** + * Whether the chat UI renders the model's reasoning ("thinking") blocks. + * Default true. Independent from debugMode (which gates tool-call + * internals and other diagnostics) and from the per-request thinking + * level (which controls whether the model thinks at all). + */ + private Boolean showThinking; private Boolean stateGraphEnabled; /** diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 16f58427..465ba419 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -32,6 +32,7 @@ public class SystemSettingService { private static final String LANGUAGE_KEY = "language"; private static final String STREAM_ENABLED_KEY = "streamEnabled"; private static final String DEBUG_MODE_KEY = "debugMode"; + private static final String SHOW_THINKING_KEY = "showThinking"; private static final String STATEGRAPH_ENABLED_KEY = "stateGraphEnabled"; // 搜索服务配置 keys @@ -164,6 +165,7 @@ public class SystemSettingService { dto.setLanguage(getValue(LANGUAGE_KEY, "zh-CN")); dto.setStreamEnabled(Boolean.parseBoolean(getValue(STREAM_ENABLED_KEY, "true"))); dto.setDebugMode(Boolean.parseBoolean(getValue(DEBUG_MODE_KEY, "false"))); + dto.setShowThinking(Boolean.parseBoolean(getValue(SHOW_THINKING_KEY, "true"))); dto.setStateGraphEnabled(Boolean.parseBoolean(getValue(STATEGRAPH_ENABLED_KEY, "false"))); // 搜索服务配置 @@ -314,10 +316,27 @@ public class SystemSettingService { } public SystemSettingsDTO saveSettings(SystemSettingsDTO dto) { - saveValue(LANGUAGE_KEY, dto.getLanguage(), "当前界面语言"); - saveValue(STREAM_ENABLED_KEY, String.valueOf(Boolean.TRUE.equals(dto.getStreamEnabled())), "是否开启流式响应"); - saveValue(DEBUG_MODE_KEY, String.valueOf(Boolean.TRUE.equals(dto.getDebugMode())), "是否开启调试模式"); - saveValue(STATEGRAPH_ENABLED_KEY, String.valueOf(Boolean.TRUE.equals(dto.getStateGraphEnabled())), "启用 StateGraph 架构的 ReAct Agent"); + // All of these are null-guarded: the bulk PUT /settings is shared by + // every settings page (System, Music, Video, Image, Stt, Tts, Model3D), + // each sending a partial payload. An unconditional write coerces the + // absent fields (null) to false/blank and silently resets them — that + // is how streamEnabled kept flipping off (killing live thinking and + // content streaming) whenever an unrelated settings page was saved. + if (dto.getLanguage() != null) { + saveValue(LANGUAGE_KEY, dto.getLanguage(), "当前界面语言"); + } + if (dto.getStreamEnabled() != null) { + saveValue(STREAM_ENABLED_KEY, String.valueOf(dto.getStreamEnabled()), "是否开启流式响应"); + } + if (dto.getDebugMode() != null) { + saveValue(DEBUG_MODE_KEY, String.valueOf(dto.getDebugMode()), "是否开启调试模式"); + } + if (dto.getShowThinking() != null) { + saveValue(SHOW_THINKING_KEY, String.valueOf(dto.getShowThinking()), "聊天界面是否展示模型思考过程"); + } + if (dto.getStateGraphEnabled() != null) { + saveValue(STATEGRAPH_ENABLED_KEY, String.valueOf(dto.getStateGraphEnabled()), "启用 StateGraph 架构的 ReAct Agent"); + } // 搜索服务配置 if (dto.getSearchEnabled() != null) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java index 8da88b08..64964cba 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamAnnounceService.java @@ -172,12 +172,20 @@ public class TeamAnnounceService { // Persist the announce turn: message persistence is the caller's // contract, and without it the lead's synthesized reply would // vanish from the conversation history on the next reload. - conversationService.saveMessage(leadConversationId, "user", message); + // Role stays "user" (the agent context pipeline resolves the + // current turn's input from the last user row); the metadata type + // marks it as an internal orchestration note so the chat UI can + // render a compact system strip instead of a user bubble. + conversationService.saveMessage(leadConversationId, "user", message, null, "completed", + 0, 0, null, null, + "{\"type\":\"team_announce\",\"taskCount\":" + taskCount + "}"); AgentService.ChatResult result = agentService.chatWithUsage( team.getLeadAgentId(), message, leadConversationId); String reply = result == null ? null : result.content(); if (reply != null && !reply.isBlank()) { - conversationService.saveMessage(leadConversationId, "assistant", reply); + conversationService.saveMessage(leadConversationId, "assistant", reply, null, "completed", + 0, 0, null, null, + "{\"type\":\"team_announce_reply\"}"); } streamTracker.broadcastObject(leadConversationId, "team_announce_reply", Map.of("teamId", String.valueOf(team.getId()), diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 4ad09d22..360f19bd 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -659,8 +659,12 @@ public class ConversationService { String summary = summarizeMessage(content, parts); // Derive the conversation title from the first user message // (only when the title is still the default "新对话"). + // Internal orchestration notes (e.g. team task settlement rows, + // metadata type team_announce) are user-role for context-pipeline + // reasons but must never become the visible conversation title. // 用第一条用户消息作为会话标题。 - if ("user".equals(role) && "新对话".equals(conv.getTitle())) { + if ("user".equals(role) && "新对话".equals(conv.getTitle()) + && (metadata == null || !metadata.contains("\"team_announce\""))) { conv.setTitle(summary.length() > 20 ? summary.substring(0, 20) + "..." : summary); } // Keep a short preview of the latest assistant reply for the diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ThinkTagStreamExtractorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ThinkTagStreamExtractorTest.java new file mode 100644 index 00000000..1a8d6602 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ThinkTagStreamExtractorTest.java @@ -0,0 +1,167 @@ +package vip.mate.agent.graph; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for the incremental {@code } tag extractor used by the + * streaming path. Covers whole-tag chunks, tags split across chunk + * boundaries, multiple think spans, unterminated tags, literal {@code <} + * characters that never become a tag, and the disable (structured-reasoning + * bypass) mode. Every case also asserts character conservation: content + + * thinking + tag characters must add up to the input. + */ +class ThinkTagStreamExtractorTest { + + /** Feed all chunks, then flush; returns [content, thinking]. */ + private static String[] run(ThinkTagStreamExtractor extractor, List chunks) { + StringBuilder content = new StringBuilder(); + StringBuilder thinking = new StringBuilder(); + for (String chunk : chunks) { + var ex = extractor.feed(chunk); + content.append(ex.content()); + thinking.append(ex.thinking()); + } + var rest = extractor.flush(); + content.append(rest.content()); + thinking.append(rest.thinking()); + return new String[]{content.toString(), thinking.toString()}; + } + + private static String[] run(List chunks) { + return run(new ThinkTagStreamExtractor(), chunks); + } + + @Test + void passesThroughContentWithoutTags() { + var out = run(List.of("Hello ", "world", "!")); + assertEquals("Hello world!", out[0]); + assertEquals("", out[1]); + } + + @Test + void extractsSingleTagWithinOneChunk() { + var out = run(List.of("reasoninganswer")); + assertEquals("answer", out[0]); + assertEquals("reasoning", out[1]); + } + + @Test + void extractsTagSplitAcrossChunks() { + var out = run(List.of("step one", " step twofinal")); + assertEquals("final", out[0]); + assertEquals("step one step two", out[1]); + } + + @Test + void extractsTagSplitCharByChar() { + var out = run("abcd".chars() + .mapToObj(c -> String.valueOf((char) c)) + .toList()); + assertEquals("cd", out[0]); + assertEquals("ab", out[1]); + } + + @Test + void extractsMultipleThinkSpans() { + var out = run(List.of("at1bt2c")); + assertEquals("abc", out[0]); + assertEquals("t1t2", out[1]); + } + + @Test + void unterminatedTagRoutesRemainderToThinking() { + var out = run(List.of("beforenever closed ", "still thinking")); + assertEquals("before", out[0]); + assertEquals("never closed still thinking", out[1]); + } + + @Test + void unterminatedTagFlushesPartialCloseTagAsThinking() { + // Stream dies right inside a partial close tag: the held-back "abc")); + assertEquals("a < b and a << b, ", out[0]); + assertEquals("", out[1]); + } + + @Test + void heldBackFalseAlarmPrefixIsReleasedAsContent() { + // " matters")); + assertEquals("size matters", out[0]); + assertEquals("", out[1]); + } + + @Test + void flushReturnsHeldBackTailAsContentInTextMode() { + var out = run(List.of("answer ends with plan outro")); + assertEquals("intro outro", out[0]); + assertEquals("plan", out[1]); + } + + @Test + void disabledExtractorPassesTagsThrough() { + var extractor = new ThinkTagStreamExtractor(); + extractor.disable(); + var out = run(extractor, List.of("not extracted")); + assertEquals("not extracted", out[0]); + assertEquals("", out[1]); + } + + @Test + void disableReleasesHeldBackTailAsContent() { + var extractor = new ThinkTagStreamExtractor(); + var first = extractor.feed("partial stays literal"); + assertEquals(" stays literal", second.content()); + assertEquals("", second.thinking()); + } + + @Test + void emptyAndNullChunksAreNoOps() { + var extractor = new ThinkTagStreamExtractor(); + assertEquals("", extractor.feed("").content()); + assertEquals("", extractor.feed(null).content()); + assertEquals("", extractor.flush().content()); + assertEquals("", extractor.flush().thinking()); + } + + @Test + void conservesEveryNonTagCharacterAcrossRandomSplits() { + String input = "startalphamidbeta gammaend < loose"; + String expectedContent = "startmidend < loose"; + String expectedThinking = "alphabeta gamma"; + // Deterministic sweep over split widths instead of randomness so a + // failure always reproduces. + for (int width = 1; width <= input.length(); width++) { + java.util.ArrayList chunks = new java.util.ArrayList<>(); + for (int i = 0; i < input.length(); i += width) { + chunks.add(input.substring(i, Math.min(i + width, input.length()))); + } + var out = run(chunks); + assertEquals(expectedContent, out[0], "content mismatch at width " + width); + assertEquals(expectedThinking, out[1], "thinking mismatch at width " + width); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java index a4d2bb29..47ff1273 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamAnnounceServiceTest.java @@ -19,7 +19,9 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; /** @@ -142,11 +144,15 @@ class TeamAnnounceServiceTest { verify(streamTracker, timeout(3000)) .broadcastObject(eq(LEAD_CONV), eq("team_announce_reply"), any()); // The announce turn persists, so the lead's reply survives a reload and - // stays in the lead's conversation window for later turns. + // stays in the lead's conversation window for later turns. Both rows + // carry an internal-note metadata type so the chat UI renders them as + // a collapsed system strip instead of a user bubble. verify(conversationService, timeout(3000)) - .saveMessage(eq(LEAD_CONV), eq("user"), anyString()); + .saveMessage(eq(LEAD_CONV), eq("user"), anyString(), isNull(), eq("completed"), + eq(0), eq(0), isNull(), isNull(), contains("\"team_announce\"")); verify(conversationService, timeout(3000)) - .saveMessage(eq(LEAD_CONV), eq("assistant"), eq("综合汇报")); + .saveMessage(eq(LEAD_CONV), eq("assistant"), eq("综合汇报"), isNull(), eq("completed"), + eq(0), eq(0), isNull(), isNull(), contains("\"team_announce_reply\"")); } @Test diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 5a50dc7f..4514aec5 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -51,7 +51,7 @@ the model produced them) so tool boxes never reorder. -->