improve canvas agent session handling
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-09 00:40:35 +08:00
parent 88f3a5453b
commit 9d2a1eab68
2 changed files with 1496 additions and 53 deletions

View File

@ -1650,6 +1650,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const hasInitializedChatStateRef = useRef(false); const hasInitializedChatStateRef = useRef(false);
const sessionMenuRef = useRef<HTMLDivElement>(null); const sessionMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const forceHistoryHydrationRef = useRef<string | null>(null);
// 🆕 标签悬停预览状态 // 🆕 标签悬停预览状态
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null); const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
@ -1680,34 +1681,83 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
unitCost: number; unitCost: number;
imageCount: number; imageCount: number;
} | null>(null); } | null>(null);
const [streamImageGenerationState, setStreamImageGenerationState] = const [streamImageGenerationStateByChatId, setStreamImageGenerationStateByChatId] =
useState<ImageGenerationStatusStreamPayload | null>(null); useState<Record<string, ImageGenerationStatusStreamPayload | null>>({});
const streamImageGenerationState = useMemo(
() => streamImageGenerationStateByChatId[activeChatId] || null,
[activeChatId, streamImageGenerationStateByChatId]
);
const imageParamsRef = useRef(imageParams); const imageParamsRef = useRef(imageParams);
const videoParamsRef = useRef(videoParams); const videoParamsRef = useRef(videoParams);
const deepThinkingEnabledRef = useRef(deepThinkingEnabled); const deepThinkingEnabledRef = useRef(deepThinkingEnabled);
const imageAutoTitleCacheRef = useRef<Map<string, string>>(new Map()); const imageAutoTitleCacheRef = useRef<Map<string, string>>(new Map());
const sendGuardRef = useRef<{ const sendGuardRef = useRef<Record<string, {
inFlight: boolean; inFlight: boolean;
signature: string; signature: string;
timestamp: number; timestamp: number;
}>({ }>>({});
inFlight: false,
signature: "",
timestamp: 0,
});
const storyboardNodeMapRef = useRef<Record<string, Record<number, string>>>({}); const storyboardNodeMapRef = useRef<Record<string, Record<number, string>>>({});
const releaseSendGuard = useCallback(() => { const getSendGuard = useCallback((chatSessionId: string) => {
sendGuardRef.current.inFlight = false; return (
sendGuardRef.current[chatSessionId] || {
inFlight: false,
signature: "",
timestamp: 0,
}
);
}, []); }, []);
const shouldBlockDuplicateSend = useCallback((signature: string) => { const setSendGuard = useCallback(
(
chatSessionId: string,
next: {
inFlight: boolean;
signature: string;
timestamp: number;
}
) => {
sendGuardRef.current[chatSessionId] = next;
},
[]
);
const releaseSendGuard = useCallback(
(chatSessionId: string) => {
sendGuardRef.current[chatSessionId] = {
...getSendGuard(chatSessionId),
inFlight: false,
};
},
[getSendGuard]
);
const setStreamImageGenerationStateForChat = useCallback(
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
setStreamImageGenerationStateByChatId((prev) => ({
...prev,
[chatSessionId]: next,
}));
},
[]
);
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => {
setStreamImageGenerationStateByChatId((prev) => {
if (!(chatSessionId in prev)) return prev;
const next = { ...prev };
delete next[chatSessionId];
return next;
});
}, []);
const shouldBlockDuplicateSend = useCallback((chatSessionId: string, signature: string) => {
const now = Date.now(); const now = Date.now();
const previous = sendGuardRef.current; const previous = getSendGuard(chatSessionId);
return ( return (
previous.signature === signature && previous.signature === signature &&
now - previous.timestamp < 4500 now - previous.timestamp < 4500
); );
}, []); }, [getSendGuard]);
const handleTypewriterActiveChange = useCallback((typingKey: string, active: boolean) => { const handleTypewriterActiveChange = useCallback((typingKey: string, active: boolean) => {
setActiveTypingSections((prev) => { setActiveTypingSections((prev) => {
@ -2941,9 +2991,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
? "视频" ? "视频"
: activeComposerMode === "detail" : activeComposerMode === "detail"
? "AI详情" ? "AI详情"
: activeComposerMode === "image" : activeComposerMode === "image"
? "生图" ? "生图"
: "Agent"; : "Agent";
const shouldMaskAgentRuntimeModelName =
activeComposerMode === "agent" || selectedModelId === "auto";
const getAgentSafeModelName = useCallback(
(modelName?: string | null) => {
const normalized = String(modelName || "").trim();
if (!shouldMaskAgentRuntimeModelName) {
return normalized || undefined;
}
return normalized ? "agent model" : "agent model";
},
[shouldMaskAgentRuntimeModelName]
);
// 默认选择模型(根据系统设置) // 默认选择模型(根据系统设置)
useEffect(() => { useEffect(() => {
@ -3426,7 +3488,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
setMessages, setMessages,
sendMessage, sendMessage,
status, status,
stop,
} = useChat<ChatMessage>({ } = useChat<ChatMessage>({
id: activeChatId, id: activeChatId,
messages: mergedInitialMessages, messages: mergedInitialMessages,
@ -3494,13 +3555,13 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return; if (!payload || typeof payload !== "object") return;
if (payload.phase === "completed") { if (payload.phase === "completed") {
setStreamImageGenerationState(null); clearStreamImageGenerationStateForChat(activeChatId);
return; return;
} }
setStreamImageGenerationState({ setStreamImageGenerationStateForChat(activeChatId, {
phase: payload.phase || "processing", phase: payload.phase || "processing",
providerType: payload.providerType, providerType: payload.providerType,
modelName: payload.modelName, modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined, imageCount: Number(payload.imageCount || 0) || undefined,
}); });
} catch (e) { } catch (e) {
@ -3543,28 +3604,59 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
part.text.includes("<!-- IMAGE_GENERATION_END -->") part.text.includes("<!-- IMAGE_GENERATION_END -->")
) )
: false; : false;
if (hasImageGenStartMarker) {
setStreamImageGenerationState((prev) => ({
phase: "processing",
providerType:
prev?.providerType ||
(activeComposerMode === "agent" ? "alibaba-image" : currentModel?.providerType),
modelName:
prev?.modelName ||
(activeComposerMode === "agent"
? "智能处理"
: currentModel?.displayName || undefined),
imageCount:
prev?.imageCount || Math.max(1, Number(imageParamsRef.current?.imageCount || 1)),
}));
}
if (hasImageGenEndMarker) {
setStreamImageGenerationState(null);
}
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : []; const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
const normalizedAttachments = Array.isArray(messageData.attachments) const normalizedAttachments = Array.isArray(messageData.attachments)
? messageData.attachments ? messageData.attachments
: []; : [];
const emptyNearestUserImageKeys = new Set<string>();
const incomingImageUrls = normalizedParts.flatMap((part: any) =>
part?.type === "text" && typeof part?.text === "string"
? extractAssistantImageUrls(part.text, emptyNearestUserImageKeys)
: typeof part?.url === "string" && /^https?:\/\//i.test(part.url)
? [part.url]
: []
);
const incomingVideoUrls = normalizedParts.flatMap((part: any) =>
part?.type === "text" && typeof part?.text === "string"
? extractVideoUrls(part.text)
: typeof part?.url === "string" &&
/^https?:\/\//i.test(part.url) &&
/\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(part.url)
? [part.url]
: []
);
normalizedAttachments.forEach((attachment: any) => {
const rawUrl = String(attachment?.url || "").trim();
if (!rawUrl || !/^https?:\/\//i.test(rawUrl)) return;
const contentType = String(attachment?.contentType || "").toLowerCase();
if (
contentType.startsWith("video/") ||
/\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(rawUrl)
) {
incomingVideoUrls.push(rawUrl);
return;
}
incomingImageUrls.push(rawUrl);
});
if (hasImageGenStartMarker) {
const previousState =
streamImageGenerationStateByChatId[activeChatId] || null;
setStreamImageGenerationStateForChat(activeChatId, {
phase: "processing",
providerType:
previousState?.providerType ||
(activeComposerMode === "agent" ? "alibaba-image" : currentModel?.providerType),
modelName:
previousState?.modelName ||
getAgentSafeModelName(currentModel?.displayName),
imageCount:
previousState?.imageCount ||
Math.max(1, Number(imageParamsRef.current?.imageCount || 1)),
});
}
if (hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) {
clearStreamImageGenerationStateForChat(activeChatId);
}
setMessages((prevMessages) => { setMessages((prevMessages) => {
const existingIndex = prevMessages.findIndex( const existingIndex = prevMessages.findIndex(
@ -3728,6 +3820,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
} }
}, },
onError: (error) => { onError: (error) => {
clearStreamImageGenerationStateForChat(activeChatId);
releaseSendGuard(activeChatId);
const fallbackMessage = const fallbackMessage =
error instanceof Error ? error.message : "生成失败"; error instanceof Error ? error.message : "生成失败";
const responseData = (error as any)?.data; const responseData = (error as any)?.data;
@ -3757,9 +3851,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
useEffect(() => { useEffect(() => {
if (status === "ready") { if (status === "ready") {
releaseSendGuard(); releaseSendGuard(activeChatId);
} }
}, [releaseSendGuard, status]); }, [activeChatId, releaseSendGuard, status]);
useEffect(() => { useEffect(() => {
const launchPayload = pendingLaunchRef.current; const launchPayload = pendingLaunchRef.current;
@ -3817,7 +3911,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
} }
if (!historyData || !rawChatId) return; if (!historyData || !rawChatId) return;
if (status === "streaming" || status === "submitted") return; const shouldForceHydrate = forceHistoryHydrationRef.current === rawChatId;
if ((status === "streaming" || status === "submitted") && !shouldForceHydrate) return;
const historyMessages = historyData.messages || []; const historyMessages = historyData.messages || [];
const lastMessageId = historyMessages.at(-1)?.id || "none"; const lastMessageId = historyMessages.at(-1)?.id || "none";
@ -3828,6 +3923,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
loadedHistorySignatureRef.current = signature; loadedHistorySignatureRef.current = signature;
processedMessageIds.current = new Set(); processedMessageIds.current = new Set();
hasHydratedHistoryRef.current = true; hasHydratedHistoryRef.current = true;
forceHistoryHydrationRef.current = null;
setMessages(historyMessages); setMessages(historyMessages);
setMessageViewportKey((prev) => prev + 1); setMessageViewportKey((prev) => prev + 1);
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature); console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
@ -6223,20 +6319,21 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 处理发送(图片已在上传时添加到画布,这里只发送消息) // 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
if (sendGuardRef.current.inFlight || status === "streaming" || status === "submitted") { const currentSendGuard = getSendGuard(activeChatId);
if (currentSendGuard.inFlight || status === "streaming" || status === "submitted") {
return; return;
} }
if (isDetailGenerationMode && aiDetailGenerationEnabled) { if (isDetailGenerationMode && aiDetailGenerationEnabled) {
sendGuardRef.current = { setSendGuard(activeChatId, {
inFlight: true, inFlight: true,
signature: `detail:${activeChatId}:${Date.now()}`, signature: `detail:${activeChatId}:${Date.now()}`,
timestamp: Date.now(), timestamp: Date.now(),
}; });
try { try {
await handleDetailGeneration(); await handleDetailGeneration();
} finally { } finally {
releaseSendGuard(); releaseSendGuard(activeChatId);
} }
return; return;
} }
@ -6389,17 +6486,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
mode: activeComposerMode, mode: activeComposerMode,
}); });
if (shouldBlockDuplicateSend(sendSignature)) { if (shouldBlockDuplicateSend(activeChatId, sendSignature)) {
toast.message("检测到重复发送,已自动忽略"); toast.message("检测到重复发送,已自动忽略");
return; return;
} }
sendGuardRef.current = { setSendGuard(activeChatId, {
inFlight: true, inFlight: true,
signature: sendSignature, signature: sendSignature,
timestamp: Date.now(), timestamp: Date.now(),
}; });
setStreamImageGenerationState(null); clearStreamImageGenerationStateForChat(activeChatId);
// 使用与 multimodal-input 相同的格式发送消息 // 使用与 multimodal-input 相同的格式发送消息
try { try {
@ -6408,7 +6505,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
parts: parts, parts: parts,
}); });
} catch (error) { } catch (error) {
releaseSendGuard(); releaseSendGuard(activeChatId);
console.error("[CanvasChat] sendMessage failed:", error); console.error("[CanvasChat] sendMessage failed:", error);
toast.error("发送失败,请重试"); toast.error("发送失败,请重试");
return; return;
@ -6448,8 +6545,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
aiDetailGenerationEnabled, aiDetailGenerationEnabled,
handleDetailGeneration, handleDetailGeneration,
ensureImagePointsBeforeSend, ensureImagePointsBeforeSend,
isVideoModel, getSendGuard,
clearStreamImageGenerationStateForChat,
releaseSendGuard, releaseSendGuard,
setSendGuard,
shouldBlockDuplicateSend, shouldBlockDuplicateSend,
status, status,
]); ]);
@ -7001,20 +7100,23 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
updatedAt: now, updatedAt: now,
}; };
releaseSendGuard(activeChatId);
setChatSessions((prevSessions) => [newSession, ...prevSessions]); setChatSessions((prevSessions) => [newSession, ...prevSessions]);
setActiveChatId(newChatSessionId); setActiveChatId(newChatSessionId);
toast.success("已新建聊天窗口"); toast.success("已新建聊天窗口");
}, [chatSessions.length]); }, [activeChatId, chatSessions.length, releaseSendGuard]);
const switchToSession = useCallback((sessionId: string) => { const switchToSession = useCallback((sessionId: string) => {
if (!sessionId || sessionId === activeChatId) { if (!sessionId || sessionId === activeChatId) {
setIsSessionMenuOpen(false); setIsSessionMenuOpen(false);
return; return;
} }
forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId);
releaseSendGuard(activeChatId);
setActiveChatId(sessionId); setActiveChatId(sessionId);
setIsSessionMenuOpen(false); setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口"); toast.success("已切换聊天窗口");
}, [activeChatId]); }, [activeChatId, releaseSendGuard]);
const activeSessionTitle = useMemo(() => { const activeSessionTitle = useMemo(() => {
const current = chatSessions.find((session) => session.id === activeChatId); const current = chatSessions.find((session) => session.id === activeChatId);

File diff suppressed because it is too large Load Diff