m = new java.util.LinkedHashMap<>();
m.put("taskIndex", r.taskIndex);
m.put("agentName", r.agentName);
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);
+ // 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);
return m;
}).toList();
@@ -285,66 +308,185 @@ public class DelegateAgentTool {
"totalDurationMs", totalDurationMs,
"success", results.stream().allMatch(r -> r.success),
"completedCount", results.stream().filter(r -> r.success).count(),
+ "blankCount", results.stream().filter(ChildResult::isBlank).count(),
"totalCount", results.size(),
"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));
+ 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();
+
+ // 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()) {
- sb.append("⚠️ 部分任务未执行:\n");
+ sb.append("⚠️ 部分任务未执行(Agent 未找到或参数错误):\n");
errors.forEach(e -> sb.append(" ").append(e).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) {
- sb.append("---\n### [任务 ").append(r.taskIndex + 1).append("] Agent「").append(r.agentName).append("」");
- sb.append(r.success ? " ✓" : " ✗").append(" (").append(r.durationMs / 1000).append("s)\n\n");
- sb.append(r.success ? r.result : "[错误] " + r.error).append("\n\n");
+ sb.append("### [任务 ").append(r.taskIndex + 1).append("] ").append(r.agentName).append("\n");
+ // Per-row machine-readable status — impossible to confuse with a different outcome
+ 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); // 并行结果允许更长
}
- // ==================== 子 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.
+ *
+ * Raw result length must be measured before 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,
String parentConversationId, String childConversationId) {
DelegationContext.enter(parentConversationId, CHILD_DENIED_TOOLS);
try {
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;
- 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) {
- log.error("子 Agent 执行失败: taskIndex={}, agent={}, error={}",
+ log.error("Child agent failed: taskIndex={}, agent={}, error={}",
taskIndex, target.getName(), e.getMessage());
- return ChildResult.error(taskIndex, target.getName(), e.getMessage());
+ return ChildResult.ofError(taskIndex, target.getName(), e.getMessage());
} finally {
DelegationContext.exit();
}
}
- /** 子 Agent 执行结果 */
- private record ChildResult(int taskIndex, String agentName, boolean success,
- String result, String error, long durationMs) {
+ /**
+ * Result carrier for a single child agent execution.
+ *
+ *
{@code outcome} values:
+ *
+ * - {@code "success"} — completed successfully with non-empty content (trimmedLength > 0)
+ * - {@code "blank_success"} — completed successfully but returned empty content (trimmedLength == 0)
+ * - {@code "timeout"} — did not complete within the parallel wait window
+ * - {@code "error"} — threw an exception during execution
+ *
+ *
+ * {@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) {
- 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) {
- return new ChildResult(idx, name, false, null, err != null ? err : "Unknown error", 0);
+ return ofError(idx, name, err);
}
+
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;
}
+
+ 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.")
public String listAvailableAgents() {
@@ -386,11 +528,20 @@ public class DelegateAgentTool {
return streamTracker.addEventRelay(childConvId, (eventName, jsonData) -> {
if ("tool_call_started".equals(eventName) || "tool_call_completed".equals(eventName) || "phase".equals(eventName)) {
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(
"childConversationId", childConvId,
"childAgentName", childAgentName,
"originalEvent", eventName,
- "data", jsonData));
+ "data", parsedData));
} catch (Exception e) {
log.debug("Relay error: {}", e.getMessage());
}
diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts
index 653d07b8..d1ed253a 100644
--- a/mateclaw-ui/src/composables/chat/useChat.ts
+++ b/mateclaw-ui/src/composables/chat/useChat.ts
@@ -1,12 +1,13 @@
/**
- * 聊天功能统一 Composable
- * 整合 useMessages、useStream、useMessageQueue,提供完整的聊天功能
+ * Unified chat composable.
+ * Integrates useMessages, useStream, and useMessageQueue into a complete chat feature.
*
- * 核心机制(参考 claude-code-haha 的 Interrupt + Queue + Resume 模型):
- * - 运行中允许继续输入新消息
- * - 可中断阶段:发送 interrupt 请求,中断后自动续跑排队消息
- * - 不可中断阶段:消息排队,等当前步骤结束后自动继续
- * - 审批中:消息排队,不打断审批流程
+ * Core mechanism (Interrupt + Queue + Resume model):
+ * - New messages can be sent while a response is already generating.
+ * - 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 { useMessages } from './useMessages'
@@ -17,78 +18,78 @@ import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
import { http } from '@/api'
export interface UseChatOptions {
- /** API 基础 URL */
+ /** Base API URL */
baseUrl: string
- /** 认证 Token */
+ /** Auth token */
token?: string
- /** 当前思考深度(响应式 ref),off 时抑制 thinking 展示 */
+ /** Current thinking depth (reactive ref); when "off", thinking segments are suppressed */
thinkingLevel?: import('vue').Ref
/**
- * 统一回调:流结束后(done/error/stopped 都会触发)。
- * 前端应在此回调中做持久化历史收口(reconcile)。
+ * Unified callback fired when the stream ends (done/error/stopped all trigger this).
+ * The caller should perform history reconcile / persistence in this callback.
*/
onStreamEnd?: (meta: StreamEndMeta) => void
}
-/** 流结束元信息 */
+/** Metadata emitted when a stream ends */
export interface StreamEndMeta {
conversationId: string
reason: 'completed' | 'stopped' | 'interrupted' | 'failed' | 'error' | 'awaiting_approval'
- /** 后端持久化的 assistant 消息 ID(若有) */
+ /** Backend-persisted assistant message ID, if available */
assistantMessageId?: number
- /** 后端是否已持久化 */
+ /** Whether the backend has already persisted the message */
persisted?: boolean
- /** 后端当前消息总数 */
+ /** Total message count reported by the backend */
messageCount?: number
}
export interface UseChatReturn {
- /** 消息列表 */
+ /** Message list */
messages: import('vue').Ref
- /** 是否正在生成 */
+ /** Whether the assistant is currently generating */
isGenerating: import('vue').ComputedRef
- /** 当前流阶段 */
+ /** Current stream phase */
streamPhase: import('vue').Ref
- /** 最近一次阶段事件 */
+ /** Most recent phase event */
phaseInfo: import('vue').Ref
- /** 当前错误 */
+ /** Current error */
error: import('vue').Ref
- /** 排队的消息 */
+ /** Queued message waiting to be sent */
queuedMessage: import('vue').Ref
- /** 是否有排队消息 */
+ /** Whether there is a queued message */
hasQueued: import('vue').ComputedRef
- /** 排队消息数量 */
+ /** Number of queued messages */
queueSize: import('vue').ComputedRef
- /** 心跳数据 */
+ /** Latest heartbeat data */
heartbeat: import('vue').Ref
- /** 发送消息(运行中也可调用,自动走 interrupt/queue) */
+ /** Send a message (can be called while generating — automatically routes to interrupt/queue) */
sendMessage: (content: string, options: SendMessageOptions) => Promise
- /** 停止生成(用户主动停止,不自动续跑) */
+ /** Stop generation (user-initiated stop; does not auto-resume queued messages) */
stopGeneration: () => void
- /** 取消排队消息 */
+ /** Cancel the queued message */
cancelQueued: () => void
- /** 重新生成 */
+ /** Regenerate a message */
regenerate: (messageId: string | number) => Promise
- /** 添加消息 */
+ /** Add a message */
addMessage: (message: Omit & { id?: string | number }) => Message
- /** 清空消息 */
+ /** Clear all messages */
clearMessages: () => void
- /** 重连到运行中的流 */
+ /** Reconnect to a stream that is already running on the backend */
reconnectStream: (conversationId: string) => Promise
- /** 彻底重置流上下文 — 切换/新建会话时调用 */
+ /** Fully reset stream context — call when switching or creating a conversation */
resetForNewConversation: () => void
}
export interface SendMessageOptions {
- /** 会话 ID */
+ /** Conversation ID */
conversationId: string
/** Agent ID */
agentId: string | number
- /** 附件列表 */
+ /** Attachment list */
attachments?: MessageContentPart[]
- /** 消息内容 */
+ /** Message content parts */
contentParts?: MessageContentPart[]
- /** 思考深度:off / low / medium / high / max */
+ /** Thinking depth: off / low / medium / high / max */
thinkingLevel?: string
}
@@ -97,7 +98,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
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 => {
const headers: Record = {
@@ -112,32 +113,32 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const error = ref(null)
const currentAssistantId = ref(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 | null = null
const streamPhase = ref('idle')
const phaseInfo = ref(null)
- /** 分段式展示数据:当前助手消息的所有分段 */
+ /** All segments of the current assistant message (for segmented display) */
const currentSegments = ref([])
const segIdCounter = { value: 0 }
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 = ''
- /** 重置当前 turn 的流式状态 — 必须在每次创建新 assistant placeholder 之前调用 */
+ /** Reset streaming state for the current turn — must be called before creating a new assistant placeholder */
function resetCurrentTurnState() {
currentSegments.value = []
segIdCounter.value = 0
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 = () => {
if (!currentAssistantId.value || currentSegments.value.length === 0) return
const msg = getMessage(currentAssistantId.value)
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
const metadata = parseMetadata((msg as any).metadata)
updateMessage(currentAssistantId.value, {
@@ -148,7 +149,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const heartbeat = ref(null)
/** Track which conversation the current stream belongs to */
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 {
const eventConvId = data?.conversationId
if (eventConvId && streamConversationId && eventConvId !== streamConversationId) {
@@ -156,18 +157,18 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
return false
}
- /** 已处理的 approval pendingId 集合(幂等去重) */
+ /** Set of already-processed approval pendingIds (idempotency dedup) */
const processedApprovalIds = new Set()
/**
- * 解析 metadata - 处理从数据库加载的 JSON 字符串
+ * Parse metadata — handles JSON strings loaded from the database.
*/
const parseMetadata = (metadata: any): any => {
if (!metadata) return {}
if (typeof metadata === 'string') {
try {
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') {
try { parsed = JSON.parse(parsed) } catch { /* ignore */ }
}
@@ -181,8 +182,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
/**
- * SSE 以 error/done 结束但审批实际上已失效时,收口残留的 awaiting_approval UI 状态。
- * 必须用 updateMessage 触发响应式更新,不能只改嵌套字段。
+ * Expire stale awaiting_approval UI state when the stream ends with error/done but the approval
+ * is no longer active. Must use updateMessage to trigger Vue reactivity — mutating nested fields
+ * alone is not sufficient.
*/
const expirePendingApprovals = (finalStatus: 'completed' | 'failed' | 'stopped') => {
for (const m of messages.value) {
@@ -218,7 +220,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
}
- // 消息管理
+ // Message management
const {
messages,
isGenerating,
@@ -232,14 +234,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
getMessage,
} = useMessages({
onComplete: () => {
- // 不在 onComplete 里清 currentAssistantId — 让 done 事件来清
+ // Do not clear currentAssistantId here — the 'done' event handles cleanup
},
})
- // 消息队列
+ // Message queue
const messageQueue = useMessageQueue()
- // 流连接(注入 auth + workspace header,与 axios interceptor 保持一致)
+ // Stream connection (inject auth + workspace headers, consistent with the axios interceptor)
const streamHeaders: Record = {}
if (token) {
streamHeaders['Authorization'] = `Bearer ${token}`
@@ -253,7 +255,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
headers: streamHeaders,
})
- // ===== SSE 事件处理器 =====
+ // ===== SSE event handlers =====
stream.on('content_delta', (data) => {
if (isStaleEvent(data)) return
@@ -262,16 +264,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (['thinking', 'reasoning', 'drafting_answer', 'preparing_context'].includes(streamPhase.value)) {
streamPhase.value = 'streaming'
}
- // 分段:追加到当前 content segment 或创建新的
+ // Segments: append to the current running content segment, or create a new one
const segs = currentSegments.value
let contentSeg = segs.findLast((s: MessageSegment) => s.type === 'content' && s.status === 'running')
if (!contentSeg) {
- // 关闭之前的 thinking segment
+ // Close any running thinking segment first
const thinkingSeg = segs.findLast((s: MessageSegment) => s.type === 'thinking' && s.status === 'running')
if (thinkingSeg) thinkingSeg.status = 'completed'
contentSeg = { id: genSegId(), type: 'content', status: 'running', text: '', timestamp: Date.now() }
segs.push(contentSeg)
- flushSegmentsToMessage() // 新 content segment 创建时同步一次
+ flushSegmentsToMessage() // sync once when a new content segment is created
}
contentSeg.text = (contentSeg.text || '') + (data.delta || '')
}
@@ -279,24 +281,24 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('thinking_delta', (data) => {
if (isStaleEvent(data)) return
- // thinkingLevel=off 时抑制 thinking 展示
+ // Suppress thinking display when thinkingLevel=off
if (options.thinkingLevel?.value === 'off') return
if (currentAssistantId.value) {
appendMessageContent(currentAssistantId.value, data.delta || '', 'thinking')
if (streamPhase.value !== 'summarizing_observations') {
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
- // 优先复用已有的 thinking segment(无论 running 还是 completed)
+ // Reuse an existing thinking segment regardless of status (running or completed)
let thinkSeg = segs.find((s: MessageSegment) => s.type === 'thinking')
if (!thinkSeg) {
thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() }
- // 插入到开头(thinking 始终在最上方)
+ // Insert at front — thinking always appears at the top
segs.unshift(thinkSeg)
flushSegmentsToMessage()
}
- // 新的 thinking 到来,重新设为 running
+ // Re-mark as running when new thinking content arrives
thinkSeg.status = 'running'
thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '')
}
@@ -313,7 +315,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
return
}
- // 没有 placeholder 时才创建(正常路径 placeholder 已在 sendMessage 中创建)
+ // Only create a placeholder here if one does not already exist (the normal path creates it in sendMessage)
resetCurrentTurnState()
const assistantMessage = createAssistantMessage('', streamConversationId)
;(assistantMessage as any)._turnId = activeTurnId
@@ -342,7 +344,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value)
if (msg?.status === 'failed') {
- // 不清除 currentAssistantId — 让 done 事件来做
+ // Do not clear currentAssistantId — the 'done' event handles cleanup
return
}
if (msg) {
@@ -362,16 +364,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
status: data.status || 'completed',
metadata: { ...metadata, toolCalls }
} as any)
- // 关键修复:不在这里清除 currentAssistantId,让 done 来清
+ // Do not clear currentAssistantId here — the 'done' event does it
return
}
}
}
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) {
currentSegments.value.forEach((s: MessageSegment) => { if (s.status === 'running') s.status = 'completed' })
const msg = getMessage(currentAssistantId.value)
@@ -384,7 +386,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
}
- // === 自动 TTS:message_complete 且 status=completed 时触发 ===
+ // Auto TTS: trigger when message_complete arrives with status=completed
if (data.status === 'completed' && data.hasContent && currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value)
if (msg?.content && streamConversationId) {
@@ -402,13 +404,13 @@ export function useChat(options: UseChatOptions): UseChatReturn {
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)
if (msgIndex >= 0) {
const msg = messages.value[msgIndex]
if (data.promptTokens !== undefined) msg.promptTokens = data.promptTokens
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) {
msg.id = data.assistantMessageId
}
@@ -424,11 +426,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
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) {
- // 队列已空,确保 phase 不残留 queued
+ // Queue already empty — phase cannot linger at 'queued'
} else if (data.status === 'stopped') {
- // 用户主动停止,清除排队
+ // User-initiated stop — discard queued message
messageQueue.clear()
}
@@ -467,7 +469,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
error.value = new Error(data.message || '请求失败')
streamPhase.value = 'idle'
phaseInfo.value = null
- // 错误时清理排队状态,避免脏残留
+ // Clear queue on error to avoid stale state
messageQueue.clear()
expirePendingApprovals('failed')
@@ -482,7 +484,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
})
})
- // ===== Agent 事件处理 =====
+ // ===== Agent event handlers =====
stream.on('tool_call_started', (data) => {
if (isStaleEvent(data)) return
@@ -503,7 +505,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
metadata: { ...metadata, toolCalls, currentPhase: 'executing_tool', runningToolName: data.toolName }
} 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 runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running' && (s.type === 'thinking' || s.type === 'content'))
if (runningSeg) runningSeg.status = 'completed'
@@ -537,7 +539,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
metadata: { ...metadata, toolCalls, runningToolName: undefined }
} as any)
}
- // 分段:找到对应的 running tool_call segment 并标记完成
+ // Segments: find the matching running tool_call segment and mark it complete
const segs = currentSegments.value
const toolSeg = segs.findLast((s: MessageSegment) =>
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) => {
if (isStaleEvent(data)) return
@@ -587,7 +589,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const msg = getMessage(currentAssistantId.value)
if (msg) {
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
updateMessage(currentAssistantId.value, {
...msg,
@@ -597,21 +599,24 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
})
- // ===== Agent 委派事件 =====
+ // ===== Agent delegation events =====
stream.on('delegation_start', (data) => {
if (isStaleEvent(data)) return
streamPhase.value = 'executing_tool'
if (currentAssistantId.value) {
const segs = currentSegments.value
- // 关闭之前的 thinking/content segment
+ // Close any running thinking/content segment
const runningSeg = segs.findLast((s: MessageSegment) => s.status === 'running')
if (runningSeg) runningSeg.status = 'completed'
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) {
segs.push({
- id: genSegId(),
+ id: child.childConversationId || genSegId(),
type: 'tool_call',
status: 'running',
toolName: `→ ${child.childAgentName || 'Agent'}`,
@@ -620,9 +625,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
})
}
} else {
- // 单任务模式
+ // Single-task mode: same stable ID approach
segs.push({
- id: genSegId(),
+ id: data.childConversationId || genSegId(),
type: 'tool_call',
status: 'running',
toolName: `→ ${data.childAgentName || 'Agent'}`,
@@ -638,35 +643,40 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (isStaleEvent(data)) return
if (!currentAssistantId.value) return
const segs = currentSegments.value
- const childName = data.childAgentName || ''
- // Find the running delegation segment for this child (or fall back to any running delegation)
- const delegSeg = segs.findLast((s: MessageSegment) =>
- s.type === 'tool_call' && s.status === 'running' && s.toolName === `→ ${childName}`)
- || segs.findLast((s: MessageSegment) => s.type === 'tool_call' && s.status === 'running' && s.toolName?.startsWith('→'))
+
+ // Primary lookup: by stable childConversationId (set as the segment ID at creation time).
+ // Fallback: any running delegation segment (for older backends that don't send the field).
+ const delegSeg = (data.childConversationId
+ ? 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
+ // 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 = rawPayload && typeof rawPayload === 'object'
+ ? rawPayload
+ : (() => { try { return JSON.parse(String(rawPayload || '{}')) } catch { return {} } })()
+
if (data.originalEvent === 'tool_call_started') {
- // Child started a sub-tool — append activity hint so the user sees the child is working
- const childData = data.data
- const toolName = typeof childData === 'object' ? childData?.toolName : String(childData || '')
+ const toolName = childData?.toolName || ''
if (toolName) {
delegSeg.toolArgs = (delegSeg.toolArgs || '') + `\n → ${toolName}`
}
} else if (data.originalEvent === 'tool_call_completed') {
- // Child finished a sub-tool call — update the running hint
- const childData = data.data
- const toolName = typeof childData === 'object' ? childData?.toolName : String(childData || '')
- const success = typeof childData === 'object' ? childData?.success !== false : true
+ const toolName = childData?.toolName || ''
+ const success = childData?.success !== false
if (toolName) {
- // Replace last appended "→ toolName" with "✓/✗ toolName"
+ // Replace the matching "→ toolName" hint with "✓/✗ toolName"
delegSeg.toolArgs = (delegSeg.toolArgs || '').replace(
new RegExp(`\\n → ${toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`),
`\n ${success ? '✓' : '✗'} ${toolName}`)
}
} else if (data.originalEvent === 'phase') {
- // Child entered a new phase (reasoning, executing_tool, etc.)
- const phase = typeof data.data === 'object' ? data.data?.phase : String(data.data || '')
+ const phase = childData?.phase || String(rawPayload || '')
const phaseHints: Record = {
reasoning: '…',
executing_tool: '→',
@@ -688,20 +698,22 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (isStaleEvent(data)) return
if (!currentAssistantId.value) return
const segs = currentSegments.value
- const childName = data.childAgentName || ''
- const delegSeg = segs.findLast((s: MessageSegment) =>
- s.type === 'tool_call' && s.status === 'running' && s.toolName === `→ ${childName}`)
+ // Prefer childConversationId (stable) over agent name (non-unique)
+ const delegSeg = (data.childConversationId
+ ? segs.find((s: MessageSegment) => s.id === data.childConversationId)
+ : undefined)
|| 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) {
delegSeg.status = data.success ? 'completed' : 'error'
delegSeg.toolSuccess = data.success
- // Append duration to args so the user sees how long each child took
if (data.durationMs) {
const durSec = Math.round(data.durationMs / 1000)
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
}
}
@@ -717,19 +729,26 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// fall back to aggregate success flag for older backends.
if (Array.isArray(data.childResults) && data.childResults.length > 0) {
for (const cr of data.childResults) {
- const agentName = cr.agentName || ''
- const seg = segs.findLast((s: MessageSegment) =>
- s.type === 'tool_call' &&
- (s.status === 'running' || s.status === 'completed') &&
- s.toolName?.includes(agentName))
+ // Primary: stable childConversationId lookup. Fallback: agent name substring.
+ const seg = (cr.childConversationId
+ ? segs.find((s: MessageSegment) => s.id === cr.childConversationId)
+ : undefined)
+ || segs.findLast((s: MessageSegment) =>
+ s.type === 'tool_call' && s.toolName?.includes(cr.agentName || ''))
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.toolSuccess = cr.success
if (cr.durationMs) {
const durSec = Math.round(cr.durationMs / 1000)
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 {
@@ -814,11 +833,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
})
- // ===== 工具审批事件(带幂等去重) =====
+ // ===== Tool approval events (with idempotency dedup) =====
stream.on('tool_approval_requested', (data) => {
if (isStaleEvent(data)) return
- // 幂等去重:同一 pendingId 只处理一次
+ // Idempotency: process each pendingId only once
if (data.pendingId && processedApprovalIds.has(data.pendingId)) {
// duplicate approval ignored
return
@@ -911,12 +930,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
streamPhase.value = data.decision === 'approved' ? 'streaming' : 'completed'
})
- // ===== Heartbeat 事件 =====
+ // ===== Heartbeat events =====
stream.on('heartbeat', (data: HeartbeatData) => {
heartbeat.value = data
- // heartbeat 到达意味着连接活跃,useStream 的 timeout 已由 resetStreamTimeout 自动重置
- // 从 heartbeat 中更新 phase(如果前端还没有更精确的 phase)
+ // Heartbeat arrival means the connection is alive; useStream resets the timeout automatically.
+ // Update phase from heartbeat only when the frontend doesn't have a more precise phase yet.
if (data.currentPhase && streamPhase.value !== 'interrupting') {
const phaseMap: Record = {
'preparing_context': 'preparing_context',
@@ -934,16 +953,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const mapped = phaseMap[data.currentPhase]
if (mapped) streamPhase.value = mapped
}
- // 利用 heartbeat 的 queueLength 校准本地排队状态
- // 仅在消息已被后端确认(status=sending)后才以 heartbeat 兜底清理,
- // 避免在 interrupt 请求仍在途中时误清尚未到达后端的消息
+ // Use heartbeat queueLength to reconcile local queue state.
+ // Only clear when the message has been acknowledged by the backend (status=sending),
+ // to avoid discarding a message whose interrupt request is still in flight.
if (data.queueLength === 0 && messageQueue.hasQueued.value
&& messageQueue.queuedMessage.value?.status === 'sending') {
messageQueue.clear()
}
})
- // ===== Interrupt + Queue 事件 =====
+ // ===== Interrupt + Queue events =====
stream.on('turn_interrupt_requested', () => {
streamPhase.value = 'interrupting'
@@ -951,31 +970,31 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('turn_interrupted', (data) => {
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) {
streamPhase.value = 'queued'
}
})
stream.on('queued_input_accepted', (data) => {
- // 后端已确认接收排队消息,标记为 sending(允许 heartbeat 兜底清理)
+ // Backend confirmed receipt of the queued message — mark as 'sending' to allow heartbeat cleanup
messageQueue.markSending()
streamPhase.value = 'queued'
})
stream.on('queued_input_started', (data) => {
if (isStaleEvent(data)) return
- // 后端已开始处理排队消息
- // 1. 先用排队的内容创建用户消息(此时上一轮回答已完成,顺序正确)
+ // Backend has started processing the queued message.
+ // 1. Create the user message first (previous turn is now complete so ordering is correct)
const queued = messageQueue.dequeue()
const messageContent = data.message || queued?.content || ''
if (messageContent) {
const convId = data.conversationId || streamConversationId
createUserMessage(messageContent, queued?.contentParts, convId)
}
- // 2. 再创建 assistant 占位消息
+ // 2. Create the assistant placeholder message
resetCurrentTurnState()
const convId2 = data.conversationId || streamConversationId
const assistantMessage = createAssistantMessage('', convId2)
@@ -985,7 +1004,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
phaseInfo.value = null
})
- // ===== 异步任务完成事件(视频生成、图片生成等) =====
+ // ===== Async task completion events (video generation, image generation, etc.) =====
stream.on('async_task_completed', (data) => {
if (isStaleEvent(data)) return
if (data.success && streamConversationId) {
@@ -1008,7 +1027,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (!mediaPart) return
- // 优先附加到当前 assistant 消息(避免图片跑到文字回复上方)
+ // Prefer appending to the current assistant message (avoids image appearing above the text reply)
if (currentAssistantId.value) {
const msg = getMessage(currentAssistantId.value)
if (msg) {
@@ -1020,7 +1039,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
}
- // 回退:Agent 已结束,新建独立消息
+ // Fallback: agent already finished — create a standalone message
addMessage({
role: 'assistant',
content: '',
@@ -1031,13 +1050,13 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
})
- // ===== TTS 自动朗读 =====
+ // ===== Auto TTS =====
let ttsAutoModeCache: string | null = null
let ttsCacheExpiry = 0
async function triggerAutoTts(conversationId: string, text: string) {
try {
- // 缓存 settings 5 分钟,避免每条消息都请求
+ // Cache settings for 5 minutes to avoid a request on every message
const now = Date.now()
if (!ttsAutoModeCache || now > ttsCacheExpiry) {
const res: any = await http.get('/system-settings')
@@ -1045,14 +1064,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
ttsCacheExpiry = now + 5 * 60 * 1000
}
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(() => {})
} 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) => {
if (data.audioUrl) {
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 { conversationId, agentId, attachments = [], contentParts = [] } = options
- // 审批命令不走 interrupt 逻辑
+ // Approval commands bypass the interrupt logic
const isApprovalCommand = /^\/(approve|deny)$/i.test(content.trim())
- // ===== 运行中发送新消息:走 interrupt / queue 路径 =====
+ // ===== Sending while generating: route to interrupt / queue path =====
if (isGenerating.value && !isApprovalCommand) {
return await handleInterruptOrQueue(content, options)
}
- // ===== 正常发送路径 =====
- // 清除上一次 stop 的 fallback timer,防止误杀新连接
+ // ===== Normal send path =====
+ // Clear the previous stop fallback timer to avoid killing the new connection
if (stopFallbackTimer) {
clearTimeout(stopFallbackTimer)
stopFallbackTimer = null
}
- // 切换会话时断开旧流,防止旧事件污染新会话
+ // Disconnect the old stream when switching conversations to prevent event pollution
if (streamConversationId && streamConversationId !== conversationId) {
stream.disconnect()
currentAssistantId.value = null
@@ -1108,7 +1127,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
;(assistantMessage as any)._turnId = activeTurnId
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 = {
agentId,
message: content,
@@ -1127,16 +1146,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
/**
- * 运行中发送新消息:判断当前阶段是否可中断
- * - 可中断(thinking/streaming/executing_tool):发送 interrupt 请求
- * - 不可中断(awaiting_approval):排队
+ * Send a new message while one is already generating.
+ * - Interruptible phases (thinking/streaming/executing_tool): send an interrupt request.
+ * - Non-interruptible phases (awaiting_approval): queue the message.
*/
const handleInterruptOrQueue = async (content: string, options: SendMessageOptions) => {
const { conversationId, agentId } = options
- // 不立即创建用户消息 —— 等 queued_input_started 再插入,
- // 这样用户消息会出现在上一轮回答之后,保证正确的消息顺序。
- // 加入本地队列(保存 contentParts 以便延迟创建时使用)
+ // 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.
+ // Add to the local queue now (saves contentParts for delayed creation).
messageQueue.enqueue(content, options.contentParts, conversationId)
try {
@@ -1151,14 +1170,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const result = await res.json()
if (result.data?.interrupted) {
- // 可中断:后端已发起中断,排队消息会被后端自动续跑
+ // Interruptible: backend initiated the interrupt; queued message will auto-resume
streamPhase.value = 'interrupting'
messageQueue.markSending()
} else if (result.data?.queued) {
- // 不可中断但已排队:等当前步骤结束后自动续跑
+ // Non-interruptible but queued: will auto-resume when the current step ends
streamPhase.value = 'queued'
} else {
- // 没有活跃的流,直接发送
+ // No active stream — send directly
messageQueue.clear()
createUserMessage(content, options.contentParts, conversationId)
resetCurrentTurnState()
@@ -1176,8 +1195,9 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
} catch (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()
if (failedQueued) {
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:
- // 不立即断开 SSE,而是先发 stop 信号,等后端通过 SSE 返回 done 事件后再清理。
- // 这样 done 事件能正常到达,onStreamEnd 被触发,消息状态和会话列表都能正确更新。
- // 加一个 fallback timeout(3 秒),防止 done 事件因网络问题永远不到达。
+ // Design: do not disconnect the SSE immediately — send a stop signal first and wait for the
+ // backend to return a 'done' event. This ensures onStreamEnd fires and message/conversation
+ // state is updated correctly.
+ // A 3-second fallback timeout guards against 'done' never arriving due to network issues.
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 assistantId = currentAssistantId.value
- // 仅在前端确实在生成时才触发停止(含 SSE 接收中 / reconnect 中 / 审批等待中)。
- // 否则只是"旁观者"身份,不能把对方(渠道用户)的 agent run 也一起杀掉。
+ // Only stop when the frontend is actively involved in the stream (receiving SSE /
+ // reconnecting / awaiting approval). As a bystander we must not kill another user's run.
const activelyStreaming = isGenerating.value
|| streamPhase.value === 'reconnecting'
|| streamPhase.value === 'awaiting_approval'
if (!activelyStreaming) {
- // 没有真正在流 → 什么都不做,让调用方直接走 resetForNewConversation
+ // Not actually streaming — let the caller go straight to resetForNewConversation
return
}
- // 先取消排队消息
+ // Cancel queued message first
messageQueue.clear()
- // 标记为停止中(让 UI 立即反馈)
+ // Mark as stopped immediately so the UI gives instant feedback
streamPhase.value = 'stopped'
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)
stopFallbackTimer = setTimeout(() => {
stopFallbackTimer = null
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) {
stream.disconnect()
}
@@ -1234,7 +1255,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
})
}, 3000)
- // 当 done 事件到达时,取消 fallback timer
+ // Cancel the fallback timer when the done/error event arrives
const unsubscribe = stream.on('done', () => {
if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null }
unsubscribe()
@@ -1244,7 +1265,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
unsubscribeError()
})
- // 发送后端 stop 请求(fire-and-forget,不阻塞 resetForNewConversation)
+ // Send the backend stop request (fire-and-forget, does not block resetForNewConversation)
if (convId) {
fetchWithAuth(`${baseUrl}/api/v1/chat/${convId}/stop`, {
method: 'POST',
@@ -1254,25 +1275,25 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
}
- // 取消排队消息
+ // Cancel the queued message
const cancelQueued = () => {
messageQueue.cancel()
- // 通知后端清除排队消息(fire-and-forget)
+ // Notify backend (fire-and-forget)
if (streamConversationId) {
const headers: Record = { 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
- // 后端没有专门的取消排队 API,用 stop 的语义来处理
+ // No dedicated cancel-queue API — the stop semantic covers this case
}
if (streamPhase.value === 'queued') {
streamPhase.value = isGenerating.value ? 'streaming' : 'idle'
}
}
- // 重连到运行中的流
+ // Reconnect to a stream that is already running on the backend
const reconnectStream = async (conversationId: string) => {
if (isGenerating.value) return
- // 清除残留的 stop fallback timer
+ // Clear any leftover stop fallback timer
if (stopFallbackTimer) { clearTimeout(stopFallbackTimer); stopFallbackTimer = null }
streamPhase.value = 'reconnecting'
streamConversationId = conversationId
@@ -1282,8 +1303,8 @@ export function useChat(options: UseChatOptions): UseChatReturn {
resetCurrentTurnState()
- // 清理尾部的空 assistant 消息(来自上一轮被误杀的 run 留下的空壳,或 placeholder 遗留),
- // 避免与即将重连产生的 streaming 气泡共存形成"重复两条"假象。
+ // Remove trailing empty assistant messages left over from a killed run or a stale placeholder.
+ // Prevents "two bubbles" appearing when the reconnect creates a new streaming placeholder.
while (messages.value.length > 0) {
const tail = messages.value[messages.value.length - 1]
if (tail && tail.role === 'assistant'
@@ -1307,11 +1328,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
})
} catch (e) {
console.error('[useChat] Reconnect failed:', e)
- // 重连失败:清理占位消息
+ // Reconnect failed — clean up the placeholder message
const msgIndex = messages.value.findIndex(m => m.id === currentAssistantId.value)
if (msgIndex >= 0) {
const msg = messages.value[msgIndex]
- // 如果占位消息没有内容,移除它
+ // Remove the placeholder if it has no content
if (!msg.content && (!msg.contentParts || msg.contentParts.length === 0)) {
messages.value.splice(msgIndex, 1)
} else {
@@ -1324,7 +1345,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
}
- // 重新生成
+ // Regenerate a message
const regenerate = async (messageId: string | number) => {
const message = getMessage(messageId)
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 = () => {
stream.disconnect()
streamConversationId = ''