feat: thinking display overhaul — live think-tag extraction, real durations, default visibility, reconnect and team-note fixes

This commit is contained in:
matevip 2026-08-03 05:56:16 -04:00
parent 679f749959
commit 046080d6aa
22 changed files with 854 additions and 80 deletions

View File

@ -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 <think> tag extraction: models without structured reasoning
// stream their reasoning inside <think>...</think> 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<String> 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 即关闭
// 内联标签提取此类模型不会再用 <think> 包裹思考正文里的
// 字面标签是真实内容
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 先处理形如 "…</think>answer"
// chunk 里思考先于正文出现
if (tagThinking != null && !tagThinking.isEmpty()) {
onThinkingDelta.accept(tagThinking);
}
// 3. content delta已剥离 <think> 内文本
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 {
// ==================== <think> 标签 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) {}
/**

View File

@ -0,0 +1,129 @@
package vip.mate.agent.graph;
/**
* Incremental extractor that routes inline {@code <think>...</think>} 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.
* <p>
* A tag may be split across chunk boundaries ({@code "abc<thi"} +
* {@code "nk>xyz"}). The extractor holds back a chunk tail that is a proper
* prefix of the next expected tag (at most {@code </think>.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 <think>} it is returned as thinking matching the post-stream
* fallback parser's semantics for unterminated tags.
* <p>
* 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 = "<think>";
private static final String CLOSE_TAG = "</think>";
/** 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 <think>} 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;
}
}

View File

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

View File

@ -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;
/**

View File

@ -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) {

View File

@ -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()),

View File

@ -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

View File

@ -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 <think>} 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<String> 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<String> 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("<think>reasoning</think>answer"));
assertEquals("answer", out[0]);
assertEquals("reasoning", out[1]);
}
@Test
void extractsTagSplitAcrossChunks() {
var out = run(List.of("<thi", "nk>step one", " step two</th", "ink>final"));
assertEquals("final", out[0]);
assertEquals("step one step two", out[1]);
}
@Test
void extractsTagSplitCharByChar() {
var out = run("<think>ab</think>cd".chars()
.mapToObj(c -> String.valueOf((char) c))
.toList());
assertEquals("cd", out[0]);
assertEquals("ab", out[1]);
}
@Test
void extractsMultipleThinkSpans() {
var out = run(List.of("a<think>t1</think>b<think>t2</think>c"));
assertEquals("abc", out[0]);
assertEquals("t1t2", out[1]);
}
@Test
void unterminatedTagRoutesRemainderToThinking() {
var out = run(List.of("before<think>never 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 "</thi"
// can no longer complete, so it drains as thinking text.
var out = run(List.of("<think>abc</thi"));
assertEquals("", out[0]);
assertEquals("abc</thi", out[1]);
}
@Test
void literalAngleBracketsAreNotSwallowed() {
var out = run(List.of("a < b and a << b, <thin fabric>"));
assertEquals("a < b and a << b, <thin fabric>", out[0]);
assertEquals("", out[1]);
}
@Test
void heldBackFalseAlarmPrefixIsReleasedAsContent() {
// "<thin" is a plausible tag start at the chunk boundary but the next
// chunk disproves it every character must come back as content.
var out = run(List.of("size <thin", "g> matters"));
assertEquals("size <thing> matters", out[0]);
assertEquals("", out[1]);
}
@Test
void flushReturnsHeldBackTailAsContentInTextMode() {
var out = run(List.of("answer ends with <thi"));
assertEquals("answer ends with <thi", out[0]);
assertEquals("", out[1]);
}
@Test
void contentBeforeAndAfterTagInSameChunk() {
var out = run(List.of("intro <think>plan</think> 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("<think>not extracted</think>"));
assertEquals("<think>not extracted</think>", out[0]);
assertEquals("", out[1]);
}
@Test
void disableReleasesHeldBackTailAsContent() {
var extractor = new ThinkTagStreamExtractor();
var first = extractor.feed("partial <thi");
assertEquals("partial ", first.content());
extractor.disable();
var second = extractor.feed("nk> stays literal");
assertEquals("<think> 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 = "start<think>alpha</think>mid<think>beta gamma</think>end < 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<String> 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);
}
}
}

View File

@ -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

View File

@ -51,7 +51,7 @@
the model produced them) so tool boxes never reorder. -->
<template v-else>
<template v-for="seg in iter.items" :key="seg.id">
<ThinkingSegment v-if="seg.type === 'thinking' && debugMode" :segment="seg" />
<ThinkingSegment v-if="seg.type === 'thinking' && showThinking" :segment="seg" />
<ToolCallSegment v-else-if="seg.type === 'tool_call'" :segment="seg" />
<template v-else-if="seg.type === 'content'">
<button
@ -115,9 +115,11 @@
<div v-if="showExecutionPanel" class="execution-section">
<button class="execution-toggle" type="button" @click="executionExpanded = !executionExpanded">
<span class="execution-toggle__indicator" :class="{ active: isGenerating }">
<el-icon><Tools /></el-icon>
<el-icon v-if="isGenerating && !toolCallsMeta.length" class="spin"><Loading /></el-icon>
<el-icon v-else><Tools /></el-icon>
</span>
<span class="execution-toggle__label">{{ executionPhaseLabel }}</span>
<span class="execution-toggle__count" v-if="phaseElapsed">{{ phaseElapsed }}</span>
<span class="execution-toggle__count" v-if="toolCallsMeta.length">{{ toolCallsMeta.length }} calls</span>
<span class="execution-toggle__arrow" :class="{ expanded: executionExpanded }">
<el-icon><ArrowDown /></el-icon>
@ -713,13 +715,13 @@ const hasContent = computed(() => {
return !!(textPart?.text || props.message.content)
})
// Debug mode gates whether the model's reasoning ("thinking") is surfaced.
// Off (default) keeps the transcript focused on tool activity + the answer,
// directly addressing the "thinking piles up" complaint. Tool-call boxes stay
// visible (they auto-collapse) so the user still sees what the agent did.
const { debugMode } = storeToRefs(useSystemSettingsStore())
// showThinking (default on) gates whether the model's reasoning is rendered.
// The segments auto-collapse when a thinking phase completes, so the final
// answer stays the focal point even with reasoning visible. debugMode remains
// a separate switch for tool-call internals and other diagnostics.
const { showThinking } = storeToRefs(useSystemSettingsStore())
const showThinkingPanel = computed(() => debugMode.value && !!thinkingContent.value)
const showThinkingPanel = computed(() => showThinking.value && !!thinkingContent.value)
//
const thinkingDuration = computed(() => {
@ -729,6 +731,14 @@ const thinkingDuration = computed(() => {
const segs = (props.message as any).segments || []
const thinkSeg = segs.find((s: any) => s.type === 'thinking')
const contentSeg = segs.find((s: any) => s.type === 'content')
// Best signal: the thinking segment's own persisted bounds. Persisted
// metadata serializes longs as strings, so coerce before comparing.
const thinkStart = Number(thinkSeg?.timestamp) || 0
const thinkEnd = Number(thinkSeg?.endTimestamp) || 0
if (thinkStart && thinkEnd && thinkEnd >= thinkStart) {
const sec = Math.max(1, Math.round((thinkEnd - thinkStart) / 1000))
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
}
if (thinkSeg?.timestamp && contentSeg?.timestamp) {
const sec = Math.max(1, Math.round((contentSeg.timestamp - thinkSeg.timestamp) / 1000))
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
@ -1376,21 +1386,61 @@ const planMeta = computed<PlanMeta | undefined>(() => {
return parsedMetadata.value?.plan
})
const PHASE_NAME_KEYS: Record<string, string> = {
reasoning: 'chat.phaseNames.reasoning',
action: 'chat.phaseNames.action',
planning: 'chat.phaseNames.planning',
summarizing: 'chat.phaseNames.summarizing',
awaiting_approval: 'chat.phaseNames.awaitingApproval',
executing: 'chat.phaseNames.executing',
replaying: 'chat.phaseNames.replaying',
resumed_execution: 'chat.phaseNames.resumed',
}
const currentPhaseName = computed(() => {
const phase = parsedMetadata.value?.currentPhase
switch (phase) {
case 'reasoning': return 'Reasoning'
case 'action': return 'Executing tools'
case 'planning': return 'Planning'
case 'summarizing': return 'Summarizing'
case 'awaiting_approval': return 'Waiting for approval'
case 'executing': return 'Executing'
case 'replaying': return 'Resuming execution'
case 'resumed_execution': return 'Resumed'
default: return 'Processing'
return t(PHASE_NAME_KEYS[phase as string] || 'chat.phaseNames.processing')
})
// Live elapsed indicator for the phase-only window (LLM prefill / long
// reasoning before any tool call or content lands). A frozen "Reasoning"
// label with zero movement reads as a hang; a ticking clock + spinner shows
// the turn is alive. Resets whenever the backend reports a phase change.
const phaseSince = ref(Date.now())
const phaseNow = ref(Date.now())
let phaseTimer: ReturnType<typeof setInterval> | null = null
watch(() => parsedMetadata.value?.currentPhase, (p, old) => {
if (p !== old) {
phaseSince.value = Date.now()
phaseNow.value = Date.now()
}
})
watch(isGenerating, (gen) => {
if (gen) {
if (phaseTimer == null) phaseTimer = setInterval(() => { phaseNow.value = Date.now() }, 1000)
} else if (phaseTimer != null) {
clearInterval(phaseTimer)
phaseTimer = null
}
}, { immediate: true })
onBeforeUnmount(() => {
if (phaseTimer != null) {
clearInterval(phaseTimer)
phaseTimer = null
}
})
const phaseElapsed = computed(() => {
// Only meaningful while waiting on the model with nothing else to show.
if (!isGenerating.value || toolCallsMeta.value.length) return ''
const sec = Math.floor((phaseNow.value - phaseSince.value) / 1000)
if (sec < 3) return ''
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
})
const truncateArgs = (args: string) => {
if (!args) return ''
const clean = args.replace(/\s+/g, ' ').trim()

View File

@ -63,6 +63,10 @@
<span class="cron-divider__label">{{ msg.content }}</span>
<div class="cron-divider__line"></div>
</div>
<!-- Team task settlement note user-role for the context pipeline,
but rendered as a collapsed system strip instead of a user
bubble so orchestration bookkeeping doesn't flood the chat. -->
<TeamAnnouncePanel v-else-if="isTeamAnnounce(msg)" :message="msg" />
<!-- 普通消息气泡 -->
<MessageBubble
v-else
@ -120,6 +124,7 @@ import { ArrowDown, ChatDotRound, DataLine, EditPen, Monitor, Right } from '@ele
const { t } = useI18n()
import MessageBubble from './MessageBubble.vue'
import CompressionSummary from './CompressionSummary.vue'
import TeamAnnouncePanel from './TeamAnnouncePanel.vue'
import { useStickToBottom } from '@/composables/chat/useStickToBottom'
import type { Message } from '@/types'
@ -187,6 +192,17 @@ const isCronHeader = (msg: Message) => {
return msg.role === 'system' && typeof msg.content === 'string' && msg.content.startsWith('📋 ')
}
// Team task settlement note. New rows carry metadata.type = 'team_announce';
// the content-prefix fallback catches rows persisted before that marker existed.
const isTeamAnnounce = (msg: Message) => {
if (msg.role !== 'user') return false
try {
const metadata = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata
if (metadata?.type === 'team_announce') return true
} catch { /* fall through to prefix check */ }
return typeof msg.content === 'string' && msg.content.startsWith('[System Message] ')
}
//
const { scrollRef, contentRef, isAtBottom, escapedFromLock, scrollToBottom, resetLock } = useStickToBottom({
enabled: props.autoScroll,

View File

@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown, UserFilled } from '@element-plus/icons-vue'
import type { Message } from '@/types'
const { t } = useI18n()
const props = defineProps<{
message: Message
}>()
// Collapsed by default: the settlement note is orchestration bookkeeping, not
// conversation content. Users who want the per-task detail expand on demand.
const expanded = ref(false)
const meta = computed<Record<string, any>>(() => {
const raw = (props.message as any).metadata
if (!raw) return {}
if (typeof raw === 'object') return raw
try { return JSON.parse(raw) || {} } catch { return {} }
})
const label = computed(() => {
const count = Number(meta.value.taskCount) || 0
return count > 0
? t('chat.teamAnnounce', { count })
: t('chat.teamAnnounceGeneric')
})
// Show the per-task result blocks but trim the trailing model-facing
// orchestration instructions ("Review these results ... team_tasks(...)")
// they stay in the stored content for the LLM turn, only display drops them.
const displayText = computed(() => {
const content = props.message.content || ''
const cut = content.indexOf('\n\nReview these results')
return (cut > 0 ? content.slice(0, cut) : content).trim()
})
</script>
<template>
<div class="team-announce">
<button class="team-announce__header" type="button" @click="expanded = !expanded">
<span class="team-announce__icon"><el-icon :size="13"><UserFilled /></el-icon></span>
<span class="team-announce__label">{{ label }}</span>
<el-icon class="team-announce__arrow" :class="{ 'is-open': expanded }" :size="12"><ArrowDown /></el-icon>
</button>
<Transition name="team-announce-slide">
<div v-if="expanded" class="team-announce__body">{{ displayText }}</div>
</Transition>
</div>
</template>
<style scoped>
.team-announce {
margin: 8px 0;
border-radius: 8px;
border: 1px dashed var(--mc-border, rgba(0, 0, 0, 0.12));
background: transparent;
overflow: hidden;
}
.team-announce__header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
font-size: 12px;
color: var(--mc-text-tertiary);
background: none;
border: none;
cursor: pointer;
user-select: none;
text-align: left;
}
.team-announce__header:hover {
color: var(--mc-text-secondary);
}
.team-announce__icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.team-announce__label {
flex: 1;
font-weight: 500;
}
.team-announce__arrow {
transition: transform 0.2s;
}
.team-announce__arrow.is-open {
transform: rotate(180deg);
}
.team-announce__body {
margin: 0 12px 8px;
padding: 6px 0 6px 10px;
border-left: 2px solid var(--mc-border, rgba(0, 0, 0, 0.12));
font-size: 12px;
line-height: 1.7;
color: var(--mc-text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 260px;
overflow-y: auto;
overscroll-behavior: contain;
}
.team-announce-slide-enter-active, .team-announce-slide-leave-active {
transition: all 0.2s ease;
}
.team-announce-slide-enter-from, .team-announce-slide-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { Opportunity, ArrowDown } from '@element-plus/icons-vue'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
@ -21,15 +21,84 @@ const { renderMarkdown } = useMarkdownRenderer()
const renderedThinking = computed(() => renderMarkdown(props.segment.thinkingText || ''))
const isRunning = computed(() => props.segment.status === 'running')
// Live thinking stopwatch. While running it ticks every second off the
// segment's start timestamp; the moment the segment completes we freeze the
// clock locally (frozenEnd) the stream path never writes endTimestamp, so
// the freeze IS the end signal. History replays start out completed and use
// the backend-persisted timestamp/endTimestamp pair instead. No estimation
// fallback: without real bounds we show no duration at all.
const now = ref(Date.now())
const frozenEnd = ref<number | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
function stopTimer() {
if (timer != null) {
clearInterval(timer)
timer = null
}
}
watch(isRunning, (running, wasRunning) => {
if (running) {
frozenEnd.value = null
now.value = Date.now()
if (timer == null) timer = setInterval(() => { now.value = Date.now() }, 1000)
} else {
stopTimer()
// Freeze only on a live runningcompleted transition. A segment that
// mounts already completed (history replay) must not fake an end time
// its duration comes from persisted endTimestamp or not at all.
if (wasRunning === true && props.segment.timestamp && !props.segment.endTimestamp) {
frozenEnd.value = Date.now()
}
}
}, { immediate: true })
watch(() => props.segment.status, (val) => {
if (val === 'completed') expanded.value = false
})
onUnmounted(stopTimer)
const durationText = computed(() => {
// Persisted metadata serializes longs as strings; live segments carry
// numbers. Coerce both (epoch millis are safely below 2^53).
const start = Number(props.segment.timestamp) || 0
if (!start) return ''
const end = isRunning.value
? now.value
: (Number(props.segment.endTimestamp) || frozenEnd.value || 0)
if (!end || end < start) return ''
const sec = Math.max(1, Math.round((end - start) / 1000))
return sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`
})
// Header label: while running the label + a live ticking duration; once
// completed the duration folds into the label ("Thought for 12s"). The char
// count only shows when no real duration is available (old history).
const headerLabel = computed(() => {
if (isRunning.value) return t('chat.thinkingInProgress')
return durationText.value
? t('chat.thinkingDoneFor', { duration: durationText.value })
: t('chat.thinking')
})
const lengthHint = computed(() => {
if (durationText.value) return ''
const len = props.segment.thinkingText?.length || 0
if (len < 100) return ''
return len < 1000 ? `${len} chars` : `${(len / 1000).toFixed(1)}k chars`
})
// While streaming with the body expanded, keep the newest thinking line in
// view the body is height-capped so without this the visible text freezes
// at the top while new deltas pile up below the fold.
const bodyEl = ref<HTMLElement | null>(null)
watch(() => props.segment.thinkingText, async () => {
if (!isRunning.value || !expanded.value) return
await nextTick()
if (bodyEl.value) bodyEl.value.scrollTop = bodyEl.value.scrollHeight
})
</script>
<template>
@ -38,12 +107,13 @@ const lengthHint = computed(() => {
<span class="seg-thinking__icon">
<el-icon :class="{ 'is-loading': isRunning }" :size="14"><Opportunity /></el-icon>
</span>
<span class="seg-thinking__label">{{ isRunning ? t('chat.thinkingInProgress') : t('chat.thinking') }}</span>
<span class="seg-thinking__label">{{ headerLabel }}</span>
<span v-if="isRunning && durationText" class="seg-thinking__duration">{{ durationText }}</span>
<span v-if="lengthHint" class="seg-thinking__hint">{{ lengthHint }}</span>
<el-icon class="seg-thinking__arrow" :class="{ 'is-open': expanded }" :size="12"><ArrowDown /></el-icon>
</div>
<Transition name="seg-slide">
<div v-if="expanded" class="seg-thinking__body markdown-body" v-html="renderedThinking"></div>
<div v-if="expanded" ref="bodyEl" class="seg-thinking__body markdown-body" v-html="renderedThinking"></div>
</Transition>
</div>
</template>
@ -88,6 +158,11 @@ const lengthHint = computed(() => {
font-weight: 500;
flex: 1;
}
.seg-thinking__duration {
font-size: 11px;
color: var(--mc-text-tertiary);
font-variant-numeric: tabular-nums;
}
.seg-thinking__hint {
font-size: 11px;
color: var(--mc-text-tertiary);
@ -100,10 +175,17 @@ const lengthHint = computed(() => {
transform: rotate(180deg);
}
.seg-thinking__body {
padding: 0 12px 10px;
font-size: 13px;
line-height: 1.6;
font-size: 12px;
line-height: 1.7;
color: var(--mc-thinking-text);
opacity: 0.88;
max-height: 260px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
border-left: 2px solid var(--mc-thinking-border);
margin: 0 12px 10px;
padding: 2px 0 2px 10px;
}
.seg-slide-enter-active, .seg-slide-leave-active {

View File

@ -731,6 +731,17 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (remote.supersededReason !== undefined) {
next.supersededReason = remote.supersededReason
}
// Server wall-clock bounds are authoritative for durations
// ("thought for Ns"). Local segments often miss endTimestamp:
// round-boundary closes flip status without stamping an end,
// and a re-grouped segment list remounts the component so its
// local freeze is lost. Fill whichever side is missing.
if (next.timestamp == null && remote.timestamp != null) {
next.timestamp = remote.timestamp
}
if (next.endTimestamp == null && remote.endTimestamp != null) {
next.endTimestamp = remote.endTimestamp
}
return next
})
;(msg as any).metadata = { ...(metadata || {}), segments: merged }
@ -2192,14 +2203,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
try {
// reconnectStream always rebuilds from an EMPTY placeholder (above), so it
// needs the server to replay the WHOLE buffer — not just events newer than
// a previously-acked lastEventId. Clearing it forces connect() to omit
// lastEventId so the backend full-replays and the placeholder repaints.
// Without this, a reconnect into the same conversation (poll-detected
// running stream after a switch-away, window refocus) dedup-skips the
// buffer and the bubble stays blank until a hard refresh resets this ref —
// the "switch conversations mid-stream → blank, refresh fixes it" bug.
// Setting null (not a foreign id) right before connect can't leak or race.
stream.lastEventId.value = null
// a previously-acked lastEventId — AND the client to accept that replay.
// resetDedup() clears both halves atomically: lastEventId (so connect()
// omits it and the backend full-replays) and seenEventIds (so emit()
// doesn't silently drop the replayed ids it already saw before the
// switch-away). Clearing only lastEventId reintroduces the "switch
// conversations mid-stream → blank bubble, refresh fixes it" bug: the
// server replays everything and the client discards everything.
stream.resetDedup()
await stream.connect({
conversationId,
reconnect: true,

View File

@ -127,6 +127,12 @@ export interface UseStreamReturn {
disconnect: () => void
/** 中止请求 */
abort: () => void
/**
* Atomically clear seen-event-id dedup and the Last-Event-ID echo. Call
* before a reconnect that rebuilds from an empty placeholder and needs the
* server's full buffer replay accepted.
*/
resetDedup: () => void
/** 注册事件处理器 */
on: (event: SSEEventType, handler: (data: any) => void) => () => void
/** 注册所有事件处理器 */
@ -471,6 +477,19 @@ export function useStream(options: UseStreamOptions): UseStreamReturn {
}
}
/**
* Atomically clear ALL dedup state (seen event ids + Last-Event-ID echo).
* Call before a reconnect that rebuilds the transcript from an empty
* placeholder and therefore wants the server's FULL buffer replay.
* The two halves must reset together: clearing only lastEventId makes the
* server replay everything while seenEventIds silently drops everything
* the "blank transcript after switching into a running conversation" bug.
*/
const resetDedup = () => {
seenEventIds.clear()
lastEventId.value = null
}
// 断开连接
const disconnect = () => {
if (streamTimeoutTimer) {
@ -517,6 +536,7 @@ export function useStream(options: UseStreamOptions): UseStreamReturn {
connect,
disconnect,
abort,
resetDedup,
on,
onEvent,
}

View File

@ -180,6 +180,20 @@ export default {
},
thinking: 'Thinking',
thinkingInProgress: 'Thinking...',
thinkingDoneFor: 'Thought for {duration}',
teamAnnounce: '{count} team task(s) settled',
teamAnnounceGeneric: 'Team tasks settled',
phaseNames: {
reasoning: 'Reasoning',
action: 'Executing tools',
planning: 'Planning',
summarizing: 'Summarizing',
awaitingApproval: 'Waiting for approval',
executing: 'Executing',
replaying: 'Resuming execution',
resumed: 'Resumed',
processing: 'Processing',
},
stopped: 'Generation stopped',
interrupted: 'Interrupted',
subagentStalled: 'Subagent stalled — no progress',
@ -1145,6 +1159,7 @@ export default {
language: 'Language',
streamEnabled: 'Stream Response',
debugMode: 'Debug Mode',
showThinking: 'Show Thinking Process',
workspaceStorageRoot: 'Default Workspace Storage Path',
searchEnabled: 'Enable Search',
searchProvider: 'Search Provider',
@ -1197,6 +1212,7 @@ export default {
language: 'Interface language preference stored in backend settings.',
streamEnabled: 'Controls whether chat prefers streaming output in UI settings.',
debugMode: 'Reserved for showing more execution details later.',
showThinking: 'Render the model\'s reasoning in chat (collapsible). The "Deep Thinking" toggle in the chat input controls whether the model thinks; this only controls whether it is displayed.',
workspaceStorageRoot: 'Files of new conversations and workspaces are stored under this path (the global fallback directory). Takes effect immediately and never migrates existing data; leave blank to use the server default. Must be an absolute path.',
workspaceStorageRootPlaceholder: 'Leave blank for the server default, e.g. /data/mateclaw/workspace',
searchEnabled: 'When disabled, the search tool will be unavailable to agents.',

View File

@ -180,6 +180,20 @@ export default {
},
thinking: '深度思考',
thinkingInProgress: '思考中...',
thinkingDoneFor: '已深度思考(用时 {duration}',
teamAnnounce: '{count} 个团队任务已结算',
teamAnnounceGeneric: '团队任务已结算',
phaseNames: {
reasoning: '推理中',
action: '执行工具',
planning: '规划中',
summarizing: '总结中',
awaitingApproval: '等待审批',
executing: '执行中',
replaying: '恢复执行',
resumed: '已恢复',
processing: '处理中',
},
stopped: '已停止生成',
interrupted: '已中断',
subagentStalled: '子 Agent 无进展',
@ -1007,6 +1021,7 @@ export default {
language: '界面语言',
streamEnabled: '流式响应',
debugMode: '调试模式',
showThinking: '显示思考过程',
workspaceStorageRoot: '默认工作空间存储路径',
searchEnabled: '启用搜索',
searchProvider: '搜索提供商',
@ -1065,6 +1080,7 @@ export default {
language: '界面语言会持久化到后端设置中。',
streamEnabled: '用于控制前端默认流式响应偏好。',
debugMode: '预留给后续执行明细展示。',
showThinking: '在聊天中展示模型的思考过程(可随时折叠)。聊天输入框的"深度思考"开关决定模型是否思考,本开关只决定界面是否展示。',
workspaceStorageRoot: '新建会话、工作空间的文件将存放在该路径下(作为全局兜底目录)。修改后立即生效,不影响已有数据;留空则使用服务端默认位置。必须为绝对路径。',
workspaceStorageRootPlaceholder: '留空使用服务端默认位置,例如 /data/mateclaw/workspace',
searchEnabled: '关闭后搜索工具将不可用Agent 无法联网搜索。',

View File

@ -18,6 +18,7 @@ const STORAGE_KEY = 'mateclaw-system-settings'
interface CachedSettings {
streamEnabled: boolean
debugMode: boolean
showThinking: boolean
}
function readCache(): CachedSettings {
@ -28,10 +29,11 @@ function readCache(): CachedSettings {
return {
streamEnabled: parsed.streamEnabled !== false, // default true
debugMode: parsed.debugMode === true, // default false
showThinking: parsed.showThinking !== false, // default true
}
}
} catch { /* ignore */ }
return { streamEnabled: true, debugMode: false }
return { streamEnabled: true, debugMode: false, showThinking: true }
}
export const useSystemSettingsStore = defineStore('systemSettings', () => {
@ -39,15 +41,18 @@ export const useSystemSettingsStore = defineStore('systemSettings', () => {
// Whether the chat UI renders tokens incrementally (true) or buffers the
// turn and reveals it once on completion (false).
const streamEnabled = ref<boolean>(cached.streamEnabled)
// Whether thinking blocks and tool-call internals are shown. Off = only the
// final answer plus collapsed summaries (keeps the transcript clean).
// Whether tool-call internals and other diagnostics are shown.
const debugMode = ref<boolean>(cached.debugMode)
// Whether the model's reasoning ("thinking") blocks are rendered in chat.
// Independent from debugMode: this is a user preference, not a debug aid.
const showThinking = ref<boolean>(cached.showThinking)
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
streamEnabled: streamEnabled.value,
debugMode: debugMode.value,
showThinking: showThinking.value,
}))
} catch { /* ignore */ }
}
@ -57,6 +62,7 @@ export const useSystemSettingsStore = defineStore('systemSettings', () => {
if (!settings) return
if (typeof settings.streamEnabled === 'boolean') streamEnabled.value = settings.streamEnabled
if (typeof settings.debugMode === 'boolean') debugMode.value = settings.debugMode
if (typeof settings.showThinking === 'boolean') showThinking.value = settings.showThinking
persist()
}
@ -68,7 +74,7 @@ export const useSystemSettingsStore = defineStore('systemSettings', () => {
} catch { /* keep cached defaults */ }
}
return { streamEnabled, debugMode, apply, load }
return { streamEnabled, debugMode, showThinking, apply, load }
})
if (import.meta.hot) {

View File

@ -250,6 +250,9 @@ export interface MessageSegment {
delegationAsync?: boolean
/** 时间戳 */
timestamp?: number
/** Wall-clock end of the segment (set when status flips to completed); with
* timestamp it yields the real duration for history replays. */
endTimestamp?: number
/**
* Iteration index this segment belongs to (0-based). Set by iteration_start
* lets MessageBubble group thinking/tool/content segments per iteration so
@ -284,6 +287,10 @@ export interface GeneratedFile {
}
export interface MessageMetadata {
/** Internal note discriminator, e.g. 'compression_summary' | 'team_announce' | 'team_announce_reply' */
type?: string
/** type=team_announce: number of settled team tasks carried by this note */
taskCount?: number
currentPhase?: string
toolCalls?: ToolCallMeta[]
plan?: PlanMeta
@ -812,6 +819,8 @@ export interface SystemSettings {
language: 'zh-CN' | 'en-US'
streamEnabled: boolean
debugMode: boolean
// Whether chat renders the model's reasoning ("thinking") blocks; default true
showThinking: boolean
// Default workspace storage root; '' = use the server-side default
workspaceStorageRoot?: string
// 搜索服务配置

View File

@ -199,9 +199,17 @@ export function reconcileMessages(local: Message[], fetched: Message[]): Message
// 收集未被 id 匹配过的本地 assistant通常是流式产生的 client-uuid placeholder
// 供 fetched 端新 assistant"认领"它们的 timeline避免两条并排。
const unclaimedLocalAssistants: Message[] = []
// Optimistic user bubbles carry a client temp id; the DB copy comes back
// with a Snowflake id, so id-matching never pairs them. Without claiming,
// the temp copy survives to the tail-preserve loop below and the question
// renders twice (once from DB, once appended after the answer).
const unclaimedLocalUsers: Message[] = []
for (const lm of local) {
if (lm.role === 'assistant' && isClientId(lm.id)) {
if (!isClientId(lm.id)) continue
if (lm.role === 'assistant') {
unclaimedLocalAssistants.push(lm)
} else if (lm.role === 'user') {
unclaimedLocalUsers.push(lm)
}
}
@ -228,6 +236,17 @@ export function reconcileMessages(local: Message[], fetched: Message[]): Message
const claimed = unclaimedLocalAssistants.shift()!
matchedLocalIds.add(String(claimed.id))
result.push(mergeAssistantMessages(claimed, fm))
} else if (fm.role === 'user') {
// 认领内容相同的本地乐观 user 消息(按内容匹配,重复文本时先到先领),
// 使其不会落入尾部保留循环造成问题气泡重复。
const idx = unclaimedLocalUsers.findIndex(
u => (u.content || '') === (fm.content || '')
)
if (idx >= 0) {
matchedLocalIds.add(String(unclaimedLocalUsers[idx].id))
unclaimedLocalUsers.splice(idx, 1)
}
result.push(fm)
} else {
result.push(fm)
}

View File

@ -1175,6 +1175,9 @@ async function pollActivity() {
// 2. content_delta assistant
await refreshCurrentConversationMessages(cid)
if (currentConversationId.value !== cid || isGenerating.value) return
// Match selectConversation's behavior: a stale scroll-escape from an
// earlier upward scroll must not suppress follow-scroll on reconnect.
messageListRef.value?.resetScrollLock()
await reconnectStream(cid)
} else if (!hasLocalOnlyFailedTail()) {
// DB user / assistant

View File

@ -45,6 +45,19 @@
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.showThinking') }}</div>
<div class="setting-hint">{{ t('settings.hints.showThinking') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.showThinking" type="checkbox" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.workspaceStorageRoot') }}</div>
@ -345,6 +358,7 @@ const settings = reactive<SystemSettings>({
language: 'zh-CN',
streamEnabled: true,
debugMode: false,
showThinking: true,
workspaceStorageRoot: '',
searchEnabled: true,
searchProvider: 'serper',