mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat: thinking display overhaul — live think-tag extraction, real durations, default visibility, reconnect and team-note fixes
This commit is contained in:
parent
679f749959
commit
046080d6aa
@ -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) {}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
115
mateclaw-ui/src/components/chat/TeamAnnouncePanel.vue
Normal file
115
mateclaw-ui/src/components/chat/TeamAnnouncePanel.vue
Normal 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>
|
||||
@ -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 running→completed 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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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 无法联网搜索。',
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
// 搜索服务配置
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 会合并进来)。
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user