fix(goal,ui): live ring update after agent-triggered setGoal / addGoalCriterion

This commit is contained in:
matevip 2026-05-21 14:43:59 +08:00
parent 89f8413db8
commit c9e54e820f
5 changed files with 70 additions and 1 deletions

View File

@ -10,6 +10,7 @@ import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.exception.MateClawException;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalCreateRequest;
@ -40,6 +41,7 @@ public class GoalManagementTool {
private final GoalService goalService;
private final GoalProperties properties;
private final ObjectMapper objectMapper;
private final ChatStreamTracker streamTracker;
@Tool(description = """
Set a persistent goal for the current conversation. The agent will \
@ -89,6 +91,7 @@ public class GoalManagementTool {
? origin.requesterId() : "system";
try {
GoalEntity created = goalService.create(req, username);
broadcastGoalEvent(created.getConversationId(), "goal_created", created);
return successJson(Map.of(
"goalId", String.valueOf(created.getId()),
"status", created.getStatus().getValue(),
@ -119,6 +122,7 @@ public class GoalManagementTool {
String username = resolveUsername(ctx);
try {
GoalEntity updated = goalService.appendCriterion(goal.getId(), criterion.trim(), username);
broadcastGoalEvent(updated.getConversationId(), "goal_updated", updated);
return successJson(Map.of(
"goalId", String.valueOf(updated.getId()),
"exitCriteria", updated.getExitCriteria() == null ? "" : updated.getExitCriteria()));
@ -144,6 +148,14 @@ public class GoalManagementTool {
true, "manual", 0, 0L);
try {
GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic);
// Broadcast a goal_completed event with the same shape as the
// GoalEvaluationNode auto-completed path, so the frontend
// handler doesn't need to branch on which path completed it.
if (streamTracker != null && completed.getConversationId() != null) {
streamTracker.broadcastObject(completed.getConversationId(), "goal_completed", Map.of(
"goalId", String.valueOf(completed.getId()),
"score", synthetic.score()));
}
return successJson(Map.of(
"goalId", String.valueOf(completed.getId()),
"status", completed.getStatus().getValue()));
@ -203,6 +215,29 @@ public class GoalManagementTool {
}
}
/**
* Broadcast a goal-namespaced SSE event so the frontend store can
* refresh its active-goal cache without waiting for the user to
* reload. Best-effort: a missing stream (e.g. cron-origin tool call
* with no SSE subscriber) is not an error path.
*/
private void broadcastGoalEvent(String conversationId, String eventName, GoalEntity goal) {
if (streamTracker == null || conversationId == null || conversationId.isBlank()) {
return;
}
// Send the full goal payload so the store can hydrate without an
// extra GET round-trip. Long IDs are stringified at the wire by
// ToStringSerializer; the rest of the payload is plain JSON.
try {
streamTracker.broadcastObject(conversationId, eventName, Map.of(
"goalId", String.valueOf(goal.getId()),
"conversationId", conversationId,
"goal", goal));
} catch (Exception e) {
log.debug("[GoalManagementTool] broadcast {} failed: {}", eventName, e.getMessage());
}
}
private String errorJson(String message) {
try {
return objectMapper.writeValueAsString(Map.of(

View File

@ -35,6 +35,7 @@ import static org.mockito.Mockito.when;
class GoalManagementToolTest {
@Mock private GoalService goalService;
@Mock private vip.mate.channel.web.ChatStreamTracker streamTracker;
private GoalProperties properties;
private GoalManagementTool tool;
@ -43,7 +44,7 @@ class GoalManagementToolTest {
void setUp() {
properties = new GoalProperties();
properties.setEnabled(true);
tool = new GoalManagementTool(goalService, properties, new ObjectMapper());
tool = new GoalManagementTool(goalService, properties, new ObjectMapper(), streamTracker);
}
private ToolContext ctxWith(String convId, Long agentId, String requester) {

View File

@ -1620,6 +1620,18 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (cid) goalStore.handleSseEvent(cid, 'goal_exhausted', data)
})
stream.on('goal_created', (data) => {
if (isStaleEvent(data)) return
const cid = data?.conversationId || streamConversationId
if (cid) goalStore.handleSseEvent(cid, 'goal_created', data)
})
stream.on('goal_updated', (data) => {
if (isStaleEvent(data)) return
const cid = data?.conversationId || streamConversationId
if (cid) goalStore.handleSseEvent(cid, 'goal_updated', data)
})
// ===== Send message (supports sending while generating) =====
const sendMessage = async (content: string, options: SendMessageOptions) => {

View File

@ -49,6 +49,11 @@ export type SSEEventType =
| 'goal_followup'
| 'goal_completed'
| 'goal_exhausted'
// Tool-side goal mutations (GoalManagementTool) — emitted when the
// agent invokes setGoal / addGoalCriterion so the store can refresh
// without a full page reload.
| 'goal_created'
| 'goal_updated'
// Stream lifecycle + per-iteration boundaries (single-turn UX overhaul).
// The parser handles arbitrary `event:` lines via parseEvent — these names
// exist in the union purely so TypeScript callers can register handlers

View File

@ -151,6 +151,22 @@ export const useGoalStore = defineStore('goal', () => {
activeGoalByConv.value[conversationId] = null
break
}
case 'goal_created':
case 'goal_updated': {
// Tool-side mutation from GoalManagementTool. Payload carries the
// full goal snapshot so we can hydrate the cache without a GET.
// Falls back to a fetch when the payload shape is unexpected so
// future server-side changes don't silently degrade the UX.
const fresh = data?.goal as Goal | undefined
if (fresh && typeof fresh.id === 'string') {
activeGoalByConv.value[conversationId] = fresh
} else {
// Best-effort refetch — runs async; don't await inside the
// synchronous SSE handler.
void loadActiveForConversation(conversationId)
}
break
}
default:
// Not a goal event — caller filters by prefix, this is a safety net.
break