mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 22:16:58 +08:00
feat(chat): render structured MCP tool results (#651)
This commit is contained in:
parent
e8e227b54d
commit
a1f1a6f841
@ -5,6 +5,7 @@ import vip.mate.agent.graph.state.MateClawStateKeys;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Graph 事件发布工具
|
* Graph 事件发布工具
|
||||||
@ -114,19 +115,20 @@ public final class GraphEventPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success) {
|
public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success) {
|
||||||
|
return toolComplete(toolCallId, toolName, result, success, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GraphEvent toolComplete(String toolCallId, String toolName, String result, boolean success,
|
||||||
|
Map<String, Object> structuredContent) {
|
||||||
long ts = System.currentTimeMillis();
|
long ts = System.currentTimeMillis();
|
||||||
// Carry the full tool result; transport-layer chunking lives in
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
// ChatStreamTracker.broadcastChunked, which splits oversize payloads
|
data.put("toolCallId", toolCallId != null ? toolCallId : "");
|
||||||
// into ordered tool_result_chunk events when they exceed the 8 KB
|
data.put("toolName", toolName);
|
||||||
// single-event budget. The previous unconditional 500-char truncation
|
data.put("result", result != null ? result : "");
|
||||||
// here destroyed data that the front-end could otherwise render in full.
|
data.put("success", success);
|
||||||
return new GraphEvent(EVENT_TOOL_COMPLETE, Map.of(
|
data.put("timestamp", ts);
|
||||||
"toolCallId", toolCallId != null ? toolCallId : "",
|
if (success && structuredContent != null) data.put("structuredContent", structuredContent);
|
||||||
"toolName", toolName,
|
return new GraphEvent(EVENT_TOOL_COMPLETE, data, ts);
|
||||||
"result", result != null ? result : "",
|
|
||||||
"success", success,
|
|
||||||
"timestamp", ts
|
|
||||||
), ts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static GraphEvent planCreated(Long planId, List<String> steps) {
|
public static GraphEvent planCreated(Long planId, List<String> steps) {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
|
|||||||
import vip.mate.tool.mcp.runtime.McpProgressContext;
|
import vip.mate.tool.mcp.runtime.McpProgressContext;
|
||||||
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
|
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
|
||||||
import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
|
import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
|
||||||
|
import vip.mate.tool.mcp.runtime.McpToolResultCapture;
|
||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.agent.GraphEventPublisher;
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
@ -249,6 +250,18 @@ public class ToolExecutionExecutor {
|
|||||||
: result;
|
: result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Avoid requesting a second copy of cards already supplied through the display channel. */
|
||||||
|
static String withProductCardDirective(String toolName, String result, Map<String, Object> structured) {
|
||||||
|
if (structured != null && structured.get("mateclawUi") instanceof Map<?, ?> ui
|
||||||
|
&& Integer.valueOf(1).equals(ui.get("version")) && ui.get("blocks") instanceof List<?> blocks
|
||||||
|
&& blocks.size() <= 8 && blocks.stream().anyMatch(block -> block instanceof Map<?, ?> value
|
||||||
|
&& "product-cards".equals(value.get("type")) && value.get("data") instanceof List<?> products
|
||||||
|
&& !products.isEmpty() && products.size() <= 24)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return withProductCardDirective(toolName, result);
|
||||||
|
}
|
||||||
|
|
||||||
private final Map<String, ToolCallback> toolCallbackMap;
|
private final Map<String, ToolCallback> toolCallbackMap;
|
||||||
/**
|
/**
|
||||||
* Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function`
|
* Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function`
|
||||||
@ -854,7 +867,8 @@ public class ToolExecutionExecutor {
|
|||||||
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
||||||
replayOrigin = MemberFileIsolation.scope(replayOrigin);
|
replayOrigin = MemberFileIsolation.scope(replayOrigin);
|
||||||
if (MemberFileIsolation.isEnabled()) workspaceBasePath = replayOrigin.workspaceBasePath();
|
if (MemberFileIsolation.isEnabled()) workspaceBasePath = replayOrigin.workspaceBasePath();
|
||||||
String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
|
McpToolResultCapture resultCapture = new McpToolResultCapture();
|
||||||
|
String result = invokeObserved(callback, callArguments, resultCapture.attach(toolContextWithScopedCatalog(replayOrigin)),
|
||||||
UUID.randomUUID().toString(), toolCall.id());
|
UUID.randomUUID().toString(), toolCall.id());
|
||||||
throwIfStopRequested(conversationId);
|
throwIfStopRequested(conversationId);
|
||||||
int rawLen = result != null ? result.length() : 0;
|
int rawLen = result != null ? result.length() : 0;
|
||||||
@ -879,7 +893,8 @@ public class ToolExecutionExecutor {
|
|||||||
// deliberately used here: the full result remains confined to
|
// deliberately used here: the full result remains confined to
|
||||||
// tool_direct_result / DIRECT_TOOL_OUTPUTS.
|
// tool_direct_result / DIRECT_TOOL_OUTPUTS.
|
||||||
events.add(GraphEventPublisher.toolComplete(
|
events.add(GraphEventPublisher.toolComplete(
|
||||||
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
|
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, !resultCapture.isError(),
|
||||||
|
resultCapture.structuredContent()));
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||||
}
|
}
|
||||||
@ -893,11 +908,12 @@ public class ToolExecutionExecutor {
|
|||||||
result, toolName, toolCall.id(), conversationId, workspaceBasePath);
|
result, toolName, toolCall.id(), conversationId, workspaceBasePath);
|
||||||
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
log.info("[ToolExecutor] Pre-approved tool {} returned {} chars{}", toolName, rawLen,
|
||||||
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, true));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, result, !resultCapture.isError(),
|
||||||
|
resultCapture.structuredContent()));
|
||||||
// Append the card-rendering directive to the LLM-facing response only,
|
// Append the card-rendering directive to the LLM-facing response only,
|
||||||
// leaving the broadcast tool-result panel unchanged.
|
// leaving the broadcast tool-result panel unchanged.
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
|
toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : "", resultCapture.structuredContent()));
|
||||||
} catch (CancellationException e) {
|
} catch (CancellationException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -1084,13 +1100,14 @@ public class ToolExecutionExecutor {
|
|||||||
// not yet migrated to ToolContext keep working unchanged.
|
// not yet migrated to ToolContext keep working unchanged.
|
||||||
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
|
ToolExecutionContext.set(pc.conversationId, pc.requesterId, pc.workspaceBasePath);
|
||||||
String result;
|
String result;
|
||||||
|
McpToolResultCapture resultCapture = new McpToolResultCapture();
|
||||||
String progressToken = null;
|
String progressToken = null;
|
||||||
try {
|
try {
|
||||||
ChatOrigin runtimeOrigin = pc.origin != null ? pc.origin : ChatOrigin.EMPTY;
|
ChatOrigin runtimeOrigin = pc.origin != null ? pc.origin : ChatOrigin.EMPTY;
|
||||||
runtimeOrigin = runtimeOrigin
|
runtimeOrigin = runtimeOrigin
|
||||||
.withConversationId(pc.conversationId)
|
.withConversationId(pc.conversationId)
|
||||||
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
||||||
ToolContext toolContext = toolContextWithScopedCatalog(runtimeOrigin);
|
ToolContext toolContext = resultCapture.attach(toolContextWithScopedCatalog(runtimeOrigin));
|
||||||
|
|
||||||
// MCP progress: generate progressToken and inject into ToolContext
|
// MCP progress: generate progressToken and inject into ToolContext
|
||||||
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
|
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
|
||||||
@ -1138,11 +1155,11 @@ public class ToolExecutionExecutor {
|
|||||||
streamTracker.broadcastObject(pc.conversationId,
|
streamTracker.broadcastObject(pc.conversationId,
|
||||||
GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
||||||
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName,
|
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName,
|
||||||
DIRECT_TOOL_PLACEHOLDER, true).data());
|
DIRECT_TOOL_PLACEHOLDER, !resultCapture.isError(), resultCapture.structuredContent()).data());
|
||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
events.add(GraphEventPublisher.toolComplete(
|
events.add(GraphEventPublisher.toolComplete(
|
||||||
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true));
|
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, !resultCapture.isError(), resultCapture.structuredContent()));
|
||||||
// Placeholder keeps the tool_call_id ↔ tool_response pairing valid
|
// Placeholder keeps the tool_call_id ↔ tool_response pairing valid
|
||||||
// for OpenAI-compatible providers, while withholding the data from
|
// for OpenAI-compatible providers, while withholding the data from
|
||||||
// any subsequent LLM round (the graph won't take a next round —
|
// any subsequent LLM round (the graph won't take a next round —
|
||||||
@ -1179,17 +1196,17 @@ public class ToolExecutionExecutor {
|
|||||||
result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath);
|
result, toolName, pc.toolCall.id(), pc.conversationId, pc.workspaceBasePath);
|
||||||
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
|
log.info("[ToolExecutor] Tool {} returned {} chars{}", toolName, rawLen,
|
||||||
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
result != null && result.length() < rawLen ? " (now " + result.length() + " after spill/truncate)" : "");
|
||||||
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true));
|
events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, !resultCapture.isError(), resultCapture.structuredContent()));
|
||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE,
|
||||||
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, true).data());
|
GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, result, !resultCapture.isError(), resultCapture.structuredContent()).data());
|
||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
// Append the card-rendering directive to the LLM-facing response only,
|
// Append the card-rendering directive to the LLM-facing response only,
|
||||||
// leaving the broadcast tool-result panel unchanged.
|
// leaving the broadcast tool-result panel unchanged.
|
||||||
return new ToolResponseMessage.ToolResponse(
|
return new ToolResponseMessage.ToolResponse(
|
||||||
pc.toolCall.id(), pc.responseName,
|
pc.toolCall.id(), pc.responseName,
|
||||||
withProductCardDirective(toolName, result != null ? result : ""));
|
withProductCardDirective(toolName, result != null ? result : "", resultCapture.structuredContent()));
|
||||||
} catch (CancellationException e) {
|
} catch (CancellationException e) {
|
||||||
if (streamTracker != null) {
|
if (streamTracker != null) {
|
||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
|
|||||||
@ -344,6 +344,9 @@ public final class AgentStreamAccumulator {
|
|||||||
&& toolName.equals(tc.get("name")));
|
&& toolName.equals(tc.get("name")));
|
||||||
if (matches) {
|
if (matches) {
|
||||||
tc.put("result", data.getOrDefault("result", ""));
|
tc.put("result", data.getOrDefault("result", ""));
|
||||||
|
if (data.get("structuredContent") instanceof Map<?, ?> structured) {
|
||||||
|
tc.put("structuredContent", structured);
|
||||||
|
}
|
||||||
tc.put("success", data.getOrDefault("success", true));
|
tc.put("success", data.getOrDefault("success", true));
|
||||||
tc.put("status", "completed");
|
tc.put("status", "completed");
|
||||||
break;
|
break;
|
||||||
@ -362,6 +365,9 @@ public final class AgentStreamAccumulator {
|
|||||||
seg.put("status", "completed");
|
seg.put("status", "completed");
|
||||||
seg.put("endTimestamp", System.currentTimeMillis());
|
seg.put("endTimestamp", System.currentTimeMillis());
|
||||||
seg.put("toolResult", data.getOrDefault("result", ""));
|
seg.put("toolResult", data.getOrDefault("result", ""));
|
||||||
|
if (data.get("structuredContent") instanceof Map<?, ?> structured) {
|
||||||
|
seg.put("structuredContent", structured);
|
||||||
|
}
|
||||||
seg.put("toolSuccess", data.getOrDefault("success", true));
|
seg.put("toolSuccess", data.getOrDefault("success", true));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
package vip.mate.tool.mcp.runtime;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Per-invocation structured output, separate from the model-facing text budget. */
|
||||||
|
public final class McpToolResultCapture {
|
||||||
|
public static final String CONTEXT_KEY = "_mcp_tool_result_capture";
|
||||||
|
public static final int MAX_STRUCTURED_BYTES = 100 * 1024;
|
||||||
|
|
||||||
|
private volatile Map<String, Object> structuredContent;
|
||||||
|
private volatile boolean error;
|
||||||
|
|
||||||
|
public ToolContext attach(ToolContext context) {
|
||||||
|
Map<String, Object> values = new HashMap<>(context.getContext());
|
||||||
|
values.put(CONTEXT_KEY, this);
|
||||||
|
return new ToolContext(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static McpToolResultCapture from(ToolContext context) {
|
||||||
|
if (context == null) return null;
|
||||||
|
Object value = context.getContext().get(CONTEXT_KEY);
|
||||||
|
return value instanceof McpToolResultCapture capture ? capture : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void capture(McpSchema.CallToolResult result, ObjectMapper mapper) {
|
||||||
|
structuredContent = null;
|
||||||
|
error = result != null && Boolean.TRUE.equals(result.isError());
|
||||||
|
if (result == null || error || !(result.structuredContent() instanceof Map<?, ?>)) return;
|
||||||
|
try {
|
||||||
|
byte[] json = mapper.writeValueAsBytes(result.structuredContent());
|
||||||
|
if (json.length <= MAX_STRUCTURED_BYTES) {
|
||||||
|
// Detach the JSON tree from the client's mutable response object.
|
||||||
|
structuredContent = mapper.readValue(json, new TypeReference<Map<String, Object>>() { });
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// A display-only payload must never retry a completed remote tool.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> structuredContent() { return structuredContent; }
|
||||||
|
public boolean isError() { return error; }
|
||||||
|
}
|
||||||
@ -11,7 +11,6 @@ import org.springframework.ai.tool.definition.ToolDefinition;
|
|||||||
import org.springframework.ai.tool.metadata.ToolMetadata;
|
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP tool callback wrapper that injects {@code _meta.progressToken} into
|
* MCP tool callback wrapper that injects {@code _meta.progressToken} into
|
||||||
@ -20,8 +19,9 @@ import java.util.UUID;
|
|||||||
*
|
*
|
||||||
* <p>When {@code MCP_PROGRESS_TOKEN} is present in {@link ToolContext}, this wrapper
|
* <p>When {@code MCP_PROGRESS_TOKEN} is present in {@link ToolContext}, this wrapper
|
||||||
* calls {@link McpSyncClient#callTool(McpSchema.CallToolRequest)} directly with the
|
* calls {@link McpSyncClient#callTool(McpSchema.CallToolRequest)} directly with the
|
||||||
* progressToken injected. Otherwise it delegates to the original callback
|
* progressToken injected. A per-call result capture also selects this path so
|
||||||
* (compatible with MCP Servers that do not support progress, and with built-in tools).
|
* structured content can reach the UI without a model rewriting it. With neither
|
||||||
|
* context value present, it delegates to the original callback.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public final class ProgressAwareMcpToolCallback implements ToolCallback {
|
public final class ProgressAwareMcpToolCallback implements ToolCallback {
|
||||||
@ -66,9 +66,11 @@ public final class ProgressAwareMcpToolCallback implements ToolCallback {
|
|||||||
progressToken = s;
|
progressToken = s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (progressToken == null) {
|
McpToolResultCapture capture = McpToolResultCapture.from(toolContext);
|
||||||
|
if (progressToken == null && capture == null) {
|
||||||
return delegate.call(toolInput, toolContext);
|
return delegate.call(toolInput, toolContext);
|
||||||
}
|
}
|
||||||
|
McpSchema.CallToolResult result;
|
||||||
try {
|
try {
|
||||||
// Apply identity forwarding BEFORE building CallToolRequest —
|
// Apply identity forwarding BEFORE building CallToolRequest —
|
||||||
// otherwise the progress path would silently bypass identity injection.
|
// otherwise the progress path would silently bypass identity injection.
|
||||||
@ -80,15 +82,18 @@ public final class ProgressAwareMcpToolCallback implements ToolCallback {
|
|||||||
McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder()
|
McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder()
|
||||||
.name(rawToolName)
|
.name(rawToolName)
|
||||||
.arguments(arguments != null ? arguments : Map.of())
|
.arguments(arguments != null ? arguments : Map.of())
|
||||||
.meta(Map.of("progressToken", progressToken))
|
.meta(progressToken != null ? Map.of("progressToken", progressToken) : Map.of())
|
||||||
.build();
|
.build();
|
||||||
McpSchema.CallToolResult result = mcpClient.callTool(request);
|
result = mcpClient.callTool(request);
|
||||||
return serializeResult(result);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
// The structured path executes exactly once; replaying could duplicate side effects.
|
||||||
|
if (capture != null) throw new IllegalStateException("MCP tool call failed", e);
|
||||||
log.warn("Progress-aware MCP call failed for tool '{}', falling back to delegate: {}",
|
log.warn("Progress-aware MCP call failed for tool '{}', falling back to delegate: {}",
|
||||||
rawToolName, e.getMessage());
|
rawToolName, e.getMessage());
|
||||||
return delegate.call(toolInput, toolContext);
|
return delegate.call(toolInput, toolContext);
|
||||||
}
|
}
|
||||||
|
if (capture != null) capture.capture(result, objectMapper);
|
||||||
|
return serializeResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> parseArguments(String toolInput) {
|
private Map<String, Object> parseArguments(String toolInput) {
|
||||||
@ -103,7 +108,14 @@ public final class ProgressAwareMcpToolCallback implements ToolCallback {
|
|||||||
|
|
||||||
private String serializeResult(McpSchema.CallToolResult result) {
|
private String serializeResult(McpSchema.CallToolResult result) {
|
||||||
if (result == null) return "";
|
if (result == null) return "";
|
||||||
if (result.content() == null || result.content().isEmpty()) return "";
|
if (result.content() == null || result.content().isEmpty()) {
|
||||||
|
if (result.structuredContent() == null) return "";
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(result.structuredContent());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return "[Structured tool result could not be serialized]";
|
||||||
|
}
|
||||||
|
}
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
for (var content : result.content()) {
|
for (var content : result.content()) {
|
||||||
if (content instanceof McpSchema.TextContent tc) {
|
if (content instanceof McpSchema.TextContent tc) {
|
||||||
|
|||||||
@ -573,3 +573,77 @@ Subprocesses are cleaned up on normal shutdown. If MateClaw was force-killed (`k
|
|||||||
- [Tools](./tools) — how MCP tools relate to built-in tools
|
- [Tools](./tools) — how MCP tools relate to built-in tools
|
||||||
- [Skills](./skills) — MCP-backed skills
|
- [Skills](./skills) — MCP-backed skills
|
||||||
- [Configuration](./config) — full configuration reference
|
- [Configuration](./config) — full configuration reference
|
||||||
|
|
||||||
|
## Structured charts, tables and product cards
|
||||||
|
|
||||||
|
The web console can render a successful MCP tool's `structuredContent` directly, without asking the model to rewrite it into Markdown. Results stay attached to the tool call and are restored when reopening the conversation. Existing text responses remain supported.
|
||||||
|
|
||||||
|
Return a normal MCP `tools/call` result with a text summary and the following application-specific UI envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": [{ "type": "text", "text": "Quarterly sales: Q1 120, Q2 180." }],
|
||||||
|
"structuredContent": {
|
||||||
|
"mateclawUi": {
|
||||||
|
"version": 1,
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "echarts",
|
||||||
|
"title": "Quarterly sales",
|
||||||
|
"data": {
|
||||||
|
"xAxis": { "type": "category", "data": ["Q1", "Q2"] },
|
||||||
|
"yAxis": { "type": "value" },
|
||||||
|
"series": [{ "type": "bar", "data": [120, 180] }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"columns": [{ "key": "quarter", "label": "Quarter" }, { "key": "sales", "label": "Sales" }],
|
||||||
|
"rows": [{ "quarter": "Q1", "sales": 120 }, { "quarter": "Q2", "sales": 180 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "product-cards",
|
||||||
|
"data": [{ "name": "Example product", "price": 99, "imageUrl": "https://example.com/product.png", "url": "https://example.com/product" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isError": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`structuredContent` is an MCP field; **`mateclawUi` is MateClaw's versioned rendering contract**, not an AG-UI event format or an MCP Apps UI resource. This implementation takes inspiration from [registered tool renderers](https://github.com/CopilotKit/CopilotKit) and [MCP Apps structured results](https://github.com/modelcontextprotocol/ext-apps), while retaining the existing SSE transport. It does not load third-party HTML or MCP Apps iframes.
|
||||||
|
|
||||||
|
Supported block types:
|
||||||
|
|
||||||
|
| Type | `data` | Limits |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `echarts` | ECharts option object with a `series` object or nonempty array | Declarative options only; formatters, executable code, HTML tooltips, navigation and image hooks are removed |
|
||||||
|
| `table` | `columns: [{key, label}]`, `rows: object[]` | 1–32 columns, at most 200 rows; text is escaped |
|
||||||
|
| `product-cards` | Array of products with `name`; optional `price`, `originalPrice`, `lowestPrice`, `url`, `imageUrl`, `platformLabel`, `shopName`, `purchaseAdvice` | At most 24 products; only valid HTTP(S) links and images |
|
||||||
|
|
||||||
|
A response supports at most 8 blocks. The complete `structuredContent` must fit within 100 KiB of UTF-8 JSON. Larger structured payloads are omitted from the display channel; provide a useful text summary in `content`. Unknown versions/types or invalid block data fall back to escaped JSON. `isError: true` marks the tool as failed and suppresses rich rendering. The UI does not receive the result's top-level `_meta`.
|
||||||
|
|
||||||
|
The backend adds `structuredContent` to `tool_call_completed` alongside `toolCallId`, `toolName`, `result` and `success`. The same data is stored in message metadata segments. Model text truncation does not truncate this separate payload. Tools configured to return directly still keep their output out of subsequent model context. Prefer supplying `content` for a concise model-facing summary; structured-only tools use serialized JSON as the text fallback.
|
||||||
|
|
||||||
|
### Register a custom renderer
|
||||||
|
|
||||||
|
Trusted frontend code can register another Vue component during application startup:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import InventoryCard from './InventoryCard.vue'
|
||||||
|
import { registerToolResultRenderer } from '@/components/chat/tool-results/registry'
|
||||||
|
|
||||||
|
registerToolResultRenderer('inventory', {
|
||||||
|
component: InventoryCard,
|
||||||
|
validate: (data: unknown) => {
|
||||||
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return false
|
||||||
|
const value = data as Record<string, unknown>
|
||||||
|
return typeof value.name === 'string' && typeof value.quantity === 'number'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The component receives a `data` prop. A tool can then return `{ "type": "inventory", "data": { "name": "Example", "quantity": 10 } }` inside `blocks`. Registration requires a frontend rebuild; tool responses can only name registered types and cannot import components or execute scripts. Custom components must validate their schema and safely render untrusted values.
|
||||||
|
|||||||
@ -540,3 +540,77 @@ user = claims["sub"] # 验签通过才相信
|
|||||||
- [工具系统](./tools)——MCP 工具和内置工具的关系
|
- [工具系统](./tools)——MCP 工具和内置工具的关系
|
||||||
- [技能系统](./skills)——MCP 支撑的技能
|
- [技能系统](./skills)——MCP 支撑的技能
|
||||||
- [配置说明](./config)——完整配置参考
|
- [配置说明](./config)——完整配置参考
|
||||||
|
|
||||||
|
## 工具结果直接展示图表、表格与商品卡片
|
||||||
|
|
||||||
|
Web 控制台支持直接渲染 MCP 工具成功返回的 `structuredContent`,无需模型重新包装为 Markdown。展示内容绑定到对应工具调用,重新打开会话时也能恢复;原有文本回复继续兼容。
|
||||||
|
|
||||||
|
MCP 的 `tools/call` 返回值可同时提供文字摘要和下面的界面数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": [{ "type": "text", "text": "季度销量:第一季度 120,第二季度 180。" }],
|
||||||
|
"structuredContent": {
|
||||||
|
"mateclawUi": {
|
||||||
|
"version": 1,
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "echarts",
|
||||||
|
"title": "季度销量",
|
||||||
|
"data": {
|
||||||
|
"xAxis": { "type": "category", "data": ["第一季度", "第二季度"] },
|
||||||
|
"yAxis": { "type": "value" },
|
||||||
|
"series": [{ "type": "bar", "data": [120, 180] }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"columns": [{ "key": "quarter", "label": "季度" }, { "key": "sales", "label": "销量" }],
|
||||||
|
"rows": [{ "quarter": "第一季度", "sales": 120 }, { "quarter": "第二季度", "sales": 180 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "product-cards",
|
||||||
|
"data": [{ "name": "示例商品", "price": 99, "imageUrl": "https://example.com/product.png", "url": "https://example.com/product" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isError": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`structuredContent` 是 MCP 标准字段;**`mateclawUi` 是 MateClaw 的版本化渲染约定**,并非 AG-UI 事件格式或 MCP Apps 界面资源。本实现参考 [工具组件注册机制](https://github.com/CopilotKit/CopilotKit) 与 [MCP Apps 的结构化结果设计](https://github.com/modelcontextprotocol/ext-apps),沿用现有 SSE 通道,暂不加载第三方 HTML 或 MCP Apps iframe。
|
||||||
|
|
||||||
|
支持的组件:
|
||||||
|
|
||||||
|
| 类型 | `data` 格式 | 约束 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `echarts` | 包含 `series` 对象或非空数组的 ECharts option 对象 | 仅支持声明式配置;移除 formatter、可执行代码、HTML 提示框、跳转和图片钩子 |
|
||||||
|
| `table` | `columns: [{key, label}]`、`rows: object[]` | 1–32 列、最多 200 行,内容按文本转义 |
|
||||||
|
| `product-cards` | 商品数组,必填 `name`;可选 `price`、`originalPrice`、`lowestPrice`、`url`、`imageUrl`、`platformLabel`、`shopName`、`purchaseAdvice` | 最多 24 个商品;链接和图片仅允许有效 HTTP(S) URL |
|
||||||
|
|
||||||
|
一次最多 8 个组件;完整 `structuredContent` 的 UTF-8 JSON 不得超过 100 KiB。超限时不发送结构化展示数据,请在 `content` 中保留有用的文字摘要。未知版本、未知类型或不合法数据回退为转义后的 JSON。`isError: true` 会将工具标为失败,并禁止富内容展示。结果顶层 `_meta` 不发送至 UI。
|
||||||
|
|
||||||
|
后端在原有 `tool_call_completed` 事件的 `toolCallId`、`toolName`、`result`、`success` 之外增加 `structuredContent`,同时保存在消息元数据的工具片段中。模型文本截断不影响这份独立数据。“直接返回用户”的工具继续将结果隔离于后续模型上下文。建议通过 `content` 提供简洁摘要;只有结构化结果时,序列化的 JSON 会作为文字回退。
|
||||||
|
|
||||||
|
### 自定义前端渲染器
|
||||||
|
|
||||||
|
可信前端代码可在应用初始化时注册 Vue 组件:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import InventoryCard from './InventoryCard.vue'
|
||||||
|
import { registerToolResultRenderer } from '@/components/chat/tool-results/registry'
|
||||||
|
|
||||||
|
registerToolResultRenderer('inventory', {
|
||||||
|
component: InventoryCard,
|
||||||
|
validate: (data: unknown) => {
|
||||||
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return false
|
||||||
|
const value = data as Record<string, unknown>
|
||||||
|
return typeof value.name === 'string' && typeof value.quantity === 'number'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
组件通过 `data` prop 接收数据。工具随后可以在 `blocks` 中返回 `{ "type": "inventory", "data": { "name": "示例", "quantity": 10 } }`。注册新组件需要重新构建前端;工具返回值只能选择已注册类型,不能导入组件或执行脚本。自定义组件需校验自身数据结构,并安全处理不可信内容。
|
||||||
|
|||||||
@ -0,0 +1,186 @@
|
|||||||
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.modelcontextprotocol.client.McpSyncClient;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||||
|
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||||
|
import vip.mate.agent.AgentToolSet;
|
||||||
|
import vip.mate.agent.GraphEventPublisher;
|
||||||
|
import vip.mate.tool.guard.ToolGuardResult;
|
||||||
|
import vip.mate.tool.mcp.runtime.ProgressAwareMcpToolCallback;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CyclicBarrier;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class ToolExecutionExecutorStructuredResultTest {
|
||||||
|
private static final Map<String, Object> STRUCTURED = Map.of("mateclawUi", Map.of(
|
||||||
|
"version", 1, "blocks", List.of(Map.of("type", "echarts", "data", Map.of(
|
||||||
|
"series", List.of(Map.of("type", "bar", "data", List.of(1, 2))))))));
|
||||||
|
private final McpSyncClient client = mock(McpSyncClient.class);
|
||||||
|
|
||||||
|
private ToolExecutionExecutor executor(boolean direct) {
|
||||||
|
return executor(direct, "chart");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ToolExecutionExecutor executor(boolean direct, String toolName) {
|
||||||
|
ToolCallback delegate = mock(ToolCallback.class);
|
||||||
|
when(delegate.getToolDefinition()).thenReturn(ToolDefinition.builder()
|
||||||
|
.name(toolName).description("Chart").inputSchema("{}").build());
|
||||||
|
when(delegate.getToolMetadata()).thenReturn(ToolMetadata.builder().returnDirect(direct).build());
|
||||||
|
ToolCallback callback = new ProgressAwareMcpToolCallback(delegate, client, toolName, new ObjectMapper());
|
||||||
|
return new ToolExecutionExecutor(AgentToolSet.fromCallbacks(List.of(), List.of(callback)),
|
||||||
|
(name, args) -> ToolGuardResult.allow(), null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AssistantMessage.ToolCall call(String id) {
|
||||||
|
return new AssistantMessage.ToolCall(id, "function", "chart", "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendsStructuredPayloadEvenWithoutProgressAndBeforeTextTruncation() {
|
||||||
|
when(client.callTool(any())).thenReturn(McpSchema.CallToolResult.builder()
|
||||||
|
.addTextContent("summary ".repeat(12000)).structuredContent(STRUCTURED).build());
|
||||||
|
var result = executor(false).execute(List.of(call("chart-1")), "conv", "agent", false, "user", null);
|
||||||
|
var event = result.events().stream().filter(e -> e.type().equals(GraphEventPublisher.EVENT_TOOL_COMPLETE))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals(STRUCTURED, event.data().get("structuredContent"));
|
||||||
|
assertTrue(result.responses().getFirst().responseData().length() < 96000);
|
||||||
|
assertFalse(result.responses().getFirst().responseData().contains("mateclawUi"));
|
||||||
|
verify(client, times(1)).callTool(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void structuredOnlyToolHasReadableModelFallback() {
|
||||||
|
when(client.callTool(any())).thenReturn(McpSchema.CallToolResult.builder().structuredContent(STRUCTURED).build());
|
||||||
|
var result = executor(false).execute(List.of(call("chart-1")), "conv", "agent", false, "user", null);
|
||||||
|
assertTrue(result.responses().getFirst().responseData().contains("mateclawUi"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void directToolKeepsStructuredDataOutOfModelResponse() {
|
||||||
|
when(client.callTool(any())).thenReturn(McpSchema.CallToolResult.builder()
|
||||||
|
.addTextContent("summary").structuredContent(STRUCTURED).meta(Map.of("secret", "hidden")).build());
|
||||||
|
var result = executor(true).execute(List.of(call("chart-1")), "conv", "agent", false, "user", null);
|
||||||
|
var event = result.events().stream().filter(e -> e.type().equals(GraphEventPublisher.EVENT_TOOL_COMPLETE))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals(STRUCTURED, event.data().get("structuredContent"));
|
||||||
|
assertFalse(event.data().toString().contains("hidden"));
|
||||||
|
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, result.responses().getFirst().responseData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void errorResultIsNotAdvertisedAsSuccessfulRichContent() {
|
||||||
|
when(client.callTool(any())).thenReturn(McpSchema.CallToolResult.builder()
|
||||||
|
.addTextContent("query failed").structuredContent(STRUCTURED).isError(true).build());
|
||||||
|
var result = executor(false).execute(List.of(call("chart-1")), "conv", "agent", false, "user", null);
|
||||||
|
var event = result.events().stream().filter(e -> e.type().equals(GraphEventPublisher.EVENT_TOOL_COMPLETE))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals(false, event.data().get("success"));
|
||||||
|
assertFalse(event.data().containsKey("structuredContent"));
|
||||||
|
assertEquals("query failed", result.responses().getFirst().responseData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oversizedStructuredResultUsesTextFallback() {
|
||||||
|
when(client.callTool(any())).thenReturn(McpSchema.CallToolResult.builder()
|
||||||
|
.addTextContent("summary").structuredContent(Map.of("text", "汉".repeat(40000))).build());
|
||||||
|
var result = executor(false).execute(List.of(call("chart-1")), "conv", "agent", false, "user", null);
|
||||||
|
var event = result.events().stream().filter(e -> e.type().equals(GraphEventPublisher.EVENT_TOOL_COMPLETE))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertFalse(event.data().containsKey("structuredContent"));
|
||||||
|
assertEquals("summary", event.data().get("result"));
|
||||||
|
verify(client, times(1)).callTool(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approvedReplayPreservesStructuredContent() {
|
||||||
|
when(client.callTool(any())).thenReturn(McpSchema.CallToolResult.builder()
|
||||||
|
.addTextContent("summary").structuredContent(STRUCTURED).build());
|
||||||
|
var events = new ArrayList<GraphEventPublisher.GraphEvent>();
|
||||||
|
executor(false).executePreApproved(call("chart-1"), "{}", events, "conv", null);
|
||||||
|
assertEquals(STRUCTURED, events.getLast().data().get("structuredContent"));
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void consecutiveCallsDoNotReusePriorStructuredOutput() {
|
||||||
|
when(client.callTool(any())).thenReturn(
|
||||||
|
McpSchema.CallToolResult.builder().addTextContent("first").structuredContent(STRUCTURED).build(),
|
||||||
|
McpSchema.CallToolResult.builder().addTextContent("second").build());
|
||||||
|
var executor = executor(false);
|
||||||
|
var first = executor.execute(List.of(call("first")), "conv", "agent", false, "user", null);
|
||||||
|
var second = executor.execute(List.of(call("second")), "conv", "agent", false, "user", null);
|
||||||
|
assertTrue(first.events().stream().anyMatch(e -> e.data().containsKey("structuredContent")));
|
||||||
|
assertFalse(second.events().stream().anyMatch(e -> e.data().containsKey("structuredContent")));
|
||||||
|
assertEquals("second", second.responses().getFirst().responseData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedRemoteCallIsNotRetriedViaDelegate() {
|
||||||
|
when(client.callTool(any())).thenThrow(new IllegalStateException("lost connection"));
|
||||||
|
var result = executor(false).execute(List.of(call("first")), "conv", "agent", false, "user", null);
|
||||||
|
var event = result.events().stream().filter(e -> e.type().equals(GraphEventPublisher.EVENT_TOOL_COMPLETE))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals(false, event.data().get("success"));
|
||||||
|
verify(client, times(1)).callTool(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void simultaneousCallsKeepTheirOwnStructuredResult() throws Exception {
|
||||||
|
var barrier = new CyclicBarrier(2);
|
||||||
|
when(client.callTool(any())).thenAnswer(invocation -> {
|
||||||
|
McpSchema.CallToolRequest request = invocation.getArgument(0);
|
||||||
|
barrier.await(5, TimeUnit.SECONDS);
|
||||||
|
return McpSchema.CallToolResult.builder().addTextContent("summary")
|
||||||
|
.structuredContent(Map.of("owner", request.arguments().get("owner"))).build();
|
||||||
|
});
|
||||||
|
var executor = executor(false);
|
||||||
|
var first = CompletableFuture.supplyAsync(() -> executor.execute(List.of(
|
||||||
|
new AssistantMessage.ToolCall("first", "function", "chart", "{\"owner\":\"first\"}")),
|
||||||
|
"conv-a", "agent", false, "user", null));
|
||||||
|
var second = CompletableFuture.supplyAsync(() -> executor.execute(List.of(
|
||||||
|
new AssistantMessage.ToolCall("second", "function", "chart", "{\"owner\":\"second\"}")),
|
||||||
|
"conv-b", "agent", false, "user", null));
|
||||||
|
for (var pair : Map.of("first", first, "second", second).entrySet()) {
|
||||||
|
var event = pair.getValue().get(10, TimeUnit.SECONDS).events().stream()
|
||||||
|
.filter(e -> e.type().equals(GraphEventPublisher.EVENT_TOOL_COMPLETE)).findFirst().orElseThrow();
|
||||||
|
assertEquals(pair.getKey(), event.data().get("toolCallId"));
|
||||||
|
assertEquals(Map.of("owner", pair.getKey()), event.data().get("structuredContent"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approvedDirectReplayKeepsStructuredDataOutOfModel() {
|
||||||
|
when(client.callTool(any())).thenReturn(McpSchema.CallToolResult.builder()
|
||||||
|
.addTextContent("summary").structuredContent(STRUCTURED).build());
|
||||||
|
var events = new ArrayList<GraphEventPublisher.GraphEvent>();
|
||||||
|
var response = executor(true).executePreApproved(call("chart-1"), "{}", events, "conv", null);
|
||||||
|
assertEquals(STRUCTURED, events.getLast().data().get("structuredContent"));
|
||||||
|
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, response.responseData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void structuredProductCardsDoNotAskModelToRenderDuplicateCards() {
|
||||||
|
String name = "mcp_1_ckjia_shopping_recom";
|
||||||
|
String text = "recommendations: one product";
|
||||||
|
var cards = Map.of("mateclawUi", Map.of("version", 1, "blocks", List.of(
|
||||||
|
Map.of("type", "product-cards", "data", List.of(Map.of("name", "Product", "price", 12))))));
|
||||||
|
when(client.callTool(any())).thenReturn(McpSchema.CallToolResult.builder()
|
||||||
|
.addTextContent(text).structuredContent(cards).build());
|
||||||
|
var result = executor(false, name).execute(List.of(
|
||||||
|
new AssistantMessage.ToolCall("shopping", "function", name, "{}")),
|
||||||
|
"conv", "agent", false, "user", null);
|
||||||
|
assertEquals(text, result.responses().getFirst().responseData());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package vip.mate.channel.web;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.agent.AgentService.StreamDelta;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AgentStreamAccumulatorStructuredResultTest {
|
||||||
|
@Test
|
||||||
|
void persistsStructuredContentForExactCallAcrossDuplicateCompletions() throws Exception {
|
||||||
|
var mapper = new ObjectMapper();
|
||||||
|
var accumulator = new AgentStreamAccumulator(mapper, new AgentStreamAccumulator.Sink() {
|
||||||
|
public void broadcast(String id, String event, Object payload) { }
|
||||||
|
public void updatePhase(String id, String phase) { }
|
||||||
|
});
|
||||||
|
for (String id : List.of("first", "second")) {
|
||||||
|
accumulator.accept(StreamDelta.event("tool_call_started", Map.of(
|
||||||
|
"toolCallId", id, "toolName", "chart", "arguments", "{}")), "conv");
|
||||||
|
}
|
||||||
|
var structured = Map.of("mateclawUi", Map.of("version", 1, "blocks", List.of()));
|
||||||
|
var completed = StreamDelta.event("tool_call_completed", Map.of(
|
||||||
|
"toolCallId", "first", "toolName", "chart", "result", "summary",
|
||||||
|
"success", true, "structuredContent", structured));
|
||||||
|
accumulator.accept(completed, "conv");
|
||||||
|
accumulator.accept(completed, "conv");
|
||||||
|
var metadata = mapper.readTree(accumulator.toMetadataJson());
|
||||||
|
assertEquals(2, metadata.path("segments").size());
|
||||||
|
assertEquals(mapper.valueToTree(structured), metadata.path("segments").get(0).path("structuredContent"));
|
||||||
|
assertEquals(mapper.valueToTree(structured), metadata.path("toolCalls").get(0).path("structuredContent"));
|
||||||
|
// Saving history marks unfinished tools interrupted; the duplicate must not complete it.
|
||||||
|
assertEquals("interrupted", metadata.path("segments").get(1).path("status").asText());
|
||||||
|
assertFalse(metadata.path("segments").get(1).has("structuredContent"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package vip.mate.tool.mcp.runtime;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class McpToolResultCaptureTest {
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsExactByteLimitAndRejectsOneByteMore() throws Exception {
|
||||||
|
int overhead = mapper.writeValueAsBytes(Map.of("text", "")).length;
|
||||||
|
var capture = new McpToolResultCapture();
|
||||||
|
String text = "x".repeat(McpToolResultCapture.MAX_STRUCTURED_BYTES - overhead);
|
||||||
|
capture.capture(McpSchema.CallToolResult.builder().structuredContent(Map.of("text", text)).build(), mapper);
|
||||||
|
assertNotNull(capture.structuredContent());
|
||||||
|
capture.capture(McpSchema.CallToolResult.builder().structuredContent(Map.of("text", text + "x")).build(), mapper);
|
||||||
|
assertNull(capture.structuredContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void capturedResponseIsDetachedAndErrorClearsIt() {
|
||||||
|
Map<String, Object> nested = new HashMap<>(Map.of("value", "before"));
|
||||||
|
var capture = new McpToolResultCapture();
|
||||||
|
capture.capture(McpSchema.CallToolResult.builder().structuredContent(Map.of("nested", nested)).build(), mapper);
|
||||||
|
nested.put("value", "after");
|
||||||
|
assertEquals(Map.of("nested", Map.of("value", "before")), capture.structuredContent());
|
||||||
|
capture.capture(McpSchema.CallToolResult.builder().structuredContent(nested).isError(true).build(), mapper);
|
||||||
|
assertTrue(capture.isError());
|
||||||
|
assertNull(capture.structuredContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1198,6 +1198,7 @@ const segments = computed<MessageSegment[]>(() => {
|
|||||||
id: `tc-${i}`, type: 'tool_call', status: 'completed',
|
id: `tc-${i}`, type: 'tool_call', status: 'completed',
|
||||||
toolName: tc.name, toolArgs: tc.arguments,
|
toolName: tc.name, toolArgs: tc.arguments,
|
||||||
toolResult: tc.result, toolSuccess: tc.success,
|
toolResult: tc.result, toolSuccess: tc.success,
|
||||||
|
toolCallId: tc.toolCallId, structuredContent: tc.structuredContent,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (props.message.content) {
|
if (props.message.content) {
|
||||||
@ -1208,7 +1209,8 @@ const segments = computed<MessageSegment[]>(() => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Use segmented rendering when there are multiple segments, OR when the turn
|
* Use segmented rendering when there are multiple segments, OR when the turn
|
||||||
* contains a delegation segment. Delegations live in `segments` but not in
|
* contains structured output or a delegation segment. Structured output must
|
||||||
|
* remain visible even when it is the only segment. Delegations live in `segments` but not in
|
||||||
* `metadata.toolCalls`, so the fallback path (which only reads toolCalls)
|
* `metadata.toolCalls`, so the fallback path (which only reads toolCalls)
|
||||||
* renders nothing for them — a single-step plan that delegates to a subagent
|
* renders nothing for them — a single-step plan that delegates to a subagent
|
||||||
* would otherwise show the subagent call as completely invisible. Forcing
|
* would otherwise show the subagent call as completely invisible. Forcing
|
||||||
@ -1216,7 +1218,7 @@ const segments = computed<MessageSegment[]>(() => {
|
|||||||
*/
|
*/
|
||||||
const useSegmentedView = computed(() =>
|
const useSegmentedView = computed(() =>
|
||||||
segments.value.length > 1 ||
|
segments.value.length > 1 ||
|
||||||
segments.value.some(s => s.type === 'tool_call' && (s.toolName || '').startsWith('→'))
|
segments.value.some(s => s.type === 'tool_call' && (s.structuredContent || (s.toolName || '').startsWith('→')))
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useToolLabel } from '@/composables/useToolLabel'
|
|||||||
import type { MessageSegment } from '@/types'
|
import type { MessageSegment } from '@/types'
|
||||||
import DelegationNodeView from './DelegationNodeView.vue'
|
import DelegationNodeView from './DelegationNodeView.vue'
|
||||||
import ExecutionDetailDialog from './ExecutionDetailDialog.vue'
|
import ExecutionDetailDialog from './ExecutionDetailDialog.vue'
|
||||||
|
import ToolResultView from './tool-results/ToolResultView.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
segment: MessageSegment
|
segment: MessageSegment
|
||||||
@ -199,6 +200,8 @@ const detailStatus = computed<'running' | 'completed' | 'error'>(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<ToolResultView v-if="isSuccess && segment.structuredContent" :structured-content="segment.structuredContent" />
|
||||||
|
|
||||||
<ExecutionDetailDialog
|
<ExecutionDetailDialog
|
||||||
v-if="canViewDetail"
|
v-if="canViewDetail"
|
||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
|
|||||||
@ -0,0 +1,112 @@
|
|||||||
|
import { createApp, defineComponent, h } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { Message } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useStreamingMarkdown', () => ({
|
||||||
|
useStreamingMarkdown: (content: unknown) => ({
|
||||||
|
renderedContent: content,
|
||||||
|
copyCode: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
vi.mock('@/composables/useAuthenticatedAttachment', () => ({
|
||||||
|
useAuthenticatedAttachment: () => ({
|
||||||
|
blobUrls: {},
|
||||||
|
loadAllImages: vi.fn(),
|
||||||
|
loadAllVideos: vi.fn(),
|
||||||
|
loadAllAudios: vi.fn(),
|
||||||
|
loadAllModels: vi.fn(),
|
||||||
|
downloadFile: vi.fn(),
|
||||||
|
openImage: vi.fn(),
|
||||||
|
getDisplayUrl: vi.fn((url: string) => url),
|
||||||
|
revokeAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
vi.mock('@/composables/useMcToast', () => ({
|
||||||
|
mcToast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||||
|
}))
|
||||||
|
vi.mock('@/composables/useToolLabel', () => ({
|
||||||
|
useToolLabel: () => ({ getToolLabel: (name: string) => name }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/api', () => ({ http: { get: vi.fn(), post: vi.fn() }, fetchAuthenticatedBlob: vi.fn() }))
|
||||||
|
vi.mock('@/utils/clipboard', () => ({ copyToClipboard: vi.fn() }))
|
||||||
|
vi.mock('@/utils/generatedFileLinks', () => ({
|
||||||
|
buildGeneratedFileNameMap: vi.fn(() => new Map()),
|
||||||
|
linkifyGeneratedFileUrls: vi.fn((content: string) => content),
|
||||||
|
}))
|
||||||
|
vi.mock('@/utils/lazyModelViewer', () => ({ ensureModelViewer: vi.fn() }))
|
||||||
|
vi.mock('../preview/previewKind', () => ({ previewKindOf: vi.fn(() => null) }))
|
||||||
|
vi.mock('../preview/previewBus', () => ({ openFilePreview: vi.fn() }))
|
||||||
|
vi.mock('@/components/goal/GoalAvatarRing.vue', () => ({
|
||||||
|
default: defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) }),
|
||||||
|
}))
|
||||||
|
vi.mock('../ThinkingSegment.vue', () => ({
|
||||||
|
default: defineComponent({ props: ['segment'], setup: props => () => h('div', props.segment.thinkingText) }),
|
||||||
|
}))
|
||||||
|
vi.mock('../ContentSegment.vue', () => ({
|
||||||
|
default: defineComponent({ props: ['segment'], setup: props => () => h('div', props.segment.text) }),
|
||||||
|
}))
|
||||||
|
vi.mock('../PlanStepsPanel.vue', () => ({ default: defineComponent({ setup: () => () => null }) }))
|
||||||
|
vi.mock('../BrowserTimeline.vue', () => ({ default: defineComponent({ setup: () => () => null }) }))
|
||||||
|
vi.mock('../TypingCursor.vue', () => ({ default: defineComponent({ setup: () => () => null }) }))
|
||||||
|
vi.mock('../UserMessageContent.vue', () => ({
|
||||||
|
default: defineComponent({ props: ['content'], setup: props => () => h('div', props.content) }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import MessageBubble from '../MessageBubble.vue'
|
||||||
|
import { http, fetchAuthenticatedBlob } from '@/api'
|
||||||
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
|
|
||||||
|
const apps: Array<ReturnType<typeof createApp>> = []
|
||||||
|
|
||||||
|
function mountMessage(message: Message, locale = 'zh-CN') {
|
||||||
|
const host = document.createElement('div')
|
||||||
|
document.body.appendChild(host)
|
||||||
|
const app = createApp(MessageBubble, { message })
|
||||||
|
app.use(createPinia())
|
||||||
|
for (const name of ['el-icon', 'el-popover', 'model-viewer']) app.component(name, { template: '<span><slot /></span>' })
|
||||||
|
app.use(createI18n({
|
||||||
|
legacy: false,
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
locale,
|
||||||
|
messages: {
|
||||||
|
'zh-CN': {
|
||||||
|
chat: {
|
||||||
|
stopped: '已被用户手动中止',
|
||||||
|
interrupted: '已中断并继续处理下一条消息',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
chat: {
|
||||||
|
stopped: 'Stopped manually by user',
|
||||||
|
interrupted: 'Interrupted and continued with the next message',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
app.mount(host)
|
||||||
|
apps.push(app)
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
apps.splice(0).forEach(app => app.unmount())
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('structured tool history', () => {
|
||||||
|
it.each(['segments', 'toolCalls'])('renders saved %s data as the same visible table', (source) => {
|
||||||
|
const structuredContent = { mateclawUi: { version: 1, blocks: [{ type: 'table', data: { columns: [{ key: 'a', label: 'A' }], rows: [{ a: 'Saved result' }] } }] } }
|
||||||
|
const metadata = source === 'segments'
|
||||||
|
? { segments: [{ id: 'tool', type: 'tool_call', status: 'completed', toolName: 'search', structuredContent }] }
|
||||||
|
: { toolCalls: [{ name: 'search', status: 'completed', success: true, structuredContent }] }
|
||||||
|
const host = mountMessage({ id: 'saved', conversationId: 'conv', role: 'assistant', content: '', contentParts: [], status: 'completed', metadata } as Message)
|
||||||
|
expect(host.querySelector('td')?.textContent).toBe('Saved result')
|
||||||
|
expect(host.querySelector('.seg-tool__body')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import { createApp, nextTick, defineComponent } from 'vue'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { MessageSegment } from '@/types'
|
||||||
|
vi.mock('@/composables/useToolLabel', () => ({ useToolLabel: () => ({ getToolLabel: (name: string) => name }) }))
|
||||||
|
vi.mock('../ExecutionDetailDialog.vue', () => ({ default: { template: '<div />' } }))
|
||||||
|
import ToolCallSegment from '../ToolCallSegment.vue'
|
||||||
|
import { registerToolResultRenderer } from '../tool-results/registry'
|
||||||
|
const apps: ReturnType<typeof createApp>[] = []
|
||||||
|
afterEach(() => { apps.splice(0).forEach(app => app.unmount()); document.body.innerHTML = '' })
|
||||||
|
function mount(segment: MessageSegment) {
|
||||||
|
const host = document.createElement('div')
|
||||||
|
document.body.append(host)
|
||||||
|
const app = createApp(ToolCallSegment, { segment })
|
||||||
|
app.config.globalProperties.$t = (key: string) => key
|
||||||
|
app.component('el-icon', { template: '<span><slot /></span>' })
|
||||||
|
app.mount(host)
|
||||||
|
apps.push(app)
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
const table = { mateclawUi: { version: 1, blocks: [{ type: 'table', title: '<script>title</script>', data: { columns: [{ key: 'value', label: 'Value' }], rows: [{ value: '<img src=x onerror=alert(1)>' }] } }] } }
|
||||||
|
const segment = (status: MessageSegment['status'] = 'completed'): MessageSegment => ({ id: 'a', type: 'tool_call', toolName: 'search', status, toolResult: 'plain result', structuredContent: table })
|
||||||
|
describe('structured tool cards', () => {
|
||||||
|
it('shows a table immediately while raw details stay collapsed, and escapes text', async () => {
|
||||||
|
const host = mount(segment())
|
||||||
|
expect(host.querySelector('table')).not.toBeNull()
|
||||||
|
expect(host.querySelector('.seg-tool__body')).toBeNull()
|
||||||
|
expect(host.querySelector('td')?.textContent).toBe('<img src=x onerror=alert(1)>')
|
||||||
|
expect(host.querySelector('script, img')).toBeNull()
|
||||||
|
;(host.querySelector('.seg-tool__header') as HTMLElement).click()
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelector('table')).not.toBeNull()
|
||||||
|
expect(host.querySelector('.seg-tool__body')?.textContent).toContain('plain result')
|
||||||
|
})
|
||||||
|
it('isolates a failing trusted renderer and shows escaped JSON', async () => {
|
||||||
|
registerToolResultRenderer('broken', { component: defineComponent({ setup() { throw new Error('Broken renderer') } }), validate: () => true })
|
||||||
|
const host = mount({ ...segment(), structuredContent: { mateclawUi: { version: 1, blocks: [{ type: 'broken', data: '<img onerror=bad>' }] } } })
|
||||||
|
await nextTick()
|
||||||
|
expect(host.querySelector('pre')?.textContent).toContain('<img onerror=bad>')
|
||||||
|
expect(host.querySelector('img')).toBeNull()
|
||||||
|
})
|
||||||
|
it('does not display rich results for running or failed calls', () => {
|
||||||
|
for (const value of [segment('running'), segment('error'), { ...segment(), toolSuccess: false }]) expect(mount(value).querySelector('table')).toBeNull()
|
||||||
|
})
|
||||||
|
it('escapes fallback JSON and renders safe product media without dangerous navigation', () => {
|
||||||
|
const value = segment()
|
||||||
|
value.structuredContent = { mateclawUi: { version: 1, blocks: [{ type: 'unknown', data: '<script>bad</script>' }, { type: 'product-cards', data: [{ name: '<img onerror=bad>', url: 'javascript:alert(1)', imageUrl: 'data:image/svg+xml,bad', price: 12 }, { name: 'Good', url: 'https://shop.test/p', imageUrl: 'https://shop.test/p.png' }] }] } }
|
||||||
|
const host = mount(value)
|
||||||
|
expect(host.querySelector('pre')?.textContent).toContain('<script>bad</script>')
|
||||||
|
expect(host.querySelector('script')).toBeNull()
|
||||||
|
expect(host.querySelectorAll('img')).toHaveLength(1)
|
||||||
|
expect(host.querySelectorAll('a')).toHaveLength(1)
|
||||||
|
expect(host.querySelector('a')?.getAttribute('rel')).toContain('noopener')
|
||||||
|
expect(host.querySelector('a')?.getAttribute('href')).toBe('https://shop.test/p')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { createApp, nextTick } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useThemeStore } from '@/stores/useThemeStore'
|
||||||
|
const mock = vi.hoisted(() => ({ init: vi.fn(), setOption: vi.fn(), dispose: vi.fn(), resize: vi.fn(), disconnect: vi.fn() }))
|
||||||
|
vi.mock('echarts', () => ({ init: mock.init }))
|
||||||
|
import ToolResultChart from '../tool-results/ToolResultChart.vue'
|
||||||
|
let app: ReturnType<typeof createApp> | undefined
|
||||||
|
let host: HTMLElement
|
||||||
|
function mount() {
|
||||||
|
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {} })
|
||||||
|
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect = mock.disconnect })
|
||||||
|
mock.init.mockReturnValue({ setOption: mock.setOption, dispose: mock.dispose, resize: mock.resize })
|
||||||
|
host = document.createElement('div')
|
||||||
|
document.body.append(host)
|
||||||
|
app = createApp(ToolResultChart, { data: { tooltip: { formatter: '<img onerror=bad>' }, series: [{ type: 'bar', data: [1] }] } })
|
||||||
|
const pinia = createPinia()
|
||||||
|
app.use(pinia)
|
||||||
|
app.mount(host)
|
||||||
|
return useThemeStore(pinia)
|
||||||
|
}
|
||||||
|
afterEach(() => { app?.unmount(); app = undefined; document.body.innerHTML = ''; vi.clearAllMocks(); vi.unstubAllGlobals() })
|
||||||
|
describe('tool chart lifecycle', () => {
|
||||||
|
it('owns one canvas instance, sanitizes options, and disposes on unmount', async () => {
|
||||||
|
mount()
|
||||||
|
await vi.waitFor(() => expect(mock.setOption).toHaveBeenCalledOnce())
|
||||||
|
expect(host.querySelector('.echarts-block')).toBeNull()
|
||||||
|
expect(mock.init).toHaveBeenCalledOnce()
|
||||||
|
expect(mock.setOption.mock.calls[0][0].tooltip).toEqual({ renderMode: 'richText', confine: true })
|
||||||
|
app!.unmount(); app = undefined
|
||||||
|
expect(mock.dispose).toHaveBeenCalledOnce()
|
||||||
|
expect(mock.disconnect).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
it('does not mount after unmount while lazy loading', async () => {
|
||||||
|
mount()
|
||||||
|
app!.unmount(); app = undefined
|
||||||
|
await nextTick()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(mock.init).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('shows readable JSON when chart option rendering fails', async () => {
|
||||||
|
mock.setOption.mockImplementationOnce(() => { throw new Error('Unsupported chart') })
|
||||||
|
mount()
|
||||||
|
await vi.waitFor(() => expect(host.querySelector('pre')).not.toBeNull())
|
||||||
|
expect(host.querySelector('pre')?.textContent).toContain('series')
|
||||||
|
expect(host.querySelector('img')).toBeNull()
|
||||||
|
expect(mock.dispose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { resolveToolResult, registerToolResultRenderer, safeHttpUrl, safeChartOption } from '../tool-results/registry'
|
||||||
|
const payload = (type: string, data: unknown) => ({ mateclawUi: { version: 1, blocks: [{ type, data }] } })
|
||||||
|
describe('tool result contract', () => {
|
||||||
|
it('recognizes table data and trusted registered components', () => {
|
||||||
|
expect(resolveToolResult(payload('table', { columns: [{ key: 'name', label: 'Name' }], rows: [{ name: 'A' }] }))[0].component).toBeTruthy()
|
||||||
|
const component = defineComponent({ template: '<span />' })
|
||||||
|
registerToolResultRenderer('custom-test', { component, validate: data => data === 42 })
|
||||||
|
expect(resolveToolResult(payload('custom-test', 42))[0].component).toBe(component)
|
||||||
|
expect(resolveToolResult(payload('custom-test', 41))[0].fallback).toContain('41')
|
||||||
|
})
|
||||||
|
it('falls back for unknown versions, types, malformed and oversized payloads', () => {
|
||||||
|
for (const input of [{ mateclawUi: { version: 2, blocks: [] } }, payload('unknown', '<script>bad</script>'), payload('table', {}), payload('table', { columns: [], rows: Array(201).fill({}) }), { mateclawUi: { version: 1, blocks: Array(9).fill({ type: 'unknown' }) } }, payload('unknown', '汉'.repeat(40000))]) {
|
||||||
|
const blocks = resolveToolResult(input)
|
||||||
|
expect(blocks[0].fallback).toBeTruthy()
|
||||||
|
expect(blocks.every(b => !b.component)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('allows only absolute http(s) product URLs', () => {
|
||||||
|
for (const url of ['javascript:alert(1)', 'data:image/svg+xml,x', '//evil.test', 'https:\n//evil.test', 'https://user:pass@evil.test']) expect(safeHttpUrl(url)).toBeUndefined()
|
||||||
|
expect(safeHttpUrl('https://shop.test/a')).toBe('https://shop.test/a')
|
||||||
|
})
|
||||||
|
it('removes chart HTML, links, graphics, image symbols and formatter features recursively', () => {
|
||||||
|
const option = safeChartOption({ title: { text: '<img src=x>', link: 'javascript:alert(1)' }, tooltip: { renderMode: 'html', formatter: '<img onerror=alert(1)>', extraCssText: 'bad' }, graphic: { type: 'image' }, visualMap: { inRange: { symbol: ['image://https://bad.test/x'] } }, series: [{ type: 'bar', symbol: 'image://https://bad.test/x', data: [1], tooltip: { formatter: '<script>' } }] })
|
||||||
|
expect(option.tooltip).toEqual({ renderMode: 'richText', confine: true })
|
||||||
|
expect(JSON.stringify(option)).not.toMatch(/javascript:|image:\/\/|onerror|extraCssText|formatter|graphic/)
|
||||||
|
expect(option.title.text).toBe('<img src=x>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onErrorCaptured, ref, watch } from 'vue'
|
||||||
|
import type { ResolvedToolBlock } from './registry'
|
||||||
|
const props = defineProps<{ block: ResolvedToolBlock }>()
|
||||||
|
const failed = ref(false)
|
||||||
|
onErrorCaptured(() => { failed.value = true; return false })
|
||||||
|
watch(() => props.block, () => { failed.value = false })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<component v-if="block.component && !failed" :is="block.component" :data="block.data" />
|
||||||
|
<pre v-else>{{ block.fallback }}</pre>
|
||||||
|
</template>
|
||||||
|
<style scoped>pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 350px; overflow: auto; font-size: 12px; }</style>
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
|
import { useThemeStore } from '@/stores/useThemeStore'
|
||||||
|
import { safeChartOption } from './safety'
|
||||||
|
const props = defineProps<{ data: Record<string, unknown> }>()
|
||||||
|
const theme = useThemeStore()
|
||||||
|
const host = ref<HTMLElement | null>(null)
|
||||||
|
const error = ref(false)
|
||||||
|
let chart: import('echarts').ECharts | undefined
|
||||||
|
let observer: ResizeObserver | undefined
|
||||||
|
let generation = 0
|
||||||
|
async function render() {
|
||||||
|
const current = ++generation
|
||||||
|
chart?.dispose()
|
||||||
|
chart = undefined
|
||||||
|
error.value = false
|
||||||
|
try {
|
||||||
|
const echarts = await import('echarts')
|
||||||
|
if (current !== generation || !host.value) return
|
||||||
|
chart = echarts.init(host.value, theme.isDark ? 'dark' : undefined, { renderer: 'canvas' })
|
||||||
|
chart.setOption(safeChartOption(props.data))
|
||||||
|
} catch {
|
||||||
|
chart?.dispose()
|
||||||
|
chart = undefined
|
||||||
|
error.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
observer = new ResizeObserver(() => chart?.resize())
|
||||||
|
if (host.value) observer.observe(host.value)
|
||||||
|
void render()
|
||||||
|
})
|
||||||
|
watch(() => [props.data, theme.isDark], () => { void render() }, { deep: true })
|
||||||
|
onBeforeUnmount(() => { generation++; observer?.disconnect(); chart?.dispose() })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="tool-result-chart" ref="host" role="img" aria-label="Tool result chart" />
|
||||||
|
<pre v-if="error">{{ JSON.stringify(data, null, 2) }}</pre>
|
||||||
|
</template>
|
||||||
|
<style scoped>.tool-result-chart { width: 100%; height: 350px; }</style>
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||||
|
import { safeHttpUrl } from './safety'
|
||||||
|
const props = defineProps<{ data: Record<string, unknown>[] }>()
|
||||||
|
const { renderMarkdown } = useMarkdownRenderer()
|
||||||
|
const html = computed(() => {
|
||||||
|
const products = props.data.map(product => ({ ...product, url: safeHttpUrl(product.url), imageUrl: safeHttpUrl(product.imageUrl) }))
|
||||||
|
return renderMarkdown('```product-cards\n' + JSON.stringify(products) + '\n```')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template><div class="tool-result-products markdown-body" v-html="html" /></template>
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ data: { columns: { key: string; label: string }[]; rows: Record<string, unknown>[] } }>()
|
||||||
|
const cellText = (value: unknown) => value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="tool-result-table"><table>
|
||||||
|
<thead><tr><th v-for="column in data.columns" :key="column.key" scope="col">{{ column.label }}</th></tr></thead>
|
||||||
|
<tbody><tr v-for="(row, i) in data.rows" :key="i"><td v-for="column in data.columns" :key="column.key">{{ cellText(Object.hasOwn(row, column.key) ? row[column.key] : null) }}</td></tr></tbody>
|
||||||
|
</table></div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.tool-result-table { overflow: auto; max-height: 420px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||||
|
th, td { text-align: left; padding: 8px 12px; border: 1px solid var(--mc-border-light); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
th { background: var(--mc-bg-muted); }
|
||||||
|
</style>
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { resolveToolResult } from './registry'
|
||||||
|
import ToolResultBlock from './ToolResultBlock.vue'
|
||||||
|
const props = defineProps<{ structuredContent: unknown }>()
|
||||||
|
const blocks = computed(() => resolveToolResult(props.structuredContent))
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="tool-result-view">
|
||||||
|
<section v-for="(block, index) in blocks" :key="index" class="tool-result-block">
|
||||||
|
<h4 v-if="block.title">{{ block.title }}</h4>
|
||||||
|
<ToolResultBlock :block="block" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.tool-result-view { padding: 8px 10px 12px 22px; min-width: 0; }
|
||||||
|
.tool-result-block + .tool-result-block { margin-top: 12px; }
|
||||||
|
h4 { margin: 0 0 8px; font-size: 14px; }
|
||||||
|
</style>
|
||||||
55
mateclaw-ui/src/components/chat/tool-results/registry.ts
Normal file
55
mateclaw-ui/src/components/chat/tool-results/registry.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
export { safeHttpUrl, safeChartOption } from './safety'
|
||||||
|
import { markRaw, type Component } from 'vue'
|
||||||
|
import ToolResultTable from './ToolResultTable.vue'
|
||||||
|
import ToolResultProducts from './ToolResultProducts.vue'
|
||||||
|
import ToolResultChart from './ToolResultChart.vue'
|
||||||
|
|
||||||
|
export const MAX_TOOL_UI_BYTES = 100 * 1024
|
||||||
|
export const MAX_TABLE_ROWS = 200
|
||||||
|
export const MAX_PRODUCTS = 24
|
||||||
|
interface Renderer { component: Component; validate: (data: unknown) => boolean }
|
||||||
|
export interface ResolvedToolBlock { title?: string; component?: Component; data?: unknown; fallback?: string }
|
||||||
|
const registry = new Map<string, Renderer>()
|
||||||
|
const record = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
|
||||||
|
/** Registration is limited to trusted application code; payloads only name a renderer. */
|
||||||
|
export function registerToolResultRenderer(type: string, renderer: Renderer): void {
|
||||||
|
registry.set(type, { ...renderer, component: markRaw(renderer.component) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveToolResult(input: unknown): ResolvedToolBlock[] {
|
||||||
|
let json: string
|
||||||
|
try { json = JSON.stringify(input, null, 2) ?? String(input) } catch { return [{ fallback: 'Invalid structured tool result' }] }
|
||||||
|
const bounded = (text: string) => text.length > MAX_TOOL_UI_BYTES ? text.slice(0, MAX_TOOL_UI_BYTES) + '\n… [truncated]' : text
|
||||||
|
const fallback = () => [{ fallback: bounded(json) }]
|
||||||
|
if (new TextEncoder().encode(JSON.stringify(input)).length > MAX_TOOL_UI_BYTES) return fallback()
|
||||||
|
const ui = record(input) ? input.mateclawUi : undefined
|
||||||
|
if (!record(ui) || ui.version !== 1 || !Array.isArray(ui.blocks) || ui.blocks.length > 8) return fallback()
|
||||||
|
return ui.blocks.map((block: unknown) => {
|
||||||
|
if (!record(block)) return { fallback: bounded(JSON.stringify(block, null, 2)) }
|
||||||
|
const title = typeof block.title === 'string' ? block.title : undefined
|
||||||
|
const renderer = registry.get(block.type)
|
||||||
|
try {
|
||||||
|
if (renderer && renderer.validate(block.data)) return { title, component: renderer.component, data: block.data, fallback: bounded(JSON.stringify(block, null, 2)) }
|
||||||
|
} catch { /* A faulty trusted validator must not interrupt the conversation. */ }
|
||||||
|
return { title, fallback: bounded(JSON.stringify(block, null, 2)) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
registerToolResultRenderer('table', {
|
||||||
|
component: ToolResultTable,
|
||||||
|
validate: data => record(data) && Array.isArray(data.columns) && data.columns.length > 0 && data.columns.length <= 32
|
||||||
|
&& data.columns.every((c: unknown) => record(c) && typeof c.key === 'string' && typeof c.label === 'string')
|
||||||
|
&& Array.isArray(data.rows) && data.rows.length <= MAX_TABLE_ROWS && data.rows.every(record),
|
||||||
|
})
|
||||||
|
registerToolResultRenderer('product-cards', {
|
||||||
|
component: ToolResultProducts,
|
||||||
|
validate: data => Array.isArray(data) && data.length <= MAX_PRODUCTS && data.every(item => record(item)
|
||||||
|
&& typeof item.name === 'string'
|
||||||
|
&& ['url', 'imageUrl', 'platformLabel', 'shopName', 'purchaseAdvice'].every(k => item[k] == null || typeof item[k] === 'string')
|
||||||
|
&& ['price', 'originalPrice', 'lowestPrice'].every(k => item[k] == null || typeof item[k] === 'string' || typeof item[k] === 'number')),
|
||||||
|
})
|
||||||
|
registerToolResultRenderer('echarts', {
|
||||||
|
component: ToolResultChart,
|
||||||
|
validate: data => record(data) && (record(data.series) || (Array.isArray(data.series) && data.series.length > 0 && data.series.every(record))),
|
||||||
|
})
|
||||||
34
mateclaw-ui/src/components/chat/tool-results/safety.ts
Normal file
34
mateclaw-ui/src/components/chat/tool-results/safety.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
const record = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
|
||||||
|
export function safeHttpUrl(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string' || !/^https?:\/\//i.test(value) || /[\s\u0000-\u001f\u007f]/.test(value)) return
|
||||||
|
try {
|
||||||
|
const url = new URL(value)
|
||||||
|
if (!url.username && !url.password && url.hostname) return value
|
||||||
|
} catch { /* Invalid URLs are rendered without navigation or media. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_KEYS = new Set(['title', 'legend', 'xAxis', 'yAxis', 'series', 'grid', 'color', 'dataset', 'radar', 'polar', 'angleAxis', 'radiusAxis', 'visualMap', 'tooltip'])
|
||||||
|
const UNSAFE_CHART_KEYS = new Set(['__proto__', 'constructor', 'prototype', 'formatter', 'valueFormatter', 'renderItem', 'link', 'sublink', 'target', 'sublinkTarget', 'image', 'graphic', 'rich', 'extraCssText', 'className', 'appendTo', 'appendToBody', 'renderMode', 'backgroundColor', 'borderColor'])
|
||||||
|
/** Use canvas text tooltips and remove all executable, HTML, navigation and image hooks. */
|
||||||
|
export function safeChartOption(input: Record<string, any>): Record<string, any> {
|
||||||
|
const clean = (value: any): any => {
|
||||||
|
if (Array.isArray(value)) return value.map(clean)
|
||||||
|
if (record(value)) {
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
if (UNSAFE_CHART_KEYS.has(key) || typeof child === 'function') continue
|
||||||
|
if (key === 'tooltip') { result[key] = { renderMode: 'richText', confine: true }; continue }
|
||||||
|
if (typeof child === 'string' && (child.includes('image://') || child.trimStart().startsWith('function'))) continue
|
||||||
|
result[key] = clean(child)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && /image:\/\//i.test(value)) return ''
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const option = clean(Object.fromEntries(Object.entries(input).filter(([key]) => CHART_KEYS.has(key))))
|
||||||
|
option.tooltip = { renderMode: 'richText', confine: true }
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
const streamMock = vi.hoisted(() => {
|
||||||
|
const handlers = new Map<string, Set<(data?: any) => void>>()
|
||||||
|
return {
|
||||||
|
handlers,
|
||||||
|
connect: vi.fn().mockResolvedValue(undefined),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
resetDedup: vi.fn(),
|
||||||
|
on: vi.fn((event: string, handler: (data?: any) => void) => {
|
||||||
|
const listeners = handlers.get(event) ?? new Set()
|
||||||
|
listeners.add(handler)
|
||||||
|
handlers.set(event, listeners)
|
||||||
|
return () => listeners.delete(handler)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../useStream', () => ({
|
||||||
|
useStream: () => streamMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useChat } from '../useChat'
|
||||||
|
|
||||||
|
describe('tool structured completion', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
streamMock.handlers.clear()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {}, removeItem: () => {} })
|
||||||
|
})
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
const emit = (name: string, data: any) => streamMock.handlers.get(name)?.forEach(h => h(data))
|
||||||
|
it('pairs explicit IDs exactly, including duplicate completion, and persists structured content', async () => {
|
||||||
|
const chat = useChat({ baseUrl: '' })
|
||||||
|
await chat.sendMessage('tools', { conversationId: 'conv', agentId: 'agent' })
|
||||||
|
emit('tool_call_started', { toolName: 'search', toolCallId: 'a' })
|
||||||
|
emit('tool_call_started', { toolName: 'search', toolCallId: 'b' })
|
||||||
|
const structuredContent = { mateclawUi: { version: 1, blocks: [] } }
|
||||||
|
emit('tool_call_completed', { toolName: 'search', toolCallId: 'a', success: true, result: 'ok', structuredContent })
|
||||||
|
emit('tool_call_completed', { toolName: 'search', toolCallId: 'a', success: true, result: 'ok', structuredContent })
|
||||||
|
emit('tool_call_completed', { toolName: 'search', toolCallId: 'unknown', success: true, result: 'bad' })
|
||||||
|
const meta = chat.messages.value.at(-1)?.metadata as any
|
||||||
|
expect(meta.toolCalls[0].structuredContent).toEqual(structuredContent)
|
||||||
|
expect(meta.segments[0].structuredContent).toEqual(structuredContent)
|
||||||
|
expect(meta.toolCalls[1].status).toBe('running')
|
||||||
|
expect(meta.segments[1].status).toBe('running')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1000,15 +1000,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
// Match by toolCallId when available, fall back to "first running" for legacy events.
|
// Match by toolCallId when available, fall back to "first running" for legacy events.
|
||||||
let target = -1
|
let target = -1
|
||||||
if (data.toolCallId) {
|
if (data.toolCallId) {
|
||||||
target = toolCalls.findIndex((tc: any) => tc.toolCallId === data.toolCallId && tc.status === 'running')
|
target = toolCalls.findIndex((tc: any) => tc.toolCallId === data.toolCallId)
|
||||||
}
|
}
|
||||||
if (target < 0) {
|
if (!data.toolCallId) {
|
||||||
target = toolCalls.findIndex((tc: any) => tc.status === 'running' && tc.name === data.toolName)
|
target = toolCalls.findIndex((tc: any) => tc.status === 'running' && tc.name === data.toolName)
|
||||||
}
|
}
|
||||||
if (target >= 0) {
|
if (target >= 0) {
|
||||||
toolCalls[target] = {
|
toolCalls[target] = {
|
||||||
...toolCalls[target],
|
...toolCalls[target],
|
||||||
result: data.result,
|
result: data.result,
|
||||||
|
structuredContent: data.structuredContent,
|
||||||
success: data.success,
|
success: data.success,
|
||||||
status: 'completed'
|
status: 'completed'
|
||||||
}
|
}
|
||||||
@ -1030,20 +1031,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
metadata: { ...metadata, toolCalls, runningToolName: undefined, generatedFiles }
|
metadata: { ...metadata, toolCalls, runningToolName: undefined, generatedFiles }
|
||||||
} as any)
|
} as any)
|
||||||
}
|
}
|
||||||
// Segments: prefer toolCallId match, fall back to first-running by toolName.
|
// Explicit IDs also match replayed completions; only ID-less events use the running-name fallback.
|
||||||
const segs = currentSegments.value
|
const segs = currentSegments.value
|
||||||
let toolSeg: MessageSegment | undefined
|
let toolSeg: MessageSegment | undefined
|
||||||
if (data.toolCallId) {
|
if (data.toolCallId) {
|
||||||
toolSeg = segs.find((s: MessageSegment) =>
|
toolSeg = segs.find((s: MessageSegment) =>
|
||||||
s.type === 'tool_call' && s.status === 'running' && s.toolCallId === data.toolCallId)
|
s.type === 'tool_call' && s.toolCallId === data.toolCallId)
|
||||||
}
|
}
|
||||||
if (!toolSeg) {
|
if (!data.toolCallId) {
|
||||||
toolSeg = segs.find((s: MessageSegment) =>
|
toolSeg = segs.find((s: MessageSegment) =>
|
||||||
s.type === 'tool_call' && s.status === 'running' && s.toolName === data.toolName)
|
s.type === 'tool_call' && s.status === 'running' && s.toolName === data.toolName)
|
||||||
}
|
}
|
||||||
if (toolSeg) {
|
if (toolSeg) {
|
||||||
toolSeg.status = data.success !== false ? 'completed' : 'error'
|
toolSeg.status = data.success !== false ? 'completed' : 'error'
|
||||||
toolSeg.toolResult = data.result
|
toolSeg.toolResult = data.result
|
||||||
|
toolSeg.structuredContent = data.structuredContent
|
||||||
toolSeg.toolSuccess = data.success
|
toolSeg.toolSuccess = data.success
|
||||||
}
|
}
|
||||||
flushSegmentsToMessage()
|
flushSegmentsToMessage()
|
||||||
|
|||||||
@ -141,6 +141,8 @@ export interface ChatAttachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallMeta {
|
export interface ToolCallMeta {
|
||||||
|
toolCallId?: string
|
||||||
|
structuredContent?: Record<string, unknown>
|
||||||
name: string
|
name: string
|
||||||
arguments?: string
|
arguments?: string
|
||||||
status: 'running' | 'completed' | 'awaiting_approval'
|
status: 'running' | 'completed' | 'awaiting_approval'
|
||||||
@ -223,6 +225,7 @@ export interface MessageSegment {
|
|||||||
toolName?: string
|
toolName?: string
|
||||||
toolArgs?: string
|
toolArgs?: string
|
||||||
toolResult?: string
|
toolResult?: string
|
||||||
|
structuredContent?: Record<string, unknown>
|
||||||
toolSuccess?: boolean
|
toolSuccess?: boolean
|
||||||
/** LLM-provided tool call id, used to pair tool_call_started ↔ tool_call_completed */
|
/** LLM-provided tool call id, used to pair tool_call_started ↔ tool_call_completed */
|
||||||
toolCallId?: string
|
toolCallId?: string
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user