mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +08:00
feat(agent,ui): nested subagent timeline + always-on plan panel
This commit is contained in:
parent
66af70388b
commit
8bd8a02cd0
@ -394,6 +394,11 @@ public class ChatStreamTracker {
|
|||||||
|| "error".equals(eventName)
|
|| "error".equals(eventName)
|
||||||
|| "tool_approval_requested".equals(eventName)
|
|| "tool_approval_requested".equals(eventName)
|
||||||
|| "phase".equals(eventName)
|
|| "phase".equals(eventName)
|
||||||
|
// Plan lifecycle events from a child agent: flush buffered
|
||||||
|
// tool calls first so the parent timeline preserves order.
|
||||||
|
|| "plan_created".equals(eventName)
|
||||||
|
|| "plan_step_started".equals(eventName)
|
||||||
|
|| "plan_step_completed".equals(eventName)
|
||||||
|| "done".equals(eventName);
|
|| "done".equals(eventName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -890,6 +890,11 @@ public class DelegateAgentTool {
|
|||||||
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
|
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
|
||||||
String parentConversationId, String childConversationId,
|
String parentConversationId, String childConversationId,
|
||||||
ChatOrigin parentOrigin) {
|
ChatOrigin parentOrigin) {
|
||||||
|
boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId);
|
||||||
|
if (relayChildEvents) {
|
||||||
|
streamTracker.register(childConversationId);
|
||||||
|
streamTracker.incrementFlux(childConversationId);
|
||||||
|
}
|
||||||
DelegationContext.enter(parentConversationId, deniedToolsForChild());
|
DelegationContext.enter(parentConversationId, deniedToolsForChild());
|
||||||
try {
|
try {
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
@ -909,6 +914,9 @@ public class DelegateAgentTool {
|
|||||||
taskIndex, target.getName(), e.getMessage());
|
taskIndex, target.getName(), e.getMessage());
|
||||||
return ChildResult.ofError(taskIndex, target.getName(), e.getMessage());
|
return ChildResult.ofError(taskIndex, target.getName(), e.getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
|
if (relayChildEvents) {
|
||||||
|
streamTracker.complete(childConversationId);
|
||||||
|
}
|
||||||
DelegationContext.exit();
|
DelegationContext.exit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1109,9 +1117,14 @@ public class DelegateAgentTool {
|
|||||||
return childConvId;
|
return childConvId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Child event types that are relayed to the parent for the nested delegation timeline. */
|
||||||
|
private static final Set<String> RELAYED_CHILD_EVENTS = Set.of(
|
||||||
|
"tool_call_started", "tool_call_completed", "phase",
|
||||||
|
"plan_created", "plan_step_started", "plan_step_completed");
|
||||||
|
|
||||||
private Runnable registerRelay(String childConvId, String parentConvId, String childAgentName) {
|
private Runnable registerRelay(String childConvId, String parentConvId, String childAgentName) {
|
||||||
return streamTracker.addEventRelay(childConvId, (eventName, jsonData) -> {
|
return streamTracker.addEventRelay(childConvId, (eventName, jsonData) -> {
|
||||||
if ("tool_call_started".equals(eventName) || "tool_call_completed".equals(eventName) || "phase".equals(eventName)) {
|
if (RELAYED_CHILD_EVENTS.contains(eventName)) {
|
||||||
try {
|
try {
|
||||||
// Parse jsonData into a plain Object so the frontend receives a proper
|
// Parse jsonData into a plain Object so the frontend receives a proper
|
||||||
// JSON object under "data", not a string containing serialized JSON.
|
// JSON object under "data", not a string containing serialized JSON.
|
||||||
@ -1149,28 +1162,65 @@ public class DelegateAgentTool {
|
|||||||
private Runnable registerBatchedRelay(String childConvId, String parentConvId, String childAgentName) {
|
private Runnable registerBatchedRelay(String childConvId, String parentConvId, String childAgentName) {
|
||||||
return streamTracker.addBatchedEventRelay(childConvId, parentConvId, 5, 500L,
|
return streamTracker.addBatchedEventRelay(childConvId, parentConvId, 5, 500L,
|
||||||
(eventName, jsonData) -> {
|
(eventName, jsonData) -> {
|
||||||
if ("tool_call_started".equals(eventName)
|
// The batched relay delivers (1) pass-through events directly
|
||||||
|| "tool_call_completed".equals(eventName)
|
// (plan/phase/error) and (2) batched tool-calls as a
|
||||||
|| "phase".equals(eventName)) {
|
// "delegation_batch" envelope. Unpack each form to a stream
|
||||||
try {
|
// of delegation_progress events on the parent so the frontend
|
||||||
Object parsedData;
|
// only handles a single event shape (see useChat delegation_progress).
|
||||||
try {
|
if ("delegation_batch".equals(eventName)) {
|
||||||
parsedData = objectMapper.readValue(jsonData, Object.class);
|
relayBatchEnvelope(jsonData, childConvId, parentConvId, childAgentName);
|
||||||
} catch (Exception ignored) {
|
} else if (RELAYED_CHILD_EVENTS.contains(eventName)) {
|
||||||
parsedData = jsonData;
|
relayChildEvent(eventName, jsonData, childConvId, parentConvId, childAgentName);
|
||||||
}
|
|
||||||
streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of(
|
|
||||||
"childConversationId", childConvId,
|
|
||||||
"childAgentName", childAgentName,
|
|
||||||
"originalEvent", eventName,
|
|
||||||
"data", parsedData));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("Batched relay error: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forward one child event to the parent as a delegation_progress envelope. */
|
||||||
|
private void relayChildEvent(String eventName, String jsonData,
|
||||||
|
String childConvId, String parentConvId, String childAgentName) {
|
||||||
|
try {
|
||||||
|
Object parsedData;
|
||||||
|
try {
|
||||||
|
parsedData = objectMapper.readValue(jsonData, Object.class);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
parsedData = jsonData;
|
||||||
|
}
|
||||||
|
streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of(
|
||||||
|
"childConversationId", childConvId,
|
||||||
|
"childAgentName", childAgentName,
|
||||||
|
"originalEvent", eventName,
|
||||||
|
"data", parsedData));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Child event relay error: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unpack a delegation_batch envelope and replay each entry as delegation_progress. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void relayBatchEnvelope(String envelopeJson, String childConvId,
|
||||||
|
String parentConvId, String childAgentName) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> envelope = objectMapper.readValue(envelopeJson, Map.class);
|
||||||
|
Object eventsObj = envelope.get("events");
|
||||||
|
if (!(eventsObj instanceof List<?> events)) return;
|
||||||
|
for (Object entryObj : events) {
|
||||||
|
if (!(entryObj instanceof Map<?, ?> entry)) continue;
|
||||||
|
Object name = entry.get("event");
|
||||||
|
Object payload = entry.get("data");
|
||||||
|
if (name == null) continue;
|
||||||
|
if (!RELAYED_CHILD_EVENTS.contains(name.toString())) continue;
|
||||||
|
String payloadJson = payload == null
|
||||||
|
? "{}"
|
||||||
|
: (payload instanceof String s ? s : objectMapper.writeValueAsString(payload));
|
||||||
|
relayChildEvent(name.toString(),
|
||||||
|
payloadJson,
|
||||||
|
childConvId, parentConvId, childAgentName);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Batch envelope relay error: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void broadcastEnd(String parentConvId, String childConvId, String agentName, ChildResult result) {
|
private void broadcastEnd(String parentConvId, String childConvId, String agentName, ChildResult result) {
|
||||||
streamTracker.broadcastObject(parentConvId, "delegation_end", Map.of(
|
streamTracker.broadcastObject(parentConvId, "delegation_end", Map.of(
|
||||||
"childConversationId", childConvId,
|
"childConversationId", childConvId,
|
||||||
|
|||||||
@ -30,11 +30,14 @@
|
|||||||
<!-- 消息体 -->
|
<!-- 消息体 -->
|
||||||
<div class="msg-body" :class="`${role}-body`">
|
<div class="msg-body" :class="`${role}-body`">
|
||||||
<div class="msg-bubble" :class="`${role}-bubble`">
|
<div class="msg-bubble" :class="`${role}-bubble`">
|
||||||
|
<!-- Plan-step panel — always rendered at the top of the bubble whenever
|
||||||
|
this turn has a plan, in both the segmented and fallback render
|
||||||
|
paths, so plan-mode progress is never buried in a collapsed panel. -->
|
||||||
|
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
|
||||||
|
|
||||||
<!-- ===== 分段式渲染模式(Claude Code 风格)===== -->
|
<!-- ===== 分段式渲染模式(Claude Code 风格)===== -->
|
||||||
<template v-if="useSegmentedView">
|
<template v-if="useSegmentedView">
|
||||||
<div class="segments-view">
|
<div class="segments-view">
|
||||||
<!-- 计划步骤面板(始终显示在 segments 之上) -->
|
|
||||||
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
|
|
||||||
<template v-for="iter in groupedIterations" :key="iter.key">
|
<template v-for="iter in groupedIterations" :key="iter.key">
|
||||||
<!-- Iteration interrupted before any output landed — surface a chip
|
<!-- Iteration interrupted before any output landed — surface a chip
|
||||||
so the user knows the agent moved on instead of silently
|
so the user knows the agent moved on instead of silently
|
||||||
@ -117,9 +120,6 @@
|
|||||||
|
|
||||||
<Transition name="thinking-slide">
|
<Transition name="thinking-slide">
|
||||||
<div v-if="executionExpanded" class="execution-content">
|
<div v-if="executionExpanded" class="execution-content">
|
||||||
<!-- Plan 步骤进度 -->
|
|
||||||
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
|
|
||||||
|
|
||||||
<!-- 工具调用列表 -->
|
<!-- 工具调用列表 -->
|
||||||
<div v-if="toolCallsMeta.length" class="tool-calls">
|
<div v-if="toolCallsMeta.length" class="tool-calls">
|
||||||
<div
|
<div
|
||||||
@ -139,7 +139,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!toolCallsMeta.length && !planMeta" class="execution-empty">
|
<div v-if="!toolCallsMeta.length" class="execution-empty">
|
||||||
{{ currentPhaseName }}...
|
{{ currentPhaseName }}...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -974,8 +974,18 @@ const segments = computed<MessageSegment[]>(() => {
|
|||||||
return segs
|
return segs
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 是否使用分段模式渲染(有 segments 数据且包含多个分段) */
|
/**
|
||||||
const useSegmentedView = computed(() => segments.value.length > 1)
|
* Use segmented rendering when there are multiple segments, OR when the turn
|
||||||
|
* contains a delegation segment. Delegations live in `segments` but not in
|
||||||
|
* `metadata.toolCalls`, so the fallback path (which only reads toolCalls)
|
||||||
|
* renders nothing for them — a single-step plan that delegates to a subagent
|
||||||
|
* would otherwise show the subagent call as completely invisible. Forcing
|
||||||
|
* segmented view here makes delegation surface as a timeline entry.
|
||||||
|
*/
|
||||||
|
const useSegmentedView = computed(() =>
|
||||||
|
segments.value.length > 1 ||
|
||||||
|
segments.value.some(s => s.type === 'tool_call' && (s.toolName || '').startsWith('→'))
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group segments by iterationIndex so each ReAct iteration renders as its own
|
* Group segments by iterationIndex so each ReAct iteration renders as its own
|
||||||
@ -1168,8 +1178,9 @@ const executionPhaseLabel = computed(() => {
|
|||||||
|
|
||||||
const showExecutionPanel = computed(() => {
|
const showExecutionPanel = computed(() => {
|
||||||
if (role.value !== 'assistant') return false
|
if (role.value !== 'assistant') return false
|
||||||
// 审批卡片有独立的渲染区域,但 execution panel 也应该在审批阶段展示上下文
|
// The plan-step panel renders top-level outside this execution panel,
|
||||||
return toolCallsMeta.value.length > 0 || !!planMeta.value
|
// so plan presence alone no longer keeps an (otherwise empty) panel open.
|
||||||
|
return toolCallsMeta.value.length > 0
|
||||||
|| (isGenerating.value && parsedMetadata.value?.currentPhase)
|
|| (isGenerating.value && parsedMetadata.value?.currentPhase)
|
||||||
|| !!pendingApproval.value
|
|| !!pendingApproval.value
|
||||||
})
|
})
|
||||||
|
|||||||
@ -56,11 +56,45 @@ const isRead = computed(() => {
|
|||||||
const isSuccess = computed(() => props.segment.status === 'completed' && props.segment.toolSuccess !== false)
|
const isSuccess = computed(() => props.segment.status === 'completed' && props.segment.toolSuccess !== false)
|
||||||
const isError = computed(() => props.segment.status === 'error' || props.segment.toolSuccess === false)
|
const isError = computed(() => props.segment.status === 'error' || props.segment.toolSuccess === false)
|
||||||
const isRunning = computed(() => props.segment.status === 'running')
|
const isRunning = computed(() => props.segment.status === 'running')
|
||||||
|
|
||||||
|
// Nested subagent timeline relayed from the child conversation: the child's own
|
||||||
|
// plan checklist + the tools it called. Only present on delegation segments.
|
||||||
|
const childTimeline = computed(() => isDelegation.value ? props.segment.childTimeline : undefined)
|
||||||
|
const childPlan = computed(() => childTimeline.value?.plan)
|
||||||
|
const childTools = computed(() => childTimeline.value?.tools || [])
|
||||||
|
const hasChildActivity = computed(() =>
|
||||||
|
!!childPlan.value || childTools.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// The body is expandable when there's any nested detail to show – either the
|
||||||
|
// child's activity timeline or the final result preview.
|
||||||
|
const hasBody = computed(() => hasChildActivity.value || !!props.segment.toolResult)
|
||||||
|
|
||||||
|
function childStepStatus(i: number): 'pending' | 'running' | 'completed' {
|
||||||
|
const plan = childPlan.value
|
||||||
|
if (!plan) return 'pending'
|
||||||
|
const done = plan.stepResults?.[i]
|
||||||
|
if (done?.status === 'completed') return 'completed'
|
||||||
|
if (i === plan.currentStep) return 'running'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact progress hint shown in the delegation header (e.g. "2/3" plan steps,
|
||||||
|
// or "4 tools") so collapsed delegations still convey what the subagent did.
|
||||||
|
const childProgress = computed(() => {
|
||||||
|
const plan = childPlan.value
|
||||||
|
if (plan?.steps?.length) {
|
||||||
|
const done = plan.stepResults?.filter(r => r?.status === 'completed').length || 0
|
||||||
|
return `${done}/${plan.steps.length}`
|
||||||
|
}
|
||||||
|
const n = childTools.value.length
|
||||||
|
return n ? `${n} ${n === 1 ? 'tool' : 'tools'}` : ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="seg-tool" :class="{ 'is-running': isRunning, 'is-success': isSuccess, 'is-error': isError }">
|
<div class="seg-tool" :class="{ 'is-running': isRunning, 'is-success': isSuccess, 'is-error': isError }">
|
||||||
<div class="seg-tool__header" @click="segment.toolResult ? (expanded = !expanded) : null">
|
<div class="seg-tool__header" @click="hasBody ? (expanded = !expanded) : null">
|
||||||
<span class="seg-tool__status">
|
<span class="seg-tool__status">
|
||||||
<el-icon v-if="isRunning" class="is-loading" :size="13"><Loading /></el-icon>
|
<el-icon v-if="isRunning" class="is-loading" :size="13"><Loading /></el-icon>
|
||||||
<el-icon v-else-if="isSuccess" :size="13"><Select /></el-icon>
|
<el-icon v-else-if="isSuccess" :size="13"><Select /></el-icon>
|
||||||
@ -72,17 +106,50 @@ const isRunning = computed(() => props.segment.status === 'running')
|
|||||||
<el-icon v-else :size="12"><Setting /></el-icon>
|
<el-icon v-else :size="12"><Setting /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<span class="seg-tool__name">{{ displayName }}</span>
|
<span class="seg-tool__name">{{ displayName }}</span>
|
||||||
|
<span v-if="isDelegation && childProgress" class="seg-tool__badge">{{ childProgress }}</span>
|
||||||
<span v-if="truncatedArgs" class="seg-tool__args">{{ truncatedArgs }}</span>
|
<span v-if="truncatedArgs" class="seg-tool__args">{{ truncatedArgs }}</span>
|
||||||
<el-icon
|
<el-icon
|
||||||
v-if="segment.toolResult"
|
v-if="hasBody"
|
||||||
class="seg-tool__arrow"
|
class="seg-tool__arrow"
|
||||||
:class="{ 'is-open': expanded }"
|
:class="{ 'is-open': expanded }"
|
||||||
:size="11"
|
:size="11"
|
||||||
><ArrowDown /></el-icon>
|
><ArrowDown /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<Transition name="seg-slide">
|
<Transition name="seg-slide">
|
||||||
<div v-if="expanded && segment.toolResult" class="seg-tool__body">
|
<div v-if="expanded && hasBody" class="seg-tool__body">
|
||||||
<pre>{{ resultPreview }}</pre>
|
<!-- Nested subagent timeline (delegation segments) -->
|
||||||
|
<div v-if="hasChildActivity" class="seg-child">
|
||||||
|
<!-- The child agent's own plan checklist, if it ran in plan mode -->
|
||||||
|
<div v-if="childPlan" class="seg-child__plan">
|
||||||
|
<div
|
||||||
|
v-for="(step, i) in childPlan.steps"
|
||||||
|
:key="i"
|
||||||
|
class="seg-child__step"
|
||||||
|
:class="`is-${childStepStatus(i)}`"
|
||||||
|
>
|
||||||
|
<el-icon v-if="childStepStatus(i) === 'running'" class="is-loading" :size="11"><Loading /></el-icon>
|
||||||
|
<el-icon v-else-if="childStepStatus(i) === 'completed'" :size="11"><Select /></el-icon>
|
||||||
|
<span v-else class="seg-child__dot"></span>
|
||||||
|
<span class="seg-child__step-text">{{ step }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- The tools the child agent called -->
|
||||||
|
<div v-if="childTools.length" class="seg-child__tools">
|
||||||
|
<div
|
||||||
|
v-for="(t, i) in childTools"
|
||||||
|
:key="i"
|
||||||
|
class="seg-child__tool"
|
||||||
|
:class="`is-${t.status}`"
|
||||||
|
>
|
||||||
|
<el-icon v-if="t.status === 'running'" class="is-loading" :size="11"><Loading /></el-icon>
|
||||||
|
<el-icon v-else-if="t.status === 'completed'" :size="11"><Select /></el-icon>
|
||||||
|
<el-icon v-else :size="11"><CloseBold /></el-icon>
|
||||||
|
<span class="seg-child__tool-name">{{ getToolLabel(t.name) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Final tool/agent result preview -->
|
||||||
|
<pre v-if="segment.toolResult">{{ resultPreview }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
@ -177,6 +244,51 @@ const isRunning = computed(() => props.segment.status === 'running')
|
|||||||
.seg-tool__body {
|
.seg-tool__body {
|
||||||
padding: 0 10px 6px 22px;
|
padding: 0 10px 6px 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seg-tool__badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 6px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested subagent timeline */
|
||||||
|
.seg-child {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.seg-child__plan {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 4px;
|
||||||
|
border-left: 2px solid var(--mc-border-light);
|
||||||
|
}
|
||||||
|
.seg-child__step,
|
||||||
|
.seg-child__tool {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
|
.seg-child__step.is-running,
|
||||||
|
.seg-child__tool.is-running { color: var(--mc-primary); }
|
||||||
|
.seg-child__step.is-completed,
|
||||||
|
.seg-child__tool.is-completed { color: var(--mc-text-secondary); }
|
||||||
|
.seg-child__tool.is-error { color: var(--mc-danger); }
|
||||||
|
.seg-child__dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mc-text-quaternary, #c0c0c0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
.seg-child__tools {
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
.seg-tool__body pre {
|
.seg-tool__body pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
@ -746,8 +746,8 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
|
|
||||||
// ===== Agent event handlers =====
|
// ===== Agent event handlers =====
|
||||||
|
|
||||||
// Body of tool_call_started — extracted so delegation_batch can replay the
|
// Body of tool_call_started. Used directly and reused once (split out as a
|
||||||
// same behavior for buffered child events without duplicating logic.
|
// function ⟶ no logic duplication).
|
||||||
function handleToolCallStarted(data: any) {
|
function handleToolCallStarted(data: any) {
|
||||||
if (isStaleEvent(data)) return
|
if (isStaleEvent(data)) return
|
||||||
streamPhase.value = 'executing_tool'
|
streamPhase.value = 'executing_tool'
|
||||||
@ -1001,31 +1001,48 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
? rawPayload
|
? rawPayload
|
||||||
: (() => { try { return JSON.parse(String(rawPayload || '{}')) } catch { return {} } })()
|
: (() => { try { return JSON.parse(String(rawPayload || '{}')) } catch { return {} } })()
|
||||||
|
|
||||||
if (data.originalEvent === 'tool_call_started') {
|
// Build a structured child timeline on the delegation segment instead of
|
||||||
const toolName = childData?.toolName || ''
|
// jamming tool names into toolArgs as text. The timeline holds the child
|
||||||
if (toolName) {
|
// agent's own plan checklist + the tools it called, so the UI can render
|
||||||
delegSeg.toolArgs = (delegSeg.toolArgs || '') + `\n → ${toolName}`
|
// a proper nested view (see ToolCallSegment.vue delegation branch).
|
||||||
|
const timeline = (delegSeg.childTimeline ||= { tools: [] })
|
||||||
|
if (!timeline.tools) timeline.tools = []
|
||||||
|
|
||||||
|
switch (data.originalEvent) {
|
||||||
|
case 'tool_call_started': {
|
||||||
|
const name = childData?.toolName || ''
|
||||||
|
if (name) timeline.tools.push({ name, status: 'running' })
|
||||||
|
break
|
||||||
}
|
}
|
||||||
} else if (data.originalEvent === 'tool_call_completed') {
|
case 'tool_call_completed': {
|
||||||
const toolName = childData?.toolName || ''
|
const name = childData?.toolName || ''
|
||||||
const success = childData?.success !== false
|
const ok = childData?.success !== false
|
||||||
if (toolName) {
|
// Match the most recent running entry with this name.
|
||||||
// Replace the matching "→ toolName" hint with "✓/✗ toolName"
|
const entry = [...timeline.tools].reverse()
|
||||||
delegSeg.toolArgs = (delegSeg.toolArgs || '').replace(
|
.find(t => t.name === name && t.status === 'running')
|
||||||
new RegExp(`\\n → ${toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`),
|
if (entry) entry.status = ok ? 'completed' : 'error'
|
||||||
`\n ${success ? '✓' : '✗'} ${toolName}`)
|
break
|
||||||
}
|
}
|
||||||
} else if (data.originalEvent === 'phase') {
|
case 'plan_created': {
|
||||||
const phase = childData?.phase || String(rawPayload || '')
|
const steps = childData?.steps
|
||||||
const phaseHints: Record<string, string> = {
|
if (Array.isArray(steps)) {
|
||||||
reasoning: '…',
|
timeline.plan = { planId: childData?.planId ?? '', steps, currentStep: 0, stepResults: [] }
|
||||||
executing_tool: '→',
|
}
|
||||||
planning: '📋',
|
break
|
||||||
summarizing: '✍',
|
|
||||||
}
|
}
|
||||||
const hint = phaseHints[phase]
|
case 'plan_step_started': {
|
||||||
if (hint && !delegSeg.toolArgs?.endsWith(hint)) {
|
if (timeline.plan && typeof childData?.index === 'number') {
|
||||||
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ' ' + hint
|
timeline.plan.currentStep = childData.index
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'plan_step_completed': {
|
||||||
|
if (timeline.plan && typeof childData?.index === 'number') {
|
||||||
|
const results = [...(timeline.plan.stepResults || [])]
|
||||||
|
results[childData.index] = { result: childData.result ?? '', status: 'completed' }
|
||||||
|
timeline.plan.stepResults = results
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flushSegmentsToMessage()
|
flushSegmentsToMessage()
|
||||||
@ -1106,6 +1123,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
if (delegSeg) {
|
if (delegSeg) {
|
||||||
delegSeg.status = data.success ? 'completed' : 'error'
|
delegSeg.status = data.success ? 'completed' : 'error'
|
||||||
delegSeg.toolSuccess = data.success
|
delegSeg.toolSuccess = data.success
|
||||||
|
if (data.resultPreview) {
|
||||||
|
delegSeg.toolResult = data.resultPreview
|
||||||
|
}
|
||||||
if (data.durationMs) {
|
if (data.durationMs) {
|
||||||
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ` (${Math.round(data.durationMs / 1000)}s)`
|
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ` (${Math.round(data.durationMs / 1000)}s)`
|
||||||
}
|
}
|
||||||
@ -1211,26 +1231,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
flushSegmentsToMessage()
|
flushSegmentsToMessage()
|
||||||
})
|
})
|
||||||
|
|
||||||
stream.on('delegation_batch', (data) => {
|
// Delegation batch envelopes are unpacked server-side into individual
|
||||||
if (isStaleEvent(data)) return
|
// delegation_progress events (see DelegateAgentTool.relayBatchEnvelope),
|
||||||
// Buffered child events from a delegated subagent. Replay them in order
|
// so the frontend only handles delegation_progress — no batch handler needed.
|
||||||
// through the same handlers as live events so segment state stays
|
|
||||||
// consistent with the rest of the timeline.
|
|
||||||
const events = Array.isArray(data?.events) ? data.events : []
|
|
||||||
for (const ev of events) {
|
|
||||||
const evData = ev?.data ?? {}
|
|
||||||
switch (ev?.event) {
|
|
||||||
case 'tool_call_started':
|
|
||||||
handleToolCallStarted(evData)
|
|
||||||
break
|
|
||||||
case 'tool_call_completed':
|
|
||||||
handleToolCallCompleted(evData)
|
|
||||||
break
|
|
||||||
// Other event kinds (phase / thinking_delta / content_delta / etc.)
|
|
||||||
// are not currently produced inside batches; extend here when added.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
stream.on('plan_created', (data) => {
|
stream.on('plan_created', (data) => {
|
||||||
if (isStaleEvent(data)) return
|
if (isStaleEvent(data)) return
|
||||||
|
|||||||
@ -67,7 +67,6 @@ export type SSEEventType =
|
|||||||
| 'iteration_end'
|
| 'iteration_end'
|
||||||
| 'content_truncated'
|
| 'content_truncated'
|
||||||
| 'tool_result_chunk'
|
| 'tool_result_chunk'
|
||||||
| 'delegation_batch'
|
|
||||||
// Recovery affordance for non-transient errors (ERROR_FALLBACK turns)
|
// Recovery affordance for non-transient errors (ERROR_FALLBACK turns)
|
||||||
| 'feedback_event'
|
| 'feedback_event'
|
||||||
// Context compaction lifecycle. Fired by ConversationWindowManager
|
// Context compaction lifecycle. Fired by ConversationWindowManager
|
||||||
|
|||||||
@ -168,6 +168,15 @@ export interface MessageSegment {
|
|||||||
approval?: PendingApprovalMeta
|
approval?: PendingApprovalMeta
|
||||||
/** type=plan */
|
/** type=plan */
|
||||||
plan?: PlanMeta
|
plan?: PlanMeta
|
||||||
|
/**
|
||||||
|
* For delegation segments (toolName starts with "→"): the subagent's own
|
||||||
|
* activity, relayed from the child conversation. Renders as a nested timeline
|
||||||
|
* (its plan checklist + the tools it called) instead of jammed text in toolArgs.
|
||||||
|
*/
|
||||||
|
childTimeline?: {
|
||||||
|
plan?: PlanMeta
|
||||||
|
tools?: { name: string; status: 'running' | 'completed' | 'error' }[]
|
||||||
|
}
|
||||||
/** 时间戳 */
|
/** 时间戳 */
|
||||||
timestamp?: number
|
timestamp?: number
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user