"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { useSession } from "next-auth/react";
import { createPortal } from "react-dom";
import type { DetailUiProtocol } from "@/lib/detail-ui-protocol";
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder";
import {
buildDetailTextLayerNodes,
buildDetailTextLayerNodesFromReviewPatch,
buildDetailTextLayerNodesFromSurfaceScreen,
collectManagedDetailTextLayerNodeIds,
} from "@/lib/detail-ui-renderer";
import { buildUiScreenDocumentFromDetailUiDsl } from "@/lib/ui-screen/detail-adapter";
import type { UiScreenDocument } from "@/lib/ui-screen/schema";
import {
applyUiSurfacePatch,
type UiSurfacePatch,
type UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
inferOriginalImageUrl,
} from "@/lib/image-url";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import {
getGenerationCreditCostFromImageGenConfig,
getImageCreditCostFromConfig,
getImageModelCreditDiscountLabel,
} from "@/lib/ai/image-model-pricing";
import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability";
import type { ChatMessage } from "@/lib/types";
import type {
AgentPreset,
AgentWorkflowComposerContext,
} from "@/types/agent";
import type { CanvasNode } from "@/types/canvas";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
import {
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import {
isCanvasImageReadyForAi,
isCanvasImageUploadPending,
} from "@/lib/canvas/local-upload-state";
import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
import { ImageGenerationLoading } from "@/components/image-generation-loading";
import { Response } from "@/components/elements/response";
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel";
import { DetailSurfaceInspectorPanel } from "@/components/canvas/DetailSurfaceInspectorPanel";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import {
getCanvasToolbarItem,
normalizeCanvasToolbarConfig,
type CanvasToolbarConfig,
type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config";
import {
AGENT_PROGRESS_STEPS,
type AgentProgressStreamPayload,
type AgentTargetLockRefPreview,
type AgentTargetLockStreamPayload,
type CanvasMediaStreamPayload,
getAgentProgressFallbackTitle,
getAgentTargetLockTitle,
getAgentTargetResolutionLabel,
type ImageGenerationStatusStreamPayload,
} from "@/components/canvas/chat-panel/canvas-agent-stream";
import {
clampChatRequestText,
deriveSessionTitleFromMessages,
extractUserTextPrompt,
findNearestUserPrompt,
getSessionRootStorageKey,
getSessionStorageKey,
isGenericCanvasPrompt,
isValidChatHttpUrl,
isValidChatUuid,
MAX_CHAT_AUTO_CONTEXT_LENGTH,
MAX_CHAT_HIDDEN_PROMPT_LENGTH,
MAX_CHAT_TEXT_PART_LENGTH,
normalizeChatAttachmentName,
normalizeChatMediaType,
type PickedObjectAttachment,
sanitizeChatAttachment,
toRawCanvasChatId,
} from "@/components/canvas/chat-panel/canvas-chat-session";
import {
buildCanvasHistoryMessagesSignature,
buildDownloadFilename,
extractAssistantImageUrls,
extractCanvasModelName,
extractImageUrls,
extractVideoUrls,
formatMessageDateLabel,
getAssistantImageNodeIdentityKey,
getCanvasAssistantMessagesAfterTargetUser,
getMessageDayKey,
getMessageTimestamp,
getNearestUserImageUrlKeys,
getNearestUserImageUrls,
hasCanvasImageMarkdown,
hasCanvasLikelyGenerationErrorMessage,
messageHasAssistantMedia,
normalizeImageIdentityUrl,
normalizeMediaUrl,
sortCanvasMessagesByCreatedAt,
stripCanvasModelRoutingLines,
stripMediaMarkup,
triggerBrowserDownload,
} from "@/components/canvas/chat-panel/canvas-chat-media";
// 标签悬停预览信息
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;
selectedAgentPresetId?: string | null;
chatScope?: "canvas" | "agent";
workflowContext?: AgentWorkflowComposerContext | null;
presentation?: "panel" | "workspace";
agentEnabled?: boolean;
workspaceVariant?: "canvas" | "video";
canvasToolbarConfig?: CanvasToolbarConfig;
}
type VideoStoryboardStreamData =
| {
stage: "detecting";
provider?: string;
message?: string;
}
| {
stage: "start";
provider?: string;
taskId: string;
totalShots: number;
gridType?: "grid_2x2" | "grid_3x2" | "grid_3x3";
rows?: number;
cols?: number;
confidence?: number;
sourceImageUrl?: string;
shots?: Array<{
shotIndex: number;
tileImageUrl?: string;
prompt?: string;
}>;
}
| {
stage: "hd_shot_completed";
provider?: string;
taskId: string;
shotIndex: number;
imageUrl: string;
prompt?: string;
}
| {
stage: "hd_shot_failed";
provider?: string;
taskId: string;
shotIndex: number;
error?: string;
}
| {
stage: "awaiting_confirmation";
provider?: string;
taskId: string;
totalShots: number;
gridType?: "grid_2x2" | "grid_3x2" | "grid_3x3";
rows?: number;
cols?: number;
message?: string;
shots?: Array<{
shotIndex: number;
tileImageUrl?: string;
previewImageUrl?: string;
prompt?: string;
}>;
}
| {
stage: "shot_completed";
provider?: string;
taskId: string;
shotIndex: number;
modelId?: string;
videoUrl: string;
text?: string;
}
| {
stage: "shot_failed";
provider?: string;
taskId: string;
shotIndex: number;
error?: string;
}
| {
stage: "completed";
provider?: string;
taskId: string;
totalShots?: number;
successCount?: number;
failedCount?: number;
}
| {
stage: "failed";
provider?: string;
taskId: string;
error?: string;
}
| {
stage: "shot_retry";
provider?: string;
taskId: string;
shotIndex: number;
attempt?: number;
modelId?: string;
error?: string;
};
// 建议卡片数据 - 已移除
const SUGGESTIONS: { title: string; desc: string; image: string }[] = [];
const CANVAS_COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
const CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT = [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
];
function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
const trimmed = text.trim();
if (!trimmed) return "";
const sanitizedPrefix = fixedPrefix.trim();
if (sanitizedPrefix && trimmed.startsWith(sanitizedPrefix)) {
return trimmed.slice(sanitizedPrefix.length).trimStart();
}
const legacyPrefixRemoved = trimmed
.replace(/^让\s*buyi[-_ ]?ai\s*/i, "")
.replace(/^让\s*lovart\s*/i, "")
.trimStart();
if (legacyPrefixRemoved !== trimmed) {
return legacyPrefixRemoved;
}
return trimmed;
}
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// 限制最大尺寸,避免节点过大
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
// 如果图片太大,按比例缩小
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
// 设置最小尺寸
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
// 加载失败时使用默认尺寸
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}
function buildPreviewImageUrl(url: string, width: number): string {
return buildImageProxyUrl(url, {
width,
quality: 86,
format: "webp",
});
}
const CANVAS_IMAGE_ANALYSIS_TITLE = "图片分析";
const CANVAS_IMAGE_DONE_SUMMARY = "已完成本次生成,如需微调可继续告诉我。";
const CANVAS_AGENT_SECTION_TITLES = new Set([
"需求理解",
CANVAS_IMAGE_ANALYSIS_TITLE,
"执行计划",
"优化后提示词",
"输出设置",
]);
const CANVAS_IMAGE_TASK_POLL_INTERVAL_MS = 1600;
const CANVAS_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000;
type CanvasImageGenerationRequest = {
prompt: string;
model?: string;
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
negativePrompt?: string;
referenceImage?: string;
referenceImages?: string[];
};
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function requestCanvasImageGeneration(
payload: CanvasImageGenerationRequest
): Promise<{ images: Array<{ originalUrl?: string; thumbnailUrl?: string }> }> {
const createResponse = await fetch("/api/canvas/generate/image", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...payload,
async: true,
}),
});
const createResult = await createResponse.json().catch(() => ({}));
if (!createResponse.ok || !createResult?.success) {
throw new Error(createResult?.error || "创建生图任务失败");
}
if (Array.isArray(createResult?.data?.images) && createResult.data.images.length > 0) {
return { images: createResult.data.images };
}
const taskId = String(createResult?.taskId || "").trim();
if (!taskId) {
throw new Error("创建生图任务失败:未返回任务ID");
}
const deadline = Date.now() + CANVAS_IMAGE_TASK_TIMEOUT_MS;
let lastError = "";
while (Date.now() < deadline) {
await sleep(CANVAS_IMAGE_TASK_POLL_INTERVAL_MS);
const statusResponse = await fetch(
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`,
{ cache: "no-store" }
);
const statusResult = await statusResponse.json().catch(() => ({}));
if (!statusResponse.ok || !statusResult?.success) {
lastError = statusResult?.error || `查询任务失败(${statusResponse.status})`;
continue;
}
const task = statusResult?.task;
const status = String(task?.status || "").trim();
if (status === "succeeded") {
const result = task?.result || {};
const images = Array.isArray(result?.images) ? result.images : [];
if (images.length === 0) {
throw new Error("任务完成但未返回图片");
}
return { images };
}
if (status === "failed") {
throw new Error(String(task?.error || "图片生成失败"));
}
}
throw new Error(lastError || "图片生成超时,请稍后重试");
}
type CanvasAnalysisSection = {
before: string;
analysis: string;
after: string;
};
type CanvasTypewriterTextProps = {
text: string;
typingKey: string;
enabled: boolean;
className?: string;
renderMarkdown?: boolean;
onActiveChange?: (typingKey: string, active: boolean) => void;
onComplete?: (typingKey: string) => void;
};
function CanvasTypewriterText({
text,
typingKey,
enabled,
className,
renderMarkdown = false,
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]);
if (renderMarkdown) {
return {displayText};
}
return
{displayText}
;
}
function normalizeCanvasAgentText(value: string): string {
return String(value || "")
.replace(/\r\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function hasCanvasRenderableMarkdown(value: string): boolean {
const text = String(value || "");
if (!text) return false;
return (
/(^|\n)\s*#{1,6}\s+/.test(text) ||
/\*\*[^*]+\*\*/.test(text) ||
/(^|\n)\s*[-*+]\s+/.test(text) ||
/(^|\n)\s*\d+\.\s+/.test(text) ||
/\[[^\]]+\]\([^)]+\)/.test(text)
);
}
function normalizeCanvasSectionTitle(line: string): string {
let normalized = line.trim();
if (!normalized) return "";
normalized = normalized
.replace(/^#{1,6}\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\[(.+)\]$/, "$1")
.replace(/^【(.+)】$/, "$1")
.replace(/[::]+$/, "")
.trim();
return normalized;
}
function shouldSplitCanvasAnalysisAfterLine(line: string): boolean {
const normalized = String(line || "")
.replace(/^[-*]\s*/, "")
.trim();
if (!normalized) return false;
if (
/^(现在我将|接下来|下一步|我将|现在开始|下面我|随后|Then\b|Next\b|Now\b)/i.test(
normalized
)
) {
return true;
}
// 模型徽标行(例如:◈ Nano Banana Pro)
return /^[◈◇◆🔹⬡✦•·]\s*[^\n]+$/i.test(normalized);
}
function extractCanvasImageAnalysisSection(content: string): CanvasAnalysisSection | null {
const lines = String(content || "").split(/\r?\n/);
let startIndex = -1;
let inlineAnalysis = "";
for (let i = 0; i < lines.length; i += 1) {
const title = normalizeCanvasSectionTitle(lines[i]);
if (!title || !title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
startIndex = i;
const headingText = lines[i]
.trim()
.replace(/^#{1,6}\s*/, "")
.replace(/^[-*]\s*/, "")
.replace(/^\[(.+)\]$/, "$1")
.replace(/^【(.+)】$/, "$1")
.trim();
const inlineMatch = headingText.match(/^图片分析[::]\s*(.+)$/);
if (inlineMatch?.[1]) {
inlineAnalysis = inlineMatch[1].trim();
}
break;
}
if (startIndex < 0) return null;
let endIndex = lines.length;
for (let i = startIndex + 1; i < lines.length; i += 1) {
if (shouldSplitCanvasAnalysisAfterLine(lines[i])) {
endIndex = i;
break;
}
const title = normalizeCanvasSectionTitle(lines[i]);
if (!title || title.startsWith(CANVAS_IMAGE_ANALYSIS_TITLE)) continue;
if (CANVAS_AGENT_SECTION_TITLES.has(title)) {
endIndex = i;
break;
}
}
const analysisLines = lines.slice(startIndex + 1, endIndex);
if (inlineAnalysis) {
analysisLines.unshift(inlineAnalysis);
}
const analysis = normalizeCanvasAgentText(analysisLines.join("\n"));
if (!analysis) return null;
return {
before: normalizeCanvasAgentText(lines.slice(0, startIndex).join("\n")),
analysis,
after: normalizeCanvasAgentText(lines.slice(endIndex).join("\n")),
};
}
function normalizeVideoParamsForProvider(
params: Partial | null | undefined,
providerType?: string,
capability?: VideoToolCapabilityContract | null
): VideoGenerationParams {
if (capability) {
const modeOptions = capability.modeOptions || [];
const modeSet = new Set(modeOptions.map((item) => item.mode));
const fallbackMode = capability.defaults?.mode || modeOptions[0]?.mode || "text-to-video";
const merged = {
mode: fallbackMode,
resolution: (capability.defaults?.resolution || "720p") as VideoGenerationParams["resolution"],
ratio: (capability.defaults?.ratio || "16:9") as VideoGenerationParams["ratio"],
duration: Number(capability.defaults?.duration) || 5,
generateAudio: capability.defaults?.generateAudio ?? false,
watermark: false,
cameraFixed: false,
cameraMove: "none" as VideoGenerationParams["cameraMove"],
fps: capability.defaults?.fps || 24,
...(params || {}),
} as VideoGenerationParams;
if (!modeSet.has(merged.mode)) {
merged.mode = fallbackMode as VideoGenerationParams["mode"];
}
const scopedOptions = getVideoToolOptionsForMode(capability, merged.mode);
const ratioOptions = scopedOptions.ratioOptions || [];
const durationOptions = scopedOptions.durationOptions || [];
const resolutionOptions = scopedOptions.resolutionOptions || [];
const ratioSet = new Set(ratioOptions);
const durationSet = new Set(durationOptions);
const resolutionSet = new Set(resolutionOptions);
const fallbackRatio =
scopedOptions.defaults?.ratio || ratioOptions[0] || capability.defaults?.ratio || "16:9";
const fallbackDuration =
Number(scopedOptions.defaults?.duration) ||
durationOptions[0] ||
Number(capability.defaults?.duration) ||
5;
const fallbackResolution =
scopedOptions.defaults?.resolution ||
resolutionOptions[0] ||
capability.defaults?.resolution ||
"720p";
if (!ratioSet.has(merged.ratio)) {
merged.ratio = fallbackRatio as VideoGenerationParams["ratio"];
}
if (!durationSet.has(Number(merged.duration))) {
merged.duration = fallbackDuration;
} else {
merged.duration = Number(merged.duration) || fallbackDuration;
}
if (resolutionOptions.length > 0 && !resolutionSet.has(merged.resolution)) {
merged.resolution = fallbackResolution as VideoGenerationParams["resolution"];
} else if (resolutionOptions.length === 0) {
merged.resolution = fallbackResolution as VideoGenerationParams["resolution"];
}
if (!capability.ui.showGenerateAudio) {
merged.generateAudio = false;
}
if (!capability.ui.showFps) {
merged.fps = capability.defaults?.fps || 24;
}
if (!capability.ui.showCameraMove) {
merged.cameraMove = "none";
merged.cameraFixed = false;
}
if (!capability.ui.showReturnLastFrame) {
merged.returnLastFrame = false;
}
if (!capability.ui.showSeed) {
merged.seed = undefined;
}
return merged;
}
const isFalVideo = providerType === "fal-video";
const isRuxaVideo = providerType === "ruxa-video";
const base: VideoGenerationParams = {
mode: isFalVideo || isRuxaVideo ? "first-frame" : "text-to-video",
resolution: "720p",
ratio: "16:9",
duration: 5,
generateAudio: true,
watermark: false,
cameraFixed: false,
cameraMove: "none",
fps: 24,
};
const merged = { ...base, ...(params || {}) };
if (isFalVideo) {
if (merged.mode !== "first-frame" && merged.mode !== "first-last-frame") {
merged.mode = "first-frame";
}
if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) {
merged.ratio = "16:9";
}
merged.duration = Math.max(3, Math.min(15, Math.round(Number(merged.duration) || 5)));
merged.generateAudio = merged.generateAudio ?? true;
return merged;
}
if (isRuxaVideo) {
merged.mode = "first-frame";
if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) {
merged.ratio = "16:9";
}
merged.duration = merged.duration >= 8 ? 10 : 5;
merged.generateAudio = false;
merged.cameraMove = "none";
merged.cameraFixed = false;
merged.fps = 24;
return merged;
}
if (!["text-to-video", "first-frame", "first-last-frame", "reference-images"].includes(merged.mode)) {
merged.mode = "text-to-video";
}
if (!["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(merged.ratio)) {
merged.ratio = "16:9";
}
merged.duration = merged.duration >= 8 ? 10 : 5;
return merged;
}
function getVideoImageLimit(
params: VideoGenerationParams | null | undefined,
providerType?: string,
capability?: VideoToolCapabilityContract | null
): { min: number; max: number } {
if (!params) {
return { min: 0, max: 0 };
}
if (capability?.modeOptions?.length) {
const modeOption = capability.modeOptions.find((item) => item.mode === params.mode);
if (modeOption) {
return { min: modeOption.imageMin, max: modeOption.imageMax };
}
}
const isFalVideo = providerType === "fal-video";
const isRuxaVideo = providerType === "ruxa-video";
if (isRuxaVideo) {
return { min: 1, max: 1 };
}
if (isFalVideo) {
if (params.mode === "first-last-frame") {
return { min: 2, max: 2 };
}
return { min: 1, max: 1 };
}
switch (params.mode) {
case "text-to-video":
return { min: 0, max: 0 };
case "first-last-frame":
return { min: 2, max: 2 };
case "first-frame":
return { min: 1, max: 1 };
case "reference-images":
return { min: 1, max: 4 };
default:
return { min: 0, max: 0 };
}
}
type MessageVote = {
chatId: string;
messageId: string;
isUpvoted: boolean;
};
interface CanvasChatSession {
id: string;
title: string;
createdAt: number;
updatedAt: number;
}
type DetailBriefForm = {
productSpu: string;
demandType: string;
productName: string;
targetAudience: string;
mainUser: string;
usageScenario: string;
triggerMoment: string;
coreNeed: string;
riskConcern: string;
expectedResult: string;
coreSellingPoint: string;
positioningStatement: string;
userVoice: string;
competitorReference: string;
priceBand: string;
brandTone: string;
bannedWords: string;
};
type DetailSlotConfig = {
key: string;
label: string;
role: "product" | "model" | "bundle";
};
type DetailSectionKey = "required" | "strategy" | "assets" | "archives";
type DetailGenerationMode = "classic" | "agent_layout";
type DetailArchive = {
id: string;
title: string;
prompt: string;
generationMode: DetailGenerationMode;
brief: DetailBriefForm;
slots: Array;
createdAt: number;
updatedAt?: number;
summary?: string;
imageCount?: number;
coverUrl?: string;
uiSurfaceSnapshot?: UiSurfaceSnapshot | null;
uiSurfaceLatestReview?: {
screenIndex?: number;
action?: "pass" | "protocol_patch" | "regenerate_image";
passed?: boolean;
issues?: string[];
} | null;
};
type DetailPlanResponse = {
generationMode?: DetailGenerationMode;
summary?: string;
prompts?: string[];
screenSize?: {
width: number;
height: number;
};
globalStyle?: {
designLanguage?: string;
typography?: {
fontFamily?: string;
title?: string;
subtitle?: string;
body?: string;
};
layout?: {
grid?: string;
safeArea?: string;
rhythm?: string;
};
palette?: string;
};
screens?: Array<{
index: number;
screenType?: "A" | "B" | "C";
objective?: string;
title?: string;
subtitle?: string;
copy?: string;
layoutNote?: string;
useModel?: boolean;
modelReason?: string;
bundleShowcase?: boolean;
bundleReason?: string;
visualPrompt?: string;
fabe?: {
b?: string;
f?: string;
a?: string;
e?: string;
};
}>;
skillFlow?: {
name?: string;
version?: string;
steps?: string[];
};
designSkill?: {
version?: string;
name?: string;
source?: string;
visualDirection?: string[];
reviewChecklist?: string[];
};
qualityChecks?: {
passed?: boolean;
issues?: string[];
};
uiValidation?: {
passed?: boolean;
issues?: string[];
};
preflight?: {
passed?: boolean;
designDirectorScore?: number;
marketingDirectorScore?: number;
issues?: string[];
rewriteApplied?: boolean;
};
model?: {
id: string;
displayName: string;
providerType: string;
};
uiProtocol?: DetailUiProtocol;
uiSurface?: UiSurfaceSnapshot;
uiDslV2Screens?: DetailUiDslV2Document[];
brandStyleBoard?: UiScreenDocument;
};
type DetailPlanScreenSpec = NonNullable[number];
const DETAIL_TARGET_WIDTH = 790;
const DETAIL_TARGET_HEIGHT = 1300;
const DETAIL_STYLE_BOARD_WIDTH = 790;
const DETAIL_STYLE_BOARD_HEIGHT = 1080;
const DETAIL_STYLE_BOARD_SPACING = 36;
const DETAIL_REVIEW_COUNT = 3;
const DETAIL_REVIEW_REGEN_COUNT = 2;
const DETAIL_REVIEW_IMAGE_COUNT = 3;
const DETAIL_REVIEW_IMAGE_WIDTH = 420;
const DETAIL_REVIEW_IMAGE_HEIGHT = 420;
const DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS = 1;
const DETAIL_TYPOGRAPHY_LOCK = {
title: "主标题 64px / 700 / 行高1.15,建议单行最多2行,不得出现“第X屏”",
subtitle: "副标题 34px / 600 / 行高1.25,位于主标题下方16px",
body: "正文 24px / 500 / 行高1.45,段间距10px,每块最多4行",
safeArea: "文字安全区固定 y=64~360,产品主体避免遮挡标题区",
rhythm: "左右边距56px,模块间距24px,全套页面留白节奏一致",
} as const;
const DETAIL_SLOT_CONFIGS: DetailSlotConfig[] = [
{ key: "product-1", label: "产品图1", role: "product" },
{ key: "product-2", label: "产品图2", role: "product" },
{ key: "product-3", label: "产品图3", role: "product" },
{ key: "product-4", label: "产品图4", role: "product" },
{ key: "product-5", label: "产品图5", role: "product" },
{ key: "model-1", label: "模特图", role: "model" },
{ key: "bundle-1", label: "搭配图1", role: "bundle" },
{ key: "bundle-2", label: "搭配图2", role: "bundle" },
{ key: "bundle-3", label: "搭配图3", role: "bundle" },
];
const DETAIL_PRODUCT_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) =>
slot.role === "product" ? index : -1
).filter((index) => index >= 0);
const DETAIL_MODEL_SLOT_INDEX = DETAIL_SLOT_CONFIGS.findIndex((slot) => slot.role === "model");
const DETAIL_BUNDLE_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) =>
slot.role === "bundle" ? index : -1
).filter((index) => index >= 0);
const DETAIL_FIELD_EXAMPLES: Record = {
productSpu: "示例:SPU-889901",
demandType: "示例:清洁除尘 / 户外通勤 / 礼赠",
productName: "示例:Buyi-AI 无线手持吸尘器",
targetAudience: "示例:25-40岁养宠家庭、宝妈",
mainUser: "示例:家庭主理人、租房上班族",
usageScenario: "示例:下班回家10分钟快速清洁",
triggerMoment: "示例:宠物掉毛高峰、饭后碎屑清理",
coreNeed: "示例:省时省力清除毛发与灰尘",
riskConcern: "示例:担心吸力不足、续航短、噪音大",
expectedResult: "示例:3分钟清洁桌面,地面无明显毛发",
coreSellingPoint: "示例:18000Pa吸力、0.9kg轻量、一键倒尘",
positioningStatement: "示例:让日常清洁更快更轻松",
userVoice: "示例:家里有猫掉毛太多,希望安静一点不吵宝宝",
competitorReference: "示例:对标戴森入门款,突出轻便高性价比",
priceBand: "示例:199-299元",
brandTone: "示例:专业、可靠、亲和",
bannedWords: "示例:最强、第一、100%治愈",
};
const DETAIL_GENERATION_MODE_OPTIONS: Array<{
value: DetailGenerationMode;
label: string;
description: string;
}> = [
{
value: "classic",
label: "传统生图",
description: "整页直接生图,速度更快",
},
{
value: "agent_layout",
label: "AGENT排版",
description: "生图只出画面,文字层可编辑",
},
];
function extractDetailFrameCount(text: string): number {
const normalized = text.replace(/\s+/g, "");
const matched = normalized.match(/(\d{1,2})(?:张|屏|页)/);
if (!matched) return 15;
const value = Number(matched[1]);
if (!Number.isFinite(value)) return 15;
return Math.min(20, Math.max(5, Math.round(value)));
}
type DetailReviewPlan = {
index: number;
title: string;
reviewText: string;
imagePrompt: string;
};
type DetailReviewGroup = {
reviewText: string;
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
};
type DetailScreenReviewResult = {
pass: boolean;
reviewAction?: "pass" | "protocol_patch" | "regenerate_image";
score?: number;
issues?: string[];
layoutFixes?: string[];
revisedPrompt?: string;
uiPatch?: DetailUiScreenReviewPatch;
uiSurfacePatch?: UiSurfacePatch;
uiValidation?: {
passed?: boolean;
issues?: string[];
};
model?: {
id?: string;
displayName?: string;
providerType?: string;
} | null;
};
type DetailUiValidationSnapshot = {
passed?: boolean;
issues?: string[];
};
type DetailUiReviewSnapshot = {
screenIndex: number;
action?: "pass" | "protocol_patch" | "regenerate_image";
passed?: boolean;
issues?: string[];
};
function normalizeDetailGenerationMode(value: unknown): DetailGenerationMode {
return value === "agent_layout" ? "agent_layout" : "classic";
}
function extractFirstNumber(value: string, fallback: number): number {
const matched = String(value || "").match(/(\d{1,3})/);
if (!matched) return fallback;
const parsed = Number(matched[1]);
if (!Number.isFinite(parsed)) return fallback;
return parsed;
}
function extractFontWeight(value: string, fallback = "600"): string {
const matched = String(value || "").match(/(?:\/|weight[::]?\s*)(\d{3})/i);
if (!matched) return fallback;
return matched[1] || fallback;
}
function sanitizeDetailImageModelPrompt(prompt: string): string {
const filteredLines = String(prompt || "")
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
if (
/^(字体规范|标题规范|副标题规范|正文规范|版式规范|安全区|留白节奏|Typography|Layout|Palette|固定字体锁定|固定副标题锁定|固定正文锁定|固定排版安全区|固定留白节奏)/i.test(
line
)
) {
return "";
}
return line;
})
.filter(Boolean);
let result = filteredLines.join("\n");
const literalReplacements: Array<[RegExp, string]> = [
[/画布固定(?:竖版)?\s*\d+\s*x\s*\d+\s*(?:像素|pixels?)。?/gi, "画布为竖版详情页构图。"],
[/尺寸固定\s*\d+\s*x\s*\d+\s*(?:像素|pixels?)。?/gi, "尺寸为竖版详情页比例。"],
[/输出为竖版电商详情图,尺寸固定\s*\d+\s*x\s*\d+\s*(?:像素|pixels?)。?/gi, "输出为竖版电商详情图。"],
[/y\s*=\s*\d+\s*[-~~]\s*\d+/gi, "顶部标题安全区"],
[/左右边距\s*\d+\s*px/gi, "左右留白充足"],
[/模块间距\s*\d+\s*px/gi, "模块间距均匀"],
[/标题和副标题间距\s*\d+\s*px/gi, "标题与副标题间距清晰"],
[/主标题\s*\d+\s*px[^,。;\n]*/gi, "主标题醒目突出"],
[/副标题\s*\d+\s*px[^,。;\n]*/gi, "副标题层级清晰"],
[/正文\s*\d+\s*px[^,。;\n]*/gi, "正文精简易读"],
[/商业摄影质感,留出清晰文案排版区,不要水印。?/gi, "商业摄影质感,留出清晰文字区域,不要水印。"],
];
for (const [pattern, replacement] of literalReplacements) {
result = result.replace(pattern, replacement);
}
result = result
.replace(/\b\d+\s*px\b/gi, "")
.replace(/[((]\s*顶部标题安全区\s*[))]/g, "顶部标题安全区")
.replace(/;{2,}/g, ";")
.replace(/。{2,}/g, "。")
.replace(/\s{2,}/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
return result;
}
function splitDetailKeywords(value: string, limit = 3): string[] {
const raw = String(value || "")
.split(/[\n,,、;;|]/)
.map((item) => item.replace(/\s+/g, " ").trim())
.filter(Boolean);
if (raw.length > 0) return raw.slice(0, limit);
return [];
}
function buildDetailReviewPlans(params: {
brief: DetailBriefForm;
summary?: string;
language: "zh" | "en";
count?: number;
}): DetailReviewPlan[] {
const count = Math.max(1, Math.min(5, Math.round(params.count || DETAIL_REVIEW_COUNT)));
const audience = params.brief.targetAudience || (params.language === "en" ? "target users" : "目标用户");
const scenario = params.brief.usageScenario || (params.language === "en" ? "daily life scenario" : "日常场景");
const coreNeed = params.brief.coreNeed || (params.language === "en" ? "core need" : "核心需求");
const productName = params.brief.productName || (params.language === "en" ? "the product" : "该产品");
const expectedResult =
params.brief.expectedResult || (params.language === "en" ? "clear and reliable improvement" : "效果明显且稳定");
const riskConcern =
params.brief.riskConcern || (params.language === "en" ? "quality and usability concerns" : "担心效果不稳定");
const keywords = splitDetailKeywords(params.brief.coreSellingPoint, 3);
const firstKeyword = keywords[0] || (params.language === "en" ? "stable performance" : "稳定好用");
const secondKeyword = keywords[1] || (params.language === "en" ? "easy operation" : "上手简单");
const thirdKeyword = keywords[2] || (params.language === "en" ? "value for money" : "性价比高");
const summaryHint = String(params.summary || "").replace(/\s+/g, " ").trim();
if (params.language === "en") {
const templates = [
{
title: "Real Need Solved",
reviewText: `I am part of ${audience}. In ${scenario}, my biggest issue was ${coreNeed}. After using ${productName}, the result is ${expectedResult}. It feels ${firstKeyword} and I am willing to repurchase.`,
imagePrompt: `UGC buyer photo set for ${productName}, scenario: ${scenario}, highlight ${coreNeed} solved, realistic lifestyle composition, clear product details, natural lighting, authentic user-generated look`,
},
{
title: "From Concern to Trust",
reviewText: `Before purchase I worried about ${riskConcern}. After using it for a while, ${productName} stays ${secondKeyword} and reliable. The overall user experience is much better than expected.`,
imagePrompt: `UGC buyer experience photo set for ${productName}, before-after feeling in ${scenario}, close-up and handheld usage moments, realistic texture, social proof style, commercial clarity`,
},
{
title: "Conversion Testimonial",
reviewText: `For users like ${audience}, ${productName} is a practical choice. It balances ${firstKeyword}, ${secondKeyword}, and ${thirdKeyword}. The final outcome is ${expectedResult}.`,
imagePrompt: `UGC testimonial photo set for ${productName}, mixed angles including scene shot, detail shot and usage shot, trust-driven ecommerce style, clean but real, high conversion mood`,
},
];
const fallback = templates[templates.length - 1]!;
return Array.from({ length: count }, (_, index) => {
const template = templates[index] || fallback;
const summaryText = summaryHint ? ` ${summaryHint}` : "";
return {
index: index + 1,
title: `${template.title} ${index + 1}`,
reviewText: `${template.reviewText}${summaryText}`.trim(),
imagePrompt: template.imagePrompt,
};
});
}
const templates = [
{
title: "真实需求反馈",
reviewText: `我属于${audience},在${scenario}里最头疼的是${coreNeed}。用了${productName}后,明显感受到${expectedResult},整体体验${firstKeyword},确实值得回购。`,
imagePrompt: `${productName}买家真实晒单,场景:${scenario},突出“${coreNeed}被解决”的使用结果,生活化拍摄,真实自然光,产品细节清晰,电商高转化氛围`,
},
{
title: "顾虑消除反馈",
reviewText: `下单前我最担心${riskConcern},实际使用后发现${productName}${secondKeyword},稳定性也不错。连续使用一段时间后效果依旧在线。`,
imagePrompt: `${productName}买家体验晒单,表达“从担心到放心”的转变,含近景细节、手持使用、场景实拍,真实用户视角,质感干净`,
},
{
title: "转化口碑反馈",
reviewText: `如果你也是${audience},并且在${scenario}下有${coreNeed},这款${productName}真的很合适。它兼顾了${firstKeyword}、${secondKeyword}和${thirdKeyword},结果就是${expectedResult}。`,
imagePrompt: `${productName}买家口碑晒单,三张图分别体现场景图、细节图、结果图,构图统一,真实可信,强化转化购买意愿`,
},
];
const fallback = templates[templates.length - 1]!;
return Array.from({ length: count }, (_, index) => {
const template = templates[index] || fallback;
const summaryText = summaryHint ? ` ${summaryHint}` : "";
return {
index: index + 1,
title: `${template.title}${index + 1}`,
reviewText: `${template.reviewText}${summaryText}`.trim(),
imagePrompt: template.imagePrompt,
};
});
}
function buildDetailResultMarkdown(params: {
generationMode?: DetailGenerationMode;
summary?: string;
images: Array<{ originalUrl: string; thumbnailUrl: string }>;
prompts: string[];
skillFlow?: {
name?: string;
version?: string;
steps?: string[];
};
designSkill?: {
version?: string;
name?: string;
source?: string;
visualDirection?: string[];
reviewChecklist?: string[];
};
qualityChecks?: {
passed?: boolean;
issues?: string[];
};
uiValidation?: {
passed?: boolean;
issues?: string[];
};
brandStyleBoard?: UiScreenDocument | null;
globalStyle?: {
designLanguage?: string;
typography?: {
fontFamily?: string;
title?: string;
subtitle?: string;
body?: string;
};
layout?: {
grid?: string;
safeArea?: string;
rhythm?: string;
};
palette?: string;
};
screens?: Array<{
screenType?: "A" | "B" | "C";
visualPrompt?: string;
title?: string;
subtitle?: string;
copy?: string;
layoutNote?: string;
useModel?: boolean;
modelReason?: string;
bundleShowcase?: boolean;
bundleReason?: string;
fabe?: {
b?: string;
f?: string;
a?: string;
e?: string;
};
}>;
reviewGroups?: DetailReviewGroup[];
}): string {
const lines: string[] = [];
lines.push("### AI详情生成完成");
if (params.generationMode) {
lines.push(
`生成路线:${params.generationMode === "agent_layout" ? "AGENT排版(画面+可编辑文字层)" : "传统生图(整页生图)"}`
);
}
if (params.summary) {
lines.push(params.summary);
}
if (params.brandStyleBoard) {
lines.push(`品牌 UI 配色板:已先生成「${params.brandStyleBoard.title || "品牌 UI 配色板"}」,后续详情页将沿用这套视觉基准。`);
}
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.designSkill?.name) {
lines.push(
`设计技能:${params.designSkill.name}${params.designSkill.version ? ` (${params.designSkill.version})` : ""}`
);
params.designSkill.reviewChecklist?.slice(0, 3).forEach((item) => {
lines.push(`- ${item}`);
});
}
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}`));
}
}
if (params.uiValidation) {
if (params.uiValidation.passed) {
lines.push("协议排版校验:通过");
} else if (params.uiValidation.issues?.length) {
lines.push("协议排版校验:存在待优化项");
params.uiValidation.issues.slice(0, 3).forEach((issue) => lines.push(`- ${issue}`));
}
}
lines.push(`共生成 ${params.images.length} 张详情图。`);
if (params.globalStyle) {
const typography = params.globalStyle.typography;
lines.push(
`全局风格:${params.globalStyle.designLanguage || "统一电商详情风格"}`
);
lines.push(
`字体规范:${typography?.fontFamily || "统一字体"} / ${typography?.title || "标题规范一致"} / ${typography?.subtitle || "副标题规范一致"}`
);
}
lines.push("");
params.images.forEach((image, index) => {
const screen = params.screens?.[index];
const prompt = params.prompts[index] || screen?.visualPrompt || "";
lines.push(`#### 分镜 ${index + 1}`);
if (screen?.screenType) {
lines.push(`分镜类型:${screen.screenType}`);
}
if (screen?.title) {
lines.push(`标题:${screen.title}`);
}
if (screen?.subtitle) {
lines.push(`副标题:${screen.subtitle}`);
}
if (screen?.copy) {
lines.push(`文案:${screen.copy}`);
}
if (screen?.layoutNote) {
lines.push(`排版:${screen.layoutNote}`);
}
if (screen?.useModel !== undefined) {
lines.push(screen.useModel ? "人物:使用固定模特图" : "人物:不使用模特图");
}
if (screen?.modelReason) {
lines.push(`人物策略:${screen.modelReason}`);
}
if (screen?.bundleShowcase !== undefined) {
lines.push(screen.bundleShowcase ? "搭配展示:是" : "搭配展示:否");
}
if (screen?.bundleReason) {
lines.push(`搭配策略:${screen.bundleReason}`);
}
if (screen?.fabe) {
const fabeParts = [
screen.fabe.b ? `B ${screen.fabe.b}` : "",
screen.fabe.f ? `F ${screen.fabe.f}` : "",
screen.fabe.a ? `A ${screen.fabe.a}` : "",
screen.fabe.e ? `E ${screen.fabe.e}` : "",
].filter(Boolean);
if (fabeParts.length > 0) {
lines.push(`FABE:${fabeParts.join(" | ")}`);
}
}
if (prompt) {
lines.push(prompt);
}
lines.push(
``
);
lines.push("");
});
if (params.reviewGroups?.length) {
lines.push("### 买家评价与晒图");
params.reviewGroups.forEach((group, index) => {
lines.push(`评价 ${index + 1}:${group.reviewText}`);
group.images.forEach((image) => {
lines.push(
``
);
});
lines.push("");
});
}
return lines.join("\n").trim();
}
function buildDetailReviewAppendMarkdown(params: {
groups: DetailReviewGroup[];
failedCount?: number;
}): string {
const lines: string[] = [];
lines.push("### 买家评价与晒图已追加");
lines.push(`本次新增 ${params.groups.length} 条评价,每条 ${DETAIL_REVIEW_IMAGE_COUNT} 张晒图。`);
if (params.failedCount && params.failedCount > 0) {
lines.push(`失败 ${params.failedCount} 条,已跳过。`);
}
lines.push("");
params.groups.forEach((group, index) => {
lines.push(`评价 ${index + 1}:${group.reviewText}`);
group.images.forEach((image) => {
lines.push(
``
);
});
lines.push("");
});
return lines.join("\n").trim();
}
export function CanvasChatPanel({
chatId,
initialMessages = [],
onClose,
onOpenFileList,
selectedAgentPresetId,
chatScope = "canvas",
workflowContext = null,
presentation = "panel",
agentEnabled = true,
workspaceVariant = "canvas",
canvasToolbarConfig: rawCanvasToolbarConfig,
}: CanvasChatPanelProps) {
const isWorkspacePresentation = presentation === "workspace";
const isVideoWorkspace = workspaceVariant === "video";
const propCanvasToolbarConfig = useMemo(
() => normalizeCanvasToolbarConfig(rawCanvasToolbarConfig),
[rawCanvasToolbarConfig]
);
const effectiveAgentEnabled = agentEnabled && !isVideoWorkspace;
const { data: session } = useSession();
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState([]);
const [selectedModelId, setSelectedModelId] = useState(
effectiveAgentEnabled ? "auto" : ""
);
const [isDetailGenerationMode, setIsDetailGenerationMode] = useState(false);
const [detailGenerationMode, setDetailGenerationMode] = useState("classic");
const [isDetailGenerating, setIsDetailGenerating] = useState(false);
const [detailSections, setDetailSections] = useState>({
required: true,
strategy: false,
assets: true,
archives: true,
});
const [isDetailWorkbenchCollapsed, setIsDetailWorkbenchCollapsed] = useState(true);
const [detailProgress, setDetailProgress] = useState<{
status: "idle" | "running" | "done" | "error";
stage: string;
current: number;
total: number;
}>({
status: "idle",
stage: "",
current: 0,
total: 0,
});
const [detailUiValidation, setDetailUiValidation] = useState<{
plan: DetailUiValidationSnapshot | null;
latestScreen: DetailUiReviewSnapshot | null;
}>({
plan: null,
latestScreen: null,
});
const [detailSurfaceSnapshot, setDetailSurfaceSnapshot] = useState(
null,
);
const [detailSlots, setDetailSlots] = useState>(
() => DETAIL_SLOT_CONFIGS.map(() => null)
);
const [detailSlotUploading, setDetailSlotUploading] = useState(null);
const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState(null);
const [detailArchives, setDetailArchives] = useState([]);
const [deletingDetailArchiveId, setDeletingDetailArchiveId] = useState(null);
const [detailBrief, setDetailBrief] = useState({
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(chatId);
const activeChatIdRef = useRef(chatId);
const inFlightChatIdRef = useRef(null);
const inFlightRawChatIdRef = useRef(null);
const [chatSessions, setChatSessions] = useState([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
const [selectedSkillTagId, setSelectedSkillTagId] = useState(null);
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef(null);
const fileInputRef = useRef(null);
const videoSlotInputRefs = useRef>([]);
const agentSlotInputRefs = useRef>([]);
const detailSlotInputRefs = useRef>([]);
const textareaRef = useRef(null);
const detailModeSwitchRef = useRef(false);
const hiddenPromptRef = useRef(null);
const autoContextRef = useRef(null);
const contextPayloadRef = useRef(null);
const lastWorkflowAutofillSignatureRef = useRef(null);
const lastWorkflowAutofillUrlsRef = useRef([]);
const pendingLaunchRef = useRef(null);
const [launchAutoSendTick, setLaunchAutoSendTick] = useState(0);
const hasInitializedChatStateRef = useRef(false);
const hasInitializedVideoWorkspaceRef = useRef(false);
const sessionMenuRef = useRef(null);
const historyScrollTimerRef = useRef | null>(null);
const forceHistoryHydrationRef = useRef(null);
// 🆕 标签悬停预览状态
const [tagPreview, setTagPreview] = useState(null);
const previewTimeoutRef = useRef(null);
const [fullscreenPreview, setFullscreenPreview] = useState<{
url: string;
alt?: string;
} | null>(null);
const fullscreenPreviewSrc = useMemo(() => {
if (!fullscreenPreview?.url) return "";
const originalUrl = inferOriginalImageUrl(fullscreenPreview.url);
return buildImageProxyUrl(originalUrl || fullscreenPreview.url, {
delivery: "proxy",
width: 2400,
quality: 90,
format: "webp",
});
}, [fullscreenPreview]);
// 🆕 图片参数状态
const [imageParams, setImageParams] = useState({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
});
const [videoParams, setVideoParams] = useState(null);
const [videoSlotUploading, setVideoSlotUploading] = useState(null);
const [agentSlotUploading, setAgentSlotUploading] = useState(null);
const [lastImageModelId, setLastImageModelId] = useState(null);
const [lastVideoModelId, setLastVideoModelId] = useState(null);
const [lastConversationModelId, setLastConversationModelId] = useState(null);
const [deepThinkingEnabled, setDeepThinkingEnabled] = useState(false);
const [analysisCollapseState, setAnalysisCollapseState] = useState>({});
const [typedAssistantSections, setTypedAssistantSections] = useState>({});
const [activeTypingSections, setActiveTypingSections] = useState>({});
const [insufficientPointsState, setInsufficientPointsState] = useState<{
balance: number;
expectedCost: number;
unitCost: number;
imageCount: number;
} | null>(null);
const [streamImageGenerationStateByChatId, setStreamImageGenerationStateByChatId] =
useState>({});
const streamImageGenerationState = useMemo(
() => streamImageGenerationStateByChatId[activeChatId] || null,
[activeChatId, streamImageGenerationStateByChatId]
);
const [agentTargetLockStateByChatId, setAgentTargetLockStateByChatId] =
useState>({});
const activeAgentTargetLock = useMemo(
() => agentTargetLockStateByChatId[activeChatId] || null,
[activeChatId, agentTargetLockStateByChatId]
);
const [agentProgressStateByChatId, setAgentProgressStateByChatId] =
useState>({});
const activeAgentProgress = useMemo(
() => agentProgressStateByChatId[activeChatId] || null,
[activeChatId, agentProgressStateByChatId]
);
const imageParamsRef = useRef(imageParams);
const videoParamsRef = useRef(videoParams);
const deepThinkingEnabledRef = useRef(deepThinkingEnabled);
const imageAutoTitleCacheRef = useRef