diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index d824f4484..76223afa8 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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) { diff --git a/components/canvas/chat-panel/canvas-chat-media.ts b/components/canvas/chat-panel/canvas-chat-media.ts index 3789f3c66..7f2bbb26d 100644 --- a/components/canvas/chat-panel/canvas-chat-media.ts +++ b/components/canvas/chat-panel/canvas-chat-media.ts @@ -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 { diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index 770c06d2c..a813dd560 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -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()) diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 05fd1d274..187266fb1 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -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( diff --git a/store/canvas/index.ts b/store/canvas/index.ts index 369ccfe76..c626f9fe6 100644 --- a/store/canvas/index.ts +++ b/store/canvas/index.ts @@ -250,7 +250,6 @@ export const useCanvasStore = create()( 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 ||