10205 lines
404 KiB
TypeScript
10205 lines
404 KiB
TypeScript
"use client";
|
||
|
||
import { useChat } from "@ai-sdk/react";
|
||
import { DefaultChatTransport } from "ai";
|
||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||
import { useSession } from "next-auth/react";
|
||
import { createPortal } from "react-dom";
|
||
import useSWR, { useSWRConfig } from "swr";
|
||
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
} from "@/components/ui/select";
|
||
import { toast } from "sonner";
|
||
import { useCanvasStore } from "@/store/canvas";
|
||
import { cn } from "@/lib/utils";
|
||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||
import {
|
||
extractImageUrlsFromText,
|
||
extractOriginalImageUrlFromAlt,
|
||
inferOriginalImageUrl,
|
||
} from "@/lib/image-url";
|
||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||
import type {
|
||
AgentPreset,
|
||
AgentWorkflowComposerContext,
|
||
} from "@/types/agent";
|
||
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
|
||
import {
|
||
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||
normalizeCanvasSkillTags,
|
||
type CanvasSkillTag,
|
||
} from "@/lib/canvas-skill-tags";
|
||
import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
|
||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
|
||
import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
||
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
|
||
|
||
// 标签悬停预览信息
|
||
interface TagPreviewInfo {
|
||
url: string;
|
||
name: string;
|
||
x: number; // 标签中心x位置
|
||
y: number; // 标签顶部y位置
|
||
// 聊天面板容器边界
|
||
containerBounds?: {
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
};
|
||
// 物品标签额外信息
|
||
pickedObject?: {
|
||
objectName: string;
|
||
objectPosition: string;
|
||
clickX: number;
|
||
clickY: number;
|
||
};
|
||
}
|
||
|
||
type CanvasMediaStreamPayload = {
|
||
messageId?: string;
|
||
imageUrls?: string[];
|
||
videoUrls?: string[];
|
||
prompt?: string;
|
||
sourceImageUrls?: string[];
|
||
agentPresetId?: string;
|
||
agentPresetName?: string;
|
||
agentCategoryId?: string;
|
||
agentCategoryName?: string;
|
||
agentExecutionSnapshotId?: string;
|
||
};
|
||
|
||
type ImageGenerationStatusStreamPayload = {
|
||
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||
providerType?: string;
|
||
modelName?: string;
|
||
imageCount?: number;
|
||
};
|
||
|
||
interface CanvasChatPanelProps {
|
||
chatId: string;
|
||
initialMessages?: ChatMessage[];
|
||
onClose?: () => void;
|
||
onOpenFileList?: () => void;
|
||
selectedAgentPresetId?: string | null;
|
||
chatScope?: "canvas" | "agent";
|
||
workflowContext?: AgentWorkflowComposerContext | null;
|
||
presentation?: "panel" | "workspace";
|
||
}
|
||
|
||
type VideoStoryboardStreamData =
|
||
| {
|
||
stage: "detecting";
|
||
provider?: string;
|
||
message?: string;
|
||
}
|
||
| {
|
||
stage: "start";
|
||
provider?: string;
|
||
taskId: string;
|
||
totalShots: number;
|
||
gridType?: "grid_2x2" | "grid_3x2" | "grid_3x3";
|
||
rows?: number;
|
||
cols?: number;
|
||
confidence?: number;
|
||
sourceImageUrl?: string;
|
||
shots?: Array<{
|
||
shotIndex: number;
|
||
tileImageUrl?: string;
|
||
prompt?: string;
|
||
}>;
|
||
}
|
||
| {
|
||
stage: "hd_shot_completed";
|
||
provider?: string;
|
||
taskId: string;
|
||
shotIndex: number;
|
||
imageUrl: string;
|
||
prompt?: string;
|
||
}
|
||
| {
|
||
stage: "hd_shot_failed";
|
||
provider?: string;
|
||
taskId: string;
|
||
shotIndex: number;
|
||
error?: string;
|
||
}
|
||
| {
|
||
stage: "awaiting_confirmation";
|
||
provider?: string;
|
||
taskId: string;
|
||
totalShots: number;
|
||
gridType?: "grid_2x2" | "grid_3x2" | "grid_3x3";
|
||
rows?: number;
|
||
cols?: number;
|
||
message?: string;
|
||
shots?: Array<{
|
||
shotIndex: number;
|
||
tileImageUrl?: string;
|
||
previewImageUrl?: string;
|
||
prompt?: string;
|
||
}>;
|
||
}
|
||
| {
|
||
stage: "shot_completed";
|
||
provider?: string;
|
||
taskId: string;
|
||
shotIndex: number;
|
||
modelId?: string;
|
||
videoUrl: string;
|
||
text?: string;
|
||
}
|
||
| {
|
||
stage: "shot_failed";
|
||
provider?: string;
|
||
taskId: string;
|
||
shotIndex: number;
|
||
error?: string;
|
||
}
|
||
| {
|
||
stage: "completed";
|
||
provider?: string;
|
||
taskId: string;
|
||
totalShots?: number;
|
||
successCount?: number;
|
||
failedCount?: number;
|
||
}
|
||
| {
|
||
stage: "failed";
|
||
provider?: string;
|
||
taskId: string;
|
||
error?: string;
|
||
}
|
||
| {
|
||
stage: "shot_retry";
|
||
provider?: string;
|
||
taskId: string;
|
||
shotIndex: number;
|
||
attempt?: number;
|
||
modelId?: string;
|
||
error?: string;
|
||
};
|
||
|
||
// 建议卡片数据 - 已移除
|
||
const SUGGESTIONS: { title: string; desc: string; image: string }[] = [];
|
||
const CANVAS_COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
|
||
const CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT = [
|
||
"打造引人注目的社交媒体视觉",
|
||
"帮我把这张产品图做成高级电商海报",
|
||
"基于参考图延展 3 套同风格方案",
|
||
];
|
||
|
||
function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
|
||
const trimmed = text.trim();
|
||
if (!trimmed) return "";
|
||
|
||
const sanitizedPrefix = fixedPrefix.trim();
|
||
if (sanitizedPrefix && trimmed.startsWith(sanitizedPrefix)) {
|
||
return trimmed.slice(sanitizedPrefix.length).trimStart();
|
||
}
|
||
|
||
const legacyPrefixRemoved = trimmed
|
||
.replace(/^让\s*buyi[-_ ]?ai\s*/i, "")
|
||
.replace(/^让\s*lovart\s*/i, "")
|
||
.trimStart();
|
||
if (legacyPrefixRemoved !== trimmed) {
|
||
return legacyPrefixRemoved;
|
||
}
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
|
||
function getImageDimensions(url: string): Promise<{
|
||
width: number;
|
||
height: number;
|
||
originalWidth: number;
|
||
originalHeight: number;
|
||
}> {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
// 限制最大尺寸,避免节点过大
|
||
const maxSize = 500;
|
||
const originalWidth = img.naturalWidth;
|
||
const originalHeight = img.naturalHeight;
|
||
let width = originalWidth;
|
||
let height = originalHeight;
|
||
|
||
// 如果图片太大,按比例缩小
|
||
if (width > maxSize || height > maxSize) {
|
||
const scale = Math.min(maxSize / width, maxSize / height);
|
||
width = Math.round(width * scale);
|
||
height = Math.round(height * scale);
|
||
}
|
||
|
||
// 设置最小尺寸
|
||
const minSize = 150;
|
||
if (width < minSize && height < minSize) {
|
||
const scale = minSize / Math.min(width, height);
|
||
width = Math.round(width * scale);
|
||
height = Math.round(height * scale);
|
||
}
|
||
|
||
resolve({ width, height, originalWidth, originalHeight });
|
||
};
|
||
img.onerror = () => {
|
||
// 加载失败时使用默认尺寸
|
||
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
|
||
};
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
// 从消息中提取图片 URL(只提取原始图片,避免重复)
|
||
function extractImageUrls(text: string): string[] {
|
||
if (!/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/i.test(text || "")) {
|
||
return [];
|
||
}
|
||
return extractImageUrlsFromText(text);
|
||
}
|
||
|
||
function extractPlainImageUrls(text: string): string[] {
|
||
if (!text) return [];
|
||
const matches = String(text).match(
|
||
/(https?:\/\/[^\s"'<>]+?\.(?:png|jpe?g|webp|gif|bmp|tiff?|svg)(?:\?[^\s"'<>]+)?)/gi
|
||
);
|
||
return matches ? Array.from(new Set(matches)) : [];
|
||
}
|
||
|
||
function buildPreviewImageUrl(url: string, width: number): string {
|
||
return buildImageProxyUrl(url, {
|
||
width,
|
||
quality: 86,
|
||
format: "webp",
|
||
});
|
||
}
|
||
|
||
function normalizeMediaUrl(url: string): string {
|
||
const raw = String(url || "").trim();
|
||
if (!raw) return "";
|
||
|
||
try {
|
||
const parsed = new URL(raw);
|
||
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase();
|
||
} catch {
|
||
return raw
|
||
.split("#")[0]
|
||
.split("?")[0]
|
||
.replace(/\/+$/, "")
|
||
.toLowerCase();
|
||
}
|
||
}
|
||
|
||
function normalizeImageIdentityUrl(url: string): string {
|
||
const raw = String(url || "").trim();
|
||
if (!raw) return "";
|
||
const canonical = inferOriginalImageUrl(raw);
|
||
return normalizeMediaUrl(canonical || raw);
|
||
}
|
||
|
||
function buildDownloadFilename(url: string): string {
|
||
const fallback = `canvas-image-${Date.now()}.png`;
|
||
const raw = String(url || "").trim();
|
||
if (!raw) return fallback;
|
||
|
||
try {
|
||
const pathname = new URL(raw).pathname || "";
|
||
const candidate = pathname.split("/").pop() || "";
|
||
if (candidate) return candidate;
|
||
} catch {
|
||
const candidate = raw.split("/").pop()?.split("?")[0] || "";
|
||
if (candidate) return candidate;
|
||
}
|
||
|
||
return fallback;
|
||
}
|
||
|
||
function getAssistantImageNodeIdentityKey(node: any): string {
|
||
if (!node || node.type !== "image") return "";
|
||
const data = ((node.data as any) || {}) as Record<string, any>;
|
||
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
|
||
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
|
||
if (!sourceMessageId) return "";
|
||
const sourceImageKey = normalizeImageIdentityUrl(
|
||
String(metadata.sourceImageKey || data.src || "").trim()
|
||
);
|
||
if (!sourceImageKey) return "";
|
||
return `${sourceMessageId}::${sourceImageKey}`;
|
||
}
|
||
|
||
function collectMessageImageUrlKeys(message: ChatMessage | undefined): Set<string> {
|
||
const urlKeys = new Set<string>();
|
||
if (!message) return urlKeys;
|
||
|
||
const pushUrl = (url?: string | null) => {
|
||
const raw = String(url || "").trim();
|
||
if (!raw) return;
|
||
const normalized = normalizeMediaUrl(raw);
|
||
if (!normalized) return;
|
||
urlKeys.add(normalized);
|
||
};
|
||
|
||
const parts = (message as any).parts || [];
|
||
for (const part of parts) {
|
||
if (!part) continue;
|
||
if (
|
||
(part.type === "file" || part.type === "image" || part.type === "input_image") &&
|
||
(typeof part.url === "string" || typeof part.data === "string")
|
||
) {
|
||
pushUrl(typeof part.url === "string" ? part.url : part.data);
|
||
}
|
||
|
||
if (part.type === "text" && typeof part.text === "string") {
|
||
extractImageUrls(part.text).forEach((url) => pushUrl(url));
|
||
extractPlainImageUrls(part.text).forEach((url) => pushUrl(url));
|
||
}
|
||
}
|
||
|
||
const attachments = (message as any).attachments || [];
|
||
for (const attachment of attachments) {
|
||
pushUrl(attachment?.url);
|
||
}
|
||
|
||
return urlKeys;
|
||
}
|
||
|
||
function collectMessageImageUrls(message: ChatMessage | undefined): string[] {
|
||
const urls: string[] = [];
|
||
const seen = new Set<string>();
|
||
if (!message) return urls;
|
||
|
||
const pushUrl = (url?: string | null) => {
|
||
const raw = String(url || "").trim();
|
||
if (!raw) return;
|
||
const normalized = normalizeMediaUrl(raw);
|
||
if (!normalized || seen.has(normalized)) return;
|
||
seen.add(normalized);
|
||
urls.push(raw);
|
||
};
|
||
|
||
const parts = (message as any).parts || [];
|
||
for (const part of parts) {
|
||
if (!part) continue;
|
||
if (
|
||
(part.type === "file" || part.type === "image" || part.type === "input_image") &&
|
||
(typeof part.url === "string" || typeof part.data === "string")
|
||
) {
|
||
const direct = typeof part.url === "string" ? part.url : part.data;
|
||
pushUrl(inferOriginalImageUrl(String(direct || "").trim()));
|
||
}
|
||
|
||
if (part.type === "text" && typeof part.text === "string") {
|
||
extractImageUrls(part.text).forEach((url) => pushUrl(url));
|
||
extractPlainImageUrls(part.text).forEach((url) => pushUrl(url));
|
||
}
|
||
}
|
||
|
||
const attachments = (message as any).attachments || [];
|
||
for (const attachment of attachments) {
|
||
pushUrl(inferOriginalImageUrl(String(attachment?.url || "").trim()));
|
||
}
|
||
|
||
const legacyContent =
|
||
typeof (message as any).content === "string" ? (message as any).content : "";
|
||
if (legacyContent) {
|
||
extractImageUrls(legacyContent).forEach((url) => pushUrl(url));
|
||
extractPlainImageUrls(legacyContent).forEach((url) => pushUrl(url));
|
||
}
|
||
|
||
return urls;
|
||
}
|
||
|
||
function getNearestUserImageUrlKeys(messages: ChatMessage[], assistantIndex: number): Set<string> {
|
||
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
|
||
const message = messages[i];
|
||
if (message?.role !== "user") continue;
|
||
return collectMessageImageUrlKeys(message);
|
||
}
|
||
return new Set<string>();
|
||
}
|
||
|
||
function getNearestUserImageUrls(messages: ChatMessage[], assistantIndex: number): string[] {
|
||
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
|
||
const message = messages[i];
|
||
if (message?.role !== "user") continue;
|
||
return collectMessageImageUrls(message);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function extractAssistantImageUrls(text: string, referenceUrlKeys?: Set<string>): string[] {
|
||
const markdownPattern = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gim;
|
||
const markdownUrls: string[] = [];
|
||
const seenMarkdownUrls = new Set<string>();
|
||
let match: RegExpExecArray | null = null;
|
||
|
||
while ((match = markdownPattern.exec(text)) !== null) {
|
||
const altText = String(match[1] || "");
|
||
const markdownSrc = String(match[2] || "").trim();
|
||
if (!markdownSrc) continue;
|
||
|
||
const originalFromAlt = extractOriginalImageUrlFromAlt(altText, markdownSrc);
|
||
const candidates = [originalFromAlt, inferOriginalImageUrl(markdownSrc), markdownSrc];
|
||
let selected = "";
|
||
|
||
for (const candidate of candidates) {
|
||
const normalized = normalizeMediaUrl(candidate);
|
||
if (!normalized) continue;
|
||
if (referenceUrlKeys?.size && referenceUrlKeys.has(normalized)) continue;
|
||
selected = candidate;
|
||
break;
|
||
}
|
||
|
||
if (!selected) {
|
||
selected = candidates.find((candidate) => normalizeMediaUrl(candidate)) || "";
|
||
}
|
||
if (!selected) continue;
|
||
|
||
const dedupeKey = normalizeMediaUrl(selected);
|
||
if (!dedupeKey || seenMarkdownUrls.has(dedupeKey)) continue;
|
||
seenMarkdownUrls.add(dedupeKey);
|
||
markdownUrls.push(selected);
|
||
}
|
||
|
||
if (markdownUrls.length > 0) {
|
||
return markdownUrls;
|
||
}
|
||
|
||
const urls = extractImageUrls(text);
|
||
if (!referenceUrlKeys || referenceUrlKeys.size === 0) return urls;
|
||
|
||
return urls.filter((url) => {
|
||
const normalized = normalizeMediaUrl(url);
|
||
if (!normalized) return false;
|
||
return !referenceUrlKeys.has(normalized);
|
||
});
|
||
}
|
||
|
||
function extractVideoUrls(text: string): string[] {
|
||
if (!text) return [];
|
||
const urls = new Set<string>();
|
||
|
||
const videoTagPattern = /<video[^>]+src="([^"]+)"/gi;
|
||
let match: RegExpExecArray | null = null;
|
||
while ((match = videoTagPattern.exec(text)) !== null) {
|
||
if (match[1]) urls.add(match[1]);
|
||
}
|
||
|
||
const markdownVideoPattern = /\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi;
|
||
while ((match = markdownVideoPattern.exec(text)) !== null) {
|
||
if (match[1]) urls.add(match[1]);
|
||
}
|
||
|
||
const plainVideoPattern = /(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/gi;
|
||
while ((match = plainVideoPattern.exec(text)) !== null) {
|
||
if (match[1]) urls.add(match[1]);
|
||
}
|
||
|
||
return Array.from(urls);
|
||
}
|
||
|
||
function stripMediaMarkup(text: string): string {
|
||
return String(text || "")
|
||
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "")
|
||
.replace(/<video[\s\S]*?<\/video>/gi, "")
|
||
.replace(/<a[\s\S]*?<\/a>/gi, "")
|
||
.replace(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi, "")
|
||
.replace(/https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?/gi, "")
|
||
.trim();
|
||
}
|
||
|
||
function extractCanvasModelName(text: string): string {
|
||
const source = String(text || "");
|
||
if (!source) return "";
|
||
|
||
const badgeMatch = source.match(
|
||
/^\s*[◈◇◆🔹⬡✦•·]\s*([^\n]+?)\s*$/im
|
||
);
|
||
if (badgeMatch?.[1]) {
|
||
return badgeMatch[1].trim();
|
||
}
|
||
|
||
const routedMatch = source.match(
|
||
/^\s*(?:Routed to|Using model)\s*[::]\s*([^\n(]+).*$/im
|
||
);
|
||
if (routedMatch?.[1]) {
|
||
return routedMatch[1].trim();
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
function stripCanvasModelRoutingLines(text: string): string {
|
||
return String(text || "")
|
||
.replace(/^\s*(?:Routed to|Using model)\s*[::].*$/gim, "")
|
||
.replace(/^\s*[◈◇◆🔹⬡✦•·]\s*[^\n]+\s*$/gim, "")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim();
|
||
}
|
||
|
||
function messageHasAssistantMedia(
|
||
message: ChatMessage | undefined,
|
||
referenceUrlKeys?: Set<string>
|
||
): boolean {
|
||
if (!message || message.role !== "assistant") return false;
|
||
const textParts = (message.parts || []).filter(
|
||
(part: any) => part.type === "text" && part.text
|
||
);
|
||
return textParts.some((part: any) => {
|
||
const text = String(part.text || "");
|
||
return (
|
||
extractAssistantImageUrls(text, referenceUrlKeys).length > 0 ||
|
||
extractVideoUrls(text).length > 0
|
||
);
|
||
});
|
||
}
|
||
|
||
function getMessageTimestamp(message: ChatMessage): number | null {
|
||
const rawValue =
|
||
(message as any).createdAt ??
|
||
(message as any).created_at ??
|
||
(message as any).timestamp ??
|
||
null;
|
||
|
||
if (rawValue instanceof Date) {
|
||
return rawValue.getTime();
|
||
}
|
||
|
||
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||
return rawValue;
|
||
}
|
||
|
||
if (typeof rawValue === "string") {
|
||
const parsed = Date.parse(rawValue);
|
||
if (Number.isFinite(parsed)) {
|
||
return parsed;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getMessageDayKey(timestamp: number): string {
|
||
const date = new Date(timestamp);
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function formatMessageDateLabel(timestamp: number): string {
|
||
return new Intl.DateTimeFormat("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
}).format(new Date(timestamp));
|
||
}
|
||
|
||
const CANVAS_IMAGE_ANALYSIS_TITLE = "图片分析";
|
||
const CANVAS_IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。";
|
||
const CANVAS_AGENT_SECTION_TITLES = new Set([
|
||
"需求理解",
|
||
CANVAS_IMAGE_ANALYSIS_TITLE,
|
||
"执行计划",
|
||
"优化后提示词",
|
||
"输出设置",
|
||
]);
|
||
const CANVAS_IMAGE_TASK_POLL_INTERVAL_MS = 1600;
|
||
const CANVAS_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000;
|
||
|
||
type CanvasImageGenerationRequest = {
|
||
prompt: string;
|
||
model?: string;
|
||
aspectRatio?: string;
|
||
imageSize?: string;
|
||
imageCount?: number;
|
||
negativePrompt?: string;
|
||
referenceImage?: string;
|
||
referenceImages?: string[];
|
||
};
|
||
|
||
function sleep(ms: number) {
|
||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function requestCanvasImageGeneration(
|
||
payload: CanvasImageGenerationRequest
|
||
): Promise<{ images: Array<{ originalUrl?: string; thumbnailUrl?: string }> }> {
|
||
const createResponse = await fetch("/api/canvas/generate/image", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
...payload,
|
||
async: true,
|
||
}),
|
||
});
|
||
|
||
const createResult = await createResponse.json().catch(() => ({}));
|
||
if (!createResponse.ok || !createResult?.success) {
|
||
throw new Error(createResult?.error || "创建生图任务失败");
|
||
}
|
||
|
||
if (Array.isArray(createResult?.data?.images) && createResult.data.images.length > 0) {
|
||
return { images: createResult.data.images };
|
||
}
|
||
|
||
const taskId = String(createResult?.taskId || "").trim();
|
||
if (!taskId) {
|
||
throw new Error("创建生图任务失败:未返回任务ID");
|
||
}
|
||
|
||
const deadline = Date.now() + CANVAS_IMAGE_TASK_TIMEOUT_MS;
|
||
let lastError = "";
|
||
|
||
while (Date.now() < deadline) {
|
||
await sleep(CANVAS_IMAGE_TASK_POLL_INTERVAL_MS);
|
||
|
||
const statusResponse = await fetch(
|
||
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`,
|
||
{ cache: "no-store" }
|
||
);
|
||
const statusResult = await statusResponse.json().catch(() => ({}));
|
||
|
||
if (!statusResponse.ok || !statusResult?.success) {
|
||
lastError = statusResult?.error || `查询任务失败(${statusResponse.status})`;
|
||
continue;
|
||
}
|
||
|
||
const task = statusResult?.task;
|
||
const status = String(task?.status || "").trim();
|
||
|
||
if (status === "succeeded") {
|
||
const result = task?.result || {};
|
||
const images = Array.isArray(result?.images) ? result.images : [];
|
||
if (images.length === 0) {
|
||
throw new Error("任务完成但未返回图片");
|
||
}
|
||
return { images };
|
||
}
|
||
|
||
if (status === "failed") {
|
||
throw new Error(String(task?.error || "图片生成失败"));
|
||
}
|
||
}
|
||
|
||
throw new Error(lastError || "图片生成超时,请稍后重试");
|
||
}
|
||
|
||
type CanvasAnalysisSection = {
|
||
before: string;
|
||
analysis: string;
|
||
after: string;
|
||
};
|
||
|
||
type CanvasTypewriterTextProps = {
|
||
text: string;
|
||
typingKey: string;
|
||
enabled: boolean;
|
||
className?: string;
|
||
onActiveChange?: (typingKey: string, active: boolean) => void;
|
||
onComplete?: (typingKey: string) => void;
|
||
};
|
||
|
||
function CanvasTypewriterText({
|
||
text,
|
||
typingKey,
|
||
enabled,
|
||
className,
|
||
onActiveChange,
|
||
onComplete,
|
||
}: CanvasTypewriterTextProps) {
|
||
const content = String(text || "");
|
||
const [displayText, setDisplayText] = useState(enabled ? "" : content);
|
||
const activeRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (!enabled || !content) {
|
||
setDisplayText(content);
|
||
if (activeRef.current) {
|
||
activeRef.current = false;
|
||
onActiveChange?.(typingKey, false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let cursor = 0;
|
||
activeRef.current = true;
|
||
onActiveChange?.(typingKey, true);
|
||
setDisplayText("");
|
||
|
||
const timer = window.setInterval(() => {
|
||
cursor += 1;
|
||
if (cursor >= content.length) {
|
||
setDisplayText(content);
|
||
window.clearInterval(timer);
|
||
if (activeRef.current) {
|
||
activeRef.current = false;
|
||
onActiveChange?.(typingKey, false);
|
||
}
|
||
onComplete?.(typingKey);
|
||
return;
|
||
}
|
||
setDisplayText(content.slice(0, cursor));
|
||
}, 16);
|
||
|
||
return () => {
|
||
window.clearInterval(timer);
|
||
if (activeRef.current) {
|
||
activeRef.current = false;
|
||
onActiveChange?.(typingKey, false);
|
||
}
|
||
};
|
||
}, [content, enabled, onActiveChange, onComplete, typingKey]);
|
||
|
||
return <div className={className}>{displayText}</div>;
|
||
}
|
||
|
||
function normalizeCanvasAgentText(value: string): string {
|
||
return String(value || "")
|
||
.replace(/\r\n/g, "\n")
|
||
.replace(/^#{1,6}\s*/gm, "")
|
||
.replace(/^\*\*(.+)\*\*$/gm, "$1")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim();
|
||
}
|
||
|
||
function normalizeCanvasSectionTitle(line: string): string {
|
||
let normalized = line.trim();
|
||
if (!normalized) return "";
|
||
|
||
normalized = normalized
|
||
.replace(/^#{1,6}\s*/, "")
|
||
.replace(/^[-*]\s*/, "")
|
||
.replace(/^\[(.+)\]$/, "$1")
|
||
.replace(/^【(.+)】$/, "$1")
|
||
.replace(/[::]+$/, "")
|
||
.trim();
|
||
|
||
return normalized;
|
||
}
|
||
|
||
function shouldSplitCanvasAnalysisAfterLine(line: string): boolean {
|
||
const normalized = String(line || "")
|
||
.replace(/^[-*]\s*/, "")
|
||
.trim();
|
||
if (!normalized) return false;
|
||
|
||
if (
|
||
/^(现在我将|接下来|下一步|我将|现在开始|下面我|随后|Then\b|Next\b|Now\b)/i.test(
|
||
normalized
|
||
)
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
// 模型徽标行(例如:◈ Nano Banana Pro)
|
||
return /^[◈◇◆🔹⬡✦•·]\s*[^\n]+$/i.test(normalized);
|
||
}
|
||
|
||
function extractCanvasImageAnalysisSection(content: string): CanvasAnalysisSection | null {
|
||
const lines = String(content || "").split(/\r?\n/);
|
||
let startIndex = -1;
|
||
let inlineAnalysis = "";
|
||
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
const title = normalizeCanvasSectionTitle(lines[i]);
|
||
if (!title || !title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
|
||
|
||
startIndex = i;
|
||
const headingText = lines[i]
|
||
.trim()
|
||
.replace(/^#{1,6}\s*/, "")
|
||
.replace(/^[-*]\s*/, "")
|
||
.replace(/^\[(.+)\]$/, "$1")
|
||
.replace(/^【(.+)】$/, "$1")
|
||
.trim();
|
||
const inlineMatch = headingText.match(/^图片分析[::]\s*(.+)$/);
|
||
if (inlineMatch?.[1]) {
|
||
inlineAnalysis = inlineMatch[1].trim();
|
||
}
|
||
break;
|
||
}
|
||
|
||
if (startIndex < 0) return null;
|
||
|
||
let endIndex = lines.length;
|
||
for (let i = startIndex + 1; i < lines.length; i += 1) {
|
||
if (shouldSplitCanvasAnalysisAfterLine(lines[i])) {
|
||
endIndex = i;
|
||
break;
|
||
}
|
||
const title = normalizeCanvasSectionTitle(lines[i]);
|
||
if (!title || title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
|
||
if (CANVAS_AGENT_SECTION_TITLES.has(title)) {
|
||
endIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const analysisLines = lines.slice(startIndex + 1, endIndex);
|
||
if (inlineAnalysis) {
|
||
analysisLines.unshift(inlineAnalysis);
|
||
}
|
||
|
||
const analysis = normalizeCanvasAgentText(analysisLines.join("\n"));
|
||
if (!analysis) return null;
|
||
|
||
return {
|
||
before: normalizeCanvasAgentText(lines.slice(0, startIndex).join("\n")),
|
||
analysis,
|
||
after: normalizeCanvasAgentText(lines.slice(endIndex).join("\n")),
|
||
};
|
||
}
|
||
|
||
function normalizeVideoParamsForProvider(
|
||
params: VideoGenerationParams | null | undefined,
|
||
providerType?: string,
|
||
capability?: VideoToolCapabilityContract | null
|
||
): VideoGenerationParams {
|
||
if (capability) {
|
||
const modeOptions = capability.modeOptions || [];
|
||
const ratioOptions = capability.ratioOptions || [];
|
||
const durationOptions = capability.durationOptions || [];
|
||
const resolutionOptions = capability.resolutionOptions || [];
|
||
const modeSet = new Set(modeOptions.map((item) => item.mode));
|
||
const ratioSet = new Set(ratioOptions);
|
||
const durationSet = new Set(durationOptions);
|
||
const resolutionSet = new Set(resolutionOptions);
|
||
|
||
const fallbackMode = capability.defaults?.mode || modeOptions[0]?.mode || "text-to-video";
|
||
const fallbackRatio = capability.defaults?.ratio || ratioOptions[0] || "16:9";
|
||
const fallbackDuration = capability.defaults?.duration || durationOptions[0] || 5;
|
||
const fallbackResolution = capability.defaults?.resolution || resolutionOptions[0] || "720p";
|
||
|
||
const merged = {
|
||
mode: fallbackMode,
|
||
resolution: fallbackResolution as VideoGenerationParams["resolution"],
|
||
ratio: fallbackRatio as VideoGenerationParams["ratio"],
|
||
duration: Number(fallbackDuration) || 5,
|
||
generateAudio: capability.defaults?.generateAudio ?? false,
|
||
watermark: false,
|
||
cameraFixed: false,
|
||
cameraMove: "none" as VideoGenerationParams["cameraMove"],
|
||
fps: capability.defaults?.fps || 24,
|
||
...(params || {}),
|
||
} as VideoGenerationParams;
|
||
|
||
if (!modeSet.has(merged.mode)) {
|
||
merged.mode = fallbackMode as VideoGenerationParams["mode"];
|
||
}
|
||
if (!ratioSet.has(merged.ratio)) {
|
||
merged.ratio = fallbackRatio as VideoGenerationParams["ratio"];
|
||
}
|
||
if (!durationSet.has(Number(merged.duration))) {
|
||
merged.duration = Number(fallbackDuration) || 5;
|
||
} else {
|
||
merged.duration = Number(merged.duration) || Number(fallbackDuration) || 5;
|
||
}
|
||
if (resolutionOptions.length > 0 && !resolutionSet.has(merged.resolution)) {
|
||
merged.resolution = fallbackResolution as VideoGenerationParams["resolution"];
|
||
}
|
||
if (!capability.ui.showGenerateAudio) {
|
||
merged.generateAudio = false;
|
||
}
|
||
if (!capability.ui.showFps) {
|
||
merged.fps = capability.defaults?.fps || 24;
|
||
}
|
||
if (!capability.ui.showCameraMove) {
|
||
merged.cameraMove = "none";
|
||
merged.cameraFixed = false;
|
||
}
|
||
if (!capability.ui.showReturnLastFrame) {
|
||
merged.returnLastFrame = false;
|
||
}
|
||
if (!capability.ui.showSeed) {
|
||
merged.seed = undefined;
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
const isFalVideo = providerType === "fal-video";
|
||
const isRuxaVideo = providerType === "ruxa-video";
|
||
const base: VideoGenerationParams = {
|
||
mode: isFalVideo || isRuxaVideo ? "first-frame" : "text-to-video",
|
||
resolution: "720p",
|
||
ratio: "16:9",
|
||
duration: 5,
|
||
generateAudio: true,
|
||
watermark: false,
|
||
cameraFixed: false,
|
||
cameraMove: "none",
|
||
fps: 24,
|
||
};
|
||
const merged = { ...base, ...(params || {}) };
|
||
|
||
if (isFalVideo) {
|
||
if (merged.mode !== "first-frame" && merged.mode !== "first-last-frame") {
|
||
merged.mode = "first-frame";
|
||
}
|
||
if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) {
|
||
merged.ratio = "16:9";
|
||
}
|
||
merged.duration = Math.max(3, Math.min(15, Math.round(Number(merged.duration) || 5)));
|
||
merged.generateAudio = merged.generateAudio ?? true;
|
||
return merged;
|
||
}
|
||
|
||
if (isRuxaVideo) {
|
||
merged.mode = "first-frame";
|
||
if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) {
|
||
merged.ratio = "16:9";
|
||
}
|
||
merged.duration = merged.duration >= 8 ? 10 : 5;
|
||
merged.generateAudio = false;
|
||
merged.cameraMove = "none";
|
||
merged.cameraFixed = false;
|
||
merged.fps = 24;
|
||
return merged;
|
||
}
|
||
|
||
if (!["text-to-video", "first-frame", "first-last-frame", "reference-images"].includes(merged.mode)) {
|
||
merged.mode = "text-to-video";
|
||
}
|
||
if (!["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(merged.ratio)) {
|
||
merged.ratio = "16:9";
|
||
}
|
||
merged.duration = merged.duration >= 8 ? 10 : 5;
|
||
return merged;
|
||
}
|
||
|
||
function getVideoImageLimit(
|
||
params: VideoGenerationParams | null | undefined,
|
||
providerType?: string,
|
||
capability?: VideoToolCapabilityContract | null
|
||
): { min: number; max: number } {
|
||
if (!params) {
|
||
return { min: 0, max: 0 };
|
||
}
|
||
if (capability?.modeOptions?.length) {
|
||
const modeOption = capability.modeOptions.find((item) => item.mode === params.mode);
|
||
if (modeOption) {
|
||
return { min: modeOption.imageMin, max: modeOption.imageMax };
|
||
}
|
||
}
|
||
const isFalVideo = providerType === "fal-video";
|
||
const isRuxaVideo = providerType === "ruxa-video";
|
||
if (isRuxaVideo) {
|
||
return { min: 1, max: 1 };
|
||
}
|
||
if (isFalVideo) {
|
||
if (params.mode === "first-last-frame") {
|
||
return { min: 2, max: 2 };
|
||
}
|
||
return { min: 1, max: 1 };
|
||
}
|
||
|
||
switch (params.mode) {
|
||
case "text-to-video":
|
||
return { min: 0, max: 0 };
|
||
case "first-last-frame":
|
||
return { min: 2, max: 2 };
|
||
case "first-frame":
|
||
return { min: 1, max: 1 };
|
||
case "reference-images":
|
||
return { min: 1, max: 4 };
|
||
default:
|
||
return { min: 0, max: 0 };
|
||
}
|
||
}
|
||
|
||
function extractUserTextPrompt(message: ChatMessage): string {
|
||
const parts = (message as any)?.parts || [];
|
||
const textFromParts = parts
|
||
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
||
.map((part: any) =>
|
||
part.text
|
||
.replace(
|
||
/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g,
|
||
""
|
||
)
|
||
.trim()
|
||
)
|
||
.filter(Boolean)
|
||
.join("\n")
|
||
.trim();
|
||
|
||
if (textFromParts) return textFromParts;
|
||
|
||
const content = (message as any)?.content;
|
||
return typeof content === "string" ? content.trim() : "";
|
||
}
|
||
|
||
function findNearestUserPrompt(messages: ChatMessage[], assistantIndex: number): string {
|
||
for (let i = assistantIndex - 1; i >= 0; i -= 1) {
|
||
const candidate = messages[i];
|
||
if (candidate?.role !== "user") continue;
|
||
const prompt = extractUserTextPrompt(candidate);
|
||
if (prompt) return prompt;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
const GENERIC_CANVAS_PROMPT_SET = new Set([
|
||
"对话生成",
|
||
"图片生成",
|
||
"AI生成",
|
||
"智能生成",
|
||
"自动生成",
|
||
]);
|
||
|
||
function isGenericCanvasPrompt(prompt: unknown): boolean {
|
||
if (typeof prompt !== "string") return true;
|
||
const normalized = prompt.trim();
|
||
if (!normalized) return true;
|
||
return GENERIC_CANVAS_PROMPT_SET.has(normalized);
|
||
}
|
||
|
||
// 🆕 拾取物品的附件类型扩展
|
||
interface PickedObjectAttachment extends Attachment {
|
||
pickedObject?: {
|
||
objectName: string;
|
||
objectPosition: string;
|
||
clickX: number;
|
||
clickY: number;
|
||
boundingBox: { x: number; y: number; width: number; height: number };
|
||
};
|
||
}
|
||
|
||
type MessageVote = {
|
||
chatId: string;
|
||
messageId: string;
|
||
isUpvoted: boolean;
|
||
};
|
||
|
||
interface CanvasChatSession {
|
||
id: string;
|
||
title: string;
|
||
createdAt: number;
|
||
updatedAt: number;
|
||
}
|
||
|
||
type DetailBriefForm = {
|
||
productSpu: string;
|
||
demandType: string;
|
||
productName: string;
|
||
targetAudience: string;
|
||
mainUser: string;
|
||
usageScenario: string;
|
||
triggerMoment: string;
|
||
coreNeed: string;
|
||
riskConcern: string;
|
||
expectedResult: string;
|
||
coreSellingPoint: string;
|
||
positioningStatement: string;
|
||
userVoice: string;
|
||
competitorReference: string;
|
||
priceBand: string;
|
||
brandTone: string;
|
||
bannedWords: string;
|
||
};
|
||
|
||
type DetailSlotConfig = {
|
||
key: string;
|
||
label: string;
|
||
role: "product" | "model" | "bundle";
|
||
};
|
||
|
||
type DetailSectionKey = "required" | "strategy" | "assets" | "archives";
|
||
type DetailGenerationMode = "classic" | "agent_layout";
|
||
|
||
type DetailArchive = {
|
||
id: string;
|
||
title: string;
|
||
prompt: string;
|
||
generationMode: DetailGenerationMode;
|
||
brief: DetailBriefForm;
|
||
slots: Array<PickedObjectAttachment | null>;
|
||
createdAt: number;
|
||
updatedAt?: number;
|
||
summary?: string;
|
||
imageCount?: number;
|
||
coverUrl?: string;
|
||
};
|
||
|
||
type DetailPlanResponse = {
|
||
generationMode?: DetailGenerationMode;
|
||
summary?: string;
|
||
prompts?: string[];
|
||
screenSize?: {
|
||
width: number;
|
||
height: number;
|
||
};
|
||
globalStyle?: {
|
||
designLanguage?: string;
|
||
typography?: {
|
||
fontFamily?: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
body?: string;
|
||
};
|
||
layout?: {
|
||
grid?: string;
|
||
safeArea?: string;
|
||
rhythm?: string;
|
||
};
|
||
palette?: string;
|
||
};
|
||
screens?: Array<{
|
||
index: number;
|
||
screenType?: "A" | "B" | "C";
|
||
objective?: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
copy?: string;
|
||
layoutNote?: string;
|
||
useModel?: boolean;
|
||
modelReason?: string;
|
||
bundleShowcase?: boolean;
|
||
bundleReason?: string;
|
||
visualPrompt?: string;
|
||
fabe?: {
|
||
b?: string;
|
||
f?: string;
|
||
a?: string;
|
||
e?: string;
|
||
};
|
||
}>;
|
||
skillFlow?: {
|
||
name?: string;
|
||
version?: string;
|
||
steps?: string[];
|
||
};
|
||
qualityChecks?: {
|
||
passed?: boolean;
|
||
issues?: string[];
|
||
};
|
||
preflight?: {
|
||
passed?: boolean;
|
||
designDirectorScore?: number;
|
||
marketingDirectorScore?: number;
|
||
issues?: string[];
|
||
rewriteApplied?: boolean;
|
||
};
|
||
model?: {
|
||
id: string;
|
||
displayName: string;
|
||
providerType: string;
|
||
};
|
||
};
|
||
type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number];
|
||
|
||
const DETAIL_TARGET_WIDTH = 790;
|
||
const DETAIL_TARGET_HEIGHT = 1300;
|
||
const DETAIL_REVIEW_COUNT = 3;
|
||
const DETAIL_REVIEW_REGEN_COUNT = 2;
|
||
const DETAIL_REVIEW_IMAGE_COUNT = 3;
|
||
const DETAIL_REVIEW_IMAGE_WIDTH = 420;
|
||
const DETAIL_REVIEW_IMAGE_HEIGHT = 420;
|
||
const DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS = 1;
|
||
const DETAIL_TYPOGRAPHY_LOCK = {
|
||
title: "主标题 64px / 700 / 行高1.15,建议单行最多2行,不得出现“第X屏”",
|
||
subtitle: "副标题 34px / 600 / 行高1.25,位于主标题下方16px",
|
||
body: "正文 24px / 500 / 行高1.45,段间距10px,每块最多4行",
|
||
safeArea: "文字安全区固定 y=64~360,产品主体避免遮挡标题区",
|
||
rhythm: "左右边距56px,模块间距24px,全套页面留白节奏一致",
|
||
} as const;
|
||
const DETAIL_SLOT_CONFIGS: DetailSlotConfig[] = [
|
||
{ key: "product-1", label: "产品图1", role: "product" },
|
||
{ key: "product-2", label: "产品图2", role: "product" },
|
||
{ key: "product-3", label: "产品图3", role: "product" },
|
||
{ key: "product-4", label: "产品图4", role: "product" },
|
||
{ key: "product-5", label: "产品图5", role: "product" },
|
||
{ key: "model-1", label: "模特图", role: "model" },
|
||
{ key: "bundle-1", label: "搭配图1", role: "bundle" },
|
||
{ key: "bundle-2", label: "搭配图2", role: "bundle" },
|
||
{ key: "bundle-3", label: "搭配图3", role: "bundle" },
|
||
];
|
||
const DETAIL_PRODUCT_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) =>
|
||
slot.role === "product" ? index : -1
|
||
).filter((index) => index >= 0);
|
||
const DETAIL_MODEL_SLOT_INDEX = DETAIL_SLOT_CONFIGS.findIndex((slot) => slot.role === "model");
|
||
const DETAIL_BUNDLE_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) =>
|
||
slot.role === "bundle" ? index : -1
|
||
).filter((index) => index >= 0);
|
||
const DETAIL_FIELD_EXAMPLES: Record<keyof DetailBriefForm, string> = {
|
||
productSpu: "示例:SPU-889901",
|
||
demandType: "示例:清洁除尘 / 户外通勤 / 礼赠",
|
||
productName: "示例:Buyi-AI 无线手持吸尘器",
|
||
targetAudience: "示例:25-40岁养宠家庭、宝妈",
|
||
mainUser: "示例:家庭主理人、租房上班族",
|
||
usageScenario: "示例:下班回家10分钟快速清洁",
|
||
triggerMoment: "示例:宠物掉毛高峰、饭后碎屑清理",
|
||
coreNeed: "示例:省时省力清除毛发与灰尘",
|
||
riskConcern: "示例:担心吸力不足、续航短、噪音大",
|
||
expectedResult: "示例:3分钟清洁桌面,地面无明显毛发",
|
||
coreSellingPoint: "示例:18000Pa吸力、0.9kg轻量、一键倒尘",
|
||
positioningStatement: "示例:让日常清洁更快更轻松",
|
||
userVoice: "示例:家里有猫掉毛太多,希望安静一点不吵宝宝",
|
||
competitorReference: "示例:对标戴森入门款,突出轻便高性价比",
|
||
priceBand: "示例:199-299元",
|
||
brandTone: "示例:专业、可靠、亲和",
|
||
bannedWords: "示例:最强、第一、100%治愈",
|
||
};
|
||
const DETAIL_GENERATION_MODE_OPTIONS: Array<{
|
||
value: DetailGenerationMode;
|
||
label: string;
|
||
description: string;
|
||
}> = [
|
||
{
|
||
value: "classic",
|
||
label: "传统生图",
|
||
description: "整页直接生图,速度更快",
|
||
},
|
||
{
|
||
value: "agent_layout",
|
||
label: "AGENT排版",
|
||
description: "生图只出画面,文字层可编辑",
|
||
},
|
||
];
|
||
|
||
const SESSION_TITLE_MAX_LEN = 18;
|
||
|
||
function toRawCanvasChatId(value: string): string {
|
||
return value.startsWith("canvas-") ? value.replace("canvas-", "") : value;
|
||
}
|
||
|
||
function getSessionStorageKey(canvasId: string, scope: "canvas" | "agent"): string {
|
||
return `canvas-chat-sessions:${scope}:${canvasId}`;
|
||
}
|
||
|
||
function getSessionRootStorageKey(canvasId: string, scope: "canvas" | "agent"): string {
|
||
return `canvas-chat-root:${scope}:${canvasId}`;
|
||
}
|
||
|
||
function deriveSessionTitleFromMessages(messages: ChatMessage[]): string {
|
||
const firstUserMessage = messages.find((message) => message.role === "user");
|
||
if (!firstUserMessage) return "";
|
||
|
||
const prompt = extractUserTextPrompt(firstUserMessage).replace(/\s+/g, " ").trim();
|
||
if (!prompt) return "";
|
||
return prompt.length > SESSION_TITLE_MAX_LEN
|
||
? `${prompt.slice(0, SESSION_TITLE_MAX_LEN)}...`
|
||
: prompt;
|
||
}
|
||
|
||
function extractDetailFrameCount(text: string): number {
|
||
const normalized = text.replace(/\s+/g, "");
|
||
const matched = normalized.match(/(\d{1,2})(?:张|屏|页)/);
|
||
if (!matched) return 14;
|
||
const value = Number(matched[1]);
|
||
if (!Number.isFinite(value)) return 14;
|
||
return Math.min(20, Math.max(5, Math.round(value)));
|
||
}
|
||
|
||
type DetailReviewPlan = {
|
||
index: number;
|
||
title: string;
|
||
reviewText: string;
|
||
imagePrompt: string;
|
||
};
|
||
|
||
type DetailReviewGroup = {
|
||
reviewText: string;
|
||
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
|
||
};
|
||
|
||
type DetailScreenReviewResult = {
|
||
pass: boolean;
|
||
score?: number;
|
||
issues?: string[];
|
||
layoutFixes?: string[];
|
||
revisedPrompt?: string;
|
||
model?: {
|
||
id?: string;
|
||
displayName?: string;
|
||
providerType?: string;
|
||
} | null;
|
||
};
|
||
|
||
function normalizeDetailGenerationMode(value: unknown): DetailGenerationMode {
|
||
return value === "agent_layout" ? "agent_layout" : "classic";
|
||
}
|
||
|
||
function extractFirstNumber(value: string, fallback: number): number {
|
||
const matched = String(value || "").match(/(\d{1,3})/);
|
||
if (!matched) return fallback;
|
||
const parsed = Number(matched[1]);
|
||
if (!Number.isFinite(parsed)) return fallback;
|
||
return parsed;
|
||
}
|
||
|
||
function extractFontWeight(value: string, fallback = "600"): string {
|
||
const matched = String(value || "").match(/(?:\/|weight[::]?\s*)(\d{3})/i);
|
||
if (!matched) return fallback;
|
||
return matched[1] || fallback;
|
||
}
|
||
|
||
function sanitizeDetailImageModelPrompt(prompt: string): string {
|
||
const filteredLines = String(prompt || "")
|
||
.split(/\n+/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
.map((line) => {
|
||
if (
|
||
/^(字体规范|标题规范|副标题规范|正文规范|版式规范|安全区|留白节奏|Typography|Layout|Palette|固定字体锁定|固定副标题锁定|固定正文锁定|固定排版安全区|固定留白节奏)/i.test(
|
||
line
|
||
)
|
||
) {
|
||
return "";
|
||
}
|
||
return line;
|
||
})
|
||
.filter(Boolean);
|
||
|
||
let result = filteredLines.join("\n");
|
||
|
||
const literalReplacements: Array<[RegExp, string]> = [
|
||
[/画布固定(?:竖版)?\s*\d+\s*x\s*\d+\s*(?:像素|pixels?)。?/gi, "画布为竖版详情页构图。"],
|
||
[/尺寸固定\s*\d+\s*x\s*\d+\s*(?:像素|pixels?)。?/gi, "尺寸为竖版详情页比例。"],
|
||
[/输出为竖版电商详情图,尺寸固定\s*\d+\s*x\s*\d+\s*(?:像素|pixels?)。?/gi, "输出为竖版电商详情图。"],
|
||
[/y\s*=\s*\d+\s*[-~~]\s*\d+/gi, "顶部标题安全区"],
|
||
[/左右边距\s*\d+\s*px/gi, "左右留白充足"],
|
||
[/模块间距\s*\d+\s*px/gi, "模块间距均匀"],
|
||
[/标题和副标题间距\s*\d+\s*px/gi, "标题与副标题间距清晰"],
|
||
[/主标题\s*\d+\s*px[^,。;\n]*/gi, "主标题醒目突出"],
|
||
[/副标题\s*\d+\s*px[^,。;\n]*/gi, "副标题层级清晰"],
|
||
[/正文\s*\d+\s*px[^,。;\n]*/gi, "正文精简易读"],
|
||
[/商业摄影质感,留出清晰文案排版区,不要水印。?/gi, "商业摄影质感,留出清晰文字区域,不要水印。"],
|
||
];
|
||
|
||
for (const [pattern, replacement] of literalReplacements) {
|
||
result = result.replace(pattern, replacement);
|
||
}
|
||
|
||
result = result
|
||
.replace(/\b\d+\s*px\b/gi, "")
|
||
.replace(/[((]\s*顶部标题安全区\s*[))]/g, "顶部标题安全区")
|
||
.replace(/;{2,}/g, ";")
|
||
.replace(/。{2,}/g, "。")
|
||
.replace(/\s{2,}/g, " ")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim();
|
||
|
||
return result;
|
||
}
|
||
|
||
function splitDetailKeywords(value: string, limit = 3): string[] {
|
||
const raw = String(value || "")
|
||
.split(/[\n,,、;;|]/)
|
||
.map((item) => item.replace(/\s+/g, " ").trim())
|
||
.filter(Boolean);
|
||
if (raw.length > 0) return raw.slice(0, limit);
|
||
return [];
|
||
}
|
||
|
||
function buildDetailReviewPlans(params: {
|
||
brief: DetailBriefForm;
|
||
summary?: string;
|
||
language: "zh" | "en";
|
||
count?: number;
|
||
}): DetailReviewPlan[] {
|
||
const count = Math.max(1, Math.min(5, Math.round(params.count || DETAIL_REVIEW_COUNT)));
|
||
const audience = params.brief.targetAudience || (params.language === "en" ? "target users" : "目标用户");
|
||
const scenario = params.brief.usageScenario || (params.language === "en" ? "daily life scenario" : "日常场景");
|
||
const coreNeed = params.brief.coreNeed || (params.language === "en" ? "core need" : "核心需求");
|
||
const productName = params.brief.productName || (params.language === "en" ? "the product" : "该产品");
|
||
const expectedResult =
|
||
params.brief.expectedResult || (params.language === "en" ? "clear and reliable improvement" : "效果明显且稳定");
|
||
const riskConcern =
|
||
params.brief.riskConcern || (params.language === "en" ? "quality and usability concerns" : "担心效果不稳定");
|
||
const keywords = splitDetailKeywords(params.brief.coreSellingPoint, 3);
|
||
const firstKeyword = keywords[0] || (params.language === "en" ? "stable performance" : "稳定好用");
|
||
const secondKeyword = keywords[1] || (params.language === "en" ? "easy operation" : "上手简单");
|
||
const thirdKeyword = keywords[2] || (params.language === "en" ? "value for money" : "性价比高");
|
||
const summaryHint = String(params.summary || "").replace(/\s+/g, " ").trim();
|
||
|
||
if (params.language === "en") {
|
||
const templates = [
|
||
{
|
||
title: "Real Need Solved",
|
||
reviewText: `I am part of ${audience}. In ${scenario}, my biggest issue was ${coreNeed}. After using ${productName}, the result is ${expectedResult}. It feels ${firstKeyword} and I am willing to repurchase.`,
|
||
imagePrompt: `UGC buyer photo set for ${productName}, scenario: ${scenario}, highlight ${coreNeed} solved, realistic lifestyle composition, clear product details, natural lighting, authentic user-generated look`,
|
||
},
|
||
{
|
||
title: "From Concern to Trust",
|
||
reviewText: `Before purchase I worried about ${riskConcern}. After using it for a while, ${productName} stays ${secondKeyword} and reliable. The overall user experience is much better than expected.`,
|
||
imagePrompt: `UGC buyer experience photo set for ${productName}, before-after feeling in ${scenario}, close-up and handheld usage moments, realistic texture, social proof style, commercial clarity`,
|
||
},
|
||
{
|
||
title: "Conversion Testimonial",
|
||
reviewText: `For users like ${audience}, ${productName} is a practical choice. It balances ${firstKeyword}, ${secondKeyword}, and ${thirdKeyword}. The final outcome is ${expectedResult}.`,
|
||
imagePrompt: `UGC testimonial photo set for ${productName}, mixed angles including scene shot, detail shot and usage shot, trust-driven ecommerce style, clean but real, high conversion mood`,
|
||
},
|
||
];
|
||
const fallback = templates[templates.length - 1]!;
|
||
return Array.from({ length: count }, (_, index) => {
|
||
const template = templates[index] || fallback;
|
||
const summaryText = summaryHint ? ` ${summaryHint}` : "";
|
||
return {
|
||
index: index + 1,
|
||
title: `${template.title} ${index + 1}`,
|
||
reviewText: `${template.reviewText}${summaryText}`.trim(),
|
||
imagePrompt: template.imagePrompt,
|
||
};
|
||
});
|
||
}
|
||
|
||
const templates = [
|
||
{
|
||
title: "真实需求反馈",
|
||
reviewText: `我属于${audience},在${scenario}里最头疼的是${coreNeed}。用了${productName}后,明显感受到${expectedResult},整体体验${firstKeyword},确实值得回购。`,
|
||
imagePrompt: `${productName}买家真实晒单,场景:${scenario},突出“${coreNeed}被解决”的使用结果,生活化拍摄,真实自然光,产品细节清晰,电商高转化氛围`,
|
||
},
|
||
{
|
||
title: "顾虑消除反馈",
|
||
reviewText: `下单前我最担心${riskConcern},实际使用后发现${productName}${secondKeyword},稳定性也不错。连续使用一段时间后效果依旧在线。`,
|
||
imagePrompt: `${productName}买家体验晒单,表达“从担心到放心”的转变,含近景细节、手持使用、场景实拍,真实用户视角,质感干净`,
|
||
},
|
||
{
|
||
title: "转化口碑反馈",
|
||
reviewText: `如果你也是${audience},并且在${scenario}下有${coreNeed},这款${productName}真的很合适。它兼顾了${firstKeyword}、${secondKeyword}和${thirdKeyword},结果就是${expectedResult}。`,
|
||
imagePrompt: `${productName}买家口碑晒单,三张图分别体现场景图、细节图、结果图,构图统一,真实可信,强化转化购买意愿`,
|
||
},
|
||
];
|
||
const fallback = templates[templates.length - 1]!;
|
||
return Array.from({ length: count }, (_, index) => {
|
||
const template = templates[index] || fallback;
|
||
const summaryText = summaryHint ? ` ${summaryHint}` : "";
|
||
return {
|
||
index: index + 1,
|
||
title: `${template.title}${index + 1}`,
|
||
reviewText: `${template.reviewText}${summaryText}`.trim(),
|
||
imagePrompt: template.imagePrompt,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildDetailResultMarkdown(params: {
|
||
generationMode?: DetailGenerationMode;
|
||
summary?: string;
|
||
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
|
||
prompts: string[];
|
||
skillFlow?: {
|
||
name?: string;
|
||
version?: string;
|
||
steps?: string[];
|
||
};
|
||
qualityChecks?: {
|
||
passed?: boolean;
|
||
issues?: string[];
|
||
};
|
||
globalStyle?: {
|
||
designLanguage?: string;
|
||
typography?: {
|
||
fontFamily?: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
body?: string;
|
||
};
|
||
layout?: {
|
||
grid?: string;
|
||
safeArea?: string;
|
||
rhythm?: string;
|
||
};
|
||
palette?: string;
|
||
};
|
||
screens?: Array<{
|
||
screenType?: "A" | "B" | "C";
|
||
visualPrompt?: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
copy?: string;
|
||
layoutNote?: string;
|
||
useModel?: boolean;
|
||
modelReason?: string;
|
||
bundleShowcase?: boolean;
|
||
bundleReason?: string;
|
||
fabe?: {
|
||
b?: string;
|
||
f?: string;
|
||
a?: string;
|
||
e?: string;
|
||
};
|
||
}>;
|
||
reviewGroups?: DetailReviewGroup[];
|
||
}): string {
|
||
const lines: string[] = [];
|
||
lines.push("### AI详情生成完成");
|
||
if (params.generationMode) {
|
||
lines.push(
|
||
`生成路线:${params.generationMode === "agent_layout" ? "AGENT排版(画面+可编辑文字层)" : "传统生图(整页生图)"}`
|
||
);
|
||
}
|
||
if (params.summary) {
|
||
lines.push(params.summary);
|
||
}
|
||
if (params.skillFlow?.steps?.length) {
|
||
lines.push(
|
||
`技能流程:${params.skillFlow.name || "AI详情生成"} ${params.skillFlow.version ? `(v${params.skillFlow.version})` : ""}`
|
||
);
|
||
params.skillFlow.steps.forEach((step, index) => {
|
||
lines.push(`${index + 1}. ${step}`);
|
||
});
|
||
}
|
||
if (params.qualityChecks) {
|
||
if (params.qualityChecks.passed) {
|
||
lines.push("质检结果:通过");
|
||
} else if (params.qualityChecks.issues?.length) {
|
||
lines.push("质检结果:未通过,已使用回退方案");
|
||
params.qualityChecks.issues.forEach((issue) => lines.push(`- ${issue}`));
|
||
}
|
||
}
|
||
lines.push(`共生成 ${params.images.length} 张详情图。`);
|
||
if (params.globalStyle) {
|
||
const typography = params.globalStyle.typography;
|
||
lines.push(
|
||
`全局风格:${params.globalStyle.designLanguage || "统一电商详情风格"}`
|
||
);
|
||
lines.push(
|
||
`字体规范:${typography?.fontFamily || "统一字体"} / ${typography?.title || "标题规范一致"} / ${typography?.subtitle || "副标题规范一致"}`
|
||
);
|
||
}
|
||
lines.push("");
|
||
params.images.forEach((image, index) => {
|
||
const screen = params.screens?.[index];
|
||
const prompt = params.prompts[index] || screen?.visualPrompt || "";
|
||
lines.push(`#### 分镜 ${index + 1}`);
|
||
if (screen?.screenType) {
|
||
lines.push(`分镜类型:${screen.screenType}`);
|
||
}
|
||
if (screen?.title) {
|
||
lines.push(`标题:${screen.title}`);
|
||
}
|
||
if (screen?.subtitle) {
|
||
lines.push(`副标题:${screen.subtitle}`);
|
||
}
|
||
if (screen?.copy) {
|
||
lines.push(`文案:${screen.copy}`);
|
||
}
|
||
if (screen?.layoutNote) {
|
||
lines.push(`排版:${screen.layoutNote}`);
|
||
}
|
||
if (screen?.useModel !== undefined) {
|
||
lines.push(screen.useModel ? "人物:使用固定模特图" : "人物:不使用模特图");
|
||
}
|
||
if (screen?.modelReason) {
|
||
lines.push(`人物策略:${screen.modelReason}`);
|
||
}
|
||
if (screen?.bundleShowcase !== undefined) {
|
||
lines.push(screen.bundleShowcase ? "搭配展示:是" : "搭配展示:否");
|
||
}
|
||
if (screen?.bundleReason) {
|
||
lines.push(`搭配策略:${screen.bundleReason}`);
|
||
}
|
||
if (screen?.fabe) {
|
||
const fabeParts = [
|
||
screen.fabe.b ? `B ${screen.fabe.b}` : "",
|
||
screen.fabe.f ? `F ${screen.fabe.f}` : "",
|
||
screen.fabe.a ? `A ${screen.fabe.a}` : "",
|
||
screen.fabe.e ? `E ${screen.fabe.e}` : "",
|
||
].filter(Boolean);
|
||
if (fabeParts.length > 0) {
|
||
lines.push(`FABE:${fabeParts.join(" | ")}`);
|
||
}
|
||
}
|
||
if (prompt) {
|
||
lines.push(prompt);
|
||
}
|
||
lines.push(
|
||
``
|
||
);
|
||
lines.push("");
|
||
});
|
||
if (params.reviewGroups?.length) {
|
||
lines.push("### 买家评价与晒图");
|
||
params.reviewGroups.forEach((group, index) => {
|
||
lines.push(`评价 ${index + 1}:${group.reviewText}`);
|
||
group.images.forEach((image) => {
|
||
lines.push(
|
||
``
|
||
);
|
||
});
|
||
lines.push("");
|
||
});
|
||
}
|
||
return lines.join("\n").trim();
|
||
}
|
||
|
||
function buildDetailReviewAppendMarkdown(params: {
|
||
groups: DetailReviewGroup[];
|
||
failedCount?: number;
|
||
}): string {
|
||
const lines: string[] = [];
|
||
lines.push("### 买家评价与晒图已追加");
|
||
lines.push(`本次新增 ${params.groups.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张晒图。`);
|
||
if (params.failedCount && params.failedCount > 0) {
|
||
lines.push(`失败 ${params.failedCount} 条,已跳过。`);
|
||
}
|
||
lines.push("");
|
||
params.groups.forEach((group, index) => {
|
||
lines.push(`评价 ${index + 1}:${group.reviewText}`);
|
||
group.images.forEach((image) => {
|
||
lines.push(
|
||
``
|
||
);
|
||
});
|
||
lines.push("");
|
||
});
|
||
return lines.join("\n").trim();
|
||
}
|
||
|
||
export function CanvasChatPanel({
|
||
chatId,
|
||
initialMessages = [],
|
||
onClose,
|
||
onOpenFileList,
|
||
selectedAgentPresetId,
|
||
chatScope = "canvas",
|
||
workflowContext = null,
|
||
presentation = "panel",
|
||
}: CanvasChatPanelProps) {
|
||
const isWorkspacePresentation = presentation === "workspace";
|
||
const { data: session } = useSession();
|
||
const [input, setInput] = useState("");
|
||
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
|
||
const [selectedModelId, setSelectedModelId] = useState<string>("auto");
|
||
const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false);
|
||
const [detailGenerationMode, setDetailGenerationMode] = useState<DetailGenerationMode>("classic");
|
||
const [isDetailGenerating, setIsDetailGenerating] = useState(false);
|
||
const [detailSections, setDetailSections] = useState<Record<DetailSectionKey, boolean>>({
|
||
required: true,
|
||
strategy: false,
|
||
assets: true,
|
||
archives: true,
|
||
});
|
||
const [isDetailWorkbenchCollapsed, setIsDetailWorkbenchCollapsed] = useState(true);
|
||
const [detailProgress, setDetailProgress] = useState<{
|
||
status: "idle" | "running" | "done" | "error";
|
||
stage: string;
|
||
current: number;
|
||
total: number;
|
||
}>({
|
||
status: "idle",
|
||
stage: "",
|
||
current: 0,
|
||
total: 0,
|
||
});
|
||
const [detailSlots, setDetailSlots] = useState<Array<PickedObjectAttachment | null>>(
|
||
() => DETAIL_SLOT_CONFIGS.map(() => null)
|
||
);
|
||
const [detailSlotUploading, setDetailSlotUploading] = useState<number | null>(null);
|
||
const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState<number | null>(null);
|
||
const [detailArchives, setDetailArchives] = useState<DetailArchive[]>([]);
|
||
const [deletingDetailArchiveId, setDeletingDetailArchiveId] = useState<string | null>(null);
|
||
const [detailBrief, setDetailBrief] = useState<DetailBriefForm>({
|
||
productSpu: "",
|
||
demandType: "",
|
||
productName: "",
|
||
targetAudience: "",
|
||
mainUser: "",
|
||
usageScenario: "",
|
||
triggerMoment: "",
|
||
coreNeed: "",
|
||
riskConcern: "",
|
||
expectedResult: "",
|
||
coreSellingPoint: "",
|
||
positioningStatement: "",
|
||
userVoice: "",
|
||
competitorReference: "",
|
||
priceBand: "",
|
||
brandTone: "",
|
||
bannedWords: "",
|
||
});
|
||
const [detailModeMessageStartIndex, setDetailModeMessageStartIndex] = useState(0);
|
||
const canvasId = useMemo(() => toRawCanvasChatId(chatId), [chatId]);
|
||
const [activeChatId, setActiveChatId] = useState<string>(chatId);
|
||
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
|
||
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
|
||
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
|
||
const [messageViewportKey, setMessageViewportKey] = useState(0);
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const detailModeSwitchRef = useRef(false);
|
||
const hiddenPromptRef = useRef<string | null>(null);
|
||
const autoContextRef = useRef<string | null>(null);
|
||
const lastWorkflowAutofillSignatureRef = useRef<string | null>(null);
|
||
const lastWorkflowAutofillUrlsRef = useRef<string[]>([]);
|
||
const pendingLaunchRef = useRef<CanvasLaunchPayload | null>(null);
|
||
const [launchAutoSendTick, setLaunchAutoSendTick] = useState(0);
|
||
const hasInitializedChatStateRef = useRef(false);
|
||
const sessionMenuRef = useRef<HTMLDivElement>(null);
|
||
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const forceHistoryHydrationRef = useRef<string | null>(null);
|
||
|
||
// 🆕 标签悬停预览状态
|
||
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
|
||
const previewTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
const [fullscreenPreview, setFullscreenPreview] = useState<{
|
||
url: string;
|
||
alt?: string;
|
||
} | null>(null);
|
||
|
||
// 🆕 图片参数状态
|
||
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
|
||
aspectRatio: "auto",
|
||
imageSize: "2K",
|
||
imageCount: 1,
|
||
});
|
||
const [videoParams, setVideoParams] = useState<VideoGenerationParams | null>(null);
|
||
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
|
||
const [agentSlotUploading, setAgentSlotUploading] = useState<number | null>(null);
|
||
const [lastImageModelId, setLastImageModelId] = useState<string | null>(null);
|
||
const [lastVideoModelId, setLastVideoModelId] = useState<string | null>(null);
|
||
const [lastConversationModelId, setLastConversationModelId] = useState<string | null>(null);
|
||
const [deepThinkingEnabled, setDeepThinkingEnabled] = useState(false);
|
||
const [analysisCollapseState, setAnalysisCollapseState] = useState<Record<string, boolean>>({});
|
||
const [typedAssistantSections, setTypedAssistantSections] = useState<Record<string, boolean>>({});
|
||
const [activeTypingSections, setActiveTypingSections] = useState<Record<string, boolean>>({});
|
||
const [insufficientPointsState, setInsufficientPointsState] = useState<{
|
||
balance: number;
|
||
expectedCost: number;
|
||
unitCost: number;
|
||
imageCount: number;
|
||
} | null>(null);
|
||
const [streamImageGenerationStateByChatId, setStreamImageGenerationStateByChatId] =
|
||
useState<Record<string, ImageGenerationStatusStreamPayload | null>>({});
|
||
const streamImageGenerationState = useMemo(
|
||
() => streamImageGenerationStateByChatId[activeChatId] || null,
|
||
[activeChatId, streamImageGenerationStateByChatId]
|
||
);
|
||
const imageParamsRef = useRef(imageParams);
|
||
const videoParamsRef = useRef(videoParams);
|
||
const deepThinkingEnabledRef = useRef(deepThinkingEnabled);
|
||
const imageAutoTitleCacheRef = useRef<Map<string, string>>(new Map());
|
||
const sendGuardRef = useRef<Record<string, {
|
||
inFlight: boolean;
|
||
signature: string;
|
||
timestamp: number;
|
||
}>>({});
|
||
const storyboardNodeMapRef = useRef<Record<string, Record<number, string>>>({});
|
||
const getSendGuard = useCallback((chatSessionId: string) => {
|
||
return (
|
||
sendGuardRef.current[chatSessionId] || {
|
||
inFlight: false,
|
||
signature: "",
|
||
timestamp: 0,
|
||
}
|
||
);
|
||
}, []);
|
||
|
||
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 previous = getSendGuard(chatSessionId);
|
||
return (
|
||
previous.signature === signature &&
|
||
now - previous.timestamp < 4500
|
||
);
|
||
}, [getSendGuard]);
|
||
|
||
const handleTypewriterActiveChange = useCallback((typingKey: string, active: boolean) => {
|
||
setActiveTypingSections((prev) => {
|
||
if (active) {
|
||
if (prev[typingKey]) return prev;
|
||
return { ...prev, [typingKey]: true };
|
||
}
|
||
if (!prev[typingKey]) return prev;
|
||
const next = { ...prev };
|
||
delete next[typingKey];
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const handleTypewriterComplete = useCallback((typingKey: string) => {
|
||
setTypedAssistantSections((prev) =>
|
||
prev[typingKey] ? prev : { ...prev, [typingKey]: true }
|
||
);
|
||
}, []);
|
||
|
||
const autoNameGeneratedImageNode = useCallback(
|
||
async (nodeId: string, imageUrl: string) => {
|
||
if (!nodeId || !imageUrl) return;
|
||
|
||
const normalizeAutoTitle = (rawTitle: string) => {
|
||
const compact = String(rawTitle || "").replace(/\s+/g, "").trim();
|
||
if (!compact || compact === "新画布" || compact === "未命名画布" || compact.includes("画布")) {
|
||
return "图片";
|
||
}
|
||
return compact.slice(0, 12);
|
||
};
|
||
|
||
const applyNodeTitle = (title: string) => {
|
||
const storeState = useCanvasStore.getState();
|
||
const currentNode = storeState.getNode(nodeId);
|
||
if (!currentNode) return;
|
||
const currentData = ((currentNode.data as any) || {}) as Record<string, any>;
|
||
storeState.updateNode(nodeId, {
|
||
data: {
|
||
...currentData,
|
||
title,
|
||
metadata: {
|
||
...(currentData.metadata || {}),
|
||
autoTitle: title,
|
||
},
|
||
},
|
||
} as any);
|
||
};
|
||
|
||
const cachedTitle = imageAutoTitleCacheRef.current.get(imageUrl);
|
||
if (cachedTitle) {
|
||
applyNodeTitle(cachedTitle);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/canvas/auto-title', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ imageUrl }),
|
||
});
|
||
|
||
const data = await response.json().catch(() => ({}));
|
||
const title = normalizeAutoTitle(String(data?.title || ""));
|
||
imageAutoTitleCacheRef.current.set(imageUrl, title);
|
||
applyNodeTitle(title);
|
||
} catch (error) {
|
||
console.warn('[CanvasChat] 自动命名图片失败:', error);
|
||
applyNodeTitle("图片");
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
// 🆕 保存图片参数到 localStorage
|
||
const handleImageParamsChange = (params: ImageGenerationParams) => {
|
||
setImageParams(params);
|
||
imageParamsRef.current = params;
|
||
try {
|
||
localStorage.setItem("canvas-image-params", JSON.stringify(params));
|
||
} catch (e) {
|
||
console.error("Failed to save image params:", e);
|
||
}
|
||
};
|
||
|
||
// 加载图片参数
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem("canvas-image-params");
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
setImageParams((prev) => {
|
||
const next = { ...prev, ...parsed };
|
||
imageParamsRef.current = next;
|
||
return next;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to load image params:", e);
|
||
}
|
||
}, []);
|
||
|
||
const handleVideoParamsChange = (params: VideoGenerationParams) => {
|
||
setVideoParams(params);
|
||
videoParamsRef.current = params;
|
||
try {
|
||
localStorage.setItem("canvas-video-params", JSON.stringify(params));
|
||
} catch (e) {
|
||
console.error("Failed to save video params:", e);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem("canvas-video-params");
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
setVideoParams(parsed);
|
||
videoParamsRef.current = parsed;
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to load video params:", e);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!fullscreenPreview) return;
|
||
const handleEsc = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
setFullscreenPreview(null);
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handleEsc);
|
||
return () => window.removeEventListener("keydown", handleEsc);
|
||
}, [fullscreenPreview]);
|
||
|
||
useEffect(() => {
|
||
setTypedAssistantSections({});
|
||
setActiveTypingSections({});
|
||
setSelectedSkillTagId(null);
|
||
}, [activeChatId]);
|
||
|
||
useEffect(() => {
|
||
const now = Date.now();
|
||
let rootChatId = chatId;
|
||
|
||
if (chatScope === "agent") {
|
||
try {
|
||
const rootKey = getSessionRootStorageKey(canvasId, chatScope);
|
||
const savedRootChatId = localStorage.getItem(rootKey);
|
||
if (savedRootChatId && savedRootChatId.startsWith("canvas-")) {
|
||
rootChatId = savedRootChatId;
|
||
} else {
|
||
rootChatId = `canvas-${crypto.randomUUID()}`;
|
||
localStorage.setItem(rootKey, rootChatId);
|
||
}
|
||
} catch (error) {
|
||
console.error("[CanvasChat] Failed to initialize agent root chat id:", error);
|
||
rootChatId = `canvas-${crypto.randomUUID()}`;
|
||
}
|
||
}
|
||
|
||
const fallbackSession: CanvasChatSession = {
|
||
id: rootChatId,
|
||
title: "对话 1",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
try {
|
||
const key = getSessionStorageKey(canvasId, chatScope);
|
||
const saved = localStorage.getItem(key);
|
||
const parsed = saved ? (JSON.parse(saved) as CanvasChatSession[]) : [];
|
||
|
||
const sanitized = parsed
|
||
.filter((session) => session?.id)
|
||
.map((session) => ({
|
||
id: session.id,
|
||
title: (session.title || "").trim() || "新对话",
|
||
createdAt: Number(session.createdAt) || now,
|
||
updatedAt: Number(session.updatedAt) || now,
|
||
}));
|
||
|
||
const hasCurrent = sanitized.some((session) => session.id === rootChatId);
|
||
const nextSessions = hasCurrent
|
||
? sanitized
|
||
: [fallbackSession, ...sanitized];
|
||
|
||
const sorted = [...nextSessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||
setChatSessions(sorted);
|
||
setActiveChatId(rootChatId);
|
||
localStorage.setItem(key, JSON.stringify(sorted));
|
||
} catch (error) {
|
||
console.error("[CanvasChat] Failed to load chat sessions:", error);
|
||
setChatSessions([fallbackSession]);
|
||
setActiveChatId(rootChatId);
|
||
}
|
||
}, [canvasId, chatId, chatScope]);
|
||
|
||
useEffect(() => {
|
||
if (!isSessionMenuOpen) return;
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (!sessionMenuRef.current?.contains(event.target as Node)) {
|
||
setIsSessionMenuOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [isSessionMenuOpen]);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(
|
||
getSessionStorageKey(canvasId, chatScope),
|
||
JSON.stringify(chatSessions)
|
||
);
|
||
} catch (error) {
|
||
console.error("[CanvasChat] Failed to save chat sessions:", error);
|
||
}
|
||
}, [canvasId, chatScope, chatSessions]);
|
||
|
||
// Canvas store
|
||
const addNode = useCanvasStore((state) => state.addNode);
|
||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||
const nodes = useCanvasStore((state) => state.nodes);
|
||
const projectTitle = useCanvasStore((state) => state.projectTitle);
|
||
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
|
||
const clearSelection = useCanvasStore((state) => state.clearSelection);
|
||
const pendingChatAttachments = useCanvasStore((state) => state.pendingChatAttachments);
|
||
const clearPendingChatAttachments = useCanvasStore((state) => state.clearPendingChatAttachments);
|
||
// 🆕 物品拾取
|
||
const pendingPickedObjects = useCanvasStore((state) => state.pendingPickedObjects);
|
||
const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects);
|
||
|
||
// 获取纯 UUID(去掉 canvas- 前缀)用于 API 调用
|
||
const rawChatId = toRawCanvasChatId(activeChatId);
|
||
const voteSWRKey = rawChatId ? `/api/vote?chatId=${rawChatId}` : null;
|
||
const { mutate } = useSWRConfig();
|
||
const [votingMessageId, setVotingMessageId] = useState<string | null>(null);
|
||
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
|
||
const raw = String(imageUrl || "").trim();
|
||
if (!raw) {
|
||
toast.error("图片地址无效");
|
||
return;
|
||
}
|
||
|
||
const filename = buildDownloadFilename(raw);
|
||
try {
|
||
const response = await fetch(raw);
|
||
if (!response.ok) {
|
||
throw new Error(`download failed: ${response.status}`);
|
||
}
|
||
const blob = await response.blob();
|
||
const blobUrl = window.URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = blobUrl;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(blobUrl);
|
||
return;
|
||
} catch {
|
||
const link = document.createElement("a");
|
||
link.href = raw;
|
||
link.target = "_blank";
|
||
link.rel = "noopener noreferrer";
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
toast.message("已打开原图,可右键另存为");
|
||
}
|
||
}, []);
|
||
|
||
// 从 API 加载历史消息
|
||
const { data: historyData } = useSWR<{
|
||
messages: ChatMessage[];
|
||
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher);
|
||
const { data: messageVotes } = useSWR<MessageVote[]>(
|
||
voteSWRKey,
|
||
fetcher
|
||
);
|
||
|
||
// 获取选中的图片节点
|
||
const selectedImageNodes = nodes.filter(
|
||
n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src
|
||
);
|
||
|
||
const handleAssistantVote = useCallback(
|
||
async (messageId: string, type: "up" | "down") => {
|
||
if (!rawChatId) return;
|
||
|
||
setVotingMessageId(messageId);
|
||
try {
|
||
const response = await fetch("/api/vote", {
|
||
method: "PATCH",
|
||
body: JSON.stringify({
|
||
chatId: rawChatId,
|
||
messageId,
|
||
type,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error("Vote failed");
|
||
}
|
||
|
||
mutate<MessageVote[]>(
|
||
voteSWRKey,
|
||
(currentVotes) => {
|
||
const baseVotes = Array.isArray(currentVotes) ? currentVotes : [];
|
||
const filteredVotes = baseVotes.filter(
|
||
(vote) => vote.messageId !== messageId
|
||
);
|
||
return [
|
||
...filteredVotes,
|
||
{
|
||
chatId: rawChatId,
|
||
messageId,
|
||
isUpvoted: type === "up",
|
||
},
|
||
];
|
||
},
|
||
{ revalidate: false }
|
||
);
|
||
|
||
toast.success(type === "up" ? "已点赞" : "已点踩");
|
||
} catch (error) {
|
||
console.error("[CanvasChat] vote failed:", error);
|
||
toast.error(type === "up" ? "点赞失败,请稍后重试" : "点踩失败,请稍后重试");
|
||
} finally {
|
||
setVotingMessageId(null);
|
||
}
|
||
},
|
||
[mutate, rawChatId, voteSWRKey]
|
||
);
|
||
|
||
const getCanvasNodeAttachment = useCallback(
|
||
(node: any, fallbackName: string): PickedObjectAttachment | null => {
|
||
const src = (node?.data as any)?.src;
|
||
if (!src || typeof src !== "string") return null;
|
||
|
||
const nodeTitle =
|
||
String((node?.data as any)?.title || "").trim() ||
|
||
String((node?.data as any)?.prompt || "").trim() ||
|
||
fallbackName;
|
||
|
||
return {
|
||
url: src,
|
||
name: nodeTitle,
|
||
contentType: "image/jpeg",
|
||
assetId:
|
||
typeof (node?.data as any)?.assetId === "string" && (node?.data as any)?.assetId.trim()
|
||
? String((node?.data as any)?.assetId).trim()
|
||
: undefined,
|
||
};
|
||
},
|
||
[]
|
||
);
|
||
|
||
// 🆕 辅助函数:在 contenteditable 中插入图片标签
|
||
const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => {
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (!editableDiv || !editableDiv.isContentEditable) return;
|
||
|
||
// 检查 DOM 中是否已存在该 URL 的标签(如果存在则不重复添加)
|
||
const existing = editableDiv.querySelector(`[data-attachment-url="${url}"]`);
|
||
if (existing) {
|
||
console.log('[CanvasChat] 标签已存在于 DOM 中,跳过:', url.substring(0, 50));
|
||
return;
|
||
}
|
||
|
||
const tagSpan = document.createElement('span');
|
||
tagSpan.contentEditable = 'false';
|
||
tagSpan.setAttribute('data-attachment-index', String(index));
|
||
tagSpan.setAttribute('data-attachment-url', url);
|
||
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
|
||
const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name;
|
||
tagSpan.innerHTML = `
|
||
<img src="${url}" alt="" class="h-5 w-5 object-cover rounded-full border border-border/50" />
|
||
<span class="text-xs text-foreground/80">${shortName}</span>
|
||
`;
|
||
|
||
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
|
||
const selection = window.getSelection();
|
||
if (selection && selection.rangeCount > 0 && editableDiv.contains(selection.anchorNode)) {
|
||
const range = selection.getRangeAt(0);
|
||
range.deleteContents();
|
||
const spaceNode = document.createTextNode("\u200b");
|
||
range.insertNode(spaceNode);
|
||
range.insertNode(tagSpan);
|
||
range.setStartAfter(spaceNode);
|
||
range.setEndAfter(spaceNode);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
} else {
|
||
editableDiv.appendChild(tagSpan);
|
||
editableDiv.appendChild(document.createTextNode("\u200b"));
|
||
}
|
||
|
||
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50));
|
||
}, []);
|
||
|
||
// 🆕 辅助函数:在 contenteditable 中插入物品标签(带图片缩略图,样式与普通图片标签一致)
|
||
const insertPickedObjectTagInEditor = useCallback((
|
||
url: string,
|
||
objectName: string,
|
||
objectPosition: string,
|
||
index: number
|
||
) => {
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (!editableDiv || !editableDiv.isContentEditable) return;
|
||
|
||
// 检查 DOM 中是否已存在该物品的标签
|
||
const existingTags = editableDiv.querySelectorAll('[data-picked-object="true"]');
|
||
for (const tag of existingTags) {
|
||
if (tag.getAttribute('data-attachment-url') === url &&
|
||
tag.getAttribute('data-object-name') === objectName) {
|
||
console.log('[CanvasChat] 物品标签已存在,跳过');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const tagSpan = document.createElement('span');
|
||
tagSpan.contentEditable = 'false';
|
||
tagSpan.setAttribute('data-attachment-index', String(index));
|
||
tagSpan.setAttribute('data-attachment-url', url);
|
||
tagSpan.setAttribute('data-picked-object', 'true');
|
||
tagSpan.setAttribute('data-object-name', objectName);
|
||
tagSpan.setAttribute('data-object-position', objectPosition);
|
||
// 样式与普通图片标签一致
|
||
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
|
||
// 使用图片缩略图,不使用 emoji 或图标
|
||
tagSpan.innerHTML = `
|
||
<img src="${url}" alt="" class="h-5 w-5 object-cover rounded-full border border-border/50" />
|
||
<span class="text-xs text-foreground/80">${objectName}</span>
|
||
<span class="text-xs text-foreground/50">·</span>
|
||
<span class="text-xs text-foreground/70">${objectPosition}</span>
|
||
`;
|
||
|
||
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
|
||
const selection = window.getSelection();
|
||
if (selection && selection.rangeCount > 0 && editableDiv.contains(selection.anchorNode)) {
|
||
const range = selection.getRangeAt(0);
|
||
range.deleteContents();
|
||
const spaceNode = document.createTextNode("\u200b");
|
||
range.insertNode(spaceNode);
|
||
range.insertNode(tagSpan);
|
||
range.setStartAfter(spaceNode);
|
||
range.setEndAfter(spaceNode);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
} else {
|
||
editableDiv.appendChild(tagSpan);
|
||
editableDiv.appendChild(document.createTextNode("\u200b"));
|
||
}
|
||
|
||
console.log('[CanvasChat] 已插入物品标签:', objectName, objectPosition);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
|
||
try {
|
||
const storageKey = getCanvasLaunchStorageKey(canvasId);
|
||
const raw = sessionStorage.getItem(storageKey);
|
||
if (!raw) return;
|
||
|
||
sessionStorage.removeItem(storageKey);
|
||
const parsed = JSON.parse(raw) as CanvasLaunchPayload;
|
||
const normalizedAttachments = Array.isArray(parsed.attachments)
|
||
? parsed.attachments
|
||
.filter((item) => item?.url)
|
||
.map((item) => ({
|
||
url: String(item.url),
|
||
name: String(item.name || "参考图"),
|
||
contentType: String(item.contentType || "image/jpeg"),
|
||
assetId:
|
||
typeof item.assetId === "string" && item.assetId.trim()
|
||
? item.assetId.trim()
|
||
: undefined,
|
||
}))
|
||
: [];
|
||
const normalizedPrompt = String(parsed.prompt || "").trim();
|
||
|
||
if (!normalizedPrompt && normalizedAttachments.length === 0) {
|
||
return;
|
||
}
|
||
|
||
setAttachments(normalizedAttachments as PickedObjectAttachment[]);
|
||
setInput(normalizedPrompt);
|
||
|
||
if (parsed.selectedModelId) {
|
||
setSelectedModelId(String(parsed.selectedModelId));
|
||
}
|
||
|
||
if (parsed.imageParams && typeof parsed.imageParams === "object") {
|
||
setImageParams((prev) => {
|
||
const next = {
|
||
...prev,
|
||
...parsed.imageParams,
|
||
};
|
||
imageParamsRef.current = next;
|
||
return next;
|
||
});
|
||
}
|
||
|
||
if (!parsed.autoSend) {
|
||
setTimeout(() => {
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (editableDiv) {
|
||
editableDiv.innerHTML = "";
|
||
if (normalizedPrompt) {
|
||
editableDiv.appendChild(document.createTextNode(normalizedPrompt));
|
||
const selection = window.getSelection();
|
||
if (selection) {
|
||
const range = document.createRange();
|
||
range.selectNodeContents(editableDiv);
|
||
range.collapse(false);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
}
|
||
}
|
||
}
|
||
|
||
normalizedAttachments.forEach((attachment, index) => {
|
||
insertImageTagInEditor(attachment.url, attachment.name, index);
|
||
});
|
||
}, 50);
|
||
}
|
||
|
||
if (parsed.autoSend) {
|
||
pendingLaunchRef.current = {
|
||
prompt: normalizedPrompt,
|
||
attachments: normalizedAttachments,
|
||
autoSend: true,
|
||
createdAt: parsed.createdAt,
|
||
selectedModelId: parsed.selectedModelId,
|
||
imageParams: parsed.imageParams,
|
||
};
|
||
setLaunchAutoSendTick((value) => value + 1);
|
||
}
|
||
|
||
normalizedAttachments.forEach(async (attachment, index) => {
|
||
const dimensions = await getImageDimensions(attachment.url);
|
||
addNode({
|
||
type: "image",
|
||
position: {
|
||
x: -260 + index * 80,
|
||
y: -120 + (index % 2) * 40,
|
||
},
|
||
size: {
|
||
width: dimensions.width,
|
||
height: dimensions.height,
|
||
},
|
||
data: {
|
||
src: attachment.url,
|
||
status: "completed",
|
||
title: attachment.name || `参考图 ${index + 1}`,
|
||
prompt: normalizedPrompt || "",
|
||
metadata: {
|
||
originalWidth: dimensions.originalWidth,
|
||
originalHeight: dimensions.originalHeight,
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
});
|
||
} catch (error) {
|
||
console.error("[CanvasChat] Failed to restore launch payload:", error);
|
||
}
|
||
}, [canvasId, addNode, insertImageTagInEditor]);
|
||
|
||
// 🆕 监听 pendingPickedObjects,当用户确认拾取物品时添加标签
|
||
useEffect(() => {
|
||
if (pendingPickedObjects.length > 0) {
|
||
const currentAttachments = [...attachments];
|
||
|
||
pendingPickedObjects.forEach((picked, i) => {
|
||
// 检查是否已存在相同的物品
|
||
const exists = currentAttachments.some(a =>
|
||
a.url === picked.imageUrl &&
|
||
a.pickedObject?.objectName === picked.objectName
|
||
);
|
||
|
||
if (!exists) {
|
||
// 插入物品标签到编辑器
|
||
insertPickedObjectTagInEditor(
|
||
picked.imageUrl,
|
||
picked.objectName,
|
||
picked.objectPosition,
|
||
currentAttachments.length + i
|
||
);
|
||
|
||
// 添加到附件列表(带物品信息,原图片 URL 会发送给 AI 用于多图融合编辑)
|
||
currentAttachments.push({
|
||
url: picked.imageUrl, // 原图片 URL
|
||
name: `${picked.objectName}-${picked.objectPosition}`, // 物品名称-位置
|
||
contentType: 'image/jpeg',
|
||
pickedObject: {
|
||
objectName: picked.objectName,
|
||
objectPosition: picked.objectPosition,
|
||
clickX: picked.clickX,
|
||
clickY: picked.clickY,
|
||
boundingBox: picked.boundingBox,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
setAttachments(currentAttachments);
|
||
|
||
// 聚焦到输入框
|
||
setTimeout(() => {
|
||
(textareaRef.current as HTMLDivElement | null)?.focus();
|
||
}, 0);
|
||
|
||
// 清空待添加列表
|
||
clearPendingPickedObjects();
|
||
}
|
||
}, [pendingPickedObjects, attachments, insertPickedObjectTagInEditor, clearPendingPickedObjects]);
|
||
|
||
// 🆕 标签悬停预览 - 使用全局事件监听
|
||
useEffect(() => {
|
||
let lastTagElement: HTMLElement | null = null;
|
||
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
const target = e.target as HTMLElement;
|
||
const tagSpan = target.closest('[data-attachment-url]') as HTMLElement | null;
|
||
|
||
// 检查是否在预览弹窗内
|
||
const inPreview = target.closest('[data-tag-preview]');
|
||
if (inPreview) {
|
||
// 在预览弹窗内,保持显示
|
||
if (previewTimeoutRef.current) {
|
||
clearTimeout(previewTimeoutRef.current);
|
||
previewTimeoutRef.current = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (tagSpan && tagSpan !== lastTagElement) {
|
||
lastTagElement = tagSpan;
|
||
|
||
// 清除之前的延迟隐藏
|
||
if (previewTimeoutRef.current) {
|
||
clearTimeout(previewTimeoutRef.current);
|
||
previewTimeoutRef.current = null;
|
||
}
|
||
|
||
const url = tagSpan.getAttribute('data-attachment-url');
|
||
const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true';
|
||
const objectName = tagSpan.getAttribute('data-object-name');
|
||
|
||
if (url) {
|
||
const rect = tagSpan.getBoundingClientRect();
|
||
// 从 attachments 中获取物品信息
|
||
const attachment = attachments.find(a =>
|
||
a.url === url &&
|
||
(isPickedObject ? a.pickedObject?.objectName === objectName : true)
|
||
);
|
||
|
||
// 获取聊天面板容器边界(向上查找最近的面板容器)
|
||
const panelContainer = tagSpan.closest('.flex.flex-col.h-full') as HTMLElement | null;
|
||
let containerBounds: TagPreviewInfo['containerBounds'] = undefined;
|
||
if (panelContainer) {
|
||
const panelRect = panelContainer.getBoundingClientRect();
|
||
containerBounds = {
|
||
left: panelRect.left,
|
||
right: panelRect.right,
|
||
top: panelRect.top,
|
||
bottom: panelRect.bottom,
|
||
};
|
||
}
|
||
|
||
setTagPreview({
|
||
url,
|
||
name: objectName || tagSpan.textContent || '图片',
|
||
x: rect.left + rect.width / 2,
|
||
y: rect.top,
|
||
containerBounds,
|
||
pickedObject: attachment?.pickedObject ? {
|
||
objectName: attachment.pickedObject.objectName,
|
||
objectPosition: attachment.pickedObject.objectPosition,
|
||
clickX: attachment.pickedObject.clickX,
|
||
clickY: attachment.pickedObject.clickY,
|
||
} : undefined,
|
||
});
|
||
}
|
||
} else if (!tagSpan && lastTagElement) {
|
||
// 离开标签
|
||
lastTagElement = null;
|
||
previewTimeoutRef.current = setTimeout(() => {
|
||
setTagPreview(null);
|
||
}, 200);
|
||
}
|
||
};
|
||
|
||
document.addEventListener('mousemove', handleMouseMove);
|
||
|
||
return () => {
|
||
document.removeEventListener('mousemove', handleMouseMove);
|
||
if (previewTimeoutRef.current) {
|
||
clearTimeout(previewTimeoutRef.current);
|
||
}
|
||
};
|
||
}, [attachments]);
|
||
|
||
// 获取模型列表
|
||
const { data: modelsData } = useSWR<{
|
||
models: Array<{
|
||
id: string;
|
||
modelId: string;
|
||
displayName: string;
|
||
avatarUrl?: string | null;
|
||
sortOrder?: number;
|
||
providerType: string;
|
||
supportsVision?: boolean;
|
||
supportsImageGen?: boolean;
|
||
isMultimodal?: boolean;
|
||
capabilities?: any;
|
||
videoToolCapability?: VideoToolCapabilityContract | null;
|
||
imageGenConfig?: {
|
||
maxImages?: number;
|
||
supportsSize?: boolean;
|
||
supports1K?: boolean;
|
||
supports2K?: boolean;
|
||
supports4K?: boolean;
|
||
supportsPromptExtend?: boolean;
|
||
supportsNegativePrompt?: boolean;
|
||
supportsWatermark?: boolean;
|
||
supportsSeed?: boolean;
|
||
creditCostPerImage?: number;
|
||
} | null;
|
||
}>;
|
||
agentModel?: {
|
||
id: string;
|
||
modelId?: string;
|
||
displayName: string;
|
||
avatarUrl?: string | null;
|
||
providerType: string;
|
||
supportsVision?: boolean;
|
||
supportsImageGen?: boolean;
|
||
isMultimodal?: boolean;
|
||
imageGenConfig?: {
|
||
maxImages?: number;
|
||
supportsSize?: boolean;
|
||
supports1K?: boolean;
|
||
supports2K?: boolean;
|
||
supports4K?: boolean;
|
||
supportsPromptExtend?: boolean;
|
||
supportsNegativePrompt?: boolean;
|
||
supportsWatermark?: boolean;
|
||
supportsSeed?: boolean;
|
||
creditCostPerImage?: number;
|
||
} | null;
|
||
} | null;
|
||
}>("/api/models", fetcher);
|
||
|
||
// 获取网站设置(包含广告横幅配置和画布设置)
|
||
const { data: siteSettings } = useSWR<{
|
||
bannerEnabled?: boolean;
|
||
bannerText?: string;
|
||
bannerLink?: string | null;
|
||
lovartComposerPrefix?: string | null;
|
||
lovartComposerPreviewTexts?: string[];
|
||
canvasEnabled?: boolean;
|
||
aiDetailGenerationEnabled?: boolean;
|
||
canvasDefaultModelId?: string | null;
|
||
canvasAutoModeDefaultModelId?: string | null;
|
||
canvasDetailDefaultModelId?: string | null;
|
||
}>("/api/site-settings", fetcher);
|
||
const { data: canvasSkillTagsData } = useSWR<{
|
||
enabled?: boolean;
|
||
title?: string;
|
||
tags?: CanvasSkillTag[];
|
||
}>(
|
||
"/api/canvas-skill-tags",
|
||
fetcher
|
||
);
|
||
const { data: membershipSummary } = useSWR<{
|
||
isHighVip?: boolean;
|
||
pointsSystemConfig?: {
|
||
enabled?: boolean;
|
||
blockOnInsufficient?: boolean;
|
||
};
|
||
summary?: {
|
||
currentCredits?: number;
|
||
};
|
||
} | null>("/api/user/membership-summary", async (url: string) => {
|
||
const response = await fetch(url, { cache: "no-store" });
|
||
if (!response.ok) return null;
|
||
return response.json();
|
||
});
|
||
const { data: userImageStats } = useSWR<{ totalImages?: number } | null>(
|
||
"/api/user/image-stats",
|
||
async (url: string) => {
|
||
const response = await fetch(url, { cache: "no-store" });
|
||
if (!response.ok) return null;
|
||
return response.json();
|
||
}
|
||
);
|
||
const { data: agentPresetResponse } = useSWR<{ success?: boolean; data?: AgentPreset }>(
|
||
selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null,
|
||
fetcher
|
||
);
|
||
const greetingUserName = useMemo(() => {
|
||
const userName = String(session?.user?.name || "").trim();
|
||
if (userName) return userName;
|
||
const email = String(session?.user?.email || "").trim();
|
||
if (email.includes("@")) {
|
||
return email.split("@")[0];
|
||
}
|
||
return "";
|
||
}, [session?.user?.email, session?.user?.name]);
|
||
const [canvasTypedPreviewText, setCanvasTypedPreviewText] = useState("");
|
||
const [canvasPreviewCursorVisible, setCanvasPreviewCursorVisible] = useState(true);
|
||
const [greetingPeriodText, setGreetingPeriodText] = useState("中午好!");
|
||
useEffect(() => {
|
||
const hour = new Date().getHours();
|
||
if (hour >= 5 && hour < 11) {
|
||
setGreetingPeriodText("早上好!");
|
||
return;
|
||
}
|
||
if (hour >= 11 && hour < 18) {
|
||
setGreetingPeriodText("中午好!");
|
||
return;
|
||
}
|
||
if (hour >= 18) {
|
||
setGreetingPeriodText("晚上好!");
|
||
return;
|
||
}
|
||
setGreetingPeriodText("深夜好!");
|
||
}, []);
|
||
const canvasComposerPrefix = useMemo(
|
||
() =>
|
||
String(siteSettings?.lovartComposerPrefix || "").trim() ||
|
||
CANVAS_COMPOSER_PREFIX_DEFAULT,
|
||
[siteSettings?.lovartComposerPrefix]
|
||
);
|
||
const canvasComposerPreviewTexts = useMemo(() => {
|
||
const configured = (siteSettings?.lovartComposerPreviewTexts || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
return configured.length > 0
|
||
? configured
|
||
: CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT;
|
||
}, [siteSettings?.lovartComposerPreviewTexts]);
|
||
const canvasComposerPreviewTails = useMemo(
|
||
() =>
|
||
canvasComposerPreviewTexts
|
||
.map((text) => normalizeComposerPreviewTail(text, canvasComposerPrefix))
|
||
.filter(Boolean),
|
||
[canvasComposerPrefix, canvasComposerPreviewTexts]
|
||
);
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
if (canvasComposerPreviewTails.length === 0) {
|
||
setCanvasTypedPreviewText("");
|
||
return () => {
|
||
if (timer) clearTimeout(timer);
|
||
};
|
||
}
|
||
|
||
let textIndex = 0;
|
||
let charIndex = 0;
|
||
|
||
const typeForward = () => {
|
||
if (disposed) return;
|
||
const currentText = canvasComposerPreviewTails[textIndex] || "";
|
||
|
||
if (charIndex < currentText.length) {
|
||
charIndex += 1;
|
||
setCanvasTypedPreviewText(currentText.slice(0, charIndex));
|
||
timer = setTimeout(typeForward, 55);
|
||
return;
|
||
}
|
||
|
||
if (canvasComposerPreviewTails.length === 1) {
|
||
setCanvasTypedPreviewText(currentText);
|
||
return;
|
||
}
|
||
|
||
timer = setTimeout(typeBackward, 1200);
|
||
};
|
||
|
||
const typeBackward = () => {
|
||
if (disposed) return;
|
||
const currentText = canvasComposerPreviewTails[textIndex] || "";
|
||
|
||
if (charIndex > 0) {
|
||
charIndex -= 1;
|
||
setCanvasTypedPreviewText(currentText.slice(0, charIndex));
|
||
timer = setTimeout(typeBackward, 35);
|
||
return;
|
||
}
|
||
|
||
textIndex = (textIndex + 1) % canvasComposerPreviewTails.length;
|
||
timer = setTimeout(typeForward, 260);
|
||
};
|
||
|
||
setCanvasTypedPreviewText("");
|
||
timer = setTimeout(typeForward, 260);
|
||
|
||
return () => {
|
||
disposed = true;
|
||
if (timer) clearTimeout(timer);
|
||
};
|
||
}, [canvasComposerPreviewTails]);
|
||
|
||
useEffect(() => {
|
||
const blinkTimer = setInterval(() => {
|
||
setCanvasPreviewCursorVisible((value) => !value);
|
||
}, 450);
|
||
|
||
return () => clearInterval(blinkTimer);
|
||
}, []);
|
||
|
||
const canvasDynamicPlaceholder = `${canvasComposerPrefix}${canvasTypedPreviewText}${
|
||
canvasPreviewCursorVisible ? " |" : ""
|
||
}`;
|
||
const totalGeneratedImages = Number(userImageStats?.totalImages ?? NaN);
|
||
const aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true;
|
||
const shouldShowCanvasBanner =
|
||
!isDetailGenerationMode &&
|
||
(siteSettings?.bannerEnabled ?? true) &&
|
||
Boolean(siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!");
|
||
const canvasSkillTagsEnabled = canvasSkillTagsData?.enabled !== false;
|
||
const canvasSkillTagsTitle =
|
||
String(canvasSkillTagsData?.title || "").trim() || DEFAULT_CANVAS_SKILL_TAGS_TITLE;
|
||
const canvasSkillTags = useMemo(
|
||
() => (canvasSkillTagsEnabled ? normalizeCanvasSkillTags(canvasSkillTagsData?.tags) : []),
|
||
[canvasSkillTagsData?.tags, canvasSkillTagsEnabled]
|
||
);
|
||
const selectedSkillTag = useMemo(
|
||
() => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null,
|
||
[canvasSkillTags, selectedSkillTagId]
|
||
);
|
||
const selectedAgentPreset = useMemo(
|
||
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
|
||
[agentPresetResponse]
|
||
);
|
||
const workflowReferenceImages = workflowContext?.referenceImages || [];
|
||
const hasWorkflowContext = Boolean(
|
||
workflowContext &&
|
||
(String(workflowContext.summaryText || "").trim() ||
|
||
String(workflowContext.promptText || "").trim() ||
|
||
workflowContext.selections.length > 0 ||
|
||
workflowReferenceImages.length > 0)
|
||
);
|
||
const SelectedSkillTagIcon = useMemo(
|
||
() => getCanvasSkillIconComponent(selectedSkillTag?.iconName),
|
||
[selectedSkillTag?.iconName]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!selectedSkillTagId) return;
|
||
if (!canvasSkillTags.some((tag) => tag.id === selectedSkillTagId)) {
|
||
setSelectedSkillTagId(null);
|
||
}
|
||
}, [canvasSkillTags, selectedSkillTagId]);
|
||
|
||
useEffect(() => {
|
||
if (!isWorkspacePresentation) return;
|
||
if (!selectedSkillTagId) return;
|
||
setSelectedSkillTagId(null);
|
||
}, [isWorkspacePresentation, selectedSkillTagId]);
|
||
|
||
const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video", "ruxa-video"]);
|
||
const visualModels = useMemo(() => {
|
||
const filtered =
|
||
modelsData?.models?.filter(
|
||
(model) =>
|
||
model.modelId !== "volcano-video-master" &&
|
||
(
|
||
model.supportsImageGen ||
|
||
model.isMultimodal ||
|
||
VIDEO_PROVIDER_TYPES.has(model.providerType)
|
||
)
|
||
) || [];
|
||
|
||
return [...filtered].sort((a, b) => {
|
||
const leftOrder = Number.isFinite(a.sortOrder as number) ? Number(a.sortOrder) : 0;
|
||
const rightOrder = Number.isFinite(b.sortOrder as number) ? Number(b.sortOrder) : 0;
|
||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||
return String(a.displayName || "").localeCompare(String(b.displayName || ""), "zh-Hans-CN");
|
||
});
|
||
}, [modelsData?.models]);
|
||
|
||
// Agent模式(自动路由)模型选项
|
||
const SMART_AUTO_MODEL = {
|
||
id: "auto",
|
||
modelId: "auto",
|
||
displayName: "Agent模式",
|
||
avatarUrl: modelsData?.agentModel?.avatarUrl || null,
|
||
sortOrder: -999,
|
||
providerType: "auto",
|
||
supportsImageGen: true,
|
||
isMultimodal: false,
|
||
videoToolCapability: null,
|
||
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
|
||
};
|
||
const multimodalAgentModel = useMemo(() => {
|
||
if (!modelsData?.agentModel?.id || !modelsData.agentModel.isMultimodal) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: modelsData.agentModel.id,
|
||
modelId: modelsData.agentModel.modelId || modelsData.agentModel.id,
|
||
displayName: modelsData.agentModel.displayName,
|
||
avatarUrl: modelsData.agentModel.avatarUrl || null,
|
||
sortOrder: -998,
|
||
providerType: modelsData.agentModel.providerType,
|
||
supportsVision: modelsData.agentModel.supportsVision,
|
||
supportsImageGen: modelsData.agentModel.supportsImageGen,
|
||
isMultimodal: true,
|
||
videoToolCapability: null,
|
||
imageGenConfig: modelsData.agentModel.imageGenConfig || undefined,
|
||
};
|
||
}, [modelsData?.agentModel]);
|
||
|
||
// 所有可选模型(Agent模式 + 可视化模型)
|
||
const allModels = useMemo(() => {
|
||
const merged = [
|
||
SMART_AUTO_MODEL,
|
||
...(multimodalAgentModel ? [multimodalAgentModel] : []),
|
||
...visualModels,
|
||
];
|
||
const seen = new Set<string>();
|
||
return merged.filter((model) => {
|
||
if (!model?.id || seen.has(model.id)) {
|
||
return false;
|
||
}
|
||
seen.add(model.id);
|
||
return true;
|
||
});
|
||
}, [SMART_AUTO_MODEL, multimodalAgentModel, visualModels]);
|
||
const imageOnlyModels = useMemo(
|
||
() =>
|
||
visualModels.filter(
|
||
(model) =>
|
||
model.supportsImageGen &&
|
||
!model.isMultimodal &&
|
||
!VIDEO_PROVIDER_TYPES.has(model.providerType)
|
||
),
|
||
[visualModels]
|
||
);
|
||
const conversationOnlyModels = useMemo(
|
||
() =>
|
||
allModels.filter(
|
||
(model) =>
|
||
model.id !== "auto" &&
|
||
model.id !== modelsData?.agentModel?.id &&
|
||
model.isMultimodal &&
|
||
!VIDEO_PROVIDER_TYPES.has(model.providerType)
|
||
),
|
||
[allModels, modelsData?.agentModel?.id]
|
||
);
|
||
const videoOnlyModels = useMemo(
|
||
() => visualModels.filter((model) => VIDEO_PROVIDER_TYPES.has(model.providerType)),
|
||
[visualModels]
|
||
);
|
||
const currentModel = allModels.find((model) => model.id === selectedModelId);
|
||
const isVideoModel = Boolean(currentModel && VIDEO_PROVIDER_TYPES.has(currentModel.providerType));
|
||
const isConversationModel = Boolean(
|
||
currentModel &&
|
||
currentModel.id !== "auto" &&
|
||
currentModel.isMultimodal &&
|
||
!VIDEO_PROVIDER_TYPES.has(currentModel.providerType)
|
||
);
|
||
const isAgentModel =
|
||
!isDetailGenerationMode &&
|
||
(!selectedModelId || selectedModelId === "auto" || isConversationModel);
|
||
const computedComposerMode = useMemo<"agent" | "image" | "video" | "detail">(() => {
|
||
if (isDetailGenerationMode) return "detail";
|
||
if (isVideoModel) return "video";
|
||
if (isAgentModel) return "agent";
|
||
return "image";
|
||
}, [isAgentModel, isDetailGenerationMode, isVideoModel]);
|
||
const [composerModeOverride, setComposerModeOverride] = useState<
|
||
"agent" | "image" | "video" | "detail" | null
|
||
>(null);
|
||
const agentSelectableModels = useMemo(
|
||
() => [SMART_AUTO_MODEL, ...conversationOnlyModels],
|
||
[SMART_AUTO_MODEL, conversationOnlyModels]
|
||
);
|
||
const selectableModels = useMemo(() => {
|
||
if (isDetailGenerationMode) {
|
||
return [SMART_AUTO_MODEL];
|
||
}
|
||
if (isVideoModel) {
|
||
return videoOnlyModels;
|
||
}
|
||
if (isAgentModel) {
|
||
return agentSelectableModels;
|
||
}
|
||
return imageOnlyModels;
|
||
}, [
|
||
SMART_AUTO_MODEL,
|
||
agentSelectableModels,
|
||
imageOnlyModels,
|
||
isAgentModel,
|
||
isDetailGenerationMode,
|
||
isVideoModel,
|
||
videoOnlyModels,
|
||
]);
|
||
const modelSelectValue = selectableModels.some((model) => model.id === selectedModelId)
|
||
? selectedModelId
|
||
: selectableModels[0]?.id || selectedModelId;
|
||
const selectDisplayModel =
|
||
selectableModels.find((model) => model.id === modelSelectValue) ||
|
||
currentModel ||
|
||
SMART_AUTO_MODEL;
|
||
const agentDisplayName =
|
||
currentModel?.displayName || selectDisplayModel?.displayName || "Agent模式";
|
||
const videoFrameSlots = useMemo(() => {
|
||
if (!isVideoModel || !videoParams) return [] as string[];
|
||
if (videoParams.mode === "first-last-frame") return ["首帧", "尾帧"] as string[];
|
||
if (videoParams.mode === "first-frame") return ["首帧"] as string[];
|
||
return [] as string[];
|
||
}, [isVideoModel, videoParams]);
|
||
const activeComposerMode = composerModeOverride || computedComposerMode;
|
||
const agentUploadSlots = useMemo(() => {
|
||
if (
|
||
activeComposerMode !== "agent" ||
|
||
!selectedAgentPreset ||
|
||
selectedAgentPreset.type === "text"
|
||
) {
|
||
return [] as Array<{
|
||
slotIndex: number;
|
||
label: string;
|
||
}>;
|
||
}
|
||
|
||
return [
|
||
{ slotIndex: 0, label: "产品图1" },
|
||
{ slotIndex: 1, label: "产品图2" },
|
||
{ slotIndex: 2, label: "模特图" },
|
||
];
|
||
}, [activeComposerMode, selectedAgentPreset]);
|
||
const hasAgentUploadSlots =
|
||
activeComposerMode === "agent" && agentUploadSlots.length > 0;
|
||
const agentRequiredUploadCount = hasAgentUploadSlots ? 3 : 0;
|
||
|
||
// 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签
|
||
useEffect(() => {
|
||
if (pendingChatAttachments.length === 0) return;
|
||
|
||
if (isDetailGenerationMode) {
|
||
const normalized = pendingChatAttachments
|
||
.filter((item) => item?.url)
|
||
.map((item) => ({
|
||
url: item.url,
|
||
name: item.name || "素材图",
|
||
contentType: item.contentType || "image/jpeg",
|
||
})) as PickedObjectAttachment[];
|
||
|
||
if (normalized.length > 0) {
|
||
let inserted = 0;
|
||
const targetSlotIndex = detailSlotPickerIndex;
|
||
|
||
setDetailSlots((current) => {
|
||
const next = [...current];
|
||
|
||
const allIndexes = DETAIL_SLOT_CONFIGS.map((_, index) => index);
|
||
let priorityIndexes = allIndexes;
|
||
|
||
if (targetSlotIndex !== null && targetSlotIndex >= 0 && targetSlotIndex < DETAIL_SLOT_CONFIGS.length) {
|
||
const role = DETAIL_SLOT_CONFIGS[targetSlotIndex]?.role;
|
||
const sameRoleIndexes = DETAIL_SLOT_CONFIGS.map((slot, index) =>
|
||
slot.role === role ? index : -1
|
||
).filter((index) => index >= 0);
|
||
const sameRoleStart = sameRoleIndexes.indexOf(targetSlotIndex);
|
||
const orderedSameRole =
|
||
sameRoleStart >= 0 ? sameRoleIndexes.slice(sameRoleStart) : sameRoleIndexes;
|
||
priorityIndexes = [targetSlotIndex, ...orderedSameRole.filter((index) => index !== targetSlotIndex)];
|
||
}
|
||
|
||
let cursor = 0;
|
||
for (let i = 0; i < priorityIndexes.length && cursor < normalized.length; i += 1) {
|
||
const slotIdx = priorityIndexes[i]!;
|
||
const canReplaceActive = targetSlotIndex !== null && slotIdx === targetSlotIndex && cursor === 0;
|
||
if (!canReplaceActive && next[slotIdx]) continue;
|
||
next[slotIdx] = normalized[cursor]!;
|
||
cursor += 1;
|
||
inserted += 1;
|
||
}
|
||
|
||
return next;
|
||
});
|
||
|
||
if (inserted > 0) {
|
||
toast.success(
|
||
inserted > 1 ? `已添加 ${inserted} 张到 AI详情素材槽位` : "已添加到 AI详情素材槽位"
|
||
);
|
||
if (targetSlotIndex !== null) {
|
||
setDetailSlotPickerIndex(null);
|
||
}
|
||
} else {
|
||
toast.info("详情素材槽位已满,请先替换或清空后再添加");
|
||
}
|
||
}
|
||
|
||
clearPendingChatAttachments();
|
||
return;
|
||
}
|
||
|
||
if (hasAgentUploadSlots) {
|
||
const slotCount = agentUploadSlots.length;
|
||
const currentSlots: Array<PickedObjectAttachment | null> = Array.from(
|
||
{ length: slotCount },
|
||
(_, index) => attachments[index] ?? null
|
||
);
|
||
const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url));
|
||
const toAdd = pendingChatAttachments
|
||
.filter((item) => item?.url && !existingUrls.has(item.url))
|
||
.map((item) => ({
|
||
url: item.url,
|
||
name: item.name || "参考图",
|
||
contentType: item.contentType || "image/jpeg",
|
||
})) as PickedObjectAttachment[];
|
||
|
||
let cursor = 0;
|
||
let inserted = 0;
|
||
for (let index = 0; index < slotCount && cursor < toAdd.length; index += 1) {
|
||
if (currentSlots[index]) continue;
|
||
currentSlots[index] = toAdd[cursor]!;
|
||
cursor += 1;
|
||
inserted += 1;
|
||
}
|
||
|
||
if (inserted > 0) {
|
||
const next = currentSlots
|
||
.slice(0, slotCount)
|
||
.filter((item): item is PickedObjectAttachment => Boolean(item));
|
||
setAttachments(next);
|
||
toast.success(
|
||
slotCount === 1
|
||
? "已添加到智能体图片槽位"
|
||
: `已添加到智能体槽位(${inserted}/${slotCount})`
|
||
);
|
||
} else if (toAdd.length > 0) {
|
||
toast.info("智能体图片槽位已满,请先替换或移除");
|
||
}
|
||
|
||
if (toAdd.length - inserted > 0) {
|
||
toast.info(`还有 ${toAdd.length - inserted} 张未添加,可继续替换智能体图片槽位`);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
(textareaRef.current as HTMLDivElement | null)?.focus();
|
||
}, 0);
|
||
|
||
clearPendingChatAttachments();
|
||
return;
|
||
}
|
||
|
||
if (isVideoModel && videoFrameSlots.length > 0) {
|
||
const slotCount = videoFrameSlots.length;
|
||
const currentSlots: Array<PickedObjectAttachment | null> = Array.from(
|
||
{ length: slotCount },
|
||
(_, index) => attachments[index] ?? null
|
||
);
|
||
const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url));
|
||
const toAdd = pendingChatAttachments
|
||
.filter((item) => item?.url && !existingUrls.has(item.url))
|
||
.map((item) => ({
|
||
url: item.url,
|
||
name: item.name || "参考图",
|
||
contentType: item.contentType || "image/jpeg",
|
||
})) as PickedObjectAttachment[];
|
||
|
||
let cursor = 0;
|
||
let inserted = 0;
|
||
for (let index = 0; index < slotCount && cursor < toAdd.length; index += 1) {
|
||
if (currentSlots[index]) continue;
|
||
currentSlots[index] = toAdd[cursor]!;
|
||
cursor += 1;
|
||
inserted += 1;
|
||
}
|
||
|
||
if (inserted > 0) {
|
||
const next = currentSlots
|
||
.slice(0, slotCount)
|
||
.filter((item): item is PickedObjectAttachment => Boolean(item));
|
||
setAttachments(next);
|
||
toast.success(slotCount === 1 ? "已添加到视频首帧" : "已添加到首尾帧槽位");
|
||
} else if (toAdd.length > 0) {
|
||
toast.info("首尾帧槽位已满,请先替换或移除");
|
||
}
|
||
|
||
if (toAdd.length - inserted > 0) {
|
||
toast.info(`还有 ${toAdd.length - inserted} 张未添加,可继续替换首尾帧`);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
(textareaRef.current as HTMLDivElement | null)?.focus();
|
||
}, 0);
|
||
|
||
clearPendingChatAttachments();
|
||
return;
|
||
}
|
||
|
||
const currentAttachments = [...attachments];
|
||
const existingUrls = new Set(currentAttachments.map((item) => item.url));
|
||
const toAdd = pendingChatAttachments.filter((item) => !existingUrls.has(item.url));
|
||
|
||
if (toAdd.length > 0) {
|
||
toAdd.forEach((item, index) => {
|
||
insertImageTagInEditor(item.url, item.name, currentAttachments.length + index);
|
||
});
|
||
|
||
setAttachments([...currentAttachments, ...toAdd]);
|
||
|
||
setTimeout(() => {
|
||
(textareaRef.current as HTMLDivElement | null)?.focus();
|
||
}, 0);
|
||
}
|
||
|
||
clearPendingChatAttachments();
|
||
}, [
|
||
pendingChatAttachments,
|
||
isDetailGenerationMode,
|
||
detailSlotPickerIndex,
|
||
hasAgentUploadSlots,
|
||
agentUploadSlots.length,
|
||
isVideoModel,
|
||
videoFrameSlots.length,
|
||
attachments,
|
||
insertImageTagInEditor,
|
||
clearPendingChatAttachments,
|
||
]);
|
||
|
||
const firstImageModelId = useMemo(
|
||
() => imageOnlyModels[0]?.id || null,
|
||
[imageOnlyModels]
|
||
);
|
||
const firstVideoModelId = useMemo(
|
||
() => videoOnlyModels[0]?.id || null,
|
||
[videoOnlyModels]
|
||
);
|
||
const activeModeLabel =
|
||
activeComposerMode === "video"
|
||
? "视频"
|
||
: activeComposerMode === "detail"
|
||
? "AI详情"
|
||
: activeComposerMode === "image"
|
||
? "生图"
|
||
: "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(() => {
|
||
if (!selectedModelId && siteSettings) {
|
||
currentModelIdRef.current = "auto";
|
||
setSelectedModelId("auto");
|
||
}
|
||
}, [selectedModelId, siteSettings]);
|
||
|
||
useEffect(() => {
|
||
if (!aiDetailGenerationEnabled && isDetailGenerationMode) {
|
||
setIsDetailGenerationMode(false);
|
||
}
|
||
}, [aiDetailGenerationEnabled, isDetailGenerationMode]);
|
||
|
||
useEffect(() => {
|
||
if (!modelSelectValue || modelSelectValue === selectedModelId) return;
|
||
currentModelIdRef.current = modelSelectValue;
|
||
setSelectedModelId(modelSelectValue);
|
||
}, [modelSelectValue, selectedModelId]);
|
||
|
||
useEffect(() => {
|
||
if (activeComposerMode !== "agent") return;
|
||
if (!selectedAgentPresetId) return;
|
||
if (!selectedAgentPreset?.defaultModelId) return;
|
||
if (currentModelIdRef.current === selectedAgentPreset.defaultModelId) return;
|
||
|
||
currentModelIdRef.current = selectedAgentPreset.defaultModelId;
|
||
setSelectedModelId(selectedAgentPreset.defaultModelId);
|
||
if (isDetailGenerationMode) {
|
||
setIsDetailGenerationMode(false);
|
||
}
|
||
}, [
|
||
activeComposerMode,
|
||
isDetailGenerationMode,
|
||
selectedAgentPreset,
|
||
selectedAgentPresetId,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (isDetailGenerationMode) {
|
||
setAttachments([]);
|
||
setDetailSections({ required: true, strategy: false, assets: true, archives: true });
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (editableDiv) {
|
||
editableDiv.querySelectorAll("[data-attachment-url]").forEach((element) => element.remove());
|
||
}
|
||
} else {
|
||
setDetailProgress({ status: "idle", stage: "", current: 0, total: 0 });
|
||
}
|
||
}, [isDetailGenerationMode]);
|
||
|
||
const currentModelIdRef = useRef(selectedModelId);
|
||
const isVideoModelRef = useRef(isVideoModel);
|
||
|
||
useEffect(() => {
|
||
currentModelIdRef.current = selectedModelId;
|
||
}, [selectedModelId]);
|
||
|
||
useEffect(() => {
|
||
imageParamsRef.current = imageParams;
|
||
}, [imageParams]);
|
||
|
||
useEffect(() => {
|
||
videoParamsRef.current = videoParams;
|
||
}, [videoParams]);
|
||
|
||
useEffect(() => {
|
||
deepThinkingEnabledRef.current = deepThinkingEnabled;
|
||
}, [deepThinkingEnabled]);
|
||
|
||
useEffect(() => {
|
||
isVideoModelRef.current = isVideoModel;
|
||
}, [isVideoModel]);
|
||
|
||
useEffect(() => {
|
||
if (!isVideoModel) return;
|
||
const normalized = normalizeVideoParamsForProvider(
|
||
videoParams,
|
||
currentModel?.providerType,
|
||
currentModel?.videoToolCapability
|
||
);
|
||
if (!videoParams || JSON.stringify(videoParams) !== JSON.stringify(normalized)) {
|
||
setVideoParams(normalized);
|
||
}
|
||
}, [isVideoModel, currentModel?.providerType, currentModel?.videoToolCapability, videoParams]);
|
||
|
||
useEffect(() => {
|
||
if (!isVideoModel || videoFrameSlots.length === 0) return;
|
||
const maxAttachments = videoFrameSlots.length;
|
||
setAttachments((current) => {
|
||
if (current.length <= maxAttachments) return current;
|
||
return current.slice(0, maxAttachments);
|
||
});
|
||
}, [isVideoModel, videoFrameSlots.length]);
|
||
|
||
useEffect(() => {
|
||
if (!hasAgentUploadSlots) return;
|
||
const maxAttachments = agentUploadSlots.length;
|
||
setAttachments((current) => {
|
||
if (current.length <= maxAttachments) return current;
|
||
return current.slice(0, maxAttachments);
|
||
});
|
||
}, [agentUploadSlots.length, hasAgentUploadSlots]);
|
||
|
||
useEffect(() => {
|
||
if (!hasAgentUploadSlots || workflowReferenceImages.length === 0) {
|
||
lastWorkflowAutofillSignatureRef.current = null;
|
||
lastWorkflowAutofillUrlsRef.current = [];
|
||
return;
|
||
}
|
||
|
||
const primaryImages = workflowReferenceImages.filter((image) => image.source === "primary");
|
||
const secondaryImages = workflowReferenceImages.filter((image) => image.source === "secondary");
|
||
const usedUrls = new Set<string>();
|
||
const pickNextImage = (
|
||
candidates: typeof workflowReferenceImages
|
||
) =>
|
||
candidates.find((image) => {
|
||
if (!image?.url || usedUrls.has(image.url)) return false;
|
||
usedUrls.add(image.url);
|
||
return true;
|
||
}) || null;
|
||
|
||
const productSlot1Image = pickNextImage(primaryImages) || pickNextImage(secondaryImages);
|
||
const productSlot2Image =
|
||
pickNextImage(secondaryImages) ||
|
||
pickNextImage(primaryImages) ||
|
||
pickNextImage(workflowReferenceImages);
|
||
|
||
const suggestedSlots: Array<PickedObjectAttachment | null> = Array.from(
|
||
{ length: agentUploadSlots.length },
|
||
() => null
|
||
);
|
||
const toAttachment = (image: (typeof workflowReferenceImages)[number] | null) =>
|
||
image
|
||
? {
|
||
url: image.url,
|
||
name: image.name || image.label || image.sourceLabel,
|
||
contentType: image.mimeType || "image/png",
|
||
assetId: image.assetId || undefined,
|
||
}
|
||
: null;
|
||
|
||
if (agentUploadSlots.length > 0) {
|
||
suggestedSlots[0] = toAttachment(productSlot1Image);
|
||
}
|
||
if (agentUploadSlots.length > 1) {
|
||
suggestedSlots[1] = toAttachment(productSlot2Image);
|
||
}
|
||
|
||
const nextSuggestedUrls = suggestedSlots
|
||
.filter((item): item is PickedObjectAttachment => Boolean(item))
|
||
.map((item) => item.url);
|
||
if (nextSuggestedUrls.length === 0) return;
|
||
|
||
const nextSignature = JSON.stringify({
|
||
presetId: selectedAgentPresetId || "",
|
||
urls: nextSuggestedUrls,
|
||
});
|
||
if (lastWorkflowAutofillSignatureRef.current === nextSignature) {
|
||
return;
|
||
}
|
||
|
||
setAttachments((current) => {
|
||
const previousAutoUrls = new Set(lastWorkflowAutofillUrlsRef.current);
|
||
const next: Array<PickedObjectAttachment | null> = Array.from(
|
||
{ length: agentUploadSlots.length },
|
||
(_, index) => current[index] ?? null
|
||
);
|
||
let changed = false;
|
||
|
||
suggestedSlots.forEach((attachment, index) => {
|
||
if (!attachment || index >= 2) return;
|
||
const existing = next[index];
|
||
const canReplace = !existing?.url || previousAutoUrls.has(existing.url);
|
||
if (!canReplace) return;
|
||
if (existing?.url === attachment.url) return;
|
||
next[index] = attachment;
|
||
changed = true;
|
||
});
|
||
|
||
if (!changed) {
|
||
return current;
|
||
}
|
||
lastWorkflowAutofillSignatureRef.current = nextSignature;
|
||
lastWorkflowAutofillUrlsRef.current = nextSuggestedUrls;
|
||
return next.filter((item): item is PickedObjectAttachment => Boolean(item));
|
||
});
|
||
}, [
|
||
agentUploadSlots.length,
|
||
hasAgentUploadSlots,
|
||
selectedAgentPresetId,
|
||
workflowReferenceImages,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!currentModel) return;
|
||
if (currentModel.id !== "auto" && isDetailGenerationMode) {
|
||
setIsDetailGenerationMode(false);
|
||
}
|
||
if (isVideoModel) {
|
||
setLastVideoModelId(currentModel.id);
|
||
return;
|
||
}
|
||
if (isConversationModel) {
|
||
setLastConversationModelId(currentModel.id);
|
||
return;
|
||
}
|
||
if (currentModel.id !== "auto") {
|
||
setLastImageModelId(currentModel.id);
|
||
}
|
||
}, [currentModel, isConversationModel, isDetailGenerationMode, isVideoModel]);
|
||
|
||
const handleSelectGenerationMode = useCallback(
|
||
(mode: "agent" | "image" | "video" | "detail") => {
|
||
hiddenPromptRef.current = null;
|
||
autoContextRef.current = null;
|
||
setComposerModeOverride(mode);
|
||
|
||
if (mode === "agent") {
|
||
const preferredAgentModelId =
|
||
allModels.find(
|
||
(model) =>
|
||
model.id === lastConversationModelId &&
|
||
model.isMultimodal &&
|
||
!VIDEO_PROVIDER_TYPES.has(model.providerType)
|
||
)?.id || "auto";
|
||
currentModelIdRef.current = preferredAgentModelId;
|
||
setSelectedModelId(preferredAgentModelId);
|
||
setIsDetailGenerationMode(false);
|
||
return;
|
||
}
|
||
|
||
if (mode === "detail") {
|
||
if (!aiDetailGenerationEnabled) {
|
||
toast.error("AI详情生成功能未开启");
|
||
return;
|
||
}
|
||
currentModelIdRef.current = "auto";
|
||
setSelectedModelId("auto");
|
||
setIsDetailGenerationMode(true);
|
||
setIsDetailWorkbenchCollapsed(true);
|
||
return;
|
||
}
|
||
|
||
if (mode === "image") {
|
||
const preferredImageModelId =
|
||
allModels.find(
|
||
(model) =>
|
||
model.id === lastImageModelId &&
|
||
model.id !== "auto" &&
|
||
!model.isMultimodal &&
|
||
!VIDEO_PROVIDER_TYPES.has(model.providerType)
|
||
)?.id || firstImageModelId;
|
||
|
||
if (!preferredImageModelId) {
|
||
toast.error("当前没有可用的图像模型");
|
||
return;
|
||
}
|
||
currentModelIdRef.current = preferredImageModelId;
|
||
setSelectedModelId(preferredImageModelId);
|
||
setIsDetailGenerationMode(false);
|
||
return;
|
||
}
|
||
|
||
const preferredVideoModelId =
|
||
allModels.find(
|
||
(model) => model.id === lastVideoModelId && VIDEO_PROVIDER_TYPES.has(model.providerType)
|
||
)?.id || firstVideoModelId;
|
||
|
||
if (!preferredVideoModelId) {
|
||
toast.error("当前没有可用的视频模型");
|
||
return;
|
||
}
|
||
currentModelIdRef.current = preferredVideoModelId;
|
||
setSelectedModelId(preferredVideoModelId);
|
||
setIsDetailGenerationMode(false);
|
||
},
|
||
[
|
||
aiDetailGenerationEnabled,
|
||
allModels,
|
||
firstImageModelId,
|
||
firstVideoModelId,
|
||
lastConversationModelId,
|
||
lastImageModelId,
|
||
lastVideoModelId,
|
||
]
|
||
);
|
||
|
||
const updateDetailBriefField = useCallback(
|
||
(key: keyof DetailBriefForm, value: string) => {
|
||
setDetailBrief((prev) => ({ ...prev, [key]: value }));
|
||
},
|
||
[]
|
||
);
|
||
|
||
const normalizedDetailBrief = useMemo(() => {
|
||
const normalizeValue = (value: string) => value.replace(/\s+/g, " ").trim();
|
||
return {
|
||
productSpu: normalizeValue(detailBrief.productSpu),
|
||
demandType: normalizeValue(detailBrief.demandType),
|
||
productName: normalizeValue(detailBrief.productName),
|
||
targetAudience: normalizeValue(detailBrief.targetAudience),
|
||
mainUser: normalizeValue(detailBrief.mainUser),
|
||
usageScenario: normalizeValue(detailBrief.usageScenario),
|
||
triggerMoment: normalizeValue(detailBrief.triggerMoment),
|
||
coreNeed: normalizeValue(detailBrief.coreNeed),
|
||
riskConcern: normalizeValue(detailBrief.riskConcern),
|
||
expectedResult: normalizeValue(detailBrief.expectedResult),
|
||
coreSellingPoint: normalizeValue(detailBrief.coreSellingPoint),
|
||
positioningStatement: normalizeValue(detailBrief.positioningStatement),
|
||
userVoice: normalizeValue(detailBrief.userVoice),
|
||
competitorReference: normalizeValue(detailBrief.competitorReference),
|
||
priceBand: normalizeValue(detailBrief.priceBand),
|
||
brandTone: normalizeValue(detailBrief.brandTone),
|
||
bannedWords: normalizeValue(detailBrief.bannedWords),
|
||
};
|
||
}, [detailBrief]);
|
||
|
||
const detailValidationIssues = useMemo(() => {
|
||
const issues: string[] = [];
|
||
if (!normalizedDetailBrief.targetAudience) issues.push("缺少主购买人群");
|
||
if (!normalizedDetailBrief.usageScenario) issues.push("缺少需求场景");
|
||
if (!normalizedDetailBrief.coreNeed) issues.push("缺少核心需求");
|
||
const productCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
|
||
if (productCount < 5) issues.push(`产品图不足(${productCount}/5)`);
|
||
if (DETAIL_MODEL_SLOT_INDEX < 0 || !detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) {
|
||
issues.push("缺少模特图(1/1)");
|
||
}
|
||
return issues;
|
||
}, [detailSlots, normalizedDetailBrief]);
|
||
|
||
const hasGeneratedDetailImages = useMemo(
|
||
() =>
|
||
nodes.some(
|
||
(node) =>
|
||
node.type === "image" &&
|
||
Boolean((node.data as any)?.metadata?.detailSectionIndex)
|
||
),
|
||
[nodes]
|
||
);
|
||
|
||
const toggleDetailSection = useCallback((key: DetailSectionKey) => {
|
||
setDetailSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let isCancelled = false;
|
||
|
||
const loadDetailArchives = async () => {
|
||
try {
|
||
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
|
||
method: "GET",
|
||
cache: "no-store",
|
||
});
|
||
const result = await response.json().catch(() => ({}));
|
||
if (isCancelled) return;
|
||
if (!response.ok || !result?.success) {
|
||
setDetailArchives([]);
|
||
return;
|
||
}
|
||
|
||
const parsed = Array.isArray(result.data) ? (result.data as DetailArchive[]) : [];
|
||
const sanitized = parsed
|
||
.filter((item) => item?.id && item?.createdAt)
|
||
.map((item) => ({
|
||
id: String(item.id),
|
||
title: String(item.title || "未命名详情项目"),
|
||
prompt: String(item.prompt || ""),
|
||
generationMode: normalizeDetailGenerationMode((item as any).generationMode),
|
||
brief: {
|
||
productSpu: String(item.brief?.productSpu || ""),
|
||
demandType: String(item.brief?.demandType || ""),
|
||
productName: String(item.brief?.productName || ""),
|
||
targetAudience: String(item.brief?.targetAudience || ""),
|
||
mainUser: String(item.brief?.mainUser || ""),
|
||
usageScenario: String(item.brief?.usageScenario || ""),
|
||
triggerMoment: String(item.brief?.triggerMoment || ""),
|
||
coreNeed: String(item.brief?.coreNeed || ""),
|
||
riskConcern: String(item.brief?.riskConcern || ""),
|
||
expectedResult: String(item.brief?.expectedResult || ""),
|
||
coreSellingPoint: String(item.brief?.coreSellingPoint || ""),
|
||
positioningStatement: String(item.brief?.positioningStatement || ""),
|
||
userVoice: String(item.brief?.userVoice || ""),
|
||
competitorReference: String(item.brief?.competitorReference || ""),
|
||
priceBand: String(item.brief?.priceBand || ""),
|
||
brandTone: String(item.brief?.brandTone || ""),
|
||
bannedWords: String(item.brief?.bannedWords || ""),
|
||
},
|
||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||
const slot = item.slots?.[index];
|
||
if (!slot?.url) return null;
|
||
return {
|
||
url: String(slot.url),
|
||
name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`),
|
||
contentType: String(slot.contentType || "image/jpeg"),
|
||
} as PickedObjectAttachment;
|
||
}),
|
||
createdAt:
|
||
typeof item.createdAt === "number"
|
||
? item.createdAt
|
||
: (() => {
|
||
const timestamp = Date.parse(String(item.createdAt || ""));
|
||
return Number.isFinite(timestamp) ? timestamp : Date.now();
|
||
})(),
|
||
updatedAt:
|
||
typeof (item as any).updatedAt === "number"
|
||
? Number((item as any).updatedAt)
|
||
: (() => {
|
||
const timestamp = Date.parse(String((item as any).updatedAt || ""));
|
||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||
})(),
|
||
summary: item.summary ? String(item.summary) : undefined,
|
||
imageCount: Number(item.imageCount || 0) || undefined,
|
||
coverUrl: item.coverUrl ? String(item.coverUrl) : undefined,
|
||
}))
|
||
.sort(
|
||
(a, b) =>
|
||
(b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt)
|
||
);
|
||
|
||
setDetailArchives(sanitized.slice(0, 30));
|
||
} catch (error) {
|
||
if (isCancelled) return;
|
||
console.error("[CanvasChat] Failed to load detail archives:", error);
|
||
setDetailArchives([]);
|
||
}
|
||
};
|
||
|
||
void loadDetailArchives();
|
||
return () => {
|
||
isCancelled = true;
|
||
};
|
||
}, [canvasId]);
|
||
|
||
useEffect(() => {
|
||
if (videoFrameSlots.length === 0) return;
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (!editableDiv) return;
|
||
editableDiv.querySelectorAll("[data-attachment-url]").forEach((element) => element.remove());
|
||
}, [videoFrameSlots.length]);
|
||
|
||
// 合并 initialMessages 和历史消息
|
||
const mergedInitialMessages = historyData?.messages ?? (activeChatId === chatId ? initialMessages : []);
|
||
|
||
const appendStreamMediaToCanvas = useCallback(
|
||
async (payload: CanvasMediaStreamPayload) => {
|
||
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 (imageUrls.length === 0 && videoUrls.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const latestNodes = useCanvasStore.getState().nodes;
|
||
const suppressedMediaKeys = new Set(
|
||
(useCanvasStore.getState().getCanvasState().suppressedMediaKeys || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
);
|
||
const existingImageUrlKeys = new Set(
|
||
latestNodes
|
||
.filter((node) => node.type === "image" && (node.data as any)?.src)
|
||
.map((node) => normalizeImageIdentityUrl(String((node.data as any)?.src || "")))
|
||
.filter(Boolean)
|
||
);
|
||
const existingMessageImageKeys = new Set(
|
||
latestNodes
|
||
.filter((node) => node.type === "image")
|
||
.map((node) => {
|
||
const data = ((node.data as any) || {}) as Record<string, any>;
|
||
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
|
||
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
|
||
const sourceImageKey = normalizeImageIdentityUrl(
|
||
String(metadata.sourceImageKey || data.src || "").trim()
|
||
);
|
||
if (!sourceMessageId || !sourceImageKey) return "";
|
||
return `${sourceMessageId}::${sourceImageKey}`;
|
||
})
|
||
.filter(Boolean)
|
||
);
|
||
const existingVideoUrlKeys = new Set(
|
||
latestNodes
|
||
.filter((node) => node.type === "video" && (node.data as any)?.src)
|
||
.map((node) => normalizeMediaUrl(String((node.data as any)?.src || "")))
|
||
.filter(Boolean)
|
||
);
|
||
|
||
const sourceImageUrls = Array.isArray(payload.sourceImageUrls)
|
||
? payload.sourceImageUrls.filter((url): url is string => typeof url === "string" && /^https?:\/\//i.test(url))
|
||
: [];
|
||
const messageId = String(payload.messageId || "").trim();
|
||
const generationPrompt = String(payload.prompt || "").trim() || "对话生成";
|
||
const agentPresetId = String(payload.agentPresetId || "").trim() || undefined;
|
||
const agentPresetName = String(payload.agentPresetName || "").trim() || undefined;
|
||
const agentCategoryId = String(payload.agentCategoryId || "").trim() || undefined;
|
||
const agentCategoryName = String(payload.agentCategoryName || "").trim() || undefined;
|
||
const agentExecutionSnapshotId =
|
||
String(payload.agentExecutionSnapshotId || "").trim() || undefined;
|
||
let addedImageCount = 0;
|
||
let addedVideoCount = 0;
|
||
|
||
for (let index = 0; index < imageUrls.length; index += 1) {
|
||
const url = imageUrls[index]!;
|
||
const sourceImageKey = normalizeImageIdentityUrl(url);
|
||
if (!sourceImageKey || existingImageUrlKeys.has(sourceImageKey)) {
|
||
continue;
|
||
}
|
||
const messageImageKey = messageId ? `${messageId}::${sourceImageKey}` : "";
|
||
if (
|
||
suppressedMediaKeys.has(`image:${messageImageKey}`) ||
|
||
suppressedMediaKeys.has(`image-url:${url}`)
|
||
) {
|
||
continue;
|
||
}
|
||
if (messageImageKey && existingMessageImageKeys.has(messageImageKey)) {
|
||
continue;
|
||
}
|
||
|
||
existingImageUrlKeys.add(sourceImageKey);
|
||
if (messageImageKey) {
|
||
existingMessageImageKeys.add(messageImageKey);
|
||
}
|
||
|
||
const dimensions = await getImageDimensions(url);
|
||
const currentNodes = useCanvasStore.getState().nodes;
|
||
const existingNodes = currentNodes.length + addedImageCount + addedVideoCount;
|
||
const spacing = 20;
|
||
const x = 100 + (existingNodes % 3) * (dimensions.width + spacing);
|
||
const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing);
|
||
const compareSourceUrlRaw =
|
||
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
|
||
sourceImageUrls[0] ||
|
||
"";
|
||
const compareSourceUrl = String(compareSourceUrlRaw || "").trim();
|
||
const normalizedCompareSource = normalizeImageIdentityUrl(compareSourceUrl);
|
||
const canUseCompareSource =
|
||
Boolean(normalizedCompareSource) &&
|
||
normalizedCompareSource !== sourceImageKey;
|
||
const sourceNodeId = canUseCompareSource
|
||
? useCanvasStore
|
||
.getState()
|
||
.nodes.find((node) => {
|
||
if (node.type !== "image") return false;
|
||
const src = String(((node.data as any) || {}).src || "").trim();
|
||
return normalizeImageIdentityUrl(src) === normalizedCompareSource;
|
||
})?.id || ""
|
||
: "";
|
||
|
||
const nodeId = addNode({
|
||
type: "image",
|
||
position: { x, y },
|
||
size: { width: dimensions.width, height: dimensions.height },
|
||
data: {
|
||
src: url,
|
||
status: "completed",
|
||
title: "图片",
|
||
prompt: generationPrompt,
|
||
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
|
||
metadata: {
|
||
originalWidth: dimensions.originalWidth,
|
||
originalHeight: dimensions.originalHeight,
|
||
generationPrompt,
|
||
sourceMessageId: messageId || undefined,
|
||
sourceImageKey,
|
||
compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined,
|
||
agentPresetId,
|
||
agentPresetName,
|
||
agentCategoryId,
|
||
agentCategoryName,
|
||
agentExecutionSnapshotId,
|
||
},
|
||
},
|
||
connections: sourceNodeId ? [sourceNodeId] : [],
|
||
});
|
||
void autoNameGeneratedImageNode(nodeId, url);
|
||
addedImageCount += 1;
|
||
}
|
||
|
||
for (let index = 0; index < videoUrls.length; index += 1) {
|
||
const url = videoUrls[index]!;
|
||
const key = normalizeMediaUrl(url);
|
||
if (!key || existingVideoUrlKeys.has(key)) {
|
||
continue;
|
||
}
|
||
if (suppressedMediaKeys.has(`video:${url}`)) {
|
||
continue;
|
||
}
|
||
existingVideoUrlKeys.add(key);
|
||
const currentNodes = useCanvasStore.getState().nodes;
|
||
const existingNodes = currentNodes.length + addedImageCount + addedVideoCount;
|
||
const x = 120 + (existingNodes % 2) * 420;
|
||
const y = 120 + Math.floor(existingNodes / 2) * 260;
|
||
addNode({
|
||
type: "video",
|
||
position: { x, y },
|
||
size: { width: 380, height: 220 },
|
||
data: {
|
||
src: url,
|
||
status: "completed",
|
||
prompt: generationPrompt,
|
||
metadata: {
|
||
agentPresetId,
|
||
agentPresetName,
|
||
agentCategoryId,
|
||
agentCategoryName,
|
||
agentExecutionSnapshotId,
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
addedVideoCount += 1;
|
||
}
|
||
|
||
if (addedImageCount > 0 || addedVideoCount > 0) {
|
||
const notice = [
|
||
addedImageCount > 0 ? `${addedImageCount} 张图片` : "",
|
||
addedVideoCount > 0 ? `${addedVideoCount} 条视频` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join("、");
|
||
toast.success(`已将 ${notice} 添加到画布`);
|
||
}
|
||
},
|
||
[addNode, autoNameGeneratedImageNode]
|
||
);
|
||
|
||
const {
|
||
messages,
|
||
setMessages,
|
||
sendMessage,
|
||
status,
|
||
} = useChat<ChatMessage>({
|
||
id: activeChatId,
|
||
messages: mergedInitialMessages,
|
||
experimental_throttle: 100,
|
||
generateId: generateUUID,
|
||
transport: new DefaultChatTransport({
|
||
api: "/api/chat",
|
||
fetch: fetchWithErrorHandlers,
|
||
prepareSendMessagesRequest(request) {
|
||
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
|
||
const rawId = request.id.startsWith('canvas-')
|
||
? request.id.replace('canvas-', '')
|
||
: request.id;
|
||
|
||
console.log("[CanvasChat] Preparing request:", {
|
||
id: rawId,
|
||
modelId: currentModelIdRef.current,
|
||
imageParams: imageParamsRef.current,
|
||
contextPayload: buildContextPayload(),
|
||
lastMessage: request.messages.at(-1),
|
||
});
|
||
const hiddenPrompt = hiddenPromptRef.current || undefined;
|
||
const autoContext = autoContextRef.current || undefined;
|
||
const contextPayload = buildContextPayload();
|
||
const requestModel = allModels.find((model) => model.id === currentModelIdRef.current);
|
||
const requestIsVideoModel = Boolean(
|
||
requestModel && VIDEO_PROVIDER_TYPES.has(requestModel.providerType)
|
||
);
|
||
const requestIsConversationModel = Boolean(
|
||
requestModel &&
|
||
requestModel.id !== "auto" &&
|
||
requestModel.isMultimodal &&
|
||
!requestIsVideoModel
|
||
);
|
||
hiddenPromptRef.current = null;
|
||
autoContextRef.current = null;
|
||
return {
|
||
body: {
|
||
id: rawId,
|
||
message: request.messages.at(-1),
|
||
agentPresetId:
|
||
activeComposerMode === "agent" && selectedAgentPreset?.id
|
||
? selectedAgentPreset.id
|
||
: undefined,
|
||
selectedChatModel: currentModelIdRef.current,
|
||
composerMode: activeComposerMode,
|
||
selectedVisibilityType: "private",
|
||
deepThinkingEnabled: requestIsConversationModel
|
||
? false
|
||
: requestIsVideoModel || currentModelIdRef.current === "auto"
|
||
? undefined
|
||
: deepThinkingEnabledRef.current,
|
||
videoParams: requestIsVideoModel ? videoParamsRef.current || undefined : undefined,
|
||
imageParams:
|
||
requestIsVideoModel || requestIsConversationModel
|
||
? undefined
|
||
: imageParamsRef.current || undefined,
|
||
hiddenPrompt,
|
||
autoContext,
|
||
contextPayload,
|
||
...request.body,
|
||
},
|
||
};
|
||
},
|
||
}),
|
||
onData: (dataPart: any) => {
|
||
if (dataPart.type === "data-imageGenerationStatus") {
|
||
try {
|
||
const payload: ImageGenerationStatusStreamPayload =
|
||
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
||
if (!payload || typeof payload !== "object") return;
|
||
if (payload.phase === "completed") {
|
||
clearStreamImageGenerationStateForChat(activeChatId);
|
||
return;
|
||
}
|
||
setStreamImageGenerationStateForChat(activeChatId, {
|
||
phase: payload.phase || "processing",
|
||
providerType: payload.providerType,
|
||
modelName: getAgentSafeModelName(payload.modelName),
|
||
imageCount: Number(payload.imageCount || 0) || undefined,
|
||
});
|
||
} catch (e) {
|
||
console.error("[CanvasChat] Failed to parse data-imageGenerationStatus:", e);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (dataPart.type === "data-canvasMedia") {
|
||
try {
|
||
const payload: CanvasMediaStreamPayload =
|
||
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
||
if (!payload || typeof payload !== "object") return;
|
||
void appendStreamMediaToCanvas(payload);
|
||
} catch (e) {
|
||
console.error("[CanvasChat] Failed to parse data-canvasMedia:", e);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 处理后端返回的自定义消息
|
||
if (dataPart.type === "data-appendMessage") {
|
||
try {
|
||
const messageData = typeof dataPart.data === 'string'
|
||
? JSON.parse(dataPart.data)
|
||
: dataPart.data;
|
||
const hasImageGenStartMarker = Array.isArray(messageData.parts)
|
||
? messageData.parts.some(
|
||
(part: any) =>
|
||
part?.type === "text" &&
|
||
typeof part?.text === "string" &&
|
||
part.text.includes("<!-- IMAGE_GENERATION_START -->")
|
||
)
|
||
: false;
|
||
const hasImageGenEndMarker = Array.isArray(messageData.parts)
|
||
? messageData.parts.some(
|
||
(part: any) =>
|
||
part?.type === "text" &&
|
||
typeof part?.text === "string" &&
|
||
part.text.includes("<!-- IMAGE_GENERATION_END -->")
|
||
)
|
||
: false;
|
||
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
|
||
const normalizedAttachments = Array.isArray(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) => {
|
||
const existingIndex = prevMessages.findIndex(
|
||
(message) => message.id === messageData.id
|
||
);
|
||
|
||
if (existingIndex >= 0) {
|
||
const existingMessage = prevMessages[existingIndex];
|
||
const updatedMessage: any = {
|
||
...existingMessage,
|
||
role: messageData.role || existingMessage.role,
|
||
content:
|
||
normalizedParts.find((part: any) => part?.type === "text")?.text ||
|
||
(existingMessage as any).content ||
|
||
"",
|
||
parts: normalizedParts,
|
||
attachments: normalizedAttachments,
|
||
};
|
||
|
||
const nextMessages = [...prevMessages];
|
||
nextMessages[existingIndex] = updatedMessage;
|
||
return nextMessages;
|
||
}
|
||
|
||
const newMessage: any = {
|
||
id: messageData.id,
|
||
role: messageData.role,
|
||
content: normalizedParts.find((part: any) => part?.type === "text")?.text || "",
|
||
createdAt: new Date(),
|
||
parts: normalizedParts,
|
||
attachments: normalizedAttachments,
|
||
};
|
||
|
||
return [...prevMessages, newMessage];
|
||
});
|
||
} catch (e) {
|
||
console.error("[CanvasChat] Failed to parse data-appendMessage:", e);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (dataPart.type === "data-videoStoryboard") {
|
||
try {
|
||
const payload: VideoStoryboardStreamData =
|
||
typeof dataPart.data === "string"
|
||
? JSON.parse(dataPart.data)
|
||
: dataPart.data;
|
||
if (!payload || typeof payload !== "object") return;
|
||
|
||
if (payload.stage === "start") {
|
||
const taskId = String(payload.taskId || "").trim();
|
||
if (!taskId) return;
|
||
if (storyboardNodeMapRef.current[taskId]) return;
|
||
|
||
const totalShots = Math.max(1, Math.min(9, Number(payload.totalShots || 9)));
|
||
const layoutCols =
|
||
Math.max(1, Math.min(3, Number(payload.cols || (totalShots === 4 ? 2 : 3)))) || 3;
|
||
const shotNodeMap: Record<number, string> = {};
|
||
const currentNodes = useCanvasStore.getState().nodes;
|
||
const baseOffset = currentNodes.length;
|
||
const cardWidth = 320;
|
||
const cardHeight = 196;
|
||
const gapX = 30;
|
||
const gapY = 24;
|
||
const startX = 120 + (baseOffset % 2) * 420;
|
||
const startY = 120 + Math.floor(baseOffset / 2) * 260;
|
||
|
||
for (let shotIndex = 1; shotIndex <= totalShots; shotIndex += 1) {
|
||
const shotData = payload.shots?.find((item) => Number(item.shotIndex) === shotIndex);
|
||
const col = (shotIndex - 1) % layoutCols;
|
||
const row = Math.floor((shotIndex - 1) / layoutCols);
|
||
const nodeId = addNode({
|
||
type: "image",
|
||
position: {
|
||
x: startX + col * (cardWidth + gapX),
|
||
y: startY + row * (cardHeight + gapY),
|
||
},
|
||
size: { width: cardWidth, height: cardHeight },
|
||
data: {
|
||
src: shotData?.tileImageUrl || "",
|
||
status: "generating",
|
||
title: `镜头 ${shotIndex}`,
|
||
prompt: shotData?.prompt || `分镜 ${shotIndex} 生成中`,
|
||
originalSrc: shotData?.tileImageUrl || "",
|
||
metadata: {
|
||
storyboardTaskId: taskId,
|
||
shotIndex,
|
||
stage: "preview",
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
shotNodeMap[shotIndex] = nodeId;
|
||
}
|
||
|
||
storyboardNodeMapRef.current[taskId] = shotNodeMap;
|
||
toast.message(`分镜预览任务已开始(${totalShots} 段)`);
|
||
return;
|
||
}
|
||
|
||
if (payload.stage === "hd_shot_completed") {
|
||
const taskId = String(payload.taskId || "").trim();
|
||
const shotIndex = Math.max(1, Number(payload.shotIndex || 0));
|
||
const imageUrl = String(payload.imageUrl || "").trim();
|
||
if (!taskId || !shotIndex || !imageUrl) return;
|
||
|
||
let nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
|
||
if (!nodeId) {
|
||
nodeId = addNode({
|
||
type: "image",
|
||
position: { x: 140 + ((shotIndex - 1) % 3) * 350, y: 150 + Math.floor((shotIndex - 1) / 3) * 230 },
|
||
size: { width: 320, height: 196 },
|
||
data: {
|
||
src: imageUrl,
|
||
status: "completed",
|
||
title: `镜头 ${shotIndex}`,
|
||
prompt: payload.prompt || `镜头 ${shotIndex}`,
|
||
metadata: {
|
||
storyboardTaskId: taskId,
|
||
shotIndex,
|
||
stage: "preview",
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
if (!storyboardNodeMapRef.current[taskId]) {
|
||
storyboardNodeMapRef.current[taskId] = {};
|
||
}
|
||
storyboardNodeMapRef.current[taskId]![shotIndex] = nodeId;
|
||
return;
|
||
}
|
||
|
||
const currentNode = useCanvasStore.getState().getNode(nodeId);
|
||
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
||
updateNode(nodeId, {
|
||
data: {
|
||
...currentData,
|
||
src: imageUrl,
|
||
status: "completed",
|
||
title: currentData.title || `镜头 ${shotIndex}`,
|
||
prompt: payload.prompt || currentData.prompt || `镜头 ${shotIndex}`,
|
||
metadata: {
|
||
...(currentData.metadata || {}),
|
||
storyboardTaskId: taskId,
|
||
shotIndex,
|
||
stage: "preview",
|
||
},
|
||
},
|
||
} as any);
|
||
return;
|
||
}
|
||
|
||
if (payload.stage === "hd_shot_failed") {
|
||
const taskId = String(payload.taskId || "").trim();
|
||
const shotIndex = Math.max(1, Number(payload.shotIndex || 0));
|
||
if (!taskId || !shotIndex) return;
|
||
|
||
const nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
|
||
if (!nodeId) return;
|
||
const currentNode = useCanvasStore.getState().getNode(nodeId);
|
||
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
||
updateNode(nodeId, {
|
||
data: {
|
||
...currentData,
|
||
status: "failed",
|
||
metadata: {
|
||
...(currentData.metadata || {}),
|
||
previewError: payload.error || "预览图生成失败",
|
||
},
|
||
},
|
||
} as any);
|
||
return;
|
||
}
|
||
|
||
if (payload.stage === "awaiting_confirmation") {
|
||
const taskId = String(payload.taskId || "").trim();
|
||
if (!taskId) return;
|
||
|
||
const previewShots = Array.isArray(payload.shots) ? payload.shots : [];
|
||
for (const shot of previewShots) {
|
||
const shotIndex = Math.max(1, Number(shot.shotIndex || 0));
|
||
const nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
|
||
if (!nodeId) continue;
|
||
const currentNode = useCanvasStore.getState().getNode(nodeId);
|
||
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
||
updateNode(nodeId, {
|
||
data: {
|
||
...currentData,
|
||
src: String(shot.previewImageUrl || currentData.src || shot.tileImageUrl || ""),
|
||
originalSrc: String(shot.tileImageUrl || currentData.originalSrc || ""),
|
||
status: "completed",
|
||
title: currentData.title || `镜头 ${shotIndex}`,
|
||
prompt: shot.prompt || currentData.prompt || `镜头 ${shotIndex}`,
|
||
metadata: {
|
||
...(currentData.metadata || {}),
|
||
storyboardTaskId: taskId,
|
||
shotIndex,
|
||
stage: "awaiting_confirmation",
|
||
},
|
||
},
|
||
} as any);
|
||
}
|
||
|
||
toast.message(payload.message || "分镜预览已完成,请先确认镜头内容");
|
||
return;
|
||
}
|
||
|
||
if (payload.stage === "shot_completed") {
|
||
const taskId = String(payload.taskId || "").trim();
|
||
const shotIndex = Math.max(1, Number(payload.shotIndex || 0));
|
||
const videoUrl = String(payload.videoUrl || "").trim();
|
||
if (!taskId || !shotIndex || !videoUrl) return;
|
||
|
||
let nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
|
||
if (!nodeId) {
|
||
nodeId = addNode({
|
||
type: "video",
|
||
position: { x: 140 + ((shotIndex - 1) % 3) * 350, y: 150 + Math.floor((shotIndex - 1) / 3) * 230 },
|
||
size: { width: 320, height: 196 },
|
||
data: {
|
||
src: videoUrl,
|
||
status: "completed",
|
||
prompt: `分镜 ${shotIndex}`,
|
||
},
|
||
connections: [],
|
||
});
|
||
if (!storyboardNodeMapRef.current[taskId]) {
|
||
storyboardNodeMapRef.current[taskId] = {};
|
||
}
|
||
storyboardNodeMapRef.current[taskId]![shotIndex] = nodeId;
|
||
return;
|
||
}
|
||
|
||
const currentNode = useCanvasStore.getState().getNode(nodeId);
|
||
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
||
updateNode(nodeId, {
|
||
type: "video",
|
||
data: {
|
||
src: videoUrl,
|
||
status: "completed",
|
||
prompt: currentData.prompt || `分镜 ${shotIndex}`,
|
||
coverImage: currentData.src || currentData.originalSrc || "",
|
||
metadata: {
|
||
...(currentData.metadata || {}),
|
||
storyboardTaskId: taskId,
|
||
shotIndex,
|
||
stage: "video",
|
||
},
|
||
},
|
||
} as any);
|
||
return;
|
||
}
|
||
|
||
if (payload.stage === "shot_failed") {
|
||
const taskId = String(payload.taskId || "").trim();
|
||
const shotIndex = Math.max(1, Number(payload.shotIndex || 0));
|
||
if (!taskId || !shotIndex) return;
|
||
const nodeId = storyboardNodeMapRef.current[taskId]?.[shotIndex];
|
||
if (!nodeId) return;
|
||
const currentNode = useCanvasStore.getState().getNode(nodeId);
|
||
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
||
updateNode(nodeId, {
|
||
data: {
|
||
...currentData,
|
||
status: "failed",
|
||
},
|
||
} as any);
|
||
return;
|
||
}
|
||
|
||
if (payload.stage === "completed") {
|
||
const successCount = Math.max(0, Number(payload.successCount || 0));
|
||
const failedCount = Math.max(0, Number(payload.failedCount || 0));
|
||
toast.success(`分镜视频完成:成功 ${successCount},失败 ${failedCount}`);
|
||
return;
|
||
}
|
||
|
||
if (payload.stage === "failed") {
|
||
toast.error(payload.error || "分镜视频任务失败");
|
||
}
|
||
} catch (e) {
|
||
console.error("[CanvasChat] Failed to parse data-videoStoryboard:", e);
|
||
}
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
clearStreamImageGenerationStateForChat(activeChatId);
|
||
releaseSendGuard(activeChatId);
|
||
const fallbackMessage =
|
||
error instanceof Error ? error.message : "生成失败";
|
||
const responseData = (error as any)?.data;
|
||
const isInsufficientPoints =
|
||
responseData?.error === "insufficient_points" ||
|
||
/积分不足/.test(fallbackMessage);
|
||
|
||
if (isInsufficientPoints) {
|
||
const balance = Math.max(0, Number(responseData?.balance ?? 0));
|
||
const expectedCost = Math.max(0, Number(responseData?.expectedCost ?? 0));
|
||
const unitCost = Math.max(0, Number(responseData?.unitCost ?? 0));
|
||
const imageCount =
|
||
unitCost > 0 && expectedCost > 0
|
||
? Math.max(1, Math.round(expectedCost / unitCost))
|
||
: Math.max(1, Math.round(Number(imageParamsRef.current?.imageCount || 1)));
|
||
setInsufficientPointsState({
|
||
balance,
|
||
expectedCost,
|
||
unitCost,
|
||
imageCount,
|
||
});
|
||
}
|
||
|
||
toast.error(fallbackMessage || "生成失败");
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (status === "ready") {
|
||
releaseSendGuard(activeChatId);
|
||
}
|
||
}, [activeChatId, releaseSendGuard, status]);
|
||
|
||
useEffect(() => {
|
||
const launchPayload = pendingLaunchRef.current;
|
||
if (!launchPayload?.autoSend) return;
|
||
if (!selectedModelId || status !== "ready") return;
|
||
|
||
const launchAttachments = launchPayload.attachments || [];
|
||
const textContent =
|
||
launchPayload.prompt?.trim() ||
|
||
(launchAttachments.length > 0 ? "请基于这些图片继续生成设计方案" : "");
|
||
|
||
if (!textContent && launchAttachments.length === 0) {
|
||
pendingLaunchRef.current = null;
|
||
return;
|
||
}
|
||
|
||
const parts: any[] = launchAttachments.map((attachment) => ({
|
||
type: "file" as const,
|
||
mediaType: attachment.contentType || "image/jpeg",
|
||
name: attachment.name || "参考图",
|
||
url: attachment.url,
|
||
assetId: attachment.assetId,
|
||
}));
|
||
|
||
if (textContent) {
|
||
parts.push({ type: "text" as const, text: textContent });
|
||
}
|
||
|
||
// 强制同步当前模型,避免模型切换后瞬时发送命中旧模型
|
||
currentModelIdRef.current = selectedModelId;
|
||
|
||
sendMessage({
|
||
role: "user",
|
||
parts,
|
||
});
|
||
hasHydratedHistoryRef.current = true;
|
||
|
||
pendingLaunchRef.current = null;
|
||
setInput("");
|
||
setAttachments([]);
|
||
clearSelection();
|
||
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (editableDiv) {
|
||
editableDiv.innerHTML = "";
|
||
}
|
||
}, [selectedModelId, status, sendMessage, clearSelection, launchAutoSendTick]);
|
||
|
||
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
|
||
const loadedHistorySignatureRef = useRef<string>("");
|
||
useEffect(() => {
|
||
if (historyScrollTimerRef.current) {
|
||
clearTimeout(historyScrollTimerRef.current);
|
||
historyScrollTimerRef.current = null;
|
||
}
|
||
|
||
if (!historyData || !rawChatId) return;
|
||
const shouldForceHydrate = forceHistoryHydrationRef.current === rawChatId;
|
||
if ((status === "streaming" || status === "submitted") && !shouldForceHydrate) return;
|
||
|
||
const historyMessages = historyData.messages || [];
|
||
const lastMessageId = historyMessages.at(-1)?.id || "none";
|
||
const signature = `${rawChatId}:${historyMessages.length}:${lastMessageId}`;
|
||
|
||
if (loadedHistorySignatureRef.current === signature) return;
|
||
|
||
loadedHistorySignatureRef.current = signature;
|
||
processedMessageIds.current = new Set();
|
||
hasHydratedHistoryRef.current = true;
|
||
forceHistoryHydrationRef.current = null;
|
||
setMessages(historyMessages);
|
||
setMessageViewportKey((prev) => prev + 1);
|
||
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
|
||
|
||
requestAnimationFrame(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||
});
|
||
historyScrollTimerRef.current = setTimeout(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||
historyScrollTimerRef.current = null;
|
||
}, 160);
|
||
return () => {
|
||
if (historyScrollTimerRef.current) {
|
||
clearTimeout(historyScrollTimerRef.current);
|
||
historyScrollTimerRef.current = null;
|
||
}
|
||
};
|
||
}, [historyData, rawChatId, setMessages, status]);
|
||
|
||
// 监听消息变化,提取图片并添加到画布
|
||
// 仅处理历史同步后的新消息,避免刷新后把历史图片重复回灌到画布
|
||
const processedMessageIds = useRef<Set<string>>(new Set());
|
||
const hasHydratedHistoryRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (!hasInitializedChatStateRef.current) {
|
||
hasInitializedChatStateRef.current = true;
|
||
return;
|
||
}
|
||
|
||
loadedHistorySignatureRef.current = "";
|
||
processedMessageIds.current.clear();
|
||
hasHydratedHistoryRef.current = false;
|
||
hiddenPromptRef.current = null;
|
||
autoContextRef.current = null;
|
||
pendingLaunchRef.current = null;
|
||
setMessageViewportKey((prev) => prev + 1);
|
||
setMessages([]);
|
||
setInput("");
|
||
setAttachments([]);
|
||
setIsSessionMenuOpen(false);
|
||
setDetailSlotPickerIndex(null);
|
||
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (editableDiv) {
|
||
editableDiv.innerHTML = "";
|
||
}
|
||
}, [activeChatId, setMessages]);
|
||
|
||
useEffect(() => {
|
||
if (!activeChatId) return;
|
||
|
||
const derivedTitle = deriveSessionTitleFromMessages(messages);
|
||
const now = Date.now();
|
||
|
||
setChatSessions((prevSessions) => {
|
||
const currentIndex = prevSessions.findIndex(
|
||
(session) => session.id === activeChatId
|
||
);
|
||
const fallbackTitle = `对话 ${prevSessions.length + 1}`;
|
||
|
||
if (currentIndex < 0) {
|
||
const created: CanvasChatSession = {
|
||
id: activeChatId,
|
||
title: derivedTitle || fallbackTitle,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
return [created, ...prevSessions];
|
||
}
|
||
|
||
const current = prevSessions[currentIndex];
|
||
const nextTitle = derivedTitle || current.title || fallbackTitle;
|
||
const shouldUpdate =
|
||
current.title !== nextTitle || current.updatedAt !== now;
|
||
|
||
if (!shouldUpdate) return prevSessions;
|
||
|
||
const nextSessions = [...prevSessions];
|
||
nextSessions[currentIndex] = {
|
||
...current,
|
||
title: nextTitle,
|
||
updatedAt: now,
|
||
};
|
||
nextSessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||
return nextSessions;
|
||
});
|
||
}, [activeChatId, messages]);
|
||
|
||
useEffect(() => {
|
||
if (!hasHydratedHistoryRef.current) return;
|
||
|
||
let cancelled = false;
|
||
|
||
const processNewAssistantMessages = async () => {
|
||
const suppressedMediaKeys = new Set(
|
||
(useCanvasStore.getState().getCanvasState().suppressedMediaKeys || [])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
);
|
||
const existingImageUrlKeys = new Set(
|
||
nodes
|
||
.filter((n) => n.type === "image" && (n.data as any)?.src)
|
||
.map((n) => normalizeImageIdentityUrl((n.data as any).src))
|
||
.filter(Boolean)
|
||
);
|
||
const existingMessageImageKeys = new Set(
|
||
nodes
|
||
.filter((n) => n.type === "image")
|
||
.map((n) => {
|
||
const data = ((n.data as any) || {}) as Record<string, any>;
|
||
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
|
||
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
|
||
if (!sourceMessageId) return "";
|
||
const sourceImageKey = normalizeImageIdentityUrl(
|
||
String(metadata.sourceImageKey || data.src || "").trim()
|
||
);
|
||
if (!sourceImageKey) return "";
|
||
return `${sourceMessageId}::${sourceImageKey}`;
|
||
})
|
||
.filter(Boolean)
|
||
);
|
||
const existingVideoUrlKeys = new Set(
|
||
nodes
|
||
.filter((n) => n.type === "video" && (n.data as any)?.src)
|
||
.map((n) => normalizeMediaUrl((n.data as any).src))
|
||
.filter(Boolean)
|
||
);
|
||
|
||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
||
if (cancelled) return;
|
||
const message = messages[messageIndex];
|
||
if (message.role !== "assistant" || processedMessageIds.current.has(message.id)) {
|
||
continue;
|
||
}
|
||
|
||
const nearestUserImageKeys = getNearestUserImageUrlKeys(messages, messageIndex);
|
||
const nearestUserImageUrls = getNearestUserImageUrls(messages, messageIndex);
|
||
const imageUrlSet = new Set<string>();
|
||
const videoUrlSet = new Set<string>();
|
||
const parts = (message.parts || []) as any[];
|
||
|
||
parts.forEach((part) => {
|
||
if (!part) return;
|
||
|
||
if (part.type === "text" && typeof part.text === "string") {
|
||
extractAssistantImageUrls(part.text, nearestUserImageKeys).forEach((url) =>
|
||
imageUrlSet.add(url)
|
||
);
|
||
extractVideoUrls(part.text).forEach((url) => videoUrlSet.add(url));
|
||
return;
|
||
}
|
||
|
||
const rawUrl =
|
||
typeof part.url === "string" && part.url.trim()
|
||
? part.url.trim()
|
||
: typeof part.data === "string" && part.data.trim()
|
||
? part.data.trim()
|
||
: "";
|
||
if (!rawUrl || !/^https?:\/\//i.test(rawUrl)) return;
|
||
|
||
const mediaType = String(
|
||
part.mediaType || part.mimeType || part.contentType || ""
|
||
).toLowerCase();
|
||
if (
|
||
mediaType.startsWith("video/") ||
|
||
/\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(rawUrl)
|
||
) {
|
||
videoUrlSet.add(rawUrl);
|
||
return;
|
||
}
|
||
|
||
imageUrlSet.add(rawUrl);
|
||
});
|
||
|
||
const messageAttachments = ((message as any).attachments || []) as Array<{
|
||
url?: string;
|
||
contentType?: string;
|
||
}>;
|
||
messageAttachments.forEach((attachment) => {
|
||
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)
|
||
) {
|
||
videoUrlSet.add(rawUrl);
|
||
return;
|
||
}
|
||
imageUrlSet.add(rawUrl);
|
||
});
|
||
|
||
const legacyContent =
|
||
typeof (message as any).content === "string"
|
||
? (message as any).content
|
||
: "";
|
||
if (legacyContent) {
|
||
extractAssistantImageUrls(legacyContent, nearestUserImageKeys).forEach((url) =>
|
||
imageUrlSet.add(url)
|
||
);
|
||
extractVideoUrls(legacyContent).forEach((url) => videoUrlSet.add(url));
|
||
}
|
||
|
||
const imageUrls = Array.from(imageUrlSet);
|
||
const videoUrls = Array.from(videoUrlSet);
|
||
if (imageUrls.length === 0 && videoUrls.length === 0) continue;
|
||
|
||
processedMessageIds.current.add(message.id);
|
||
const generationPrompt = findNearestUserPrompt(messages, messageIndex);
|
||
|
||
let addedImageCount = 0;
|
||
let addedVideoCount = 0;
|
||
|
||
const newImageEntries = imageUrls.reduce<Array<{ url: string; sourceImageKey: string }>>(
|
||
(acc, url) => {
|
||
const sourceImageKey = normalizeImageIdentityUrl(url);
|
||
if (!sourceImageKey || existingImageUrlKeys.has(sourceImageKey)) {
|
||
return acc;
|
||
}
|
||
const messageImageKey = `${message.id}::${sourceImageKey}`;
|
||
if (
|
||
suppressedMediaKeys.has(`image:${messageImageKey}`) ||
|
||
suppressedMediaKeys.has(`image-url:${url}`)
|
||
) {
|
||
return acc;
|
||
}
|
||
if (existingMessageImageKeys.has(messageImageKey)) {
|
||
return acc;
|
||
}
|
||
existingImageUrlKeys.add(sourceImageKey);
|
||
existingMessageImageKeys.add(messageImageKey);
|
||
acc.push({ url, sourceImageKey });
|
||
return acc;
|
||
},
|
||
[]
|
||
);
|
||
|
||
for (let index = 0; index < newImageEntries.length; index += 1) {
|
||
if (cancelled) return;
|
||
const imageEntry = newImageEntries[index];
|
||
const url = imageEntry.url;
|
||
const messageImageKey = `${message.id}::${imageEntry.sourceImageKey}`;
|
||
const hasDuplicateInStore = useCanvasStore
|
||
.getState()
|
||
.nodes.some((node) => {
|
||
if (node.type !== "image") return false;
|
||
const data = ((node.data as any) || {}) as Record<string, any>;
|
||
const metadata = ((data.metadata as any) || {}) as Record<string, any>;
|
||
const sourceMessageId = String(metadata.sourceMessageId || "").trim();
|
||
const sourceImageKey = normalizeImageIdentityUrl(
|
||
String(metadata.sourceImageKey || data.src || "").trim()
|
||
);
|
||
if (!sourceImageKey) return false;
|
||
const nodeMessageImageKey = sourceMessageId
|
||
? `${sourceMessageId}::${sourceImageKey}`
|
||
: "";
|
||
return (
|
||
nodeMessageImageKey === messageImageKey ||
|
||
sourceImageKey === imageEntry.sourceImageKey
|
||
);
|
||
});
|
||
if (hasDuplicateInStore) {
|
||
continue;
|
||
}
|
||
|
||
const dimensions = await getImageDimensions(url);
|
||
const existingNodes = nodes.length + addedImageCount + addedVideoCount;
|
||
const spacing = 20;
|
||
const x = 100 + (existingNodes % 3) * (dimensions.width + spacing);
|
||
const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing);
|
||
const compareSourceUrlRaw =
|
||
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
|
||
nearestUserImageUrls[0] ||
|
||
"";
|
||
const compareSourceUrl = String(compareSourceUrlRaw || "").trim();
|
||
const normalizedCompareSource = normalizeImageIdentityUrl(compareSourceUrl);
|
||
const canUseCompareSource =
|
||
Boolean(normalizedCompareSource) &&
|
||
normalizedCompareSource !== imageEntry.sourceImageKey;
|
||
const sourceNodeId = canUseCompareSource
|
||
? useCanvasStore
|
||
.getState()
|
||
.nodes.find((node) => {
|
||
if (node.type !== "image") return false;
|
||
const src = String(((node.data as any) || {}).src || "").trim();
|
||
return normalizeImageIdentityUrl(src) === normalizedCompareSource;
|
||
})?.id || ""
|
||
: "";
|
||
|
||
const nodeId = addNode({
|
||
type: "image",
|
||
position: { x, y },
|
||
size: { width: dimensions.width, height: dimensions.height },
|
||
data: {
|
||
src: url,
|
||
status: "completed",
|
||
title: "图片",
|
||
prompt: generationPrompt || "对话生成",
|
||
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
|
||
metadata: {
|
||
originalWidth: dimensions.originalWidth,
|
||
originalHeight: dimensions.originalHeight,
|
||
generationPrompt: generationPrompt || undefined,
|
||
sourceMessageId: message.id,
|
||
sourceImageKey: imageEntry.sourceImageKey,
|
||
compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined,
|
||
},
|
||
},
|
||
connections: sourceNodeId ? [sourceNodeId] : [],
|
||
});
|
||
void autoNameGeneratedImageNode(nodeId, url);
|
||
addedImageCount += 1;
|
||
}
|
||
|
||
const newVideoUrls = videoUrls.filter((url) => {
|
||
const key = normalizeMediaUrl(url);
|
||
if (!key || existingVideoUrlKeys.has(key)) return false;
|
||
if (suppressedMediaKeys.has(`video:${url}`)) return false;
|
||
existingVideoUrlKeys.add(key);
|
||
return true;
|
||
});
|
||
|
||
for (let index = 0; index < newVideoUrls.length; index += 1) {
|
||
if (cancelled) return;
|
||
const url = newVideoUrls[index];
|
||
const existingNodes = nodes.length + addedImageCount + addedVideoCount;
|
||
const x = 120 + (existingNodes % 2) * 420;
|
||
const y = 120 + Math.floor(existingNodes / 2) * 260;
|
||
addNode({
|
||
type: "video",
|
||
position: { x, y },
|
||
size: { width: 380, height: 220 },
|
||
data: {
|
||
src: url,
|
||
status: "completed",
|
||
prompt: generationPrompt || "对话生成",
|
||
},
|
||
connections: [],
|
||
});
|
||
addedVideoCount += 1;
|
||
}
|
||
|
||
if (addedImageCount > 0 || addedVideoCount > 0) {
|
||
const notice = [
|
||
addedImageCount > 0 ? `${addedImageCount} 张图片` : "",
|
||
addedVideoCount > 0 ? `${addedVideoCount} 条视频` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join("、");
|
||
toast.success(`已将 ${notice} 添加到画布`);
|
||
}
|
||
}
|
||
};
|
||
|
||
void processNewAssistantMessages();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [messages, addNode, nodes, autoNameGeneratedImageNode]);
|
||
|
||
useEffect(() => {
|
||
if (nodes.length < 2) return;
|
||
|
||
const seenKeys = new Set<string>();
|
||
const duplicateNodeIds: string[] = [];
|
||
|
||
for (const node of nodes) {
|
||
const identityKey = getAssistantImageNodeIdentityKey(node);
|
||
if (!identityKey) continue;
|
||
if (seenKeys.has(identityKey)) {
|
||
duplicateNodeIds.push(node.id);
|
||
continue;
|
||
}
|
||
seenKeys.add(identityKey);
|
||
}
|
||
|
||
if (duplicateNodeIds.length === 0) return;
|
||
|
||
duplicateNodeIds.forEach((nodeId) => deleteNode(nodeId));
|
||
console.warn(
|
||
"[CanvasChat] Removed duplicate assistant image nodes:",
|
||
duplicateNodeIds.length
|
||
);
|
||
}, [deleteNode, nodes]);
|
||
|
||
// 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案)
|
||
useEffect(() => {
|
||
if (!messages.length || !nodes.length) return;
|
||
|
||
const promptByImageUrl = new Map<string, string>();
|
||
messages.forEach((message, messageIndex) => {
|
||
if (message.role !== "assistant") return;
|
||
const content = message.parts?.map((p: any) => p.text || "").join("") || "";
|
||
const imageUrls = extractImageUrls(content);
|
||
if (!imageUrls.length) return;
|
||
|
||
const prompt = findNearestUserPrompt(messages, messageIndex);
|
||
if (!prompt) return;
|
||
|
||
imageUrls.forEach((url) => {
|
||
if (!promptByImageUrl.has(url)) {
|
||
promptByImageUrl.set(url, prompt);
|
||
}
|
||
});
|
||
});
|
||
|
||
nodes.forEach((node) => {
|
||
if (node.type !== "image") return;
|
||
const data = (node.data as any) || {};
|
||
const src = data.src as string | undefined;
|
||
if (!src) return;
|
||
if (!isGenericCanvasPrompt(data.prompt)) return;
|
||
|
||
const prompt = promptByImageUrl.get(src);
|
||
if (!prompt) return;
|
||
|
||
updateNode(node.id, {
|
||
data: {
|
||
...data,
|
||
prompt,
|
||
metadata: {
|
||
...(data.metadata || {}),
|
||
generationPrompt: prompt,
|
||
},
|
||
},
|
||
});
|
||
});
|
||
}, [messages, nodes, updateNode]);
|
||
|
||
// 滚动到底部
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [messages]);
|
||
|
||
// 标记是否已经为当前画布设置过标题(避免重复调用)
|
||
const hasSetCanvasTitle = useRef(false);
|
||
|
||
// 🆕 Agent模式上下文(画布图片布局)
|
||
const buildAutoContext = useCallback(() => {
|
||
const imageNodes = nodes.filter(n => n.type === 'image' && (n.data as any)?.src);
|
||
if (imageNodes.length === 0) return "";
|
||
|
||
const MAX_CONTEXT_IMAGES = 6;
|
||
const xs = imageNodes.map(n => n.position.x);
|
||
const ys = imageNodes.map(n => n.position.y);
|
||
const minX = Math.min(...xs);
|
||
const maxX = Math.max(...xs);
|
||
const minY = Math.min(...ys);
|
||
const maxY = Math.max(...ys);
|
||
const spanX = Math.max(1, maxX - minX);
|
||
const spanY = Math.max(1, maxY - minY);
|
||
|
||
const positionLabel = (node: (typeof imageNodes)[number]) => {
|
||
const relX = (node.position.x - minX) / spanX;
|
||
const relY = (node.position.y - minY) / spanY;
|
||
const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中";
|
||
const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中";
|
||
if (horizontal === "中" && vertical === "中") return "中间";
|
||
return `${vertical}${horizontal}`;
|
||
};
|
||
|
||
const selectedNodes = selectedImageNodes.length > 0 ? selectedImageNodes : [];
|
||
const contextNodes = (selectedNodes.length > 0 ? selectedNodes : imageNodes.slice(-MAX_CONTEXT_IMAGES)).slice(0, MAX_CONTEXT_IMAGES);
|
||
|
||
const lines = [
|
||
`画布图片总数: ${imageNodes.length}`,
|
||
selectedNodes.length > 0
|
||
? `用户已选中 ${selectedNodes.length} 张图片,优先处理这些`
|
||
: `用户未选中图片,默认使用最近 ${contextNodes.length} 张`,
|
||
...contextNodes.map((node, index) => {
|
||
const label = positionLabel(node);
|
||
const metadata = (node.data as any)?.metadata;
|
||
const originalWidth = Number(metadata?.originalWidth || 0);
|
||
const originalHeight = Number(metadata?.originalHeight || 0);
|
||
const originalSize =
|
||
originalWidth > 0 && originalHeight > 0
|
||
? `,原图分辨率 ${Math.round(originalWidth)}x${Math.round(originalHeight)}`
|
||
: "";
|
||
const prompt = (node.data as any)?.prompt;
|
||
return `图片${index + 1}: ${label}${originalSize}${prompt ? `,描述:${String(prompt).slice(0, 60)}` : ""}`;
|
||
}),
|
||
"若用户提到左/右/上/下/中等方位,请匹配对应位置的图片。",
|
||
];
|
||
|
||
return lines.join("\n");
|
||
}, [nodes, selectedImageNodes]);
|
||
|
||
const buildContextPayload = useCallback(() => {
|
||
const imageNodes = nodes.filter(
|
||
(node) =>
|
||
node.type === "image" &&
|
||
typeof (node.data as any)?.src === "string" &&
|
||
String((node.data as any).src).trim().length > 0,
|
||
);
|
||
|
||
if (imageNodes.length === 0) return undefined;
|
||
|
||
const MAX_CONTEXT_IMAGES = 12;
|
||
const selectedIds = new Set(selectedImageNodes.map((node) => node.id));
|
||
const prioritizedNodes =
|
||
selectedImageNodes.length > 0
|
||
? [
|
||
...selectedImageNodes,
|
||
...imageNodes.filter((node) => !selectedIds.has(node.id)),
|
||
]
|
||
: imageNodes;
|
||
const contextNodes = prioritizedNodes.slice(0, MAX_CONTEXT_IMAGES);
|
||
const selectedAssetIds = Array.from(
|
||
new Set(
|
||
selectedImageNodes
|
||
.map((node) => {
|
||
const assetId = (node.data as any)?.assetId;
|
||
return typeof assetId === "string" && assetId.trim() ? assetId.trim() : null;
|
||
})
|
||
.filter((value): value is string => Boolean(value)),
|
||
),
|
||
);
|
||
|
||
return {
|
||
source: "canvas" as const,
|
||
projectId: canvasId || undefined,
|
||
selectedNodeIds: selectedImageNodes.map((node) => node.id),
|
||
selectedAssetIds,
|
||
focusPolicy: selectedImageNodes.length > 0 ? ("selected" as const) : ("auto" as const),
|
||
canvasImageNodes: contextNodes.map((node) => ({
|
||
nodeId: node.id,
|
||
assetId:
|
||
typeof (node.data as any)?.assetId === "string" && (node.data as any).assetId.trim()
|
||
? String((node.data as any).assetId).trim()
|
||
: undefined,
|
||
url: String((node.data as any)?.src || ""),
|
||
title:
|
||
typeof (node.data as any)?.title === "string"
|
||
? String((node.data as any).title).slice(0, 256)
|
||
: undefined,
|
||
prompt:
|
||
typeof (node.data as any)?.prompt === "string"
|
||
? String((node.data as any).prompt).slice(0, 2000)
|
||
: undefined,
|
||
x: Number(node.position.x || 0),
|
||
y: Number(node.position.y || 0),
|
||
width: Number(node.size?.width || 0) || undefined,
|
||
height: Number(node.size?.height || 0) || undefined,
|
||
isSelected: selectedIds.has(node.id),
|
||
})),
|
||
};
|
||
}, [canvasId, nodes, selectedImageNodes]);
|
||
|
||
const updateAttachmentAtVideoSlot = useCallback(
|
||
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
|
||
const slotCount = Math.max(videoFrameSlots.length, 2);
|
||
setAttachments((current) => {
|
||
const slots: Array<PickedObjectAttachment | null> = Array.from(
|
||
{ length: slotCount },
|
||
(_, index) => current[index] ?? null
|
||
);
|
||
slots[slotIndex] = attachment;
|
||
while (slots.length > 0 && !slots[slots.length - 1]) {
|
||
slots.pop();
|
||
}
|
||
return slots.filter((item): item is PickedObjectAttachment => Boolean(item));
|
||
});
|
||
},
|
||
[videoFrameSlots.length]
|
||
);
|
||
|
||
const updateAttachmentAtAgentSlot = useCallback(
|
||
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
|
||
const slotCount = Math.max(agentUploadSlots.length, 1);
|
||
setAttachments((current) => {
|
||
const slots: Array<PickedObjectAttachment | null> = Array.from(
|
||
{ length: slotCount },
|
||
(_, index) => current[index] ?? null
|
||
);
|
||
slots[slotIndex] = attachment;
|
||
while (slots.length > 0 && !slots[slots.length - 1]) {
|
||
slots.pop();
|
||
}
|
||
return slots.filter((item): item is PickedObjectAttachment => Boolean(item));
|
||
});
|
||
},
|
||
[agentUploadSlots.length]
|
||
);
|
||
|
||
const fillVideoSlotFromCanvasSelection = useCallback(
|
||
(slotIndex: number) => {
|
||
const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0];
|
||
const attachment = preferredNode
|
||
? getCanvasNodeAttachment(preferredNode, `参考图 ${slotIndex + 1}`)
|
||
: null;
|
||
if (!attachment) return false;
|
||
|
||
updateAttachmentAtVideoSlot(slotIndex, attachment);
|
||
return true;
|
||
},
|
||
[getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtVideoSlot]
|
||
);
|
||
|
||
const fillAgentSlotFromCanvasSelection = useCallback(
|
||
(slotIndex: number) => {
|
||
const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0];
|
||
const attachment = preferredNode
|
||
? getCanvasNodeAttachment(preferredNode, `智能体图片 ${slotIndex + 1}`)
|
||
: null;
|
||
if (!attachment) return false;
|
||
|
||
updateAttachmentAtAgentSlot(slotIndex, attachment);
|
||
return true;
|
||
},
|
||
[getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtAgentSlot]
|
||
);
|
||
|
||
const handleVideoSlotFileChange = useCallback(
|
||
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
if (!file.type.startsWith("image/")) {
|
||
toast.error("请上传图片文件");
|
||
event.currentTarget.value = "";
|
||
return;
|
||
}
|
||
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
toast.error("图片大小不能超过 50MB");
|
||
event.currentTarget.value = "";
|
||
return;
|
||
}
|
||
|
||
setVideoSlotUploading(slotIndex);
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
|
||
if (!response.ok) {
|
||
throw new Error("上传失败");
|
||
}
|
||
|
||
const data = await response.json();
|
||
const imageUrl = data.url as string;
|
||
const assetId =
|
||
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
|
||
|
||
updateAttachmentAtVideoSlot(slotIndex, {
|
||
url: imageUrl,
|
||
name: file.name,
|
||
contentType: file.type || "image/jpeg",
|
||
assetId,
|
||
});
|
||
|
||
const dimensions = await getImageDimensions(imageUrl);
|
||
const nodeIndex = nodes.length + slotIndex;
|
||
const spacing = 20;
|
||
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
||
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
|
||
addNode({
|
||
type: "image",
|
||
position: { x, y },
|
||
size: { width: dimensions.width, height: dimensions.height },
|
||
data: {
|
||
src: imageUrl,
|
||
status: "completed",
|
||
title: file.name.replace(/\.[^.]+$/, ""),
|
||
prompt: file.name.replace(/\.[^.]+$/, ""),
|
||
metadata: {
|
||
originalWidth: dimensions.originalWidth,
|
||
originalHeight: dimensions.originalHeight,
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
toast.success(`${slotIndex === 0 ? "首帧" : "尾帧"}上传成功`);
|
||
} catch (_error) {
|
||
toast.error("图片上传失败");
|
||
} finally {
|
||
setVideoSlotUploading(null);
|
||
event.currentTarget.value = "";
|
||
}
|
||
},
|
||
[addNode, nodes.length, updateAttachmentAtVideoSlot]
|
||
);
|
||
|
||
const handleAgentSlotFileChange = useCallback(
|
||
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
if (!file.type.startsWith("image/")) {
|
||
toast.error("请上传图片文件");
|
||
event.currentTarget.value = "";
|
||
return;
|
||
}
|
||
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
toast.error("图片大小不能超过 50MB");
|
||
event.currentTarget.value = "";
|
||
return;
|
||
}
|
||
|
||
setAgentSlotUploading(slotIndex);
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
|
||
if (!response.ok) {
|
||
throw new Error("上传失败");
|
||
}
|
||
|
||
const data = await response.json();
|
||
const imageUrl = data.url as string;
|
||
const assetId =
|
||
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
|
||
|
||
updateAttachmentAtAgentSlot(slotIndex, {
|
||
url: imageUrl,
|
||
name: file.name,
|
||
contentType: file.type || "image/jpeg",
|
||
assetId,
|
||
});
|
||
|
||
const dimensions = await getImageDimensions(imageUrl);
|
||
const nodeIndex = nodes.length + slotIndex;
|
||
const spacing = 20;
|
||
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
||
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
|
||
addNode({
|
||
type: "image",
|
||
position: { x, y },
|
||
size: { width: dimensions.width, height: dimensions.height },
|
||
data: {
|
||
src: imageUrl,
|
||
status: "completed",
|
||
title: file.name.replace(/\.[^.]+$/, ""),
|
||
prompt: file.name.replace(/\.[^.]+$/, ""),
|
||
metadata: {
|
||
originalWidth: dimensions.originalWidth,
|
||
originalHeight: dimensions.originalHeight,
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
|
||
const slotLabel = agentUploadSlots[slotIndex]?.label || `第${slotIndex + 1}张`;
|
||
toast.success(`${slotLabel}上传成功`);
|
||
} catch (_error) {
|
||
toast.error("图片上传失败");
|
||
} finally {
|
||
setAgentSlotUploading(null);
|
||
event.currentTarget.value = "";
|
||
}
|
||
},
|
||
[addNode, agentUploadSlots, nodes.length, updateAttachmentAtAgentSlot]
|
||
);
|
||
|
||
const updateDetailSlotAttachment = useCallback(
|
||
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
|
||
setDetailSlots((current) => {
|
||
const next = [...current];
|
||
next[slotIndex] = attachment;
|
||
return next;
|
||
});
|
||
},
|
||
[]
|
||
);
|
||
|
||
const fillDetailSlotsFromCanvasSelection = useCallback(
|
||
(slotIndex: number) => {
|
||
if (slotIndex < 0 || slotIndex >= DETAIL_SLOT_CONFIGS.length) return 0;
|
||
if (selectedImageNodes.length === 0) return 0;
|
||
|
||
const slotRole = DETAIL_SLOT_CONFIGS[slotIndex]?.role;
|
||
const sameRoleIndexes = DETAIL_SLOT_CONFIGS.map((slot, index) =>
|
||
slot.role === slotRole ? index : -1
|
||
).filter((index) => index >= 0);
|
||
const startOffset = sameRoleIndexes.indexOf(slotIndex);
|
||
const targetIndexes =
|
||
startOffset >= 0 ? sameRoleIndexes.slice(startOffset) : sameRoleIndexes;
|
||
if (targetIndexes.length === 0) return 0;
|
||
|
||
const candidateAttachments = selectedImageNodes
|
||
.map((node, index) =>
|
||
getCanvasNodeAttachment(
|
||
node,
|
||
`${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材"}-${index + 1}`
|
||
)
|
||
)
|
||
.filter((item): item is PickedObjectAttachment => Boolean(item));
|
||
|
||
if (candidateAttachments.length === 0) return 0;
|
||
|
||
let filledCount = 0;
|
||
setDetailSlots((current) => {
|
||
const next = [...current];
|
||
for (let i = 0; i < targetIndexes.length && i < candidateAttachments.length; i += 1) {
|
||
const index = targetIndexes[i]!;
|
||
next[index] = candidateAttachments[i]!;
|
||
filledCount += 1;
|
||
}
|
||
return next;
|
||
});
|
||
return filledCount;
|
||
},
|
||
[getCanvasNodeAttachment, selectedImageNodes]
|
||
);
|
||
|
||
const activateDetailSlotCanvasPicker = useCallback(
|
||
(slotIndex: number) => {
|
||
const filled = fillDetailSlotsFromCanvasSelection(slotIndex);
|
||
if (filled > 0) {
|
||
const label = DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材";
|
||
toast.success(
|
||
filled > 1 ? `已从画布批量填入 ${filled} 张到${label}分组` : `已从画布填入${label}`
|
||
);
|
||
setDetailSlotPickerIndex(null);
|
||
return;
|
||
}
|
||
setDetailSlotPickerIndex(slotIndex);
|
||
const label = DETAIL_SLOT_CONFIGS[slotIndex]?.label || "素材";
|
||
toast.info(`已选中${label},请在画布中点击图片即可自动填入`);
|
||
},
|
||
[fillDetailSlotsFromCanvasSelection]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!isDetailGenerationMode) {
|
||
setDetailSlotPickerIndex(null);
|
||
return;
|
||
}
|
||
if (detailSlotPickerIndex === null) return;
|
||
if (selectedImageNodes.length === 0) return;
|
||
|
||
const filled = fillDetailSlotsFromCanvasSelection(detailSlotPickerIndex);
|
||
if (filled > 0) {
|
||
const label = DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材";
|
||
toast.success(
|
||
filled > 1 ? `已从画布批量填入 ${filled} 张到${label}分组` : `已从画布填入${label}`
|
||
);
|
||
setDetailSlotPickerIndex(null);
|
||
}
|
||
}, [
|
||
detailSlotPickerIndex,
|
||
fillDetailSlotsFromCanvasSelection,
|
||
isDetailGenerationMode,
|
||
selectedImageNodes.length,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!isDetailGenerationMode) {
|
||
setIsDetailWorkbenchCollapsed(false);
|
||
}
|
||
}, [isDetailGenerationMode]);
|
||
|
||
const handleDetailSlotFileChange = useCallback(
|
||
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
if (!file.type.startsWith("image/")) {
|
||
toast.error("请上传图片文件");
|
||
event.currentTarget.value = "";
|
||
return;
|
||
}
|
||
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
toast.error("图片大小不能超过 50MB");
|
||
event.currentTarget.value = "";
|
||
return;
|
||
}
|
||
|
||
setDetailSlotUploading(slotIndex);
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
|
||
if (!response.ok) {
|
||
throw new Error("上传失败");
|
||
}
|
||
|
||
const data = await response.json();
|
||
const imageUrl = String(data.url || "").trim();
|
||
const assetId =
|
||
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
|
||
if (!imageUrl) {
|
||
throw new Error("未获取到图片地址");
|
||
}
|
||
|
||
updateDetailSlotAttachment(slotIndex, {
|
||
url: imageUrl,
|
||
name: file.name,
|
||
contentType: file.type || "image/jpeg",
|
||
assetId,
|
||
});
|
||
toast.success(`${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "图片"}上传成功`);
|
||
} catch (_error) {
|
||
toast.error("图片上传失败");
|
||
} finally {
|
||
setDetailSlotUploading(null);
|
||
event.currentTarget.value = "";
|
||
}
|
||
},
|
||
[updateDetailSlotAttachment]
|
||
);
|
||
|
||
const updateLocalDetailArchive = useCallback(
|
||
(archiveId: string, patch: Partial<DetailArchive>) => {
|
||
setDetailArchives((prev) =>
|
||
prev.map((item) =>
|
||
item.id === archiveId
|
||
? { ...item, ...patch, updatedAt: patch.updatedAt || Date.now() }
|
||
: item
|
||
)
|
||
);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const createDetailArchiveInDatabase = useCallback(
|
||
async (archive: DetailArchive): Promise<DetailArchive | null> => {
|
||
try {
|
||
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ archive }),
|
||
});
|
||
const result = await response.json().catch(() => ({}));
|
||
if (!response.ok || !result?.success || !result?.data?.id) {
|
||
console.error("[CanvasChat] Failed to persist detail archive:", result?.error || response.statusText);
|
||
return null;
|
||
}
|
||
const createdAtValue =
|
||
typeof result.data.createdAt === "number"
|
||
? result.data.createdAt
|
||
: Date.parse(String(result.data.createdAt || ""));
|
||
return {
|
||
...archive,
|
||
id: String(result.data.id),
|
||
createdAt: Number.isFinite(createdAtValue) ? createdAtValue : archive.createdAt,
|
||
updatedAt: Number.isFinite(createdAtValue) ? createdAtValue : archive.createdAt,
|
||
};
|
||
} catch (error) {
|
||
console.error("[CanvasChat] Failed to persist detail archive:", error);
|
||
return null;
|
||
}
|
||
},
|
||
[canvasId]
|
||
);
|
||
|
||
const patchDetailArchiveInDatabase = useCallback(
|
||
async (archiveId: string, archive: DetailArchive) => {
|
||
try {
|
||
const response = await fetch(`/api/canvas/${canvasId}/detail-archives`, {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ archiveId, archive }),
|
||
});
|
||
const result = await response.json().catch(() => ({}));
|
||
if (!response.ok || !result?.success) {
|
||
console.error(
|
||
"[CanvasChat] Failed to update detail archive:",
|
||
result?.error || response.statusText
|
||
);
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
console.error("[CanvasChat] Failed to update detail archive:", error);
|
||
return false;
|
||
}
|
||
},
|
||
[canvasId]
|
||
);
|
||
|
||
const saveDetailArchiveSnapshot = useCallback(
|
||
async (params: {
|
||
summary?: string;
|
||
imageCount?: number;
|
||
coverUrl?: string;
|
||
prompt: string;
|
||
generationMode: DetailGenerationMode;
|
||
brief: DetailBriefForm;
|
||
slots: Array<PickedObjectAttachment | null>;
|
||
}): Promise<DetailArchive | null> => {
|
||
const archiveId = generateUUID();
|
||
const promptText = params.prompt.replace(/\s+/g, " ").trim();
|
||
const spuSeed = String(params.brief.productSpu || "").replace(/\s+/g, "").trim();
|
||
const demandSeed = String(
|
||
params.brief.demandType || params.brief.coreNeed || params.brief.usageScenario || ""
|
||
)
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
const createdAt = Date.now();
|
||
const archiveTitle =
|
||
spuSeed && demandSeed
|
||
? `${spuSeed}+${demandSeed}`
|
||
: spuSeed || demandSeed || params.brief.productName || "AI详情项目";
|
||
|
||
const archive: DetailArchive = {
|
||
id: archiveId,
|
||
title: archiveTitle,
|
||
prompt: promptText,
|
||
generationMode: params.generationMode,
|
||
brief: { ...params.brief },
|
||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||
const slot = params.slots[index];
|
||
return slot
|
||
? {
|
||
url: slot.url,
|
||
name: slot.name,
|
||
contentType: slot.contentType,
|
||
}
|
||
: null;
|
||
}),
|
||
createdAt,
|
||
updatedAt: createdAt,
|
||
summary: params.summary,
|
||
imageCount: params.imageCount,
|
||
coverUrl: params.coverUrl,
|
||
};
|
||
|
||
setDetailArchives((prev) => [archive, ...prev].slice(0, 30));
|
||
const saved = await createDetailArchiveInDatabase(archive);
|
||
if (saved?.id) {
|
||
setDetailArchives((prev) => {
|
||
const next = [saved, ...prev.filter((item) => item.id !== archive.id && item.id !== saved.id)];
|
||
return next.slice(0, 30);
|
||
});
|
||
return saved;
|
||
}
|
||
return archive;
|
||
},
|
||
[createDetailArchiveInDatabase]
|
||
);
|
||
|
||
const loadDetailArchive = useCallback((archive: DetailArchive) => {
|
||
setIsDetailGenerationMode(true);
|
||
setDetailGenerationMode(normalizeDetailGenerationMode(archive.generationMode));
|
||
setDetailBrief({ ...archive.brief });
|
||
setDetailSlots(
|
||
DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||
const slot = archive.slots[index];
|
||
return slot
|
||
? {
|
||
url: slot.url,
|
||
name: slot.name,
|
||
contentType: slot.contentType || "image/jpeg",
|
||
}
|
||
: null;
|
||
})
|
||
);
|
||
setInput(archive.prompt || "");
|
||
setDetailSections((prev) => ({
|
||
...prev,
|
||
required: true,
|
||
assets: true,
|
||
archives: true,
|
||
}));
|
||
setDetailSlotPickerIndex(null);
|
||
setTimeout(() => {
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (editableDiv) {
|
||
editableDiv.textContent = archive.prompt || "";
|
||
editableDiv.focus();
|
||
}
|
||
}, 0);
|
||
toast.success("已加载历史详情项目,可继续修改后重新生成(会自动存为新存档)");
|
||
}, []);
|
||
|
||
const deleteDetailArchive = useCallback(
|
||
async (archiveId: string) => {
|
||
if (!archiveId || deletingDetailArchiveId === archiveId) return;
|
||
const targetArchive = detailArchives.find((item) => item.id === archiveId);
|
||
const targetTitle = targetArchive?.title || "该存档";
|
||
if (!window.confirm(`确认删除“${targetTitle}”吗?删除后不可恢复。`)) {
|
||
return;
|
||
}
|
||
|
||
setDeletingDetailArchiveId(archiveId);
|
||
try {
|
||
const response = await fetch(
|
||
`/api/canvas/${canvasId}/detail-archives?archiveId=${encodeURIComponent(archiveId)}`,
|
||
{ method: "DELETE" }
|
||
);
|
||
const result = await response.json().catch(() => ({}));
|
||
if (!response.ok || !result?.success) {
|
||
throw new Error(String(result?.error || "删除失败"));
|
||
}
|
||
|
||
setDetailArchives((prev) => prev.filter((item) => item.id !== archiveId));
|
||
toast.success("已删除历史存档");
|
||
} catch (error) {
|
||
console.error("[CanvasChat] Failed to delete detail archive:", error);
|
||
toast.error(error instanceof Error ? error.message : "删除历史存档失败");
|
||
} finally {
|
||
setDeletingDetailArchiveId((current) => (current === archiveId ? null : current));
|
||
}
|
||
},
|
||
[canvasId, deletingDetailArchiveId, detailArchives]
|
||
);
|
||
|
||
const handleDetailGeneration = useCallback(async () => {
|
||
if (isDetailGenerating) return;
|
||
|
||
const brief = normalizedDetailBrief;
|
||
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
|
||
toast.error("请先补充 AI详情生成必填项:主购买人群、需求场景、核心需求");
|
||
return;
|
||
}
|
||
const productAttachments = DETAIL_PRODUCT_SLOT_INDEXES
|
||
.map((index) => detailSlots[index])
|
||
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
|
||
if (productAttachments.length < 5) {
|
||
toast.error("请先上传 5 张产品图后再执行 AI详情生成");
|
||
return;
|
||
}
|
||
const modelAttachment =
|
||
DETAIL_MODEL_SLOT_INDEX >= 0 ? detailSlots[DETAIL_MODEL_SLOT_INDEX] : null;
|
||
if (!modelAttachment?.url) {
|
||
toast.error("请上传 1 张人物模特图后再执行 AI详情生成");
|
||
return;
|
||
}
|
||
const bundleAttachments = DETAIL_BUNDLE_SLOT_INDEXES
|
||
.map((index) => detailSlots[index])
|
||
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
|
||
|
||
const userText = input
|
||
.replace(/\u200b/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
const basePrompt = brief.productName
|
||
? `${brief.productName}详情页生成`
|
||
: "电商详情页生成";
|
||
const demandHint = brief.demandType ? `,需求类型:${brief.demandType}` : "";
|
||
const detailPrompt =
|
||
userText ||
|
||
`${basePrompt},面向${brief.targetAudience},在${brief.usageScenario}场景解决${brief.coreNeed}${demandHint}`;
|
||
const archiveSnapshot = {
|
||
prompt: detailPrompt,
|
||
generationMode: detailGenerationMode,
|
||
brief: { ...detailBrief },
|
||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||
const slot = detailSlots[index];
|
||
return slot
|
||
? {
|
||
url: slot.url,
|
||
name: slot.name,
|
||
contentType: slot.contentType,
|
||
}
|
||
: null;
|
||
}),
|
||
};
|
||
const detailCount = extractDetailFrameCount(detailPrompt);
|
||
const assistantMessageId = generateUUID();
|
||
const skillLogicPreview = [
|
||
"### AI详情生成",
|
||
detailGenerationMode === "agent_layout"
|
||
? "当前模式:AGENT排版(画面与文字分层生成)"
|
||
: "当前模式:传统生图(整页直接生成)",
|
||
"我们将基于《详情升级文案方案》执行以下技能流程:",
|
||
"1. 定位卡:锁定人群/场景/触发时刻/风险与期望结果",
|
||
"2. 需求拆解:卖点转买点,确定核心需求优先级",
|
||
"3. 黄金五屏:核心卖点 -> 差异强化 -> 证据收口",
|
||
"4. FABE文案:每屏输出利益/特征/优势/证据",
|
||
"5. A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾",
|
||
"6. 字体规范:主标题64/700,副标题34/600,正文24/500,全套一致",
|
||
"7. 分镜预审:先走平面设计总监+营销总监双审,不通过先重写再放行",
|
||
"8. 逐屏质检:每屏先生成再由Agent复检,必要时自动二次修图",
|
||
"",
|
||
"正在规划分镜提示词...",
|
||
].join("\n");
|
||
const referenceImageNotes = DETAIL_SLOT_CONFIGS.map((config, index) => {
|
||
const current = detailSlots[index];
|
||
return `${index + 1}. ${config.label}${current?.name ? `(${current.name})` : ""}`;
|
||
});
|
||
const modelImageHint = `模特图固定策略:涉及人物场景时必须使用第${DETAIL_MODEL_SLOT_INDEX + 1}张模特图同一人物,不得替换人物。`;
|
||
const bundleImageHint =
|
||
bundleAttachments.length > 0
|
||
? `已上传${bundleAttachments.length}张搭配产品图:必须新增1屏场景化产品搭配展示,突出关联销售。`
|
||
: "未上传搭配产品图:不强制搭配展示屏。";
|
||
const detailBriefText = [
|
||
"【AI详情生成输入】",
|
||
brief.productSpu ? `商品SPU:${brief.productSpu}` : "",
|
||
brief.demandType ? `需求类型:${brief.demandType}` : "",
|
||
brief.productName ? `产品:${brief.productName}` : "",
|
||
`主购买人群:${brief.targetAudience}`,
|
||
brief.mainUser ? `主使用者:${brief.mainUser}` : "",
|
||
`需求场景:${brief.usageScenario}`,
|
||
brief.triggerMoment ? `触发时刻:${brief.triggerMoment}` : "",
|
||
`核心需求:${brief.coreNeed}`,
|
||
brief.riskConcern ? `用户顾虑:${brief.riskConcern}` : "",
|
||
brief.expectedResult ? `期望结果:${brief.expectedResult}` : "",
|
||
brief.coreSellingPoint ? `核心卖点关键词:${brief.coreSellingPoint}` : "",
|
||
brief.positioningStatement ? `一句话定位:${brief.positioningStatement}` : "",
|
||
brief.userVoice ? `用户原话样本:${brief.userVoice}` : "",
|
||
brief.competitorReference ? `竞品对标:${brief.competitorReference}` : "",
|
||
brief.priceBand ? `价格带:${brief.priceBand}` : "",
|
||
brief.brandTone ? `品牌语气:${brief.brandTone}` : "",
|
||
brief.bannedWords ? `禁用表达:${brief.bannedWords}` : "",
|
||
`参考图说明:${referenceImageNotes.join(";")}`,
|
||
modelImageHint,
|
||
bundleImageHint,
|
||
`输出规格:每屏 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素`,
|
||
`生成模式:${detailGenerationMode === "agent_layout" ? "AGENT排版(画面+可编辑文字层)" : "传统生图(整页出图)"}`,
|
||
`字体规范:${DETAIL_TYPOGRAPHY_LOCK.title};${DETAIL_TYPOGRAPHY_LOCK.subtitle};${DETAIL_TYPOGRAPHY_LOCK.body}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(detailPrompt) ? "en" : "zh";
|
||
let workingArchive: DetailArchive | null = null;
|
||
let completedDetailImageCount = 0;
|
||
const generatedImages: Array<{ originalUrl: string; thumbnailUrl: string }> = [];
|
||
let detailPlaceholderNodeIds: string[] = [];
|
||
const detailPlaceholderSpacing = 24;
|
||
const detailPlaceholderItemWidth = DETAIL_TARGET_WIDTH;
|
||
const detailPlaceholderItemHeight = DETAIL_TARGET_HEIGHT;
|
||
const detailPlaceholderStartX = 120;
|
||
const detailPlaceholderStartY = 120;
|
||
const getDetailPlaceholderPosition = (index: number) => ({
|
||
x: detailPlaceholderStartX,
|
||
y:
|
||
detailPlaceholderStartY +
|
||
index * (detailPlaceholderItemHeight + detailPlaceholderSpacing),
|
||
});
|
||
const expectedPlaceholderCount =
|
||
bundleAttachments.length > 0 ? Math.min(20, detailCount + 1) : detailCount;
|
||
const patchDetailPlaceholder = (
|
||
index: number,
|
||
options: {
|
||
label?: string;
|
||
status?: "generating" | "completed" | "failed";
|
||
src?: string;
|
||
prompt?: string;
|
||
title?: string;
|
||
metadata?: Record<string, unknown>;
|
||
} = {}
|
||
) => {
|
||
const nodeId = detailPlaceholderNodeIds[index];
|
||
if (!nodeId) return;
|
||
const storeState = useCanvasStore.getState();
|
||
const currentNode = storeState.getNode(nodeId);
|
||
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
||
const currentMetadata = ((currentData.metadata as any) || {}) as Record<string, any>;
|
||
const currentStatus =
|
||
currentData.status === "completed" || currentData.status === "failed"
|
||
? currentData.status
|
||
: "generating";
|
||
const nextStatus = options.status || currentStatus;
|
||
updateNode(nodeId, {
|
||
size: {
|
||
width: detailPlaceholderItemWidth,
|
||
height: detailPlaceholderItemHeight,
|
||
},
|
||
data: {
|
||
...currentData,
|
||
src: String(options.src ?? currentData.src ?? ""),
|
||
status: nextStatus,
|
||
prompt: options.prompt ?? currentData.prompt,
|
||
title: options.title ?? currentData.title ?? `详情图-${index + 1}`,
|
||
metadata: {
|
||
...currentMetadata,
|
||
detailSectionIndex: index + 1,
|
||
detailTargetWidth: DETAIL_TARGET_WIDTH,
|
||
detailTargetHeight: DETAIL_TARGET_HEIGHT,
|
||
detailGeneratingPlaceholder: nextStatus !== "completed",
|
||
detailProgressLabel:
|
||
options.label ??
|
||
currentMetadata.detailProgressLabel ??
|
||
(nextStatus === "failed" ? "生成失败" : "平面设计中..."),
|
||
...(options.metadata || {}),
|
||
},
|
||
},
|
||
});
|
||
};
|
||
const ensureDetailPlaceholders = (count: number, defaultLabel: string) => {
|
||
for (let index = detailPlaceholderNodeIds.length; index < count; index += 1) {
|
||
const { x, y } = getDetailPlaceholderPosition(index);
|
||
const nodeId = addNode({
|
||
type: "image",
|
||
position: { x, y },
|
||
size: { width: detailPlaceholderItemWidth, height: detailPlaceholderItemHeight },
|
||
data: {
|
||
src: "",
|
||
status: "generating",
|
||
prompt: detailPrompt,
|
||
title: `详情图-${index + 1}`,
|
||
metadata: {
|
||
detailSectionIndex: index + 1,
|
||
detailTargetWidth: DETAIL_TARGET_WIDTH,
|
||
detailTargetHeight: DETAIL_TARGET_HEIGHT,
|
||
detailGeneratingPlaceholder: true,
|
||
detailProgressLabel: defaultLabel,
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
detailPlaceholderNodeIds.push(nodeId);
|
||
}
|
||
};
|
||
const markExtraPlaceholders = (startIndex: number, label: string) => {
|
||
for (let index = startIndex; index < detailPlaceholderNodeIds.length; index += 1) {
|
||
patchDetailPlaceholder(index, {
|
||
status: "failed",
|
||
label,
|
||
metadata: {
|
||
detailSkipped: true,
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
setIsDetailWorkbenchCollapsed(true);
|
||
setIsDetailGenerating(true);
|
||
setDetailProgress({
|
||
status: "running",
|
||
stage: "Agent 正在解析需求并规划详情结构...",
|
||
current: 0,
|
||
total: Math.max(1, detailCount * 2 + 1 + DETAIL_REVIEW_COUNT),
|
||
});
|
||
ensureDetailPlaceholders(expectedPlaceholderCount, "需求解析与分镜规划中...");
|
||
try {
|
||
workingArchive = await saveDetailArchiveSnapshot({
|
||
summary: `进行中:已完成 0/${detailCount} 张详情图`,
|
||
imageCount: 0,
|
||
coverUrl: undefined,
|
||
prompt: archiveSnapshot.prompt,
|
||
generationMode: archiveSnapshot.generationMode,
|
||
brief: archiveSnapshot.brief,
|
||
slots: archiveSnapshot.slots,
|
||
});
|
||
|
||
const detailReferenceAttachments = DETAIL_SLOT_CONFIGS.map((config, index) => {
|
||
const current = detailSlots[index];
|
||
if (!current?.url) return null;
|
||
return {
|
||
...current,
|
||
name: `${config.label}${current.name ? `-${current.name}` : ""}`,
|
||
};
|
||
}).filter((item): item is PickedObjectAttachment => Boolean(item));
|
||
const userParts: any[] = detailReferenceAttachments.map((item) => ({
|
||
type: "file",
|
||
mediaType: item.contentType,
|
||
name: item.name,
|
||
url: item.url,
|
||
}));
|
||
userParts.push({
|
||
type: "text",
|
||
text: `${detailPrompt}\n\n${detailBriefText}`,
|
||
});
|
||
|
||
const userMessage: any = {
|
||
id: generateUUID(),
|
||
role: "user",
|
||
parts: userParts,
|
||
};
|
||
const assistantPendingMessage: any = {
|
||
id: assistantMessageId,
|
||
role: "assistant",
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: skillLogicPreview,
|
||
},
|
||
],
|
||
};
|
||
|
||
setMessages((prev) => [...prev, userMessage, assistantPendingMessage]);
|
||
|
||
setInput("");
|
||
setAttachments([]);
|
||
clearSelection();
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (editableDiv) {
|
||
editableDiv.innerHTML = "";
|
||
}
|
||
|
||
const planResponse = await fetch("/api/canvas/detail-plan", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
prompt: detailPrompt,
|
||
count: detailCount,
|
||
generationMode: detailGenerationMode,
|
||
referenceImages: detailReferenceAttachments.map((item) => item.url).slice(0, 12),
|
||
referenceImageNotes,
|
||
modelImageUrl: modelAttachment.url,
|
||
bundleImageUrls: bundleAttachments.map((item) => item.url),
|
||
outputLanguage,
|
||
brief,
|
||
}),
|
||
});
|
||
|
||
const planResult = await planResponse.json();
|
||
if (!planResponse.ok || !planResult?.success) {
|
||
throw new Error(planResult?.error || "详情分镜规划失败");
|
||
}
|
||
|
||
const planData = (planResult.data || {}) as DetailPlanResponse;
|
||
const activeDetailGenerationMode = normalizeDetailGenerationMode(
|
||
planData.generationMode || detailGenerationMode
|
||
);
|
||
const perScreenSteps = activeDetailGenerationMode === "agent_layout" ? 1 : 2;
|
||
const prompts = Array.isArray(planData.prompts)
|
||
? planData.prompts.map((item) => String(item || "").trim()).filter(Boolean)
|
||
: [];
|
||
if (prompts.length === 0) {
|
||
throw new Error("未获取到有效分镜提示词");
|
||
}
|
||
const screenSpecs = Array.isArray(planData.screens) ? planData.screens : [];
|
||
const reviewPlans = buildDetailReviewPlans({
|
||
brief,
|
||
summary: planData.summary,
|
||
language: outputLanguage,
|
||
count: DETAIL_REVIEW_COUNT,
|
||
});
|
||
const totalSteps = prompts.length * perScreenSteps + 1 + reviewPlans.length;
|
||
const qualityIssues = Array.isArray(planData.qualityChecks?.issues)
|
||
? planData.qualityChecks?.issues.filter(Boolean)
|
||
: [];
|
||
const preflightIssues = Array.isArray(planData.preflight?.issues)
|
||
? planData.preflight?.issues.filter(Boolean)
|
||
: [];
|
||
const preflightDesignScore = Number(planData.preflight?.designDirectorScore || 0);
|
||
const preflightMarketingScore = Number(planData.preflight?.marketingDirectorScore || 0);
|
||
const preflightPassed = planData.preflight?.passed !== false;
|
||
setDetailProgress({
|
||
status: "running",
|
||
stage:
|
||
planData.qualityChecks?.passed === false
|
||
? `分镜预审未通过:${qualityIssues[0] || "已自动切换为稳定方案"}`
|
||
: preflightPassed
|
||
? `双总监预审通过(设计${preflightDesignScore || "--"} / 营销${preflightMarketingScore || "--"}),开始逐屏生成`
|
||
: `双总监预审提示:${preflightIssues[0] || "已自动重写分镜后放行"}`,
|
||
current: 1,
|
||
total: totalSteps,
|
||
});
|
||
|
||
const modelForGeneration =
|
||
selectedModelId !== "auto"
|
||
? selectedModelId
|
||
: siteSettings?.canvasDetailDefaultModelId ||
|
||
siteSettings?.canvasAutoModeDefaultModelId ||
|
||
siteSettings?.canvasDefaultModelId ||
|
||
undefined;
|
||
ensureDetailPlaceholders(prompts.length, "需求解析与分镜规划中...");
|
||
if (detailPlaceholderNodeIds.length > prompts.length) {
|
||
markExtraPlaceholders(prompts.length, "本轮未使用");
|
||
}
|
||
const isAgentLayoutMode = activeDetailGenerationMode === "agent_layout";
|
||
const titleFontSize = Math.max(
|
||
36,
|
||
Math.min(
|
||
80,
|
||
extractFirstNumber(planData.globalStyle?.typography?.title || "", 64)
|
||
)
|
||
);
|
||
const subtitleFontSize = Math.max(
|
||
24,
|
||
Math.min(
|
||
50,
|
||
extractFirstNumber(planData.globalStyle?.typography?.subtitle || "", 34)
|
||
)
|
||
);
|
||
const bodyFontSize = Math.max(
|
||
18,
|
||
Math.min(
|
||
36,
|
||
extractFirstNumber(planData.globalStyle?.typography?.body || "", 24)
|
||
)
|
||
);
|
||
const titleFontWeight = extractFontWeight(planData.globalStyle?.typography?.title || "", "700");
|
||
const subtitleFontWeight = extractFontWeight(
|
||
planData.globalStyle?.typography?.subtitle || "",
|
||
"600"
|
||
);
|
||
const bodyFontWeight = extractFontWeight(planData.globalStyle?.typography?.body || "", "500");
|
||
const createAgentLayoutTextLayers = (params: {
|
||
nodeId: string;
|
||
screenIndex: number;
|
||
screen?: DetailPlanScreenSpec;
|
||
}) => {
|
||
if (!isAgentLayoutMode) return;
|
||
const storeState = useCanvasStore.getState();
|
||
const imageNode = storeState.getNode(params.nodeId);
|
||
if (!imageNode) return;
|
||
const x = Number(imageNode.position?.x || 0);
|
||
const y = Number(imageNode.position?.y || 0);
|
||
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
|
||
const safeLeft = Math.round(width * 0.07);
|
||
const textWidth = Math.max(240, Math.round(width - safeLeft * 2));
|
||
const screen = params.screen;
|
||
const titleText = String(screen?.title || "").trim();
|
||
const subtitleText = String(screen?.subtitle || "").trim();
|
||
const bodyText = String(screen?.copy || "").trim();
|
||
|
||
if (titleText) {
|
||
addNode({
|
||
type: "text",
|
||
position: { x: x + safeLeft, y: y + 72 },
|
||
size: { width: textWidth, height: 140 },
|
||
data: {
|
||
content: titleText,
|
||
fontSize: params.screenIndex === 0 ? titleFontSize : Math.round(titleFontSize * 0.86),
|
||
fontWeight: titleFontWeight,
|
||
color: "#111827",
|
||
lineHeight: 1.15,
|
||
textAlign: "left",
|
||
transparent: true,
|
||
},
|
||
connections: [],
|
||
parentId: params.nodeId,
|
||
});
|
||
}
|
||
|
||
if (subtitleText) {
|
||
addNode({
|
||
type: "text",
|
||
position: { x: x + safeLeft, y: y + 214 },
|
||
size: { width: textWidth, height: 120 },
|
||
data: {
|
||
content: subtitleText,
|
||
fontSize: subtitleFontSize,
|
||
fontWeight: subtitleFontWeight,
|
||
color: "#374151",
|
||
lineHeight: 1.22,
|
||
textAlign: "left",
|
||
transparent: true,
|
||
},
|
||
connections: [],
|
||
parentId: params.nodeId,
|
||
});
|
||
}
|
||
|
||
if (bodyText) {
|
||
addNode({
|
||
type: "text",
|
||
position: { x: x + safeLeft, y: y + 304 },
|
||
size: { width: textWidth, height: 260 },
|
||
data: {
|
||
content: bodyText,
|
||
fontSize: bodyFontSize,
|
||
fontWeight: bodyFontWeight,
|
||
color: "#4b5563",
|
||
lineHeight: 1.38,
|
||
textAlign: "left",
|
||
transparent: true,
|
||
},
|
||
connections: [],
|
||
parentId: params.nodeId,
|
||
});
|
||
}
|
||
};
|
||
for (let index = 0; index < prompts.length; index += 1) {
|
||
const screen = screenSpecs[index];
|
||
patchDetailPlaceholder(index, {
|
||
label: "分镜规划完成",
|
||
status: "generating",
|
||
prompt: String(screen?.visualPrompt || prompts[index] || "").trim(),
|
||
title: `详情图-${index + 1}`,
|
||
metadata: {
|
||
detailTitle: screen?.title || "",
|
||
detailSubtitle: screen?.subtitle || "",
|
||
detailScreenType: screen?.screenType || "",
|
||
detailLayoutNote: screen?.layoutNote || "",
|
||
detailPlanSummary: planData.summary || "",
|
||
detailGlobalStyle: planData.globalStyle || null,
|
||
detailPreflight: planData.preflight || null,
|
||
detailGenerationMode: activeDetailGenerationMode,
|
||
detailGeneratingPlaceholder: true,
|
||
},
|
||
});
|
||
}
|
||
const preflightLabel = planData.preflight?.rewriteApplied
|
||
? "双总监预审重写完成"
|
||
: "双总监预审通过";
|
||
for (let index = 0; index < prompts.length; index += 1) {
|
||
patchDetailPlaceholder(index, {
|
||
label: preflightLabel,
|
||
status: "generating",
|
||
});
|
||
}
|
||
for (let index = 0; index < prompts.length; index += 1) {
|
||
patchDetailPlaceholder(index, {
|
||
label: `排队待生成 ${index + 1}/${prompts.length}`,
|
||
status: "generating",
|
||
});
|
||
}
|
||
for (let index = 0; index < prompts.length; index += 1) {
|
||
const screen = screenSpecs[index];
|
||
const useModelForScene = Boolean(screen?.useModel);
|
||
const bundleShowcase = Boolean((screen as any)?.bundleShowcase);
|
||
const bundleReason = String((screen as any)?.bundleReason || "").trim();
|
||
const promptText =
|
||
String(screen?.visualPrompt || "").trim() || prompts[index]!;
|
||
const generationStepCurrent = 2 + index * perScreenSteps;
|
||
const reviewStepCurrent = generationStepCurrent + 1;
|
||
patchDetailPlaceholder(index, {
|
||
label: `主图首稿生成中 ${index + 1}/${prompts.length}`,
|
||
status: "generating",
|
||
prompt: promptText,
|
||
});
|
||
setDetailProgress({
|
||
status: "running",
|
||
stage: `正在生成第 ${index + 1}/${prompts.length} 屏详情图`,
|
||
current: generationStepCurrent,
|
||
total: totalSteps,
|
||
});
|
||
const globalStyle = planData.globalStyle;
|
||
const styleDirective = [
|
||
globalStyle?.designLanguage ? `全局风格:${globalStyle.designLanguage}` : "",
|
||
globalStyle?.typography?.fontFamily
|
||
? `字体家族统一:${globalStyle.typography.fontFamily}`
|
||
: "",
|
||
globalStyle?.typography?.title ? `标题规范:${globalStyle.typography.title}` : "",
|
||
globalStyle?.typography?.subtitle
|
||
? `副标题规范:${globalStyle.typography.subtitle}`
|
||
: "",
|
||
globalStyle?.typography?.body ? `正文规范:${globalStyle.typography.body}` : "",
|
||
globalStyle?.layout?.grid ? `布局栅格:${globalStyle.layout.grid}` : "",
|
||
globalStyle?.layout?.safeArea ? `安全区:${globalStyle.layout.safeArea}` : "",
|
||
globalStyle?.layout?.rhythm ? `留白节奏:${globalStyle.layout.rhythm}` : "",
|
||
globalStyle?.palette ? `配色:${globalStyle.palette}` : "",
|
||
`固定字体锁定:${DETAIL_TYPOGRAPHY_LOCK.title}`,
|
||
`固定副标题锁定:${DETAIL_TYPOGRAPHY_LOCK.subtitle}`,
|
||
`固定正文锁定:${DETAIL_TYPOGRAPHY_LOCK.body}`,
|
||
`固定排版安全区:${DETAIL_TYPOGRAPHY_LOCK.safeArea}`,
|
||
`固定留白节奏:${DETAIL_TYPOGRAPHY_LOCK.rhythm}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join(";");
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: isAgentLayoutMode
|
||
? `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏画面层,随后自动渲染可编辑文字层...`
|
||
: `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 屏,并锁定统一字体与版式...`,
|
||
},
|
||
],
|
||
}
|
||
: message,
|
||
),
|
||
);
|
||
|
||
const generationPrompt = sanitizeDetailImageModelPrompt([
|
||
promptText,
|
||
styleDirective ? `全局样式约束:${styleDirective}` : "",
|
||
screen?.screenType
|
||
? `分镜类型:${screen.screenType}(A=技术公信,B=场景共鸣,C=细节佐证)`
|
||
: "",
|
||
index === 0
|
||
? "首屏封面规则:必须做成海报主视觉,单一主体强焦点,产品主体占画面55%-70%,顶部标题区清晰留白,禁止拼贴式说明图。"
|
||
: "",
|
||
useModelForScene
|
||
? `人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。`
|
||
: "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。",
|
||
screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "",
|
||
bundleShowcase
|
||
? "搭配展示规则:本屏必须同场景展示主产品与搭配产品,主次层级清晰,突出组合购买价值与加购理由。"
|
||
: "",
|
||
bundleReason ? `搭配依据:${bundleReason}` : "",
|
||
isAgentLayoutMode
|
||
? "生成策略:仅生成画面层(产品、光影、材质、场景),不要生成任何文字、数字、Logo、标语、水印。"
|
||
: screen?.title
|
||
? `本屏标题:${screen.title}`
|
||
: "",
|
||
isAgentLayoutMode ? "" : screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "",
|
||
isAgentLayoutMode ? "" : screen?.copy ? `本屏文案说明:${screen.copy}` : "",
|
||
screen?.layoutNote ? `本屏排版说明:${screen.layoutNote}` : "",
|
||
screen?.fabe?.b ? `B(利益):${screen.fabe.b}` : "",
|
||
screen?.fabe?.f ? `F(特征):${screen.fabe.f}` : "",
|
||
screen?.fabe?.a ? `A(优势):${screen.fabe.a}` : "",
|
||
screen?.fabe?.e ? `E(证据):${screen.fabe.e}` : "",
|
||
`输出为竖版电商详情图,尺寸固定 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素。`,
|
||
isAgentLayoutMode
|
||
? "硬性约束:禁止输出任何可见文字或字符,排版文字由后续程序化图层渲染。"
|
||
: "要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰,标题不得包含“第X屏”。",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n"));
|
||
const styleAnchorUrl =
|
||
index > 0 ? generatedImages[0]?.originalUrl || generatedImages[0]?.thumbnailUrl : null;
|
||
const generationReferenceImagesBase = [
|
||
...productAttachments.map((item) => item.url),
|
||
...(bundleShowcase ? bundleAttachments.map((item) => item.url) : []),
|
||
...(useModelForScene ? [modelAttachment.url] : []),
|
||
...(styleAnchorUrl ? [styleAnchorUrl] : []),
|
||
].filter(Boolean);
|
||
const generateDetailFrame = async (
|
||
promptOverride: string,
|
||
extraReferenceImages: string[] = []
|
||
): Promise<{ originalUrl: string; thumbnailUrl: string }> => {
|
||
const sanitizedPrompt = sanitizeDetailImageModelPrompt(promptOverride);
|
||
const generationReferenceImages = [
|
||
...generationReferenceImagesBase,
|
||
...extraReferenceImages,
|
||
].filter(Boolean);
|
||
const result = await requestCanvasImageGeneration({
|
||
prompt: sanitizedPrompt,
|
||
model: modelForGeneration,
|
||
aspectRatio: "9:16",
|
||
imageSize: imageParams.imageSize,
|
||
imageCount: 1,
|
||
negativePrompt: imageParams.negativePrompt,
|
||
referenceImage: productAttachments[0]?.url,
|
||
referenceImages: generationReferenceImages,
|
||
});
|
||
if (!Array.isArray(result?.images) || result.images.length === 0) {
|
||
throw new Error(`第 ${index + 1} 张生成失败`);
|
||
}
|
||
const generatedImage = result.images[0];
|
||
return {
|
||
originalUrl: String(generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "").trim(),
|
||
thumbnailUrl: String(generatedImage?.thumbnailUrl || generatedImage?.originalUrl || "").trim(),
|
||
};
|
||
};
|
||
|
||
const firstPassImage = await generateDetailFrame(generationPrompt);
|
||
let finalGenerated = firstPassImage;
|
||
let finalGenerationPrompt = generationPrompt;
|
||
let screenReviewResult: DetailScreenReviewResult | null = null;
|
||
if (!isAgentLayoutMode) {
|
||
patchDetailPlaceholder(index, {
|
||
label: `双总监审稿中 ${index + 1}/${prompts.length}`,
|
||
status: "generating",
|
||
});
|
||
|
||
setDetailProgress({
|
||
status: "running",
|
||
stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`,
|
||
current: reviewStepCurrent,
|
||
total: totalSteps,
|
||
});
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`,
|
||
},
|
||
],
|
||
}
|
||
: message,
|
||
),
|
||
);
|
||
|
||
try {
|
||
const reviewResponse = await fetch("/api/canvas/detail-review", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
outputLanguage,
|
||
imageUrl: firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
|
||
referenceImages: generationReferenceImagesBase.slice(0, 6),
|
||
brief,
|
||
globalStyle: planData.globalStyle,
|
||
screen: {
|
||
index: index + 1,
|
||
title: screen?.title || "",
|
||
subtitle: screen?.subtitle || "",
|
||
copy: screen?.copy || "",
|
||
layoutNote: screen?.layoutNote || "",
|
||
visualPrompt: generationPrompt,
|
||
screenType: screen?.screenType || "",
|
||
},
|
||
}),
|
||
});
|
||
const reviewResult = await reviewResponse.json().catch(() => ({}));
|
||
if (reviewResponse.ok && reviewResult?.success) {
|
||
screenReviewResult = (reviewResult?.data || {}) as DetailScreenReviewResult;
|
||
}
|
||
} catch (reviewError) {
|
||
console.error("[CanvasChat] Detail screen review failed:", reviewError);
|
||
}
|
||
|
||
const shouldRegenerate =
|
||
screenReviewResult?.pass === false &&
|
||
typeof screenReviewResult?.revisedPrompt === "string" &&
|
||
screenReviewResult.revisedPrompt.trim().length > 20;
|
||
|
||
if (shouldRegenerate) {
|
||
patchDetailPlaceholder(index, {
|
||
label: `二次优化生成中 ${index + 1}/${prompts.length}`,
|
||
status: "generating",
|
||
});
|
||
const issueHint = Array.isArray(screenReviewResult?.issues)
|
||
? screenReviewResult.issues.filter(Boolean).slice(0, 2).join(";")
|
||
: "";
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏质检发现可优化项${issueHint ? `:${issueHint}` : ""},正在二次修图...`,
|
||
},
|
||
],
|
||
}
|
||
: message,
|
||
),
|
||
);
|
||
|
||
for (let retry = 0; retry < DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS; retry += 1) {
|
||
try {
|
||
const revisedPrompt = sanitizeDetailImageModelPrompt(
|
||
String(screenReviewResult?.revisedPrompt || "").trim()
|
||
);
|
||
const regenerated = await generateDetailFrame(revisedPrompt, [
|
||
firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
|
||
]);
|
||
finalGenerated = regenerated;
|
||
finalGenerationPrompt = revisedPrompt;
|
||
break;
|
||
} catch (regenError) {
|
||
if (retry === DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS - 1) {
|
||
console.error("[CanvasChat] Detail second-pass generation failed:", regenError);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
patchDetailPlaceholder(index, {
|
||
label: `排版层渲染中 ${index + 1}/${prompts.length}`,
|
||
status: "generating",
|
||
});
|
||
}
|
||
|
||
generatedImages.push(finalGenerated);
|
||
completedDetailImageCount = generatedImages.length;
|
||
|
||
if (workingArchive?.id) {
|
||
const progressSummary = `进行中:已完成 ${completedDetailImageCount}/${prompts.length} 张详情图`;
|
||
const progressCoverUrl = generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl;
|
||
const nextArchive: DetailArchive = {
|
||
...workingArchive,
|
||
summary: progressSummary,
|
||
imageCount: completedDetailImageCount,
|
||
coverUrl: progressCoverUrl,
|
||
};
|
||
workingArchive = nextArchive;
|
||
updateLocalDetailArchive(nextArchive.id, {
|
||
summary: progressSummary,
|
||
imageCount: completedDetailImageCount,
|
||
coverUrl: progressCoverUrl,
|
||
});
|
||
await patchDetailArchiveInDatabase(nextArchive.id, nextArchive);
|
||
}
|
||
|
||
const dimensions = await getImageDimensions(
|
||
finalGenerated.originalUrl || finalGenerated.thumbnailUrl
|
||
);
|
||
const targetNodeId = detailPlaceholderNodeIds[index];
|
||
if (targetNodeId) {
|
||
const storeState = useCanvasStore.getState();
|
||
const currentNode = storeState.getNode(targetNodeId);
|
||
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
||
updateNode(targetNodeId, {
|
||
size: {
|
||
width: DETAIL_TARGET_WIDTH,
|
||
height: DETAIL_TARGET_HEIGHT,
|
||
},
|
||
data: {
|
||
...currentData,
|
||
src: finalGenerated.originalUrl || finalGenerated.thumbnailUrl,
|
||
status: "completed",
|
||
prompt: promptText,
|
||
title: `详情图-${index + 1}`,
|
||
metadata: {
|
||
...(currentData.metadata || {}),
|
||
originalWidth: dimensions.originalWidth,
|
||
originalHeight: dimensions.originalHeight,
|
||
generationPrompt: finalGenerationPrompt,
|
||
detailSectionIndex: index + 1,
|
||
detailPlanSummary: planData.summary || "",
|
||
detailTargetWidth: DETAIL_TARGET_WIDTH,
|
||
detailTargetHeight: DETAIL_TARGET_HEIGHT,
|
||
detailTitle: screen?.title || "",
|
||
detailSubtitle: screen?.subtitle || "",
|
||
detailScreenType: screen?.screenType || "",
|
||
detailLayoutNote: screen?.layoutNote || "",
|
||
detailUseModel: useModelForScene,
|
||
detailModelReason: screen?.modelReason || "",
|
||
detailBundleShowcase: bundleShowcase,
|
||
detailBundleReason: bundleReason,
|
||
detailGlobalStyle: planData.globalStyle || null,
|
||
detailGenerationMode: activeDetailGenerationMode,
|
||
detailScreenReview: screenReviewResult || null,
|
||
detailGeneratingPlaceholder: false,
|
||
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,
|
||
},
|
||
},
|
||
});
|
||
createAgentLayoutTextLayers({
|
||
nodeId: targetNodeId,
|
||
screenIndex: index,
|
||
screen,
|
||
});
|
||
}
|
||
}
|
||
if (generatedImages.length !== prompts.length) {
|
||
throw new Error(
|
||
`详情主图未完整生成:计划 ${prompts.length} 屏,实际完成 ${generatedImages.length} 屏`
|
||
);
|
||
}
|
||
|
||
const reviewGroups: DetailReviewGroup[] = [];
|
||
const reviewFailures: string[] = [];
|
||
const detailStartX = 120;
|
||
const detailStartY = 120;
|
||
const detailSpacing = 24;
|
||
const reviewStartY =
|
||
detailStartY + prompts.length * (DETAIL_TARGET_HEIGHT + detailSpacing) + 120;
|
||
|
||
for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) {
|
||
const reviewPlan = reviewPlans[reviewIndex]!;
|
||
const reviewStepCurrent = prompts.length * 2 + 2 + reviewIndex;
|
||
setDetailProgress({
|
||
status: "running",
|
||
stage: `正在生成买家评价与晒图 ${reviewIndex + 1}/${reviewPlans.length}`,
|
||
current: reviewStepCurrent,
|
||
total: totalSteps,
|
||
});
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: `### AI详情生成\n详情图已完成,正在生成买家评价与晒图 ${reviewIndex + 1}/${reviewPlans.length}...`,
|
||
},
|
||
],
|
||
}
|
||
: message,
|
||
),
|
||
);
|
||
|
||
const reviewPrompt = [
|
||
reviewPlan.imagePrompt,
|
||
`评价文本:${reviewPlan.reviewText}`,
|
||
"输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。",
|
||
"人物规则:买家秀必须使用真实买家视角,不得复用上传的官方模特图形象。",
|
||
`同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图要保持同一叙事主题但角度不同。`,
|
||
"图片内容要看得见产品主体和真实使用痕迹,适合放在详情页评价区。",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
|
||
const reviewReferenceImages = [
|
||
...productAttachments.map((item) => item.url),
|
||
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
|
||
].filter(Boolean);
|
||
|
||
const reviewResult = await requestCanvasImageGeneration({
|
||
prompt: reviewPrompt,
|
||
model: modelForGeneration,
|
||
aspectRatio: "1:1",
|
||
imageSize: imageParams.imageSize,
|
||
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
|
||
negativePrompt: imageParams.negativePrompt,
|
||
referenceImage: productAttachments[0]?.url,
|
||
referenceImages: reviewReferenceImages,
|
||
});
|
||
const reviewImagesRaw = Array.isArray(reviewResult?.images) ? reviewResult.images : [];
|
||
if (reviewImagesRaw.length === 0) {
|
||
reviewFailures.push(`评价${reviewIndex + 1}生成失败`);
|
||
continue;
|
||
}
|
||
|
||
const reviewImages = reviewImagesRaw
|
||
.slice(0, DETAIL_REVIEW_IMAGE_COUNT)
|
||
.map((item: any) => ({
|
||
originalUrl: String(item?.originalUrl || item?.thumbnailUrl || "").trim(),
|
||
thumbnailUrl: String(item?.thumbnailUrl || item?.originalUrl || "").trim(),
|
||
}))
|
||
.filter((item: { originalUrl: string; thumbnailUrl: string }) => Boolean(item.originalUrl));
|
||
|
||
if (reviewImages.length === 0) {
|
||
reviewFailures.push(`评价${reviewIndex + 1}未返回有效图片`);
|
||
continue;
|
||
}
|
||
|
||
reviewGroups.push({
|
||
reviewText: reviewPlan.reviewText,
|
||
images: reviewImages,
|
||
});
|
||
|
||
reviewImages.forEach((image: { originalUrl: string; thumbnailUrl: string }, imageIndex: number) => {
|
||
const x =
|
||
detailStartX +
|
||
imageIndex * (DETAIL_REVIEW_IMAGE_WIDTH + 20);
|
||
const y =
|
||
reviewStartY +
|
||
reviewIndex * (DETAIL_REVIEW_IMAGE_HEIGHT + 72);
|
||
addNode({
|
||
type: "image",
|
||
position: { x, y },
|
||
size: {
|
||
width: DETAIL_REVIEW_IMAGE_WIDTH,
|
||
height: DETAIL_REVIEW_IMAGE_HEIGHT,
|
||
},
|
||
data: {
|
||
src: image.originalUrl || image.thumbnailUrl,
|
||
status: "completed",
|
||
prompt: reviewPlan.imagePrompt,
|
||
title: `买家晒图-${reviewPlan.index}-${imageIndex + 1}`,
|
||
note: reviewPlan.reviewText,
|
||
metadata: {
|
||
detailReviewIndex: reviewPlan.index,
|
||
detailReviewTitle: reviewPlan.title,
|
||
detailReviewText: reviewPlan.reviewText,
|
||
detailReviewImageIndex: imageIndex + 1,
|
||
detailReviewImageCount: DETAIL_REVIEW_IMAGE_COUNT,
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
});
|
||
}
|
||
|
||
const finalMarkdown = buildDetailResultMarkdown({
|
||
generationMode: activeDetailGenerationMode,
|
||
summary: planData.summary,
|
||
images: generatedImages,
|
||
prompts,
|
||
skillFlow: planData.skillFlow,
|
||
qualityChecks: planData.qualityChecks,
|
||
globalStyle: planData.globalStyle,
|
||
screens: screenSpecs,
|
||
reviewGroups,
|
||
});
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: finalMarkdown,
|
||
},
|
||
],
|
||
}
|
||
: message,
|
||
),
|
||
);
|
||
setDetailProgress({
|
||
status: "done",
|
||
stage:
|
||
reviewFailures.length > 0
|
||
? `详情主图完成 ${generatedImages.length}/${prompts.length} 屏,评价晒图部分失败 ${reviewFailures.length} 条`
|
||
: `详情主图完成 ${generatedImages.length}/${prompts.length} 屏 + ${reviewGroups.length} 条评价晒图`,
|
||
current: totalSteps,
|
||
total: totalSteps,
|
||
});
|
||
const finalSummary =
|
||
planData.summary ||
|
||
(reviewFailures.length > 0
|
||
? `详情主图完成 ${generatedImages.length}/${prompts.length} 屏,评价晒图部分失败 ${reviewFailures.length} 条`
|
||
: `详情主图完成 ${generatedImages.length}/${prompts.length} 屏 + ${reviewGroups.length} 条评价晒图`);
|
||
if (workingArchive?.id) {
|
||
const finalArchive: DetailArchive = {
|
||
...workingArchive,
|
||
summary: finalSummary,
|
||
imageCount: generatedImages.length,
|
||
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
|
||
};
|
||
workingArchive = finalArchive;
|
||
updateLocalDetailArchive(finalArchive.id, {
|
||
summary: finalArchive.summary,
|
||
imageCount: finalArchive.imageCount,
|
||
coverUrl: finalArchive.coverUrl,
|
||
});
|
||
await patchDetailArchiveInDatabase(finalArchive.id, finalArchive);
|
||
} else {
|
||
await saveDetailArchiveSnapshot({
|
||
summary: finalSummary,
|
||
imageCount: generatedImages.length,
|
||
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
|
||
prompt: archiveSnapshot.prompt,
|
||
generationMode: archiveSnapshot.generationMode,
|
||
brief: archiveSnapshot.brief,
|
||
slots: archiveSnapshot.slots,
|
||
});
|
||
}
|
||
|
||
if (reviewFailures.length > 0) {
|
||
toast.warning(
|
||
`详情主图完成 ${generatedImages.length}/${prompts.length} 屏,买家评价晒图成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`
|
||
);
|
||
} else {
|
||
toast.success(
|
||
`AI详情生成完成:详情主图 ${generatedImages.length}/${prompts.length} 屏,评价晒图 ${reviewGroups.length} 条`
|
||
);
|
||
}
|
||
} catch (error) {
|
||
const errMsg = error instanceof Error ? error.message : "AI详情生成失败";
|
||
if (detailPlaceholderNodeIds.length > 0) {
|
||
for (let index = completedDetailImageCount; index < detailPlaceholderNodeIds.length; index += 1) {
|
||
const nodeId = detailPlaceholderNodeIds[index];
|
||
if (!nodeId) continue;
|
||
const storeState = useCanvasStore.getState();
|
||
const currentNode = storeState.getNode(nodeId);
|
||
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
||
if (currentData.status === "completed") continue;
|
||
updateNode(nodeId, {
|
||
data: {
|
||
...currentData,
|
||
src: String(currentData.src || ""),
|
||
status: "failed",
|
||
metadata: {
|
||
...(currentData.metadata || {}),
|
||
detailGeneratingPlaceholder: false,
|
||
detailFailedReason: errMsg,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
}
|
||
if (workingArchive?.id) {
|
||
const failedSummary = `异常中断:已完成 ${completedDetailImageCount} 张详情图,错误:${errMsg}`;
|
||
const failedArchive: DetailArchive = {
|
||
...workingArchive,
|
||
summary: failedSummary,
|
||
imageCount: completedDetailImageCount,
|
||
coverUrl: generatedImages[0]?.thumbnailUrl || generatedImages[0]?.originalUrl,
|
||
};
|
||
updateLocalDetailArchive(failedArchive.id, {
|
||
summary: failedSummary,
|
||
imageCount: completedDetailImageCount,
|
||
coverUrl: failedArchive.coverUrl,
|
||
});
|
||
void patchDetailArchiveInDatabase(failedArchive.id, failedArchive);
|
||
}
|
||
setDetailProgress((prev) => ({
|
||
status: "error",
|
||
stage: errMsg,
|
||
current: prev.current,
|
||
total: prev.total,
|
||
}));
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: `### AI详情生成失败\n${errMsg}`,
|
||
},
|
||
],
|
||
}
|
||
: message,
|
||
),
|
||
);
|
||
toast.error(errMsg);
|
||
} finally {
|
||
setIsDetailGenerating(false);
|
||
}
|
||
}, [
|
||
addNode,
|
||
clearSelection,
|
||
detailBrief,
|
||
detailGenerationMode,
|
||
detailSlots,
|
||
imageParams.negativePrompt,
|
||
input,
|
||
isDetailGenerating,
|
||
normalizedDetailBrief,
|
||
patchDetailArchiveInDatabase,
|
||
selectedModelId,
|
||
saveDetailArchiveSnapshot,
|
||
setMessages,
|
||
siteSettings?.canvasAutoModeDefaultModelId,
|
||
siteSettings?.canvasDetailDefaultModelId,
|
||
siteSettings?.canvasDefaultModelId,
|
||
updateLocalDetailArchive,
|
||
]);
|
||
|
||
const handleGenerateMoreDetailReviews = useCallback(async () => {
|
||
if (isDetailGenerating) return;
|
||
if (!hasGeneratedDetailImages) {
|
||
toast.error("请先完成一轮AI详情生成,再追加评价晒图");
|
||
return;
|
||
}
|
||
|
||
const brief = normalizedDetailBrief;
|
||
if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) {
|
||
toast.error("请先补充 AI详情生成必填项后再追加评价晒图");
|
||
return;
|
||
}
|
||
|
||
const productAttachments = DETAIL_PRODUCT_SLOT_INDEXES
|
||
.map((index) => detailSlots[index])
|
||
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
|
||
if (productAttachments.length < 5) {
|
||
toast.error("请先补齐5张产品图,再追加评价晒图");
|
||
return;
|
||
}
|
||
const bundleAttachments = DETAIL_BUNDLE_SLOT_INDEXES
|
||
.map((index) => detailSlots[index])
|
||
.filter((item): item is PickedObjectAttachment => Boolean(item?.url));
|
||
|
||
const languageSeed = [
|
||
brief.productName,
|
||
brief.targetAudience,
|
||
brief.usageScenario,
|
||
brief.coreNeed,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
const outputLanguage: "zh" | "en" = /[a-zA-Z]/.test(languageSeed) ? "en" : "zh";
|
||
const reviewPlans = buildDetailReviewPlans({
|
||
brief,
|
||
language: outputLanguage,
|
||
count: DETAIL_REVIEW_REGEN_COUNT,
|
||
});
|
||
if (reviewPlans.length === 0) {
|
||
toast.error("未生成可用的评价方案");
|
||
return;
|
||
}
|
||
|
||
const assistantMessageId = generateUUID();
|
||
const modelForGeneration =
|
||
selectedModelId !== "auto"
|
||
? selectedModelId
|
||
: siteSettings?.canvasDetailDefaultModelId ||
|
||
siteSettings?.canvasAutoModeDefaultModelId ||
|
||
siteSettings?.canvasDefaultModelId ||
|
||
undefined;
|
||
const existingImageBottom = nodes
|
||
.filter((node) => node.type === "image")
|
||
.reduce((max, node) => {
|
||
const currentBottom = Number(node.position?.y || 0) + Number(node.size?.height || 0);
|
||
return Math.max(max, currentBottom);
|
||
}, 120);
|
||
const reviewStartY = existingImageBottom + 120;
|
||
const reviewStartX = 120;
|
||
const reviewGroups: DetailReviewGroup[] = [];
|
||
const reviewFailures: string[] = [];
|
||
const batchId = generateUUID();
|
||
|
||
setIsDetailGenerating(true);
|
||
setDetailProgress({
|
||
status: "running",
|
||
stage: "正在追加买家评价与晒图...",
|
||
current: 0,
|
||
total: reviewPlans.length,
|
||
});
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: assistantMessageId,
|
||
role: "assistant",
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: `### 买家评价与晒图\n正在生成新的 ${reviewPlans.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张...`,
|
||
},
|
||
],
|
||
} as any,
|
||
]);
|
||
|
||
try {
|
||
for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) {
|
||
const reviewPlan = reviewPlans[reviewIndex]!;
|
||
setDetailProgress({
|
||
status: "running",
|
||
stage: `正在追加第 ${reviewIndex + 1}/${reviewPlans.length} 条评价晒图`,
|
||
current: reviewIndex + 1,
|
||
total: reviewPlans.length,
|
||
});
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: `### 买家评价与晒图\n正在追加第 ${reviewIndex + 1}/${reviewPlans.length} 条...`,
|
||
},
|
||
],
|
||
}
|
||
: message
|
||
)
|
||
);
|
||
|
||
const reviewPrompt = [
|
||
reviewPlan.imagePrompt,
|
||
`评价文本:${reviewPlan.reviewText}`,
|
||
"输出要求:生成真实电商买家晒单风格图片,避免夸张广告质感。",
|
||
"人物规则:买家秀必须使用真实买家视角,不得复用上传的官方模特图形象。",
|
||
`同一条评价连续输出 ${DETAIL_REVIEW_IMAGE_COUNT} 张图片,场景和构图保持同一叙事主题但角度不同。`,
|
||
"图片内容需要清晰展示产品主体与使用场景,可直接用于详情页评价区。",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
const reviewReferenceImages = [
|
||
...productAttachments.map((item) => item.url),
|
||
...(bundleAttachments.length ? bundleAttachments.map((item) => item.url) : []),
|
||
].filter(Boolean);
|
||
|
||
const reviewResult = await requestCanvasImageGeneration({
|
||
prompt: reviewPrompt,
|
||
model: modelForGeneration,
|
||
aspectRatio: "1:1",
|
||
imageSize: imageParams.imageSize,
|
||
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
|
||
negativePrompt: imageParams.negativePrompt,
|
||
referenceImage: productAttachments[0]?.url,
|
||
referenceImages: reviewReferenceImages,
|
||
});
|
||
const reviewImagesRaw = Array.isArray(reviewResult?.images) ? reviewResult.images : [];
|
||
if (reviewImagesRaw.length === 0) {
|
||
reviewFailures.push(`第${reviewIndex + 1}条生成失败`);
|
||
continue;
|
||
}
|
||
|
||
const reviewImages = reviewImagesRaw
|
||
.slice(0, DETAIL_REVIEW_IMAGE_COUNT)
|
||
.map((item: any) => ({
|
||
originalUrl: String(item?.originalUrl || item?.thumbnailUrl || "").trim(),
|
||
thumbnailUrl: String(item?.thumbnailUrl || item?.originalUrl || "").trim(),
|
||
}))
|
||
.filter((item: { originalUrl: string; thumbnailUrl: string }) =>
|
||
Boolean(item.originalUrl)
|
||
);
|
||
if (reviewImages.length === 0) {
|
||
reviewFailures.push(`第${reviewIndex + 1}条无有效图片`);
|
||
continue;
|
||
}
|
||
|
||
const rowIndex = reviewGroups.length;
|
||
reviewGroups.push({
|
||
reviewText: reviewPlan.reviewText,
|
||
images: reviewImages,
|
||
});
|
||
|
||
reviewImages.forEach((image: { originalUrl: string; thumbnailUrl: string }, imageIndex: number) => {
|
||
const x = reviewStartX + imageIndex * (DETAIL_REVIEW_IMAGE_WIDTH + 20);
|
||
const y = reviewStartY + rowIndex * (DETAIL_REVIEW_IMAGE_HEIGHT + 72);
|
||
addNode({
|
||
type: "image",
|
||
position: { x, y },
|
||
size: {
|
||
width: DETAIL_REVIEW_IMAGE_WIDTH,
|
||
height: DETAIL_REVIEW_IMAGE_HEIGHT,
|
||
},
|
||
data: {
|
||
src: image.originalUrl || image.thumbnailUrl,
|
||
status: "completed",
|
||
prompt: reviewPlan.imagePrompt,
|
||
title: `买家晒图-${reviewPlan.index}-${imageIndex + 1}`,
|
||
note: reviewPlan.reviewText,
|
||
metadata: {
|
||
detailReviewIndex: reviewPlan.index,
|
||
detailReviewTitle: reviewPlan.title,
|
||
detailReviewText: reviewPlan.reviewText,
|
||
detailReviewImageIndex: imageIndex + 1,
|
||
detailReviewImageCount: DETAIL_REVIEW_IMAGE_COUNT,
|
||
detailReviewBatchId: batchId,
|
||
detailReviewBatchType: "manual-append",
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
});
|
||
}
|
||
|
||
const finalText = buildDetailReviewAppendMarkdown({
|
||
groups: reviewGroups,
|
||
failedCount: reviewFailures.length,
|
||
});
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: finalText,
|
||
},
|
||
],
|
||
}
|
||
: message
|
||
)
|
||
);
|
||
setDetailProgress({
|
||
status: "done",
|
||
stage:
|
||
reviewFailures.length > 0
|
||
? `已追加 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`
|
||
: `已追加 ${reviewGroups.length} 条买家评价晒图`,
|
||
current: reviewPlans.length,
|
||
total: reviewPlans.length,
|
||
});
|
||
|
||
if (reviewFailures.length > 0) {
|
||
toast.warning(`追加成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`);
|
||
} else {
|
||
toast.success(`已追加 ${reviewGroups.length} 条买家评价晒图`);
|
||
}
|
||
} catch (error) {
|
||
const errMsg = error instanceof Error ? error.message : "追加买家评价与晒图失败";
|
||
setDetailProgress({
|
||
status: "error",
|
||
stage: errMsg,
|
||
current: 0,
|
||
total: reviewPlans.length,
|
||
});
|
||
setMessages((prev) =>
|
||
prev.map((message) =>
|
||
message.id === assistantMessageId
|
||
? {
|
||
...message,
|
||
parts: [
|
||
{
|
||
type: "text",
|
||
text: `### 买家评价与晒图追加失败\n${errMsg}`,
|
||
},
|
||
],
|
||
}
|
||
: message
|
||
)
|
||
);
|
||
toast.error(errMsg);
|
||
} finally {
|
||
setIsDetailGenerating(false);
|
||
}
|
||
}, [
|
||
addNode,
|
||
detailSlots,
|
||
hasGeneratedDetailImages,
|
||
imageParams.negativePrompt,
|
||
isDetailGenerating,
|
||
nodes,
|
||
normalizedDetailBrief,
|
||
selectedModelId,
|
||
setMessages,
|
||
siteSettings?.canvasAutoModeDefaultModelId,
|
||
siteSettings?.canvasDetailDefaultModelId,
|
||
siteSettings?.canvasDefaultModelId,
|
||
]);
|
||
|
||
const ensureImagePointsBeforeSend = useCallback(() => {
|
||
if (isVideoModel || activeComposerMode === "agent") return true;
|
||
if (membershipSummary?.isHighVip) return true;
|
||
|
||
const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true;
|
||
const blockOnInsufficient =
|
||
membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false;
|
||
const unitCost = Math.max(
|
||
0,
|
||
Math.round(Number(currentModel?.imageGenConfig?.creditCostPerImage ?? 0))
|
||
);
|
||
|
||
if (!pointsEnabled || !blockOnInsufficient || unitCost <= 0) {
|
||
return true;
|
||
}
|
||
|
||
const requestedImageCount = Math.max(
|
||
1,
|
||
Math.round(Number(imageParams?.imageCount || 1))
|
||
);
|
||
const expectedCost = unitCost * requestedImageCount;
|
||
const currentBalance = Math.max(
|
||
0,
|
||
Number(membershipSummary?.summary?.currentCredits ?? 0)
|
||
);
|
||
|
||
if (currentBalance >= expectedCost) {
|
||
return true;
|
||
}
|
||
|
||
setInsufficientPointsState({
|
||
balance: currentBalance,
|
||
expectedCost,
|
||
unitCost,
|
||
imageCount: requestedImageCount,
|
||
});
|
||
toast.error("积分不足,请先充值后再生图");
|
||
return false;
|
||
}, [
|
||
activeComposerMode,
|
||
currentModel?.imageGenConfig?.creditCostPerImage,
|
||
imageParams?.imageCount,
|
||
isVideoModel,
|
||
membershipSummary?.isHighVip,
|
||
membershipSummary?.pointsSystemConfig?.blockOnInsufficient,
|
||
membershipSummary?.pointsSystemConfig?.enabled,
|
||
membershipSummary?.summary?.currentCredits,
|
||
]);
|
||
|
||
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
|
||
const handleSend = useCallback(async () => {
|
||
const currentSendGuard = getSendGuard(activeChatId);
|
||
if (currentSendGuard.inFlight || status === "streaming" || status === "submitted") {
|
||
return;
|
||
}
|
||
|
||
if (isDetailGenerationMode && aiDetailGenerationEnabled) {
|
||
setSendGuard(activeChatId, {
|
||
inFlight: true,
|
||
signature: `detail:${activeChatId}:${Date.now()}`,
|
||
timestamp: Date.now(),
|
||
});
|
||
try {
|
||
await handleDetailGeneration();
|
||
} finally {
|
||
releaseSendGuard(activeChatId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!input.trim() && attachments.length === 0 && !selectedSkillTag && !hasWorkflowContext) {
|
||
return;
|
||
}
|
||
const effectiveModelId = currentModelIdRef.current || selectedModelId;
|
||
if (!effectiveModelId) {
|
||
toast.error("请选择一个模型");
|
||
return;
|
||
}
|
||
|
||
if (!ensureImagePointsBeforeSend()) {
|
||
return;
|
||
}
|
||
|
||
// 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致)
|
||
const parts: any[] = [];
|
||
|
||
let effectiveAttachments = [...attachments];
|
||
if (hasAgentUploadSlots) {
|
||
if (effectiveAttachments.length > agentRequiredUploadCount) {
|
||
effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount);
|
||
}
|
||
|
||
if (effectiveAttachments.length < agentRequiredUploadCount) {
|
||
toast.error("当前智能体需要上传 3 张图片:产品图1、产品图2、模特图");
|
||
return;
|
||
}
|
||
}
|
||
const videoImageLimit = isVideoModel
|
||
? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability)
|
||
: null;
|
||
|
||
if (videoImageLimit) {
|
||
if (videoImageLimit.max === 0) {
|
||
effectiveAttachments = [];
|
||
} else if (effectiveAttachments.length > videoImageLimit.max) {
|
||
effectiveAttachments = effectiveAttachments.slice(0, videoImageLimit.max);
|
||
}
|
||
|
||
if (effectiveAttachments.length < videoImageLimit.min) {
|
||
toast.error(
|
||
videoImageLimit.min === 1
|
||
? "当前视频模式需要至少 1 张参考图"
|
||
: "当前视频模式需要 2 张首尾帧图片"
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 🆕 检查是否有标注的图片
|
||
const annotatedNodes = selectedImageNodes.filter(node => (node.data as any)?.hasAnnotations);
|
||
const hasAnnotatedImages = annotatedNodes.length > 0;
|
||
|
||
// 添加文件部分(作为 file 类型的 part)
|
||
// 🆕 收集物品信息用于构建隐藏提示
|
||
const pickedObjectInfos: string[] = [];
|
||
|
||
effectiveAttachments.forEach((a) => {
|
||
const att = a as PickedObjectAttachment;
|
||
parts.push({
|
||
type: "file" as const,
|
||
mediaType: a.contentType,
|
||
name: a.name,
|
||
url: a.url,
|
||
assetId: a.assetId,
|
||
hasAnnotations: hasAnnotatedImages, // 🆕 标记图片是否有标注
|
||
pickedObject: att.pickedObject, // 🆕 包含物品信息
|
||
});
|
||
|
||
// 收集物品信息
|
||
if (att.pickedObject) {
|
||
const { objectName, objectPosition, boundingBox } = att.pickedObject;
|
||
pickedObjectInfos.push(
|
||
`【物品:${objectName}】位置:${objectPosition},边界框:(${Math.round(boundingBox.x * 100)}%, ${Math.round(boundingBox.y * 100)}%) - (${Math.round((boundingBox.x + boundingBox.width) * 100)}%, ${Math.round((boundingBox.y + boundingBox.height) * 100)}%)`
|
||
);
|
||
}
|
||
});
|
||
|
||
// 添加文本部分(用户可见)
|
||
let textContent = input
|
||
.replace(/\u200b/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
|
||
if (textContent && effectiveAttachments.length > 0) {
|
||
let strippedText = textContent;
|
||
for (const attachment of effectiveAttachments) {
|
||
const fullName = (attachment.name || "").trim();
|
||
const shortName = fullName.length > 10 ? `${fullName.substring(0, 8)}...` : fullName;
|
||
for (const candidate of [fullName, shortName]) {
|
||
if (!candidate) continue;
|
||
if (strippedText.startsWith(candidate)) {
|
||
strippedText = strippedText.slice(candidate.length).trimStart();
|
||
}
|
||
}
|
||
}
|
||
textContent = strippedText || textContent;
|
||
}
|
||
|
||
if (!textContent && effectiveAttachments.length > 0) {
|
||
textContent = "请处理这张图片";
|
||
}
|
||
if (!textContent && hasWorkflowContext && workflowContext?.summaryText) {
|
||
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
|
||
}
|
||
if (!textContent && hasAgentUploadSlots && selectedAgentPreset) {
|
||
textContent = `请按「${selectedAgentPreset.name}」智能体配置顺序处理产品图1、产品图2和模特图。`;
|
||
}
|
||
if (!textContent && selectedSkillTag) {
|
||
textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`;
|
||
}
|
||
|
||
// 🆕 隐藏提示词(不在 UI 中展示)
|
||
const hiddenSegments: string[] = [];
|
||
if (selectedSkillTag?.prompt) {
|
||
hiddenSegments.push(
|
||
`【快捷标签:${selectedSkillTag.name}】\n${selectedSkillTag.prompt}`
|
||
);
|
||
}
|
||
if (hasWorkflowContext && workflowContext?.promptText) {
|
||
hiddenSegments.push(
|
||
`【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。`
|
||
);
|
||
}
|
||
if (
|
||
activeComposerMode === "agent" &&
|
||
selectedAgentPreset?.type === "text" &&
|
||
selectedAgentPreset.slots?.length
|
||
) {
|
||
const presetPrompt = selectedAgentPreset.slots
|
||
.map((slot, index) => {
|
||
const title = String(slot.title || slot.uploadLabel || `模板${index + 1}`).trim();
|
||
const promptText = String(slot.promptTemplate || "").trim();
|
||
if (!promptText) return "";
|
||
return `【智能体:${selectedAgentPreset.name}|${title}】\n${promptText}`;
|
||
})
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
if (presetPrompt) {
|
||
hiddenSegments.push(presetPrompt);
|
||
}
|
||
}
|
||
if (pickedObjectInfos.length > 0) {
|
||
const objectsPrompt = pickedObjectInfos.join('\n');
|
||
hiddenSegments.push(
|
||
`【用户指定的物品】\n${objectsPrompt}\n\n请精准地只处理上述指定的物品区域,保持其他部分不变。`
|
||
);
|
||
console.log('[CanvasChat] 检测到拾取物品,已生成隐藏提示:', pickedObjectInfos.length, '个');
|
||
}
|
||
if (hasAnnotatedImages) {
|
||
hiddenSegments.push("【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】");
|
||
console.log('[CanvasChat] 检测到标注图片,已生成隐藏提示');
|
||
}
|
||
if (isDetailGenerationMode && aiDetailGenerationEnabled) {
|
||
hiddenSegments.push(
|
||
[
|
||
"【AI详情生成模式】",
|
||
"你正在处理电商详情页任务,请先规划分镜结构,再执行生图。",
|
||
"要求:保持同一产品外观一致、风格一致、文案排版一致,按详情页叙事顺序输出结果。",
|
||
].join("\n")
|
||
);
|
||
}
|
||
const hiddenPrompt = hiddenSegments.join('\n\n').trim();
|
||
hiddenPromptRef.current = hiddenPrompt || null;
|
||
|
||
const autoContext = buildAutoContext();
|
||
autoContextRef.current = autoContext ? autoContext : null;
|
||
|
||
if (textContent) {
|
||
parts.push({ type: "text" as const, text: textContent });
|
||
}
|
||
|
||
// 若 ref 为空才回填,避免覆盖“刚切换但状态尚未刷新的新模型值”
|
||
if (!currentModelIdRef.current) {
|
||
currentModelIdRef.current = effectiveModelId;
|
||
}
|
||
|
||
const normalizedAttachmentKeys = effectiveAttachments
|
||
.map((attachment) => normalizeMediaUrl(attachment.url))
|
||
.filter(Boolean)
|
||
.sort();
|
||
const sendSignature = JSON.stringify({
|
||
chatId: activeChatId,
|
||
model: effectiveModelId,
|
||
text: textContent,
|
||
attachments: normalizedAttachmentKeys,
|
||
skillTagId: selectedSkillTag?.id || null,
|
||
mode: activeComposerMode,
|
||
});
|
||
|
||
if (shouldBlockDuplicateSend(activeChatId, sendSignature)) {
|
||
toast.message("检测到重复发送,已自动忽略");
|
||
return;
|
||
}
|
||
|
||
setSendGuard(activeChatId, {
|
||
inFlight: true,
|
||
signature: sendSignature,
|
||
timestamp: Date.now(),
|
||
});
|
||
clearStreamImageGenerationStateForChat(activeChatId);
|
||
|
||
// 使用与 multimodal-input 相同的格式发送消息
|
||
try {
|
||
sendMessage({
|
||
role: "user",
|
||
parts: parts,
|
||
});
|
||
} catch (error) {
|
||
releaseSendGuard(activeChatId);
|
||
console.error("[CanvasChat] sendMessage failed:", error);
|
||
toast.error("发送失败,请重试");
|
||
return;
|
||
}
|
||
hasHydratedHistoryRef.current = true;
|
||
|
||
// 清空状态
|
||
setInput("");
|
||
setAttachments([]);
|
||
|
||
// 🆕 清空画布选中状态(防止 useEffect 重新添加标签)
|
||
clearSelection();
|
||
|
||
// 🆕 清空 contenteditable div 的内容
|
||
const editableDiv = textareaRef.current as HTMLDivElement | null;
|
||
if (editableDiv) {
|
||
editableDiv.innerHTML = '';
|
||
console.log('[CanvasChat] 已清空输入框和选中状态');
|
||
}
|
||
}, [
|
||
activeChatId,
|
||
activeComposerMode,
|
||
agentRequiredUploadCount,
|
||
input,
|
||
attachments,
|
||
hasAgentUploadSlots,
|
||
hasWorkflowContext,
|
||
selectedSkillTag,
|
||
selectedAgentPreset,
|
||
selectedModelId,
|
||
sendMessage,
|
||
clearSelection,
|
||
buildAutoContext,
|
||
buildContextPayload,
|
||
isVideoModel,
|
||
videoParams,
|
||
videoFrameSlots.length,
|
||
currentModel?.providerType,
|
||
selectedImageNodes,
|
||
isDetailGenerationMode,
|
||
aiDetailGenerationEnabled,
|
||
handleDetailGeneration,
|
||
ensureImagePointsBeforeSend,
|
||
getSendGuard,
|
||
clearStreamImageGenerationStateForChat,
|
||
releaseSendGuard,
|
||
setSendGuard,
|
||
shouldBlockDuplicateSend,
|
||
status,
|
||
workflowContext,
|
||
]);
|
||
|
||
const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => {
|
||
if (!firstImageUrl || hasSetCanvasTitle.current) {
|
||
return;
|
||
}
|
||
|
||
hasSetCanvasTitle.current = true;
|
||
console.log('[CanvasChat] 开始调用 auto-title API...');
|
||
|
||
try {
|
||
console.log('[CanvasChat] 发送 auto-title 请求:', firstImageUrl);
|
||
const response = await fetch('/api/canvas/auto-title', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ imageUrl: firstImageUrl }),
|
||
});
|
||
|
||
console.log('[CanvasChat] auto-title 响应状态:', response.status);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const title = data.title || '未命名画布';
|
||
console.log('[CanvasChat] 识别到的标题:', title);
|
||
|
||
const rawId = chatId.startsWith('canvas-')
|
||
? chatId.replace('canvas-', '')
|
||
: chatId;
|
||
|
||
console.log('[CanvasChat] 更新画布:', rawId);
|
||
|
||
const updateResponse = await fetch(`/api/canvas/${rawId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title,
|
||
thumbnail: firstImageUrl,
|
||
}),
|
||
});
|
||
|
||
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
|
||
} else {
|
||
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
|
||
}
|
||
} catch (error) {
|
||
console.error('[CanvasChat] 自动命名失败:', error);
|
||
}
|
||
}, [chatId]);
|
||
|
||
const uploadImageFileAndAddToCanvas = useCallback(async (
|
||
file: File,
|
||
nodeIndex: number
|
||
): Promise<{ imageUrl: string; name: string; contentType: string; assetId?: string } | null> => {
|
||
if (!file.type.startsWith('image/')) {
|
||
toast.error('请选择图片文件');
|
||
return null;
|
||
}
|
||
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
toast.error('图片大小不能超过 50MB');
|
||
return null;
|
||
}
|
||
|
||
const ext = file.type.split('/')[1] || 'png';
|
||
const fileName = (file.name || '').trim() || `粘贴图片-${Date.now()}-${nodeIndex + 1}.${ext}`;
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
|
||
if (!response.ok) {
|
||
throw new Error('上传失败');
|
||
}
|
||
|
||
const data = await response.json();
|
||
const imageUrl = data.url as string;
|
||
const assetId =
|
||
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
|
||
const dimensions = await getImageDimensions(imageUrl);
|
||
const spacing = 20;
|
||
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
||
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
|
||
|
||
addNode({
|
||
type: 'image',
|
||
position: { x, y },
|
||
size: { width: dimensions.width, height: dimensions.height },
|
||
data: {
|
||
src: imageUrl,
|
||
assetId,
|
||
status: 'completed',
|
||
title: fileName.replace(/\.[^.]+$/, ""),
|
||
prompt: fileName.replace(/\.[^.]+$/, ""),
|
||
metadata: {
|
||
originalWidth: dimensions.originalWidth,
|
||
originalHeight: dimensions.originalHeight,
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
|
||
return {
|
||
imageUrl,
|
||
name: fileName,
|
||
contentType: file.type || 'image/jpeg',
|
||
assetId,
|
||
};
|
||
} catch (_error) {
|
||
toast.error('图片上传失败');
|
||
return null;
|
||
}
|
||
}, [addNode]);
|
||
|
||
const handlePastedImages = useCallback(async (files: File[]) => {
|
||
if (files.length === 0) return;
|
||
|
||
let firstImageUrl: string | null = null;
|
||
const baseNodeCount = nodes.length;
|
||
let uploadIndex = 0;
|
||
|
||
if (hasAgentUploadSlots) {
|
||
const slotCount = agentUploadSlots.length;
|
||
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
|
||
let handledCount = 0;
|
||
|
||
for (const file of files) {
|
||
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
|
||
if (targetSlotIndex < 0) break;
|
||
|
||
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
|
||
if (!uploaded) continue;
|
||
|
||
const attachment: PickedObjectAttachment = {
|
||
url: uploaded.imageUrl,
|
||
name: uploaded.name,
|
||
contentType: uploaded.contentType,
|
||
assetId: uploaded.assetId,
|
||
};
|
||
updateAttachmentAtAgentSlot(targetSlotIndex, attachment);
|
||
currentSlots[targetSlotIndex] = attachment;
|
||
handledCount += 1;
|
||
uploadIndex += 1;
|
||
if (!firstImageUrl) {
|
||
firstImageUrl = uploaded.imageUrl;
|
||
}
|
||
}
|
||
|
||
if (handledCount > 0) {
|
||
toast.success(
|
||
handledCount === 1
|
||
? "已粘贴图片到智能体槽位"
|
||
: `已粘贴 ${handledCount} 张图片到智能体槽位`
|
||
);
|
||
}
|
||
if (handledCount < files.length) {
|
||
toast.info("智能体图片槽位已满,剩余图片未添加");
|
||
}
|
||
void maybeAutoSetCanvasTitle(firstImageUrl);
|
||
return;
|
||
}
|
||
|
||
if (isVideoModel && videoFrameSlots.length > 0) {
|
||
const slotCount = videoFrameSlots.length;
|
||
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
|
||
let handledCount = 0;
|
||
|
||
for (const file of files) {
|
||
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
|
||
if (targetSlotIndex < 0) break;
|
||
|
||
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
|
||
if (!uploaded) continue;
|
||
|
||
const attachment: PickedObjectAttachment = {
|
||
url: uploaded.imageUrl,
|
||
name: uploaded.name,
|
||
contentType: uploaded.contentType,
|
||
};
|
||
updateAttachmentAtVideoSlot(targetSlotIndex, attachment);
|
||
currentSlots[targetSlotIndex] = attachment;
|
||
handledCount += 1;
|
||
uploadIndex += 1;
|
||
if (!firstImageUrl) {
|
||
firstImageUrl = uploaded.imageUrl;
|
||
}
|
||
}
|
||
|
||
if (handledCount > 0) {
|
||
toast.success(handledCount === 1 ? "已粘贴图片到视频槽位" : `已粘贴 ${handledCount} 张图片到视频槽位`);
|
||
}
|
||
if (handledCount < files.length) {
|
||
toast.info("视频模式图片槽位已满,剩余图片未添加");
|
||
}
|
||
void maybeAutoSetCanvasTitle(firstImageUrl);
|
||
return;
|
||
}
|
||
|
||
let handledCount = 0;
|
||
for (const file of files) {
|
||
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
|
||
if (!uploaded) continue;
|
||
|
||
handledCount += 1;
|
||
uploadIndex += 1;
|
||
if (!firstImageUrl) {
|
||
firstImageUrl = uploaded.imageUrl;
|
||
}
|
||
|
||
setAttachments((prev) => {
|
||
const newIndex = prev.length;
|
||
setTimeout(() => {
|
||
insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
|
||
(textareaRef.current as HTMLDivElement | null)?.focus();
|
||
}, 0);
|
||
|
||
return [
|
||
...prev,
|
||
{
|
||
url: uploaded.imageUrl,
|
||
name: uploaded.name,
|
||
contentType: uploaded.contentType,
|
||
},
|
||
];
|
||
});
|
||
}
|
||
|
||
if (handledCount > 0) {
|
||
toast.success(handledCount === 1 ? "图片已添加到画布" : `已粘贴并添加 ${handledCount} 张图片`);
|
||
}
|
||
|
||
void maybeAutoSetCanvasTitle(firstImageUrl);
|
||
}, [
|
||
agentUploadSlots.length,
|
||
attachments,
|
||
hasAgentUploadSlots,
|
||
insertImageTagInEditor,
|
||
isVideoModel,
|
||
maybeAutoSetCanvasTitle,
|
||
nodes.length,
|
||
updateAttachmentAtAgentSlot,
|
||
updateAttachmentAtVideoSlot,
|
||
uploadImageFileAndAddToCanvas,
|
||
videoFrameSlots.length,
|
||
]);
|
||
|
||
// 处理文件选择 - 上传后立即添加到画布
|
||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files;
|
||
if (!files) return;
|
||
|
||
let firstImageUrl: string | null = null;
|
||
// 记录本次上传开始时的节点数量,用于计算新图片位置
|
||
const baseNodeCount = nodes.length;
|
||
let uploadIndex = 0;
|
||
|
||
if (hasAgentUploadSlots) {
|
||
const slotCount = agentUploadSlots.length;
|
||
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
|
||
let handledCount = 0;
|
||
|
||
for (const file of Array.from(files)) {
|
||
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
|
||
if (targetSlotIndex < 0) break;
|
||
|
||
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
|
||
if (!uploaded) continue;
|
||
|
||
updateAttachmentAtAgentSlot(targetSlotIndex, {
|
||
url: uploaded.imageUrl,
|
||
name: uploaded.name,
|
||
contentType: uploaded.contentType,
|
||
assetId: uploaded.assetId,
|
||
});
|
||
|
||
currentSlots[targetSlotIndex] = {
|
||
url: uploaded.imageUrl,
|
||
name: uploaded.name,
|
||
contentType: uploaded.contentType,
|
||
assetId: uploaded.assetId,
|
||
};
|
||
handledCount += 1;
|
||
uploadIndex += 1;
|
||
if (!firstImageUrl) {
|
||
firstImageUrl = uploaded.imageUrl;
|
||
}
|
||
}
|
||
|
||
if (handledCount > 0) {
|
||
toast.success(
|
||
handledCount === 1
|
||
? "图片已添加到智能体槽位"
|
||
: `已添加 ${handledCount} 张图片到智能体槽位`
|
||
);
|
||
}
|
||
if (handledCount < files.length) {
|
||
toast.info("智能体需要的图片槽位已满,剩余图片未添加");
|
||
}
|
||
|
||
void maybeAutoSetCanvasTitle(firstImageUrl);
|
||
e.target.value = '';
|
||
return;
|
||
}
|
||
|
||
for (const file of Array.from(files)) {
|
||
if (!file.type.startsWith('image/')) {
|
||
toast.error('请选择图片文件');
|
||
continue;
|
||
}
|
||
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
toast.error('图片大小不能超过 50MB');
|
||
continue;
|
||
}
|
||
|
||
// 上传文件
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
|
||
if (!response.ok) {
|
||
throw new Error('上传失败');
|
||
}
|
||
|
||
const data = await response.json();
|
||
const imageUrl = data.url;
|
||
const assetId =
|
||
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
|
||
|
||
// 记录第一张图片的 URL(用于自动命名和缩略图)
|
||
if (!firstImageUrl) {
|
||
firstImageUrl = imageUrl;
|
||
}
|
||
|
||
// 添加到附件列表并在编辑器中插入标签
|
||
setAttachments(prev => {
|
||
const newIndex = prev.length;
|
||
// 在编辑器中插入图片标签
|
||
setTimeout(() => {
|
||
insertImageTagInEditor(imageUrl, file.name, newIndex);
|
||
(textareaRef.current as HTMLDivElement | null)?.focus();
|
||
}, 0);
|
||
return [...prev, {
|
||
url: imageUrl,
|
||
name: file.name,
|
||
contentType: file.type,
|
||
assetId,
|
||
}];
|
||
});
|
||
|
||
// 获取图片原始尺寸后添加到画布
|
||
const dimensions = await getImageDimensions(imageUrl);
|
||
// 使用 baseNodeCount + uploadIndex 计算位置,确保多张图片不重叠
|
||
const nodeIndex = baseNodeCount + uploadIndex;
|
||
const spacing = 20;
|
||
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
||
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
|
||
uploadIndex++; // 递增索引
|
||
|
||
addNode({
|
||
type: 'image',
|
||
position: { x, y },
|
||
size: { width: dimensions.width, height: dimensions.height },
|
||
data: {
|
||
src: imageUrl,
|
||
assetId,
|
||
status: 'completed',
|
||
title: file.name.replace(/\.[^.]+$/, ""),
|
||
prompt: file.name.replace(/\.[^.]+$/, ""),
|
||
metadata: {
|
||
originalWidth: dimensions.originalWidth,
|
||
originalHeight: dimensions.originalHeight,
|
||
},
|
||
},
|
||
connections: [],
|
||
});
|
||
|
||
toast.success('图片已添加到画布');
|
||
|
||
} catch (error) {
|
||
toast.error('图片上传失败');
|
||
}
|
||
}
|
||
|
||
console.log('[CanvasChat] 检查是否需要自动命名:', {
|
||
firstImageUrl: !!firstImageUrl,
|
||
hasSetCanvasTitle: hasSetCanvasTitle.current
|
||
});
|
||
void maybeAutoSetCanvasTitle(firstImageUrl);
|
||
|
||
e.target.value = '';
|
||
};
|
||
|
||
// 移除附件(通过标签元素删除,支持同图多物品)
|
||
const removeAttachmentByTagElement = (tagEl: HTMLElement) => {
|
||
const url = tagEl.getAttribute('data-attachment-url') || '';
|
||
const isPicked = tagEl.getAttribute('data-picked-object') === 'true';
|
||
const objectName = tagEl.getAttribute('data-object-name') || '';
|
||
const objectPosition = tagEl.getAttribute('data-object-position') || '';
|
||
setAttachments(prev => prev.filter(a => {
|
||
if (!url) return true;
|
||
if (isPicked) {
|
||
return !(
|
||
a.url === url &&
|
||
a.pickedObject?.objectName === objectName &&
|
||
a.pickedObject?.objectPosition === objectPosition
|
||
);
|
||
}
|
||
return a.url !== url;
|
||
}));
|
||
if (url) {
|
||
console.log('[CanvasChat] 已移除附件:', url.substring(0, 50));
|
||
}
|
||
};
|
||
|
||
// 从编辑器 DOM 同步附件列表,避免删除标签后仍发送
|
||
const syncAttachmentsWithEditor = useCallback((editableDiv?: HTMLDivElement | null) => {
|
||
const container = editableDiv || (textareaRef.current as HTMLDivElement | null);
|
||
if (!container) return;
|
||
const tagElements = Array.from(container.querySelectorAll('[data-attachment-url]')) as HTMLElement[];
|
||
const tagKeys = new Set<string>();
|
||
tagElements.forEach((el) => {
|
||
const url = el.getAttribute('data-attachment-url') || '';
|
||
if (!url) return;
|
||
const isPicked = el.getAttribute('data-picked-object') === 'true';
|
||
if (isPicked) {
|
||
const objectName = el.getAttribute('data-object-name') || '';
|
||
const objectPosition = el.getAttribute('data-object-position') || '';
|
||
tagKeys.add(`picked|${url}|${objectName}|${objectPosition}`);
|
||
} else {
|
||
tagKeys.add(`image|${url}`);
|
||
}
|
||
});
|
||
setAttachments(prev => prev.filter((att) => {
|
||
if (att.pickedObject) {
|
||
const key = `picked|${att.url}|${att.pickedObject.objectName}|${att.pickedObject.objectPosition}`;
|
||
return tagKeys.has(key);
|
||
}
|
||
return tagKeys.has(`image|${att.url}`);
|
||
}));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isDetailGenerationMode && !detailModeSwitchRef.current) {
|
||
setDetailModeMessageStartIndex(messages.length);
|
||
}
|
||
detailModeSwitchRef.current = isDetailGenerationMode;
|
||
}, [isDetailGenerationMode, messages.length]);
|
||
|
||
const visibleMessages = useMemo(
|
||
() => (isDetailGenerationMode ? messages.slice(detailModeMessageStartIndex) : messages),
|
||
[detailModeMessageStartIndex, isDetailGenerationMode, messages]
|
||
);
|
||
|
||
const isLoading = status === 'streaming' || status === 'submitted';
|
||
const isSubmitting = isLoading || isDetailGenerating;
|
||
const hasInputOrAttachments = Boolean(
|
||
input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
|
||
);
|
||
const hasEnoughAgentUploads = !hasAgentUploadSlots || attachments.length >= agentRequiredUploadCount;
|
||
const composerPlaceholder = isWorkspacePresentation
|
||
? isDetailGenerationMode
|
||
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
|
||
: hasAgentUploadSlots
|
||
? "可选补充本次生图要求,例如:高级感、暖灰色、近景特写、生成3张。"
|
||
: "可选补充本次生成要求,例如:更高级、更生活化、暖调灯光。"
|
||
: isDetailGenerationMode
|
||
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
|
||
: activeComposerMode === "agent" && selectedAgentPreset?.type === "text"
|
||
? "可上传图片做分析,也可直接输入文案需求"
|
||
: hasAgentUploadSlots
|
||
? "请先上传产品图1、产品图2、模特图,再补充说明"
|
||
: selectedSkillTag?.placeholder?.trim() ||
|
||
(selectedSkillTag
|
||
? `请输入「${selectedSkillTag.name}」相关需求`
|
||
: canvasDynamicPlaceholder || "请输入你的设计需求,例如:绿色椅子 替换 Image");
|
||
const detailProductCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
|
||
const detailModelReady = DETAIL_MODEL_SLOT_INDEX >= 0 ? Boolean(detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) : false;
|
||
const detailBundleCount = DETAIL_BUNDLE_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length;
|
||
const detailProgressPercent =
|
||
detailProgress.total > 0
|
||
? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100))
|
||
: 0;
|
||
const canSend = isDetailGenerationMode
|
||
? detailValidationIssues.length === 0 && !isDetailGenerating
|
||
: hasInputOrAttachments && hasEnoughAgentUploads;
|
||
const currentVideoImageLimit = isVideoModel
|
||
? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability)
|
||
: null;
|
||
const showVideoAttachmentButton = Boolean(
|
||
isVideoModel &&
|
||
currentVideoImageLimit &&
|
||
currentVideoImageLimit.max > 0 &&
|
||
videoFrameSlots.length === 0
|
||
);
|
||
const lastUserIndexForMarkers = visibleMessages.findLastIndex(m => m.role === "user");
|
||
const messagesAfterLastUser = lastUserIndexForMarkers >= 0 ? visibleMessages.slice(lastUserIndexForMarkers + 1) : visibleMessages;
|
||
const hasGenerationStart = messagesAfterLastUser.some(msg =>
|
||
msg.role === "assistant" && msg.parts?.some(
|
||
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_START -->")
|
||
)
|
||
);
|
||
const hasGenerationEnd = messagesAfterLastUser.some(msg =>
|
||
msg.role === "assistant" && msg.parts?.some(
|
||
(part: any) => part.type === "text" && part.text?.includes("<!-- IMAGE_GENERATION_END -->")
|
||
)
|
||
);
|
||
const shouldShowImageLoading =
|
||
((hasGenerationStart && !hasGenerationEnd) || Boolean(streamImageGenerationState)) &&
|
||
!!currentModel;
|
||
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
|
||
const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter;
|
||
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
|
||
const loadingProviderType =
|
||
streamImageGenerationState?.providerType ||
|
||
(activeComposerMode === "agent" ? "alibaba-image" : currentModel?.providerType);
|
||
const loadingModelName =
|
||
streamImageGenerationState?.modelName ||
|
||
(activeComposerMode === "agent"
|
||
? "智能处理"
|
||
: currentModel?.displayName || undefined);
|
||
const loadingPhase =
|
||
streamImageGenerationState?.phase === "queued" ||
|
||
streamImageGenerationState?.phase === "processing" ||
|
||
streamImageGenerationState?.phase === "finalizing" ||
|
||
streamImageGenerationState?.phase === "failed"
|
||
? streamImageGenerationState.phase
|
||
: "processing";
|
||
const renderableVisibleMessages = useMemo(() => {
|
||
return visibleMessages.reduce<ChatMessage[]>((acc, message) => {
|
||
const rawParts = (message.parts || []) as any[];
|
||
const cleanedParts = rawParts.map((part: any) => {
|
||
if (part.type === "text" && part.text) {
|
||
return {
|
||
...part,
|
||
text: String(part.text)
|
||
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
|
||
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
|
||
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
|
||
.trim(),
|
||
};
|
||
}
|
||
return part;
|
||
});
|
||
|
||
const fallbackContent =
|
||
typeof (message as any).content === "string"
|
||
? String((message as any).content).trim()
|
||
: "";
|
||
|
||
if (cleanedParts.length === 0 && fallbackContent) {
|
||
cleanedParts.push({ type: "text", text: fallbackContent } as any);
|
||
}
|
||
|
||
let hasContent = false;
|
||
for (const part of cleanedParts) {
|
||
if (part?.type === "text" && part?.text && String(part.text).trim().length > 0) {
|
||
hasContent = true;
|
||
break;
|
||
}
|
||
const fileUrl =
|
||
typeof part?.url === "string" && part.url.trim()
|
||
? part.url
|
||
: typeof part?.data === "string" && part.data.trim()
|
||
? part.data
|
||
: "";
|
||
if ((part?.type === "file" || part?.type === "image") && fileUrl) {
|
||
hasContent = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const attachmentsList = (message as any).attachments || [];
|
||
if (!hasContent && attachmentsList.length > 0) {
|
||
hasContent = true;
|
||
}
|
||
|
||
if (!hasContent) {
|
||
return acc;
|
||
}
|
||
|
||
acc.push({ ...message, parts: cleanedParts } as ChatMessage);
|
||
return acc;
|
||
}, []);
|
||
}, [visibleMessages]);
|
||
|
||
const isDetailEmptyState =
|
||
isDetailGenerationMode &&
|
||
renderableVisibleMessages.length === 0 &&
|
||
!isLoading &&
|
||
!shouldShowImageLoading;
|
||
const workspaceEmptyTitle = !selectedAgentPresetId
|
||
? "请先在左侧选择生图模式"
|
||
: hasAgentUploadSlots && attachments.length < agentRequiredUploadCount
|
||
? `请先补齐图片槽位(${attachments.length}/${agentRequiredUploadCount})`
|
||
: "准备开始生成";
|
||
const workspaceEmptyDescription = !selectedAgentPresetId
|
||
? "选择生图模式后,这里会显示生成结果。"
|
||
: hasAgentUploadSlots && attachments.length < agentRequiredUploadCount
|
||
? "从左侧 SKU / SPU 获取图片,或在下方继续上传替换后,再点击开始生成。"
|
||
: "可在下方补充要求后点击开始生成,结果会显示在这里。";
|
||
|
||
const renderModelSelectContent = () => (
|
||
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6]/90 p-1.5 shadow-[0_8px_20px_rgba(15,23,42,0.13)]">
|
||
<div className="max-h-[312px] space-y-0.5 overflow-y-auto pr-1">
|
||
{selectableModels.map((model) => (
|
||
<SelectItem
|
||
key={model.id}
|
||
value={model.id}
|
||
className="h-[34px] rounded-xl px-2.5 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full focus:bg-[#e7ebf0] focus:text-[#1f2937] data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]"
|
||
>
|
||
<span className="flex w-full items-center justify-between gap-2">
|
||
<span className="flex min-w-0 items-center gap-2">
|
||
<span className="h-5 w-5 overflow-hidden rounded-full border border-[#d4d9df] bg-white">
|
||
{model.avatarUrl ? (
|
||
<img
|
||
src={buildPreviewImageUrl(model.avatarUrl, 40)}
|
||
alt={model.displayName}
|
||
className="h-full w-full object-cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== model.avatarUrl) {
|
||
event.currentTarget.src = model.avatarUrl || "";
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<span className="flex h-full w-full items-center justify-center text-[9px] font-medium text-[#6b7280]">
|
||
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="truncate text-[12px] font-medium leading-none">{model.displayName}</span>
|
||
</span>
|
||
{modelSelectValue === model.id ? (
|
||
<span className="text-sm font-semibold text-[#1f2937]">✓</span>
|
||
) : null}
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
{selectableModels.length === 0 ? (
|
||
<div className="px-2 py-2 text-xs text-[#6b7280]">暂无可用模型</div>
|
||
) : null}
|
||
</div>
|
||
</SelectContent>
|
||
);
|
||
|
||
const createNewChatSession = useCallback(() => {
|
||
const now = Date.now();
|
||
const newChatSessionId = `canvas-${crypto.randomUUID()}`;
|
||
const fallbackTitle = `对话 ${chatSessions.length + 1}`;
|
||
const newSession: CanvasChatSession = {
|
||
id: newChatSessionId,
|
||
title: fallbackTitle,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
releaseSendGuard(activeChatId);
|
||
setChatSessions((prevSessions) => [newSession, ...prevSessions]);
|
||
setActiveChatId(newChatSessionId);
|
||
toast.success("已新建聊天窗口");
|
||
}, [activeChatId, chatSessions.length, releaseSendGuard]);
|
||
|
||
const switchToSession = useCallback((sessionId: string) => {
|
||
if (!sessionId || sessionId === activeChatId) {
|
||
setIsSessionMenuOpen(false);
|
||
return;
|
||
}
|
||
forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId);
|
||
releaseSendGuard(activeChatId);
|
||
setActiveChatId(sessionId);
|
||
setIsSessionMenuOpen(false);
|
||
toast.success("已切换聊天窗口");
|
||
}, [activeChatId, releaseSendGuard]);
|
||
|
||
const activeSessionTitle = useMemo(() => {
|
||
const current = chatSessions.find((session) => session.id === activeChatId);
|
||
return String(current?.title || "").trim();
|
||
}, [chatSessions, activeChatId]);
|
||
|
||
const displayCanvasName = useMemo(() => {
|
||
if (activeSessionTitle) return activeSessionTitle;
|
||
const fallbackTitle = String(projectTitle || "").trim();
|
||
return fallbackTitle || "未命名画布";
|
||
}, [activeSessionTitle, projectTitle]);
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"relative flex h-full min-h-0 flex-col",
|
||
isWorkspacePresentation ? "bg-transparent" : "bg-white dark:bg-zinc-950"
|
||
)}
|
||
>
|
||
{/* 全局遮罩提示 - 当处于画布选图模式时显示 */}
|
||
{detailSlotPickerIndex !== null && (
|
||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white dark:bg-zinc-950 animate-in fade-in duration-200">
|
||
<div className="flex flex-col items-center gap-4 p-6 text-center">
|
||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary ring-4 ring-primary/5 animate-pulse">
|
||
<ImagePlus className="h-6 w-6" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<h3 className="text-lg font-semibold text-foreground">正在从画布选择图片</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
请点击画布中的任意图片以填入
|
||
<span className="font-medium text-primary">
|
||
“{DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材槽位"}”
|
||
</span>
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setDetailSlotPickerIndex(null)}
|
||
className="mt-2"
|
||
>
|
||
取消选择
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!isWorkspacePresentation ? (
|
||
<div className="z-20 flex items-center justify-between gap-3 p-4 pb-2">
|
||
<div className="min-w-0 flex flex-1 items-center gap-2 pr-2">
|
||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-md bg-[#eef2f7] text-[#6b7280]">
|
||
<MessageCircle className="h-3.5 w-3.5" />
|
||
</span>
|
||
<h2
|
||
className="min-w-0 flex-1 truncate text-[14px] font-semibold leading-tight tracking-tight text-[#111827]"
|
||
title={displayCanvasName}
|
||
>
|
||
{displayCanvasName}
|
||
</h2>
|
||
</div>
|
||
<div className="flex shrink-0 items-center">
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-muted"
|
||
onClick={createNewChatSession}
|
||
title="新建聊天窗口"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
<div className="relative" ref={sessionMenuRef}>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className={cn(
|
||
"h-8 w-8 rounded-full hover:bg-muted",
|
||
isSessionMenuOpen && "bg-muted"
|
||
)}
|
||
onClick={() => setIsSessionMenuOpen((open) => !open)}
|
||
title="聊天窗口记录"
|
||
>
|
||
<History className="h-4 w-4" />
|
||
</Button>
|
||
{isSessionMenuOpen && (
|
||
<div className="absolute right-0 top-10 w-64 rounded-xl border bg-white dark:bg-zinc-950 shadow-xl p-2">
|
||
<div className="px-2 py-1 text-xs text-muted-foreground">聊天窗口记录</div>
|
||
<div className="max-h-56 overflow-y-auto space-y-1">
|
||
{chatSessions.length === 0 ? (
|
||
<div className="px-2 py-2 text-xs text-muted-foreground">暂无记录</div>
|
||
) : (
|
||
chatSessions.map((session, index) => {
|
||
const isActive = session.id === activeChatId;
|
||
const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
return (
|
||
<button
|
||
key={session.id}
|
||
type="button"
|
||
className={cn(
|
||
"w-full text-left rounded-lg px-2.5 py-2 transition-colors",
|
||
isActive
|
||
? "bg-primary/10 text-primary"
|
||
: "hover:bg-muted"
|
||
)}
|
||
onClick={() => switchToSession(session.id)}
|
||
>
|
||
<div className="text-sm font-medium truncate">
|
||
{session.title || `对话 ${index + 1}`}
|
||
</div>
|
||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||
最近更新 {timeLabel}
|
||
</div>
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-muted"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
console.log("[CanvasChatPanel] File list button clicked");
|
||
if (onOpenFileList) {
|
||
onOpenFileList();
|
||
}
|
||
}}
|
||
title="已生成文件列表"
|
||
>
|
||
<LayoutGrid className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-muted"
|
||
onClick={onClose}
|
||
disabled={!onClose}
|
||
title="收起聊天面板"
|
||
>
|
||
<ArrowRightToLine className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 消息列表 */}
|
||
{isDetailEmptyState ? (
|
||
<div className="flex-1 min-h-0" />
|
||
) : (
|
||
<div
|
||
key={`${activeChatId}-${messageViewportKey}`}
|
||
className={cn(
|
||
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden",
|
||
isWorkspacePresentation ? "p-6" : "p-4",
|
||
isDetailGenerationMode && renderableVisibleMessages.length === 0
|
||
? "pt-10"
|
||
: "pt-4"
|
||
)}
|
||
>
|
||
<div className="space-y-6 overflow-x-hidden pb-24 pr-1">
|
||
{!isDetailGenerationMode && renderableVisibleMessages.length === 0 ? (
|
||
isWorkspacePresentation ? (
|
||
<div className="flex h-full min-h-[360px] items-center justify-center">
|
||
<div className="w-full max-w-3xl rounded-[28px] border border-dashed border-[#d7dce5] bg-white/78 p-10 text-center shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-zinc-900/60">
|
||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#eef2ff] text-[#4f46e5] dark:bg-[#312e81]/40 dark:text-[#a5b4fc]">
|
||
<Sparkles className="h-6 w-6" />
|
||
</div>
|
||
<div className="text-2xl font-semibold tracking-tight text-foreground">
|
||
{workspaceEmptyTitle}
|
||
</div>
|
||
<div className="mt-3 text-sm leading-6 text-muted-foreground">
|
||
{workspaceEmptyDescription}
|
||
</div>
|
||
{attachments.length > 0 ? (
|
||
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
||
{attachments.map((attachment, index) => (
|
||
<div
|
||
key={`${attachment.url}-${index}`}
|
||
className="overflow-hidden rounded-2xl border bg-white shadow-sm dark:bg-zinc-900"
|
||
>
|
||
<img
|
||
src={buildPreviewImageUrl(attachment.url, 160)}
|
||
alt={attachment.name || `素材${index + 1}`}
|
||
className="h-20 w-20 object-cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== attachment.url) {
|
||
event.currentTarget.src = attachment.url;
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className={cn("flex flex-col h-full", isDetailGenerationMode ? "pt-0" : "pt-4")}>
|
||
<div className={cn(isDetailGenerationMode ? "mb-4" : "mb-8")}>
|
||
<h1 className="mb-2 text-2xl font-bold">
|
||
<span>Hi</span>
|
||
{greetingUserName ? (
|
||
<>
|
||
<span>, </span>
|
||
<span className="text-blue-600">{greetingUserName}</span>
|
||
<span> {greetingPeriodText}</span>
|
||
</>
|
||
) : (
|
||
<span> {greetingPeriodText}</span>
|
||
)}
|
||
</h1>
|
||
<p className="text-muted-foreground">
|
||
开始创作吧!
|
||
{Number.isFinite(totalGeneratedImages) ? (
|
||
<>
|
||
{" "}累计为你生成了{" "}
|
||
<span className="text-blue-600 font-semibold">
|
||
{totalGeneratedImages.toLocaleString()}
|
||
</span>{" "}
|
||
张图。
|
||
</>
|
||
) : null}
|
||
</p>
|
||
</div>
|
||
|
||
{canvasSkillTags.length > 0 && (
|
||
<div className="mb-8 rounded-3xl border border-[#eceef2] bg-white/90 p-4">
|
||
<div className="mb-3 text-center">
|
||
<h3 className="text-[16px] font-semibold text-[#1f2937]">{canvasSkillTagsTitle}</h3>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{canvasSkillTags.map((tag) => {
|
||
const isActive = selectedSkillTag?.id === tag.id;
|
||
const TagIcon = getCanvasSkillIconComponent(tag.iconName);
|
||
return (
|
||
<button
|
||
key={tag.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedSkillTagId(tag.id);
|
||
const editor = textareaRef.current as HTMLDivElement | null;
|
||
editor?.focus();
|
||
}}
|
||
className={cn(
|
||
"inline-flex min-w-0 items-center justify-center gap-1.5 rounded-2xl border px-2 py-1.5 text-[13px] font-medium transition-colors",
|
||
isActive
|
||
? "border-[#6d7fff] bg-[#eef1ff] text-[#3341a9]"
|
||
: "border-[#e4e7ec] bg-white text-[#30343b] hover:border-[#d6dae3] hover:bg-[#fafbfc]"
|
||
)}
|
||
>
|
||
{tag.iconUrl ? (
|
||
<img
|
||
src={buildPreviewImageUrl(tag.iconUrl, 48)}
|
||
alt={tag.name}
|
||
className="h-3.5 w-3.5 rounded-sm object-cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== tag.iconUrl) {
|
||
event.currentTarget.src = tag.iconUrl || "";
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<TagIcon className="h-3.5 w-3.5 text-current opacity-70" />
|
||
)}
|
||
<span className="truncate">{tag.name}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
) : null}
|
||
|
||
{renderableVisibleMessages.map((message, index) => {
|
||
const assistantTextParts = message.role === "assistant"
|
||
? (message.parts || []).filter((part: any) => part.type === "text" && part.text)
|
||
: [];
|
||
const nearestUserImageKeys = getNearestUserImageUrlKeys(
|
||
renderableVisibleMessages,
|
||
index
|
||
);
|
||
const assistantModelName =
|
||
assistantTextParts
|
||
.map((part: any) => extractCanvasModelName(part.text || ""))
|
||
.find((name) => Boolean(name)) || "";
|
||
const hasAssistantImages = assistantTextParts.some((part: any) =>
|
||
extractAssistantImageUrls(part.text || "", nearestUserImageKeys).length > 0
|
||
);
|
||
const hasAssistantVideos = assistantTextParts.some((part: any) =>
|
||
extractVideoUrls(part.text || "").length > 0
|
||
);
|
||
const hasAssistantMedia = hasAssistantImages || hasAssistantVideos;
|
||
const isAgentAutoMode = selectedModelId === "auto";
|
||
const displayAssistantModelName =
|
||
isAgentAutoMode && hasAssistantMedia ? "agent model" : assistantModelName;
|
||
const hasAssistantText = assistantTextParts.some((part: any) =>
|
||
stripCanvasModelRoutingLines(stripMediaMarkup(part.text || "")).length > 0
|
||
);
|
||
const isImageOnlyAssistant =
|
||
message.role === "assistant" &&
|
||
hasAssistantMedia &&
|
||
!hasAssistantText;
|
||
const nextMessage = renderableVisibleMessages[index + 1] as ChatMessage | undefined;
|
||
const nextMessageUserImageKeys = getNearestUserImageUrlKeys(
|
||
renderableVisibleMessages,
|
||
index + 1
|
||
);
|
||
const nextAssistantHasMedia = messageHasAssistantMedia(
|
||
nextMessage,
|
||
nextMessageUserImageKeys
|
||
);
|
||
const isLatestAssistantDuringGeneration =
|
||
message.role === "assistant" &&
|
||
hasGenerationStart &&
|
||
!hasGenerationEnd &&
|
||
index === renderableVisibleMessages.length - 1;
|
||
const hideVoteActions =
|
||
message.role === "assistant" &&
|
||
((!hasAssistantMedia && nextAssistantHasMedia) || isLatestAssistantDuringGeneration);
|
||
const showVoteActions = message.role === "assistant" && !hideVoteActions;
|
||
const showModelMeta = hideVoteActions && Boolean(displayAssistantModelName);
|
||
const mediaModelName =
|
||
displayAssistantModelName ||
|
||
(message.role === "assistant" && !isAgentAutoMode
|
||
? currentModel?.displayName || ""
|
||
: "");
|
||
const shouldTypeAssistantMessage =
|
||
message.role === "assistant" &&
|
||
isLoading &&
|
||
!hasAssistantMedia &&
|
||
index === renderableVisibleMessages.length - 1;
|
||
|
||
const messageTimestamp = getMessageTimestamp(message);
|
||
const prevMessageTimestamp =
|
||
index > 0 ? getMessageTimestamp(renderableVisibleMessages[index - 1] as ChatMessage) : null;
|
||
const showDateSeparator = Boolean(
|
||
messageTimestamp &&
|
||
(!prevMessageTimestamp ||
|
||
getMessageDayKey(prevMessageTimestamp) !== getMessageDayKey(messageTimestamp))
|
||
);
|
||
const currentVote = messageVotes?.find(
|
||
(vote) => vote.messageId === message.id
|
||
);
|
||
const isVotingCurrentMessage = votingMessageId === message.id;
|
||
|
||
if (isWorkspacePresentation) {
|
||
if (message.role !== "assistant" || !hasAssistantMedia) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div key={message.id} className="space-y-3">
|
||
{showDateSeparator && messageTimestamp ? (
|
||
<div className="text-[14px] font-medium text-[#a6adba]">
|
||
{formatMessageDateLabel(messageTimestamp)}
|
||
</div>
|
||
) : null}
|
||
<div
|
||
className={cn(
|
||
"flex w-full min-w-0",
|
||
message.role === 'user' ? "justify-end" : "justify-start"
|
||
)}
|
||
>
|
||
{/* 消息区域 */}
|
||
<div className={cn(
|
||
"flex min-w-0 flex-col gap-2",
|
||
message.role === 'user'
|
||
? "max-w-[88%] items-end"
|
||
: "w-full max-w-[88%] items-start"
|
||
)}>
|
||
{message.role === 'user' ? (
|
||
<div className="rounded-2xl bg-[#f1f2f4] px-3 py-2">
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{(() => {
|
||
const renderedUserParts: React.ReactNode[] = [];
|
||
|
||
message.parts?.forEach((part: any, idx: number) => {
|
||
const fileUrl =
|
||
typeof part?.url === "string" && part.url.trim()
|
||
? part.url
|
||
: typeof part?.data === "string" && part.data.trim()
|
||
? part.data
|
||
: "";
|
||
|
||
if (part.type === 'file' && fileUrl) {
|
||
renderedUserParts.push(
|
||
<span
|
||
key={`part-${idx}`}
|
||
className="inline-flex items-center gap-1 rounded-xl bg-white/90 px-2 py-1 text-xs"
|
||
>
|
||
<img
|
||
src={buildPreviewImageUrl(fileUrl, 48)}
|
||
alt=""
|
||
className="w-5 h-5 object-cover rounded"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== fileUrl) {
|
||
event.currentTarget.src = fileUrl;
|
||
}
|
||
}}
|
||
/>
|
||
<span className="max-w-[80px] truncate text-foreground">{part.name || 'Image'}</span>
|
||
</span>
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (part.type === 'text' && part.text) {
|
||
const text = String(part.text)
|
||
.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '')
|
||
.trim();
|
||
if (!text) return;
|
||
renderedUserParts.push(
|
||
<span key={`part-${idx}`} className="text-sm text-foreground">{text}</span>
|
||
);
|
||
}
|
||
});
|
||
|
||
if (
|
||
!message.parts?.some((p: any) => p.type === 'file') &&
|
||
(message as any).attachments?.length > 0
|
||
) {
|
||
(message as any).attachments.forEach((att: any, idx: number) => {
|
||
if (!att?.url) return;
|
||
renderedUserParts.push(
|
||
<span
|
||
key={`att-${idx}`}
|
||
className="inline-flex items-center gap-1 rounded-xl bg-white/90 px-2 py-1 text-xs"
|
||
>
|
||
<img
|
||
src={buildPreviewImageUrl(att.url, 48)}
|
||
alt=""
|
||
className="w-5 h-5 object-cover rounded"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== att.url) {
|
||
event.currentTarget.src = att.url;
|
||
}
|
||
}}
|
||
/>
|
||
<span className="max-w-[80px] truncate text-foreground">{att.name || 'Image'}</span>
|
||
</span>
|
||
);
|
||
});
|
||
}
|
||
|
||
if (renderedUserParts.length === 0) {
|
||
const fallbackText =
|
||
extractUserTextPrompt(message as any) ||
|
||
(isVideoModel ? "已发送视频生成请求" : "已发送请求");
|
||
renderedUserParts.push(
|
||
<span key="fallback-text" className="text-sm text-foreground">
|
||
{fallbackText}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return renderedUserParts;
|
||
})()}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div
|
||
className={cn(
|
||
"min-w-0 max-w-full text-[15px] text-[#2f3640]",
|
||
isImageOnlyAssistant
|
||
? "bg-transparent px-0 py-0"
|
||
: "bg-transparent px-0 py-0"
|
||
)}
|
||
>
|
||
{/* 消息内容 */}
|
||
<div className="w-full min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] leading-relaxed">
|
||
{message.parts?.map((part: any, i: number) => {
|
||
if (part.type === 'text') {
|
||
// 提取并显示图片/视频
|
||
const rawText = part.text || '';
|
||
const imageUrls = extractAssistantImageUrls(
|
||
rawText,
|
||
nearestUserImageKeys
|
||
);
|
||
const videoUrls = extractVideoUrls(rawText);
|
||
const textWithoutImages = stripCanvasModelRoutingLines(
|
||
stripMediaMarkup(rawText)
|
||
);
|
||
const analysisSection = extractCanvasImageAnalysisSection(textWithoutImages);
|
||
const plainText = normalizeCanvasAgentText(textWithoutImages);
|
||
const analysisToggleKey = `${message.id}-${i}-analysis`;
|
||
const analysisOpen = analysisCollapseState[analysisToggleKey] ?? true;
|
||
const analysisTypingKey = `${message.id}-${i}-analysis-typing`;
|
||
const plainTextTypingKey = `${message.id}-${i}-plain-typing`;
|
||
const shouldAnimateAnalysis =
|
||
shouldTypeAssistantMessage &&
|
||
Boolean(analysisSection?.analysis) &&
|
||
!typedAssistantSections[analysisTypingKey];
|
||
const shouldAnimatePlainText =
|
||
shouldTypeAssistantMessage &&
|
||
!analysisSection &&
|
||
Boolean(plainText) &&
|
||
!typedAssistantSections[plainTextTypingKey];
|
||
const shouldShowDoneSummary =
|
||
!isDetailGenerationMode &&
|
||
imageUrls.length > 0 &&
|
||
!plainText.includes(CANVAS_IMAGE_DONE_SUMMARY) &&
|
||
!isLatestAssistantDuringGeneration;
|
||
const renderPlainText = (text: string, key: string) => (
|
||
<div
|
||
key={key}
|
||
className="whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||
>
|
||
{text}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div key={i}>
|
||
{analysisSection ? (
|
||
<div className="space-y-3">
|
||
{analysisSection.before
|
||
? renderPlainText(analysisSection.before, `before-${i}`)
|
||
: null}
|
||
<Collapsible
|
||
className="my-2 rounded-[20px] bg-[#f4f5f7] px-5 py-4"
|
||
open={analysisOpen}
|
||
onOpenChange={(open) =>
|
||
setAnalysisCollapseState((prev) => ({
|
||
...prev,
|
||
[analysisToggleKey]: open,
|
||
}))
|
||
}
|
||
>
|
||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 text-left">
|
||
<span className="inline-flex items-center gap-2">
|
||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white text-[#9aa1ad] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.42)]">
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
fill="currentColor"
|
||
className="h-4 w-4"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
d="M10.5 3.75a6.75 6.75 0 1 0 4.136 12.085l4.047 4.046a.75.75 0 1 0 1.06-1.06l-4.046-4.047A6.75 6.75 0 0 0 10.5 3.75Zm-3.75 6.75a3.75 3.75 0 1 1 7.5 0 3.75 3.75 0 0 1-7.5 0Z"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
<span className="text-base font-semibold tracking-tight text-[#9aa1ad]">
|
||
图片分析
|
||
</span>
|
||
</span>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 text-[#9aa1ad] transition-transform duration-200",
|
||
analysisOpen ? "rotate-180" : "rotate-0"
|
||
)}
|
||
/>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent
|
||
className={cn(
|
||
"pt-3 text-[15px] font-medium leading-7 text-[#2f3640]",
|
||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1",
|
||
"outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in"
|
||
)}
|
||
>
|
||
<CanvasTypewriterText
|
||
text={analysisSection.analysis}
|
||
typingKey={analysisTypingKey}
|
||
enabled={shouldAnimateAnalysis}
|
||
className="whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||
onActiveChange={handleTypewriterActiveChange}
|
||
onComplete={handleTypewriterComplete}
|
||
/>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
{analysisSection.after
|
||
? renderPlainText(analysisSection.after, `after-${i}`)
|
||
: null}
|
||
</div>
|
||
) : plainText ? (
|
||
<CanvasTypewriterText
|
||
text={plainText}
|
||
typingKey={plainTextTypingKey}
|
||
enabled={shouldAnimatePlainText}
|
||
className="break-words [overflow-wrap:anywhere]"
|
||
onActiveChange={handleTypewriterActiveChange}
|
||
onComplete={handleTypewriterComplete}
|
||
/>
|
||
) : null}
|
||
{imageUrls.length > 0 && (
|
||
<div className={cn("space-y-2", textWithoutImages ? "mt-3" : "mt-0")}>
|
||
{mediaModelName ? (
|
||
<div className="flex items-center gap-1.5 text-[#a4acb8]">
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
fill="currentColor"
|
||
className="h-3.5 w-3.5"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M10.8 2.4a2.4 2.4 0 0 1 2.4 0l6.2 3.58a2.4 2.4 0 0 1 1.2 2.08v7.16a2.4 2.4 0 0 1-1.2 2.08l-6.2 3.58a2.4 2.4 0 0 1-2.4 0l-6.2-3.58a2.4 2.4 0 0 1-1.2-2.08V8.06a2.4 2.4 0 0 1 1.2-2.08l6.2-3.58Zm1.2 5.1a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9Zm0 2.25a2.25 2.25 0 1 1 0 4.5 2.25 2.25 0 0 1 0-4.5Z" />
|
||
</svg>
|
||
<span className="text-[12px] font-medium">{mediaModelName}</span>
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-wrap gap-2">
|
||
{imageUrls.map((url, idx) => (
|
||
<div
|
||
key={idx}
|
||
className={cn(
|
||
"relative group overflow-hidden border transition-all",
|
||
"rounded-2xl border-black/5 dark:border-white/10 bg-white/70 dark:bg-zinc-900/50",
|
||
"shadow-[0_8px_24px_-16px_rgba(15,23,42,0.45)] hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.5)]"
|
||
)}
|
||
>
|
||
<img
|
||
src={buildPreviewImageUrl(url, 960)}
|
||
alt=""
|
||
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
|
||
style={{ maxHeight: "260px" }}
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== url) {
|
||
event.currentTarget.src = url;
|
||
}
|
||
}}
|
||
onClick={() => setFullscreenPreview({ url })}
|
||
onDoubleClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setFullscreenPreview({ url });
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white hover:bg-black/80"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
void handleDownloadOriginalImage(url);
|
||
}}
|
||
title="下载原图"
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
原图
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{shouldShowDoneSummary ? (
|
||
<p className="text-[14px] font-medium leading-6 text-[#2f3640]">
|
||
{CANVAS_IMAGE_DONE_SUMMARY}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
{videoUrls.length > 0 && (
|
||
<div className={cn("space-y-2", textWithoutImages || imageUrls.length > 0 ? "mt-3" : "mt-0")}>
|
||
{videoUrls.map((url, idx) => (
|
||
<div
|
||
key={`video-${idx}`}
|
||
className="overflow-hidden rounded-2xl border border-black/10 bg-black/5 p-2"
|
||
>
|
||
<video
|
||
src={url}
|
||
controls
|
||
className="block w-full rounded-xl bg-black"
|
||
style={{ maxHeight: "320px" }}
|
||
/>
|
||
<a
|
||
href={url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="mt-2 inline-flex text-xs text-blue-600 hover:underline"
|
||
>
|
||
下载视频
|
||
</a>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="mt-1 flex items-center gap-2 text-[#8f96a3]">
|
||
{showModelMeta ? (
|
||
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium tracking-tight text-[#a2a9b5]">
|
||
<Eye className="h-3.5 w-3.5 opacity-80" />
|
||
<span>{displayAssistantModelName}</span>
|
||
</span>
|
||
) : null}
|
||
{showVoteActions ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-black/5",
|
||
currentVote?.isUpvoted && "text-[#22a06b] bg-emerald-50/80"
|
||
)}
|
||
title="点赞"
|
||
disabled={isVotingCurrentMessage}
|
||
onClick={() => void handleAssistantVote(message.id, "up")}
|
||
>
|
||
<ThumbsUp className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-black/5",
|
||
currentVote && !currentVote.isUpvoted && "text-[#d94848] bg-rose-50/80"
|
||
)}
|
||
title="点踩"
|
||
disabled={isVotingCurrentMessage}
|
||
onClick={() => void handleAssistantVote(message.id, "down")}
|
||
>
|
||
<ThumbsDown className="h-4 w-4" />
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{shouldRenderImageLoading && (
|
||
<ImageGenerationLoading
|
||
imageCount={loadingImageCount}
|
||
maxImages={6}
|
||
providerType={loadingProviderType}
|
||
modelName={loadingModelName}
|
||
phase={loadingPhase}
|
||
/>
|
||
)}
|
||
|
||
{isLoading && !shouldRenderImageLoading && (
|
||
<div className="flex w-full justify-start">
|
||
<div className="flex items-center gap-2 text-[#8e97a5]">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
<span className="text-[16px]">分析中</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{fullscreenPreview && createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center cursor-zoom-out"
|
||
onClick={() => setFullscreenPreview(null)}
|
||
>
|
||
<button
|
||
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
||
onClick={() => setFullscreenPreview(null)}
|
||
aria-label="关闭预览"
|
||
>
|
||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/20 transition-colors"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
void handleDownloadOriginalImage(fullscreenPreview.url);
|
||
}}
|
||
aria-label="下载原图"
|
||
title="下载原图"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
下载原图
|
||
</button>
|
||
|
||
<img
|
||
src={fullscreenPreview.url}
|
||
alt={fullscreenPreview.alt || "图片预览"}
|
||
className="max-w-[95vw] max-h-[95vh] object-contain"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
|
||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
|
||
点击空白处或按 Esc 关闭
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
|
||
{!isWorkspacePresentation && shouldShowCanvasBanner ? (
|
||
<div className="px-4 pb-2">
|
||
<div
|
||
className={cn(
|
||
"bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 p-3 rounded-xl text-sm flex items-center justify-between",
|
||
siteSettings?.bannerLink && "cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
||
)}
|
||
onClick={() => {
|
||
if (siteSettings?.bannerLink) {
|
||
window.open(siteSettings.bannerLink, "_blank");
|
||
}
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="p-1 bg-blue-100 dark:bg-blue-800 rounded">
|
||
<Box className="h-4 w-4" />
|
||
</div>
|
||
<span>{siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!"}</span>
|
||
</div>
|
||
<X className="h-4 w-4 cursor-pointer opacity-50 hover:opacity-100" onClick={(e) => e.stopPropagation()} />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
|
||
<div
|
||
className={cn(
|
||
"border-t",
|
||
isWorkspacePresentation
|
||
? "bg-white/92 p-5 backdrop-blur-sm dark:bg-zinc-950/92"
|
||
: "bg-white p-4 dark:bg-zinc-950"
|
||
)}
|
||
>
|
||
<div
|
||
className="relative flex flex-col bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background"
|
||
data-canvas-chat-input-anchor="true"
|
||
>
|
||
{isWorkspacePresentation && !isDetailGenerationMode ? (
|
||
<div className="border-b px-5 py-3">
|
||
<div className="text-sm font-semibold text-foreground">补充要求</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
这里填写本次生图补充要求。图片槽位和生成结果都在当前工作台内完成,不再使用聊天对话方式。
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{isDetailGenerationMode && (
|
||
<div
|
||
className={cn(
|
||
"order-3 mx-3 mb-11 overflow-y-auto rounded-2xl border border-black/10 bg-white p-3 pr-2",
|
||
"mt-1",
|
||
renderableVisibleMessages.length === 0 ? "max-h-[62vh]" : "max-h-[54vh]"
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between gap-3 border-b border-border/5 pb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500/10 to-fuchsia-500/10 ring-1 ring-inset ring-black/5 dark:ring-white/10">
|
||
<Sparkles className="h-4.5 w-4.5 text-violet-600 dark:text-violet-400" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-semibold text-foreground">
|
||
AI详情生成工作台
|
||
</h3>
|
||
<span className="rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] font-medium text-black/60 dark:bg-white/10 dark:text-white/70">
|
||
PC端
|
||
</span>
|
||
</div>
|
||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||
全链路智能生成 · {DETAIL_TARGET_WIDTH}x{DETAIL_TARGET_HEIGHT}px
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<div
|
||
className={cn(
|
||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors",
|
||
detailProgress.status === "running"
|
||
? "bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-600/20 dark:bg-blue-500/10 dark:text-blue-400"
|
||
: detailProgress.status === "done"
|
||
? "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-400"
|
||
: detailProgress.status === "error"
|
||
? "bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20 dark:bg-red-500/10 dark:text-red-400"
|
||
: "bg-zinc-100 text-zinc-600 ring-1 ring-inset ring-zinc-500/10 dark:bg-zinc-800 dark:text-zinc-400"
|
||
)}
|
||
>
|
||
<div className={cn(
|
||
"h-1.5 w-1.5 rounded-full",
|
||
detailProgress.status === "running" ? "animate-pulse bg-blue-600" :
|
||
detailProgress.status === "done" ? "bg-emerald-600" :
|
||
detailProgress.status === "error" ? "bg-red-600" : "bg-zinc-400"
|
||
)} />
|
||
{detailProgress.status === "running"
|
||
? "生成中"
|
||
: detailProgress.status === "done"
|
||
? "已完成"
|
||
: detailProgress.status === "error"
|
||
? "异常"
|
||
: "待启动"}
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||
onClick={() => setIsDetailWorkbenchCollapsed((prev) => !prev)}
|
||
>
|
||
{isDetailWorkbenchCollapsed ? (
|
||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||
) : (
|
||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{isDetailWorkbenchCollapsed ? (
|
||
<button
|
||
type="button"
|
||
className="mt-4 flex w-full items-center justify-between rounded-lg border border-dashed border-border/50 bg-zinc-50/50 px-4 py-2.5 text-xs text-muted-foreground transition-colors hover:bg-zinc-100 dark:bg-zinc-900/50 dark:hover:bg-zinc-800"
|
||
onClick={() => setIsDetailWorkbenchCollapsed(false)}
|
||
>
|
||
<span>配置面板已收起</span>
|
||
<span className="text-[10px] font-medium text-primary">点击展开</span>
|
||
</button>
|
||
) : (
|
||
<>
|
||
<div className="mt-4 rounded-xl border border-border/50 bg-zinc-50/50 p-3 dark:bg-zinc-900/50">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="text-[11px] font-medium text-foreground">
|
||
{detailProgress.stage || "准备就绪"}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{detailProgress.current}/{detailProgress.total || 0} 步骤
|
||
</span>
|
||
</div>
|
||
<div className="h-2 w-full overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||
<div
|
||
className={cn(
|
||
"h-full rounded-full transition-all duration-500 ease-out",
|
||
detailProgress.status === "error"
|
||
? "bg-red-500"
|
||
: detailProgress.status === "done"
|
||
? "bg-emerald-500"
|
||
: "bg-primary"
|
||
)}
|
||
style={{ width: `${detailProgressPercent}%` }}
|
||
/>
|
||
</div>
|
||
{!detailProgress.stage && (
|
||
<p className="mt-2 text-[10px] text-muted-foreground">
|
||
请先补齐必填信息与素材,然后点击下方发送按钮开始生成。
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-3 rounded-xl border border-border/50 bg-zinc-50/40 p-3 dark:bg-zinc-900/40">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="text-[11px] font-medium text-foreground">生成路线</p>
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{detailGenerationMode === "classic" ? "当前:传统生图" : "当前:AGENT排版"}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{DETAIL_GENERATION_MODE_OPTIONS.map((option) => {
|
||
const selected = detailGenerationMode === option.value;
|
||
return (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
onClick={() => {
|
||
if (isDetailGenerating) return;
|
||
setDetailGenerationMode(option.value);
|
||
}}
|
||
disabled={isDetailGenerating}
|
||
className={cn(
|
||
"rounded-lg border px-3 py-2 text-left transition-all duration-150",
|
||
isDetailGenerating && "cursor-not-allowed opacity-60",
|
||
selected
|
||
? "border-[#5b7cff] bg-[#eef3ff] text-[#2f57d8] shadow-[0_0_0_1px_rgba(91,124,255,0.28)]"
|
||
: "border-border/60 bg-background text-muted-foreground hover:border-[#9fb3ff] hover:bg-[#f8faff] hover:text-foreground"
|
||
)}
|
||
aria-pressed={selected}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-[11px] font-medium">{option.label}</div>
|
||
{selected ? <Check className="h-3.5 w-3.5" /> : null}
|
||
</div>
|
||
<div className="mt-1 text-[10px] leading-snug opacity-80">
|
||
{option.description}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
{/* Required Info */}
|
||
<Collapsible
|
||
open={detailSections.required}
|
||
onOpenChange={() => toggleDetailSection("required")}
|
||
className="overflow-hidden rounded-xl border border-border/50 bg-card shadow-sm transition-all hover:shadow-md dark:bg-zinc-900/50"
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-muted/50"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400">
|
||
<AlertCircle className="h-3.5 w-3.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-foreground">核心参数</p>
|
||
<p className="text-[10px] text-muted-foreground">人群 / 场景 / 需求 (必填)</p>
|
||
</div>
|
||
</div>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||
detailSections.required && "rotate-180"
|
||
)}
|
||
/>
|
||
</button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<div className="grid grid-cols-2 gap-3 border-t border-border/50 bg-zinc-50/30 px-4 py-4 dark:bg-zinc-900/30 xl:grid-cols-4">
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">商品SPU</label>
|
||
<input
|
||
value={detailBrief.productSpu}
|
||
onChange={(event) => updateDetailBriefField("productSpu", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.productSpu}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="建议填写"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">需求类型</label>
|
||
<input
|
||
value={detailBrief.demandType}
|
||
onChange={(event) => updateDetailBriefField("demandType", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.demandType}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="建议填写"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">主购买人群 <span className="text-red-500">*</span></label>
|
||
<input
|
||
value={detailBrief.targetAudience}
|
||
onChange={(event) => updateDetailBriefField("targetAudience", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.targetAudience}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="必填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">需求场景 <span className="text-red-500">*</span></label>
|
||
<input
|
||
value={detailBrief.usageScenario}
|
||
onChange={(event) => updateDetailBriefField("usageScenario", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.usageScenario}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="必填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">核心需求 <span className="text-red-500">*</span></label>
|
||
<input
|
||
value={detailBrief.coreNeed}
|
||
onChange={(event) => updateDetailBriefField("coreNeed", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.coreNeed}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="必填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">产品名称</label>
|
||
<input
|
||
value={detailBrief.productName}
|
||
onChange={(event) => updateDetailBriefField("productName", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.productName}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">主使用者</label>
|
||
<input
|
||
value={detailBrief.mainUser}
|
||
onChange={(event) => updateDetailBriefField("mainUser", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.mainUser}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">触发时刻</label>
|
||
<input
|
||
value={detailBrief.triggerMoment}
|
||
onChange={(event) => updateDetailBriefField("triggerMoment", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.triggerMoment}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">用户顾虑</label>
|
||
<input
|
||
value={detailBrief.riskConcern}
|
||
onChange={(event) => updateDetailBriefField("riskConcern", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.riskConcern}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">期望结果</label>
|
||
<input
|
||
value={detailBrief.expectedResult}
|
||
onChange={(event) => updateDetailBriefField("expectedResult", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.expectedResult}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">核心卖点关键词</label>
|
||
<input
|
||
value={detailBrief.coreSellingPoint}
|
||
onChange={(event) => updateDetailBriefField("coreSellingPoint", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.coreSellingPoint}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">一句话定位</label>
|
||
<input
|
||
value={detailBrief.positioningStatement}
|
||
onChange={(event) => updateDetailBriefField("positioningStatement", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.positioningStatement}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{/* Strategy Info */}
|
||
<Collapsible
|
||
open={detailSections.strategy}
|
||
onOpenChange={() => toggleDetailSection("strategy")}
|
||
className="overflow-hidden rounded-xl border border-border/50 bg-card shadow-sm transition-all hover:shadow-md dark:bg-zinc-900/50"
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-muted/50"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-purple-50 text-purple-600 dark:bg-purple-500/10 dark:text-purple-400">
|
||
<Brain className="h-3.5 w-3.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-foreground">策略信息</p>
|
||
<p className="text-[10px] text-muted-foreground">品牌语气 / 竞品 / 禁用词</p>
|
||
</div>
|
||
</div>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||
detailSections.strategy && "rotate-180"
|
||
)}
|
||
/>
|
||
</button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<div className="grid grid-cols-2 gap-3 border-t border-border/50 bg-zinc-50/30 px-4 py-4 dark:bg-zinc-900/30 xl:grid-cols-4">
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">价格带</label>
|
||
<input
|
||
value={detailBrief.priceBand}
|
||
onChange={(event) => updateDetailBriefField("priceBand", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.priceBand}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-1 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">品牌语气</label>
|
||
<input
|
||
value={detailBrief.brandTone}
|
||
onChange={(event) => updateDetailBriefField("brandTone", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.brandTone}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">竞品对标</label>
|
||
<input
|
||
value={detailBrief.competitorReference}
|
||
onChange={(event) => updateDetailBriefField("competitorReference", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.competitorReference}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="选填"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">禁用表达</label>
|
||
<input
|
||
value={detailBrief.bannedWords}
|
||
onChange={(event) => updateDetailBriefField("bannedWords", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.bannedWords}
|
||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="逗号分隔"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2 xl:col-span-4 space-y-1.5">
|
||
<label className="text-[10px] font-medium text-muted-foreground">用户原话样本</label>
|
||
<textarea
|
||
value={detailBrief.userVoice}
|
||
onChange={(event) => updateDetailBriefField("userVoice", event.target.value)}
|
||
title={DETAIL_FIELD_EXAMPLES.userVoice}
|
||
className="min-h-[64px] w-full resize-y rounded-md border border-input bg-background px-2.5 py-2 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/50"
|
||
placeholder="建议3-5条"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{/* Assets Info */}
|
||
<Collapsible
|
||
open={detailSections.assets}
|
||
onOpenChange={() => toggleDetailSection("assets")}
|
||
className="overflow-hidden rounded-xl border border-border/50 bg-card shadow-sm transition-all hover:shadow-md dark:bg-zinc-900/50"
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-muted/50"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-orange-50 text-orange-600 dark:bg-orange-500/10 dark:text-orange-400">
|
||
<ImagePlus className="h-3.5 w-3.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-foreground">素材中心</p>
|
||
<p className="text-[10px] text-muted-foreground">5张产品 + 1张模特 + 3张搭配</p>
|
||
</div>
|
||
</div>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||
detailSections.assets && "rotate-180"
|
||
)}
|
||
/>
|
||
</button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<div className="border-t border-border/50 bg-zinc-50/30 px-4 py-4 dark:bg-zinc-900/30">
|
||
<div className="mb-3 flex items-start gap-2 text-[10px] text-muted-foreground">
|
||
<AlertCircle className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
|
||
<span>Agent 会自动判断是否使用模特图;若上传搭配图,将自动新增一屏“场景化产品搭配展示”用于关联销售。</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||
{DETAIL_SLOT_CONFIGS.map((config, index) => {
|
||
const slotAttachment = detailSlots[index];
|
||
const isUploading = detailSlotUploading === index;
|
||
const isPickingFromCanvas = detailSlotPickerIndex === index;
|
||
return (
|
||
<div
|
||
key={config.key}
|
||
className={cn(
|
||
"group relative aspect-square overflow-hidden rounded-lg border transition-all",
|
||
slotAttachment
|
||
? "border-border bg-background shadow-sm"
|
||
: "border-dashed border-border/60 bg-background/50 hover:border-primary/50 hover:bg-background",
|
||
isPickingFromCanvas && "ring-2 ring-primary ring-offset-2"
|
||
)}
|
||
>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
ref={(element) => {
|
||
detailSlotInputRefs.current[index] = element;
|
||
}}
|
||
onChange={(event) => {
|
||
void handleDetailSlotFileChange(index, event);
|
||
}}
|
||
/>
|
||
{slotAttachment ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="h-full w-full cursor-pointer"
|
||
onClick={() => detailSlotInputRefs.current[index]?.click()}
|
||
>
|
||
<img
|
||
src={buildPreviewImageUrl(slotAttachment.url, 520)}
|
||
alt={config.label}
|
||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== slotAttachment.url) {
|
||
event.currentTarget.src = slotAttachment.url;
|
||
}
|
||
}}
|
||
/>
|
||
</button>
|
||
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-1 py-1 text-center text-[10px] text-white backdrop-blur-sm">
|
||
{config.label}
|
||
</div>
|
||
<div className="absolute left-1 top-1 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"rounded px-1.5 py-0.5 text-[10px] text-white shadow-sm backdrop-blur-md",
|
||
isPickingFromCanvas ? "bg-primary" : "bg-black/50 hover:bg-black/70"
|
||
)}
|
||
onClick={() => activateDetailSlotCanvasPicker(index)}
|
||
>
|
||
画布
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded bg-black/50 px-1.5 py-0.5 text-[10px] text-white shadow-sm backdrop-blur-md hover:bg-black/70"
|
||
onClick={() => detailSlotInputRefs.current[index]?.click()}
|
||
>
|
||
更换
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="absolute right-1 top-1 rounded bg-red-500/80 px-1.5 py-0.5 text-[10px] text-white shadow-sm backdrop-blur-md opacity-0 transition-opacity duration-200 hover:bg-red-600 group-hover:opacity-100"
|
||
onClick={() => updateDetailSlotAttachment(index, null)}
|
||
>
|
||
移除
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"absolute right-1 top-1 rounded px-1.5 py-0.5 text-[10px] text-white shadow-sm transition-all",
|
||
isPickingFromCanvas ? "bg-primary opacity-100" : "bg-zinc-500/50 opacity-0 hover:bg-zinc-500 group-hover:opacity-100"
|
||
)}
|
||
onClick={() => activateDetailSlotCanvasPicker(index)}
|
||
disabled={isSubmitting || isUploading}
|
||
>
|
||
画布
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2 text-muted-foreground transition-colors hover:text-foreground"
|
||
onClick={() => detailSlotInputRefs.current[index]?.click()}
|
||
disabled={isSubmitting || isUploading}
|
||
>
|
||
{isUploading ? (
|
||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||
) : (
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted/50 transition-colors group-hover:bg-muted">
|
||
<Plus className="h-4 w-4" />
|
||
</div>
|
||
)}
|
||
<span className="text-[10px] font-medium">{config.label}</span>
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="mt-3 flex items-center justify-between text-[10px] text-muted-foreground">
|
||
<div>
|
||
当前素材:产品图 <span className={detailProductCount === 5 ? "text-emerald-500 font-medium" : ""}>{detailProductCount}/5</span>,
|
||
模特图 <span className={detailModelReady ? "text-emerald-500 font-medium" : ""}>{detailModelReady ? "1/1" : "0/1"}</span>,
|
||
搭配图 {detailBundleCount}/3
|
||
</div>
|
||
{detailSlotPickerIndex !== null && (
|
||
<div className="animate-pulse font-medium text-primary">
|
||
请在画布中选择图片...
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{/* Archives Info */}
|
||
<Collapsible
|
||
open={detailSections.archives}
|
||
onOpenChange={() => toggleDetailSection("archives")}
|
||
className="overflow-hidden rounded-xl border border-border/50 bg-card shadow-sm transition-all hover:shadow-md dark:bg-zinc-900/50"
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-muted/50"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
|
||
<History className="h-3.5 w-3.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-foreground">项目存档</p>
|
||
<p className="text-[10px] text-muted-foreground">历史记录与回溯</p>
|
||
</div>
|
||
</div>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||
detailSections.archives && "rotate-180"
|
||
)}
|
||
/>
|
||
</button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<div className="border-t border-border/50 bg-zinc-50/30 px-4 py-4 dark:bg-zinc-900/30">
|
||
{detailArchives.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border/60 px-3 py-6 text-center">
|
||
<p className="text-[11px] text-muted-foreground">暂无存档记录</p>
|
||
<p className="mt-1 text-[10px] text-muted-foreground/60">生成成功后将自动保存</p>
|
||
</div>
|
||
) : (
|
||
<div className="max-h-48 space-y-2 overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-zinc-200 dark:scrollbar-thumb-zinc-800">
|
||
{detailArchives.map((archive) => (
|
||
<div
|
||
key={archive.id}
|
||
className="group flex items-center justify-between gap-3 rounded-lg border border-border/40 bg-background px-3 py-2.5 transition-all hover:border-primary/30 hover:shadow-sm"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[12px] font-medium text-foreground group-hover:text-primary">
|
||
{archive.title}
|
||
</div>
|
||
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
|
||
<span>{new Date(archive.createdAt).toLocaleString()}</span>
|
||
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||
<span>{archive.imageCount || 0}张输出</span>
|
||
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||
<span>{archive.generationMode === "agent_layout" ? "AGENT排版" : "传统生图"}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
className="rounded-md border border-border/50 bg-secondary/50 px-2.5 py-1 text-[10px] font-medium text-secondary-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
|
||
onClick={() => loadDetailArchive(archive)}
|
||
>
|
||
加载
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/50 bg-background text-muted-foreground transition-colors hover:border-destructive/40 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
|
||
onClick={() => void deleteDetailArchive(archive.id)}
|
||
disabled={deletingDetailArchiveId === archive.id}
|
||
title="删除存档"
|
||
aria-label="删除存档"
|
||
>
|
||
{deletingDetailArchiveId === archive.id ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"mt-4 flex items-center justify-between gap-2 rounded-lg border px-4 py-3 text-xs shadow-sm transition-all",
|
||
detailValidationIssues.length === 0
|
||
? "border-emerald-200 bg-emerald-50/50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400"
|
||
: "border-amber-200 bg-amber-50/50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-400"
|
||
)}
|
||
>
|
||
{detailValidationIssues.length === 0 ? (
|
||
<>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20">
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
</div>
|
||
<span className="font-medium">校验通过,准备就绪</span>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => void handleSend()}
|
||
disabled={isSubmitting}
|
||
className="h-7 bg-emerald-600 px-3 text-[10px] hover:bg-emerald-700 dark:bg-emerald-600 dark:text-white dark:hover:bg-emerald-500"
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||
生成中
|
||
</>
|
||
) : (
|
||
<>
|
||
立即生成
|
||
<ArrowUp className="ml-1.5 h-3 w-3" />
|
||
</>
|
||
)}
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-600 dark:bg-amber-500/20">
|
||
<AlertCircle className="h-3 w-3" />
|
||
</div>
|
||
<span className="font-medium">
|
||
需完善:{detailValidationIssues.join("、")}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{hasGeneratedDetailImages ? (
|
||
<div className="mt-3 overflow-hidden rounded-xl border border-violet-200 bg-violet-50/50 p-0 shadow-sm transition-all hover:shadow-md dark:border-violet-500/30 dark:bg-violet-500/10">
|
||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 text-violet-600 dark:bg-violet-500/20 dark:text-violet-300">
|
||
<Sparkles className="h-4 w-4" />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-violet-700 dark:text-violet-300">
|
||
追加评价晒图
|
||
</p>
|
||
<p className="text-[10px] text-violet-600/70 dark:text-violet-400/70">
|
||
新增2条评价 (每条3张图) · 不重跑主图
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"flex items-center rounded-md px-3 py-1.5 text-xs font-medium shadow-sm transition-all",
|
||
isSubmitting
|
||
? "cursor-not-allowed bg-violet-100 text-violet-400 dark:bg-violet-500/20"
|
||
: "bg-violet-600 text-white hover:bg-violet-700 dark:bg-violet-600 dark:hover:bg-violet-500"
|
||
)}
|
||
onClick={() => {
|
||
void handleGenerateMoreDetailReviews();
|
||
}}
|
||
disabled={isSubmitting}
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||
生成中
|
||
</>
|
||
) : (
|
||
<>
|
||
立即追加
|
||
<ArrowUp className="ml-1.5 h-3 w-3" />
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
|
||
<div
|
||
className={cn(
|
||
"relative px-4 pt-3 pb-12",
|
||
isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]"
|
||
)}
|
||
>
|
||
{!isWorkspacePresentation && !isDetailGenerationMode && selectedSkillTag && (
|
||
<div className="mb-2 flex">
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-2 rounded-full bg-[#f3f4f6] px-3 py-1.5 text-sm font-medium text-[#4b5563] transition-colors hover:bg-[#e9ebef]"
|
||
onClick={() => setSelectedSkillTagId(null)}
|
||
title="点击移除标签"
|
||
>
|
||
{selectedSkillTag.iconUrl ? (
|
||
<img
|
||
src={buildPreviewImageUrl(selectedSkillTag.iconUrl, 40)}
|
||
alt={selectedSkillTag.name}
|
||
className="h-4 w-4 rounded-sm object-cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
) : (
|
||
<SelectedSkillTagIcon className="h-4 w-4 text-[#8b5cf6]" />
|
||
)}
|
||
<span>{selectedSkillTag.name}</span>
|
||
<X className="h-3.5 w-3.5 text-[#9ca3af]" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!isDetailGenerationMode && hasWorkflowContext && workflowContext && (
|
||
<div className="mb-2 rounded-2xl border border-[#dbe3ff] bg-[#f6f8ff] px-3 py-2">
|
||
<div className="flex items-center gap-2 text-sm font-medium text-[#334155]">
|
||
<Sparkles className="h-4 w-4 text-[#4f46e5]" />
|
||
<span>当前工作流设定</span>
|
||
</div>
|
||
<div className="mt-1 text-xs leading-5 text-[#5b6472]">
|
||
{workflowContext.summaryText}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!isDetailGenerationMode && activeComposerMode === "agent" && selectedAgentPreset && (
|
||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||
<div className="inline-flex items-center gap-2 rounded-full bg-[#eef2ff] px-3 py-1.5 text-sm font-medium text-[#4338ca]">
|
||
<Sparkles className="h-4 w-4" />
|
||
<span>{selectedAgentPreset.name}</span>
|
||
</div>
|
||
{selectedAgentPreset.type === "text" ? (
|
||
<div className="inline-flex items-center rounded-full bg-[#f4f5f7] px-3 py-1.5 text-xs text-[#5b6472]">
|
||
可上传图片做分析,也可直接输入文案需求
|
||
</div>
|
||
) : hasAgentUploadSlots ? (
|
||
<div className="inline-flex items-center rounded-full bg-[#f4f5f7] px-3 py-1.5 text-xs text-[#5b6472]">
|
||
{isWorkspacePresentation ? "请确认产品图1、产品图2、模特图槽位" : "请上传产品图1、产品图2、模特图"}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
<div
|
||
ref={textareaRef as any}
|
||
contentEditable={!isSubmitting}
|
||
suppressContentEditableWarning
|
||
className="min-h-[40px] max-h-[120px] overflow-y-auto text-sm outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-[#9aa1ad]"
|
||
data-placeholder={composerPlaceholder}
|
||
onInput={(e) => {
|
||
// 从 contenteditable 中提取纯文本(排除附件标签文本)
|
||
const target = e.target as HTMLDivElement;
|
||
const clone = target.cloneNode(true) as HTMLDivElement;
|
||
clone.querySelectorAll("[data-attachment-url]").forEach((el) => el.remove());
|
||
const textContent = (clone.textContent || "")
|
||
.replace(/\u200b/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
setInput(textContent);
|
||
if ((!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
|
||
syncAttachmentsWithEditor(target);
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
// 🆕 详情模式下,Enter 键默认不直接发送,防止误触。除非使用 Meta+Enter 或 Ctrl+Enter(这里先不处理组合键,仅禁用单键发送)
|
||
// 或者如果用户习惯了 Enter 发送,可以保持,但考虑到详情生成成本较高,也许需要确认?
|
||
// 暂且保持一致,如果用户在详情模式下按 Enter,也触发 handleSend(内部会做校验)
|
||
handleSend();
|
||
}
|
||
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
|
||
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
|
||
const selection = window.getSelection();
|
||
if (selection && selection.rangeCount > 0) {
|
||
const range = selection.getRangeAt(0);
|
||
if (range.collapsed && range.startOffset === 0) {
|
||
// 检查前一个兄弟节点是否是图片标签
|
||
const prevSibling = range.startContainer.previousSibling as HTMLElement;
|
||
if (prevSibling && prevSibling.getAttribute?.('data-attachment-url')) {
|
||
e.preventDefault();
|
||
e.stopPropagation(); // 阻止事件冒泡,防止触发全局删除
|
||
// 使用 URL 来删除附件,更可靠
|
||
removeAttachmentByTagElement(prevSibling);
|
||
prevSibling.remove();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
onPaste={(e) => {
|
||
const clipboardItems = Array.from(e.clipboardData?.items || []);
|
||
const imageFiles = clipboardItems
|
||
.filter((item) => item.type.startsWith("image/"))
|
||
.map((item) => item.getAsFile())
|
||
.filter((file): file is File => Boolean(file));
|
||
|
||
if (imageFiles.length > 0) {
|
||
e.preventDefault();
|
||
void handlePastedImages(imageFiles);
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
const text = e.clipboardData.getData('text/plain');
|
||
document.execCommand('insertText', false, text);
|
||
}}
|
||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||
/>
|
||
</div>
|
||
|
||
{hasAgentUploadSlots && (
|
||
<div className="mb-11 px-4 pb-1">
|
||
<div className="flex flex-row gap-2 overflow-x-auto scrollbar-hide">
|
||
{agentUploadSlots.map((slot, index) => {
|
||
const slotAttachment = attachments[index];
|
||
const isUploading = agentSlotUploading === index;
|
||
const isDisabled = isLoading || (index > 0 && !attachments[index - 1]);
|
||
return (
|
||
<button
|
||
key={`canvas-agent-slot-${slot.slotIndex}-${index}`}
|
||
type="button"
|
||
onClick={() => {
|
||
if (isDisabled) return;
|
||
if (fillAgentSlotFromCanvasSelection(index)) {
|
||
toast.success(`${slot.label}已从画布填充`);
|
||
return;
|
||
}
|
||
agentSlotInputRefs.current[index]?.click();
|
||
}}
|
||
className={cn(
|
||
"group relative flex h-24 min-w-24 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/30 text-muted-foreground transition-colors",
|
||
!isDisabled && "cursor-pointer hover:border-primary/60 hover:bg-muted/50",
|
||
isDisabled && "cursor-not-allowed opacity-55"
|
||
)}
|
||
>
|
||
{slotAttachment ? (
|
||
<>
|
||
<img
|
||
src={buildPreviewImageUrl(slotAttachment.url, 256)}
|
||
alt={slotAttachment.name || slot.label}
|
||
className="h-full w-full object-cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== slotAttachment.url) {
|
||
event.currentTarget.src = slotAttachment.url;
|
||
}
|
||
}}
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 text-center text-[11px] text-white">
|
||
{slot.label}
|
||
</div>
|
||
<div
|
||
className="absolute right-1 top-1 rounded-full bg-black/65 px-1 text-[11px] text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
updateAttachmentAtAgentSlot(index, null);
|
||
}}
|
||
>
|
||
x
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-1 px-1">
|
||
<span className="text-xl leading-none">+</span>
|
||
<span className="text-center text-xs">{isUploading ? "上传中" : slot.label}</span>
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="mt-1.5 text-[11px] text-muted-foreground">
|
||
智能体需要上传 3 张图片:产品图1、产品图2、模特图;已选主 SKU / 搭配 SKU 的素材图会优先进入前两个产品槽位,模特图继续手动上传或替换。
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isVideoModel && videoFrameSlots.length > 0 && (
|
||
<div className="mb-11 px-4 pb-1">
|
||
<div className="flex flex-row gap-2">
|
||
{videoFrameSlots.map((label, index) => {
|
||
const slotAttachment = attachments[index];
|
||
const isUploading = videoSlotUploading === index;
|
||
const isDisabled = isLoading || (index > 0 && !attachments[index - 1]);
|
||
return (
|
||
<button
|
||
key={`canvas-video-slot-${label}-${index}`}
|
||
type="button"
|
||
onClick={() => {
|
||
if (isDisabled) return;
|
||
if (fillVideoSlotFromCanvasSelection(index)) {
|
||
toast.success(`${label}已从画布填充`);
|
||
return;
|
||
}
|
||
videoSlotInputRefs.current[index]?.click();
|
||
}}
|
||
className={cn(
|
||
"group relative flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/30 text-muted-foreground transition-colors",
|
||
!isDisabled && "cursor-pointer hover:border-primary/60 hover:bg-muted/50",
|
||
isDisabled && "cursor-not-allowed opacity-55"
|
||
)}
|
||
>
|
||
{slotAttachment ? (
|
||
<>
|
||
<img
|
||
src={buildPreviewImageUrl(slotAttachment.url, 256)}
|
||
alt={slotAttachment.name || label}
|
||
className="h-full w-full object-cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== slotAttachment.url) {
|
||
event.currentTarget.src = slotAttachment.url;
|
||
}
|
||
}}
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 text-center text-[11px] text-white">
|
||
{label}
|
||
</div>
|
||
<div
|
||
className="absolute right-1 top-1 rounded-full bg-black/65 px-1 text-[11px] text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
updateAttachmentAtVideoSlot(index, null);
|
||
}}
|
||
>
|
||
x
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-1">
|
||
<span className="text-xl leading-none">+</span>
|
||
<span className="text-sm">{isUploading ? "上传中" : label}</span>
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="mt-1.5 text-[11px] text-muted-foreground">
|
||
已选中画布图片时,点击首帧/尾帧可直接填充;未选中则打开上传。
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bottom Toolbar */}
|
||
<div className="absolute left-2 bottom-2 right-2 flex items-center gap-2">
|
||
<div className="flex min-w-0 flex-1 items-center gap-1.5 pr-0.5">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleFileSelect}
|
||
/>
|
||
{videoFrameSlots.map((_, index) => (
|
||
<input
|
||
key={`canvas-video-slot-input-${index}`}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
ref={(element) => {
|
||
videoSlotInputRefs.current[index] = element;
|
||
}}
|
||
onChange={(event) => {
|
||
void handleVideoSlotFileChange(index, event);
|
||
}}
|
||
/>
|
||
))}
|
||
{agentUploadSlots.map((_, index) => (
|
||
<input
|
||
key={`canvas-agent-slot-input-${index}`}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
ref={(element) => {
|
||
agentSlotInputRefs.current[index] = element;
|
||
}}
|
||
onChange={(event) => {
|
||
void handleAgentSlotFileChange(index, event);
|
||
}}
|
||
/>
|
||
))}
|
||
{!isWorkspacePresentation ? (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className={cn(
|
||
"relative h-8 cursor-pointer rounded-full px-3 text-sm font-medium",
|
||
activeComposerMode === "video"
|
||
? "border border-blue-500 bg-blue-50 text-blue-600 hover:bg-blue-100"
|
||
: activeComposerMode === "detail"
|
||
? "border border-violet-500 bg-violet-50 text-violet-700 hover:bg-violet-100"
|
||
: activeComposerMode === "image"
|
||
? "border border-purple-500 bg-purple-50 text-purple-700 hover:bg-purple-100"
|
||
: "border border-[#9eb3ff] bg-white text-zinc-700 hover:bg-zinc-50"
|
||
)}
|
||
>
|
||
<span className="flex items-center">
|
||
{activeComposerMode === "video" ? (
|
||
<Video className="mr-1 h-4 w-4" />
|
||
) : activeComposerMode === "detail" ? (
|
||
<LayoutGrid className="mr-1 h-4 w-4" />
|
||
) : activeComposerMode === "image" ? (
|
||
<ImagePlus className="mr-1 h-4 w-4" />
|
||
) : (
|
||
<Sparkles className="mr-1 h-4 w-4" />
|
||
)}
|
||
{activeModeLabel}
|
||
</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
side="top"
|
||
sideOffset={10}
|
||
className="w-[156px] rounded-xl border border-[#d6d9df] bg-[#f4f5f7]/90 p-1.5 shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||
>
|
||
<DropdownMenuItem
|
||
onClick={() => handleSelectGenerationMode("agent")}
|
||
className={cn(
|
||
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium text-[#2e3238]",
|
||
activeComposerMode === "agent" ? "bg-white/80" : "hover:bg-white/70"
|
||
)}
|
||
>
|
||
<Sparkles className="h-4 w-4" />
|
||
<span className="flex-1">Agent</span>
|
||
{activeComposerMode === "agent" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
|
||
</DropdownMenuItem>
|
||
{aiDetailGenerationEnabled ? (
|
||
<DropdownMenuItem
|
||
onClick={() => handleSelectGenerationMode("detail")}
|
||
className={cn(
|
||
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium text-[#2e3238]",
|
||
activeComposerMode === "detail" ? "bg-white/80" : "hover:bg-white/70"
|
||
)}
|
||
>
|
||
<LayoutGrid className="h-4 w-4" />
|
||
<span className="flex-1">AI详情生成</span>
|
||
{activeComposerMode === "detail" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
<DropdownMenuItem
|
||
onClick={() => handleSelectGenerationMode("image")}
|
||
className={cn(
|
||
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium text-[#2e3238]",
|
||
activeComposerMode === "image" ? "bg-white/80" : "hover:bg-white/70"
|
||
)}
|
||
>
|
||
<ImagePlus className="h-4 w-4" />
|
||
<span className="flex-1">图像生成</span>
|
||
{activeComposerMode === "image" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
onClick={() => handleSelectGenerationMode("video")}
|
||
className={cn(
|
||
"h-8 cursor-pointer rounded-lg px-2 text-[13px] font-medium text-[#2e3238]",
|
||
activeComposerMode === "video" ? "bg-white/80" : "hover:bg-white/70"
|
||
)}
|
||
>
|
||
<Video className="h-4 w-4" />
|
||
<span className="flex-1">视频生成</span>
|
||
{activeComposerMode === "video" ? <Check className="h-4 w-4 text-[#3b82f6]" /> : null}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null}
|
||
|
||
{!isWorkspacePresentation && !isDetailGenerationMode && (!isVideoModel || showVideoAttachmentButton) && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={isSubmitting}
|
||
>
|
||
<Paperclip className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
|
||
{isVideoModel ? (
|
||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
||
<div className="w-max pr-1">
|
||
<VideoParamsBar
|
||
modelName={currentModel?.displayName}
|
||
initialParams={videoParams || undefined}
|
||
onParamsChange={handleVideoParamsChange}
|
||
inline={true}
|
||
providerType={currentModel?.providerType}
|
||
capabilities={currentModel?.videoToolCapability}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="shrink-0">
|
||
<ImageParamsBar
|
||
modelName={activeComposerMode === "agent" ? agentDisplayName : currentModel?.displayName}
|
||
initialParams={imageParams}
|
||
onParamsChange={handleImageParamsChange}
|
||
inline={true}
|
||
providerType={currentModel?.providerType}
|
||
imageGenConfig={currentModel?.imageGenConfig || undefined}
|
||
hideImageCount={activeComposerMode === "agent"}
|
||
hideAdvancedOptions={activeComposerMode === "image"}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
{!isWorkspacePresentation && activeComposerMode === "image" ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
title={`深度思考:${deepThinkingEnabled ? "开" : "关"}`}
|
||
className={cn(
|
||
"h-8 w-8 rounded-full border transition-colors",
|
||
deepThinkingEnabled
|
||
? "border-[#8ab0ff] bg-[#eef4ff] text-[#3b5bcc] hover:bg-[#e5efff]"
|
||
: "border-[#d8dce3] bg-white text-[#6b7280] hover:bg-[#f4f6f8]"
|
||
)}
|
||
onClick={() => setDeepThinkingEnabled((prev) => !prev)}
|
||
disabled={isSubmitting}
|
||
>
|
||
<Brain className="h-4 w-4" />
|
||
</Button>
|
||
) : null}
|
||
|
||
{!isWorkspacePresentation && activeComposerMode !== "detail" ? (
|
||
<Select
|
||
value={modelSelectValue}
|
||
onValueChange={(value) => {
|
||
const nextModel = allModels.find((model) => model.id === value);
|
||
const nextIsVideoModel = Boolean(
|
||
nextModel && VIDEO_PROVIDER_TYPES.has(nextModel.providerType)
|
||
);
|
||
const nextIsConversationModel = Boolean(
|
||
nextModel &&
|
||
nextModel.id !== "auto" &&
|
||
nextModel.isMultimodal &&
|
||
!nextIsVideoModel
|
||
);
|
||
currentModelIdRef.current = value;
|
||
setSelectedModelId(value);
|
||
if (value === "auto" || nextIsConversationModel) {
|
||
setComposerModeOverride("agent");
|
||
} else if (nextIsVideoModel) {
|
||
setComposerModeOverride("video");
|
||
} else {
|
||
setComposerModeOverride("image");
|
||
}
|
||
if (value !== "auto" || !aiDetailGenerationEnabled) {
|
||
setIsDetailGenerationMode(false);
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 w-8 rounded-full border border-[#cfd4dc] bg-white p-0 justify-center [&>svg]:hidden">
|
||
<span className="h-6 w-6 overflow-hidden rounded-full bg-white flex items-center justify-center">
|
||
{selectDisplayModel?.avatarUrl ? (
|
||
<img
|
||
src={buildPreviewImageUrl(selectDisplayModel.avatarUrl, 64)}
|
||
alt={selectDisplayModel.displayName || "模型头像"}
|
||
className="h-full w-full object-cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== selectDisplayModel.avatarUrl) {
|
||
event.currentTarget.src = selectDisplayModel.avatarUrl || "";
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<span className="flex h-full w-full items-center justify-center text-[10px] font-medium text-[#4b5563]">
|
||
{(selectDisplayModel?.displayName || "A").slice(0, 1)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</SelectTrigger>
|
||
{renderModelSelectContent()}
|
||
</Select>
|
||
) : null}
|
||
|
||
<Button
|
||
size={isWorkspacePresentation ? "default" : "icon"}
|
||
className={cn(
|
||
isWorkspacePresentation
|
||
? "h-10 rounded-full px-4 transition-all duration-200 flex-shrink-0"
|
||
: "h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0",
|
||
isDetailGenerationMode
|
||
? canSend
|
||
? "bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white shadow-md hover:shadow-lg hover:opacity-90 dark:from-violet-500 dark:to-fuchsia-500"
|
||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||
: canSend
|
||
? "bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
|
||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||
)}
|
||
onClick={handleSend}
|
||
disabled={isSubmitting || !canSend}
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
{isWorkspacePresentation ? <span className="ml-2">生成中</span> : null}
|
||
</>
|
||
) : isDetailGenerationMode ? (
|
||
<>
|
||
<Sparkles className="h-4 w-4" />
|
||
{isWorkspacePresentation ? <span className="ml-2">开始生成</span> : null}
|
||
</>
|
||
) : (
|
||
<>
|
||
<ArrowUp className="h-4 w-4" />
|
||
{isWorkspacePresentation ? <span className="ml-2">开始生成</span> : null}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 🆕 标签悬停预览弹窗 - 固定显示在标签上方 */}
|
||
{tagPreview && (() => {
|
||
// 计算弹窗位置
|
||
const previewWidth = 240;
|
||
const previewHeight = 280;
|
||
const padding = 16;
|
||
|
||
// 简化:直接使用标签位置,在标签上方居中显示
|
||
// tagPreview.x 是标签中心的x坐标
|
||
// tagPreview.y 是标签顶部的y坐标
|
||
let left = tagPreview.x - previewWidth / 2;
|
||
let top = tagPreview.y - previewHeight - 12;
|
||
|
||
// 确保不超出视口左边界
|
||
if (left < padding) {
|
||
left = padding;
|
||
}
|
||
// 确保不超出视口右边界
|
||
if (typeof window !== 'undefined' && left + previewWidth > window.innerWidth - padding) {
|
||
left = window.innerWidth - previewWidth - padding;
|
||
}
|
||
|
||
// 如果上方空间不够,显示在标签下方
|
||
if (top < padding) {
|
||
top = tagPreview.y + 36; // 标签下方
|
||
}
|
||
|
||
// 确保不超出视口底部
|
||
if (typeof window !== 'undefined' && top + previewHeight > window.innerHeight - padding) {
|
||
top = window.innerHeight - previewHeight - padding;
|
||
}
|
||
|
||
// 最终确保 top 不为负
|
||
if (top < padding) {
|
||
top = padding;
|
||
}
|
||
|
||
// 使用 Portal 渲染到 body,避免 backdrop-blur 影响 fixed 定位
|
||
return createPortal(
|
||
<div
|
||
data-tag-preview="true"
|
||
className="fixed z-[10001] bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-border/50 overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||
style={{
|
||
left: left,
|
||
top: top,
|
||
width: previewWidth,
|
||
}}
|
||
onMouseEnter={() => {
|
||
// 鼠标进入预览弹窗,取消隐藏
|
||
if (previewTimeoutRef.current) {
|
||
clearTimeout(previewTimeoutRef.current);
|
||
previewTimeoutRef.current = null;
|
||
}
|
||
}}
|
||
onMouseLeave={() => {
|
||
// 鼠标离开预览弹窗,隐藏
|
||
setTagPreview(null);
|
||
}}
|
||
>
|
||
{/* 图片全貌 - 使用 inline-block + 居中,让标注点相对于图片本身定位 */}
|
||
<div className="flex justify-center bg-zinc-100 dark:bg-zinc-800">
|
||
<div className="relative inline-block">
|
||
<img
|
||
src={buildPreviewImageUrl(tagPreview.url, 720)}
|
||
alt={tagPreview.name}
|
||
className="block max-w-full max-h-[180px] h-auto"
|
||
loading="lazy"
|
||
decoding="async"
|
||
onError={(event) => {
|
||
if (event.currentTarget.src !== tagPreview.url) {
|
||
event.currentTarget.src = tagPreview.url;
|
||
}
|
||
}}
|
||
/>
|
||
{/* 如果是物品标签,显示标注位置 */}
|
||
{tagPreview.pickedObject && (
|
||
<>
|
||
{/* 标注点 */}
|
||
<div
|
||
className="absolute pointer-events-none"
|
||
style={{
|
||
left: `${tagPreview.pickedObject.clickX * 100}%`,
|
||
top: `${tagPreview.pickedObject.clickY * 100}%`,
|
||
transform: 'translate(-50%, -50%)',
|
||
}}
|
||
>
|
||
{/* 序号标记 */}
|
||
<div className="relative flex items-center justify-center">
|
||
{/* 脉冲动画背景 */}
|
||
<div className="absolute w-6 h-6 rounded-full bg-blue-500/30 animate-ping" />
|
||
{/* 主标记点 */}
|
||
<div className="relative w-5 h-5 rounded-full bg-blue-500 border-2 border-white shadow-lg flex items-center justify-center">
|
||
<svg className="w-2.5 h-2.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||
<path d="M12 2v20M2 12h20" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 物品名称标签 */}
|
||
<div
|
||
className="absolute bg-blue-500 text-white text-xs px-1.5 py-0.5 rounded shadow-lg whitespace-nowrap"
|
||
style={{
|
||
left: `${tagPreview.pickedObject.clickX * 100}%`,
|
||
top: `${tagPreview.pickedObject.clickY * 100}%`,
|
||
transform: 'translate(-50%, -140%)',
|
||
}}
|
||
>
|
||
{tagPreview.pickedObject.objectName}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* 底部信息 */}
|
||
<div className="px-3 py-2 bg-zinc-50 dark:bg-zinc-800/50 border-t border-border/30">
|
||
<div className="text-xs font-medium text-foreground/80 truncate">
|
||
{tagPreview.pickedObject ? tagPreview.pickedObject.objectName : tagPreview.name}
|
||
</div>
|
||
{tagPreview.pickedObject && (
|
||
<div className="text-xs text-muted-foreground">
|
||
{tagPreview.pickedObject.objectPosition}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
})()}
|
||
<AlertDialog
|
||
open={Boolean(insufficientPointsState)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setInsufficientPointsState(null);
|
||
}}
|
||
>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>积分不足</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{insufficientPointsState
|
||
? `当前余额 ${insufficientPointsState.balance},本次预计消耗 ${insufficientPointsState.expectedCost}(${insufficientPointsState.unitCost} x ${insufficientPointsState.imageCount} 张)。请先充值后再发送生图需求。`
|
||
: "当前积分不足,请先充值后再发送生图需求。"}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>稍后再说</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={() => {
|
||
setInsufficientPointsState(null);
|
||
if (typeof window !== "undefined") {
|
||
window.location.href = "/recharge";
|
||
}
|
||
}}
|
||
>
|
||
去充值
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
}
|