fix(delegate): distinguish outcome/blank/rawLength in parallel delegation, translate all comments to English

This commit is contained in:
matevip 2026-04-23 08:09:48 +08:00
parent 9632edb008
commit 9740d46fbc
2 changed files with 414 additions and 242 deletions

View File

@ -19,16 +19,17 @@ import java.util.concurrent.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 内置工具Agent 委派 Agent 协作 * Built-in tool: Agent delegation (multi-agent collaboration).
* <p> * <p>
* 支持两种模式 * Two modes:
* <ul> * <ul>
* <li>{@link #delegateToAgent} 单任务委派串行</li> * <li>{@link #delegateToAgent} single-task serial delegation</li>
* <li>{@link #delegateParallel} 多任务并行委派最多 3 个子 Agent 同时执行</li> * <li>{@link #delegateParallel} parallel delegation to up to 3 child agents simultaneously</li>
* </ul> * </ul>
* 被委派的 Agent 在独立子会话中运行记录父子关系 * Each delegated agent runs in an isolated child conversation (parent-child relationship is
* 执行期间通过 SSE 事件 relay 向父会话实时推送进度 * persisted). Progress is relayed to the parent session via SSE events in real time.
* Agent 的工具集自动收窄禁止递归委派和 Agent 发现工具 * Child agents have a narrowed tool set recursive delegation and agent-discovery tools are
* blocked.
* *
* @author MateClaw Team * @author MateClaw Team
*/ */
@ -48,14 +49,14 @@ public class DelegateAgentTool {
*/ */
private static final int PARALLEL_TIMEOUT_SECONDS = 120; private static final int PARALLEL_TIMEOUT_SECONDS = 120;
/** 子 Agent 禁用的工具:防递归 + 防副作用 */ /** Tools blocked for child agents — prevents recursion and side effects. */
private static final Set<String> CHILD_DENIED_TOOLS = Set.of( private static final Set<String> CHILD_DENIED_TOOLS = Set.of(
"delegateToAgent", // 禁止递归委派 "delegateToAgent", // no recursive serial delegation
"delegateParallel", // 禁止并行递归 "delegateParallel", // no recursive parallel delegation
"listAvailableAgents" // Agent 不需要发现其他 Agent "listAvailableAgents" // child agents do not need to discover other agents
); );
/** 并行委派执行器JDK 21 虚拟线程,每个子 Agent 一个轻量级虚拟线程 */ /** Executor for parallel delegation — one JDK 21 virtual thread per child agent. */
private static final ExecutorService DELEGATION_EXECUTOR = private static final ExecutorService DELEGATION_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor(); Executors.newVirtualThreadPerTaskExecutor();
@ -65,7 +66,7 @@ public class DelegateAgentTool {
private final ConversationService conversationService; private final ConversationService conversationService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
// ==================== 单任务委派 ==================== // ==================== Single-task delegation ====================
@vip.mate.tool.ConcurrencyUnsafe("spawns a child agent session and writes to mate_conversation; serialize to keep session graph deterministic") @vip.mate.tool.ConcurrencyUnsafe("spawns a child agent session and writes to mate_conversation; serialize to keep session graph deterministic")
@Tool(description = """ @Tool(description = """
@ -97,10 +98,10 @@ public class DelegateAgentTool {
String parentConversationId = resolveParentConversationId(); String parentConversationId = resolveParentConversationId();
String childConversationId = createChildConv(target, parentConversationId); String childConversationId = createChildConv(target, parentConversationId);
log.info("Agent 委派: depth={}, target={}({}), childConv={}, parentConv={}", log.info("Agent delegation: depth={}, target={}({}), childConv={}, parentConv={}",
depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId); depth + 1, target.getName(), target.getId(), childConversationId, parentConversationId);
// SSE 广播 + relay // Broadcast delegation_start + register event relay to parent session
boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId); boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId);
if (hasParent) { if (hasParent) {
streamTracker.broadcastObject(parentConversationId, "delegation_start", Map.of( streamTracker.broadcastObject(parentConversationId, "delegation_start", Map.of(
@ -110,10 +111,10 @@ public class DelegateAgentTool {
} }
Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null; Runnable stopRelay = hasParent ? registerRelay(childConversationId, parentConversationId, target.getName()) : null;
// 执行 // Execute child agent
ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId); ChildResult result = runSingleChild(0, target, task, parentConversationId, childConversationId);
// 清理 + 广播结果 // Cleanup relay, then broadcast final result
if (stopRelay != null) stopRelay.run(); if (stopRelay != null) stopRelay.run();
if (hasParent) { if (hasParent) {
broadcastEnd(parentConversationId, childConversationId, target.getName(), result); broadcastEnd(parentConversationId, childConversationId, target.getName(), result);
@ -122,7 +123,7 @@ public class DelegateAgentTool {
return result.toToolResponse(target.getName()); return result.toToolResponse(target.getName());
} }
// ==================== 并行委派 ==================== // ==================== Parallel delegation ====================
@vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize") @vip.mate.tool.ConcurrencyUnsafe("internally fans out to its own thread pool; outer executor must not double-parallelize")
@Tool(description = """ @Tool(description = """
@ -134,7 +135,7 @@ public class DelegateAgentTool {
@ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]") @ToolParam(description = "JSON array of tasks: [{\"agentName\":\"X\",\"task\":\"Y\"}, ...]")
String tasksJson) { String tasksJson) {
// 1. 解析任务列表 // 1. Parse task list
List<Map<String, String>> tasks; List<Map<String, String>> tasks;
try { try {
tasks = objectMapper.readValue(tasksJson, new TypeReference<>() {}); tasks = objectMapper.readValue(tasksJson, new TypeReference<>() {});
@ -157,7 +158,7 @@ public class DelegateAgentTool {
String parentConversationId = resolveParentConversationId(); String parentConversationId = resolveParentConversationId();
boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId); boolean hasParent = parentConversationId != null && streamTracker.isRunning(parentConversationId);
// 2. 主线程校验所有 Agent + 创建子会话 + 注册 relay // 2. Main thread: validate agents, create child conversations, register relays
record PreparedChild(int index, AgentEntity agent, String task, String childConvId, Runnable stopRelay) {} record PreparedChild(int index, AgentEntity agent, String task, String childConvId, Runnable stopRelay) {}
List<PreparedChild> prepared = new ArrayList<>(); List<PreparedChild> prepared = new ArrayList<>();
List<String> errors = new ArrayList<>(); List<String> errors = new ArrayList<>();
@ -187,9 +188,9 @@ public class DelegateAgentTool {
return "[错误] 所有任务校验失败:\n" + String.join("\n", errors); return "[错误] 所有任务校验失败:\n" + String.join("\n", errors);
} }
log.info("并行委派: {} 个任务, parentConv={}", prepared.size(), parentConversationId); log.info("Parallel delegation: {} tasks, parentConv={}", prepared.size(), parentConversationId);
// 3. 广播 delegation_start并行模式 // 3. Broadcast delegation_start (parallel mode)
if (hasParent) { if (hasParent) {
List<Map<String, String>> childrenInfo = prepared.stream().map(p -> Map.of( List<Map<String, String>> childrenInfo = prepared.stream().map(p -> Map.of(
"childConversationId", p.childConvId, "childConversationId", p.childConvId,
@ -201,7 +202,7 @@ public class DelegateAgentTool {
"children", childrenInfo)); "children", childrenInfo));
} }
// 4. 并行执行 // 4. Fan out execute children in parallel
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
Map<Integer, CompletableFuture<ChildResult>> futures = new LinkedHashMap<>(); Map<Integer, CompletableFuture<ChildResult>> futures = new LinkedHashMap<>();
@ -212,38 +213,50 @@ public class DelegateAgentTool {
// Broadcast per-child completion as soon as each child finishes // Broadcast per-child completion as soon as each child finishes
// frontend can update that child's status without waiting for all children. // frontend can update that child's status without waiting for all children.
// Guard: skip CancellationException (fired when the timeout loop calls cancel(true))
// because the timeout result is already handled in the collection loop below and
// emitting here first would race-replace the correct "timeout" error before delegation_end
// has a chance to patch remaining running segments.
if (hasParent) { if (hasParent) {
final String parentConvIdFinal = parentConversationId; final String parentConvIdFinal = parentConversationId;
future.whenComplete((result, ex) -> { future.whenComplete((result, ex) -> {
if (ex instanceof java.util.concurrent.CancellationException) return;
if (!streamTracker.isRunning(parentConvIdFinal)) return; if (!streamTracker.isRunning(parentConvIdFinal)) return;
ChildResult r = (result != null) ? result ChildResult r = (result != null) ? result
: ChildResult.error(p.index, p.agent.getName(), ex != null ? ex.getMessage() : "Unknown error"); : ChildResult.ofError(p.index, p.agent.getName(),
streamTracker.broadcastObject(parentConvIdFinal, "delegation_child_complete", Map.of( ex != null ? ex.getMessage() : "Unknown error");
"taskIndex", r.taskIndex, Map<String, Object> payload = new java.util.LinkedHashMap<>();
"childConversationId", p.childConvId, payload.put("taskIndex", r.taskIndex);
"childAgentName", r.agentName, payload.put("childConversationId", p.childConvId);
"success", r.success, payload.put("childAgentName", r.agentName);
"durationMs", r.durationMs, payload.put("success", r.success);
"resultPreview", r.success ? truncate(r.result, 150) payload.put("outcome", r.outcome);
: (r.error != null ? r.error : "error"))); payload.put("rawLength", r.rawLength);
payload.put("trimmedLength", r.trimmedLength);
payload.put("blank", r.isBlank());
payload.put("durationMs", r.durationMs);
payload.put("resultPreview", r.success
? truncate(r.result, 400)
: (r.error != null ? r.error : "error"));
streamTracker.broadcastObject(parentConvIdFinal, "delegation_child_complete", payload);
}); });
} }
futures.put(p.index, future); futures.put(p.index, future);
} }
// 5. 等待全部完成带超时 // 5. Wait for all children (with timeout)
List<ChildResult> results = new ArrayList<>(); List<ChildResult> results = new ArrayList<>();
try { try {
CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0])) CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0]))
.get(PARALLEL_TIMEOUT_SECONDS, TimeUnit.SECONDS); .get(PARALLEL_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException e) { } catch (TimeoutException e) {
log.warn("并行委派超时 ({}s),收集已完成的结果", PARALLEL_TIMEOUT_SECONDS); log.warn("Parallel delegation timed out ({}s), collecting completed results", PARALLEL_TIMEOUT_SECONDS);
} catch (Exception e) { } catch (Exception e) {
log.error("并行委派异常: {}", e.getMessage()); log.error("Parallel delegation error: {}", e.getMessage());
} }
// 收集结果已完成的 + 超时的 // Collect results completed futures get their value; unfinished ones are cancelled and recorded as timeout
for (var entry : futures.entrySet()) { for (var entry : futures.entrySet()) {
int idx = entry.getKey(); int idx = entry.getKey();
CompletableFuture<ChildResult> f = entry.getValue(); CompletableFuture<ChildResult> f = entry.getValue();
@ -254,29 +267,39 @@ public class DelegateAgentTool {
try { try {
results.add(f.get()); results.add(f.get());
} catch (Exception ex) { } catch (Exception ex) {
results.add(ChildResult.error(idx, agentName, ex.getMessage())); results.add(ChildResult.ofError(idx, agentName, ex.getMessage()));
} }
} else { } else {
f.cancel(true); f.cancel(true);
results.add(ChildResult.error(idx, agentName, "超时 (" + PARALLEL_TIMEOUT_SECONDS + "s)")); // Use ofTimeout so outcome="timeout" is explicit and distinct from "error".
results.add(ChildResult.ofTimeout(idx, agentName, PARALLEL_TIMEOUT_SECONDS));
} }
} }
long totalDurationMs = System.currentTimeMillis() - startTime; long totalDurationMs = System.currentTimeMillis() - startTime;
// 6. 清理 relay // 6. Stop all relays
for (PreparedChild p : prepared) { for (PreparedChild p : prepared) {
if (p.stopRelay != null) p.stopRelay.run(); if (p.stopRelay != null) p.stopRelay.run();
} }
// 7. 广播 delegation_end含每个子任务的摘要前端可用于展示分项结果 // 7. Broadcast delegation_end with per-child structured summary
if (hasParent) { if (hasParent) {
List<Map<String, Object>> childResults = results.stream().map(r -> { List<Map<String, Object>> childResults = results.stream().map(r -> {
Map<String, Object> m = new java.util.LinkedHashMap<>(); Map<String, Object> m = new java.util.LinkedHashMap<>();
m.put("taskIndex", r.taskIndex); m.put("taskIndex", r.taskIndex);
m.put("agentName", r.agentName); m.put("agentName", r.agentName);
m.put("success", r.success); m.put("success", r.success);
m.put("outcome", r.outcome); // "success"|"blank_success"|"timeout"|"error"
m.put("rawLength", r.rawLength); // chars before truncation
m.put("trimmedLength", r.trimmedLength);
m.put("blank", r.isBlank());
m.put("durationMs", r.durationMs); m.put("durationMs", r.durationMs);
// childConversationId for stable frontend segment lookup
prepared.stream()
.filter(p -> p.index == r.taskIndex)
.findFirst()
.ifPresent(p -> m.put("childConversationId", p.childConvId));
if (!r.success && r.error != null) m.put("error", r.error); if (!r.success && r.error != null) m.put("error", r.error);
return m; return m;
}).toList(); }).toList();
@ -285,66 +308,185 @@ public class DelegateAgentTool {
"totalDurationMs", totalDurationMs, "totalDurationMs", totalDurationMs,
"success", results.stream().allMatch(r -> r.success), "success", results.stream().allMatch(r -> r.success),
"completedCount", results.stream().filter(r -> r.success).count(), "completedCount", results.stream().filter(r -> r.success).count(),
"blankCount", results.stream().filter(ChildResult::isBlank).count(),
"totalCount", results.size(), "totalCount", results.size(),
"childResults", childResults)); "childResults", childResults));
} }
// 8. 构建返回结果 // 8. Build return text structured so the parent LLM cannot misread current results
// using memory of past timeouts. The machine-readable header line is the source of truth.
results.sort(Comparator.comparingInt(r -> r.taskIndex)); results.sort(Comparator.comparingInt(r -> r.taskIndex));
long successCount = results.stream().filter(r -> r.success && !r.isBlank()).count();
long blankCount = results.stream().filter(ChildResult::isBlank).count();
long timeoutCount = results.stream().filter(r -> "timeout".equals(r.outcome)).count();
long errorCount = results.stream().filter(r -> "error".equals(r.outcome)).count();
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
// Machine-readable summary line (highest priority, appears first).
// Explicit blank/timeout/error counts prevent the parent agent from misreading a
// successful run as a timeout even when historical memory says "this agent often times out".
sb.append("[PARALLEL_DELEGATION_RESULT]")
.append(" total=").append(results.size())
.append(" success=").append(successCount)
.append(" blank_success=").append(blankCount)
.append(" timeout=").append(timeoutCount)
.append(" error=").append(errorCount)
.append(" durationMs=").append(totalDurationMs)
.append("\n\n");
// Important: this result is from the current execution. Any timeout entries in the
// conversation history were from previous runs and must not be applied to this result.
sb.append("⚠ 注意:本次结果基于当前执行,与历史对话中出现的超时记录无关。\n\n");
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
sb.append("⚠️ 部分任务未执行:\n"); sb.append("⚠️ 部分任务未执行Agent 未找到或参数错误)\n");
errors.forEach(e -> sb.append(" ").append(e).append("\n")); errors.forEach(e -> sb.append(" ").append(e).append("\n"));
sb.append("\n"); sb.append("\n");
} }
sb.append("并行执行 ").append(results.size()).append(" 个任务(总耗时 ")
.append(totalDurationMs / 1000).append("s\n\n"); sb.append("## 各子任务执行结果\n\n");
for (ChildResult r : results) { for (ChildResult r : results) {
sb.append("---\n### [任务 ").append(r.taskIndex + 1).append("] Agent「").append(r.agentName).append(""); sb.append("### [任务 ").append(r.taskIndex + 1).append("] ").append(r.agentName).append("\n");
sb.append(r.success ? "" : "").append(" (").append(r.durationMs / 1000).append("s)\n\n"); // Per-row machine-readable status impossible to confuse with a different outcome
sb.append(r.success ? r.result : "[错误] " + r.error).append("\n\n"); sb.append("outcome=").append(r.outcome)
.append(" | contentLength=").append(r.trimmedLength).append("chars")
.append(" | rawLength=").append(r.rawLength).append("chars")
.append(" | duration=").append(r.durationMs / 1000).append("s")
.append("\n\n");
switch (r.outcome) {
case "success" -> {
sb.append("✅ 执行成功,有实质内容(").append(r.trimmedLength).append(" 字符)\n\n");
sb.append(r.result);
}
case "blank_success" -> {
sb.append("⚠ 执行成功但返回内容为空rawLength=").append(r.rawLength)
.append("trim 后 0 字符)。请勿将此误报为超时或失败——子 Agent 已正常完成,只是本次无输出。\n");
}
case "timeout" ->
sb.append("❌ 超时(").append(PARALLEL_TIMEOUT_SECONDS).append("s 内未返回)\n");
default ->
sb.append("❌ 失败:").append(r.error).append("\n");
}
sb.append("\n");
} }
return truncate(sb.toString(), MAX_RESULT_LENGTH * 2); // 并行结果允许更长 return truncate(sb.toString(), MAX_RESULT_LENGTH * 2); // 并行结果允许更长
} }
// ==================== Agent 执行/并行共用 ==================== // ==================== Child agent execution (shared by single and parallel paths) ====================
/** /**
* 执行单个子 Agent在子线程内独立设置 DelegationContext解决 ThreadLocal 并行问题 * Runs a single child agent. Sets up {@link DelegationContext} independently per virtual thread
* so that parallel children do not share ThreadLocal state.
* <p>
* Raw result length must be measured <em>before</em> calling {@code truncate()}, otherwise
* {@link ChildResult#rawLength} and {@link ChildResult#trimmedLength} would always reflect the
* truncated length, making "blank_success" detection unreliable.
*/ */
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) {
DelegationContext.enter(parentConversationId, CHILD_DENIED_TOOLS); DelegationContext.enter(parentConversationId, CHILD_DENIED_TOOLS);
try { try {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
String result = agentService.chat(target.getId(), task, childConversationId); String rawResult = agentService.chat(target.getId(), task, childConversationId);
long durationMs = System.currentTimeMillis() - startTime; long durationMs = System.currentTimeMillis() - startTime;
return ChildResult.success(taskIndex, target.getName(), truncate(result, MAX_RESULT_LENGTH), durationMs); // Measure lengths before truncation so ChildResult carries accurate metadata.
return ChildResult.ofSuccess(taskIndex, target.getName(), rawResult, durationMs,
MAX_RESULT_LENGTH);
} catch (Exception e) { } catch (Exception e) {
log.error("子 Agent 执行失败: taskIndex={}, agent={}, error={}", log.error("Child agent failed: taskIndex={}, agent={}, error={}",
taskIndex, target.getName(), e.getMessage()); taskIndex, target.getName(), e.getMessage());
return ChildResult.error(taskIndex, target.getName(), e.getMessage()); return ChildResult.ofError(taskIndex, target.getName(), e.getMessage());
} finally { } finally {
DelegationContext.exit(); DelegationContext.exit();
} }
} }
/** 子 Agent 执行结果 */ /**
private record ChildResult(int taskIndex, String agentName, boolean success, * Result carrier for a single child agent execution.
String result, String error, long durationMs) { *
* <p>{@code outcome} values:
* <ul>
* <li>{@code "success"} completed successfully with non-empty content (trimmedLength > 0)</li>
* <li>{@code "blank_success"} completed successfully but returned empty content (trimmedLength == 0)</li>
* <li>{@code "timeout"} did not complete within the parallel wait window</li>
* <li>{@code "error"} threw an exception during execution</li>
* </ul>
*
* <p>{@code rawLength} and {@code trimmedLength} are measured before truncation and reflect the
* true content length.
*/
private record ChildResult(
int taskIndex, String agentName, boolean success,
String result, String error, long durationMs,
/** "success" | "blank_success" | "timeout" | "error" */
String outcome,
int rawLength, int trimmedLength) {
/** Whether the child returned no usable content (blank_success). */
boolean isBlank() { return "blank_success".equals(outcome); }
/**
* Factory for a successful child execution.
* Measures lengths from the raw result before applying the truncation limit.
*/
static ChildResult ofSuccess(int idx, String name, String rawResult, long ms, int maxLen) {
String safe = rawResult != null ? rawResult : "";
String trimmed = safe.trim();
boolean blank = trimmed.isEmpty();
return new ChildResult(
idx, name, true,
truncate(safe, maxLen),
null, ms,
blank ? "blank_success" : "success",
safe.length(), trimmed.length());
}
/**
* Factory for a child that failed (exception or timeout).
* Detects timeout by inspecting the error message so callers don't need to branch.
*/
static ChildResult ofError(int idx, String name, String err) {
String msg = err != null ? err : "Unknown error";
boolean isTimeout = msg.contains("超时") || msg.toLowerCase().contains("timeout");
return new ChildResult(idx, name, false, null, msg, 0,
isTimeout ? "timeout" : "error", 0, 0);
}
/** Factory for an explicit timeout (parallel window exceeded). */
static ChildResult ofTimeout(int idx, String name, int timeoutSec) {
String msg = "超时 (" + timeoutSec + "s)";
return new ChildResult(idx, name, false, null, msg, (long) timeoutSec * 1000L,
"timeout", 0, 0);
}
// Legacy shims kept for callers that pre-date the factory methods
static ChildResult success(int idx, String name, String result, long ms) { static ChildResult success(int idx, String name, String result, long ms) {
return new ChildResult(idx, name, true, result, null, ms); // result may already be truncated at call site lengths will be approximate
String safe = result != null ? result : "";
String trimmed = safe.trim();
boolean blank = trimmed.isEmpty();
return new ChildResult(idx, name, true, safe, null, ms,
blank ? "blank_success" : "success", safe.length(), trimmed.length());
} }
static ChildResult error(int idx, String name, String err) { static ChildResult error(int idx, String name, String err) {
return new ChildResult(idx, name, false, null, err != null ? err : "Unknown error", 0); return ofError(idx, name, err);
} }
String toToolResponse(String agentName) { String toToolResponse(String agentName) {
if (success) return "[Agent「" + agentName + "」的回复]\n\n" + result; if (success) return "[Agent「" + agentName + "」的回复]\n\n" + (result != null ? result : "");
return "[错误] Agent「" + agentName + "」执行失败: " + error; return "[错误] Agent「" + agentName + "」执行失败: " + error;
} }
private static String truncate(String text, int maxLength) {
if (text == null) return "";
if (text.length() <= maxLength) return text;
return text.substring(0, maxLength) + "\n... [截断,原文 " + text.length() + " 字符]";
}
} }
// ==================== 辅助方法 ==================== // ==================== Helper methods ====================
@Tool(description = "List all available Agents (enabled), including name, type, and description.") @Tool(description = "List all available Agents (enabled), including name, type, and description.")
public String listAvailableAgents() { public String listAvailableAgents() {
@ -386,11 +528,20 @@ public class DelegateAgentTool {
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 ("tool_call_started".equals(eventName) || "tool_call_completed".equals(eventName) || "phase".equals(eventName)) {
try { try {
// Parse jsonData into a plain Object so the frontend receives a proper
// JSON object under "data", not a string containing serialized JSON.
// If parsing fails (e.g. plain text payload), fall back to the raw string.
Object parsedData;
try {
parsedData = objectMapper.readValue(jsonData, Object.class);
} catch (Exception ignored) {
parsedData = jsonData;
}
streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of( streamTracker.broadcastObject(parentConvId, "delegation_progress", Map.of(
"childConversationId", childConvId, "childConversationId", childConvId,
"childAgentName", childAgentName, "childAgentName", childAgentName,
"originalEvent", eventName, "originalEvent", eventName,
"data", jsonData)); "data", parsedData));
} catch (Exception e) { } catch (Exception e) {
log.debug("Relay error: {}", e.getMessage()); log.debug("Relay error: {}", e.getMessage());
} }

View File

@ -1,12 +1,13 @@
/** /**
* Composable * Unified chat composable.
* useMessagesuseStreamuseMessageQueue * Integrates useMessages, useStream, and useMessageQueue into a complete chat feature.
* *
* claude-code-haha Interrupt + Queue + Resume * Core mechanism (Interrupt + Queue + Resume model):
* - * - New messages can be sent while a response is already generating.
* - interrupt * - Interruptible phases (thinking/streaming/executing_tool): sends an interrupt request;
* - * the queued message resumes automatically after interruption.
* - * - Non-interruptible phases: message is queued and auto-resumed when the current step ends.
* - During approval: message is queued, approval flow is not interrupted.
*/ */
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useMessages } from './useMessages' import { useMessages } from './useMessages'
@ -17,78 +18,78 @@ import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
import { http } from '@/api' import { http } from '@/api'
export interface UseChatOptions { export interface UseChatOptions {
/** API 基础 URL */ /** Base API URL */
baseUrl: string baseUrl: string
/** 认证 Token */ /** Auth token */
token?: string token?: string
/** 当前思考深度(响应式 refoff 时抑制 thinking 展示 */ /** Current thinking depth (reactive ref); when "off", thinking segments are suppressed */
thinkingLevel?: import('vue').Ref<string> thinkingLevel?: import('vue').Ref<string>
/** /**
* done/error/stopped * Unified callback fired when the stream ends (done/error/stopped all trigger this).
* reconcile * The caller should perform history reconcile / persistence in this callback.
*/ */
onStreamEnd?: (meta: StreamEndMeta) => void onStreamEnd?: (meta: StreamEndMeta) => void
} }
/** 流结束元信息 */ /** Metadata emitted when a stream ends */
export interface StreamEndMeta { export interface StreamEndMeta {
conversationId: string conversationId: string
reason: 'completed' | 'stopped' | 'interrupted' | 'failed' | 'error' | 'awaiting_approval' reason: 'completed' | 'stopped' | 'interrupted' | 'failed' | 'error' | 'awaiting_approval'
/** 后端持久化的 assistant 消息 ID若有 */ /** Backend-persisted assistant message ID, if available */
assistantMessageId?: number assistantMessageId?: number
/** 后端是否已持久化 */ /** Whether the backend has already persisted the message */
persisted?: boolean persisted?: boolean
/** 后端当前消息总数 */ /** Total message count reported by the backend */
messageCount?: number messageCount?: number
} }
export interface UseChatReturn { export interface UseChatReturn {
/** 消息列表 */ /** Message list */
messages: import('vue').Ref<Message[]> messages: import('vue').Ref<Message[]>
/** 是否正在生成 */ /** Whether the assistant is currently generating */
isGenerating: import('vue').ComputedRef<boolean> isGenerating: import('vue').ComputedRef<boolean>
/** 当前流阶段 */ /** Current stream phase */
streamPhase: import('vue').Ref<StreamPhase> streamPhase: import('vue').Ref<StreamPhase>
/** 最近一次阶段事件 */ /** Most recent phase event */
phaseInfo: import('vue').Ref<PhaseEventData | null> phaseInfo: import('vue').Ref<PhaseEventData | null>
/** 当前错误 */ /** Current error */
error: import('vue').Ref<Error | null> error: import('vue').Ref<Error | null>
/** 排队的消息 */ /** Queued message waiting to be sent */
queuedMessage: import('vue').Ref<QueuedMessage | null> queuedMessage: import('vue').Ref<QueuedMessage | null>
/** 是否有排队消息 */ /** Whether there is a queued message */
hasQueued: import('vue').ComputedRef<boolean> hasQueued: import('vue').ComputedRef<boolean>
/** 排队消息数量 */ /** Number of queued messages */
queueSize: import('vue').ComputedRef<number> queueSize: import('vue').ComputedRef<number>
/** 心跳数据 */ /** Latest heartbeat data */
heartbeat: import('vue').Ref<HeartbeatData | null> heartbeat: import('vue').Ref<HeartbeatData | null>
/** 发送消息(运行中也可调用,自动走 interrupt/queue */ /** Send a message (can be called while generating — automatically routes to interrupt/queue) */
sendMessage: (content: string, options: SendMessageOptions) => Promise<void> sendMessage: (content: string, options: SendMessageOptions) => Promise<void>
/** 停止生成(用户主动停止,不自动续跑) */ /** Stop generation (user-initiated stop; does not auto-resume queued messages) */
stopGeneration: () => void stopGeneration: () => void
/** 取消排队消息 */ /** Cancel the queued message */
cancelQueued: () => void cancelQueued: () => void
/** 重新生成 */ /** Regenerate a message */
regenerate: (messageId: string | number) => Promise<void> regenerate: (messageId: string | number) => Promise<void>
/** 添加消息 */ /** Add a message */
addMessage: (message: Omit<Message, 'id' | 'createTime'> & { id?: string | number }) => Message addMessage: (message: Omit<Message, 'id' | 'createTime'> & { id?: string | number }) => Message
/** 清空消息 */ /** Clear all messages */
clearMessages: () => void clearMessages: () => void
/** 重连到运行中的流 */ /** Reconnect to a stream that is already running on the backend */
reconnectStream: (conversationId: string) => Promise<void> reconnectStream: (conversationId: string) => Promise<void>
/** 彻底重置流上下文 — 切换/新建会话时调用 */ /** Fully reset stream context — call when switching or creating a conversation */
resetForNewConversation: () => void resetForNewConversation: () => void
} }
export interface SendMessageOptions { export interface SendMessageOptions {
/** 会话 ID */ /** Conversation ID */
conversationId: string conversationId: string
/** Agent ID */ /** Agent ID */
agentId: string | number agentId: string | number
/** 附件列表 */ /** Attachment list */
attachments?: MessageContentPart[] attachments?: MessageContentPart[]
/** 消息内容 */ /** Message content parts */
contentParts?: MessageContentPart[] contentParts?: MessageContentPart[]
/** 思考深度:off / low / medium / high / max */ /** Thinking depth: off / low / medium / high / max */
thinkingLevel?: string thinkingLevel?: string
} }
@ -97,7 +98,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const thinkingLevelRef = options.thinkingLevel const thinkingLevelRef = options.thinkingLevel
/** /**
* fetch localStorage token useStream / http.ts * Authenticated fetch wrapper reads the token from localStorage (consistent with useStream / http.ts).
*/ */
const fetchWithAuth = (url: string, init: RequestInit = {}): Promise<Response> => { const fetchWithAuth = (url: string, init: RequestInit = {}): Promise<Response> => {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@ -112,32 +113,32 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const error = ref<Error | null>(null) const error = ref<Error | null>(null)
const currentAssistantId = ref<string | null>(null) const currentAssistantId = ref<string | null>(null)
/** stopGeneration 的 fallback timer新流开始时必须清除防止误杀新连接 */ /** Fallback timer for stopGeneration — must be cleared when a new stream starts to avoid killing the new connection */
let stopFallbackTimer: ReturnType<typeof setTimeout> | null = null let stopFallbackTimer: ReturnType<typeof setTimeout> | null = null
const streamPhase = ref<StreamPhase>('idle') const streamPhase = ref<StreamPhase>('idle')
const phaseInfo = ref<PhaseEventData | null>(null) const phaseInfo = ref<PhaseEventData | null>(null)
/** 分段式展示数据:当前助手消息的所有分段 */ /** All segments of the current assistant message (for segmented display) */
const currentSegments = ref<MessageSegment[]>([]) const currentSegments = ref<MessageSegment[]>([])
const segIdCounter = { value: 0 } const segIdCounter = { value: 0 }
const genSegId = () => `seg-${Date.now()}-${segIdCounter.value++}` const genSegId = () => `seg-${Date.now()}-${segIdCounter.value++}`
/** 当前 turn 的唯一标识 — 确保 flushSegmentsToMessage 不会把旧 turn 的 segments 写到新消息 */ /** Unique ID for the current turn — prevents flushSegmentsToMessage from writing stale segments to a new message */
let activeTurnId = '' let activeTurnId = ''
/** 重置当前 turn 的流式状态 — 必须在每次创建新 assistant placeholder 之前调用 */ /** Reset streaming state for the current turn — must be called before creating a new assistant placeholder */
function resetCurrentTurnState() { function resetCurrentTurnState() {
currentSegments.value = [] currentSegments.value = []
segIdCounter.value = 0 segIdCounter.value = 0
activeTurnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` activeTurnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
} }
/** 将当前 segments 同步到助手消息的 metadata 中(实时渲染用) */ /** Sync current segments into the assistant message metadata (used for real-time rendering) */
const flushSegmentsToMessage = () => { const flushSegmentsToMessage = () => {
if (!currentAssistantId.value || currentSegments.value.length === 0) return if (!currentAssistantId.value || currentSegments.value.length === 0) return
const msg = getMessage(currentAssistantId.value) const msg = getMessage(currentAssistantId.value)
if (!msg) return if (!msg) return
// 保护:只写入当前 turn 创建的消息,避免旧 turn segments 污染新消息 // Guard: only write to the message created in the current turn to avoid stale segment pollution
if ((msg as any)._turnId && (msg as any)._turnId !== activeTurnId) return if ((msg as any)._turnId && (msg as any)._turnId !== activeTurnId) return
const metadata = parseMetadata((msg as any).metadata) const metadata = parseMetadata((msg as any).metadata)
updateMessage(currentAssistantId.value, { updateMessage(currentAssistantId.value, {
@ -148,7 +149,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const heartbeat = ref<HeartbeatData | null>(null) const heartbeat = ref<HeartbeatData | null>(null)
/** Track which conversation the current stream belongs to */ /** Track which conversation the current stream belongs to */
let streamConversationId = '' let streamConversationId = ''
/** 判断事件是否属于已过期的对话(防止旧流事件污染新会话) */ /** Returns true if the event belongs to an expired conversation (prevents stale stream events from polluting a new session) */
function isStaleEvent(data: any): boolean { function isStaleEvent(data: any): boolean {
const eventConvId = data?.conversationId const eventConvId = data?.conversationId
if (eventConvId && streamConversationId && eventConvId !== streamConversationId) { if (eventConvId && streamConversationId && eventConvId !== streamConversationId) {
@ -156,18 +157,18 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
return false return false
} }
/** 已处理的 approval pendingId 集合(幂等去重) */ /** Set of already-processed approval pendingIds (idempotency dedup) */
const processedApprovalIds = new Set<string>() const processedApprovalIds = new Set<string>()
/** /**
* metadata - JSON * Parse metadata handles JSON strings loaded from the database.
*/ */
const parseMetadata = (metadata: any): any => { const parseMetadata = (metadata: any): any => {
if (!metadata) return {} if (!metadata) return {}
if (typeof metadata === 'string') { if (typeof metadata === 'string') {
try { try {
let parsed = JSON.parse(metadata) let parsed = JSON.parse(metadata)
// 处理双重 JSON 编码DB metadata 是字符串Jackson 可能再次转义) // Handle double-encoded JSON (DB metadata is a string; Jackson may escape it again)
if (typeof parsed === 'string') { if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed) } catch { /* ignore */ } try { parsed = JSON.parse(parsed) } catch { /* ignore */ }
} }
@ -181,8 +182,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
/** /**
* SSE error/done awaiting_approval UI * Expire stale awaiting_approval UI state when the stream ends with error/done but the approval
* updateMessage * is no longer active. Must use updateMessage to trigger Vue reactivity mutating nested fields
* alone is not sufficient.
*/ */
const expirePendingApprovals = (finalStatus: 'completed' | 'failed' | 'stopped') => { const expirePendingApprovals = (finalStatus: 'completed' | 'failed' | 'stopped') => {
for (const m of messages.value) { for (const m of messages.value) {
@ -218,7 +220,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
} }
// 消息管理 // Message management
const { const {
messages, messages,
isGenerating, isGenerating,
@ -232,14 +234,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
getMessage, getMessage,
} = useMessages({ } = useMessages({
onComplete: () => { onComplete: () => {
// 不在 onComplete 里清 currentAssistantId — 让 done 事件来清 // Do not clear currentAssistantId here — the 'done' event handles cleanup
}, },
}) })
// 消息队列 // Message queue
const messageQueue = useMessageQueue() const messageQueue = useMessageQueue()
// 流连接(注入 auth + workspace header与 axios interceptor 保持一致) // Stream connection (inject auth + workspace headers, consistent with the axios interceptor)
const streamHeaders: Record<string, string> = {} const streamHeaders: Record<string, string> = {}
if (token) { if (token) {
streamHeaders['Authorization'] = `Bearer ${token}` streamHeaders['Authorization'] = `Bearer ${token}`
@ -253,7 +255,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
headers: streamHeaders, headers: streamHeaders,
}) })
// ===== SSE 事件处理器 ===== // ===== SSE event handlers =====
stream.on('content_delta', (data) => { stream.on('content_delta', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
@ -262,16 +264,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) { if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) {
streamPhase.value = 'streaming' streamPhase.value = 'streaming'
} }
// 分段:追加到当前 content segment 或创建新的 // Segments: append to the current running content segment, or create a new one
const segs = currentSegments.value const segs = currentSegments.value
let contentSeg = segs.findLast((s: MessageSegment) => s.type === 'content' && s.status === 'running') let contentSeg = segs.findLast((s: MessageSegment) => s.type === 'content' && s.status === 'running')
if (!contentSeg) { if (!contentSeg) {
// 关闭之前的 thinking segment // Close any running thinking segment first
const thinkingSeg = segs.findLast((s: MessageSegment) => s.type === 'thinking' && s.status === 'running') const thinkingSeg = segs.findLast((s: MessageSegment) => s.type === 'thinking' && s.status === 'running')
if (thinkingSeg) thinkingSeg.status = 'completed' if (thinkingSeg) thinkingSeg.status = 'completed'
contentSeg = { id: genSegId(), type: 'content', status: 'running', text: '', timestamp: Date.now() } contentSeg = { id: genSegId(), type: 'content', status: 'running', text: '', timestamp: Date.now() }
segs.push(contentSeg) segs.push(contentSeg)
flushSegmentsToMessage() // 新 content segment 创建时同步一次 flushSegmentsToMessage() // sync once when a new content segment is created
} }
contentSeg.text = (contentSeg.text || '') + (data.delta || '') contentSeg.text = (contentSeg.text || '') + (data.delta || '')
} }
@ -279,24 +281,24 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('thinking_delta', (data) => { stream.on('thinking_delta', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
// thinkingLevel=off 时抑制 thinking 展示 // Suppress thinking display when thinkingLevel=off
if (options.thinkingLevel?.value === 'off') return if (options.thinkingLevel?.value === 'off') return
if (currentAssistantId.value) { if (currentAssistantId.value) {
appendMessageContent(currentAssistantId.value, data.delta || '', 'thinking') appendMessageContent(currentAssistantId.value, data.delta || '', 'thinking')
if (streamPhase.value !== 'summarizing_observations') { if (streamPhase.value !== 'summarizing_observations') {
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking' streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
} }
// 分段:所有 thinking 合并到一个 segment不因 tool_call 中断而创建多个) // Segments: all thinking deltas merge into one segment (not split by tool_call interruptions)
const segs = currentSegments.value const segs = currentSegments.value
// 优先复用已有的 thinking segment无论 running 还是 completed // Reuse an existing thinking segment regardless of status (running or completed)
let thinkSeg = segs.find((s: MessageSegment) => s.type === 'thinking') let thinkSeg = segs.find((s: MessageSegment) => s.type === 'thinking')
if (!thinkSeg) { if (!thinkSeg) {
thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() } thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() }
// 插入到开头thinking 始终在最上方) // Insert at front — thinking always appears at the top
segs.unshift(thinkSeg) segs.unshift(thinkSeg)
flushSegmentsToMessage() flushSegmentsToMessage()
} }
// 新的 thinking 到来,重新设为 running // Re-mark as running when new thinking content arrives
thinkSeg.status = 'running' thinkSeg.status = 'running'
thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '') thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '')
} }
@ -313,7 +315,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
return return
} }
// 没有 placeholder 时才创建(正常路径 placeholder 已在 sendMessage 中创建) // Only create a placeholder here if one does not already exist (the normal path creates it in sendMessage)
resetCurrentTurnState() resetCurrentTurnState()
const assistantMessage = createAssistantMessage('', streamConversationId) const assistantMessage = createAssistantMessage('', streamConversationId)
;(assistantMessage as any)._turnId = activeTurnId ;(assistantMessage as any)._turnId = activeTurnId
@ -342,7 +344,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (currentAssistantId.value) { if (currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value) const msg = getMessage(currentAssistantId.value)
if (msg?.status === 'failed') { if (msg?.status === 'failed') {
// 不清除 currentAssistantId — 让 done 事件来做 // Do not clear currentAssistantId — the 'done' event handles cleanup
return return
} }
if (msg) { if (msg) {
@ -362,16 +364,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
status: data.status || 'completed', status: data.status || 'completed',
metadata: { ...metadata, toolCalls } metadata: { ...metadata, toolCalls }
} as any) } as any)
// 关键修复:不在这里清除 currentAssistantId让 done 来清 // Do not clear currentAssistantId here — the 'done' event does it
return return
} }
} }
} }
setMessageStatus(currentAssistantId.value, data.status || 'completed') setMessageStatus(currentAssistantId.value, data.status || 'completed')
// 关键修复:不在这里清除 currentAssistantId // Do not clear currentAssistantId here — the 'done' event does it
} }
// 分段:标记所有 running segments 为 completed并持久化到 message metadata // Segments: mark all running segments as completed and persist to message metadata
if (currentAssistantId.value && currentSegments.value.length > 0) { if (currentAssistantId.value && currentSegments.value.length > 0) {
currentSegments.value.forEach((s: MessageSegment) => { if (s.status === 'running') s.status = 'completed' }) currentSegments.value.forEach((s: MessageSegment) => { if (s.status === 'running') s.status = 'completed' })
const msg = getMessage(currentAssistantId.value) const msg = getMessage(currentAssistantId.value)
@ -384,7 +386,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
} }
// === 自动 TTSmessage_complete 且 status=completed 时触发 === // Auto TTS: trigger when message_complete arrives with status=completed
if (data.status === 'completed' && data.hasContent && currentAssistantId.value) { if (data.status === 'completed' && data.hasContent && currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value) const msg = getMessage(currentAssistantId.value)
if (msg?.content && streamConversationId) { if (msg?.content && streamConversationId) {
@ -402,13 +404,13 @@ export function useChat(options: UseChatOptions): UseChatReturn {
setMessageStatus(currentAssistantId.value, data.status || 'completed') setMessageStatus(currentAssistantId.value, data.status || 'completed')
} }
// 更新 token 信息 + 用持久化 id 替换本地临时 id关键让 reconcile 能匹配) // Update token counts + replace the local temp ID with the backend-persisted ID (critical: enables reconcile by ID)
const msgIndex = messages.value.findIndex(m => m.id === currentAssistantId.value) const msgIndex = messages.value.findIndex(m => m.id === currentAssistantId.value)
if (msgIndex >= 0) { if (msgIndex >= 0) {
const msg = messages.value[msgIndex] const msg = messages.value[msgIndex]
if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens
if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens if (data.completionTokens !== undefined) msg.completionTokens = data.completionTokens
// 用后端持久化 id 替换本地临时 id使 reconcile 时能按 id 匹配 // Replace the local temp ID with the backend-persisted ID so reconcile can match by ID
if (data.assistantMessageId) { if (data.assistantMessageId) {
msg.id = data.assistantMessageId msg.id = data.assistantMessageId
} }
@ -424,11 +426,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
expirePendingApprovals(data.status === 'stopped' ? 'stopped' : 'completed') expirePendingApprovals(data.status === 'stopped' ? 'stopped' : 'completed')
} }
// 兜底清理排队状态(如果 queued_input_started 已经处理了则这里是 no-op // Safety cleanup for queue state (no-op if queued_input_started already handled it)
if (!messageQueue.hasQueued.value) { if (!messageQueue.hasQueued.value) {
// 队列已空,确保 phase 不残留 queued // Queue already empty — phase cannot linger at 'queued'
} else if (data.status === 'stopped') { } else if (data.status === 'stopped') {
// 用户主动停止,清除排队 // User-initiated stop — discard queued message
messageQueue.clear() messageQueue.clear()
} }
@ -467,7 +469,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
error.value = new Error(data.message || '请求失败') error.value = new Error(data.message || '请求失败')
streamPhase.value = 'idle' streamPhase.value = 'idle'
phaseInfo.value = null phaseInfo.value = null
// 错误时清理排队状态,避免脏残留 // Clear queue on error to avoid stale state
messageQueue.clear() messageQueue.clear()
expirePendingApprovals('failed') expirePendingApprovals('failed')
@ -482,7 +484,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}) })
}) })
// ===== Agent 事件处理 ===== // ===== Agent event handlers =====
stream.on('tool_call_started', (data) => { stream.on('tool_call_started', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
@ -503,7 +505,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
metadata: { ...metadata, toolCalls, currentPhase: 'executing_tool', runningToolName: data.toolName } metadata: { ...metadata, toolCalls, currentPhase: 'executing_tool', runningToolName: data.toolName }
} as any) } as any)
} }
// 分段:关闭之前的 thinking/content segment创建新的 tool_call segment // Segments: close any running thinking/content segment, then push a new tool_call segment
const segs = currentSegments.value const segs = currentSegments.value
const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content')) const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content'))
if (runningSeg) runningSeg.status = 'completed' if (runningSeg) runningSeg.status = 'completed'
@ -537,7 +539,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
metadata: { ...metadata, toolCalls, runningToolName: undefined } metadata: { ...metadata, toolCalls, runningToolName: undefined }
} as any) } as any)
} }
// 分段:找到对应的 running tool_call segment 并标记完成 // Segments: find the matching running tool_call segment and mark it complete
const segs = currentSegments.value const segs = currentSegments.value
const toolSeg = segs.findLast((s: MessageSegment) => const toolSeg = segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' && s.status === 'running' && s.toolName === data.toolName) s.type === 'tool_call' && s.status === 'running' && s.toolName === data.toolName)
@ -550,7 +552,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
}) })
// ===== Browser 执行事件 ===== // ===== Browser action events =====
stream.on('browser_action', (data) => { stream.on('browser_action', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
@ -587,7 +589,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const msg = getMessage(currentAssistantId.value) const msg = getMessage(currentAssistantId.value)
if (msg) { if (msg) {
const metadata = parseMetadata((msg as any).metadata) const metadata = parseMetadata((msg as any).metadata)
// 去重:相同 phase 不触发 updateMessage避免不必要的 Vue 响应式更新 // Dedup: skip updateMessage if the phase hasn't changed, to avoid unnecessary Vue reactivity
if (metadata.currentPhase === data.phase) return if (metadata.currentPhase === data.phase) return
updateMessage(currentAssistantId.value, { updateMessage(currentAssistantId.value, {
...msg, ...msg,
@ -597,21 +599,24 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
}) })
// ===== Agent 委派事件 ===== // ===== Agent delegation events =====
stream.on('delegation_start', (data) => { stream.on('delegation_start', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
streamPhase.value = 'executing_tool' streamPhase.value = 'executing_tool'
if (currentAssistantId.value) { if (currentAssistantId.value) {
const segs = currentSegments.value const segs = currentSegments.value
// 关闭之前的 thinking/content segment // Close any running thinking/content segment
const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running') const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running')
if (runningSeg) runningSeg.status = 'completed' if (runningSeg) runningSeg.status = 'completed'
if (data.parallel && Array.isArray(data.children)) { if (data.parallel && Array.isArray(data.children)) {
// 并行模式:为每个子任务创建一个 delegation segment // Parallel mode: one segment per child. Use childConversationId as the segment ID
// so downstream events (delegation_child_complete, delegation_progress) can look up
// the correct row by stable ID instead of agent name — which is not unique when
// two concurrent tasks go to the same agent.
for (const child of data.children) { for (const child of data.children) {
segs.push({ segs.push({
id: genSegId(), id: child.childConversationId || genSegId(),
type: 'tool_call', type: 'tool_call',
status: 'running', status: 'running',
toolName: `${child.childAgentName || 'Agent'}`, toolName: `${child.childAgentName || 'Agent'}`,
@ -620,9 +625,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}) })
} }
} else { } else {
// 单任务模式 // Single-task mode: same stable ID approach
segs.push({ segs.push({
id: genSegId(), id: data.childConversationId || genSegId(),
type: 'tool_call', type: 'tool_call',
status: 'running', status: 'running',
toolName: `${data.childAgentName || 'Agent'}`, toolName: `${data.childAgentName || 'Agent'}`,
@ -638,35 +643,40 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
if (!currentAssistantId.value) return if (!currentAssistantId.value) return
const segs = currentSegments.value const segs = currentSegments.value
const childName = data.childAgentName || ''
// Find the running delegation segment for this child (or fall back to any running delegation) // Primary lookup: by stable childConversationId (set as the segment ID at creation time).
const delegSeg = segs.findLast((s: MessageSegment) => // Fallback: any running delegation segment (for older backends that don't send the field).
s.type === 'tool_call' && s.status === 'running' && s.toolName === `${childName}`) const delegSeg = (data.childConversationId
|| segs.findLast((s: MessageSegment) => s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→')) ? segs.find((s: MessageSegment) => s.id === data.childConversationId)
: undefined)
|| segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
if (!delegSeg) return if (!delegSeg) return
// Normalize data.data: the backend relays the child event's JSON payload.
// After the P2 fix it arrives as an object; be defensive for older backends.
const rawPayload = data.data
const childData: Record<string, any> = rawPayload && typeof rawPayload === 'object'
? rawPayload
: (() => { try { return JSON.parse(String(rawPayload || '{}')) } catch { return {} } })()
if (data.originalEvent === 'tool_call_started') { if (data.originalEvent === 'tool_call_started') {
// Child started a sub-tool — append activity hint so the user sees the child is working const toolName = childData?.toolName || ''
const childData = data.data
const toolName = typeof childData === 'object' ? childData?.toolName : String(childData || '')
if (toolName) { if (toolName) {
delegSeg.toolArgs = (delegSeg.toolArgs || '') + `\n → ${toolName}` delegSeg.toolArgs = (delegSeg.toolArgs || '') + `\n → ${toolName}`
} }
} else if (data.originalEvent === 'tool_call_completed') { } else if (data.originalEvent === 'tool_call_completed') {
// Child finished a sub-tool call — update the running hint const toolName = childData?.toolName || ''
const childData = data.data const success = childData?.success !== false
const toolName = typeof childData === 'object' ? childData?.toolName : String(childData || '')
const success = typeof childData === 'object' ? childData?.success !== false : true
if (toolName) { if (toolName) {
// Replace last appended "→ toolName" with "✓/✗ toolName" // Replace the matching "→ toolName" hint with "✓/✗ toolName"
delegSeg.toolArgs = (delegSeg.toolArgs || '').replace( delegSeg.toolArgs = (delegSeg.toolArgs || '').replace(
new RegExp(`\\n → ${toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), new RegExp(`\\n → ${toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`),
`\n ${success ? '✓' : '✗'} ${toolName}`) `\n ${success ? '✓' : '✗'} ${toolName}`)
} }
} else if (data.originalEvent === 'phase') { } else if (data.originalEvent === 'phase') {
// Child entered a new phase (reasoning, executing_tool, etc.) const phase = childData?.phase || String(rawPayload || '')
const phase = typeof data.data === 'object' ? data.data?.phase : String(data.data || '')
const phaseHints: Record<string, string> = { const phaseHints: Record<string, string> = {
reasoning: '…', reasoning: '…',
executing_tool: '→', executing_tool: '→',
@ -688,20 +698,22 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
if (!currentAssistantId.value) return if (!currentAssistantId.value) return
const segs = currentSegments.value const segs = currentSegments.value
const childName = data.childAgentName || '' // Prefer childConversationId (stable) over agent name (non-unique)
const delegSeg = segs.findLast((s: MessageSegment) => const delegSeg = (data.childConversationId
s.type === 'tool_call' && s.status === 'running' && s.toolName === `${childName}`) ? segs.find((s: MessageSegment) => s.id === data.childConversationId)
: undefined)
|| segs.findLast((s: MessageSegment) => || segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→')) s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
if (delegSeg) { if (delegSeg) {
delegSeg.status = data.success ? 'completed' : 'error' delegSeg.status = data.success ? 'completed' : 'error'
delegSeg.toolSuccess = data.success delegSeg.toolSuccess = data.success
// Append duration to args so the user sees how long each child took
if (data.durationMs) { if (data.durationMs) {
const durSec = Math.round(data.durationMs / 1000) const durSec = Math.round(data.durationMs / 1000)
delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ` (${durSec}s)` delegSeg.toolArgs = (delegSeg.toolArgs || '').trimEnd() + ` (${durSec}s)`
} }
if (!data.success && data.resultPreview) { // Write resultPreview for both success and failure so ToolCallSegment can show
// an expand arrow with the child agent's actual output, not just a green/red dot.
if (data.resultPreview) {
delegSeg.toolResult = data.resultPreview delegSeg.toolResult = data.resultPreview
} }
} }
@ -717,19 +729,26 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// fall back to aggregate success flag for older backends. // fall back to aggregate success flag for older backends.
if (Array.isArray(data.childResults) && data.childResults.length > 0) { if (Array.isArray(data.childResults) && data.childResults.length > 0) {
for (const cr of data.childResults) { for (const cr of data.childResults) {
const agentName = cr.agentName || '' // Primary: stable childConversationId lookup. Fallback: agent name substring.
const seg = segs.findLast((s: MessageSegment) => const seg = (cr.childConversationId
s.type === 'tool_call' && ? segs.find((s: MessageSegment) => s.id === cr.childConversationId)
(s.status === 'running' || s.status === 'completed') && : undefined)
s.toolName?.includes(agentName)) || segs.findLast((s: MessageSegment) =>
s.type === 'tool_call' && s.toolName?.includes(cr.agentName || ''))
if (seg && seg.status === 'running') { if (seg && seg.status === 'running') {
// Segment not yet closed by delegation_child_complete (e.g. timed out child) // Segment not yet closed by delegation_child_complete (e.g. timed-out child).
// Write whatever result info is available so ToolCallSegment can show content.
seg.status = cr.success ? 'completed' : 'error' seg.status = cr.success ? 'completed' : 'error'
seg.toolSuccess = cr.success seg.toolSuccess = cr.success
if (cr.durationMs) { if (cr.durationMs) {
const durSec = Math.round(cr.durationMs / 1000) const durSec = Math.round(cr.durationMs / 1000)
seg.toolArgs = (seg.toolArgs || '').trimEnd() + ` (${durSec}s)` seg.toolArgs = (seg.toolArgs || '').trimEnd() + ` (${durSec}s)`
} }
// Show error reason for failures; for successes leave toolResult empty here
// (delegation_child_complete already wrote the preview before we get to delegation_end).
if (cr.error) {
seg.toolResult = cr.error
}
} }
} }
} else { } else {
@ -814,11 +833,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
}) })
// ===== 工具审批事件(带幂等去重) ===== // ===== Tool approval events (with idempotency dedup) =====
stream.on('tool_approval_requested', (data) => { stream.on('tool_approval_requested', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
// 幂等去重:同一 pendingId 只处理一次 // Idempotency: process each pendingId only once
if (data.pendingId && processedApprovalIds.has(data.pendingId)) { if (data.pendingId && processedApprovalIds.has(data.pendingId)) {
// duplicate approval ignored // duplicate approval ignored
return return
@ -911,12 +930,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
streamPhase.value = data.decision === 'approved' ? 'streaming' : 'completed' streamPhase.value = data.decision === 'approved' ? 'streaming' : 'completed'
}) })
// ===== Heartbeat 事件 ===== // ===== Heartbeat events =====
stream.on('heartbeat', (data: HeartbeatData) => { stream.on('heartbeat', (data: HeartbeatData) => {
heartbeat.value = data heartbeat.value = data
// heartbeat 到达意味着连接活跃useStream 的 timeout 已由 resetStreamTimeout 自动重置 // Heartbeat arrival means the connection is alive; useStream resets the timeout automatically.
// 从 heartbeat 中更新 phase如果前端还没有更精确的 phase // Update phase from heartbeat only when the frontend doesn't have a more precise phase yet.
if (data.currentPhase && streamPhase.value !== 'interrupting') { if (data.currentPhase && streamPhase.value !== 'interrupting') {
const phaseMap: Record<string, StreamPhase> = { const phaseMap: Record<string, StreamPhase> = {
'preparing_context': 'preparing_context', 'preparing_context': 'preparing_context',
@ -934,16 +953,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const mapped = phaseMap[data.currentPhase] const mapped = phaseMap[data.currentPhase]
if (mapped) streamPhase.value = mapped if (mapped) streamPhase.value = mapped
} }
// 利用 heartbeat 的 queueLength 校准本地排队状态 // Use heartbeat queueLength to reconcile local queue state.
// 仅在消息已被后端确认status=sending后才以 heartbeat 兜底清理, // Only clear when the message has been acknowledged by the backend (status=sending),
// 避免在 interrupt 请求仍在途中时误清尚未到达后端的消息 // to avoid discarding a message whose interrupt request is still in flight.
if (data.queueLength === 0 && messageQueue.hasQueued.value if (data.queueLength === 0 && messageQueue.hasQueued.value
&& messageQueue.queuedMessage.value?.status === 'sending') { && messageQueue.queuedMessage.value?.status === 'sending') {
messageQueue.clear() messageQueue.clear()
} }
}) })
// ===== Interrupt + Queue 事件 ===== // ===== Interrupt + Queue events =====
stream.on('turn_interrupt_requested', () => { stream.on('turn_interrupt_requested', () => {
streamPhase.value = 'interrupting' streamPhase.value = 'interrupting'
@ -951,31 +970,31 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('turn_interrupted', (data) => { stream.on('turn_interrupted', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
// 当前 turn 已中断,等待后端自动启动排队消息 // Current turn has been interrupted. Wait for the backend to resume with the queued message.
// 如果后端会自动续跑,前端不需要做额外操作 // If the backend will auto-resume, the frontend does nothing extra.
// 如果后端没有排队消息但前端有(应该不会发生),则前端发送 // Edge case: backend has no queued message but frontend does (should not happen in practice).
if (data.hasQueuedMessage) { if (data.hasQueuedMessage) {
streamPhase.value = 'queued' streamPhase.value = 'queued'
} }
}) })
stream.on('queued_input_accepted', (data) => { stream.on('queued_input_accepted', (data) => {
// 后端已确认接收排队消息,标记为 sending允许 heartbeat 兜底清理) // Backend confirmed receipt of the queued message — mark as 'sending' to allow heartbeat cleanup
messageQueue.markSending() messageQueue.markSending()
streamPhase.value = 'queued' streamPhase.value = 'queued'
}) })
stream.on('queued_input_started', (data) => { stream.on('queued_input_started', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
// 后端已开始处理排队消息 // Backend has started processing the queued message.
// 1. 先用排队的内容创建用户消息(此时上一轮回答已完成,顺序正确) // 1. Create the user message first (previous turn is now complete so ordering is correct)
const queued = messageQueue.dequeue() const queued = messageQueue.dequeue()
const messageContent = data.message || queued?.content || '' const messageContent = data.message || queued?.content || ''
if (messageContent) { if (messageContent) {
const convId = data.conversationId || streamConversationId const convId = data.conversationId || streamConversationId
createUserMessage(messageContent, queued?.contentParts, convId) createUserMessage(messageContent, queued?.contentParts, convId)
} }
// 2. 再创建 assistant 占位消息 // 2. Create the assistant placeholder message
resetCurrentTurnState() resetCurrentTurnState()
const convId2 = data.conversationId || streamConversationId const convId2 = data.conversationId || streamConversationId
const assistantMessage = createAssistantMessage('', convId2) const assistantMessage = createAssistantMessage('', convId2)
@ -985,7 +1004,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
phaseInfo.value = null phaseInfo.value = null
}) })
// ===== 异步任务完成事件(视频生成、图片生成等) ===== // ===== Async task completion events (video generation, image generation, etc.) =====
stream.on('async_task_completed', (data) => { stream.on('async_task_completed', (data) => {
if (isStaleEvent(data)) return if (isStaleEvent(data)) return
if (data.success && streamConversationId) { if (data.success && streamConversationId) {
@ -1008,7 +1027,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (!mediaPart) return if (!mediaPart) return
// 优先附加到当前 assistant 消息(避免图片跑到文字回复上方) // Prefer appending to the current assistant message (avoids image appearing above the text reply)
if (currentAssistantId.value) { if (currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value) const msg = getMessage(currentAssistantId.value)
if (msg) { if (msg) {
@ -1020,7 +1039,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
} }
// 回退Agent 已结束,新建独立消息 // Fallback: agent already finished — create a standalone message
addMessage({ addMessage({
role: 'assistant', role: 'assistant',
content: '', content: '',
@ -1031,13 +1050,13 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
}) })
// ===== TTS 自动朗读 ===== // ===== Auto TTS =====
let ttsAutoModeCache: string | null = null let ttsAutoModeCache: string | null = null
let ttsCacheExpiry = 0 let ttsCacheExpiry = 0
async function triggerAutoTts(conversationId: string, text: string) { async function triggerAutoTts(conversationId: string, text: string) {
try { try {
// 缓存 settings 5 分钟,避免每条消息都请求 // Cache settings for 5 minutes to avoid a request on every message
const now = Date.now() const now = Date.now()
if (!ttsAutoModeCache || now > ttsCacheExpiry) { if (!ttsAutoModeCache || now > ttsCacheExpiry) {
const res: any = await http.get('/system-settings') const res: any = await http.get('/system-settings')
@ -1045,14 +1064,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
ttsCacheExpiry = now + 5 * 60 * 1000 ttsCacheExpiry = now + 5 * 60 * 1000
} }
if (ttsAutoModeCache !== 'always') return if (ttsAutoModeCache !== 'always') return
// 调用后端合成,后端会通过 SSE tts_ready 广播 // Kick off backend synthesis; the backend broadcasts tts_ready via SSE when done
http.post('/tts/synthesize', { conversationId, text }).catch(() => {}) http.post('/tts/synthesize', { conversationId, text }).catch(() => {})
} catch { } catch {
// 静默失败 // Silently ignore TTS errors — it's a best-effort feature
} }
} }
// ===== TTS 自动朗读:监听 tts_ready 事件 ===== // ===== Auto TTS: listen for tts_ready events =====
stream.on('tts_ready', (data) => { stream.on('tts_ready', (data) => {
if (data.audioUrl) { if (data.audioUrl) {
const token = localStorage.getItem('token') || '' const token = localStorage.getItem('token') || ''
@ -1068,26 +1087,26 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
}) })
// ===== 发送消息(支持运行中继续发送) ===== // ===== Send message (supports sending while generating) =====
const sendMessage = async (content: string, options: SendMessageOptions) => { const sendMessage = async (content: string, options: SendMessageOptions) => {
const { conversationId, agentId, attachments = [], contentParts = [] } = options const { conversationId, agentId, attachments = [], contentParts = [] } = options
// 审批命令不走 interrupt 逻辑 // Approval commands bypass the interrupt logic
const isApprovalCommand = /^\/(approve|deny)$/i.test(content.trim()) const isApprovalCommand = /^\/(approve|deny)$/i.test(content.trim())
// ===== 运行中发送新消息:走 interrupt / queue 路径 ===== // ===== Sending while generating: route to interrupt / queue path =====
if (isGenerating.value && !isApprovalCommand) { if (isGenerating.value && !isApprovalCommand) {
return await handleInterruptOrQueue(content, options) return await handleInterruptOrQueue(content, options)
} }
// ===== 正常发送路径 ===== // ===== Normal send path =====
// 清除上一次 stop 的 fallback timer防止误杀新连接 // Clear the previous stop fallback timer to avoid killing the new connection
if (stopFallbackTimer) { if (stopFallbackTimer) {
clearTimeout(stopFallbackTimer) clearTimeout(stopFallbackTimer)
stopFallbackTimer = null stopFallbackTimer = null
} }
// 切换会话时断开旧流,防止旧事件污染新会话 // Disconnect the old stream when switching conversations to prevent event pollution
if (streamConversationId && streamConversationId !== conversationId) { if (streamConversationId && streamConversationId !== conversationId) {
stream.disconnect() stream.disconnect()
currentAssistantId.value = null currentAssistantId.value = null
@ -1108,7 +1127,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
;(assistantMessage as any)._turnId = activeTurnId ;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string currentAssistantId.value = assistantMessage.id as string
// contentParts 已由 buildOutgoingParts 包含 file entries不要重复合并 attachments // contentParts already includes file entries from buildOutgoingParts — do not re-merge attachments
const body: Record<string, any> = { const body: Record<string, any> = {
agentId, agentId,
message: content, message: content,
@ -1127,16 +1146,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
/** /**
* * Send a new message while one is already generating.
* - thinking/streaming/executing_tool interrupt * - Interruptible phases (thinking/streaming/executing_tool): send an interrupt request.
* - awaiting_approval * - Non-interruptible phases (awaiting_approval): queue the message.
*/ */
const handleInterruptOrQueue = async (content: string, options: SendMessageOptions) => { const handleInterruptOrQueue = async (content: string, options: SendMessageOptions) => {
const { conversationId, agentId } = options const { conversationId, agentId } = options
// 不立即创建用户消息 —— 等 queued_input_started 再插入, // Do not create the user message immediately — wait for queued_input_started so the
// 这样用户消息会出现在上一轮回答之后,保证正确的消息顺序。 // user message appears after the previous turn's reply, preserving correct ordering.
// 加入本地队列(保存 contentParts 以便延迟创建时使用) // Add to the local queue now (saves contentParts for delayed creation).
messageQueue.enqueue(content, options.contentParts, conversationId) messageQueue.enqueue(content, options.contentParts, conversationId)
try { try {
@ -1151,14 +1170,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const result = await res.json() const result = await res.json()
if (result.data?.interrupted) { if (result.data?.interrupted) {
// 可中断:后端已发起中断,排队消息会被后端自动续跑 // Interruptible: backend initiated the interrupt; queued message will auto-resume
streamPhase.value = 'interrupting' streamPhase.value = 'interrupting'
messageQueue.markSending() messageQueue.markSending()
} else if (result.data?.queued) { } else if (result.data?.queued) {
// 不可中断但已排队:等当前步骤结束后自动续跑 // Non-interruptible but queued: will auto-resume when the current step ends
streamPhase.value = 'queued' streamPhase.value = 'queued'
} else { } else {
// 没有活跃的流,直接发送 // No active stream — send directly
messageQueue.clear() messageQueue.clear()
createUserMessage(content, options.contentParts, conversationId) createUserMessage(content, options.contentParts, conversationId)
resetCurrentTurnState() resetCurrentTurnState()
@ -1176,8 +1195,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
} catch (e) { } catch (e) {
console.error('[useChat] Interrupt request failed:', e) console.error('[useChat] Interrupt request failed:', e)
// interrupt 失败:后端从未收到这条消息,不能指望 heartbeat/queue 机制。 // Interrupt failed: the backend never received the message, so the heartbeat/queue mechanism
// 回退为本地可见消息 + 清队列,避免消息静默丢失。 // cannot be relied upon. Fall back to making the message locally visible + clear the queue
// to prevent silent message loss.
const failedQueued = messageQueue.dequeue() const failedQueued = messageQueue.dequeue()
if (failedQueued) { if (failedQueued) {
createUserMessage(failedQueued.content, failedQueued.contentParts, conversationId) createUserMessage(failedQueued.content, failedQueued.contentParts, conversationId)
@ -1186,41 +1206,42 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
} }
// 停止生成(用户主动停止,不自动续跑) // Stop generation (user-initiated; does not auto-resume queued messages).
// //
// 设计参考 claude-code-haha 的 useCancelRequest // Design: do not disconnect the SSE immediately — send a stop signal first and wait for the
// 不立即断开 SSE而是先发 stop 信号,等后端通过 SSE 返回 done 事件后再清理。 // backend to return a 'done' event. This ensures onStreamEnd fires and message/conversation
// 这样 done 事件能正常到达onStreamEnd 被触发,消息状态和会话列表都能正确更新。 // state is updated correctly.
// 加一个 fallback timeout3 秒),防止 done 事件因网络问题永远不到达。 // A 3-second fallback timeout guards against 'done' never arriving due to network issues.
const stopGeneration = async () => { const stopGeneration = async () => {
// 在任何 await 之前冻结标识符 + 安装 fallback timer防止 resetForNewConversation 并发清空后丢失上下文 // Freeze identifiers and install the fallback timer before any await, so a concurrent
// resetForNewConversation cannot clear context out from under us.
const convId = streamConversationId const convId = streamConversationId
const assistantId = currentAssistantId.value const assistantId = currentAssistantId.value
// 仅在前端确实在生成时才触发停止(含 SSE 接收中 / reconnect 中 / 审批等待中)。 // Only stop when the frontend is actively involved in the stream (receiving SSE /
// 否则只是"旁观者"身份,不能把对方(渠道用户)的 agent run 也一起杀掉。 // reconnecting / awaiting approval). As a bystander we must not kill another user's run.
const activelyStreaming = isGenerating.value const activelyStreaming = isGenerating.value
|| streamPhase.value === 'reconnecting' || streamPhase.value === 'reconnecting'
|| streamPhase.value === 'awaiting_approval' || streamPhase.value === 'awaiting_approval'
if (!activelyStreaming) { if (!activelyStreaming) {
// 没有真正在流 → 什么都不做,让调用方直接走 resetForNewConversation // Not actually streaming — let the caller go straight to resetForNewConversation
return return
} }
// 先取消排队消息 // Cancel queued message first
messageQueue.clear() messageQueue.clear()
// 标记为停止中(让 UI 立即反馈) // Mark as stopped immediately so the UI gives instant feedback
streamPhase.value = 'stopped' streamPhase.value = 'stopped'
phaseInfo.value = null phaseInfo.value = null
// 在 await 之前安装 fallback timer确保即使 resetForNewConversation 并发执行也不会遗漏 // Install fallback timer before any await so it is not missed by a concurrent resetForNewConversation
if (stopFallbackTimer) clearTimeout(stopFallbackTimer) if (stopFallbackTimer) clearTimeout(stopFallbackTimer)
stopFallbackTimer = setTimeout(() => { stopFallbackTimer = setTimeout(() => {
stopFallbackTimer = null stopFallbackTimer = null
console.warn('[useChat] Stop fallback: done event not received within 3s, force cleanup') console.warn('[useChat] Stop fallback: done event not received within 3s, force cleanup')
// 只有当 stream 仍属于旧会话时才 disconnect防止误杀新会话的流 // Only disconnect if the stream still belongs to the old conversation — avoids killing a new session's stream
if (streamConversationId === convId || !streamConversationId) { if (streamConversationId === convId || !streamConversationId) {
stream.disconnect() stream.disconnect()
} }
@ -1234,7 +1255,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}) })
}, 3000) }, 3000)
// 当 done 事件到达时,取消 fallback timer // Cancel the fallback timer when the done/error event arrives
const unsubscribe = stream.on('done', () => { const unsubscribe = stream.on('done', () => {
if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null } if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null }
unsubscribe() unsubscribe()
@ -1244,7 +1265,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
unsubscribeError() unsubscribeError()
}) })
// 发送后端 stop 请求fire-and-forget不阻塞 resetForNewConversation // Send the backend stop request (fire-and-forget, does not block resetForNewConversation)
if (convId) { if (convId) {
fetchWithAuth(`${baseUrl}/api/v1/chat/${convId}/stop`, { fetchWithAuth(`${baseUrl}/api/v1/chat/${convId}/stop`, {
method: 'POST', method: 'POST',
@ -1254,25 +1275,25 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
} }
// 取消排队消息 // Cancel the queued message
const cancelQueued = () => { const cancelQueued = () => {
messageQueue.cancel() messageQueue.cancel()
// 通知后端清除排队消息fire-and-forget // Notify backend (fire-and-forget)
if (streamConversationId) { if (streamConversationId) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' } const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}` if (token) headers.Authorization = `Bearer ${token}`
// 后端没有专门的取消排队 API用 stop 的语义来处理 // No dedicated cancel-queue API — the stop semantic covers this case
} }
if (streamPhase.value === 'queued') { if (streamPhase.value === 'queued') {
streamPhase.value = isGenerating.value ? 'streaming' : 'idle' streamPhase.value = isGenerating.value ? 'streaming' : 'idle'
} }
} }
// 重连到运行中的流 // Reconnect to a stream that is already running on the backend
const reconnectStream = async (conversationId: string) => { const reconnectStream = async (conversationId: string) => {
if (isGenerating.value) return if (isGenerating.value) return
// 清除残留的 stop fallback timer // Clear any leftover stop fallback timer
if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null } if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null }
streamPhase.value = 'reconnecting' streamPhase.value = 'reconnecting'
streamConversationId = conversationId streamConversationId = conversationId
@ -1282,8 +1303,8 @@ export function useChat(options: UseChatOptions): UseChatReturn {
resetCurrentTurnState() resetCurrentTurnState()
// 清理尾部的空 assistant 消息(来自上一轮被误杀的 run 留下的空壳,或 placeholder 遗留), // Remove trailing empty assistant messages left over from a killed run or a stale placeholder.
// 避免与即将重连产生的 streaming 气泡共存形成"重复两条"假象。 // Prevents "two bubbles" appearing when the reconnect creates a new streaming placeholder.
while (messages.value.length > 0) { while (messages.value.length > 0) {
const tail = messages.value[messages.value.length - 1] const tail = messages.value[messages.value.length - 1]
if (tail && tail.role === 'assistant' if (tail && tail.role === 'assistant'
@ -1307,11 +1328,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}) })
} catch (e) { } catch (e) {
console.error('[useChat] Reconnect failed:', e) console.error('[useChat] Reconnect failed:', e)
// 重连失败:清理占位消息 // Reconnect failed — clean up the placeholder message
const msgIndex = messages.value.findIndex(m => m.id === currentAssistantId.value) const msgIndex = messages.value.findIndex(m => m.id === currentAssistantId.value)
if (msgIndex >= 0) { if (msgIndex >= 0) {
const msg = messages.value[msgIndex] const msg = messages.value[msgIndex]
// 如果占位消息没有内容,移除它 // Remove the placeholder if it has no content
if (!msg.content && (!msg.contentParts || msg.contentParts.length === 0)) { if (!msg.content && (!msg.contentParts || msg.contentParts.length === 0)) {
messages.value.splice(msgIndex, 1) messages.value.splice(msgIndex, 1)
} else { } else {
@ -1324,7 +1345,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
} }
} }
// 重新生成 // Regenerate a message
const regenerate = async (messageId: string | number) => { const regenerate = async (messageId: string | number) => {
const message = getMessage(messageId) const message = getMessage(messageId)
if (!message) return if (!message) return
@ -1348,7 +1369,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}) })
} }
/** 彻底重置流上下文 — 切换/新建会话时调用,确保旧流状态不污染新会话 */ /** Fully reset stream context — call when switching or creating a conversation to prevent state pollution */
const resetForNewConversation = () => { const resetForNewConversation = () => {
stream.disconnect() stream.disconnect()
streamConversationId = '' streamConversationId = ''