release: v1.3.0 (hotfix bundle — #120 + UI/build fixes)

This commit is contained in:
matevip 2026-05-14 09:30:43 +08:00
parent da8005a8cb
commit 493910bf5a
21 changed files with 1789 additions and 146 deletions

6
.gitignore vendored
View File

@ -29,6 +29,12 @@ nbbuild/
nbdist/
.nb-gradle/
### frontend build artifacts ###
# Vite's primary output goes to mateclaw-server/.../static; the only thing
# that lands here is rollup-plugin-visualizer's stats.html when running
# ANALYZE=1 pnpm build.
mateclaw-ui/dist/
### maven ###
target/
*.war

View File

@ -18,9 +18,17 @@ RUN pnpm install --frozen-lockfile
COPY mateclaw-ui/ ./
# Override outDir: vite.config.ts writes to ../mateclaw-server/…/static which
# is outside this container; call vite directly to control --outDir.
# NODE_OPTIONS=--max-old-space-size=6144 keeps Rollup's `rendering chunks`
# phase from getting SIGKILL'd by the host kernel's OOM-killer on memory-
# constrained servers. The earlier removal of this flag relied on lazy-
# loading + manualChunks dropping the per-chunk peak, but Rollup still
# minifies several vendor chunks (monaco / mermaid / echarts) in parallel
# so the cumulative working set blows past Node's default ~1.5 GB heap
# and trips the OOM-killer mid-build. The fix is not the heap flag
# itself; it is keeping the build reproducible on smaller hosts.
# Skipping vue-tsc here is intentional — type errors are caught in CI, not in
# the production Docker image build.
RUN pnpm exec vite build --outDir /static --emptyOutDir
RUN NODE_OPTIONS=--max-old-space-size=6144 pnpm exec vite build --outDir /static --emptyOutDir
# Stage 2 — Backend (Maven)
FROM maven:3.9-eclipse-temurin-21 AS builder

View File

@ -484,24 +484,55 @@ public class AgentService {
// ==================== StreamDelta ====================
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData, boolean persistenceOnly) {
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData,
boolean persistenceOnly, boolean segmentOnly) {
// 兼容构造器(广播+持久化)
public StreamDelta(String content, String thinking) {
this(content, thinking, null, null, false);
this(content, thinking, null, null, false, false);
}
// 显式 5-参构造器:保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容
public StreamDelta(String content, String thinking, String eventType,
Map<String, Object> eventData, boolean persistenceOnly) {
this(content, thinking, eventType, eventData, persistenceOnly, false);
}
/** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */
public static StreamDelta persistOnly(String content, String thinking) {
return new StreamDelta(content, thinking, null, null, true);
return new StreamDelta(content, thinking, null, null, true, false);
}
/**
* Per-iteration narrative routing for ReasoningNode / SummarizingNode output.
*
* <p>The accumulator should:
* <ul>
* <li>append the text to the in-flight {@code segments} entry so the UI's
* segmented view still renders the intermediate "I'll look it up…"
* narration between tool cards;</li>
* <li>NOT broadcast — already broadcast live by NodeStreamingChatHelper;</li>
* <li>NOT append to the top-level {@code content} StringBuilder, which is
* what gets persisted as {@code mate_message.content}. That field
* should hold the final-answer span only — otherwise multiple
* iterations stack into "我来…让我…然后…" walls that next-turn replay
* sees as unanswered chain-of-thought (issue #120 narration leg).</li>
* </ul>
*
* <p>Implies {@code persistenceOnly} (no broadcast) at the accumulator
* layer, but is a stricter promise: <em>nothing</em> reaches the top-level
* persisted content field via this flavor.
*/
public static StreamDelta segmentOnly(String content, String thinking) {
return new StreamDelta(content, thinking, null, null, true, true);
}
public static StreamDelta empty() {
return new StreamDelta(null, null, null, null, false);
return new StreamDelta(null, null, null, null, false, false);
}
public static StreamDelta event(String type, Map<String, Object> data) {
return new StreamDelta(null, null, type, data, false);
return new StreamDelta(null, null, type, data, false, false);
}
public boolean isEvent() {

View File

@ -1,4 +1,6 @@
package vip.mate.agent;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
@ -27,6 +29,7 @@ import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
@ -292,10 +295,7 @@ public abstract class BaseAgent {
List<Message> messages = new ArrayList<>(limit);
for (int i = 0; i < limit; i += 1) {
Message springMessage = sanitizeForLlm(history.get(i));
if (springMessage != null) {
messages.add(springMessage);
}
messages.addAll(expandToSpringMessages(history.get(i)));
}
// Tail guard — orphan-user strip (issue #47).
@ -526,6 +526,193 @@ public abstract class BaseAgent {
return toSpringMessage(entity);
}
/**
* Replay a persisted message as 1..N Spring AI {@link Message}s.
*
* <p>Plain user/system/assistant rows expand to a single message via
* {@link #sanitizeForLlm}. Assistant rows that issued tool calls during
* the original turn expand to TWO messages:
* <ol>
* <li>{@link AssistantMessage} carrying the persisted narration <em>and</em>
* the {@link AssistantMessage.ToolCall} list reconstructed from
* {@code metadata.toolCalls}.</li>
* <li>A single {@link ToolResponseMessage} bundling one
* {@link ToolResponseMessage.ToolResponse} per completed tool call,
* with the persisted {@code result} string as content.</li>
* </ol>
*
* <p>Without this expansion the next turn would see a bare assistant text
* row containing chain-of-thought like "Let me try the browser..." but no
* tool calls and no observations. The LLM concludes the action wasn't
* actually performed and retries the same tool, looping until the iteration
* cap. Issuing the structured tool_call + tool_response pair lets the model
* see what already ran and reason from the result instead.
*
* <p>Only completed tool calls (status=completed AND result present) are
* replayed. Calls left in awaiting_approval or running state are dropped
* — replaying them without a paired response would produce a sequence the
* provider rejects (every tool_call_id must have a matching tool_response).
*
* <p>RFC-052 direct-tool rows take the existing scrub path unchanged; the
* placeholder text already names the originating tool and instructs the
* model to re-call if needed, which is the correct multi-turn signal for
* returnDirect tools.
*/
List<Message> expandToSpringMessages(MessageEntity entity) {
if (entity == null) return List.of();
// Stages 0/1/1.5 in sanitizeForLlm drop cron-header system rows,
// approval-placeholder assistants, and error-status assistants. Those
// rows must NOT replay structured tool exchanges even if metadata still
// carries them — the underlying turn is broken or synthetic. Centralize
// that decision so we can apply it before the empty-content check.
if (shouldFullyFilter(entity)) return List.of();
// Non-assistant rows expand to exactly one message via the existing
// conversion path (which also handles user-message media injection).
if (!"assistant".equals(entity.getRole())) {
Message m = toSpringMessage(entity);
return m == null ? List.of() : List.of(m);
}
// RFC-052 direct-tool rows: keep the existing placeholder-only path.
// The placeholder names the originating tool and tells the model to
// re-call it; resurrecting the original tool_call/tool_response pair
// would leak the very content RFC-052 elides.
if (!directToolNamesIn(entity).isEmpty()) {
Message m = toSpringMessage(entity);
return m == null ? List.of() : List.of(m);
}
String renderedContent = conversationService.renderMessageContent(entity);
List<PersistedToolCall> persisted = extractCompletedToolCalls(entity);
// No tool calls to replay → fall back to the legacy single-message
// path, which preserves the "drop on blank content" behavior for
// narration-only assistants.
if (persisted.isEmpty()) {
Message m = toSpringMessage(entity);
return m == null ? List.of() : List.of(m);
}
// Have completed tool calls → emit the structured pair regardless of
// whether the rendered content is blank. A pure tool-call turn (LLM
// returned tool_calls with no preamble text) is the canonical case
// here: previously the row was dropped entirely because
// renderMessageContent collapsed to "" once the tool_call part was
// skipped, leaving the next turn with no record that the tools ran.
return buildToolExchange(entity, renderedContent == null ? "" : renderedContent, persisted);
}
/**
* True when the persisted row must be dropped before any history reaches
* the LLM, irrespective of its tool-call payload. Mirrors stages 0/1/1.5
* of {@link #sanitizeForLlm}: cron-run header system rows, approval
* placeholders, and error-status / "[错误] " assistants. Replaying tool
* exchanges from these would resurface UI scaffolding or self-replicate
* provider failures.
*/
static boolean shouldFullyFilter(MessageEntity entity) {
if (entity == null) return true;
String role = entity.getRole();
if ("system".equals(role)
&& entity.getContent() != null
&& entity.getContent().startsWith("📋 ")) return true;
if ("assistant".equals(role)
&& isApprovalPlaceholder(entity.getContent())) return true;
if ("assistant".equals(role)
&& ("error".equals(entity.getStatus())
|| (entity.getContent() != null
&& entity.getContent().startsWith("[错误] ")))) return true;
return false;
}
/**
* Build the structured {@code [AssistantMessage(toolCalls), ToolResponseMessage]}
* pair from a persisted row. Pure: depends only on its arguments, so it
* can be exercised directly from unit tests without a BaseAgent fixture.
*
* <p>Both sides reuse the same id for each call so the in-prompt sequence
* validates with every provider's tool_call_id pairing rule. For legacy
* rows persisted before {@code toolCallId} was captured, synthesize a
* stable id from {@code entity.id + index}. The id never escapes this
* prompt; synthetic and real ids cannot collide downstream.
*/
static List<Message> buildToolExchange(MessageEntity entity, String content,
List<PersistedToolCall> persisted) {
List<AssistantMessage.ToolCall> toolCalls = new ArrayList<>(persisted.size());
List<ToolResponseMessage.ToolResponse> responses = new ArrayList<>(persisted.size());
for (int i = 0; i < persisted.size(); i++) {
PersistedToolCall p = persisted.get(i);
String id = (p.toolCallId() == null || p.toolCallId().isEmpty())
? "legacy-" + entity.getId() + "-" + i
: p.toolCallId();
toolCalls.add(new AssistantMessage.ToolCall(id, "function", p.name(), p.arguments()));
responses.add(new ToolResponseMessage.ToolResponse(id, p.name(), p.result()));
}
AssistantMessage rebuilt = AssistantMessage.builder()
.content(content == null ? "" : content)
.toolCalls(toolCalls)
.build();
ToolResponseMessage toolResponses = ToolResponseMessage.builder()
.responses(responses)
.build();
return List.of(rebuilt, toolResponses);
}
/**
* Parse {@code metadata.toolCalls} into a list of completed entries that
* are safe to replay. Returns empty if metadata is missing, malformed, or
* carries no entry with both {@code status='completed'} and a non-null
* {@code result}.
*
* <p>Handles H2's JSON-column double-wrap (the column read can produce a
* JSON-encoded string of JSON) the same way
* {@code ConversationService#reconcileResolvedMessages} does.
*/
static List<PersistedToolCall> extractCompletedToolCalls(MessageEntity entity) {
if (entity == null) return List.of();
String raw = entity.getMetadata();
if (raw == null || raw.isBlank() || !raw.contains("toolCalls")) return List.of();
try {
String json = raw.trim();
if (json.startsWith("\"") && json.endsWith("\"")) {
json = HISTORY_METADATA_MAPPER.readValue(json, String.class);
}
Map<String, Object> meta = HISTORY_METADATA_MAPPER.readValue(json,
new TypeReference<Map<String, Object>>() {});
Object tc = meta.get("toolCalls");
if (!(tc instanceof List<?> list)) return List.of();
List<PersistedToolCall> result = new ArrayList<>(list.size());
for (Object entry : list) {
if (!(entry instanceof Map<?, ?> raw2)) continue;
@SuppressWarnings("unchecked")
Map<String, Object> call = (Map<String, Object>) raw2;
if (!"completed".equals(String.valueOf(call.get("status")))) continue;
Object resultField = call.get("result");
if (resultField == null) continue;
String name = String.valueOf(call.getOrDefault("name", ""));
if (name.isBlank()) continue;
String args = String.valueOf(call.getOrDefault("arguments", ""));
String toolCallId = String.valueOf(call.getOrDefault("toolCallId", ""));
result.add(new PersistedToolCall(toolCallId, name, args, String.valueOf(resultField)));
}
return result;
} catch (Exception e) {
log.warn("[BaseAgent] Failed to parse metadata.toolCalls for replay (msgId={}): {}",
entity.getId(), e.getMessage());
return List.of();
}
}
private static final ObjectMapper HISTORY_METADATA_MAPPER = new ObjectMapper();
/**
* A completed tool call recovered from {@code mate_message.metadata}, ready
* to be replayed as an {@link AssistantMessage.ToolCall} / matching
* {@link ToolResponseMessage.ToolResponse} pair.
*/
record PersistedToolCall(String toolCallId, String name, String arguments, String result) {}
/**
* 判断消息是否为持久化的压缩摘要。
*/

View File

@ -214,15 +214,33 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
// 与 chatStructuredStream 一致:把每轮 STREAMED_CONTENT 用 persistOnly 推给 Accumulator,
// 否则中间叙述(reasoning narrative + summarize)只在 SSE 上出现一次,刷新后丢失。
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
// SummarizingNode output) into segments only — final-answer
// text arrives via the FINAL_ANSWER branch below. Pre-#120
// this used persistOnly, which appended every iteration's
// narration into the persisted assistant content; next-turn
// replay then saw a chain of "Let me try X..." with no
// observations and looped retrying tools.
//
// Exception — evidence-insufficient terminal turn
// (ReasoningNode.java:617): when an answer is rejected for
// unsupported references, FINAL_ANSWER is replaced with a
// short "[证据不足]" warning and STREAMED_CONTENT carries the
// actual answer body the user/UI need to see. Falling back
// to persistOnly for that case keeps both the original
// answer text and the warning in mate_message.content; with
// pure segmentOnly the persisted content would shrink to
// just the warning, breaking single-segment renderers like
// copy / TTS / history reload (segments.length<=1 disables
// the segmented view in MessageBubble).
boolean isFinalAnswerTurn = hasFinalAnswer(output);
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
lastEmittedStreamedContent.set(streamed);
deltas.add(AgentService.StreamDelta.persistOnly(streamed, null));
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
}
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
deltas.add(contentAlreadyStreamed
@ -347,17 +365,30 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean thinkingAlreadyStreamed = output.state()
.value(THINKING_STREAMED, false);
// 2a. 中间叙述内容持久化:每轮 ReasoningNode(带 tool_calls)和 SummarizingNode
// 都把当轮 LLM 输出写入 STREAMED_CONTENT。NodeStreamingChatHelper 已实时广播
// 给前端,但 Accumulator 不在 SSE 订阅链路上,必须用 persistOnly StreamDelta
// 补一刀,否则刷新后正文文字全部丢失(只剩 final_answer + tool_call 卡片)。
// 2a. Route per-iteration narrative into the segments timeline
// so the segmented UI view still shows "我来…" preludes
// between tool cards, but keep the top-level content
// field (= persisted mate_message.content) reserved for
// the final-answer span. NodeStreamingChatHelper already
// broadcast the live deltas; segmentOnly suppresses
// re-broadcast and skips content.append while still
// populating the segments[] entry.
//
// Exception — evidence-insufficient terminal turn
// (ReasoningNode.java:617): STREAMED_CONTENT carries
// the rejected answer body, FINAL_ANSWER is just the
// short "[证据不足]" warning. Use persistOnly there so
// mate_message.content keeps both the answer text and
// the warning — single-segment renderers (copy / TTS /
// history reload) read content, not segments.
boolean isFinalAnswerTurn = hasFinalAnswer(output);
String streamed = output.state().<String>value(STREAMED_CONTENT).orElse("");
if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) {
lastEmittedStreamedContent.set(streamed);
deltas.add(AgentService.StreamDelta.persistOnly(streamed, null));
deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed));
}
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
deltas.add(contentAlreadyStreamed
@ -509,6 +540,35 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
return inputs;
}
/**
* Pick the right {@link AgentService.StreamDelta} flavor for the per-iteration
* {@code STREAMED_CONTENT} the graph just emitted.
*
* <p>The contract:
* <ul>
* <li>Intermediate ReAct iterations (no {@code FINAL_ANSWER} yet) →
* {@code segmentOnly}. The content is reasoning preamble / mid-loop
* summary that belongs in the segments timeline, not in the persisted
* {@code mate_message.content}.</li>
* <li>Terminal turn where {@code FINAL_ANSWER} is set →
* {@code persistOnly}. This covers the evidence-insufficient path
* (ReasoningNode.java:617) where {@code STREAMED_CONTENT} carries the
* actual rejected answer body and {@code FINAL_ANSWER} is just a short
* "[证据不足]" warning. Persisting the streamed body keeps single-segment
* renderers (copy / TTS / history reload) showing the full text.</li>
* </ul>
*
* <p>Package-private so the unit test can pin the decision without standing
* up a full StateGraph fixture. Returning {@code null} for blank input is the
* caller's responsibility — this helper just decides flavor for non-blank
* content.
*/
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, String streamed) {
return isFinalAnswerTurn
? AgentService.StreamDelta.persistOnly(streamed, null)
: AgentService.StreamDelta.segmentOnly(streamed, null);
}
private boolean hasFinalAnswer(NodeOutput output) {
if (output == null || output.state() == null) {
return false;

View File

@ -1726,7 +1726,15 @@ public class ChatController {
// content_delta
if (delta.content() != null && !delta.content().isBlank()) {
content.append(delta.content());
// segmentOnly deltas route per-iteration narration to the
// segments timeline only — the persisted top-level content
// field stays clean so it carries the final answer span,
// not "我来…让我…" concatenations across iterations (issue
// #120 narration leg). segmentOnly implies persistenceOnly,
// so no broadcast either.
if (!delta.segmentOnly()) {
content.append(delta.content());
}
streamTracker.updatePhase(conversationId, "drafting_answer");
if (!delta.persistenceOnly()) {
broadcastEvent(conversationId, "content_delta", Map.of("delta", delta.content()));
@ -1745,7 +1753,9 @@ public class ChatController {
// thinking_delta
if (delta.thinking() != null && !delta.thinking().isBlank()) {
thinking.append(delta.thinking());
if (!delta.segmentOnly()) {
thinking.append(delta.thinking());
}
if (!delta.persistenceOnly()) {
broadcastEvent(conversationId, "thinking_delta", Map.of("delta", delta.thinking()));
}
@ -1824,6 +1834,11 @@ public class ChatController {
} else if ("tool_call_started".equals(eventType)) {
// toolCalls(兼容)
Map<String, Object> tc = new LinkedHashMap<>();
// toolCallId is required for history replay to pair the persisted
// assistant tool_call with its tool_response — providers reject any
// sequence whose ids don't match. Always record it (empty string
// when the upstream event didn't carry one, e.g. forced tool calls).
tc.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", "")));
tc.put("name", data.getOrDefault("toolName", ""));
tc.put("arguments", data.getOrDefault("arguments", ""));
tc.put("status", "running");
@ -1831,6 +1846,7 @@ public class ChatController {
// segments: 关闭 running thinking/content,插入 tool_call
finalizeRunningSegments("thinking", "content");
var seg = newSegment("tool_call");
seg.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", "")));
seg.put("toolName", data.getOrDefault("toolName", ""));
seg.put("toolArgs", data.getOrDefault("arguments", ""));
segments.add(seg);
@ -1848,10 +1864,17 @@ public class ChatController {
}
} else if ("tool_call_completed".equals(eventType)) {
String toolName = String.valueOf(data.getOrDefault("toolName", ""));
// toolCalls(兼容)
String toolCallId = String.valueOf(data.getOrDefault("toolCallId", ""));
// toolCalls(兼容)— prefer toolCallId match so parallel calls of
// the same tool don't collide on the running+toolName fallback.
for (int i = toolCalls.size() - 1; i >= 0; i--) {
Map<String, Object> tc = toolCalls.get(i);
if ("running".equals(tc.get("status")) && toolName.equals(tc.get("name"))) {
boolean matches = (!toolCallId.isEmpty()
&& toolCallId.equals(String.valueOf(tc.getOrDefault("toolCallId", ""))))
|| (toolCallId.isEmpty()
&& "running".equals(tc.get("status"))
&& toolName.equals(tc.get("name")));
if (matches) {
tc.put("result", data.getOrDefault("result", ""));
tc.put("success", data.getOrDefault("success", true));
tc.put("status", "completed");
@ -1861,8 +1884,13 @@ public class ChatController {
// segments: 标记对应 tool_call 完成
for (int i = segments.size() - 1; i >= 0; i--) {
var seg = segments.get(i);
if ("tool_call".equals(seg.get("type")) && "running".equals(seg.get("status"))
&& toolName.equals(seg.get("toolName"))) {
if (!"tool_call".equals(seg.get("type"))) continue;
boolean matches = (!toolCallId.isEmpty()
&& toolCallId.equals(String.valueOf(seg.getOrDefault("toolCallId", ""))))
|| (toolCallId.isEmpty()
&& "running".equals(seg.get("status"))
&& toolName.equals(seg.get("toolName")));
if (matches) {
seg.put("status", "completed");
seg.put("toolResult", data.getOrDefault("result", ""));
seg.put("toolSuccess", data.getOrDefault("success", true));

View File

@ -0,0 +1,584 @@
package vip.mate.agent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Issue #120 regression: persisted assistant rows that issued tool calls must
* replay as a structured {@code AssistantMessage(toolCalls)} +
* {@link ToolResponseMessage} pair, not as bare text. Without the pair the next
* turn sees chain-of-thought narration ("Let me try the browser…") with no
* matching observations and re-attempts the same tool, looping until the
* iteration cap.
*
* <p>These tests drive {@link BaseAgent#extractCompletedToolCalls(MessageEntity)}
* directly — {@code expandToSpringMessages} is instance-bound (its
* {@code sanitizeForLlm} call uses {@code conversationService.renderMessageContent})
* and exercising it requires a full BaseAgent fixture covered by integration
* tests. The static extractor is the load-bearing parser; if it round-trips
* metadata correctly the structural replay is correct.
*/
class BaseAgentToolCallReplayTest {
@Test
@DisplayName("completed toolCalls round-trip from metadata, preserving id+name+args+result")
void completedToolCalls_extractedInOrder() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setContent("Looked it up via the web.");
msg.setMetadata("{\"toolCalls\":["
+ "{\"toolCallId\":\"call_001\",\"name\":\"search\","
+ "\"arguments\":\"{\\\"q\\\":\\\"spring ai\\\"}\","
+ "\"status\":\"completed\",\"result\":\"No relevant hits.\",\"success\":true},"
+ "{\"toolCallId\":\"call_002\",\"name\":\"browser_use\","
+ "\"arguments\":\"{\\\"url\\\":\\\"https://x\\\"}\","
+ "\"status\":\"completed\",\"result\":\"404\",\"success\":false}"
+ "]}");
List<BaseAgent.PersistedToolCall> calls = BaseAgent.extractCompletedToolCalls(msg);
assertEquals(2, calls.size());
assertEquals("call_001", calls.get(0).toolCallId());
assertEquals("search", calls.get(0).name());
assertEquals("{\"q\":\"spring ai\"}", calls.get(0).arguments());
assertEquals("No relevant hits.", calls.get(0).result());
assertEquals("call_002", calls.get(1).toolCallId());
assertEquals("browser_use", calls.get(1).name());
assertEquals("404", calls.get(1).result());
}
@Test
@DisplayName("running / awaiting_approval entries are skipped — replaying them produces orphan tool_call_ids")
void incompleteToolCalls_dropped() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setMetadata("{\"toolCalls\":["
+ "{\"toolCallId\":\"call_a\",\"name\":\"a\",\"status\":\"completed\",\"result\":\"ok\"},"
+ "{\"toolCallId\":\"call_b\",\"name\":\"b\",\"status\":\"running\"},"
+ "{\"toolCallId\":\"call_c\",\"name\":\"c\",\"status\":\"awaiting_approval\"}"
+ "]}");
List<BaseAgent.PersistedToolCall> calls = BaseAgent.extractCompletedToolCalls(msg);
assertEquals(1, calls.size());
assertEquals("call_a", calls.get(0).toolCallId());
}
@Test
@DisplayName("completed entry without a result is also skipped — there's nothing to feed back as observation")
void completedWithoutResult_dropped() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setMetadata("{\"toolCalls\":["
+ "{\"toolCallId\":\"x\",\"name\":\"t\",\"status\":\"completed\"}"
+ "]}");
assertTrue(BaseAgent.extractCompletedToolCalls(msg).isEmpty());
}
@Test
@DisplayName("H2 double-wrap (JSON-encoded string of JSON) unwraps transparently")
void h2DoubleWrap_unwrapped() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
// What H2's JSON column read sometimes hands back through MyBatis
msg.setMetadata("\"{\\\"toolCalls\\\":["
+ "{\\\"toolCallId\\\":\\\"id-1\\\",\\\"name\\\":\\\"t\\\","
+ "\\\"status\\\":\\\"completed\\\",\\\"result\\\":\\\"ok\\\"}"
+ "]}\"");
List<BaseAgent.PersistedToolCall> calls = BaseAgent.extractCompletedToolCalls(msg);
assertEquals(1, calls.size());
assertEquals("id-1", calls.get(0).toolCallId());
assertEquals("ok", calls.get(0).result());
}
@Test
@DisplayName("legacy rows without toolCallId still extract — caller synthesizes a stable id at replay time")
void legacyMissingToolCallId_extractsBlankId() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setMetadata("{\"toolCalls\":["
+ "{\"name\":\"search\",\"status\":\"completed\",\"result\":\"hit\"}"
+ "]}");
List<BaseAgent.PersistedToolCall> calls = BaseAgent.extractCompletedToolCalls(msg);
assertEquals(1, calls.size());
assertEquals("", calls.get(0).toolCallId());
assertEquals("search", calls.get(0).name());
}
@Test
@DisplayName("metadata without a toolCalls field returns empty (cheap exit, no JSON parse)")
void noToolCallsField_emptyShortCircuit() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setMetadata("{\"segments\":[{\"type\":\"text\"}]}");
assertTrue(BaseAgent.extractCompletedToolCalls(msg).isEmpty());
}
@Test
@DisplayName("null / blank / null entity safely returns empty")
void nullSafe() {
assertTrue(BaseAgent.extractCompletedToolCalls(null).isEmpty());
MessageEntity blank = new MessageEntity();
assertTrue(BaseAgent.extractCompletedToolCalls(blank).isEmpty());
blank.setMetadata("");
assertTrue(BaseAgent.extractCompletedToolCalls(blank).isEmpty());
}
@Test
@DisplayName("malformed JSON does not throw — it just yields an empty list")
void malformedJson_emptyAndNoThrow() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
// Looks like it has toolCalls (short-circuit lets us through) but the
// JSON itself is junk after that.
msg.setMetadata("{\"toolCalls\": [not actually json}");
assertTrue(BaseAgent.extractCompletedToolCalls(msg).isEmpty());
}
/**
* Smoke-test that the structured Spring AI primitives BaseAgent emits
* actually carry the data we expect. We assemble the same pair the
* production replay path does and read it back.
*/
@Test
@DisplayName("AssistantMessage + ToolResponseMessage pair carries matching tool_call ids")
void assistantPlusToolResponse_pairCarriesIds() {
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
"call_42", "function", "search", "{\"q\":\"hello\"}");
AssistantMessage assistant = AssistantMessage.builder()
.content("Looking it up.")
.toolCalls(List.of(tc))
.build();
ToolResponseMessage responses = ToolResponseMessage.builder()
.responses(List.of(new ToolResponseMessage.ToolResponse("call_42", "search", "no hits")))
.build();
assertNotNull(assistant.getToolCalls());
assertEquals(1, assistant.getToolCalls().size());
assertEquals("call_42", assistant.getToolCalls().get(0).id());
assertEquals("search", assistant.getToolCalls().get(0).name());
assertEquals(1, responses.getResponses().size());
assertEquals("call_42", responses.getResponses().get(0).id());
assertEquals("no hits", responses.getResponses().get(0).responseData());
}
// ========== shouldFullyFilter — mirrors sanitizeForLlm stages 0/1/1.5 ==========
@Test
@DisplayName("shouldFullyFilter: cron-header system row dropped before any tool replay")
void shouldFullyFilter_cronHeader() {
MessageEntity msg = new MessageEntity();
msg.setRole("system");
msg.setContent("📋 每日新闻 · 定时触发 · 2026-04-30T10:55");
msg.setMetadata("{\"toolCalls\":[{\"toolCallId\":\"x\",\"name\":\"t\","
+ "\"status\":\"completed\",\"result\":\"y\"}]}");
assertTrue(BaseAgent.shouldFullyFilter(msg));
}
@Test
@DisplayName("shouldFullyFilter: approval-placeholder assistant dropped even with tool metadata")
void shouldFullyFilter_approvalPlaceholder() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
// Real marker from ApprovalPlaceholderUtil — see callers in BaseAgent /
// ConversationService. The point under test: even if metadata.toolCalls
// looks complete, an approval placeholder must not resurrect a tool
// exchange (the underlying tool never executed, was awaiting approval).
msg.setContent("[APPROVAL_PENDING] 工具调用 search 等待您的批准");
msg.setMetadata("{\"toolCalls\":[{\"name\":\"t\",\"status\":\"completed\",\"result\":\"y\"}]}");
assertTrue(BaseAgent.isApprovalPlaceholder(msg.getContent()),
"sanity: content must match ApprovalPlaceholderUtil");
assertTrue(BaseAgent.shouldFullyFilter(msg));
}
@Test
@DisplayName("shouldFullyFilter: error-status assistant dropped — replaying produces 400 loops")
void shouldFullyFilter_errorStatusAssistant() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setStatus("error");
msg.setContent("anything");
msg.setMetadata("{\"toolCalls\":[{\"name\":\"t\",\"status\":\"completed\",\"result\":\"y\"}]}");
assertTrue(BaseAgent.shouldFullyFilter(msg));
}
@Test
@DisplayName("shouldFullyFilter: '[错误] ' content-prefix assistant dropped (legacy persistence)")
void shouldFullyFilter_errorPrefixContent() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setContent("[错误] Bad request - reasoning_content");
assertTrue(BaseAgent.shouldFullyFilter(msg));
}
@Test
@DisplayName("shouldFullyFilter: ordinary assistant / user / null-entity rows pass through")
void shouldFullyFilter_normalRowsPass() {
MessageEntity ordinary = new MessageEntity();
ordinary.setRole("assistant");
ordinary.setContent("normal answer");
assertFalse(BaseAgent.shouldFullyFilter(ordinary));
MessageEntity user = new MessageEntity();
user.setRole("user");
user.setContent("hi");
assertFalse(BaseAgent.shouldFullyFilter(user));
assertTrue(BaseAgent.shouldFullyFilter(null), "null entity is filtered");
}
// ========== buildToolExchange — the load-bearing pair builder ==========
@Test
@DisplayName("buildToolExchange: empty content + completed tool call still emits the structured pair (P1 fix)")
void buildToolExchange_emptyContentStillEmits() {
MessageEntity entity = new MessageEntity();
entity.setId(1234567890L);
entity.setRole("assistant");
entity.setContent("");
List<BaseAgent.PersistedToolCall> calls = List.of(
new BaseAgent.PersistedToolCall("call_x", "search", "{\"q\":\"a\"}", "no hits"));
List<Message> out = BaseAgent.buildToolExchange(entity, "", calls);
assertEquals(2, out.size(), "pure tool-call turn must replay as [assistant, toolResponses]");
assertTrue(out.get(0) instanceof AssistantMessage);
assertTrue(out.get(1) instanceof ToolResponseMessage);
AssistantMessage am = (AssistantMessage) out.get(0);
assertEquals("", am.getText() == null ? "" : am.getText(),
"content stays blank — the tool exchange carries the semantics");
assertEquals(1, am.getToolCalls().size());
assertEquals("call_x", am.getToolCalls().get(0).id());
ToolResponseMessage trm = (ToolResponseMessage) out.get(1);
assertEquals(1, trm.getResponses().size());
assertEquals("call_x", trm.getResponses().get(0).id(),
"tool_call.id must equal tool_response.id");
}
@Test
@DisplayName("buildToolExchange: non-empty content is preserved verbatim alongside the tool calls")
void buildToolExchange_contentPreserved() {
MessageEntity entity = new MessageEntity();
entity.setId(42L);
entity.setRole("assistant");
List<BaseAgent.PersistedToolCall> calls = List.of(
new BaseAgent.PersistedToolCall("id-1", "fetch", "{}", "200 OK"));
List<Message> out = BaseAgent.buildToolExchange(entity, "I'll look it up.", calls);
AssistantMessage am = (AssistantMessage) out.get(0);
assertEquals("I'll look it up.", am.getText());
}
@Test
@DisplayName("buildToolExchange: legacy row with missing toolCallId gets a stable 'legacy-<id>-<idx>' synthesis")
void buildToolExchange_legacyIdSynthesis() {
MessageEntity entity = new MessageEntity();
entity.setId(99L);
entity.setRole("assistant");
List<BaseAgent.PersistedToolCall> calls = List.of(
new BaseAgent.PersistedToolCall("", "search", "{}", "hit"),
new BaseAgent.PersistedToolCall(null, "fetch", "{}", "ok"));
List<Message> out = BaseAgent.buildToolExchange(entity, "", calls);
AssistantMessage am = (AssistantMessage) out.get(0);
ToolResponseMessage trm = (ToolResponseMessage) out.get(1);
assertEquals("legacy-99-0", am.getToolCalls().get(0).id());
assertEquals("legacy-99-1", am.getToolCalls().get(1).id());
assertEquals("legacy-99-0", trm.getResponses().get(0).id());
assertEquals("legacy-99-1", trm.getResponses().get(1).id(),
"synthetic ids must match across the pair so provider tool_call_id validation passes");
}
@Test
@DisplayName("buildToolExchange: multiple tool calls preserve order and pair 1:1")
void buildToolExchange_multipleCallsOrdered() {
MessageEntity entity = new MessageEntity();
entity.setId(7L);
entity.setRole("assistant");
List<BaseAgent.PersistedToolCall> calls = List.of(
new BaseAgent.PersistedToolCall("a", "t1", "{}", "r1"),
new BaseAgent.PersistedToolCall("b", "t2", "{}", "r2"),
new BaseAgent.PersistedToolCall("c", "t3", "{}", "r3"));
List<Message> out = BaseAgent.buildToolExchange(entity, "", calls);
AssistantMessage am = (AssistantMessage) out.get(0);
ToolResponseMessage trm = (ToolResponseMessage) out.get(1);
for (int i = 0; i < calls.size(); i++) {
assertEquals(calls.get(i).toolCallId(), am.getToolCalls().get(i).id());
assertEquals(calls.get(i).toolCallId(), trm.getResponses().get(i).id());
assertEquals(calls.get(i).result(), trm.getResponses().get(i).responseData());
}
}
@Test
@DisplayName("emitted pair survives a 1:1 round-trip in a list ([assistant, toolResponses])")
void replayList_pairsAreContiguous() {
// What the BaseAgent.expandToSpringMessages path produces is a list of
// exactly these two Message subtypes in this order; the consumer
// (Spring AI chat client) iterates them as a single tool exchange.
AssistantMessage assistant = AssistantMessage.builder()
.content("ran X")
.toolCalls(List.of(new AssistantMessage.ToolCall("id-1", "function", "X", "{}")))
.build();
ToolResponseMessage responses = ToolResponseMessage.builder()
.responses(List.of(new ToolResponseMessage.ToolResponse("id-1", "X", "ok")))
.build();
List<Message> replay = List.of(assistant, responses);
assertEquals(2, replay.size());
assertTrue(replay.get(0) instanceof AssistantMessage);
assertTrue(replay.get(1) instanceof ToolResponseMessage);
AssistantMessage a = (AssistantMessage) replay.get(0);
ToolResponseMessage t = (ToolResponseMessage) replay.get(1);
assertEquals(a.getToolCalls().get(0).id(), t.getResponses().get(0).id(),
"tool_call.id must match its tool_response.id — providers reject mismatched pairs");
}
// ====================================================================
// End-to-end orchestration: expandToSpringMessages with a TestAgent
// fixture so the conversationService.renderMessageContent interaction
// is exercised, not just the static helpers it composes (P3).
// ====================================================================
@Test
@DisplayName("E2E: pure tool-call turn (blank rendered content) still replays as structured pair")
void e2e_pureToolCallTurn() {
TestAgent agent = newTestAgent();
MessageEntity entity = new MessageEntity();
entity.setId(101L);
entity.setRole("assistant");
entity.setContent(""); // pure tool call — no preamble text
entity.setMetadata("{\"toolCalls\":["
+ "{\"toolCallId\":\"call_zz\",\"name\":\"search\","
+ "\"status\":\"completed\",\"result\":\"hit\"}"
+ "]}");
when(agent.conversationService.renderMessageContent(entity)).thenReturn("");
List<Message> out = agent.callExpand(entity);
assertEquals(2, out.size(),
"P1 regression: blank rendered content must NOT short-circuit the tool exchange");
assertTrue(out.get(0) instanceof AssistantMessage);
assertTrue(out.get(1) instanceof ToolResponseMessage);
assertEquals(1, ((AssistantMessage) out.get(0)).getToolCalls().size());
assertEquals("call_zz", ((AssistantMessage) out.get(0)).getToolCalls().get(0).id());
}
@Test
@DisplayName("E2E: narration-only assistant (no toolCalls) yields one AssistantMessage via toSpringMessage")
void e2e_narrativeOnly() {
TestAgent agent = newTestAgent();
MessageEntity entity = new MessageEntity();
entity.setId(102L);
entity.setRole("assistant");
entity.setContent("All set.");
entity.setMetadata("{}");
when(agent.conversationService.renderMessageContent(entity)).thenReturn("All set.");
List<Message> out = agent.callExpand(entity);
assertEquals(1, out.size());
assertTrue(out.get(0) instanceof AssistantMessage);
assertEquals("All set.", ((AssistantMessage) out.get(0)).getText());
assertTrue(((AssistantMessage) out.get(0)).getToolCalls() == null
|| ((AssistantMessage) out.get(0)).getToolCalls().isEmpty(),
"no metadata.toolCalls present → no resurrected tool calls");
}
@Test
@DisplayName("E2E: narration-only assistant with blank rendered content is dropped (legacy behavior preserved)")
void e2e_blankNarrativeDropped() {
TestAgent agent = newTestAgent();
MessageEntity entity = new MessageEntity();
entity.setId(103L);
entity.setRole("assistant");
entity.setContent("");
entity.setMetadata("{}");
when(agent.conversationService.renderMessageContent(entity)).thenReturn("");
List<Message> out = agent.callExpand(entity);
assertTrue(out.isEmpty(),
"blank content + no toolCalls → drop, same as pre-#120 behavior");
}
@Test
@DisplayName("E2E: approval-placeholder assistant dropped even when metadata.toolCalls looks complete")
void e2e_approvalPlaceholderWithToolCalls() {
TestAgent agent = newTestAgent();
MessageEntity entity = new MessageEntity();
entity.setId(104L);
entity.setRole("assistant");
entity.setContent("[APPROVAL_PENDING] 工具调用等待您的批准");
entity.setMetadata("{\"toolCalls\":["
+ "{\"toolCallId\":\"x\",\"name\":\"t\","
+ "\"status\":\"completed\",\"result\":\"y\"}"
+ "]}");
// renderMessageContent should NEVER be consulted for approval placeholders —
// shouldFullyFilter cuts before we look at content. Stubbing it would mask a
// regression where the filter ordering flipped.
List<Message> out = agent.callExpand(entity);
assertTrue(out.isEmpty(),
"approval placeholder must not resurrect a tool exchange — the tool never actually ran");
}
@Test
@DisplayName("E2E: error-status assistant dropped — replaying produces provider 400 loops")
void e2e_errorAssistantWithToolCalls() {
TestAgent agent = newTestAgent();
MessageEntity entity = new MessageEntity();
entity.setId(105L);
entity.setRole("assistant");
entity.setStatus("error");
entity.setContent("anything");
entity.setMetadata("{\"toolCalls\":["
+ "{\"toolCallId\":\"x\",\"name\":\"t\","
+ "\"status\":\"completed\",\"result\":\"y\"}"
+ "]}");
List<Message> out = agent.callExpand(entity);
assertTrue(out.isEmpty());
}
@Test
@DisplayName("E2E: cron-header system row dropped before any tool replay")
void e2e_cronHeaderDropped() {
TestAgent agent = newTestAgent();
MessageEntity entity = new MessageEntity();
entity.setId(106L);
entity.setRole("system");
entity.setContent("📋 每日新闻 · 定时触发 · 2026-04-30T10:55");
entity.setMetadata("{\"toolCalls\":["
+ "{\"toolCallId\":\"x\",\"name\":\"t\","
+ "\"status\":\"completed\",\"result\":\"y\"}"
+ "]}");
List<Message> out = agent.callExpand(entity);
assertTrue(out.isEmpty());
}
@Test
@DisplayName("E2E: RFC-052 direct-tool row replays as placeholder only — no tool exchange resurrected")
void e2e_directToolPlaceholderOnly() {
TestAgent agent = newTestAgent();
MessageEntity entity = new MessageEntity();
entity.setId(107L);
entity.setRole("assistant");
entity.setContent("EMPLOYEE-SECRET-DATA");
entity.setMetadata("{\"directToolNames\":[\"query_employee_salary\"],"
+ "\"toolCalls\":["
+ "{\"toolCallId\":\"x\",\"name\":\"query_employee_salary\","
+ "\"status\":\"completed\",\"result\":\"SECRET-PAYLOAD\"}"
+ "]}");
when(agent.conversationService.renderMessageContent(entity)).thenReturn("EMPLOYEE-SECRET-DATA");
List<Message> out = agent.callExpand(entity);
assertEquals(1, out.size(),
"RFC-052 direct-tool row must replay as ONE placeholder message, not as a tool exchange");
assertTrue(out.get(0) instanceof AssistantMessage);
String text = ((AssistantMessage) out.get(0)).getText();
assertFalse(text.contains("EMPLOYEE-SECRET-DATA"),
"direct-tool content must be scrubbed from history");
assertFalse(text.contains("SECRET-PAYLOAD"),
"tool result must not leak via resurrected tool exchange either");
assertTrue(text.contains("query_employee_salary"),
"placeholder names the originating tool so the model can re-call it on follow-up");
}
@Test
@DisplayName("E2E: non-assistant rows are delegated to toSpringMessage unchanged")
void e2e_userMessagePassThrough() {
TestAgent agent = newTestAgent();
MessageEntity entity = new MessageEntity();
entity.setId(108L);
entity.setRole("user");
entity.setContent("hi");
when(agent.conversationService.renderMessageContent(entity)).thenReturn("hi");
when(agent.conversationService.parseMessageParts(any())).thenReturn(List.of());
List<Message> out = agent.callExpand(entity);
assertEquals(1, out.size());
assertTrue(out.get(0) instanceof UserMessage);
assertEquals("hi", ((UserMessage) out.get(0)).getText());
}
// ---------- Test scaffold ----------
private static TestAgent newTestAgent() {
ConversationService conv = mock(ConversationService.class);
TestAgent agent = new TestAgent(conv);
agent.agentName = "test-agent";
agent.modelName = "test-model";
return agent;
}
/**
* Minimal concrete BaseAgent fixture so the package-private
* {@code expandToSpringMessages} entry point can be exercised end-to-end
* (renderMessageContent → sanitizeForLlm fork → buildToolExchange).
*/
static class TestAgent extends BaseAgent {
TestAgent(ConversationService conv) {
super(null, conv);
}
List<Message> callExpand(MessageEntity entity) {
return expandToSpringMessages(entity);
}
@Override public String chat(String userMessage, String conversationId) {
throw new UnsupportedOperationException();
}
@Override public reactor.core.publisher.Flux<String> chatStream(String userMessage, String conversationId) {
throw new UnsupportedOperationException();
}
@Override public String execute(String goal, String conversationId) {
throw new UnsupportedOperationException();
}
}
}

View File

@ -0,0 +1,69 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.agent.AgentService;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pins the routing decision for {@code STREAMED_CONTENT} deltas emitted by
* {@link StateGraphReActAgent} during the structured-stream loop.
*
* <p>Background — issue #120 follow-up: the original fix routed every per-iteration
* {@code STREAMED_CONTENT} via {@link AgentService.StreamDelta#segmentOnly}, which
* keeps the persisted {@code mate_message.content} clean of mid-loop "我来…" preamble.
* That broke the evidence-insufficient terminal turn though
* ({@code ReasoningNode.java:617}): there {@code FINAL_ANSWER} is just a short
* "[证据不足]" warning while {@code STREAMED_CONTENT} carries the actual answer body
* the user/UI need to see. Single-segment renderers (copy / TTS / history reload)
* read {@code content}, not segments, so {@code segmentOnly} for that case would
* shrink the visible message to the warning alone.
*
* <p>The helper under test embodies the corrected contract.
*/
class StateGraphReActAgentStreamedContentDeltaTest {
@Test
@DisplayName("intermediate iteration (no FINAL_ANSWER yet) → segmentOnly — narration stays out of content")
void intermediateIteration_routedToSegmentsOnly() {
AgentService.StreamDelta d = StateGraphReActAgent.streamedContentDelta(
/* isFinalAnswerTurn */ false,
"I'll search for X.");
assertTrue(d.persistenceOnly(),
"segmentOnly implies persistenceOnly — no re-broadcast (NodeStreamingChatHelper already pushed it)");
assertTrue(d.segmentOnly(),
"intermediate narration MUST set segmentOnly so content.append is skipped");
// Sanity: the content payload survives the wrap.
org.junit.jupiter.api.Assertions.assertEquals("I'll search for X.", d.content());
}
@Test
@DisplayName("evidence-insufficient terminal turn (FINAL_ANSWER set) → persistOnly — answer body persists to content")
void evidenceInsufficientFinalTurn_routedToPersistOnly() {
// Regression: STREAMED_CONTENT here is the rejected answer body; FINAL_ANSWER
// is only the "[证据不足]" warning. Persisting the streamed body keeps
// mate_message.content readable through single-segment renderers.
AgentService.StreamDelta d = StateGraphReActAgent.streamedContentDelta(
/* isFinalAnswerTurn */ true,
"The answer is 42. References: [1] [2] [3].");
assertTrue(d.persistenceOnly(),
"persistOnly suppresses re-broadcast — content was already streamed live");
assertFalse(d.segmentOnly(),
"persistOnly variant MUST NOT set segmentOnly — content.append needs to run");
org.junit.jupiter.api.Assertions.assertEquals(
"The answer is 42. References: [1] [2] [3].", d.content());
}
@Test
@DisplayName("both flavors leave thinking null — STREAMED_CONTENT routing only carries text content")
void thinkingFieldNeverSet() {
org.junit.jupiter.api.Assertions.assertNull(
StateGraphReActAgent.streamedContentDelta(false, "x").thinking());
org.junit.jupiter.api.Assertions.assertNull(
StateGraphReActAgent.streamedContentDelta(true, "x").thinking());
}
}

View File

@ -6,7 +6,7 @@
"description": "MateClaw - Personal AI Assistant Web Console",
"scripts": {
"dev": "vite",
"build": "node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build",
"build": "node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.vue --fix"
},
@ -14,6 +14,7 @@
"@element-plus/icons-vue": "^2.3.1",
"@google/model-viewer": "^4.2.0",
"@guolao/vue-monaco-editor": "^1.6.0",
"@intlify/core-base": "9.14.4",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
@ -47,6 +48,7 @@
"autoprefixer": "^10.4.20",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^4.0.6",
"typescript": "~5.7.2",
"vite": "^7.3.1",

View File

@ -17,6 +17,9 @@ importers:
'@guolao/vue-monaco-editor':
specifier: ^1.6.0
version: 1.6.0(monaco-editor@0.55.1)(vue@3.5.31(typescript@5.7.3))
'@intlify/core-base':
specifier: 9.14.4
version: 9.14.4
'@vue-flow/background':
specifier: ^1.3.2
version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))
@ -111,6 +114,9 @@ importers:
eslint-plugin-vue:
specifier: ^9.32.0
version: 9.33.0(eslint@9.39.4(jiti@2.6.1))
rollup-plugin-visualizer:
specifier: ^7.0.1
version: 7.0.1(rollup@4.60.1)
tailwindcss:
specifier: ^4.0.6
version: 4.2.2
@ -948,10 +954,18 @@ packages:
alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@ -993,6 +1007,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@ -1017,6 +1035,10 @@ packages:
resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==}
engines: {node: '>=22.0.0'}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -1238,6 +1260,18 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
default-browser@5.5.0:
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
engines: {node: '>=18'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
delaunator@5.1.0:
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
@ -1270,6 +1304,9 @@ packages:
peerDependencies:
vue: ^3.3.0
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
@ -1423,6 +1460,14 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@ -1505,6 +1550,11 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -1513,6 +1563,15 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-in-ssh@1.0.0:
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
engines: {node: '>=20'}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-promise@2.2.2:
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
@ -1520,6 +1579,10 @@ packages:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@ -1746,6 +1809,10 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -1827,6 +1894,10 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -1856,6 +1927,19 @@ packages:
robust-predicates@3.0.3:
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
rollup-plugin-visualizer@7.0.1:
resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==}
engines: {node: '>=22'}
hasBin: true
peerDependencies:
rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rolldown:
optional: true
rollup:
optional: true
rollup@4.60.1:
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -1864,6 +1948,10 @@ packages:
roughjs@4.6.6:
resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
@ -1887,6 +1975,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@ -1894,6 +1986,14 @@ packages:
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@ -2080,10 +2180,30 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -2826,10 +2946,14 @@ snapshots:
alien-signals@3.1.2: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
argparse@2.0.1: {}
async-validator@4.2.5: {}
@ -2874,6 +2998,10 @@ snapshots:
node-releases: 2.0.37
update-browserslist-db: 1.2.3(browserslist@4.28.2)
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@ -2901,6 +3029,12 @@ snapshots:
'@chevrotain/types': 12.0.0
'@chevrotain/utils': 12.0.0
cliui@9.0.1:
dependencies:
string-width: 7.2.0
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -3138,6 +3272,15 @@ snapshots:
deep-is@0.1.4: {}
default-browser-id@5.0.1: {}
default-browser@5.5.0:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.1
define-lazy-prop@3.0.0: {}
delaunator@5.1.0:
dependencies:
robust-predicates: 3.0.3
@ -3188,6 +3331,8 @@ snapshots:
transitivePeerDependencies:
- typescript
emoji-regex@10.6.0: {}
enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
@ -3381,6 +3526,10 @@ snapshots:
function-bind@1.1.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -3454,16 +3603,28 @@ snapshots:
internmap@2.0.3: {}
is-docker@3.0.0: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-in-ssh@1.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-promise@2.2.2: {}
is-what@5.5.0: {}
is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
isexe@2.0.0: {}
jiti@2.6.1: {}
@ -3673,6 +3834,15 @@ snapshots:
dependencies:
boolbase: 1.0.0
open@11.0.0:
dependencies:
default-browser: 5.5.0
define-lazy-prop: 3.0.0
is-in-ssh: 1.0.0
is-inside-container: 1.0.0
powershell-utils: 0.1.0
wsl-utils: 0.3.1
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@ -3749,6 +3919,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
powershell-utils@0.1.0: {}
prelude-ls@1.2.1: {}
promise-worker-transferable@1.0.4:
@ -3768,6 +3940,15 @@ snapshots:
robust-predicates@3.0.3: {}
rollup-plugin-visualizer@7.0.1(rollup@4.60.1):
dependencies:
open: 11.0.0
picomatch: 4.0.4
source-map: 0.7.6
yargs: 18.0.0
optionalDependencies:
rollup: 4.60.1
rollup@4.60.1:
dependencies:
'@types/estree': 1.0.8
@ -3806,6 +3987,8 @@ snapshots:
points-on-curve: 0.2.0
points-on-path: 0.2.1
run-applescript@7.1.0: {}
rw@1.3.3: {}
safer-buffer@2.1.2: {}
@ -3820,10 +4003,22 @@ snapshots:
source-map-js@1.2.1: {}
source-map@0.7.6: {}
speakingurl@14.0.1: {}
state-local@1.0.7: {}
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
get-east-asian-width: 1.6.0
strip-ansi: 7.2.0
strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.2.2
strip-json-comments@3.1.1: {}
stylis@4.4.0: {}
@ -3960,8 +4155,32 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.2.0
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xml-name-validator@4.0.0: {}
y18n@5.0.8: {}
yargs-parser@22.0.0: {}
yargs@18.0.0:
dependencies:
cliui: 9.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
string-width: 7.2.0
y18n: 5.0.8
yargs-parser: 22.0.0
yocto-queue@0.1.0: {}
zrender@6.0.0:

View File

@ -634,6 +634,19 @@ html.dark body::before {
box-shadow: var(--mc-shadow-soft);
}
/* While a workflow canvas is in fullscreen mode, suppress the backdrop
filter on every page frame. A non-`none` backdrop-filter promotes the
element to a containing block for position:fixed descendants, which
pinned the fullscreen canvas to the frame's top-left instead of the
viewport. The frames sit behind the fullscreen overlay anyway, so
the blur has no visible cost during this transient state. */
body.workflow-canvas-fullscreen .mc-page-frame {
backdrop-filter: none;
}
body.workflow-canvas-fullscreen .mc-page-frame::before {
display: none;
}
.mc-page-frame::before {
content: '';
position: absolute;

View File

@ -66,7 +66,7 @@
@input="patch({ promptTemplate: ($event.target as HTMLTextAreaElement).value })"
spellcheck="false"
rows="3"
:placeholder="t('workflows.canvas.fields.promptPlaceholder')"
:placeholder="PROMPT_PLACEHOLDER"
/>
</label>
@ -319,6 +319,14 @@ const emit = defineEmits<{
const { t } = useI18n()
// The Pebble example shown in the textarea placeholder is the same in every
// locale and contains literal `{{ }}`. Routing it through vue-i18n's t()
// works but the library does a second compile pass on the returned string
// looking for linked messages / nested placeholders, which trips on the
// inner braces and floods the console with parse errors. Keeping it as a
// plain const sidesteps the parser entirely.
const PROMPT_PLACEHOLDER = 'Hello {{ inputs.payload }}'
const modeType = computed(() => (props.step?.mode?.type ?? 'sequential') as string)
const localizedModeLabel = computed(() => {

View File

@ -123,6 +123,11 @@ const props = withDefaults(defineProps<Props>(), { canvasId: 'workflow-canvas' }
const emit = defineEmits<{
(e: 'select-step', payload: StepNodeData | null): void
(e: 'insert-step', payload: { afterIndex: number; modeType: string }): void
/** Toggled by the fullscreen button. The parent uses this to float the
* property inspector above the fullscreen overlay (otherwise the panel,
* which lives as a flex sibling outside this component, gets hidden
* behind the z-index: 2000 fixed canvas). */
(e: 'update:fullscreen', value: boolean): void
}>()
function onAddNode(e: Event) {
@ -238,15 +243,29 @@ watch(fullscreen, (on) => {
document.addEventListener('keydown', handleEsc)
// Lock page scroll behind the overlay.
document.body.style.overflow = 'hidden'
// The settings layout's mc-page-frame uses backdrop-filter, which
// promotes it to a containing block for position:fixed descendants
// (CSS containment spec). That makes our `inset: 0` anchor to the
// frame instead of the viewport, leaving the canvas pinned to the
// top-left of the page rather than truly fullscreen. Suppress the
// filter while fullscreen is active — the frames sit behind the
// overlay anyway, so dropping the blur has no visible cost.
document.body.classList.add('workflow-canvas-fullscreen')
} else {
document.removeEventListener('keydown', handleEsc)
document.body.style.overflow = ''
document.body.classList.remove('workflow-canvas-fullscreen')
}
emit('update:fullscreen', on)
})
onBeforeUnmount(() => {
window.removeEventListener('mateclaw:workflow-step-select', handleDomStepSelect as EventListener)
document.removeEventListener('keydown', handleEsc)
document.body.style.overflow = ''
// Unmounting while in fullscreen (route change, panel close) must
// restore the page frame's backdrop blur — otherwise we leave the
// rest of the app permanently transparent.
document.body.classList.remove('workflow-canvas-fullscreen')
})
</script>

View File

@ -16,7 +16,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, watch } from 'vue'
import { VueMonacoEditor, loader } from '@guolao/vue-monaco-editor'
import * as monaco from 'monaco-editor'
// Precise imports replace `import * as monaco from 'monaco-editor'` — the
// barrel pulled in TypeScript / CSS / HTML language workers (~8.7 MB) that
// this JSON editor never touches. The editor.api entry exposes the same
// `monaco` namespace surface we need (registerLanguage, KeyMod, etc.).
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import 'monaco-editor/esm/vs/language/json/monaco.contribution.js'
// Vite's ?worker imports — Monaco loads its language workers as separate
// JS bundles, and without this MonacoEnvironment shim the editor falls
// back to fetching from a CDN URL ('vs/base/worker/workerMain.js') that

View File

@ -1,4 +1,5 @@
import { createI18n } from 'vue-i18n'
import { compileToFunction, registerMessageCompiler } from '@intlify/core-base'
import { ref } from 'vue'
import { settingsApi } from '@/api'
@ -9,11 +10,30 @@ const DEFAULT_LOCALE: AppLocale = 'zh-CN'
export const currentLocale = ref<AppLocale>(DEFAULT_LOCALE)
// Replace vue-i18n's default message compiler with a safety wrapper. The
// default compiler throws on parse errors in production builds, which
// caused a regression: workflow step prompts containing Pebble syntax
// (`Hello {{ inputs.payload }}`) leaked into i18n's parser through one of
// vue-i18n's internal lookups and aborted the property panel render.
// Catching the throw here keeps the panel alive — the worst case is that
// a malformed message renders as its literal text instead of the
// interpolated form, which is the same fallback dev mode already has.
const safeMessageCompiler = ((message: any, context: any) => {
try {
return compileToFunction(message, context)
} catch {
const literal = typeof message === 'string' ? message : String(message)
return () => literal
}
}) as typeof compileToFunction
registerMessageCompiler(safeMessageCompiler)
export const i18n = createI18n({
legacy: false,
locale: DEFAULT_LOCALE,
fallbackLocale: DEFAULT_LOCALE,
messages: {} as Record<AppLocale, any>,
messageCompiler: safeMessageCompiler,
})
const loadedLocales = new Set<AppLocale>()

View File

@ -2178,6 +2178,7 @@ export default {
tabs: {
canvas: 'Canvas',
json: 'JSON',
runs: 'Runs ({count})',
},
canvas: {
empty: 'The current draft has no steps. Add one in the JSON editor or pick a template.',
@ -2221,7 +2222,8 @@ export default {
agentPlaceholder: 'Select digital employee',
agentMissing: 'Current digital employee is not in the list: {name}',
promptTemplate: 'Prompt template',
promptPlaceholder: 'Hello {{ inputs.payload }}',
// promptPlaceholder is intentionally not localized — hardcoded in
// StepPropertyPanel so vue-i18n's parser never sees the Pebble braces.
outputVar: 'Output variable',
outputVarPlaceholder: 'data',
outputContentType: 'Output type',

View File

@ -2190,6 +2190,7 @@ export default {
tabs: {
canvas: '画布视图',
json: 'JSON 编辑',
runs: '运行 ({count})',
},
canvas: {
empty: '当前草稿没有任何步骤,先在 JSON 编辑器中添加步骤或选择一个模板。',
@ -2233,7 +2234,8 @@ export default {
agentPlaceholder: '选择数字员工',
agentMissing: '当前数字员工不在列表中:{name}',
promptTemplate: 'Prompt 模板',
promptPlaceholder: 'Hello {{ inputs.payload }}',
// promptPlaceholder is intentionally not localized — hardcoded in
// StepPropertyPanel so vue-i18n's parser never sees the Pebble braces.
outputVar: '输出变量名',
outputVarPlaceholder: 'data',
outputContentType: '输出类型',

View File

@ -0,0 +1,243 @@
/**
* Curated catalog of one-click MCP servers shown on the MCP connections page.
*
* Each entry maps to the MateClaw backend's MCP server schema:
* - HTTP-based remote MCPs use transport = 'streamable_http' with a `url`
* and optional bearer-style `headersJson` (placeholders the user replaces).
* - Stdio MCPs use transport = 'stdio' with `command`, `argsJson`, and
* optional `envJson` (placeholders for API keys).
*
* When a user clicks a catalog card the McpServers.vue create modal is
* pre-filled with these fields; credential placeholders like
* `YOUR_API_KEY` are kept visible so the user knows what to swap.
*/
export interface McpCredentialKey {
/** Env var or header name (e.g. CONTEXT7_API_KEY, Authorization). */
key: string
/** Whether the server fails to connect when this key is unset. */
required: boolean
}
export interface McpCatalogEntry {
/** Stable slug used as the default server name. */
key: string
/** Human-readable name shown on the card. */
name: string
/** One-line capability description shown under the name. */
description: string
/** Official docs link, opened from the card hover external-link icon. */
docsUrl: string
/** Pre-fill payload for the create-MCP form. */
config:
| {
transport: 'streamable_http' | 'sse'
url: string
headersJson?: string
}
| {
transport: 'stdio'
command: string
argsJson?: string
envJson?: string
}
/** Which env vars / headers in the config need user-supplied secrets. */
credentialKeys?: McpCredentialKey[]
}
export const mcpCatalog: McpCatalogEntry[] = [
{
key: 'context7',
name: 'Context7',
description: 'Fetch up-to-date library docs and code examples',
docsUrl: 'https://github.com/upstash/context7',
config: {
transport: 'streamable_http',
url: 'https://mcp.context7.com/mcp',
headersJson: JSON.stringify({ CONTEXT7_API_KEY: 'YOUR_API_KEY' }, null, 2),
},
credentialKeys: [{ key: 'CONTEXT7_API_KEY', required: false }],
},
{
key: 'figma',
name: 'Figma',
description: 'Generate diagrams and better code from Figma context',
docsUrl: 'https://help.figma.com/hc/en-us/articles/32132100833559',
config: {
transport: 'streamable_http',
url: 'https://mcp.figma.com/mcp',
},
},
{
key: 'linear',
name: 'Linear',
description: 'Manage issues, projects and team workflows in Linear',
docsUrl: 'https://linear.app/docs/mcp',
config: {
transport: 'streamable_http',
url: 'https://mcp.linear.app/mcp',
},
},
{
key: 'notion',
name: 'Notion',
description: 'Search, update and power workflows across your Notion workspace',
docsUrl: 'https://developers.notion.com/docs/mcp',
config: {
transport: 'streamable_http',
url: 'https://mcp.notion.com/mcp',
},
},
{
key: 'slack',
name: 'Slack',
description: 'Send messages, create canvases and fetch Slack data',
docsUrl: 'https://docs.slack.dev/ai/mcp-server',
config: {
transport: 'streamable_http',
url: 'https://mcp.slack.com/mcp',
},
},
{
key: 'supabase',
name: 'Supabase',
description: 'Manage databases, authentication and storage',
docsUrl: 'https://supabase.com/docs/guides/getting-started/mcp',
config: {
transport: 'streamable_http',
url: 'https://mcp.supabase.com/mcp',
},
},
{
key: 'vercel',
name: 'Vercel',
description: 'Analyze, debug and manage projects and deployments',
docsUrl: 'https://vercel.com/docs/mcp/vercel-mcp',
config: {
transport: 'streamable_http',
url: 'https://mcp.vercel.com',
},
},
{
key: 'sentry',
name: 'Sentry',
description: 'Search, query and debug errors intelligently',
docsUrl: 'https://docs.sentry.io/product/sentry-mcp/',
config: {
transport: 'streamable_http',
url: 'https://mcp.sentry.dev/mcp',
headersJson: JSON.stringify({ SENTRY_ACCESS_TOKEN: 'YOUR_ACCESS_TOKEN' }, null, 2),
},
credentialKeys: [{ key: 'SENTRY_ACCESS_TOKEN', required: true }],
},
{
key: 'stripe',
name: 'Stripe',
description: 'Payment processing and financial infrastructure tools',
docsUrl: 'https://docs.stripe.com/mcp',
config: {
transport: 'streamable_http',
url: 'https://mcp.stripe.com',
headersJson: JSON.stringify({ STRIPE_SECRET_KEY: 'YOUR_SECRET_KEY' }, null, 2),
},
credentialKeys: [{ key: 'STRIPE_SECRET_KEY', required: true }],
},
{
key: 'atlassian',
name: 'Atlassian',
description: 'Access Jira and Confluence from your agent',
docsUrl: 'https://www.atlassian.com/platform/remote-mcp-server',
config: {
transport: 'streamable_http',
url: 'https://mcp.atlassian.com/v1/mcp',
},
},
{
key: 'cloudflare',
name: 'Cloudflare',
description: 'Build with compute, storage and AI on the Cloudflare platform',
docsUrl: 'https://developers.cloudflare.com/agents/model-context-protocol/',
config: {
transport: 'streamable_http',
url: 'https://bindings.mcp.cloudflare.com/mcp',
},
},
{
key: 'huggingface',
name: 'Hugging Face',
description: 'Access the Hugging Face Hub and thousands of Gradio apps',
docsUrl: 'https://huggingface.co/settings/mcp',
config: {
transport: 'streamable_http',
url: 'https://huggingface.co/mcp',
},
},
{
key: 'posthog',
name: 'PostHog',
description: 'Query, analyze and manage your PostHog insights',
docsUrl: 'https://posthog.com/docs/model-context-protocol',
config: {
transport: 'streamable_http',
url: 'https://mcp.posthog.com/mcp',
},
},
{
key: 'playwright',
name: 'Playwright',
description: 'Browser automation with Playwright',
docsUrl: 'https://github.com/microsoft/playwright-mcp',
config: {
transport: 'stdio',
command: 'npx',
argsJson: JSON.stringify(['@playwright/mcp@latest']),
},
},
{
key: 'chrome-devtools',
name: 'Chrome DevTools',
description: 'Browser debugging and performance analysis with Chrome DevTools',
docsUrl: 'https://github.com/ChromeDevTools/chrome-devtools-mcp',
config: {
transport: 'stdio',
command: 'npx',
argsJson: JSON.stringify(['chrome-devtools-mcp@latest']),
},
},
{
key: 'exa',
name: 'Exa',
description: 'Web search and code context retrieval powered by Exa AI',
docsUrl: 'https://docs.exa.ai/reference/exa-mcp',
config: {
transport: 'stdio',
command: 'npx',
argsJson: JSON.stringify(['-y', 'exa-mcp-server', 'tools=web_search_exa,get_code_context_exa']),
envJson: JSON.stringify({ EXA_API_KEY: 'YOUR_API_KEY' }, null, 2),
},
credentialKeys: [{ key: 'EXA_API_KEY', required: true }],
},
]
/** Two-letter / single-letter initial for the fallback icon bubble. */
export function catalogInitial(entry: { name: string }): string {
const ch = entry.name.trim().charAt(0)
return ch ? ch.toUpperCase() : '?'
}
/** Stable color hash for the catalog icon background. */
export function catalogColor(key: string): string {
// 8 evenly-spaced HSL hues; pick by simple character sum for determinism.
const palette = [
'#e0e7ff', // indigo-100
'#fce7f3', // pink-100
'#dcfce7', // green-100
'#fef3c7', // amber-100
'#dbeafe', // blue-100
'#f3e8ff', // purple-100
'#ffedd5', // orange-100
'#cffafe', // cyan-100
]
let sum = 0
for (let i = 0; i < key.length; i++) sum = (sum + key.charCodeAt(i)) >>> 0
return palette[sum % palette.length]
}

View File

@ -43,35 +43,46 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
const route = useRoute()
const { t } = useI18n()
// 折叠状态
// Routes that benefit from extra editor width — the sub-nav auto-collapses
// to a 56px rail unless the user has explicitly toggled it open.
const COMPACT_ROUTES = ['/settings/workflows', '/settings/triggers']
const navCollapsed = ref(localStorage.getItem('mc-settings-nav-collapsed') === 'true')
const userExplicit = ref(localStorage.getItem('mc-settings-nav-collapsed') === 'true')
const userExplicit = ref(localStorage.getItem('mc-settings-nav-collapsed') !== null)
let mediumQuery: MediaQueryList | null = null
function toggleNav() {
navCollapsed.value = !navCollapsed.value
userExplicit.value = navCollapsed.value
userExplicit.value = true
localStorage.setItem('mc-settings-nav-collapsed', String(navCollapsed.value))
}
function handleMediumChange(e: MediaQueryListEvent | MediaQueryList) {
if (e.matches && !userExplicit.value) {
navCollapsed.value = true
} else if (!e.matches && !userExplicit.value) {
navCollapsed.value = false
}
function isCompactRoute(path: string): boolean {
return COMPACT_ROUTES.some((p) => path.startsWith(p))
}
function recomputeAuto() {
if (userExplicit.value) return
const compact = isCompactRoute(route.path) || !!mediumQuery?.matches
navCollapsed.value = compact
}
function handleMediumChange(_e: MediaQueryListEvent | MediaQueryList) {
recomputeAuto()
}
watch(() => route.path, recomputeAuto)
onMounted(() => {
mediumQuery = window.matchMedia('(max-width: 1200px)')
handleMediumChange(mediumQuery)
recomputeAuto()
mediumQuery.addEventListener('change', handleMediumChange)
})

View File

@ -16,7 +16,7 @@
<div class="workflows-grid">
<!-- left: list -->
<aside class="workflows-list mc-surface-card">
<aside class="workflows-list">
<div class="list-header">
<span>{{ t('workflows.defined', { count: workflows.length }) }}</span>
<button class="btn-ghost" @click="reload">{{ t('workflows.refresh') }}</button>
@ -41,7 +41,7 @@
</aside>
<!-- middle: editor -->
<section class="workflows-editor mc-surface-card" v-if="selected">
<section class="workflows-editor" v-if="selected">
<header class="editor-header">
<input v-model="selected.name" class="editor-name" :placeholder="t('workflows.namePlaceholder')" />
<input v-model="selected.description" class="editor-desc" :placeholder="t('workflows.descPlaceholder')" />
@ -61,30 +61,32 @@
<button class="tab-btn" :class="{ active: editorTab === 'json' }" @click="editorTab = 'json'">
{{ t('workflows.tabs.json') }}
</button>
<button class="tab-btn" :class="{ active: editorTab === 'runs' }" @click="onSwitchToRuns">
{{ t('workflows.tabs.runs', { count: runs.length + pausedRuns.length }) }}
</button>
</div>
<div v-if="editorTab === 'canvas'" class="canvas-pane">
<div v-if="editorTab === 'canvas'" class="canvas-pane" :class="{ 'canvas-fullscreen': canvasFullscreen }">
<WorkflowCanvas
v-model="canvasModel"
:canvas-id="`wf-${selected.id}`"
@select-step="onCanvasSelect"
@insert-step="onInsertStep"
>
<template v-if="canvasSelection" #panel>
<StepPropertyPanel
:step="selectedStep"
:index="canvasSelection.index"
:available-agents="availableAgents"
:available-channels="availableChannels"
@patch="onStepPatch"
@duplicate="onStepDuplicate"
@delete="onStepDelete"
/>
</template>
</WorkflowCanvas>
@update:fullscreen="canvasFullscreen = $event"
/>
<StepPropertyPanel
v-if="canvasSelection"
:step="selectedStep"
:index="canvasSelection.index"
:available-agents="availableAgents"
:available-channels="availableChannels"
@patch="onStepPatch"
@duplicate="onStepDuplicate"
@delete="onStepDelete"
/>
</div>
<div v-else class="json-pane">
<div v-else-if="editorTab === 'json'" class="json-pane">
<div class="editor-toolbar">
<label class="template-picker">
<span>{{ t('workflows.templates.label') }}</span>
@ -108,6 +110,81 @@
/>
</div>
<div v-else class="runs-pane">
<section v-if="pausedRuns.length" class="paused-section">
<header class="paused-header">
<span>{{ t('workflows.paused.header', { count: pausedRuns.length }) }}</span>
<button class="btn-ghost" @click="reloadPausedRuns">{{ t('workflows.refresh') }}</button>
</header>
<ul class="paused-list">
<li v-for="entry in pausedRuns" :key="entry.run.id" class="paused-row">
<div class="paused-row-line">
<span class="run-state state-paused">paused</span>
<span class="paused-run-hash">{{ t('workflows.paused.runHash', { id: entry.run.id }) }}</span>
</div>
<div class="paused-meta" v-if="entry.pause">
<div><span>{{ t('workflows.paused.pauseTokenLabel') }}:</span>
<code class="pause-token">{{ truncateToken(entry.pause.pauseToken) }}</code></div>
<div v-if="entry.pause.pausedAt">
<span>{{ t('workflows.paused.pausedAtLabel') }}:</span> {{ formatTime(entry.pause.pausedAt) }}
</div>
<div v-if="entry.pause.resumeDeadline">
<span>{{ t('workflows.paused.deadlineLabel') }}:</span> {{ formatTime(entry.pause.resumeDeadline) }}
</div>
</div>
<div class="paused-actions" v-if="entry.pause">
<button class="resume-btn approve" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'approved')">
{{ t('workflows.paused.resumeApproved') }}
</button>
<button class="resume-btn reject" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'rejected')">
{{ t('workflows.paused.resumeRejected') }}
</button>
<button class="resume-btn neutral" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'timeout')">
{{ t('workflows.paused.resumeTimeout') }}
</button>
<button class="resume-btn neutral" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'cancelled')">
{{ t('workflows.paused.resumeCancelled') }}
</button>
</div>
</li>
</ul>
</section>
<header class="runs-header">
<span>{{ t('workflows.runs.header', { count: runs.length }) }}</span>
<button class="btn-ghost" @click="reloadRuns">{{ t('workflows.refresh') }}</button>
</header>
<ul class="runs-list">
<li v-for="run in runs" :key="run.id" class="run-row" @click="loadRun(run.id)">
<div class="run-row-line">
<span class="run-state" :class="'state-' + run.state">{{ run.state }}</span>
<span class="run-time">{{ formatTime(run.startedAt) }}</span>
</div>
<div class="run-row-meta">
<span>{{ t('workflows.runs.runHash', { id: run.id }) }}</span>
<span v-if="run.triggeredBy">· {{ run.triggeredBy }}</span>
<span v-if="run.errorMessage" class="run-err">· {{ run.errorMessage }}</span>
</div>
</li>
<li v-if="!runs.length" class="runs-empty">{{ t('workflows.runs.empty') }}</li>
</ul>
<section v-if="runDetail" class="run-detail">
<div class="run-detail-title">{{ t('workflows.runs.detailTitle', { id: runDetail.run.id, state: runDetail.run.state }) }}</div>
<ol class="run-steps">
<li v-for="step in runDetail.steps" :key="step.id">
<span class="step-state" :class="'state-' + step.state">{{ step.state }}</span>
<span class="step-name">{{ step.stepName || t('workflows.unnamed') }}</span>
<span v-if="step.durationMs != null" class="step-duration">{{ step.durationMs }} ms</span>
<span v-if="step.errorMessage" class="step-err">{{ step.errorMessage }}</span>
</li>
</ol>
</section>
</div>
<div v-if="compileErrors.length" class="errors-panel">
<div class="errors-title">{{ t('workflows.compileErrorsTitle', { count: compileErrors.length }) }}</div>
<ul>
@ -123,85 +200,9 @@
</div>
</section>
<section class="workflows-empty mc-surface-card" v-else>
<section class="workflows-empty" v-else>
<p>{{ t('workflows.selectHint') }}</p>
</section>
<!-- right: runs -->
<aside class="workflows-runs mc-surface-card" v-if="selected">
<section v-if="pausedRuns.length" class="paused-section">
<header class="paused-header">
<span>{{ t('workflows.paused.header', { count: pausedRuns.length }) }}</span>
<button class="btn-ghost" @click="reloadPausedRuns">{{ t('workflows.refresh') }}</button>
</header>
<ul class="paused-list">
<li v-for="entry in pausedRuns" :key="entry.run.id" class="paused-row">
<div class="paused-row-line">
<span class="run-state state-paused">paused</span>
<span class="paused-run-hash">{{ t('workflows.paused.runHash', { id: entry.run.id }) }}</span>
</div>
<div class="paused-meta" v-if="entry.pause">
<div><span>{{ t('workflows.paused.pauseTokenLabel') }}:</span>
<code class="pause-token">{{ truncateToken(entry.pause.pauseToken) }}</code></div>
<div v-if="entry.pause.pausedAt">
<span>{{ t('workflows.paused.pausedAtLabel') }}:</span> {{ formatTime(entry.pause.pausedAt) }}
</div>
<div v-if="entry.pause.resumeDeadline">
<span>{{ t('workflows.paused.deadlineLabel') }}:</span> {{ formatTime(entry.pause.resumeDeadline) }}
</div>
</div>
<div class="paused-actions" v-if="entry.pause">
<button class="resume-btn approve" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'approved')">
{{ t('workflows.paused.resumeApproved') }}
</button>
<button class="resume-btn reject" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'rejected')">
{{ t('workflows.paused.resumeRejected') }}
</button>
<button class="resume-btn neutral" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'timeout')">
{{ t('workflows.paused.resumeTimeout') }}
</button>
<button class="resume-btn neutral" :disabled="resumingId === entry.run.id"
@click="onResume(entry, 'cancelled')">
{{ t('workflows.paused.resumeCancelled') }}
</button>
</div>
</li>
</ul>
</section>
<header class="runs-header">
<span>{{ t('workflows.runs.header', { count: runs.length }) }}</span>
<button class="btn-ghost" @click="reloadRuns">{{ t('workflows.refresh') }}</button>
</header>
<ul class="runs-list">
<li v-for="run in runs" :key="run.id" class="run-row" @click="loadRun(run.id)">
<div class="run-row-line">
<span class="run-state" :class="'state-' + run.state">{{ run.state }}</span>
<span class="run-time">{{ formatTime(run.startedAt) }}</span>
</div>
<div class="run-row-meta">
<span>{{ t('workflows.runs.runHash', { id: run.id }) }}</span>
<span v-if="run.triggeredBy">· {{ run.triggeredBy }}</span>
<span v-if="run.errorMessage" class="run-err">· {{ run.errorMessage }}</span>
</div>
</li>
<li v-if="!runs.length" class="runs-empty">{{ t('workflows.runs.empty') }}</li>
</ul>
<section v-if="runDetail" class="run-detail">
<div class="run-detail-title">{{ t('workflows.runs.detailTitle', { id: runDetail.run.id, state: runDetail.run.state }) }}</div>
<ol class="run-steps">
<li v-for="step in runDetail.steps" :key="step.id">
<span class="step-state" :class="'state-' + step.state">{{ step.state }}</span>
<span class="step-name">{{ step.stepName || t('workflows.unnamed') }}</span>
<span v-if="step.durationMs != null" class="step-duration">{{ step.durationMs }} ms</span>
<span v-if="step.errorMessage" class="step-err">{{ step.errorMessage }}</span>
</li>
</ol>
</section>
</aside>
</div>
</div>
</div>
@ -213,7 +214,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcConfirm } from '@/components/common/useConfirm'
import { ElMessage } from 'element-plus'
@ -234,9 +235,18 @@ import {
} from '@/api'
import type { Channel } from '@/types'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import WorkflowCanvas from '@/components/workflow/WorkflowCanvas.vue'
// Lazy-load the canvas + Monaco editor — each pulls a heavy vendor chunk
// (@vue-flow/* and monaco-editor respectively). Keeping them out of the
// route's main chunk means /settings/workflows starts as ~500 KB instead
// of 4 MB, and the Docker production build no longer needs the
// --max-old-space-size=6144 escape hatch.
const WorkflowCanvas = defineAsyncComponent(
() => import('@/components/workflow/WorkflowCanvas.vue'),
)
const WorkflowJsonEditor = defineAsyncComponent(
() => import('@/components/workflow/WorkflowJsonEditor.vue'),
)
import StepPropertyPanel from '@/components/workflow/StepPropertyPanel.vue'
import WorkflowJsonEditor from '@/components/workflow/WorkflowJsonEditor.vue'
import CreateWorkflowDialog from '@/components/workflow/CreateWorkflowDialog.vue'
import PublishDialog from '@/components/workflow/PublishDialog.vue'
import GenerateWorkflowDialog from '@/components/workflow/GenerateWorkflowDialog.vue'
@ -285,7 +295,13 @@ const templateChoice = ref('')
// the JSON) and the raw JSON editor. Canvas is the default — most
// authors visit the page to make sense of an existing flow rather
// than to type fresh JSON.
const editorTab = ref<'canvas' | 'json'>('canvas')
const editorTab = ref<'canvas' | 'json' | 'runs'>('canvas')
async function onSwitchToRuns() {
editorTab.value = 'runs'
await reloadRuns()
await reloadPausedRuns()
}
// The canvas reads from `draftJson` directly. We keep the model write
// path on the JSON editor only — the canvas is purely a derived view
@ -296,6 +312,12 @@ const canvasModel = computed({
})
const canvasSelection = ref<StepNodeData | null>(null)
// Mirrors the WorkflowCanvas's internal fullscreen flag so we can float
// the property inspector above the fixed-position overlay. Without this,
// clicking a node in fullscreen would emit a selection but the panel —
// rendered as a flex sibling outside the canvas — sits underneath the
// z-index: 2000 overlay and reads as "panel doesn't open".
const canvasFullscreen = ref(false)
function onCanvasSelect(payload: StepNodeData | null) {
canvasSelection.value = payload
}
@ -830,21 +852,53 @@ watch(workspaceId, async () => {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
flex: 1;
}
.workflows-page :deep(.mc-page-header) {
align-items: center;
margin-bottom: 18px;
flex-shrink: 0;
}
.workflows-page :deep(.header-actions) {
display: flex;
gap: 8px;
flex-shrink: 0;
align-self: center;
}
.workflows-grid {
display: grid;
grid-template-columns: 280px 1fr 320px;
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
min-height: 480px;
flex: 1;
min-height: 0;
min-width: 0;
}
.workflows-list,
.workflows-editor,
.workflows-runs,
.workflows-empty {
padding: 12px;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.06));
border-radius: 10px;
background: var(--mc-bg-elevated, rgba(255, 255, 255, 0.4));
}
.workflows-list {
/* Slightly recessed so the editor reads as primary surface. */
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.02));
}
.runs-pane {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 360px;
overflow-y: auto;
}
.list-header,
.runs-header {
@ -1146,7 +1200,7 @@ button:disabled {
display: flex;
flex-direction: row;
gap: 12px;
min-height: 420px;
min-height: 320px;
align-items: stretch;
}
.canvas-pane > .workflow-canvas {
@ -1166,6 +1220,40 @@ button:disabled {
max-height: 360px;
}
}
/* Fullscreen mode: the canvas itself becomes a fixed overlay
(z-index: 2000). Float the panel above it so the same flex sibling
stays interactive — the previous behaviour where the panel slipped
underneath the overlay made it look like the property panel had
broken when fullscreen was on.
bottom: 180px reserves room for vue-flow's MiniMap (~150px tall) at
the bottom-right corner. Without it, the panel covers the minimap
and the user loses overview navigation. The panel scrolls internally
when content exceeds the shortened height. */
.canvas-pane.canvas-fullscreen {
position: static;
}
.canvas-pane.canvas-fullscreen > .step-panel {
position: fixed;
top: 56px;
right: 14px;
bottom: 180px;
width: min(360px, 30vw);
max-height: none;
z-index: 2001;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.22);
overflow-y: auto;
}
@media (max-width: 760px) {
.canvas-pane.canvas-fullscreen > .step-panel {
top: auto;
left: 10px;
right: 10px;
bottom: 10px;
width: auto;
height: min(56vh, 420px);
}
}
.canvas-inspector {
background: var(--mc-bg-elevated, rgba(0, 0, 0, 0.02));
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.08));
@ -1221,7 +1309,7 @@ button:disabled {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 360px;
min-height: 280px;
}
.paused-section {
border-bottom: 1px dashed var(--mc-border-light, rgba(0, 0, 0, 0.08));
@ -1326,7 +1414,7 @@ button:disabled {
can breathe on tablets, and stack everything vertically below 720px
so the page is usable on a phone. The runs panel becomes a
collapsible details element on small screens. */
@media (max-width: 1100px) {
@media (max-width: 1300px) {
.workflows-grid {
grid-template-columns: 1fr;
}

View File

@ -1,6 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { visualizer } from 'rollup-plugin-visualizer'
import { resolve } from 'path'
export default defineConfig({
@ -17,6 +18,15 @@ export default defineConfig({
},
}),
tailwindcss(),
// ANALYZE=1 pnpm build writes dist/stats.html. Skipped on normal builds so
// CI artifact upload is opt-in and the Docker image build doesn't waste
// memory generating an HTML report it never reads.
process.env.ANALYZE && visualizer({
filename: 'dist/stats.html',
open: false,
gzipSize: true,
brotliSize: true,
}),
],
resolve: {
alias: {
@ -51,5 +61,33 @@ export default defineConfig({
build: {
outDir: '../mateclaw-server/src/main/resources/static',
emptyOutDir: true,
// Cap warning so a future barrel import (see history with monaco) trips
// the build log instead of slipping in silently.
chunkSizeWarningLimit: 1024,
rollupOptions: {
output: {
// Pin heavy vendor libs to named chunks. Two reasons:
// 1. Cache: bumping a business chunk doesn't invalidate the
// monaco / vue-flow bundles, so returning visitors only
// re-download the few KB that actually changed.
// 2. Rollup minify peak heap: keeping monaco out of the route
// chunk lets each Worker on the chunk run with a smaller
// working set, which is what blew up the Docker build at
// 1.3.0 (needed --max-old-space-size=6144 as a band-aid).
manualChunks: {
'vendor-monaco': ['monaco-editor', '@guolao/vue-monaco-editor'],
'vendor-vue-flow': [
'@vue-flow/core',
'@vue-flow/background',
'@vue-flow/controls',
'@vue-flow/minimap',
],
'vendor-mermaid': ['mermaid'],
'vendor-echarts': ['echarts'],
'vendor-element': ['element-plus', '@element-plus/icons-vue'],
'vendor-markdown': ['marked', 'marked-highlight', 'highlight.js', 'dompurify'],
},
},
},
},
})