mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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.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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user