fix canvas generation chat and failover behavior

This commit is contained in:
134 2026-06-14 09:54:47 +08:00
parent cf21c80d24
commit e5838f480a
5 changed files with 396 additions and 45 deletions

View File

@ -438,6 +438,30 @@ const CANVAS_IMAGE_TASK_POLL_INTERVAL_MS = 1600;
const CANVAS_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000;
const CANVAS_GENERATION_NODE_GAP = 56;
const CANVAS_GENERATION_STACK_GAP = 28;
const CANVAS_GENERATION_COLLISION_GAP = 32;
function hashCanvasChatValue(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) | 0;
}
return Math.abs(hash).toString(36);
}
function buildCanvasMediaFallbackMessageId(chatId: string, urls: string[]) {
const key = urls.map((url) => normalizeMediaUrl(url)).filter(Boolean).join("|");
return `canvas-media-${chatId}-${hashCanvasChatValue(key || String(Date.now()))}`;
}
function buildCanvasMediaMarkdown(params: {
imageUrls: string[];
videoUrls: string[];
}) {
return [
...params.imageUrls.map((url) => `![Generated Image](${url})`),
...params.videoUrls.map((url) => `[生成视频](${url})`),
].join("\n\n");
}
function findCanvasImageAnchorNode(
nodes: CanvasNode[],
@ -466,6 +490,8 @@ function findCanvasImageAnchorNode(
function getAnchoredGenerationPosition(params: {
anchorNode?: CanvasNode | null;
existingNodes?: CanvasNode[];
excludeNodeIds?: string[];
size: { width: number; height: number };
index?: number;
fallbackNodeCount?: number;
@ -473,34 +499,140 @@ function getAnchoredGenerationPosition(params: {
}) {
const index = params.index || 0;
const anchorNode = params.anchorNode || null;
const size = {
width: Math.max(1, Number(params.size?.width || 300)),
height: Math.max(1, Number(params.size?.height || 300)),
};
const excludedIds = new Set(params.excludeNodeIds || []);
const overlapsExistingNode = (position: { x: number; y: number }) => {
const padding = CANVAS_GENERATION_COLLISION_GAP;
const left = position.x - padding;
const top = position.y - padding;
const right = position.x + size.width + padding;
const bottom = position.y + size.height + padding;
return (params.existingNodes || []).some((existingNode) => {
if (!existingNode || excludedIds.has(existingNode.id)) return false;
const nodeWidth = Math.max(1, Number(existingNode.size?.width || 300));
const nodeHeight = Math.max(1, Number(existingNode.size?.height || 300));
const nodeLeft = Number(existingNode.position?.x || 0);
const nodeTop = Number(existingNode.position?.y || 0);
const nodeRight = nodeLeft + nodeWidth;
const nodeBottom = nodeTop + nodeHeight;
return !(
right <= nodeLeft ||
left >= nodeRight ||
bottom <= nodeTop ||
top >= nodeBottom
);
});
};
const firstFreePosition = (candidates: Array<{ x: number; y: number }>) => {
return candidates.find((candidate) => !overlapsExistingNode(candidate)) || null;
};
if (anchorNode) {
const anchorWidth = Number(anchorNode.size?.width || 300);
const anchorHeight = Number(anchorNode.size?.height || 300);
const stackStep = Math.max(
96,
Math.min(params.size.height, anchorHeight) + CANVAS_GENERATION_STACK_GAP
Math.min(size.height, anchorHeight) + CANVAS_GENERATION_STACK_GAP
);
return {
x: anchorNode.position.x + anchorWidth + CANVAS_GENERATION_NODE_GAP,
y: anchorNode.position.y + index * stackStep,
};
const anchorLeft = Number(anchorNode.position?.x || 0);
const anchorTop = Number(anchorNode.position?.y || 0);
const anchorRight = anchorLeft + anchorWidth;
const anchorBottom = anchorTop + anchorHeight;
const gap = CANVAS_GENERATION_NODE_GAP;
const preferred = [
{
x: anchorRight + gap,
y: anchorTop + index * stackStep,
},
{
x: anchorLeft - size.width - gap,
y: anchorTop + index * stackStep,
},
{
x: anchorLeft + index * (size.width + CANVAS_GENERATION_STACK_GAP),
y: anchorBottom + gap,
},
{
x: anchorLeft + index * (size.width + CANVAS_GENERATION_STACK_GAP),
y: anchorTop - size.height - gap,
},
];
const preferredFree = firstFreePosition(preferred);
if (preferredFree) return preferredFree;
const horizontalStep = size.width + gap;
const verticalStep = size.height + gap;
const spiralCandidates: Array<{ x: number; y: number }> = [];
for (let ring = 1; ring <= 10; ring += 1) {
const rightX = anchorRight + gap + (ring - 1) * horizontalStep;
const leftX = anchorLeft - size.width - gap - (ring - 1) * horizontalStep;
const downY = anchorBottom + gap + (ring - 1) * verticalStep;
const upY = anchorTop - size.height - gap - (ring - 1) * verticalStep;
spiralCandidates.push(
{ x: rightX, y: anchorTop },
{ x: leftX, y: anchorTop },
{ x: anchorLeft, y: downY },
{ x: anchorLeft, y: upY },
{ x: rightX, y: downY },
{ x: rightX, y: upY },
{ x: leftX, y: downY },
{ x: leftX, y: upY }
);
}
const spiralFree = firstFreePosition(spiralCandidates);
if (spiralFree) return spiralFree;
return preferred[0]!;
}
const viewport = params.viewport || { x: 0, y: 0, zoom: 1 };
const zoom = Math.max(0.1, viewport.zoom || 1);
if (typeof window !== "undefined") {
return {
x: (-viewport.x + window.innerWidth * 0.52) / zoom - params.size.width / 2 + index * 28,
y: (-viewport.y + window.innerHeight * 0.46) / zoom - params.size.height / 2 + index * 28,
const centerPosition = {
x:
(-viewport.x + window.innerWidth * 0.52) / zoom -
size.width / 2 +
index * 28,
y:
(-viewport.y + window.innerHeight * 0.46) / zoom -
size.height / 2 +
index * 28,
};
if (!overlapsExistingNode(centerPosition)) return centerPosition;
}
const fallbackNodeCount = params.fallbackNodeCount || 0;
return {
x: 100 + (fallbackNodeCount % 3) * (params.size.width + 20),
y: 100 + Math.floor(fallbackNodeCount / 3) * (params.size.height + 20),
const fallbackBase = {
x:
100 +
(fallbackNodeCount % 3) *
(size.width + CANVAS_GENERATION_COLLISION_GAP),
y:
100 +
Math.floor(fallbackNodeCount / 3) *
(size.height + CANVAS_GENERATION_COLLISION_GAP),
};
if (!overlapsExistingNode(fallbackBase)) return fallbackBase;
for (let row = 0; row < 20; row += 1) {
for (let col = 0; col < 6; col += 1) {
const candidate = {
x: 100 + col * (size.width + CANVAS_GENERATION_COLLISION_GAP),
y: 100 + row * (size.height + CANVAS_GENERATION_COLLISION_GAP),
};
if (!overlapsExistingNode(candidate)) return candidate;
}
}
return fallbackBase;
}
type CanvasImageGenerationRequest = {
@ -3351,7 +3483,13 @@ export function CanvasChatPanel({
}, [videoFrameSlots.length]);
// 合并 initialMessages 和历史消息
const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []);
const mergedInitialMessages = useMemo(
() =>
sortCanvasMessagesByCreatedAt(
historyData?.messages ?? (activeChatId === chatId ? initialMessages : [])
),
[activeChatId, chatId, historyData?.messages, initialMessages]
);
const removeCanvasGenerationPlaceholders = useCallback(
(chatSessionId?: string) => {
@ -3496,9 +3634,23 @@ export function CanvasChatPanel({
: selectedImageNodesRef.current,
sourceImageUrls,
});
const size = { width: dimensions.width, height: dimensions.height };
const placeholderNode = placeholderNodes[index];
const size = anchorNode
? {
width: Math.max(
1,
Math.round(Number(anchorNode.size?.width || dimensions.width))
),
height: Math.max(
1,
Math.round(Number(anchorNode.size?.height || dimensions.height))
),
}
: { width: dimensions.width, height: dimensions.height };
const position = getAnchoredGenerationPosition({
anchorNode,
existingNodes: currentNodes,
excludeNodeIds: placeholderNode ? [placeholderNode.id] : [],
fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount,
index,
size,
@ -3524,11 +3676,9 @@ export function CanvasChatPanel({
agentExecutionSnapshotId,
sourceNodeId: sourceNodeId || anchorNode?.id || undefined,
placementMode: "anchored-generation",
allowOverlap: true,
recentlyGeneratedAt: completedAt,
},
};
const placeholderNode = placeholderNodes[index];
const nodeId = placeholderNode
? placeholderNode.id
: addNode({
@ -3733,6 +3883,77 @@ export function CanvasChatPanel({
const payload: CanvasMediaStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return;
const imageUrls = Array.isArray(payload.imageUrls)
? payload.imageUrls.filter(
(url): url is string =>
typeof url === "string" && /^https?:\/\//i.test(url)
)
: [];
const videoUrls = Array.isArray(payload.videoUrls)
? payload.videoUrls.filter(
(url): url is string =>
typeof url === "string" && /^https?:\/\//i.test(url)
)
: [];
if (isEventForActiveChat && (imageUrls.length > 0 || videoUrls.length > 0)) {
const allUrls = [...imageUrls, ...videoUrls];
const fallbackMessageId =
String(payload.messageId || "").trim() ||
buildCanvasMediaFallbackMessageId(eventChatId, allUrls);
const mediaMarkdown = buildCanvasMediaMarkdown({ imageUrls, videoUrls });
const mediaUrlKeys = new Set(
allUrls.map((url) => normalizeMediaUrl(url)).filter(Boolean)
);
setMessages((prevMessages) => {
const existingIndex = prevMessages.findIndex(
(message) => message.id === fallbackMessageId
);
if (existingIndex >= 0) {
const existingMessage = prevMessages[existingIndex] as any;
const existingText = ((existingMessage.parts || []) as any[])
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => String(part.text))
.join("\n");
const existingUrlKeys = new Set(
[
...extractAssistantImageUrls(existingText),
...extractVideoUrls(existingText),
]
.map((url) => normalizeMediaUrl(url))
.filter(Boolean)
);
const hasAllMedia = Array.from(mediaUrlKeys).every((key) =>
existingUrlKeys.has(key)
);
if (hasAllMedia) return prevMessages;
const nextText = [existingText, mediaMarkdown]
.filter((text) => String(text || "").trim())
.join("\n\n");
const nextMessages = [...prevMessages];
nextMessages[existingIndex] = {
...existingMessage,
role: "assistant",
content: nextText,
parts: [{ type: "text", text: nextText }],
};
return sortCanvasMessagesByCreatedAt(nextMessages);
}
const createdAt = new Date();
return sortCanvasMessagesByCreatedAt([
...prevMessages,
{
id: fallbackMessageId,
role: "assistant",
content: mediaMarkdown,
createdAt,
parts: [{ type: "text", text: mediaMarkdown }],
} as any,
]);
});
}
void appendStreamMediaToCanvas(payload);
clearStreamImageGenerationStateForChat(eventChatId);
} catch (e) {
@ -3875,19 +4096,24 @@ export function CanvasChatPanel({
const nextMessages = [...prevMessages];
nextMessages[existingIndex] = updatedMessage;
return nextMessages;
return sortCanvasMessagesByCreatedAt(nextMessages);
}
const createdAtValue =
messageData.createdAt ||
messageData.created_at ||
messageData.timestamp ||
new Date();
const newMessage: any = {
id: messageData.id,
role: messageData.role,
content: normalizedParts.find((part: any) => part?.type === "text")?.text || "",
createdAt: new Date(),
createdAt: createdAtValue,
parts: normalizedParts,
attachments: normalizedAttachments,
};
return [...prevMessages, newMessage];
return sortCanvasMessagesByCreatedAt([...prevMessages, newMessage]);
});
} catch (e) {
console.error("[CanvasChat] Failed to parse data-appendMessage:", e);
@ -4342,7 +4568,7 @@ export function CanvasChatPanel({
processedMessageIds.current = new Set();
hasHydratedHistoryRef.current = true;
forceHistoryHydrationRef.current = null;
setMessages(historyMessages);
setMessages(sortCanvasMessagesByCreatedAt(historyMessages));
setMessageViewportKey((prev) => prev + 1);
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
@ -4697,9 +4923,21 @@ export function CanvasChatPanel({
: selectedImageNodesRef.current,
sourceImageUrls: nearestUserImageUrls,
});
const size = { width: dimensions.width, height: dimensions.height };
const size = anchorNode
? {
width: Math.max(
1,
Math.round(Number(anchorNode.size?.width || dimensions.width))
),
height: Math.max(
1,
Math.round(Number(anchorNode.size?.height || dimensions.height))
),
}
: { width: dimensions.width, height: dimensions.height };
const position = getAnchoredGenerationPosition({
anchorNode,
existingNodes: currentNodes,
fallbackNodeCount: currentNodes.length + addedImageCount + addedVideoCount,
index,
size,
@ -4725,7 +4963,6 @@ export function CanvasChatPanel({
compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined,
sourceNodeId: sourceNodeId || anchorNode?.id || undefined,
placementMode: "anchored-generation",
allowOverlap: true,
recentlyGeneratedAt: completedAt,
},
},
@ -8000,7 +8237,12 @@ export function CanvasChatPanel({
}, [isDetailGenerationMode, messages.length]);
const visibleMessages = useMemo(
() => (isDetailGenerationMode ? messages.slice(detailModeMessageStartIndex) : messages),
() => {
const scopedMessages = isDetailGenerationMode
? messages.slice(detailModeMessageStartIndex)
: messages;
return sortCanvasMessagesByCreatedAt(scopedMessages);
},
[detailModeMessageStartIndex, isDetailGenerationMode, messages]
);
@ -8141,12 +8383,20 @@ export function CanvasChatPanel({
};
const placeholderSize = anchorNode
? {
width: Math.max(180, Math.round(Number(anchorNode.size?.width || ratioPlaceholderSize.width))),
height: Math.max(180, Math.round(Number(anchorNode.size?.height || ratioPlaceholderSize.height))),
width: Math.max(
180,
Math.round(Number(anchorNode.size?.width || ratioPlaceholderSize.width))
),
height: Math.max(
180,
Math.round(Number(anchorNode.size?.height || ratioPlaceholderSize.height))
),
}
: ratioPlaceholderSize;
const position = getAnchoredGenerationPosition({
anchorNode,
existingNodes: currentNodes,
excludeNodeIds: placeholderId ? [placeholderId] : [],
fallbackNodeCount: currentNodes.length,
size: placeholderSize,
viewport: useCanvasStore.getState().viewport,
@ -8156,11 +8406,16 @@ export function CanvasChatPanel({
chatId: activeChatId,
detailProgressLabel: label,
generationProgress:
phase === "queued" ? 12 : phase === "finalizing" ? 96 : phase === "failed" ? 100 : undefined,
phase === "queued"
? 12
: phase === "finalizing"
? 96
: phase === "failed"
? 100
: undefined,
modelName: streamState.modelName || currentModel?.displayName || undefined,
providerType: streamState.providerType || currentModel?.providerType || undefined,
placementMode: "anchored-generation",
allowOverlap: true,
};
if (placeholderId) {

View File

@ -369,11 +369,23 @@ export function formatMessageDateLabel(timestamp: number): string {
}
export function sortCanvasMessagesByCreatedAt(messages: ChatMessage[]): ChatMessage[] {
return [...messages].sort((a, b) => {
const timeA = getMessageTimestamp(a) ?? 0;
const timeB = getMessageTimestamp(b) ?? 0;
return timeA - timeB;
});
return messages
.map((message, index) => ({ message, index }))
.sort((a, b) => {
const messageA = a.message;
const messageB = b.message;
const timeA = getMessageTimestamp(messageA) ?? 0;
const timeB = getMessageTimestamp(messageB) ?? 0;
if (timeA !== timeB) return timeA - timeB;
if (messageA.role !== messageB.role) {
if (messageA.role === "user") return -1;
if (messageB.role === "user") return 1;
}
return a.index - b.index;
})
.map((item) => item.message);
}
export function buildCanvasMessageFingerprint(message: ChatMessage): string {

View File

@ -716,15 +716,22 @@ export function LovartHome({
.filter((item) => item !== "精选");
const uniqueConfigured = Array.from(new Set(configured));
const merged = [
"全部",
...uniqueConfigured.filter((item) => item !== "全部"),
...DISCOVERY_DEFAULT_TABS,
];
if (uniqueConfigured.length > 0) {
const configuredTabs = [
"全部",
...uniqueConfigured.filter((item) => item !== "全部"),
];
return Array.from(new Set(configuredTabs)).slice(0, 12);
}
return Array.from(new Set(merged)).slice(0, 12);
return ["全部", ...DISCOVERY_DEFAULT_TABS].slice(0, 12);
}, [surpriseCategories]);
useEffect(() => {
if (discoveryTabs.includes(activeCategory)) return;
setActiveCategory(discoveryTabs[0] || "全部");
}, [activeCategory, discoveryTabs]);
const composerPreviewTexts = useMemo(() => {
const configured = (siteSettingsData?.lovartComposerPreviewTexts || [])
.map((item) => String(item || "").trim())

View File

@ -311,6 +311,7 @@ type CanvasModelWithProvider = Awaited<
>[number];
type CanvasImageSizeValue = "1K" | "2K" | "4K";
const CANVAS_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
interface CanvasImageGenConfig {
maxImages?: number;
@ -389,6 +390,67 @@ function getCanvasModelRefKeys(item: CanvasModelWithProvider) {
].filter(Boolean);
}
function normalizeModelNameForSimilarity(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[「」『』【】[\]()(){}]/g, " ")
.replace(/[_/|::,,.。+]+/g, "-")
.replace(/\s+/g, " ");
}
function tokenizeModelNameForSimilarity(value: unknown) {
const normalized = normalizeModelNameForSimilarity(value);
const compact = normalized.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
const tokens = new Set(
normalized
.split(/[^a-z0-9\u4e00-\u9fa5]+/g)
.map((item) => item.trim())
.filter((item) => item.length >= 2)
);
if (compact.length >= 2) {
tokens.add(compact);
for (let index = 0; index < compact.length - 1; index += 1) {
tokens.add(compact.slice(index, index + 2));
}
}
return tokens;
}
function scoreModelNameSimilarity(
source: CanvasModelWithProvider,
candidate: CanvasModelWithProvider
) {
const sourceNames = [
source.model.displayName,
source.model.modelId,
].filter(Boolean);
const candidateNames = [
candidate.model.displayName,
candidate.model.modelId,
].filter(Boolean);
let bestScore = 0;
for (const sourceName of sourceNames) {
const sourceTokens = tokenizeModelNameForSimilarity(sourceName);
if (sourceTokens.size === 0) continue;
for (const candidateName of candidateNames) {
const candidateTokens = tokenizeModelNameForSimilarity(candidateName);
if (candidateTokens.size === 0) continue;
let overlap = 0;
sourceTokens.forEach((token) => {
if (candidateTokens.has(token)) overlap += 1;
});
const score = overlap / Math.max(sourceTokens.size, candidateTokens.size);
bestScore = Math.max(bestScore, score);
}
}
return bestScore;
}
function findCanvasFailoverModel(params: {
selectedModel: CanvasModelWithProvider;
imageModels: CanvasModelWithProvider[];
@ -422,14 +484,30 @@ function findCanvasFailoverModel(params: {
if (configured) return configured;
}
const sameProviderType = params.imageModels.find(
(item) =>
isUsableFallback(item) &&
item.provider.type === params.selectedModel.provider.type
);
if (sameProviderType) return sameProviderType;
const usableModels = params.imageModels.filter(isUsableFallback);
const scoredModels = usableModels
.map((item, index) => ({
item,
index,
nameSimilarity: scoreModelNameSimilarity(params.selectedModel, item),
sameProviderType: item.provider.type === params.selectedModel.provider.type,
}))
.sort((left, right) => {
const leftSameName =
left.nameSimilarity >= CANVAS_FAILOVER_SAME_NAME_THRESHOLD;
const rightSameName =
right.nameSimilarity >= CANVAS_FAILOVER_SAME_NAME_THRESHOLD;
if (leftSameName !== rightSameName) return leftSameName ? -1 : 1;
if (left.sameProviderType !== right.sameProviderType) {
return left.sameProviderType ? -1 : 1;
}
if (right.nameSimilarity !== left.nameSimilarity) {
return right.nameSimilarity - left.nameSimilarity;
}
return left.index - right.index;
});
return params.imageModels.find(isUsableFallback) || null;
return scoredModels[0]?.item || null;
}
export async function generateCanvasImage(

View File

@ -250,7 +250,6 @@ export const useCanvasStore = create<CanvasStore>()(
const stepY = height + padding;
const overlaps = (pos: Position) => nodes.some(n => {
if (n.type !== 'image') return false;
const nSize = n.size || { width: 300, height: 300 };
return !(
pos.x + width + padding <= n.position.x ||