ai-chatbot/components/canvas/CanvasChatPanel.tsx
Your Name ba10d4e5be
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
style: refine model badge typography to match competitor
2026-02-21 05:43:27 +08:00

6620 lines
256 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye } 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 { extractImageUrlsFromText } from "@/lib/image-url";
import type { Attachment, ChatMessage } from "@/lib/types";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
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";
// 标签悬停预览信息
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;
};
}
interface CanvasChatPanelProps {
chatId: string;
initialMessages?: ChatMessage[];
onClose?: () => void;
onOpenFileList?: () => void;
}
// 建议卡片数据 - 已移除
const SUGGESTIONS: { title: string; desc: string; image: string }[] = [];
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
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 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 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 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 extractAssistantImageUrls(text: string, referenceUrlKeys?: Set<string>): string[] {
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*([A-Za-z][A-Za-z0-9 .:+\-_/()]{2,})\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*[A-Za-z][A-Za-z0-9 .:+\-_/()]{2,}\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,
"执行计划",
"输出设置",
]);
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*[A-Za-z][A-Za-z0-9 .:+\-_/()]{2,}\s*$/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
): VideoGenerationParams {
const isFalVideo = providerType === "fal-video";
const base: VideoGenerationParams = {
mode: isFalVideo ? "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 (!["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
): { min: number; max: number } {
if (!params) {
return { min: 0, max: 0 };
}
const isFalVideo = providerType === "fal-video";
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 DetailArchive = {
id: string;
title: string;
prompt: string;
brief: DetailBriefForm;
slots: Array<PickedObjectAttachment | null>;
createdAt: number;
updatedAt?: number;
summary?: string;
imageCount?: number;
coverUrl?: string;
};
type DetailPlanResponse = {
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[];
};
model?: {
id: string;
displayName: string;
providerType: string;
};
};
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_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 SESSION_TITLE_MAX_LEN = 18;
function toRawCanvasChatId(value: string): string {
return value.startsWith("canvas-") ? value.replace("canvas-", "") : value;
}
function getSessionStorageKey(canvasId: string): string {
return `canvas-chat-sessions:${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 5;
const value = Number(matched[1]);
if (!Number.isFinite(value)) return 5;
return Math.min(12, 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 }>;
};
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: {
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.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(
`![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})`
);
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(
`![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})`
);
});
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(
`![thumb|||${image.originalUrl}|||2K](${image.thumbnailUrl || image.originalUrl})`
);
});
lines.push("");
});
return lines.join("\n").trim();
}
export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) {
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string>("");
const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false);
const [isDetailGenerating, setIsDetailGenerating] = useState(false);
const [detailSections, setDetailSections] = useState<Record<DetailSectionKey, boolean>>({
required: true,
strategy: false,
assets: true,
archives: true,
});
const [isDetailWorkbenchCollapsed, setIsDetailWorkbenchCollapsed] = useState(false);
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 [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 [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = 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 pendingLaunchRef = useRef<CanvasLaunchPayload | null>(null);
const sessionMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | 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 [lastImageModelId, setLastImageModelId] = useState<string | null>(null);
const [lastVideoModelId, setLastVideoModelId] = 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 imageParamsRef = useRef(imageParams);
const videoParamsRef = useRef(videoParams);
const deepThinkingEnabledRef = useRef(deepThinkingEnabled);
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 }
);
}, []);
// 🆕 保存图片参数到 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({});
}, [activeChatId]);
useEffect(() => {
const now = Date.now();
const fallbackSession: CanvasChatSession = {
id: chatId,
title: "对话 1",
createdAt: now,
updatedAt: now,
};
try {
const key = getSessionStorageKey(canvasId);
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 === chatId);
const nextSessions = hasCurrent
? sanitized
: [fallbackSession, ...sanitized];
const sorted = [...nextSessions].sort((a, b) => b.updatedAt - a.updatedAt);
setChatSessions(sorted);
setActiveChatId(chatId);
localStorage.setItem(key, JSON.stringify(sorted));
} catch (error) {
console.error("[CanvasChat] Failed to load chat sessions:", error);
setChatSessions([fallbackSession]);
setActiveChatId(chatId);
}
}, [canvasId, chatId]);
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),
JSON.stringify(chatSessions)
);
} catch (error) {
console.error("[CanvasChat] Failed to save chat sessions:", error);
}
}, [canvasId, chatSessions]);
// Canvas store
const addNode = useCanvasStore((state) => state.addNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const nodes = useCanvasStore((state) => state.nodes);
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);
// 从 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",
};
},
[]
);
// 🆕 辅助函数:在 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(() => {
pendingLaunchRef.current = null;
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"),
}))
: [];
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;
});
}
setTimeout(() => {
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,
};
}
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;
capabilities?: string[] | null;
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
} | null;
}>;
agentModel?: {
id: string;
displayName: string;
avatarUrl?: string | null;
providerType: string;
imageGenConfig?: {
maxImages?: number;
supportsSize?: 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;
canvasEnabled?: boolean;
aiDetailGenerationEnabled?: boolean;
canvasDefaultModelId?: string | null;
canvasAutoModeDefaultModelId?: string | null;
}>("/api/site-settings", fetcher);
const { data: membershipSummary } = useSWR<{
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 aiDetailGenerationEnabled = siteSettings?.aiDetailGenerationEnabled === true;
const VIDEO_PROVIDER_TYPES = new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video"]);
const visualModels = useMemo(() => {
const filtered =
modelsData?.models?.filter(
(model) =>
model.modelId !== "volcano-video-master" &&
(model.supportsImageGen || 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,
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
};
// 所有可选模型(Agent模式 + 可视化模型)
const allModels = [SMART_AUTO_MODEL, ...visualModels];
const imageOnlyModels = useMemo(
() =>
visualModels.filter(
(model) => model.supportsImageGen && !VIDEO_PROVIDER_TYPES.has(model.providerType)
),
[visualModels]
);
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 selectableModels = useMemo(() => {
if (isDetailGenerationMode) {
return [SMART_AUTO_MODEL];
}
if (isVideoModel) {
return videoOnlyModels;
}
return [SMART_AUTO_MODEL, ...imageOnlyModels];
}, [SMART_AUTO_MODEL, imageOnlyModels, 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 =
modelsData?.agentModel?.displayName || selectDisplayModel?.displayName || "Agent模式";
const agentAvatarUrl = modelsData?.agentModel?.avatarUrl || selectDisplayModel?.avatarUrl || null;
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]);
// 监听 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 (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,
isVideoModel,
videoFrameSlots.length,
attachments,
insertImageTagInEditor,
clearPendingChatAttachments,
]);
const firstImageModelId = useMemo(
() =>
allModels.find(
(model) => model.id !== "auto" && !VIDEO_PROVIDER_TYPES.has(model.providerType)
)?.id || null,
[allModels]
);
const firstVideoModelId = useMemo(
() => allModels.find((model) => VIDEO_PROVIDER_TYPES.has(model.providerType))?.id || null,
[allModels]
);
const activeComposerMode = useMemo<"agent" | "image" | "video" | "detail">(() => {
if (isDetailGenerationMode) return "detail";
if (selectedModelId === "auto") return "agent";
if (isVideoModel) return "video";
return "image";
}, [isDetailGenerationMode, isVideoModel, selectedModelId]);
const activeModeLabel =
activeComposerMode === "video"
? "视频"
: activeComposerMode === "detail"
? "AI详情"
: activeComposerMode === "image"
? "生图"
: "Agent";
// 默认选择模型(根据系统设置)
useEffect(() => {
if (!selectedModelId && siteSettings) {
// 使用系统设置的默认模型,如果没有设置则使用Agent模式
const defaultModel = siteSettings.canvasDefaultModelId || "auto";
setSelectedModelId(defaultModel);
}
}, [selectedModelId, siteSettings]);
useEffect(() => {
if (!aiDetailGenerationEnabled && isDetailGenerationMode) {
setIsDetailGenerationMode(false);
}
}, [aiDetailGenerationEnabled, isDetailGenerationMode]);
useEffect(() => {
if (!modelSelectValue || modelSelectValue === selectedModelId) return;
setSelectedModelId(modelSelectValue);
}, [modelSelectValue, selectedModelId]);
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);
if (!videoParams || JSON.stringify(videoParams) !== JSON.stringify(normalized)) {
setVideoParams(normalized);
}
}, [isVideoModel, currentModel?.providerType, 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 (!currentModel) return;
if (currentModel.id !== "auto" && isDetailGenerationMode) {
setIsDetailGenerationMode(false);
}
if (isVideoModel) {
setLastVideoModelId(currentModel.id);
return;
}
if (currentModel.id !== "auto") {
setLastImageModelId(currentModel.id);
}
}, [currentModel, isDetailGenerationMode, isVideoModel]);
const handleSelectGenerationMode = useCallback(
(mode: "agent" | "image" | "video" | "detail") => {
if (mode === "agent") {
setSelectedModelId("auto");
setIsDetailGenerationMode(false);
return;
}
if (mode === "detail") {
if (!aiDetailGenerationEnabled) {
toast.error("AI详情生成功能未开启");
return;
}
setSelectedModelId("auto");
setIsDetailGenerationMode(true);
return;
}
if (mode === "image") {
const preferredImageModelId =
allModels.find(
(model) =>
model.id === lastImageModelId &&
model.id !== "auto" &&
!VIDEO_PROVIDER_TYPES.has(model.providerType)
)?.id || firstImageModelId;
if (!preferredImageModelId) {
toast.error("当前没有可用的图像模型");
return;
}
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;
}
setSelectedModelId(preferredVideoModelId);
setIsDetailGenerationMode(false);
},
[
aiDetailGenerationEnabled,
allModels,
firstImageModelId,
firstVideoModelId,
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 || ""),
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 {
messages,
setMessages,
sendMessage,
status,
stop,
} = 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,
lastMessage: request.messages.at(-1),
});
const hiddenPrompt = hiddenPromptRef.current || undefined;
const autoContext = autoContextRef.current || undefined;
hiddenPromptRef.current = null;
autoContextRef.current = null;
return {
body: {
id: rawId,
message: request.messages.at(-1),
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: "private",
deepThinkingEnabled:
isVideoModelRef.current || currentModelIdRef.current === "auto"
? undefined
: deepThinkingEnabledRef.current,
videoParams: isVideoModelRef.current ? videoParamsRef.current || undefined : undefined,
imageParams: isVideoModelRef.current ? undefined : imageParamsRef.current || undefined,
hiddenPrompt,
autoContext,
...request.body,
},
};
},
}),
onData: (dataPart: any) => {
// 处理后端返回的自定义消息
if (dataPart.type === "data-appendMessage") {
try {
const messageData = typeof dataPart.data === 'string'
? JSON.parse(dataPart.data)
: dataPart.data;
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
const normalizedAttachments = Array.isArray(messageData.attachments)
? messageData.attachments
: [];
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);
}
}
},
onError: (error) => {
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(() => {
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,
}));
if (textContent) {
parts.push({ type: "text" as const, text: textContent });
}
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]);
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
const loadedHistorySignatureRef = useRef<string>("");
useEffect(() => {
if (historyScrollTimerRef.current) {
clearTimeout(historyScrollTimerRef.current);
historyScrollTimerRef.current = null;
}
if (!historyData || !rawChatId) return;
if (status === "streaming" || status === "submitted") 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(
historyMessages
.filter((message) => message.role === "assistant")
.map((message) => message.id)
);
hasHydratedHistoryRef.current = true;
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(() => {
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;
// 只处理新消息
messages.forEach(async (message, messageIndex) => {
if (message.role === 'assistant' && !processedMessageIds.current.has(message.id)) {
const content = message.parts?.map((p: any) => p.text || '').join('') || '';
const imageUrls = extractImageUrls(content);
const videoUrls = extractVideoUrls(content);
const generationPrompt = findNearestUserPrompt(messages, messageIndex);
if (imageUrls.length > 0 || videoUrls.length > 0) {
processedMessageIds.current.add(message.id);
let addedImageCount = 0;
let addedVideoCount = 0;
if (imageUrls.length > 0) {
// 检查画布中是否已存在相同 URL 的图片
const existingUrls = new Set(
nodes.filter(n => n.type === 'image' && (n.data as any)?.src)
.map(n => (n.data as any).src)
);
// 只添加不存在的图片
const newUrls = imageUrls.filter(url => !existingUrls.has(url));
// 异步获取每张图片的尺寸并添加到画布
for (let index = 0; index < newUrls.length; index++) {
const url = newUrls[index];
// 获取图片原始尺寸
const dimensions = await getImageDimensions(url);
// 计算位置(避免重叠,根据图片宽度动态调整间距)
const existingNodes = nodes.length + index;
const spacing = 20; // 图片间距
const x = 100 + (existingNodes % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing);
const nodeId = addNode({
type: 'image',
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: 'completed',
title: '图片',
prompt: generationPrompt || '对话生成',
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
},
},
connections: [],
});
void autoNameGeneratedImageNode(nodeId, url);
addedImageCount += 1;
}
}
if (videoUrls.length > 0) {
const existingVideoUrls = new Set(
nodes.filter(n => n.type === 'video' && (n.data as any)?.src)
.map(n => (n.data as any).src)
);
const newVideoUrls = videoUrls.filter(url => !existingVideoUrls.has(url));
for (let index = 0; index < newVideoUrls.length; index++) {
const url = newVideoUrls[index];
const existingNodes = nodes.length + index;
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} 添加到画布`);
}
}
}
});
}, [messages, addNode, 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);
const imageAutoTitleCacheRef = useRef<Map<string, string>>(new Map());
// 🆕 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 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 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 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 fetch("/api/files/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
const imageUrl = data.url as string;
updateAttachmentAtVideoSlot(slotIndex, {
url: imageUrl,
name: file.name,
contentType: file.type || "image/jpeg",
});
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 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 fetch("/api/files/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
const imageUrl = String(data.url || "").trim();
if (!imageUrl) {
throw new Error("未获取到图片地址");
}
updateDetailSlotAttachment(slotIndex, {
url: imageUrl,
name: file.name,
contentType: file.type || "image/jpeg",
});
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;
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,
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);
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 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,
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详情生成",
"我们将基于《详情升级文案方案》执行以下技能流程:",
"1. 定位卡:锁定人群/场景/触发时刻/风险与期望结果",
"2. 需求拆解:卖点转买点,确定核心需求优先级",
"3. 黄金五屏:核心卖点 -> 差异强化 -> 证据收口",
"4. FABE文案:每屏输出利益/特征/优势/证据",
"5. A/B/C视觉节奏:场景共鸣 + 理性证明 + 转化收尾",
"6. 一致性约束:固定790x1300 + 统一字体层级 + 统一排版风格",
"",
"正在规划分镜提示词...",
].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} 像素`,
]
.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 }> = [];
setIsDetailWorkbenchCollapsed(true);
setIsDetailGenerating(true);
setDetailProgress({
status: "running",
stage: "Agent 正在解析需求并规划详情结构...",
current: 0,
total: Math.max(1, detailCount + 1 + DETAIL_REVIEW_COUNT),
});
try {
workingArchive = await saveDetailArchiveSnapshot({
summary: `进行中:已完成 0/${detailCount} 张详情图`,
imageCount: 0,
coverUrl: undefined,
prompt: archiveSnapshot.prompt,
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,
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 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 + 1 + reviewPlans.length;
const qualityIssues = Array.isArray(planData.qualityChecks?.issues)
? planData.qualityChecks?.issues.filter(Boolean)
: [];
setDetailProgress({
status: "running",
stage:
planData.qualityChecks?.passed === false
? `结构质检提示:${qualityIssues[0] || "已自动切换为稳定方案"}`
: "结构质检通过,开始逐屏生成",
current: 1,
total: totalSteps,
});
const modelForGeneration =
selectedModelId !== "auto"
? selectedModelId
: siteSettings?.canvasAutoModeDefaultModelId ||
siteSettings?.canvasDefaultModelId ||
undefined;
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]!;
setDetailProgress({
status: "running",
stage: `正在生成第 ${index + 1}/${prompts.length} 屏详情图`,
current: index + 2,
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}` : "",
]
.filter(Boolean)
.join(";");
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
parts: [
{
type: "text",
text: `### AI详情生成\n正在生成第 ${index + 1}/${prompts.length} 张...`,
},
],
}
: message,
),
);
const generationPrompt = [
promptText,
styleDirective ? `全局样式约束:${styleDirective}` : "",
screen?.screenType
? `分镜类型:${screen.screenType}(A=技术公信,B=场景共鸣,C=细节佐证)`
: "",
useModelForScene
? `人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。`
: "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。",
screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "",
bundleShowcase
? "搭配展示规则:本屏必须同场景展示主产品与搭配产品,主次层级清晰,突出组合购买价值与加购理由。"
: "",
bundleReason ? `搭配依据:${bundleReason}` : "",
screen?.title ? `本屏标题:${screen.title}` : "",
screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "",
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} 像素。`,
"要求:整套详情图风格统一,产品外观严格一致,主标题/副标题/正文字体与层级全套一致,商业摄影质感,排版区域清晰。",
]
.filter(Boolean)
.join("\n\n");
const styleAnchorUrl =
index > 0 ? generatedImages[0]?.originalUrl || generatedImages[0]?.thumbnailUrl : null;
const generationReferenceImages = [
...productAttachments.map((item) => item.url),
...(bundleShowcase ? bundleAttachments.map((item) => item.url) : []),
...(useModelForScene ? [modelAttachment.url] : []),
...(styleAnchorUrl ? [styleAnchorUrl] : []),
].filter(Boolean);
const generationResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: generationPrompt,
model: modelForGeneration,
aspectRatio: "9:16",
imageCount: 1,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: generationReferenceImages,
}),
});
const generationResult = await generationResponse.json();
if (!generationResponse.ok || !generationResult.success || !generationResult.data?.images?.length) {
throw new Error(generationResult.error || `第 ${index + 1} 张生成失败`);
}
const generated = generationResult.data.images[0]!;
generatedImages.push(generated);
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(
generated.originalUrl || generated.thumbnailUrl
);
const spacing = 24;
const itemWidth = DETAIL_TARGET_WIDTH;
const itemHeight = DETAIL_TARGET_HEIGHT;
const column = index % 3;
const row = Math.floor(index / 3);
const x = 120 + column * (itemWidth + spacing);
const y = 120 + row * (itemHeight + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: itemWidth, height: itemHeight },
data: {
src: generated.originalUrl || generated.thumbnailUrl,
status: "completed",
prompt: promptText,
title: `详情图-${index + 1}`,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt,
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,
},
},
connections: [],
});
}
const reviewGroups: DetailReviewGroup[] = [];
const reviewFailures: string[] = [];
const detailRows = Math.ceil(prompts.length / 3);
const detailStartX = 120;
const detailStartY = 120;
const detailSpacing = 24;
const reviewStartY =
detailStartY + detailRows * (DETAIL_TARGET_HEIGHT + detailSpacing) + 120;
for (let reviewIndex = 0; reviewIndex < reviewPlans.length; reviewIndex += 1) {
const reviewPlan = reviewPlans[reviewIndex]!;
const reviewStepCurrent = prompts.length + 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) : []),
modelAttachment.url,
].filter(Boolean);
const reviewResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
}),
});
const reviewResult = await reviewResponse.json();
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images)
? reviewResult.data.images
: [];
if (!reviewResponse.ok || !reviewResult?.success || 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({
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} 张,评价晒图部分失败 ${reviewFailures.length} 条`
: `已完成 ${generatedImages.length} 张详情图 + ${reviewGroups.length} 条评价晒图`,
current: totalSteps,
total: totalSteps,
});
const finalSummary =
planData.summary ||
(reviewFailures.length > 0
? `详情图完成 ${generatedImages.length} 张,评价晒图部分失败 ${reviewFailures.length} 条`
: `已完成 ${generatedImages.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,
brief: archiveSnapshot.brief,
slots: archiveSnapshot.slots,
});
}
if (reviewFailures.length > 0) {
toast.warning(
`详情图完成 ${generatedImages.length} 张,买家评价晒图成功 ${reviewGroups.length} 条,失败 ${reviewFailures.length} 条`
);
} else {
toast.success(
`AI详情生成完成:详情图 ${generatedImages.length} 张,评价晒图 ${reviewGroups.length} 条`
);
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : "AI详情生成失败";
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,
detailSlots,
imageParams.negativePrompt,
input,
isDetailGenerating,
normalizedDetailBrief,
patchDetailArchiveInDatabase,
selectedModelId,
saveDetailArchiveSnapshot,
setMessages,
siteSettings?.canvasAutoModeDefaultModelId,
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 modelAttachment =
DETAIL_MODEL_SLOT_INDEX >= 0 ? detailSlots[DETAIL_MODEL_SLOT_INDEX] : null;
if (!modelAttachment?.url) {
toast.error("请先补齐模特图,再追加评价晒图");
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?.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) : []),
modelAttachment.url,
].filter(Boolean);
const reviewResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: reviewPrompt,
model: modelForGeneration,
aspectRatio: "1:1",
imageCount: DETAIL_REVIEW_IMAGE_COUNT,
negativePrompt: imageParams.negativePrompt,
referenceImage: productAttachments[0]?.url,
referenceImages: reviewReferenceImages,
}),
});
const reviewResult = await reviewResponse.json();
const reviewImagesRaw = Array.isArray(reviewResult?.data?.images)
? reviewResult.data.images
: [];
if (!reviewResponse.ok || !reviewResult?.success || 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?.canvasDefaultModelId,
]);
const ensureImagePointsBeforeSend = useCallback(() => {
if (isVideoModel) 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;
}, [
currentModel?.imageGenConfig?.creditCostPerImage,
imageParams?.imageCount,
isVideoModel,
membershipSummary?.pointsSystemConfig?.blockOnInsufficient,
membershipSummary?.pointsSystemConfig?.enabled,
membershipSummary?.summary?.currentCredits,
]);
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async () => {
if (isDetailGenerationMode && aiDetailGenerationEnabled) {
await handleDetailGeneration();
return;
}
if (!input.trim() && attachments.length === 0) return;
if (!selectedModelId) {
toast.error("请选择一个模型");
return;
}
if (!ensureImagePointsBeforeSend()) {
return;
}
// 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致)
const parts: any[] = [];
let effectiveAttachments = [...attachments];
const videoImageLimit = isVideoModel
? getVideoImageLimit(videoParams, currentModel?.providerType)
: 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,
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 = "请处理这张图片";
}
// 🆕 隐藏提示词(不在 UI 中展示)
const hiddenSegments: string[] = [];
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 });
}
// 使用与 multimodal-input 相同的格式发送消息
sendMessage({
role: "user",
parts: parts,
});
hasHydratedHistoryRef.current = true;
// 清空状态
setInput("");
setAttachments([]);
// 🆕 清空画布选中状态(防止 useEffect 重新添加标签)
clearSelection();
// 🆕 清空 contenteditable div 的内容
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (editableDiv) {
editableDiv.innerHTML = '';
console.log('[CanvasChat] 已清空输入框和选中状态');
}
}, [
input,
attachments,
selectedModelId,
sendMessage,
clearSelection,
buildAutoContext,
isVideoModel,
videoParams,
videoFrameSlots.length,
currentModel?.providerType,
selectedImageNodes,
isDetailGenerationMode,
aiDetailGenerationEnabled,
handleDetailGeneration,
ensureImagePointsBeforeSend,
]);
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 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,
},
},
});
};
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("图片");
}
},
[],
);
const uploadImageFileAndAddToCanvas = useCallback(async (
file: File,
nodeIndex: number
): Promise<{ imageUrl: string; name: string; contentType: 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 fetch('/api/files/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传失败');
}
const data = await response.json();
const imageUrl = data.url as string;
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,
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',
};
} 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 (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);
}, [
attachments,
insertImageTagInEditor,
isVideoModel,
maybeAutoSetCanvasTitle,
nodes.length,
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;
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 fetch('/api/files/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传失败');
}
const data = await response.json();
const imageUrl = data.url;
// 记录第一张图片的 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,
}];
});
// 获取图片原始尺寸后添加到画布
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,
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);
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;
const currentVideoImageLimit = isVideoModel
? getVideoImageLimit(videoParams, currentModel?.providerType)
: 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 && !!currentModel;
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter;
const loadingImageCount = imageParams.imageCount || 1;
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 renderModelSelectContent = () => (
<SelectContent className="w-[238px] max-h-[340px] overflow-hidden rounded-[18px] border border-[#cfd4dc] bg-[#f3f4f6] 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={model.avatarUrl} alt={model.displayName} className="h-full w-full object-cover" />
) : (
<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,
};
setChatSessions((prevSessions) => [newSession, ...prevSessions]);
setActiveChatId(newChatSessionId);
toast.success("已新建聊天窗口");
}, [chatSessions.length]);
const switchToSession = useCallback((sessionId: string) => {
if (!sessionId || sessionId === activeChatId) {
setIsSessionMenuOpen(false);
return;
}
setActiveChatId(sessionId);
setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口");
}, [activeChatId]);
return (
<div className="relative flex h-full min-h-0 flex-col bg-background/50 backdrop-blur-sm">
{/* Header - Minimal with Icons */}
<div className="z-20 ml-auto p-4 flex flex-col items-end gap-2">
<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-background/95 backdrop-blur-md 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="收起聊天面板"
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 消息列表 */}
{isDetailEmptyState ? (
<div className="h-3 shrink-0" />
) : (
<div
key={`${activeChatId}-${messageViewportKey}`}
className={cn(
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden 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 && (
<div className={cn("flex flex-col h-full", isDetailGenerationMode ? "pt-0" : "pt-4")}>
{/* Greeting */}
<div className={cn(isDetailGenerationMode ? "mb-4" : "mb-8")}>
<h1 className="text-2xl font-bold mb-2">Hi, 我是你的AI设计师</h1>
<p className="text-muted-foreground">让我们开始今天的创作吧!</p>
</div>
{/* Suggestions - Removed */}
{/* Refresh - Removed */}
{/* Banner - 从系统设置中获取 */}
{(siteSettings?.bannerEnabled ?? true) && (siteSettings?.bannerText || "升级会员, Nano Banana Pro 免费365天!") && (
<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>
)}
{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 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(assistantModelName);
const mediaModelName =
assistantModelName ||
(message.role === "assistant" && selectedModelId !== "auto"
? 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;
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={fileUrl}
alt=""
className="w-5 h-5 object-cover rounded"
/>
<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={att.url}
alt=""
className="w-5 h-5 object-cover rounded"
/>
<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={url}
alt=""
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
style={{ maxHeight: "260px" }}
onClick={() => setFullscreenPreview({ url })}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setFullscreenPreview({ url });
}}
/>
</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>{assistantModelName}</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={currentModel?.providerType}
modelName={currentModel?.displayName}
/>
)}
{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>
<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
)}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
<div className="p-4 bg-background/80 backdrop-blur-md border-t">
<div
className="relative bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background"
data-canvas-chat-input-anchor="true"
>
{isDetailGenerationMode && (
<div
className={cn(
"mx-3 overflow-y-auto rounded-2xl border border-black/10 bg-white/95 p-3 pr-2",
"mt-3",
renderableVisibleMessages.length === 0 ? "max-h-[62vh]" : "max-h-[54vh]"
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-black">
AI详情生成工作台(PC)
</p>
<p className="mt-0.5 text-[11px] text-black/55">
固定规格 {DETAIL_TARGET_WIDTH} x {DETAIL_TARGET_HEIGHT},先分析后生成,保持整套详情图风格统一。
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-black/15 px-2 py-1 text-[11px] text-black/70 hover:bg-black/[0.03]"
onClick={() => setIsDetailWorkbenchCollapsed((prev) => !prev)}
>
{isDetailWorkbenchCollapsed ? (
<>
<ChevronDown className="h-3.5 w-3.5" />
展开
</>
) : (
<>
<ChevronUp className="h-3.5 w-3.5" />
收起
</>
)}
</button>
<span
className={cn(
"whitespace-nowrap rounded-full px-2 py-0.5 text-[10px] font-medium leading-none",
detailProgress.status === "running"
? "bg-blue-50 text-blue-700"
: detailProgress.status === "done"
? "bg-emerald-100 text-emerald-700"
: detailProgress.status === "error"
? "bg-red-100 text-red-700"
: "bg-black/5 text-black/65"
)}
>
{detailProgress.status === "running"
? "执行中"
: detailProgress.status === "done"
? "已完成"
: detailProgress.status === "error"
? "异常"
: "待启动"}
</span>
</div>
</div>
{isDetailWorkbenchCollapsed ? (
<button
type="button"
className="mt-3 w-full rounded-xl border border-black/10 bg-black/[0.02] px-3 py-2 text-left text-[11px] text-black/60 hover:bg-black/[0.04]"
onClick={() => setIsDetailWorkbenchCollapsed(false)}
>
已收起详情输入区,点击展开继续编辑信息和素材。
</button>
) : (
<>
<div className="mt-3 rounded-xl border border-black/10 bg-black/[0.02] p-2">
<div className="mb-1 flex items-center justify-between text-[11px] text-black/55">
<span>{detailProgress.stage || "待开始:请先补齐输入信息与素材。"}</span>
<span>
{detailProgress.current}/{detailProgress.total || 0}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-black/10">
<div
className={cn(
"h-full rounded-full transition-all duration-300",
detailProgress.status === "error"
? "bg-red-500"
: detailProgress.status === "done"
? "bg-emerald-500"
: "bg-blue-500"
)}
style={{ width: `${detailProgressPercent}%` }}
/>
</div>
</div>
<div className="mt-3 space-y-2">
<p className="px-1 text-[11px] text-black/45">提示:鼠标悬停输入框可查看填写示例。</p>
<div className="rounded-xl border border-black/10 bg-white">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left"
onClick={() => toggleDetailSection("required")}
>
<div>
<p className="text-xs font-semibold text-black">必填信息</p>
<p className="text-[11px] text-black/55">人群 / 场景 / 核心需求为必填</p>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-black/55 transition-transform",
detailSections.required && "rotate-180"
)}
/>
</button>
{detailSections.required ? (
<div className="grid grid-cols-2 gap-2 border-t border-black/10 p-3 xl:grid-cols-4">
<input
value={detailBrief.productSpu}
onChange={(event) => updateDetailBriefField("productSpu", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.productSpu}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="商品SPU(建议填写)"
/>
<input
value={detailBrief.demandType}
onChange={(event) => updateDetailBriefField("demandType", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.demandType}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="需求类型(建议填写)"
/>
<input
value={detailBrief.targetAudience}
onChange={(event) => updateDetailBriefField("targetAudience", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.targetAudience}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="主购买人群 *"
/>
<input
value={detailBrief.usageScenario}
onChange={(event) => updateDetailBriefField("usageScenario", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.usageScenario}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="需求场景 *"
/>
<input
value={detailBrief.coreNeed}
onChange={(event) => updateDetailBriefField("coreNeed", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.coreNeed}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="核心需求 *"
/>
<input
value={detailBrief.productName}
onChange={(event) => updateDetailBriefField("productName", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.productName}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="产品名称(选填)"
/>
<input
value={detailBrief.mainUser}
onChange={(event) => updateDetailBriefField("mainUser", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.mainUser}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="主使用者(选填)"
/>
<input
value={detailBrief.triggerMoment}
onChange={(event) => updateDetailBriefField("triggerMoment", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.triggerMoment}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="触发时刻(选填)"
/>
<input
value={detailBrief.riskConcern}
onChange={(event) => updateDetailBriefField("riskConcern", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.riskConcern}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="用户顾虑(选填)"
/>
<input
value={detailBrief.expectedResult}
onChange={(event) => updateDetailBriefField("expectedResult", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.expectedResult}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="期望结果(选填)"
/>
<input
value={detailBrief.coreSellingPoint}
onChange={(event) => updateDetailBriefField("coreSellingPoint", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.coreSellingPoint}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400 xl:col-span-2"
placeholder="核心卖点关键词(选填)"
/>
<input
value={detailBrief.positioningStatement}
onChange={(event) => updateDetailBriefField("positioningStatement", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.positioningStatement}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400 xl:col-span-2"
placeholder="一句话定位(选填)"
/>
</div>
) : null}
</div>
<div className="rounded-xl border border-black/10 bg-white">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left"
onClick={() => toggleDetailSection("strategy")}
>
<div>
<p className="text-xs font-semibold text-black">策略信息</p>
<p className="text-[11px] text-black/55">品牌语气、竞品参考、禁用词</p>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-black/55 transition-transform",
detailSections.strategy && "rotate-180"
)}
/>
</button>
{detailSections.strategy ? (
<div className="grid grid-cols-2 gap-2 border-t border-black/10 p-3 xl:grid-cols-4">
<input
value={detailBrief.priceBand}
onChange={(event) => updateDetailBriefField("priceBand", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.priceBand}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="价格带(选填)"
/>
<input
value={detailBrief.brandTone}
onChange={(event) => updateDetailBriefField("brandTone", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.brandTone}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400"
placeholder="品牌语气(选填)"
/>
<input
value={detailBrief.competitorReference}
onChange={(event) => updateDetailBriefField("competitorReference", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.competitorReference}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400 xl:col-span-2"
placeholder="竞品对标(选填)"
/>
<input
value={detailBrief.bannedWords}
onChange={(event) => updateDetailBriefField("bannedWords", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.bannedWords}
className="h-8 rounded-lg border border-black/15 bg-white px-2 text-xs outline-none focus:border-blue-400 xl:col-span-2"
placeholder="禁用表达(选填,逗号分隔)"
/>
<textarea
value={detailBrief.userVoice}
onChange={(event) => updateDetailBriefField("userVoice", event.target.value)}
title={DETAIL_FIELD_EXAMPLES.userVoice}
className="min-h-[64px] resize-y rounded-lg border border-black/15 bg-white px-2 py-1.5 text-xs outline-none focus:border-blue-400 xl:col-span-4"
placeholder="用户原话样本(选填,建议3-5条)"
/>
</div>
) : null}
</div>
<div className="rounded-xl border border-black/10 bg-white">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left"
onClick={() => toggleDetailSection("assets")}
>
<div>
<p className="text-xs font-semibold text-black">素材上传</p>
<p className="text-[11px] text-black/55">固定 5 张产品图 + 1 张模特图 + 可选 3 张搭配图</p>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-black/55 transition-transform",
detailSections.assets && "rotate-180"
)}
/>
</button>
{detailSections.assets ? (
<div className="border-t border-black/10 p-3">
<div className="mb-2 text-[11px] text-black/55">
Agent 会自动判断是否使用模特图;若上传搭配图,将自动新增一屏“场景化产品搭配展示”用于关联销售。
</div>
<div className="grid grid-cols-2 gap-2 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 h-28 overflow-hidden rounded-xl border bg-white",
isPickingFromCanvas
? "border-blue-500 ring-2 ring-blue-200"
: "border-black/15"
)}
>
<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={slotAttachment.url}
alt={config.label}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
/>
</button>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-1 py-0.5 text-center text-[10px] text-white">
{config.label}
</div>
<button
type="button"
className={cn(
"absolute left-16 top-1 rounded px-1.5 py-0.5 text-[10px] text-white transition-opacity",
isPickingFromCanvas
? "bg-blue-600 opacity-100"
: "bg-sky-700/80 opacity-0 group-hover:opacity-100"
)}
onClick={() => activateDetailSlotCanvasPicker(index)}
>
画布
</button>
<button
type="button"
className="absolute left-1 top-1 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => detailSlotInputRefs.current[index]?.click()}
>
更换
</button>
<button
type="button"
className="absolute right-1 top-1 rounded bg-black/65 px-1.5 py-0.5 text-[10px] text-white"
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",
isPickingFromCanvas ? "bg-blue-600" : "bg-sky-700/85"
)}
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-1 text-[11px] text-black/65"
onClick={() => detailSlotInputRefs.current[index]?.click()}
disabled={isSubmitting || isUploading}
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" />
)}
<span>{config.label}</span>
</button>
</>
)}
</div>
);
})}
</div>
<div className="mt-2 text-[11px] text-black/55">
当前素材:产品图 {detailProductCount}/5,模特图 {detailModelReady ? "1/1" : "0/1"},搭配图 {detailBundleCount}/3(可选)
</div>
{detailSlotPickerIndex !== null ? (
<div className="mt-1 text-[11px] text-blue-700">
当前等待画布选图:{DETAIL_SLOT_CONFIGS[detailSlotPickerIndex]?.label || "素材槽位"}。请在画布中选中图片,系统会自动填入。
</div>
) : null}
</div>
) : null}
</div>
<div className="rounded-xl border border-black/10 bg-white">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left"
onClick={() => toggleDetailSection("archives")}
>
<div>
<p className="text-xs font-semibold text-black">详情项目存档</p>
<p className="text-[11px] text-black/55">历史输入与素材可一键回填,继续修改并重新生成</p>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-black/55 transition-transform",
detailSections.archives && "rotate-180"
)}
/>
</button>
{detailSections.archives ? (
<div className="border-t border-black/10 p-3">
{detailArchives.length === 0 ? (
<div className="rounded-lg border border-dashed border-black/15 px-3 py-2 text-[11px] text-black/45">
暂无存档。每次执行 AI详情生成成功后会自动保存为新存档。
</div>
) : (
<div className="max-h-40 space-y-2 overflow-y-auto pr-1">
{detailArchives.map((archive) => (
<div
key={archive.id}
className="flex items-center justify-between gap-3 rounded-lg border border-black/10 bg-white px-2.5 py-2"
>
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-black">
{archive.title}
</div>
<div className="mt-0.5 text-[10px] text-black/45">
{new Date(archive.createdAt).toLocaleString()} · {archive.imageCount || 0}张输出
</div>
</div>
<button
type="button"
className="shrink-0 rounded-md border border-blue-200 bg-blue-50 px-2 py-1 text-[11px] text-blue-700 hover:bg-blue-100"
onClick={() => loadDetailArchive(archive)}
>
加载
</button>
</div>
))}
</div>
)}
</div>
) : null}
</div>
</div>
<div
className={cn(
"mt-3 rounded-xl border px-3 py-2 text-[11px]",
detailValidationIssues.length === 0
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200"
: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
)}
>
{detailValidationIssues.length === 0 ? (
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" />
<span>校验通过,可直接发送执行 AI详情生成。</span>
</div>
) : (
<div className="flex items-start gap-1.5">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>当前不可发送:{detailValidationIssues.join("、")}</span>
</div>
)}
</div>
{hasGeneratedDetailImages ? (
<div className="mt-2 flex items-center justify-between gap-2 rounded-xl border border-violet-200 bg-violet-50/70 px-3 py-2">
<div className="min-w-0">
<p className="text-[11px] font-medium text-violet-700">
评价晒图可继续追加
</p>
<p className="text-[10px] text-violet-600/80">
点击后仅新增2条评价,每条3张晒图,不会重跑详情主图。
</p>
</div>
<button
type="button"
className={cn(
"shrink-0 rounded-md border px-2.5 py-1 text-[11px] font-medium",
isSubmitting
? "cursor-not-allowed border-violet-200 bg-violet-100 text-violet-500/70"
: "border-violet-300 bg-white text-violet-700 hover:bg-violet-100"
)}
onClick={() => {
void handleGenerateMoreDetailReviews();
}}
disabled={isSubmitting}
>
再生成2条
</button>
</div>
) : null}
</>
)}
</div>
)}
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div
className={cn(
"relative px-4 pt-3 pb-12",
isDetailGenerationMode ? "min-h-[72px]" : "min-h-[60px]"
)}
>
<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-muted-foreground/50"
data-placeholder={
isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: "请输入你的设计需求,例如:绿色椅子 替换 Image"
}
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) {
syncAttachmentsWithEditor(target);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0)) {
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>
{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={slotAttachment.url}
alt={slotAttachment.name || label}
className="h-full w-full object-cover"
/>
<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);
}}
/>
))}
<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] 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>
{!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}
/>
</div>
</div>
) : (
<div className="shrink-0">
<ImageParamsBar
modelName={currentModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline={true}
providerType={currentModel?.providerType}
imageGenConfig={currentModel?.imageGenConfig || undefined}
/>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{activeComposerMode === "agent" ? (
<div
className="h-8 w-8 overflow-hidden rounded-full border border-[#cfd4dc] bg-white flex items-center justify-center"
title={agentDisplayName}
aria-label={agentDisplayName}
>
{agentAvatarUrl ? (
<img
src={agentAvatarUrl}
alt={agentDisplayName}
className="h-full w-full object-cover"
/>
) : (
<Sparkles className="h-4 w-4 text-[#8b95a7]" />
)}
</div>
) : null}
{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}
{activeComposerMode !== "agent" ? (
<Select
value={modelSelectValue}
onValueChange={(value) => {
setSelectedModelId(value);
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={selectDisplayModel.avatarUrl}
alt={selectDisplayModel.displayName || "模型头像"}
className="h-full w-full object-cover"
/>
) : (
<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="icon"
className={cn(
"h-8 w-8 rounded-full transition-all duration-200 flex-shrink-0",
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" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</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={tagPreview.url}
alt={tagPreview.name}
className="block max-w-full max-h-[180px] h-auto"
/>
{/* 如果是物品标签,显示标注位置 */}
{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>
);
}