diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index f7b60c60..a2d49ba9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -252,7 +252,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC String streamed = output.state().value(STREAMED_CONTENT).orElse(""); if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); - deltas.add(streamedContentDelta(isFinalAnswerTurn, + addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, output.state().value(NEEDS_TOOL_CALL, false), output.state().value(CURRENT_ITERATION, 0), streamed)); @@ -261,7 +261,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { String answer = extractFinalAnswer(output); if (answer != null && !answer.isEmpty()) { - deltas.add(AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed)); + addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed)); } } String thinking = extractFinalThinking(output); @@ -424,7 +424,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC String streamed = output.state().value(STREAMED_CONTENT).orElse(""); if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); - deltas.add(streamedContentDelta(isFinalAnswerTurn, + addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, output.state().value(NEEDS_TOOL_CALL, false), output.state().value(CURRENT_ITERATION, 0), streamed)); @@ -433,7 +433,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { String answer = extractFinalAnswer(output); if (answer != null && !answer.isEmpty()) { - deltas.add(AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed)); + addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed)); } } @@ -666,6 +666,24 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC * caller's responsibility — this helper just decides flavor for non-blank * content. */ + /** + * Append a content-bearing delta plus, when it carries a producer-assigned + * kind, a {@code segment_kind} broadcast event tagging the just-emitted + * content span. The kind cannot ride on the live {@code content_delta} + * broadcasts — text streams before the producer knows whether the + * completion carries tool calls — so it is delivered as a follow-up event + * once the completion resolves, letting the client tag its running content + * segment and collapse a provisional narration the moment later content + * arrives, without waiting for the persisted-metadata round-trip. + */ + static void addWithKindEvent(List deltas, AgentService.StreamDelta delta) { + deltas.add(delta); + if (delta.kind() != null) { + deltas.add(AgentService.StreamDelta.event("segment_kind", + Map.of("kind", delta.kind().wireName()))); + } + } + static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, boolean carriesToolCalls, int iteration, String streamed) { if (isFinalAnswerTurn) { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java index 3aa5302c..87f149ff 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java @@ -114,4 +114,28 @@ class StateGraphReActAgentStreamedContentDeltaTest { assertNull(StateGraphReActAgent.streamedContentDelta(false, true, 0, "x").thinking()); assertNull(StateGraphReActAgent.streamedContentDelta(true, false, 1, "x").thinking()); } + + @Test + @DisplayName("kind-carrying delta is followed by a segment_kind broadcast event") + void kindDeltaEmitsSegmentKindEvent() { + java.util.List deltas = new java.util.ArrayList<>(); + StateGraphReActAgent.addWithKindEvent(deltas, + StateGraphReActAgent.streamedContentDelta(false, true, 0, "先查询。")); + + assertEquals(2, deltas.size()); + AgentService.StreamDelta event = deltas.get(1); + assertTrue(event.isEvent()); + assertEquals("segment_kind", event.eventType()); + assertEquals("pre_tool_narration", event.eventData().get("kind")); + } + + @Test + @DisplayName("untagged delta emits no segment_kind event") + void untaggedDeltaEmitsNoEvent() { + java.util.List deltas = new java.util.ArrayList<>(); + StateGraphReActAgent.addWithKindEvent(deltas, AgentService.StreamDelta.segmentOnly("x", null)); + + assertEquals(1, deltas.size()); + assertFalse(deltas.get(0).isEvent()); + } } diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index bbc034b8..8a4fa3d9 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -497,6 +497,17 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (thinkingSeg) thinkingSeg.status = 'completed' contentSeg = { id: genSegId(), type: 'content', status: 'running', text: '', timestamp: Date.now() } applyIterationTags(contentSeg) + // Later content supersedes any earlier provisional narration of the + // same turn — collapse it in place, live, instead of waiting for the + // persisted-metadata annotations in the done payload. Mirrors the + // backend tracker's rule for kind-tagged segments. + for (const s of segs) { + if (s.type === 'content' && s.kind === 'pre_tool_narration' && !s.superseded) { + s.superseded = true + s.supersededBySegmentId = String(contentSeg.id) + s.supersededReason = 'pre_tool_content_replaced_by_post_tool_answer' + } + } segs.push(contentSeg) flushSegmentsToMessage() // sync once when a new content segment is created } @@ -504,6 +515,23 @@ export function useChat(options: UseChatOptions): UseChatReturn { } }) + stream.on('segment_kind', (data) => { + if (isStaleEvent(data)) return + // Producer-assigned semantics of the content span that just closed its + // completion. Text streams live before the backend knows whether the + // completion carries tool calls, so the kind arrives as this follow-up + // event; tag the newest content segment (still running at this point — + // tool_call_started closes it afterwards). First writer wins, matching + // the persistence side. + const kind = typeof data?.kind === 'string' ? data.kind : '' + if (!kind) return + const seg = currentSegments.value.findLast((s: MessageSegment) => s.type === 'content') + if (seg && !seg.kind) { + seg.kind = kind + flushSegmentsToMessage() + } + }) + stream.on('thinking_delta', (data) => { if (isStaleEvent(data)) return // Suppress thinking display when thinkingLevel=off @@ -731,6 +759,9 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (remote.supersededReason !== undefined) { next.supersededReason = remote.supersededReason } + if (next.kind == null && remote.kind != null) { + next.kind = remote.kind + } // Server wall-clock bounds are authoritative for durations // ("thought for Ns"). Local segments often miss endTimestamp: // round-boundary closes flip status without stamping an end, diff --git a/mateclaw-ui/src/composables/chat/useStream.ts b/mateclaw-ui/src/composables/chat/useStream.ts index 6b547f6f..781e026a 100644 --- a/mateclaw-ui/src/composables/chat/useStream.ts +++ b/mateclaw-ui/src/composables/chat/useStream.ts @@ -27,6 +27,9 @@ export type SSEEventType = | 'tool_approval_resolved' // 恢复/警告事件 | 'warning' + // Producer-assigned content semantics of the span that just closed its + // completion (pre_tool_narration / grounded_narration / final_answer) + | 'segment_kind' // Interrupt + Queue 事件 | 'heartbeat' | 'turn_interrupt_requested' diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 0f032719..9f33aa0a 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -265,6 +265,14 @@ export interface MessageSegment { repetitionWarning?: 'char_pattern' | 'sentence_repetition' /** Number of trailing characters dropped when the repetition guard fired. */ truncatedChars?: number + /** + * Producer-assigned content semantics from the backend agent graph: + * 'pre_tool_narration' (provisional — text emitted alongside tool calls + * before any observation this turn), 'grounded_narration', or + * 'final_answer'. Delivered live via the segment_kind SSE event and + * persisted in metadata.segments; absent on legacy messages. + */ + kind?: string /** Backend marked this model-predicted tool result as replaced by a later actual tool result. */ superseded?: boolean /** Segment ID that replaced this pre-tool prediction. */