perf(web): 子代理 stream_delta rAF 批处理 + 消息槽 memo 化 + base64 分块解码
- useSubAgentView 镜像主视图:delta 累加到 ref,rAF 批量一次 setState,避免逐 token 重渲染 - 所有非 delta 消息处理前同步落盘 pending delta,保证顺序与内容完整性 - MessageList 消息 ID 映射 useMemo,稳定回调引用减少重渲染 - MessageBubble base64 下载改分块解码(32K),大附件峰值内存从数百 MB 降为单块级
This commit is contained in:
parent
52f858bfb4
commit
5e2bdaf757
@ -423,6 +423,16 @@ function App() {
|
|||||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
|
sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
|
||||||
}, [sendMessage, handleCommand, handleStop]);
|
}, [sendMessage, handleCommand, handleStop]);
|
||||||
|
|
||||||
|
// 稳定引用:只读视图(子智能体/定时任务)下的空发送回调,
|
||||||
|
// 避免内联箭头函数每次渲染产生新引用、破坏下游 memo 化。
|
||||||
|
const noopSendMessage = useCallback(() => {}, []);
|
||||||
|
|
||||||
|
// 稳定引用:打开专家设置页
|
||||||
|
const openExpertsSettings = useCallback(() => {
|
||||||
|
setConfigInitialTab('experts');
|
||||||
|
setConfigPageOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCreateTopic = useCallback(() => {
|
const handleCreateTopic = useCallback(() => {
|
||||||
if (isReadOnly || !sessionId) {
|
if (isReadOnly || !sessionId) {
|
||||||
return;
|
return;
|
||||||
@ -1007,7 +1017,7 @@ function App() {
|
|||||||
channels.find((c) => c.id === selectedChannel)?.name ??
|
channels.find((c) => c.id === selectedChannel)?.name ??
|
||||||
'PicoBot')
|
'PicoBot')
|
||||||
}
|
}
|
||||||
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
|
onSendMessage={subAgentView || schedulerView ? noopSendMessage : handleSendMessage}
|
||||||
onNavigateToSubAgent={handleNavigateToSubAgent}
|
onNavigateToSubAgent={handleNavigateToSubAgent}
|
||||||
onStop={handleStopExecution}
|
onStop={handleStopExecution}
|
||||||
showThinking={showThinking}
|
showThinking={showThinking}
|
||||||
@ -1015,10 +1025,7 @@ function App() {
|
|||||||
highlightedMessageId={highlightedMessageId}
|
highlightedMessageId={highlightedMessageId}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
settingsClosedTick={settingsClosedTick}
|
settingsClosedTick={settingsClosedTick}
|
||||||
onOpenSettings={() => {
|
onOpenSettings={openExpertsSettings}
|
||||||
setConfigInitialTab('experts');
|
|
||||||
setConfigPageOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { MessageList } from './MessageList';
|
import { MessageList } from './MessageList';
|
||||||
import { MessageInput } from './MessageInput';
|
import { MessageInput } from './MessageInput';
|
||||||
import { ExpertSelector } from './ExpertSelector';
|
import { ExpertSelector } from './ExpertSelector';
|
||||||
@ -55,6 +55,13 @@ export function ChatContainer({
|
|||||||
model: string;
|
model: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// 稳定引用,避免内联箭头破坏下游 memo 化
|
||||||
|
const handleModelSelectionChange = useCallback(
|
||||||
|
(effective: { provider: string; model: string }) =>
|
||||||
|
setEffectiveModel({ provider: effective.provider, model: effective.model }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const selectors = (
|
const selectors = (
|
||||||
<div className="flex flex-wrap items-center gap-1 px-3 pt-2">
|
<div className="flex flex-wrap items-center gap-1 px-3 pt-2">
|
||||||
<ExpertSelector
|
<ExpertSelector
|
||||||
@ -67,9 +74,7 @@ export function ChatContainer({
|
|||||||
sessionId={sessionId ?? null}
|
sessionId={sessionId ?? null}
|
||||||
topicId={topicId ?? null}
|
topicId={topicId ?? null}
|
||||||
settingsClosedTick={settingsClosedTick}
|
settingsClosedTick={settingsClosedTick}
|
||||||
onSelectionChange={(effective) =>
|
onSelectionChange={handleModelSelectionChange}
|
||||||
setEffectiveModel({ provider: effective.provider, model: effective.model })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -132,6 +132,24 @@ function formatDuration(ms: number): string {
|
|||||||
return `${minutes}m ${seconds}s`;
|
return `${minutes}m ${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base64 → Blob 分块解码。
|
||||||
|
* 旧实现为 atob 全量字符串 + 装箱数字数组 + 单个巨型 Uint8Array,
|
||||||
|
* 50MB 附件下载瞬时占用数百 MB;分块构造后 Blob 直接接收分片,
|
||||||
|
* 峰值内存约为 atob 字符串 + 单个分块大小。
|
||||||
|
*/
|
||||||
|
function base64ToBlob(base64: string, mimeType: string): Blob {
|
||||||
|
const byteChars = atob(base64);
|
||||||
|
const total = byteChars.length;
|
||||||
|
const CHUNK_SIZE = 0x8000; // 32K
|
||||||
|
const parts: Uint8Array<ArrayBuffer>[] = [];
|
||||||
|
for (let offset = 0; offset < total; offset += CHUNK_SIZE) {
|
||||||
|
const slice = byteChars.slice(offset, offset + CHUNK_SIZE);
|
||||||
|
parts.push(Uint8Array.from(slice, (c) => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
return new Blob(parts, { type: mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
||||||
const fileName = attachment.file_name || getFileName(attachment.path);
|
const fileName = attachment.file_name || getFileName(attachment.path);
|
||||||
|
|
||||||
@ -140,13 +158,7 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
|||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const mimeType = attachment.mime_type || 'application/octet-stream';
|
const mimeType = attachment.mime_type || 'application/octet-stream';
|
||||||
const byteChars = atob(attachment.content_base64);
|
const blob = base64ToBlob(attachment.content_base64, mimeType);
|
||||||
const byteNums = new Array(byteChars.length);
|
|
||||||
for (let i = 0; i < byteChars.length; i++) {
|
|
||||||
byteNums[i] = byteChars.charCodeAt(i);
|
|
||||||
}
|
|
||||||
const byteArr = new Uint8Array(byteNums);
|
|
||||||
const blob = new Blob([byteArr], { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@ -210,13 +222,7 @@ function ImageLightbox({
|
|||||||
|
|
||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const byteChars = atob(src);
|
const blob = base64ToBlob(src, mimeType);
|
||||||
const byteNums = new Array(byteChars.length);
|
|
||||||
for (let i = 0; i < byteChars.length; i++) {
|
|
||||||
byteNums[i] = byteChars.charCodeAt(i);
|
|
||||||
}
|
|
||||||
const byteArr = new Uint8Array(byteNums);
|
|
||||||
const blob = new Blob([byteArr], { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { MessageBubble } from './MessageBubble';
|
import { MessageBubble } from './MessageBubble';
|
||||||
import type { ChatMessage } from '../../types/protocol';
|
import type { ChatMessage } from '../../types/protocol';
|
||||||
@ -55,8 +55,13 @@ export function MessageList({
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位
|
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
|
||||||
const messageIdToIndex = useRef<Map<string, number>>(new Map());
|
// useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。
|
||||||
|
const messageIdToIndex = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
messages.forEach((m, i) => map.set(m.id, i));
|
||||||
|
return map;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
// ---- scroll helpers ----
|
// ---- scroll helpers ----
|
||||||
|
|
||||||
@ -167,7 +172,7 @@ export function MessageList({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightedMessageId) return;
|
if (!highlightedMessageId) return;
|
||||||
const idx = messageIdToIndex.current.get(highlightedMessageId);
|
const idx = messageIdToIndex.get(highlightedMessageId);
|
||||||
if (idx === undefined) return;
|
if (idx === undefined) return;
|
||||||
|
|
||||||
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
||||||
@ -183,7 +188,7 @@ export function MessageList({
|
|||||||
targetElement.classList.remove('todo-highlight');
|
targetElement.classList.remove('todo-highlight');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
}, [highlightedMessageId, virtualizer]);
|
}, [highlightedMessageId, messageIdToIndex, virtualizer]);
|
||||||
|
|
||||||
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
|
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
|
||||||
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
|
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
|
||||||
@ -241,10 +246,6 @@ export function MessageList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建消息 id → index 映射(每次渲染更新,供 highlight 查找)
|
|
||||||
messageIdToIndex.current.clear();
|
|
||||||
messages.forEach((m, i) => messageIdToIndex.current.set(m.id, i));
|
|
||||||
|
|
||||||
// ---- main render ----
|
// ---- main render ----
|
||||||
|
|
||||||
const virtualItems = virtualizer.getVirtualItems();
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
|
|||||||
@ -55,6 +55,99 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
const subAgentStackRef = useRef<SubAgentView[]>([]);
|
const subAgentStackRef = useRef<SubAgentView[]>([]);
|
||||||
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map());
|
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// ---- 流式 delta 批处理(镜像主视图 useMessages 的 ref 累加 + rAF flush 范式)----
|
||||||
|
// 每个 stream_delta 只累加到 ref,rAF 批量合并为一次 setState,
|
||||||
|
// 避免逐 token 触发整棵子树重渲染与双层数组拷贝。
|
||||||
|
interface PendingSubAgentDelta {
|
||||||
|
/** 'top' 表示栈顶层;否则按 taskId 定位栈中层 */
|
||||||
|
target: 'top' | string;
|
||||||
|
id: string;
|
||||||
|
contentChunks: string[];
|
||||||
|
reasoningChunks: string[];
|
||||||
|
/** 首个 delta 到达时创建的消息壳(含首段 content),后续 delta 追加到其后 */
|
||||||
|
shell: ChatMessage | null;
|
||||||
|
}
|
||||||
|
const pendingDeltasRef = useRef<Map<string, PendingSubAgentDelta>>(new Map());
|
||||||
|
const deltaRafRef = useRef<number | null>(null);
|
||||||
|
const deltaRafScheduledRef = useRef(false);
|
||||||
|
|
||||||
|
const flushSubAgentDeltas = useCallback(() => {
|
||||||
|
deltaRafScheduledRef.current = false;
|
||||||
|
const pending = pendingDeltasRef.current;
|
||||||
|
if (pending.size === 0) return;
|
||||||
|
const entries = Array.from(pending.values());
|
||||||
|
pending.clear();
|
||||||
|
setSubAgentStack((prev) => {
|
||||||
|
if (prev.length === 0) return prev;
|
||||||
|
let stack = prev;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const layerIdx =
|
||||||
|
entry.target === 'top'
|
||||||
|
? stack.length - 1
|
||||||
|
: stack.findIndex((v) => v.taskId === entry.target);
|
||||||
|
if (layerIdx < 0) continue;
|
||||||
|
const layer = stack[layerIdx];
|
||||||
|
const deltaContent = entry.contentChunks.join('');
|
||||||
|
const deltaReasoning =
|
||||||
|
entry.reasoningChunks.length > 0 ? entry.reasoningChunks.join('') : null;
|
||||||
|
const idx = layer.messages.findIndex((m) => m.id === entry.id && m.type === 'message');
|
||||||
|
const layerCopy = { ...layer };
|
||||||
|
if (idx >= 0) {
|
||||||
|
const updated = [...layer.messages];
|
||||||
|
const existing = updated[idx];
|
||||||
|
updated[idx] = {
|
||||||
|
...existing,
|
||||||
|
content: existing.content + deltaContent,
|
||||||
|
reasoningContent: deltaReasoning
|
||||||
|
? (existing.reasoningContent || '') + deltaReasoning
|
||||||
|
: existing.reasoningContent,
|
||||||
|
};
|
||||||
|
layerCopy.messages = updated;
|
||||||
|
} else if (entry.shell) {
|
||||||
|
layerCopy.messages = [
|
||||||
|
...layer.messages,
|
||||||
|
{
|
||||||
|
...entry.shell,
|
||||||
|
content: entry.shell.content + deltaContent,
|
||||||
|
reasoningContent: deltaReasoning
|
||||||
|
? (entry.shell.reasoningContent || '') + deltaReasoning || undefined
|
||||||
|
: entry.shell.reasoningContent,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = [...stack];
|
||||||
|
next[layerIdx] = layerCopy;
|
||||||
|
stack = next;
|
||||||
|
}
|
||||||
|
return stack;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleSubAgentDeltaFlush = useCallback(() => {
|
||||||
|
if (deltaRafScheduledRef.current) return;
|
||||||
|
deltaRafScheduledRef.current = true;
|
||||||
|
deltaRafRef.current = requestAnimationFrame(() => flushSubAgentDeltas());
|
||||||
|
}, [flushSubAgentDeltas]);
|
||||||
|
|
||||||
|
/** 立即落盘待处理 delta(取消已排队的 rAF)。非 delta 消息处理前调用以保证顺序。 */
|
||||||
|
const flushSubAgentDeltasSync = useCallback(() => {
|
||||||
|
if (deltaRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(deltaRafRef.current);
|
||||||
|
deltaRafRef.current = null;
|
||||||
|
}
|
||||||
|
flushSubAgentDeltas();
|
||||||
|
}, [flushSubAgentDeltas]);
|
||||||
|
|
||||||
|
// 卸载时取消未执行的 flush
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (deltaRafRef.current !== null) cancelAnimationFrame(deltaRafRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// ref 同步:确保回调中读到最新值
|
// ref 同步:确保回调中读到最新值
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
subAgentViewRef.current = subAgentView;
|
subAgentViewRef.current = subAgentView;
|
||||||
@ -70,33 +163,24 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
if (message.type === 'assistant_response') {
|
if (message.type === 'assistant_response') {
|
||||||
bumpTopicRefreshTrigger();
|
bumpTopicRefreshTrigger();
|
||||||
}
|
}
|
||||||
// stream_delta: accumulate into existing message by ID, or create new
|
// stream_delta: 累加到批处理缓冲区,rAF 统一 flush(不再逐 token setState)
|
||||||
if (message.type === 'stream_delta') {
|
if (message.type === 'stream_delta') {
|
||||||
const msg = message as StreamDelta;
|
const msg = message as StreamDelta;
|
||||||
setSubAgentStack((prev) => {
|
const pending = pendingDeltasRef.current;
|
||||||
if (prev.length === 0) return prev;
|
const entry = pending.get(msg.id);
|
||||||
const top = prev[prev.length - 1];
|
if (entry) {
|
||||||
const existingIdx = top.messages.findIndex((m) => m.id === msg.id && m.type === 'message');
|
if (msg.delta) entry.contentChunks.push(msg.delta);
|
||||||
if (existingIdx >= 0) {
|
if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
|
||||||
const updated = [...top.messages];
|
} else {
|
||||||
const existing = updated[existingIdx];
|
pending.set(msg.id, {
|
||||||
updated[existingIdx] = {
|
target: 'top',
|
||||||
...existing,
|
id: msg.id,
|
||||||
content: existing.content + msg.delta,
|
contentChunks: [],
|
||||||
reasoningContent: msg.reasoning_delta
|
reasoningChunks: [],
|
||||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
shell: serverMessageToChatMessage(message),
|
||||||
: existing.reasoningContent,
|
|
||||||
};
|
|
||||||
const newStack = [...prev];
|
|
||||||
newStack[newStack.length - 1] = { ...top, messages: updated };
|
|
||||||
return newStack;
|
|
||||||
}
|
|
||||||
const chatMsg = serverMessageToChatMessage(message);
|
|
||||||
if (!chatMsg) return prev;
|
|
||||||
const newStack = [...prev];
|
|
||||||
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
|
|
||||||
return newStack;
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
scheduleSubAgentDeltaFlush();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// stream_end: no-op, assistant_response will replace
|
// stream_end: no-op, assistant_response will replace
|
||||||
@ -168,10 +252,31 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
return newStack;
|
return newStack;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [bumpTopicRefreshTrigger]);
|
}, [bumpTopicRefreshTrigger, scheduleSubAgentDeltaFlush]);
|
||||||
|
|
||||||
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
|
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
|
||||||
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => {
|
const appendToSubAgentLayerMessage = useCallback(
|
||||||
|
(taskId: string, message: WsOutbound) => {
|
||||||
|
// stream_delta 在 setState 之外累加到批处理缓冲区(updater 内不允许副作用)
|
||||||
|
if (message.type === 'stream_delta') {
|
||||||
|
const msg = message as StreamDelta;
|
||||||
|
const pending = pendingDeltasRef.current;
|
||||||
|
const entry = pending.get(msg.id);
|
||||||
|
if (entry) {
|
||||||
|
if (msg.delta) entry.contentChunks.push(msg.delta);
|
||||||
|
if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
|
||||||
|
} else {
|
||||||
|
pending.set(msg.id, {
|
||||||
|
target: taskId,
|
||||||
|
id: msg.id,
|
||||||
|
contentChunks: [],
|
||||||
|
reasoningChunks: [],
|
||||||
|
shell: serverMessageToChatMessage(message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scheduleSubAgentDeltaFlush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubAgentStack((prev) => {
|
setSubAgentStack((prev) => {
|
||||||
const idx = prev.findIndex((v) => v.taskId === taskId);
|
const idx = prev.findIndex((v) => v.taskId === taskId);
|
||||||
if (idx < 0) return prev;
|
if (idx < 0) return prev;
|
||||||
@ -192,32 +297,11 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
type: 'message',
|
type: 'message',
|
||||||
};
|
};
|
||||||
const newStack = [...prev];
|
const newStack = [...prev];
|
||||||
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] };
|
newStack[idx] = {
|
||||||
return newStack;
|
...layer,
|
||||||
}
|
status: 'error',
|
||||||
if (message.type === 'stream_delta') {
|
messages: [...layer.messages, errorChatMsg],
|
||||||
const msg = message as StreamDelta;
|
|
||||||
const existingIdx = layer.messages.findIndex(
|
|
||||||
(m) => m.id === msg.id && m.type === 'message',
|
|
||||||
);
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
const updated = [...layer.messages];
|
|
||||||
const existing = updated[existingIdx];
|
|
||||||
updated[existingIdx] = {
|
|
||||||
...existing,
|
|
||||||
content: existing.content + msg.delta,
|
|
||||||
reasoningContent: msg.reasoning_delta
|
|
||||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
|
||||||
: existing.reasoningContent,
|
|
||||||
};
|
};
|
||||||
const newStack = [...prev];
|
|
||||||
newStack[idx] = { ...layer, messages: updated };
|
|
||||||
return newStack;
|
|
||||||
}
|
|
||||||
const chatMsg = serverMessageToChatMessage(message);
|
|
||||||
if (!chatMsg) return prev;
|
|
||||||
const newStack = [...prev];
|
|
||||||
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
|
|
||||||
return newStack;
|
return newStack;
|
||||||
}
|
}
|
||||||
if (message.type === 'stream_end') return prev;
|
if (message.type === 'stream_end') return prev;
|
||||||
@ -239,14 +323,18 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
message.type === 'tool_result' ||
|
message.type === 'tool_result' ||
|
||||||
message.type === 'tool_pending'
|
message.type === 'tool_pending'
|
||||||
) {
|
) {
|
||||||
const exists = layer.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type);
|
const exists = layer.messages.some(
|
||||||
|
(m) => m.id === chatMsg.id && m.type === chatMsg.type,
|
||||||
|
);
|
||||||
if (exists) return prev;
|
if (exists) return prev;
|
||||||
}
|
}
|
||||||
const newStack = [...prev];
|
const newStack = [...prev];
|
||||||
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
|
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
|
||||||
return newStack;
|
return newStack;
|
||||||
});
|
});
|
||||||
}, []);
|
},
|
||||||
|
[scheduleSubAgentDeltaFlush],
|
||||||
|
);
|
||||||
|
|
||||||
const enterSubAgentView = useCallback(
|
const enterSubAgentView = useCallback(
|
||||||
(taskId: string, description: string, subagentType?: string): Command => {
|
(taskId: string, description: string, subagentType?: string): Command => {
|
||||||
@ -267,6 +355,8 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
);
|
);
|
||||||
|
|
||||||
const exitSubAgentView = useCallback((): Command | null => {
|
const exitSubAgentView = useCallback((): Command | null => {
|
||||||
|
// 栈变更前先落盘待处理 delta,避免丢失或写入清空后的新栈
|
||||||
|
flushSubAgentDeltasSync();
|
||||||
const current = subAgentStackRef.current;
|
const current = subAgentStackRef.current;
|
||||||
if (current.length <= 1) {
|
if (current.length <= 1) {
|
||||||
subAgentViewRef.current = null;
|
subAgentViewRef.current = null;
|
||||||
@ -282,9 +372,12 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
subAgentStackRef.current = clearedStack;
|
subAgentStackRef.current = clearedStack;
|
||||||
setSubAgentStack(clearedStack);
|
setSubAgentStack(clearedStack);
|
||||||
return { type: 'load_task_messages', task_id: newTop.taskId };
|
return { type: 'load_task_messages', task_id: newTop.taskId };
|
||||||
}, []);
|
}, [flushSubAgentDeltasSync]);
|
||||||
|
|
||||||
const navigateToSubAgentLevel = useCallback((index: number): Command | null => {
|
const navigateToSubAgentLevel = useCallback(
|
||||||
|
(index: number): Command | null => {
|
||||||
|
// 栈变更前先落盘待处理 delta,避免丢失或写入清空后的新栈
|
||||||
|
flushSubAgentDeltasSync();
|
||||||
const current = subAgentStackRef.current;
|
const current = subAgentStackRef.current;
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
subAgentViewRef.current = null;
|
subAgentViewRef.current = null;
|
||||||
@ -301,7 +394,9 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
subAgentStackRef.current = clearedStack;
|
subAgentStackRef.current = clearedStack;
|
||||||
setSubAgentStack(clearedStack);
|
setSubAgentStack(clearedStack);
|
||||||
return { type: 'load_task_messages', task_id: newTop.taskId };
|
return { type: 'load_task_messages', task_id: newTop.taskId };
|
||||||
}, []);
|
},
|
||||||
|
[flushSubAgentDeltasSync],
|
||||||
|
);
|
||||||
|
|
||||||
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
|
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
|
||||||
const handleSubAgentMessage = useCallback(
|
const handleSubAgentMessage = useCallback(
|
||||||
@ -309,6 +404,11 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
const currentSubAgentView = subAgentViewRef.current;
|
const currentSubAgentView = subAgentViewRef.current;
|
||||||
if (!currentSubAgentView) return false;
|
if (!currentSubAgentView) return false;
|
||||||
|
|
||||||
|
// 非 delta 消息处理前先落盘待处理的 delta,保证消息顺序与内容完整性
|
||||||
|
if (message.type !== 'stream_delta' && pendingDeltasRef.current.size > 0) {
|
||||||
|
flushSubAgentDeltasSync();
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === 'task_messages_loaded') {
|
if (message.type === 'task_messages_loaded') {
|
||||||
const msg = message as TaskMessagesLoaded;
|
const msg = message as TaskMessagesLoaded;
|
||||||
setSubAgentStack((prev) => {
|
setSubAgentStack((prev) => {
|
||||||
@ -431,6 +531,7 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
appendToSubAgentLayerMessage,
|
appendToSubAgentLayerMessage,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
requestSubAgentTodoList,
|
requestSubAgentTodoList,
|
||||||
|
flushSubAgentDeltasSync,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user