"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 {
type DetailArchive,
type DetailBriefForm,
DETAIL_BUNDLE_SLOT_INDEXES,
DETAIL_FIELD_EXAMPLES,
DETAIL_GENERATION_MODE_OPTIONS,
type DetailGenerationMode,
DETAIL_MODEL_SLOT_INDEX,
type DetailPlanResponse,
type DetailPlanScreenSpec,
DETAIL_PRODUCT_SLOT_INDEXES,
DETAIL_REVIEW_COUNT,
DETAIL_REVIEW_IMAGE_COUNT,
DETAIL_REVIEW_IMAGE_HEIGHT,
DETAIL_REVIEW_IMAGE_WIDTH,
DETAIL_REVIEW_REGEN_COUNT,
type DetailReviewGroup,
type DetailReviewPlan,
type DetailScreenReviewResult,
type DetailSectionKey,
DETAIL_SCREEN_REVIEW_MAX_ATTEMPTS,
DETAIL_SLOT_CONFIGS,
DETAIL_STYLE_BOARD_HEIGHT,
DETAIL_STYLE_BOARD_SPACING,
DETAIL_STYLE_BOARD_WIDTH,
DETAIL_TARGET_HEIGHT,
DETAIL_TARGET_WIDTH,
DETAIL_TYPOGRAPHY_LOCK,
type DetailUiReviewSnapshot,
type DetailUiValidationSnapshot,
extractDetailFrameCount,
extractFirstNumber,
extractFontWeight,
normalizeDetailGenerationMode,
sanitizeDetailImageModelPrompt,
} from "@/components/canvas/chat-panel/canvas-detail-config";
import {
buildDetailResultMarkdown,
buildDetailReviewAppendMarkdown,
buildDetailReviewPlans,
} from "@/components/canvas/chat-panel/canvas-detail-markdown";
import {
createDetailArchiveSnapshot,
patchDetailArchiveList,
prependDetailArchive,
replaceSavedDetailArchive,
sanitizeDetailArchiveList,
} from "@/components/canvas/chat-panel/canvas-detail-archive";
import {
applyDetailSlotAttachment,
fillDetailSlotAttachments,
getDetailSlotTargetIndexes,
} from "@/components/canvas/chat-panel/canvas-detail-slots";
import {
buildCanvasUploadedImageAttachment,
getCanvasUploadImageTitle,
normalizeUploadedAssetId,
validateCanvasImageUploadFile,
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
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;
}
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