6619 lines
256 KiB
TypeScript
6619 lines
256 KiB
TypeScript
"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 } 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(
|
||
``
|
||
);
|
||
lines.push("");
|
||
});
|
||
if (params.reviewGroups?.length) {
|
||
lines.push("### 买家评价与晒图");
|
||
params.reviewGroups.forEach((group, index) => {
|
||
lines.push(`评价 ${index + 1}:${group.reviewText}`);
|
||
group.images.forEach((image) => {
|
||
lines.push(
|
||
``
|
||
);
|
||
});
|
||
lines.push("");
|
||
});
|
||
}
|
||
return lines.join("\n").trim();
|
||
}
|
||
|
||
function buildDetailReviewAppendMarkdown(params: {
|
||
groups: DetailReviewGroup[];
|
||
failedCount?: number;
|
||
}): string {
|
||
const lines: string[] = [];
|
||
lines.push("### 买家评价与晒图已追加");
|
||
lines.push(`本次新增 ${params.groups.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张晒图。`);
|
||
if (params.failedCount && params.failedCount > 0) {
|
||
lines.push(`失败 ${params.failedCount} 条,已跳过。`);
|
||
}
|
||
lines.push("");
|
||
params.groups.forEach((group, index) => {
|
||
lines.push(`评价 ${index + 1}:${group.reviewText}`);
|
||
group.images.forEach((image) => {
|
||
lines.push(
|
||
``
|
||
);
|
||
});
|
||
lines.push("");
|
||
});
|
||
return lines.join("\n").trim();
|
||
}
|
||
|
||
export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: 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, autoNameGeneratedImageNode]);
|
||
|
||
// 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案)
|
||
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="text-[12px] font-medium tracking-tight text-[#8d94a2]">
|
||
◈ {assistantModelName}
|
||
</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>
|
||
);
|
||
}
|