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.lang.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.goal.config.GoalProperties; import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalCreateRequest; import vip.mate.goal.model.GoalCreateRequest;
@ -40,6 +41,7 @@ public class GoalManagementTool {
private final GoalService goalService; private final GoalService goalService;
private final GoalProperties properties; private final GoalProperties properties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ChatStreamTracker streamTracker;
@Tool(description = """ @Tool(description = """
Set a persistent goal for the current conversation. The agent will \ Set a persistent goal for the current conversation. The agent will \
@ -89,6 +91,7 @@ public class GoalManagementTool {
? origin.requesterId() : "system"; ? origin.requesterId() : "system";
try { try {
GoalEntity created = goalService.create(req, username); GoalEntity created = goalService.create(req, username);
broadcastGoalEvent(created.getConversationId(), "goal_created", created);
return successJson(Map.of( return successJson(Map.of(
"goalId", String.valueOf(created.getId()), "goalId", String.valueOf(created.getId()),
"status", created.getStatus().getValue(), "status", created.getStatus().getValue(),
@ -119,6 +122,7 @@ public class GoalManagementTool {
String username = resolveUsername(ctx); String username = resolveUsername(ctx);
try { try {
GoalEntity updated = goalService.appendCriterion(goal.getId(), criterion.trim(), username); GoalEntity updated = goalService.appendCriterion(goal.getId(), criterion.trim(), username);
broadcastGoalEvent(updated.getConversationId(), "goal_updated", updated);
return successJson(Map.of( return successJson(Map.of(
"goalId", String.valueOf(updated.getId()), "goalId", String.valueOf(updated.getId()),
"exitCriteria", updated.getExitCriteria() == null ? "" : updated.getExitCriteria())); "exitCriteria", updated.getExitCriteria() == null ? "" : updated.getExitCriteria()));
@ -144,6 +148,14 @@ public class GoalManagementTool {
true, "manual", 0, 0L); true, "manual", 0, 0L);
try { try {
GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic); 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( return successJson(Map.of(
"goalId", String.valueOf(completed.getId()), "goalId", String.valueOf(completed.getId()),
"status", completed.getStatus().getValue())); "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) { private String errorJson(String message) {
try { try {
return objectMapper.writeValueAsString(Map.of( return objectMapper.writeValueAsString(Map.of(

View File

@ -35,6 +35,7 @@ import static org.mockito.Mockito.when;
class GoalManagementToolTest { class GoalManagementToolTest {
@Mock private GoalService goalService; @Mock private GoalService goalService;
@Mock private vip.mate.channel.web.ChatStreamTracker streamTracker;
private GoalProperties properties; private GoalProperties properties;
private GoalManagementTool tool; private GoalManagementTool tool;
@ -43,7 +44,7 @@ class GoalManagementToolTest {
void setUp() { void setUp() {
properties = new GoalProperties(); properties = new GoalProperties();
properties.setEnabled(true); 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) { 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) 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) ===== // ===== Send message (supports sending while generating) =====
const sendMessage = async (content: string, options: SendMessageOptions) => { const sendMessage = async (content: string, options: SendMessageOptions) => {

View File

@ -49,6 +49,11 @@ export type SSEEventType =
| 'goal_followup' | 'goal_followup'
| 'goal_completed' | 'goal_completed'
| 'goal_exhausted' | '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). // Stream lifecycle + per-iteration boundaries (single-turn UX overhaul).
// The parser handles arbitrary `event:` lines via parseEvent — these names // The parser handles arbitrary `event:` lines via parseEvent — these names
// exist in the union purely so TypeScript callers can register handlers // 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 activeGoalByConv.value[conversationId] = null
break 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: default:
// Not a goal event — caller filters by prefix, this is a safety net. // Not a goal event — caller filters by prefix, this is a safety net.
break break