mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
fix(goal,ui): live ring update after agent-triggered setGoal / addGoalCriterion
This commit is contained in:
parent
89f8413db8
commit
c9e54e820f
@ -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(
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user