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_CONTENTNodeStreamingChatHelper 已实时广播
// 给前端 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'],
},
},
},
},
})