Compare commits

..

No commits in common. "main" and "codex/canvas-image-429-deploy-1001" have entirely different histories.

774 changed files with 6967 additions and 55402 deletions

View File

@ -153,9 +153,8 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues # Home Performance / Queues
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled. CANVAS_IMAGE_TASK_CONCURRENCY=3
CANVAS_IMAGE_TASK_CONCURRENCY=0 CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3 EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20 EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000 EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000

20
.gitattributes vendored
View File

@ -1,20 +0,0 @@
# Keep source checkouts consistent across Windows and Linux.
.gitattributes text eol=lf
.gitignore text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.json text eol=lf
*.jsonc text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.sh text eol=lf
*.py text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf

10
.gitignore vendored
View File

@ -13,7 +13,6 @@ coverage
.next/ .next/
.next-build/ .next-build/
.next-previous/ .next-previous/
/.next-*/
out/ out/
build build
@ -68,11 +67,6 @@ migration-backup-complete/
migration-*.log migration-*.log
migration-*.txt migration-*.txt
# Local release backups, model credentials and migration scratch data.
/.model-config-backups/
/backups/
/tmp/
# 本地抓包/竞品参考产物,不能提交 # 本地抓包/竞品参考产物,不能提交
artifacts/liblib-*/ artifacts/liblib-*/
analysis/qiaodou-extract/ analysis/qiaodou-extract/
@ -134,7 +128,3 @@ services/video-upscale/vendor/__pycache__/
/services/audio-processing/dist/ /services/audio-processing/dist/
/services/audio-processing/__pycache__/ /services/audio-processing/__pycache__/
# Python release-tool and test bytecode.
__pycache__/
*.pyc

View File

@ -1078,15 +1078,139 @@ export function LocalAiRuntimeSections({
</Button> </Button>
</LocalAiSectionShell> </LocalAiSectionShell>
{/* 全站生图单模型并发 */}
<LocalAiSectionShell <LocalAiSectionShell
description="生图任务直接发起,图片先显示,后台转存到存储桶。" description={
<>
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
</>
}
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")} expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
id="local-ai-section-canvas-image-task-queue" id="local-ai-section-canvas-image-task-queue"
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")} onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />} summary={
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>} <LocalAiSectionSummary
items={[
{
label: `${canvasImageTaskQueueConfig.concurrency} 并发/模型`,
tone: "neutral",
},
{
label: `${(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒补位`,
tone: "neutral",
},
{ label: "全站生图生效", tone: "enabled" },
]}
/>
}
title={
<span className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
全站生图单模型并发与批次间隔
</span>
}
> >
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p> <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 text-sm">
<div className="font-medium">生效范围</div>
<div className="mt-2 space-y-1 text-amber-800 text-xs leading-5">
<p>
•
影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。
</p>
<p>
•
多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。
</p>
<p>
• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。
</p>
<p>
•
批次间隔只控制排队补位的“下一批”发起节奏,不影响首批任务即时启动。
</p>
<p>
• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key
池继续细分。
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="canvasImageTaskQueueConcurrency">
单模型最大同时运行任务数
</Label>
<Input
id="canvasImageTaskQueueConcurrency"
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
min={MIN_CANVAS_IMAGE_TASK_CONCURRENCY}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
concurrency: e.target.value,
})
)
}
type="number"
value={canvasImageTaskQueueConfig.concurrency}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_TASK_CONCURRENCY}-
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
{canvasImageTaskQueueConfig.concurrency} 并发,默认值为{" "}
{DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasImageTaskBatchDispatchIntervalSeconds">
下一批排队任务发起间隔(秒)
</Label>
<Input
id="canvasImageTaskBatchDispatchIntervalSeconds"
max={MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
min={MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
batchDispatchIntervalMs: Math.round(
Number.parseFloat(e.target.value || "0") * 1000
),
})
)
}
step="0.5"
type="number"
value={String(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
)}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}-
{MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000} 秒,当前为{" "}
{(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)}{" "}
秒,默认值为{" "}
{(
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000
).toFixed(1)}{" "}
秒。
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
<Save className="mr-2 h-4 w-4" /> 保存并发与间隔
</Button>
</div>
</LocalAiSectionShell> </LocalAiSectionShell>
{/* 批量处理配置 */} {/* 批量处理配置 */}

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media"; import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error"; import { isProviderBalanceError } from "@/lib/request-error";
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance"; import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
const chatApiLogger = createLogger("chat.imageFailover"); const chatApiLogger = createLogger("chat.imageFailover");
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) { if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false; return false;
} }
if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(item.model)) { if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id); const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
} }
if (m.model.id === options.currentModelId) return false; if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false; if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) { if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id); const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false; return false;

View File

@ -1,47 +0,0 @@
import { saveMessages } from "@/lib/db/queries";
import type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
import { safeWriteStreamChunk, type StreamWriter, writeAssistantTextChunk, writeStructuredDataChunk } from "./stream";
export async function reportImageGenerationFailure(params: {
writer: StreamWriter;
chatId: string;
userMessageId: string;
messageId: string;
error: unknown;
label: string;
}) {
const failure = getRequestFailureDetails(params.error, "图片生成失败");
const status: ImageGenerationStatusStreamPayload = {
phase: "failed",
userMessageId: params.userMessageId,
requestId: params.messageId,
failure,
};
const text = formatRequestFailure(failure, params.messageId);
const parts = [
{ type: "text", text },
{ type: "data-imageGenerationStatus", data: status },
];
writeStructuredDataChunk(params.writer, "data-imageGenerationStatus", status, `${params.label}:status`);
safeWriteStreamChunk(params.writer, {
type: "data-appendMessage",
data: JSON.stringify({ id: params.messageId, role: "assistant", parts }),
}, params.label);
writeAssistantTextChunk(params.writer, "<!-- IMAGE_GENERATION_END -->", `${params.label}:end`);
// History carries the same terminal state when the browser loses the stream.
await saveMessages({ messages: [{
id: params.messageId,
chatId: params.chatId,
role: "assistant",
parts,
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
}] });
return text;
}

View File

@ -208,7 +208,6 @@ export function writeImageGenerationStatusChunk(
writer: StreamWriter, writer: StreamWriter,
params: { params: {
phase: "queued" | "processing" | "finalizing" | "failed" | "completed"; phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
userMessageId?: string;
providerType?: string | null; providerType?: string | null;
modelName?: string | null; modelName?: string | null;
imageCount?: number | null; imageCount?: number | null;
@ -224,7 +223,6 @@ export function writeImageGenerationStatusChunk(
"data-imageGenerationStatus", "data-imageGenerationStatus",
{ {
phase: params.phase, phase: params.phase,
userMessageId: params.userMessageId,
providerType: normalizeImageLoadingProviderType(params.providerType), providerType: normalizeImageLoadingProviderType(params.providerType),
modelName: String(params.modelName || "").trim() || undefined, modelName: String(params.modelName || "").trim() || undefined,
imageCount: imageCount:

View File

@ -1,11 +1,8 @@
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work";
import { geolocation } from "@vercel/functions"; import { geolocation } from "@vercel/functions";
import { import {
convertToModelMessages, convertToModelMessages,
createUIMessageStream as createAiUIMessageStream, createUIMessageStream,
generateText, generateText,
smoothStream, smoothStream,
stepCountIs, stepCountIs,
@ -197,7 +194,6 @@ import {
} from "@/lib/ai/runtime/agent-media-markdown"; } from "@/lib/ai/runtime/agent-media-markdown";
import { extractImageUrlsFromText } from "@/lib/image-url"; import { extractImageUrlsFromText } from "@/lib/image-url";
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence"; import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
import { withAuthenticationFailover } from "@/lib/ai/runtime/authentication-failover";
import { import {
recordAiModelHealthFailure, recordAiModelHealthFailure,
recordAiModelHealthSuccess, recordAiModelHealthSuccess,
@ -337,7 +333,6 @@ import {
} from "./_lib/image-media"; } from "./_lib/image-media";
import { import {
buildImageFallbackCandidates, buildImageFallbackCandidates,
countChatMessageImageInputs,
generateImageWithFailoverCandidate, generateImageWithFailoverCandidate,
getImageCrossProviderCandidates, getImageCrossProviderCandidates,
IMAGE_FAILOVER_PROVIDER_ALLOWLIST, IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
@ -346,8 +341,6 @@ import {
setImageFallbackModelCooldown, setImageFallbackModelCooldown,
shouldCooldownImageFallbackModel, shouldCooldownImageFallbackModel,
} from "./_lib/image-failover"; } from "./_lib/image-failover";
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { isProviderBalanceError } from "@/lib/request-error";
import { import {
buildAgentSceneWorkflowPrompt, buildAgentSceneWorkflowPrompt,
buildAgentSlotPrompt, buildAgentSlotPrompt,
@ -426,11 +419,7 @@ import {
type VideoModeType, type VideoModeType,
} from "./_lib/video-models"; } from "./_lib/video-models";
import { reportImageGenerationFailure } from "./_lib/image-generation-failure";
const chatApiLogger = createLogger("chat.route"); const chatApiLogger = createLogger("chat.route");
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN = const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i; /生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
@ -448,6 +437,16 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`); throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
} }
function getPublicImageGenerationErrorText(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
return "图片生成失败,请稍后重试";
}
return publicMessage;
}
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max( const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
1, 1,
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6), Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
@ -905,15 +904,6 @@ export async function POST(request: Request) {
const json = await request.json(); const json = await request.json();
chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2)); chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2));
requestBody = postRequestBodySchema.parse(json); requestBody = postRequestBodySchema.parse(json);
// Agent/Auto Agent has one execution owner: the isolated native ZCode entry.
// This also upgrades existing browser tabs without routing Claw here.
if (requestBody.composerMode === "agent" && requestBody.contextPayload?.source === "canvas" && requestBody.contextPayload.projectId) {
const { POST: nativeAgentPost } = await import("@/app/api/canvas/agent-chat/route");
return nativeAgentPost(new Request(new URL("/api/canvas/agent-chat", request.url), {
method: "POST", headers: request.headers, body: JSON.stringify(json),
}));
}
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] Schema validation failed:", error); chatApiLogger.error("[Chat API] Schema validation failed:", error);
return new ChatSDKError("bad_request:api").toResponse(); return new ChatSDKError("bad_request:api").toResponse();
@ -1208,14 +1198,6 @@ export async function POST(request: Request) {
} }
} }
if (shouldRunAgentConversation && preferredAgentModelId && !shouldKeepSelectedModel) {
const preferred = await getAiModelById({ id: preferredAgentModelId }).catch(() => null);
if (!preferred?.isEnabled || preferred.healthStatus === "auto_disabled") {
preferredAgentModelId = undefined;
configuredAgentConversationModelId = undefined;
}
}
if (shouldRunAgentConversation && !preferredAgentModelId) { if (shouldRunAgentConversation && !preferredAgentModelId) {
const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => { const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => {
chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError); chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError);
@ -1336,7 +1318,6 @@ export async function POST(request: Request) {
} }
const allowAgentVideoGeneration = const allowAgentVideoGeneration =
(contextPayload?.source === "canvas" && inferVideoTaskRequirements(requestTextContent).expectsVideo) ||
Boolean(requestBody.videoParams) || Boolean(requestBody.videoParams) ||
isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined); isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined);
let smartAutoWorkflowDecision: let smartAutoWorkflowDecision:
@ -5680,7 +5661,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
modelName: resolvedModelRef, modelName: resolvedModelRef,
imageCount: replacementReferences.length, imageCount: replacementReferences.length,
@ -6023,7 +6003,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -6179,7 +6158,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: successCount > 0 ? "completed" : "failed", phase: successCount > 0 ? "completed" : "failed",
modelName: resolvedModelDisplayName, modelName: resolvedModelDisplayName,
imageCount: successCount, imageCount: successCount,
@ -6412,7 +6390,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
modelName: modelName:
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.defaultModel?.displayName ||
@ -6964,7 +6941,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -7290,7 +7266,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -7519,7 +7494,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -7838,7 +7812,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: successCount > 0 ? "completed" : "failed", phase: successCount > 0 ? "completed" : "failed",
modelName: modelName:
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.defaultModel?.displayName ||
@ -8048,7 +8021,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: requestedModelProviderType, providerType: requestedModelProviderType,
modelName: finalModelId, modelName: finalModelId,
@ -8087,7 +8059,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
...directImageStatusPayload, ...directImageStatusPayload,
}, },
@ -8180,7 +8151,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: requestedModelProviderType, providerType: requestedModelProviderType,
modelName: directImageResult.model, modelName: directImageResult.model,
@ -8201,16 +8171,42 @@ export async function POST(request: Request) {
); );
} catch (directToolError) { } catch (directToolError) {
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError); chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
const errorMessage = await reportImageGenerationFailure({ const errorMessage = getDirectAgentToolErrorMessage(
writer, chatId: id, userMessageId: message.id, "generate_image",
messageId: assistantMessageId, error: directToolError, directToolError,
label: "agent:generate_image:error", );
await saveAssistantTextOnlyMessage({
chatId: id,
messageId: assistantMessageId,
text: errorMessage,
}); });
writeAssistantTextChunk(
writer,
errorMessage,
"agent:generate_image:error",
assistantMessageId,
);
if (contextPayload?.source === "canvas") {
writeImageGenerationStatusChunk(
writer,
{
phase: "failed",
providerType: requestedModelProviderType,
modelName: finalModelId,
},
"agent:generate_image:status-failed",
);
}
writeAgentProgressChunk( writeAgentProgressChunk(
writer, writer,
buildDirectAgentImageGenerateFailedProgress(errorMessage), buildDirectAgentImageGenerateFailedProgress(errorMessage),
"agent:generate_image:progress-failed", "agent:generate_image:progress-failed",
); );
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"agent:generate_image:end-error",
);
} finally { } finally {
stopDirectImageKeepalive(); stopDirectImageKeepalive();
if (stopDirectImageStatus) { if (stopDirectImageStatus) {
@ -8752,19 +8748,6 @@ export async function POST(request: Request) {
} }
} }
if (/^[0-9a-f-]{36}$/i.test(finalModelId)) {
const requested = await getAiModelById({ id: finalModelId });
if (requested?.supportsImageGen && isImageModelBalanceBlocked(requested)) {
const replacement = resolveConfiguredImageBalanceFallback(requested, await getEnabledAiModelsWithProvider(), {
imageSize: effectiveImageGenerationParams.imageSize,
aspectRatio: effectiveImageGenerationParams.aspectRatio,
referenceImageCount: countChatMessageImageInputs(uiMessagesForModel.filter(item => item.role === "user").slice(-1)),
});
if (!replacement) return new ChatSDKError("bad_request:api", "当前生图线路余额不足,已暂停新请求;请切换可用模型,已有任务和图片会保留。").toResponse();
finalModelId = replacement.model.id;
}
}
// 检查是否是 UUID 格式(数据库中的模型 ID) // 检查是否是 UUID 格式(数据库中的模型 ID)
const isUUID = const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
@ -8882,7 +8865,7 @@ export async function POST(request: Request) {
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
let activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor( const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
resolvedRuntimeModelLookupId, resolvedRuntimeModelLookupId,
).catch(() => null); ).catch(() => null);
if (shouldRunAgentConversation && activeRuntimeModelDescriptor) { if (shouldRunAgentConversation && activeRuntimeModelDescriptor) {
@ -9172,7 +9155,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: currentModelConfig?.displayName || actualModelId, modelName: currentModelConfig?.displayName || actualModelId,
@ -9260,7 +9242,6 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError); chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
}); });
chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
@ -9335,7 +9316,6 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError); chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError);
}); });
chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
} }
@ -9403,7 +9383,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -9468,14 +9447,6 @@ export async function POST(request: Request) {
} }
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] Gemini generation error:", error); chatApiLogger.error("[Chat API] Gemini generation error:", error);
let finalError = error;
if (!shouldFailoverAiModelError(error)) {
await reportImageGenerationFailure({
writer, chatId: id, userMessageId: message.id,
messageId: generateUUID(), error, label: "gemini:error",
});
return;
}
chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision..."); chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision...");
// 故障转移:尝试使用火山引擎模型 // 故障转移:尝试使用火山引擎模型
@ -9553,7 +9524,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: "volcano-vision", providerType: "volcano-vision",
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId, modelName: volcanoModel.model.displayName || volcanoModel.model.modelId,
@ -9619,14 +9589,46 @@ export async function POST(request: Request) {
chatApiLogger.info("[Chat API] No Volcano Vision model available for failover"); chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
} catch (failoverError) { } catch (failoverError) {
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError); chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
finalError = failoverError;
} }
// 如果故障转移也失败,返回错误消息 // 如果故障转移也失败,返回错误消息
await reportImageGenerationFailure({ const errorMessageId = generateUUID();
writer, chatId: id, userMessageId: message.id, writeAssistantTextChunk(
messageId: generateUUID(), error: finalError, label: "gemini:error", writer,
"图片生成失败,请稍后重试",
"gemini:error",
errorMessageId,
);
// 🆕 图片生成结束标记(失败)
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"gemini:image-end-failed",
);
// 尝试保存错误消息
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
}); });
} catch (saveError) {
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
}
} finally { } finally {
stopKeepalive?.(); stopKeepalive?.();
} }
@ -9824,7 +9826,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "queued", phase: "queued",
providerType: "fal-image", providerType: "fal-image",
modelName: modelDisplayName, modelName: modelDisplayName,
@ -9931,7 +9932,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: alibabaModelConfig?.displayName || actualModelId, modelName: alibabaModelConfig?.displayName || actualModelId,
@ -9981,10 +9981,7 @@ export async function POST(request: Request) {
let fallbackSuccess = false; let fallbackSuccess = false;
const triedFallbackModelIds = new Set<string>(); const triedFallbackModelIds = new Set<string>();
const balanceBlockedProviders = new Set<string>();
if (isProviderBalanceError(primaryError) && currentModelEntry?.provider.id) balanceBlockedProviders.add(currentModelEntry.provider.id);
for (const candidate of fallbackCandidates) { for (const candidate of fallbackCandidates) {
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
try { try {
triedFallbackModelIds.add(String(candidate.model.id || "")); triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`); chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
@ -10007,7 +10004,6 @@ export async function POST(request: Request) {
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`); chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
break; break;
} catch (fallbackError) { } catch (fallbackError) {
if (isProviderBalanceError(fallbackError)) balanceBlockedProviders.add(candidate.provider.id);
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError); const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) { if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
@ -10029,7 +10025,6 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError); chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError);
}); });
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
@ -10057,10 +10052,9 @@ export async function POST(request: Request) {
currentModelKey: actualModelId, currentModelKey: actualModelId,
currentProviderId: alibabaModelConfig?.providerId, currentProviderId: alibabaModelConfig?.providerId,
providerPriority, providerPriority,
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")) && !balanceBlockedProviders.has(candidate.provider.id)); }).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
for (const candidate of crossProviderCandidates) { for (const candidate of crossProviderCandidates) {
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
try { try {
triedFallbackModelIds.add(String(candidate.model.id || "")); triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`); chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
@ -10142,7 +10136,6 @@ export async function POST(request: Request) {
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`); chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
break; break;
} catch (crossError) { } catch (crossError) {
if (isProviderBalanceError(crossError)) balanceBlockedProviders.add(candidate.provider.id);
const crossErrorMessage = normalizeFailoverErrorMessage(crossError); const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
if (shouldCooldownImageFallbackModel(crossErrorMessage)) { if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage); setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
@ -10164,7 +10157,6 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError); chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError);
}); });
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError); chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
if (!shouldFailoverAiModelError(crossError)) throw crossError;
} }
} }
} }
@ -10232,7 +10224,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -10297,10 +10288,43 @@ export async function POST(request: Request) {
chatApiLogger.error("[Chat API] Alibaba generation error:", error); chatApiLogger.error("[Chat API] Alibaba generation error:", error);
// 返回错误消息 // 返回错误消息
await reportImageGenerationFailure({ const errorMessageId = generateUUID();
writer, chatId: id, userMessageId: message.id, const errorText = getPublicImageGenerationErrorText(error);
messageId: generateUUID(), error, label: "alibaba:error", writeAssistantTextChunk(
writer,
errorText,
"alibaba:error",
errorMessageId,
);
// 🆕 图片生成结束标记(失败)
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"alibaba:image-end-failed",
);
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: errorText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
}); });
} catch (saveError) {
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
}
} finally { } finally {
stopKeepalive?.(); stopKeepalive?.();
} }
@ -10429,7 +10453,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: "volcano-vision", providerType: "volcano-vision",
modelName: volcanoModelConfig?.displayName || actualModelId, modelName: volcanoModelConfig?.displayName || actualModelId,
@ -10475,7 +10498,6 @@ export async function POST(request: Request) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
} }
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
if (!fallbackSuccess) { if (!fallbackSuccess) {
@ -10642,7 +10664,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: "volcano-vision", providerType: "volcano-vision",
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -10707,10 +10728,43 @@ export async function POST(request: Request) {
} }
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error); chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
await reportImageGenerationFailure({ const errorMessageId = generateUUID();
writer, chatId: id, userMessageId: message.id, writeAssistantTextChunk(
messageId: generateUUID(), error, label: "volcano:error", writer,
"图片生成失败,请稍后重试",
"volcano:error",
errorMessageId,
);
// 🆕 图片生成结束标记(失败)
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"volcano:image-end-failed",
);
// 尝试保存错误消息
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
attachments: [], // 必须提供 attachments 字段
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
}); });
} catch (saveError) {
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
}
} finally { } finally {
stopKeepalive?.(); stopKeepalive?.();
} }
@ -11168,7 +11222,7 @@ export async function POST(request: Request) {
requestHeaders: request.headers, requestHeaders: request.headers,
requestUrl: request.url, requestUrl: request.url,
defaultModelId: finalModelId, defaultModelId: finalModelId,
authorizeDestructive: (nodeIds) => isExplicitDestructiveIntent(requestTextContent) && nodeIds.length > 0 && nodeIds.every((nodeId) => selectedCanvasImageNodeIds.includes(nodeId)), destructiveConfirmed: isCanvasOperationConfirmText(requestTextContent),
callbacks: { callbacks: {
onClientCommand: (command, capability) => { onClientCommand: (command, capability) => {
writeCanvasCapabilityCommandChunk( writeCanvasCapabilityCommandChunk(
@ -11283,7 +11337,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter, dataStream as unknown as StreamWriter,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType, providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
modelName: currentModelConfig?.displayName || actualModelId, modelName: currentModelConfig?.displayName || actualModelId,
@ -11375,7 +11428,6 @@ export async function POST(request: Request) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
} }
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
@ -11547,7 +11599,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter, dataStream as unknown as StreamWriter,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: resolvedProviderType || providerType, providerType: resolvedProviderType || providerType,
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -11607,10 +11658,54 @@ export async function POST(request: Request) {
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error); chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
await reportImageGenerationFailure({ const errorMessageId = generateUUID();
writer: dataStream as unknown as StreamWriter, const errorText = getPublicImageGenerationErrorText(error);
chatId: id, userMessageId: message.id,
messageId: generateUUID(), error, label: "openai-compat:error", dataStream.write({
type: "data-appendMessage",
data: JSON.stringify({
id: errorMessageId,
role: "assistant",
parts: [{ type: "text", text: errorText }],
}),
});
dataStream.write({
type: "data-appendMessage",
data: JSON.stringify({
id: generateUUID(),
role: "assistant",
parts: [{ type: "text", text: "<!-- IMAGE_GENERATION_END -->" }],
}),
});
if (contextPayload?.source === "canvas") {
writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter,
{
phase: "failed",
providerType: resolvedProviderType || providerType,
modelName: currentModelConfig?.displayName || actualModelId,
imageCount: effectiveImageParams?.imageCount || 1,
},
"openai-compat:image-status-failed",
);
}
await saveMessages({
messages: [
{
id: errorMessageId,
role: "assistant",
parts: [{ type: "text", text: errorText }],
createdAt: new Date(),
attachments: [],
chatId: id,
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
}); });
return; return;
} }
@ -12156,70 +12251,8 @@ export async function POST(request: Request) {
const shouldDisableSmoothStream = const shouldDisableSmoothStream =
resolvedModelSupportsImageGen || isVolcanoVideoModel; resolvedModelSupportsImageGen || isVolcanoVideoModel;
let activeConversationModel = currentModelConfig;
let activeConversationProvider = currentProviderConfig;
const recordedAuthFailures = new Set<string>();
const recordConversationAuthFailure = async (error: unknown) => {
const key = activeConversationModel?.id;
if (!key || recordedAuthFailures.has(key)) return;
recordedAuthFailures.add(key);
recordProviderHealthFailure({
providerKey: activeConversationProvider?.id,
providerType: activeConversationProvider?.type,
error,
});
await recordAiModelHealthFailure({
model: activeConversationModel,
provider: activeConversationProvider,
error,
context: "canvas_agent_conversation_auth",
}).catch((healthError) => chatApiLogger.warn("[Chat API] Could not persist model authentication failure:", healthError));
};
const needsVisionFallback = convertedMessages.some((item: any) =>
Array.isArray(item.content) && item.content.some((part: any) =>
part.type === "image" || (part.type === "file" && String(part.mediaType || "").startsWith("image/"))
)
);
const streamModel = shouldRunAgentConversation && !shouldKeepSelectedModel &&
currentModelConfig?.excludeFromFailover !== true &&
!resolvedModelSupportsImageGen && !isVolcanoVideoModel
? withAuthenticationFailover({
primary: languageModel as any,
onAuthenticationFailure: recordConversationAuthFailure,
resolveFallback: async () => {
const enabled = await getEnabledAiModelsWithProvider(false);
const preferredIds = [currentModelConfig?.fallbackModelId1, currentModelConfig?.fallbackModelId2];
const candidates = enabled.filter(({ model, provider }) =>
model.id !== activeConversationModel?.id && model.isEnabled && provider.isEnabled &&
!model.excludeFromFailover && !model.isSubModel && model.healthStatus !== "auto_disabled" &&
!isImageGenerationModelType(provider.type, model) && !isVideoProviderType(provider.type, model) &&
(!needsVisionFallback || model.supportsVision) &&
(isAiModelAgentAuto(model) || preferredIds.includes(model.id)) &&
!shouldAvoidProvider(getProviderHealthSnapshot(provider.id))
).sort((a, b) => {
const rank = (id: string, isDefault: boolean) => {
const index = preferredIds.indexOf(id);
return index >= 0 ? index : isDefault ? 2 : 3;
};
return rank(a.model.id, a.model.isDefault) - rank(b.model.id, b.model.isDefault);
});
for (const candidate of candidates) {
const fallback = await getLanguageModelById(candidate.model.id);
if (!fallback || typeof fallback === "string") continue;
activeConversationModel = candidate.model;
activeConversationProvider = candidate.provider;
finalModelId = candidate.model.id;
actualModelId = candidate.model.modelId;
activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(candidate.model.id).catch(() => null);
return fallback as any;
}
return null;
},
})
: languageModel;
const result = streamText({ const result = streamText({
model: streamModel, model: languageModel,
maxRetries: shouldRunAgentConversation ? 0 : undefined,
system: effectiveConversationSystemPrompt, system: effectiveConversationSystemPrompt,
messages: convertedMessages, messages: convertedMessages,
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5), stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),

View File

@ -8,8 +8,6 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import Image from "next/image"; import Image from "next/image";
import { formatBeijingTime } from "@/lib/utils"; import { formatBeijingTime } from "@/lib/utils";
import { toast } from "sonner";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type FeaturedImage = { type FeaturedImage = {
id: string; id: string;
@ -76,11 +74,18 @@ export default function FeaturedPage() {
const handleDownload = async (url: string, prompt: string) => { const handleDownload = async (url: string, prompt: string) => {
try { try {
await downloadOriginalImage({ url, name: "featured-image" }); const response = await fetch(url);
toast.success("原图已开始下载"); const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = `featured-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (error) { } catch (error) {
console.error("Download failed:", error); console.error("Download failed:", error);
toast.error(getCanvasImageDownloadErrorMessage(error));
} }
}; };

View File

@ -3,5 +3,10 @@ export default function FlowBoardLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return <>{children}</>; return (
<>
<link href="/vendor/drawnix.css" rel="stylesheet" />
{children}
</>
);
} }

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -10,10 +9,9 @@ import { Input } from "@/components/ui/input";
import Image from "next/image"; import Image from "next/image";
import { toast } from "sonner"; import { toast } from "sonner";
import { requestLongTaskResult } from "@/lib/client/long-task"; import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatBeijingTime } from "@/lib/utils"; import { formatBeijingTime } from "@/lib/utils";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type GalleryImage = { type GalleryImage = {
id: string; id: string;
@ -34,7 +32,7 @@ type Pagination = {
totalPages: number; totalPages: number;
}; };
function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) { function buildGalleryDownloadFilename(url: string, alt: string) {
const rawUrl = String(url || "").trim(); const rawUrl = String(url || "").trim();
const rawAlt = String(alt || "").trim(); const rawAlt = String(alt || "").trim();
const safeBase = const safeBase =
@ -60,7 +58,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
} }
} }
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`; return `${safeBase}-${Date.now()}.${extension}`;
} }
export default function GalleryPage() { export default function GalleryPage() {
@ -105,7 +103,7 @@ export default function GalleryPage() {
}; };
const handleDownload = async (url: string, alt: string) => { const handleDownload = async (url: string, alt: string) => {
const originalUrl = String(url || "").trim(); const originalUrl = inferOriginalImageUrl(String(url || "").trim());
if (!originalUrl) { if (!originalUrl) {
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
@ -114,17 +112,32 @@ export default function GalleryPage() {
try { try {
toast.info("正在准备下载..."); toast.info("正在准备下载...");
// Validate the supplied URL before any legacy thumbnail inference can const response = await fetch(
// remove signed storage transformation parameters. buildImageProxyUrl(originalUrl, { delivery: "proxy" })
buildImageProxyUrl(originalUrl, { delivery: "original" }); );
const parsed = new URL(originalUrl, window.location.origin); if (!response.ok) {
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl); throw new Error(`download failed: ${response.status}`);
}
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") }); const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = buildGalleryDownloadFilename(originalUrl, alt);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
toast.success("图片已开始下载"); toast.success("图片已开始下载");
} catch (error) { } catch (error) {
console.error("Download failed:", error); console.error("Download failed:", error);
toast.error(getCanvasImageDownloadErrorMessage(error)); const fallbackLink = document.createElement("a");
fallbackLink.href = originalUrl;
fallbackLink.target = "_blank";
fallbackLink.rel = "noopener noreferrer";
document.body.appendChild(fallbackLink);
fallbackLink.click();
document.body.removeChild(fallbackLink);
toast.error("下载失败,已为你打开原图,可直接另存为");
} }
}; };
@ -304,12 +317,7 @@ export default function GalleryPage() {
<> <>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="flex flex-wrap justify-center gap-6 px-4"> <div className="flex flex-wrap justify-center gap-6 px-4">
{images.map((img) => isRetiredStorageUrl(img.url) ? ( {images.map((img) => (
<div key={img.id} className="flex h-[270px] w-[200px] shrink-0 flex-col justify-center rounded-lg border bg-muted p-4 text-center text-sm text-muted-foreground">
<p>{RETIRED_STORAGE_MESSAGE}</p>
<p className="mt-3 line-clamp-3 text-xs">{img.alt}</p>
</div>
) : (
<div <div
key={img.id} key={img.id}
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105" className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
@ -318,7 +326,7 @@ export default function GalleryPage() {
> >
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<Image <Image
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url} src={img.thumbnailUrl || img.url}
alt={img.alt} alt={img.alt}
width={200} width={200}
height={270} height={270}

View File

@ -1,10 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin"; import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import { db } from "@/lib/db";
import { aiModelHealthEvent } from "@/lib/db/schema";
import { and, desc, eq, sql } from "drizzle-orm";
import { import {
listAiModelHealthEvents,
restoreAiModelHealth, restoreAiModelHealth,
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
@ -17,30 +15,14 @@ export async function GET(request: Request) {
await requireAdmin(); await requireAdmin();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const modelId = searchParams.get("modelId"); const modelId = searchParams.get("modelId") || undefined;
const providerId = searchParams.get("providerId"); const limit = Number(searchParams.get("limit") || 80);
const rawLimit = Number(searchParams.get("limit") || 20); const events = await listAiModelHealthEvents({
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20; modelId,
const rawPage = Number(searchParams.get("page") || 1); limit: Number.isFinite(limit) ? limit : 80,
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1; });
const filters = [];
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId)); return Response.json({ events });
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
const start = searchParams.get("startDate");
const end = searchParams.get("endDate");
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
const events = await db.select({
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
.limit(limit + 1).offset((page - 1) * limit);
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
return error.toResponse(); return error.toResponse();

View File

@ -1,10 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db";
import { aiModel, aiProvider } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { requireAdmin } from "@/lib/middleware/admin"; import { requireAdmin } from "@/lib/middleware/admin";
import { import {
getAllAiModels,
getAiModelById, getAiModelById,
getAiModelsByProviderId,
createAiModel, createAiModel,
updateAiModel, updateAiModel,
deleteAiModel, deleteAiModel,
@ -155,14 +154,8 @@ export async function GET(request: Request) {
const id = searchParams.get("id"); const id = searchParams.get("id");
const providerId = searchParams.get("providerId"); const providerId = searchParams.get("providerId");
if (searchParams.get("view") === "options") {
const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName,
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
return Response.json({ models: sortModelsForDisplay(models) });
}
if (id) { if (id) {
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) }); const model = await getAiModelById({ id });
if (!model) { if (!model) {
return new ChatSDKError("bad_request:api", "Model not found").toResponse(); return new ChatSDKError("bad_request:api", "Model not found").toResponse();
} }
@ -170,19 +163,12 @@ export async function GET(request: Request) {
} }
if (providerId) { if (providerId) {
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId)); const models = await getAiModelsByProviderId({ providerId });
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) }); return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
} }
const models = await db.select().from(aiModel); const models = await getAllAiModels();
const normalized = models.map(normalizeModel); return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
}) : normalized;
return Response.json({ models: sortModelsForDisplay(output) });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
return error.toResponse(); return error.toResponse();

View File

@ -1,31 +1,237 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage, user } from "@/lib/db/schema"; import { chat, generatedImage, message, user } from "@/lib/db/schema";
import { eq } from "drizzle-orm"; import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors"; function extractVideoUrlFromParts(parts: unknown): string | null {
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records"; const text = Array.isArray(parts)
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db"; ? parts
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : ""))
.join("\n")
: typeof parts === "string"
? parts
: "";
if (!text) return null;
const htmlMatch = text.match(/<video[^>]+src="([^"]+)"/i);
if (htmlMatch?.[1]) return htmlMatch[1];
const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i);
if (markdownMatch?.[1]) return markdownMatch[1];
const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
return plainMatch?.[1] || null;
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const session = await requireAdmin(); const session = await auth();
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (!session?.user?.id) {
const query = parseMediaQuery(request.nextUrl.searchParams); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (query.startDate && query.endDate && query.startDate > query.endDate) {
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
} }
const [result] = await db.execute(mediaRecordsQuery(query));
const images = await resolveArchivedImageUrls(result.records); // 检查是否为管理员
const total = Number(result.total); const currentUser = await db.query.user.findFirst({
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total, where: eq(user.id, session.user.id),
totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } }); });
if (currentUser?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// 获取查询参数
const searchParams = request.nextUrl.searchParams;
const page = Number.parseInt(searchParams.get("page") || "1");
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
const offset = (page - 1) * pageSize;
const search = searchParams.get("search") || "";
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
// 构建查询条件
const imageConditions = [];
const videoConditions = [
eq(message.role, "assistant"),
or(
isNotNull(message.temporaryVideoUrl),
isNotNull(message.permanentVideoUrl),
sql`${message.parts}::text ILIKE '%<video%'`,
sql`${message.parts}::text ILIKE '%.mp4%'`
),
];
if (search) {
// 搜索用户名或邮箱
const matchingUsers = await db
.select({ id: user.id })
.from(user)
.where(or(
like(user.name, `%${search}%`),
like(user.email, `%${search}%`)
));
if (matchingUsers.length > 0) {
const matchedUserIds = matchingUsers.map((u) => u.id);
imageConditions.push(
sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
);
videoConditions.push(
sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
);
} else {
// 没有匹配的用户,返回空结果
return NextResponse.json({
images: [],
pagination: { page, pageSize, total: 0, totalPages: 0 }
});
}
}
if (startDate) {
const start = new Date(startDate);
imageConditions.push(gte(generatedImage.createdAt, start));
videoConditions.push(gte(message.createdAt, start));
}
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
imageConditions.push(lte(generatedImage.createdAt, end));
videoConditions.push(lte(message.createdAt, end));
}
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
// 查询图片总数
const imageCountResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(generatedImage)
.where(imageWhereClause);
// 查询视频总数
const videoCountResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.where(videoWhereClause);
const totalImages = imageCountResult[0]?.count || 0;
const totalVideos = videoCountResult[0]?.count || 0;
const total = totalImages + totalVideos;
const totalPages = Math.ceil(total / pageSize);
const fetchLimit = Math.max(page * pageSize, pageSize);
// 查询图片列表(包含用户信息)
const imageRows = await db
.select({
id: generatedImage.id,
userId: generatedImage.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
thumbnailUrl: generatedImage.thumbnailUrl,
originalUrl: generatedImage.originalUrl,
imageSize: generatedImage.imageSize,
prompt: generatedImage.prompt,
modelId: generatedImage.modelId,
isFeatured: generatedImage.isFeatured,
featuredCategory: generatedImage.featuredCategory,
featuredAt: generatedImage.featuredAt,
createdAt: generatedImage.createdAt,
chatId: generatedImage.chatId,
recordType: sql<"image">`'image'`,
videoUrl: sql<string | null>`null`,
})
.from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id))
.where(imageWhereClause)
.orderBy(desc(generatedImage.createdAt))
.limit(fetchLimit);
// 查询视频列表(来自消息表)
const videoRows = await db
.select({
id: message.id,
userId: chat.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
temporaryVideoUrl: message.temporaryVideoUrl,
permanentVideoUrl: message.permanentVideoUrl,
parts: message.parts,
videoMetadata: message.videoMetadata,
createdAt: message.createdAt,
chatId: message.chatId,
})
.from(message)
.innerJoin(chat, eq(message.chatId, chat.id))
.leftJoin(user, eq(chat.userId, user.id))
.where(videoWhereClause)
.orderBy(desc(message.createdAt))
.limit(fetchLimit);
const normalizedVideos = videoRows
.map((item) => {
const metadata = (item.videoMetadata as Record<string, any> | null) || {};
const videoUrl =
item.permanentVideoUrl ||
item.temporaryVideoUrl ||
extractVideoUrlFromParts(item.parts);
if (!videoUrl) return null;
const resolution =
typeof metadata.resolution === "string" && metadata.resolution.trim()
? metadata.resolution.trim()
: null;
const prompt =
typeof metadata.prompt === "string" && metadata.prompt.trim()
? metadata.prompt.trim()
: null;
const modelId =
typeof metadata.model === "string" && metadata.model.trim()
? metadata.model.trim()
: null;
return {
id: `video-${item.id}`,
sourceMessageId: item.id,
userId: item.userId,
userName: item.userName,
userEmail: item.userEmail,
userDepartment: item.userDepartment,
thumbnailUrl: videoUrl,
originalUrl: videoUrl,
videoUrl,
imageSize: resolution,
prompt,
modelId,
isFeatured: false,
featuredCategory: null,
featuredAt: null,
createdAt: item.createdAt,
chatId: item.chatId,
recordType: "video" as const,
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
const merged = [...imageRows, ...normalizedVideos].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const images = merged.slice(offset, offset + pageSize);
return NextResponse.json({
images,
pagination: { page, pageSize, total, totalPages, totalImages, totalVideos }
});
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) return error.toResponse(); console.error("[Admin Images API] Error:", error);
console.error("[Admin Images API] GET failed", error); return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
} }
} }

View File

@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { sql } from "drizzle-orm";
import { requireAdmin } from "@/lib/middleware/admin";
import { ChatSDKError } from "@/lib/errors";
import { parseMediaQuery } from "@/lib/admin/media-records";
export async function GET(request: NextRequest) {
try {
await requireAdmin();
const q = parseMediaQuery(request.nextUrl.searchParams);
const status = request.nextUrl.searchParams.get("status") || "all";
const filters = [sql`true`];
if (q.search)
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
if (["pending", "running", "failed", "succeeded"].includes(status))
filters.push(sql`t.status=${status}`);
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
const [result] = await db.execute(sql`with filtered as not materialized (
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
) select (select count(*)::int from filtered) as total,
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
return NextResponse.json(result);
} catch (error) {
if (error instanceof ChatSDKError) return error.toResponse();
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
}
}

View File

@ -106,22 +106,6 @@ export async function POST(request: Request) {
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId)) ? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
: catalogPresets; : catalogPresets;
if (body?.dryRun === true) {
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
const updated = presets.flatMap(preset => {
const existing = existingModels.find(model => model.modelId === preset.modelId);
if (!existing) return [];
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
if (existing.avatarUrl) changedFields.push("avatarUrl");
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
});
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
}
const addedModels: AiModel[] = []; const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = []; const updatedModels: AiModel[] = [];
const disabledModels: AiModel[] = []; const disabledModels: AiModel[] = [];

View File

@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { and, desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema"; import { canvasProject } from "@/lib/db/schema";
@ -57,20 +56,14 @@ export async function POST(request: NextRequest) {
} }
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const requestedId = body.id === undefined ? undefined : z.string().uuid().safeParse(body.id);
if (requestedId && !requestedId.success) {
return NextResponse.json({ success: false, error: "项目编号无效" }, { status: 400 });
}
const id = requestedId?.data;
const title = String(body.title || "").trim() || "未命名生图项目"; const title = String(body.title || "").trim() || "未命名生图项目";
const description = String(body.description || "").trim() || null; const description = String(body.description || "").trim() || null;
const thumbnail = String(body.thumbnail || "").trim() || null; const thumbnail = String(body.thumbnail || "").trim() || null;
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState); const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
const [insertedProject] = await db const [project] = await db
.insert(canvasProject) .insert(canvasProject)
.values({ .values({
id,
userId: session.user.id, userId: session.user.id,
title, title,
description, description,
@ -85,22 +78,8 @@ export async function POST(request: NextRequest) {
agentProjectState: projectState, agentProjectState: projectState,
} as any, } as any,
}) })
.onConflictDoNothing({ target: canvasProject.id })
.returning(); .returning();
const project = insertedProject || (id ? (await db
.select()
.from(canvasProject)
.where(and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
))
.limit(1))[0] : undefined);
if (!project) {
return NextResponse.json({ success: false, error: "项目编号不可用,请重试" }, { status: 409 });
}
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) }); return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
} catch (error) { } catch (error) {
console.error("[AgentProjects API] 创建项目失败:", error); console.error("[AgentProjects API] 创建项目失败:", error);

View File

@ -1,8 +0,0 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export function GET() {
return NextResponse.json({ version: process.env.NEXT_PUBLIC_APP_RELEASE || "development" }, {
headers: { "Cache-Control": "no-store, max-age=0" },
});
}

View File

@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
@ -13,6 +12,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"tripo3d.ai", "tripo3d.ai",
"tripo3d.com", "tripo3d.com",
"worldlabs.ai", "worldlabs.ai",
"s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
"scqny.jiansun.vip", "scqny.jiansun.vip",
"blob.vercel-storage.com", "blob.vercel-storage.com",
@ -25,6 +25,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [ const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
"bbyy.ai", "bbyy.ai",
"s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
]; ];
@ -164,9 +165,6 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const rawUrl = String(searchParams.get("url") || "").trim(); const rawUrl = String(searchParams.get("url") || "").trim();
if (isRetiredStorageUrl(rawUrl)) {
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
if (!rawUrl) { if (!rawUrl) {
return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 }); return NextResponse.json({ success: false, error: "缺少 url 参数" }, { status: 400 });
} }
@ -182,18 +180,6 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 }); return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
} }
if (normalizeLegacyAliyunStorageUrl(targetUrl.toString())) {
try {
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
targetUrl = new URL(await resolveLegacyAliyunStorageUrl(targetUrl.toString()));
} catch (error) {
if (error instanceof Error && error.message.includes("HTTP 410")) {
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
throw error;
}
}
if (!isAllowedModelHost(targetUrl.hostname)) { if (!isAllowedModelHost(targetUrl.hostname)) {
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 }); return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
} }

View File

@ -2,14 +2,12 @@ import { NextResponse } from "next/server";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command"; import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context"; import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry"; import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime"; import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store"; import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations"; import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const maxDuration = 600; export const maxDuration = 600;
@ -41,8 +39,7 @@ export async function GET(
{ status: 404 } { status: 404 }
); );
} }
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined; return NextResponse.json({ success: true, task });
return NextResponse.json({ success: true, task: { ...task, failure, error: failure ? formatRequestFailure(failure, task.id) : task.error } });
} }
export async function POST( export async function POST(
@ -54,11 +51,9 @@ export async function POST(
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
} }
let requestId: string | undefined;
try { try {
const { id } = await params; const { id } = await params;
const command = canvasCapabilityClientCommandSchema.parse(await request.json()); const command = canvasCapabilityClientCommandSchema.parse(await request.json());
requestId = command.requestId;
const result = await executeCanvasCapabilityRuntime({ const result = await executeCanvasCapabilityRuntime({
command, command,
projectId: id, projectId: id,
@ -72,9 +67,8 @@ export async function POST(
error instanceof CanvasCapabilityRuntimeError || error instanceof CanvasCapabilityRuntimeError ||
error instanceof CanvasAgentMutationError error instanceof CanvasAgentMutationError
) { ) {
const failure = getRequestFailureDetails(error);
return NextResponse.json( return NextResponse.json(
{ success: false, error: formatRequestFailure(failure, requestId), code: error.code, failure, requestId }, { success: false, error: error.message, code: error.code },
{ status: error.status } { status: error.status }
); );
} }
@ -88,10 +82,10 @@ export async function POST(
{ status: 400 } { status: 400 }
); );
} }
const failure = getRequestFailureDetails(error, "执行画布能力失败"); const message = error instanceof Error ? error.message : "执行画布能力失败";
return NextResponse.json( return NextResponse.json(
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId }, { success: false, error: message },
{ status: error instanceof RmbgServiceError ? error.status : 500 } { status: 500 }
); );
} }
} }

View File

@ -1,65 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { and, eq, inArray, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject, imageArchiveTask, generatedImage, canvasImageTask } from "@/lib/db/schema";
import { imageArchiveSourceKey } from "@/lib/storage/image-archive-db";
import { isGeneratedImageSource } from "@/lib/storage/image-archive-urls";
import { scheduleImageArchiveWorker } from "@/lib/storage/image-archive-worker";
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const { id } = await params;
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)))
.limit(1);
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const body = await request.json();
if (
!Array.isArray(body.urls) ||
body.urls.length > 100 ||
body.urls.some((url: unknown) => typeof url !== "string" || !isGeneratedImageSource(url))
) {
return NextResponse.json({ error: "图片地址格式错误" }, { status: 400 });
}
const urls: string[] = [...new Set<string>(body.urls)];
if (!urls.length) return NextResponse.json({ replacements: {}, pending: [] });
const source = imageArchiveTask.sourceUrl;
const rows = await db
.select({
sourceUrl: imageArchiveTask.sourceUrl,
targetUrl: imageArchiveTask.targetUrl,
status: imageArchiveTask.status,
})
.from(imageArchiveTask)
.where(
and(
inArray(imageArchiveTask.sourceUrl, urls.map(imageArchiveSourceKey)),
sql`(
exists (select 1 from ${generatedImage} where ${generatedImage.userId} = ${session.user.id}
and (${generatedImage.originalUrl} = ${source} or ${generatedImage.thumbnailUrl} = ${source}
or ${generatedImage.originalUrl} = ${imageArchiveTask.targetUrl} or ${generatedImage.thumbnailUrl} = ${imageArchiveTask.targetUrl}))
or exists (select 1 from ${canvasImageTask} where ${canvasImageTask.userId} = ${session.user.id}
and (position(${source} in ${canvasImageTask.result}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasImageTask.result}::text) > 0))
or exists (select 1 from ${canvasProject} where ${canvasProject.id} = ${id} and ${canvasProject.userId} = ${session.user.id}
and (position(${source} in ${canvasProject.canvasData}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasProject.canvasData}::text) > 0))
)`,
),
);
const byKey = new Map(rows.map((row) => [row.sourceUrl, row]));
const replacements: Record<string, string> = {};
const pending: string[] = [];
for (const url of urls) {
const row = byKey.get(imageArchiveSourceKey(url));
if (row?.targetUrl) replacements[url] = row.targetUrl;
else if (row && row.status !== "abandoned") pending.push(url);
}
if (rows.some((row) => row.status === "pending")) scheduleImageArchiveWorker(true);
return NextResponse.json(
{ replacements, pending },
{ headers: { "Cache-Control": "private, no-store" } },
);
}

View File

@ -3,7 +3,6 @@ import { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema'; import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries'; import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { import {
buildStaleCanvasSaveConflictPayload, buildStaleCanvasSaveConflictPayload,
@ -123,8 +122,7 @@ export async function PUT(
); );
} }
const { title, description, canvasData } = body; const { title, description, thumbnail, canvasData } = body;
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
const baseCanvasRevision = const baseCanvasRevision =
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision; body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType = const workspaceType =
@ -207,11 +205,7 @@ export async function PUT(
), ),
) )
.returning(); .returning();
return NextResponse.json({ return NextResponse.json({ success: true, data: saved });
success: true,
data: saved,
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
});
}); });
} catch (error) { } catch (error) {
console.error("更新/创建画布失败:", error); console.error("更新/创建画布失败:", error);

View File

@ -1,40 +0,0 @@
import { PROJECT_AGENT_OPERATIONS, projectOperationAuthorized } from "@/lib/ai/canvas-agent-runtime/project-operation-catalog";
import { NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { getSystemSetting } from "@/lib/db/queries";
import {
normalizeVideoCanvasFunctionConfig,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
} from "@/lib/video-canvas/config";
import { getCanvasAgentAccessManifest } from "@/lib/ai/canvas-agent-runtime/capability-access";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const parsed = z.string().uuid().safeParse(new URL(request.url).searchParams.get("projectId"));
if (!parsed.success) return NextResponse.json({ error: "画布编号无效" }, { status: 400 });
const [project] = await db
.select({ id: canvasProject.id, workspace: canvasProject.workspaceType })
.from(canvasProject)
.where(and(eq(canvasProject.id, parsed.data), eq(canvasProject.userId, session.user.id)))
.limit(1);
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const config = normalizeVideoCanvasFunctionConfig(
(await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }))?.value,
);
return NextResponse.json(
{
workspace: project.workspace,
projectServices: PROJECT_AGENT_OPERATIONS.map(item => ({ ...item, authorized: projectOperationAuthorized(item) })),
utilityTools: ["getWeather", "createDocument", "updateDocument", "requestSuggestions", "loadProjectSkill"],
capabilities: getCanvasAgentAccessManifest(config).filter(
(item) => item.workspace === (project.workspace === "video" ? "video" : "canvas"),
),
},
{ headers: { "Cache-Control": "private, no-store" } },
);
}

View File

@ -1,491 +0,0 @@
import { agentModelCandidates } from "@/lib/ai/runtime/agent-model-failover";
import { agentModelCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
import { createAgentVisionExecutor } from "@/lib/ai/runtime/agent-vision-failover";
import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
import { registerProjectAgentTools } from "@/lib/ai/canvas-agent-runtime/project-tools";
import { isZcodeWorkspaceAuthorized } from "@/lib/ai/canvas-agent-runtime/authorization";
import {
createUIMessageStream,
createUIMessageStreamResponse,
generateText,
} from "ai";
import { after, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { siteSettings, canvasProject } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { postRequestBodySchema } from "@/app/(chat)/api/chat/schema";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
getAgentAiModelWithProvider,
getAccessibleAgentPresetById,
getEnabledAiModelsWithProvider,
getChatById,
getMessagesByChatId,
getMessageCountByUserId,
getSiteSettings,
saveChat,
saveMessages,
} from "@/lib/db/queries";
import { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task";
import { canvasAgentReplyText } from "@/lib/ai/zcode-agent/assistant-output";
import { createZcodeModelGateway } from "@/lib/ai/zcode-agent/model-gateway";
import { newAgentRunSnapshot, publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import {
acquireAgentRun,
agentRunView,
AgentRunError,
checkpointAgentRun,
consumeAgentRunInputs,
heartbeatAgentRun,
} from "@/lib/ai/canvas-agent-runtime/store";
import { readOwnedCanvas } from "@/lib/ai/canvas-agent-runtime/canvas";
import { buildCanvasRuntimeTools } from "@/lib/ai/canvas-agent-runtime/tools";
import { canvasRuntimeSystemPrompt } from "@/lib/ai/canvas-agent-runtime/prompt";
import { verifyCanvasAgentResults } from "@/lib/ai/canvas-agent-runtime/review";
import { inferCanvasTaskRequirements } from "@/lib/ai/canvas-agent-runtime/requirements";
import {
resolveRuntimeConversationSources,
persistRuntimeConversationResults,
} from "@/lib/ai/canvas-agent-runtime/conversation";
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
import { canApplyCanvasAgentMutations } from "@/lib/canvas/agent-feature-flags";
import {
videoAgentSettingsSchema,
inferVideoTaskRequirements,
videoNodeUrl,
videoRuntimeSystemPrompt,
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
import { readOwnedVideoCanvas } from "@/lib/ai/canvas-agent-runtime/video-store";
import {
buildVideoRuntimeTools,
createVideoRuntimePorts,
} from "@/lib/ai/canvas-agent-runtime/video-tools";
import { probeVideoMetadata } from "@/lib/ai/canvas-agent-runtime/video-probe";
import { verifyVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-review";
export const runtime = "nodejs";
export const maxDuration = 1200;
const requestSchema = postRequestBodySchema.extend({
resumeRunId: z.string().uuid().optional(),
selectedImageModel: z.string().min(1).max(180).optional(),
workspaceVariant: z.enum(["canvas", "video"]).default("canvas"),
videoAgentSettings: videoAgentSettingsSchema.optional(),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = requestSchema.parse(await request.json());
if (!shouldUseCanvasAgentRuntime(body))
throw new AgentRunError("此请求不属于画布 Agent 任务", 400);
const projectId = body.contextPayload!.projectId!;
const [ownedProject] = await db.select({ userId: canvasProject.userId, workspaceType: canvasProject.workspaceType })
.from(canvasProject).where(eq(canvasProject.id, projectId)).limit(1);
if (!ownedProject || ownedProject.userId !== session.user.id) throw new AgentRunError("画布不存在", 404);
if (!isZcodeWorkspaceAuthorized(ownedProject.workspaceType)) throw new AgentRunError("视频画布暂未授权给 ZCode Agent,请使用手工功能。", 403);
const isVideo = ownedProject.workspaceType === "video";
const preset = body.agentPresetId ? await getAccessibleAgentPresetById({ id: body.agentPresetId, userId: session.user.id }) : null;
if (preset && String(preset.type) === "video") throw new AgentRunError("视频技能暂未授权给 ZCode Agent", 403);
if (body.agentPresetId && (!preset || preset.status !== "active")) throw new AgentRunError("此 Agent 预设不可用", 403);
const objective = body.message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
if (!objective) throw new AgentRunError("请描述需要完成的创作任务", 400);
const settings = await getSiteSettings();
if (
!settings.agentEnabled ||
!canApplyCanvasAgentMutations(await getCanvasAgentFeatureFlagsFromSystemSettings())
) {
throw new AgentRunError("管理员尚未开启画布 Agent 操作", 403);
}
const [usageSettings] = await db.select().from(siteSettings).limit(1);
if (usageSettings?.usageTimeEnabled) {
const time = new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(new Date());
if (
time < (usageSettings.usageTimeStart || "08:00") ||
time > (usageSettings.usageTimeEnd || "20:00")
)
throw new AgentRunError(usageSettings.usageTimeMessage || "当前不在系统使用时间段内", 403);
}
const count = await getMessageCountByUserId({ id: session.user.id, differenceInHours: 24 });
if (count > entitlementsByUserType[session.user.type].maxMessagesPerDay)
throw new AgentRunError("今日对话次数已达到限额", 429);
const imageCanvas = isVideo ? null : await readOwnedCanvas(session.user.id, projectId);
if (imageCanvas?.project.workspaceType === "video")
throw new AgentRunError("请使用视频画布 Agent 入口", 400);
const videoCanvas = isVideo ? await readOwnedVideoCanvas(session.user.id, projectId) : null;
const nodes = imageCanvas?.nodes || [];
const availableIds = isVideo
? videoCanvas!.nodes.map((node) => node.id)
: nodes.map((node) => node.id);
const selectedNodeIds = body.contextPayload?.selectedNodeIds || [];
if (selectedNodeIds.some((id) => !availableIds.includes(id))) {
throw new AgentRunError("选中图片尚未保存到画布,请等待保存完成后重试。");
}
const existingChat = await getChatById({ id: body.id });
if (existingChat && existingChat.userId !== session.user.id)
throw new AgentRunError("对话不存在", 404);
if (!existingChat)
await saveChat({
id: body.id,
userId: session.user.id,
title: objective.slice(0, 80),
visibility: "private",
});
const conversation = isVideo
? {
sourceNodeIds: selectedNodeIds,
targetReason: "video-selected-nodes",
previousContext: null,
}
: await resolveRuntimeConversationSources({
userId: session.user.id,
chatId: body.id,
projectId,
text: objective,
parts: body.message.parts,
nodes,
selectedNodeIds,
});
const candidates = agentModelCandidates(await getAgentAiModelWithProvider(), await getEnabledAiModelsWithProvider());
const configured = candidates[0];
if (!configured) throw new AgentRunError("请先在后台配置可用的多模态 Agent 对话模型", 503);
let model = await getLanguageModelById(configured.model.id);
for (const candidate of candidates.slice(1)) {
if (model) break;
model = await getLanguageModelById(candidate.model.id);
}
if (!model) throw new AgentRunError("Agent 模型暂不可用", 503);
const visionModel = model;
const executeVisionModel = createAgentVisionExecutor(candidates, agentModelCircuitStore, getLanguageModelById);
// Validate the user's image choice independently of the Agent planning model.
if (body.selectedImageModel) {
const images = await getStyleProImageModelEntries();
if (!images.some(({ model }) => model.id === body.selectedImageModel && model.healthStatus !== "auto_disabled"))
throw new AgentRunError("你选择的生图模型当前不可用,请重新选择;不会自动换模型生成。", 400);
}
const assistantMessageId = crypto.randomUUID();
const previousVideoMessages =
existingChat
? (await getMessagesByChatId({ id: body.id })).slice(-6).map((message) => ({
role: message.role as "user" | "assistant",
content: (message.parts as Array<{ type: string; text?: string }>)
.filter((part) => part.type === "text")
.map((part) => part.text || "")
.join("\n")
.slice(0, 4000),
}))
: [];
const initialSnapshot = newAgentRunSnapshot(objective, [
...previousVideoMessages,
{ role: "user", content: objective },
]);
initialSnapshot.workspace = isVideo ? "video" : "canvas";
if (isVideo) {
initialSnapshot.videoRequirements = inferVideoTaskRequirements(objective);
initialSnapshot.videoSettings = videoAgentSettingsSchema.parse({
...body.videoAgentSettings,
...(initialSnapshot.videoRequirements.duration
? { duration: initialSnapshot.videoRequirements.duration }
: {}),
...(initialSnapshot.videoRequirements.ratio
? { ratio: initialSnapshot.videoRequirements.ratio }
: {}),
});
}
initialSnapshot.sourceNodeIds = conversation.sourceNodeIds;
initialSnapshot.requirements = {
...inferCanvasTaskRequirements(objective, initialSnapshot.sourceNodeIds.length),
sourceCount: initialSnapshot.sourceNodeIds.length,
};
const run = await acquireAgentRun({
id: body.message.id,
userId: session.user.id,
projectId,
chatId: body.id,
assistantMessageId,
resumeRunId: body.resumeRunId,
snapshot: initialSnapshot,
});
const snapshot = run.snapshot;
snapshot.imagePreferences = {
...snapshot.imagePreferences,
// Each submitted turn captures the current controls, including switching back to Auto.
modelId: body.selectedImageModel,
parameters: body.imageParams || snapshot.imagePreferences?.parameters,
};
const taskSourceNodeIds = snapshot.sourceNodeIds ?? selectedNodeIds;
const context = {
recentConversation: previousVideoMessages,
preset: preset ? { name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId, defaultParams: preset.defaultParams, slots: preset.slots.map(slot => ({ title: slot.title, promptTemplate: slot.promptTemplate, defaultParams: slot.defaultParams })) } : null,
videoSettings: snapshot.videoSettings,
selectedNodeIds: taskSourceNodeIds,
nodes: nodes
.filter((node) => taskSourceNodeIds.includes(node.id))
.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
size: node.size,
title: node.data.title,
})),
skillInstructions: body.hiddenPrompt || undefined,
imagePreferences: snapshot.imagePreferences,
targetReason: conversation.targetReason,
previousConversationContext: conversation.previousContext,
};
await saveMessages({
messages: [
{
id: body.message.id,
chatId: body.id,
role: "user",
parts: body.message.parts,
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
let archiveVideoResults: (() => Promise<void>) | undefined;
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const emit = (type: string, data: unknown) => writer.write({ type, data, ...(type === "data-canvasAgentRun" ? { transient: true } : {}) } as never);
let cancelled = false;
let leaseLost = false;
const activeModelController = new AbortController();
const withModel: typeof executeVisionModel = (execute, signal) => executeVisionModel(execute,
signal ? AbortSignal.any([signal, activeModelController.signal]) : activeModelController.signal);
const heartbeat = setInterval(() => {
void heartbeatAgentRun(run)
.then((current) => {
if (!current) leaseLost = true;
if (current?.cancelRequested) cancelled = true;
if (cancelled || leaseLost) activeModelController?.abort();
})
.catch(() => {
leaseLost = true;
activeModelController?.abort();
});
emit("data-keepalive", { label: "canvas-agent-runtime", ts: Date.now() });
}, 10_000);
const textId = crypto.randomUUID();
writer.write({ type: "start", messageId: assistantMessageId });
writer.write({ type: "text-start", id: textId });
let streamedText = "";
const emitAssistantText = (delta: string) => {
streamedText += delta;
writer.write({ type: "text-delta", id: textId, delta });
};
try {
const videoPorts = isVideo
? await createVideoRuntimePorts({
userId: session.user.id,
chatId: body.id,
projectId,
runId: run.id,
assistantMessageId,
request,
snapshot,
emit,
})
: null;
archiveVideoResults = videoPorts?.archiveResults;
const tools = isVideo
? buildVideoRuntimeTools({
runId: run.id,
projectId,
selectedNodeIds,
snapshot,
ports: videoPorts!,
})
: await buildCanvasRuntimeTools({
userId: session.user.id,
chatId: body.id,
projectId,
runId: run.id,
assistantMessageId,
selectedNodeIds: taskSourceNodeIds,
request,
model: visionModel || model,
supportsVision: Boolean(visionModel),
withModel,
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
imagePreferences: snapshot.imagePreferences,
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
operationSelectedNodeIds: selectedNodeIds,
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
"\n",
),
emit,
});
await registerProjectAgentTools({ session, projectId, request, objective: snapshot.objective,
definitions: tools.definitions, executable: tools.executable, emit,
imagePreferences: snapshot.imagePreferences });
const gateway = await createZcodeModelGateway(configured, undefined, candidates);
const status = await runZcodeCanvasTask({
runId: run.id, snapshot, definitions: tools.definitions,
instructions: (isVideo ? videoRuntimeSystemPrompt : canvasRuntimeSystemPrompt)(snapshot.objective, snapshot.summary, snapshot.inputs.map(input => input.text), context),
context, gateway, signal: activeModelController.signal,
onAssistantText: emitAssistantText,
tools: tools.executable,
checkpoint: async (state, stateStatus) => {
if (leaseLost) throw new AgentRunError("任务执行已中断");
const saved = await checkpointAgentRun(run, state, stateStatus);
cancelled ||= saved.cancelRequested;
emit("data-canvasAgentRun", agentRunView(saved));
},
consumeInputs: async (state) => {
if (leaseLost) throw new AgentRunError("任务执行已中断");
const control = await consumeAgentRunInputs(run, state);
cancelled ||= control.cancelled;
return { cancelled };
},
isCancelled: async () => {
const current = await heartbeatAgentRun(run);
if (!current) throw new AgentRunError("任务执行已中断");
cancelled ||= current.cancelRequested;
return cancelled;
},
verify: async (state) =>
isVideo
? verifyVideoAgentResults({
snapshot: state,
nodes: (await readOwnedVideoCanvas(session.user.id, projectId)).nodes,
inspect: async (node, requirements) => {
const metadata = await probeVideoMetadata(videoNodeUrl(node));
const report = await videoPorts!.analyze({
mode: "video-analysis",
prompt: `请基于真实抽帧画面检查主体、场景、镜头连贯性,说明不确定内容。用户要求:${requirements}`,
imageUrls: [],
videoUrls: [videoNodeUrl(node)],
});
const verdict = await generateText({
model,
system:
'你是视频验收员。依据实际抽帧分析判断是否满足用户要求;不能从文件名或提示词判断。若报告无法读取真实帧,必须不通过。抽帧只能检查画面,不能证明音频、精确时长或完整运动,涉及这些要求无法确认时说明需要人工检查。仅返回 JSON {"passed":boolean,"issues":string[]}。',
prompt: JSON.stringify({ requirements, report }),
maxOutputTokens: 1200,
maxRetries: 0,
abortSignal: AbortSignal.timeout(60_000),
});
state.inputTokens += verdict.usage.inputTokens || 0;
state.outputTokens += verdict.usage.outputTokens || 0;
const match = verdict.text.match(/\{[\s\S]*\}/);
if (!match) throw new Error("视频检查未返回有效结果");
return {
...z
.object({ passed: z.boolean(), issues: z.array(z.string()).max(12) })
.parse(JSON.parse(match[0])),
...metadata,
};
},
})
: verifyCanvasAgentResults({
snapshot: state,
userId: session.user.id,
projectId,
model: visionModel || model,
supportsVision: Boolean(visionModel),
withModel,
signal: activeModelController.signal,
}),
});
const finalText = canvasAgentReplyText(snapshot);
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot, status }));
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
if (!isVideo)
await persistRuntimeConversationResults({
userId: session.user.id,
chatId: body.id,
projectId,
assistantMessageId,
snapshot,
}).catch((error) => {
console.warn("[Canvas Agent] Conversation memory update failed", {
runId: run.id,
error: publicAgentError(error),
});
});
await saveMessages({
messages: [
{
id: assistantMessageId,
chatId: body.id,
role: "assistant",
parts: [{ type: "text", text: finalText }, { type: "data-canvasAgentProgress", data: progress }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
emitAssistantText(finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : `\n\n${snapshot.message}`);
} catch (error) {
const statusText = publicAgentError(error);
const text = canvasAgentReplyText({ ...snapshot, message: statusText });
await checkpointAgentRun(run, { ...snapshot, message: statusText }, "needs_review").catch(
() => undefined,
);
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot: { ...snapshot, message: statusText }, status: "needs_review" }));
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
await saveMessages({ messages: [{ id: assistantMessageId, chatId: body.id, role: "assistant",
parts: [{ type: "text", text }, { type: "data-canvasAgentProgress", data: progress }],
attachments: [], createdAt: new Date(), videoStorageStatus: null, temporaryVideoUrl: null,
permanentVideoUrl: null, archivedAt: null, videoMetadata: null }] }).catch(() => undefined);
emitAssistantText(text.startsWith(streamedText) ? text.slice(streamedText.length) : `\n\n${statusText}`);
} finally {
clearInterval(heartbeat);
writer.write({ type: "text-end", id: textId });
writer.write({ type: "finish" });
}
},
});
// Browser disconnection does not cancel paid image work. Keep consuming on the server.
const [client, background] = stream.tee();
after(async () => {
const reader = background.getReader();
try {
while (!(await reader.read()).done) {
/* Drain the server branch. */
}
} finally {
reader.releaseLock();
await archiveVideoResults?.();
}
});
return createUIMessageStreamResponse({
stream: client,
headers: { "Cache-Control": "no-store", "X-Accel-Buffering": "no" },
});
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error), message: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}

View File

@ -1,65 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
AgentRunError,
agentRunView,
assertAgentRunOwnership,
controlAgentRun,
getAgentRun,
} from "@/lib/ai/canvas-agent-runtime/store";
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
export const dynamic = "force-dynamic";
const scope = z.object({
chatId: z.string().uuid(),
projectId: z.string().uuid(),
runId: z.string().uuid().optional(),
});
const control = scope
.extend({
runId: z.string().uuid(),
action: z.enum(["steer", "cancel"]),
inputId: z.string().uuid().optional(),
text: z.string().trim().min(1).max(2000).optional(),
})
.refine(
(input) => input.action !== "steer" || Boolean(input.inputId && input.text),
"补充要求缺少内容或请求号",
);
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const input = scope.parse(Object.fromEntries(new URL(request.url).searchParams));
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
const run = await getAgentRun(session.user.id, input.chatId, input.projectId, input.runId);
return NextResponse.json(
{ run: run ? agentRunView(run) : null },
{ headers: { "Cache-Control": "private, no-store" } },
);
} catch (error) {
return failure(error);
}
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const input = control.parse(await request.json());
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
return NextResponse.json({ run: await controlAgentRun({ ...input, userId: session.user.id }) });
} catch (error) {
return failure(error);
}
}
function failure(error: unknown) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}

View File

@ -1,15 +1,100 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout"; import {
BoxCutoutServiceError,
executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 60; export const maxDuration = 60;
export async function POST(request: Request) { type BoxCutoutRequest = {
imageUrl?: string;
cropRect?: {
x?: number;
y?: number;
width?: number;
height?: number;
};
paddingRatio?: number;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
skipGallerySave?: boolean;
sourceNodeId?: string;
};
export async function POST(request: NextRequest) {
try {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 }); return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
} }
return executeBoxCutoutPOST(request, session.user.id);
const body = (await request.json()) as BoxCutoutRequest;
const rawImageUrl = String(body.imageUrl || "").trim();
if (!rawImageUrl) {
return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 });
}
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
reason: "image_box_cutout",
modelId: "box-cutout",
note: "框选抠图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeBoxCutoutJob({
userId: session.user.id,
imageUrl: resolvedImageUrl,
cropRect: body.cropRect || {},
paddingRatio: body.paddingRatio,
prompt: body.prompt || "框选抠图",
chatId: body.chatId || null,
messageId: body.messageId || null,
skipGallerySave: body.skipGallerySave === true,
sourceNodeId: body.sourceNodeId,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume box cutout points:", pointsError);
}
}
return NextResponse.json(result);
} catch (error) {
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
return NextResponse.json(
{
success: false,
error: error.detail || error.message,
},
{ status: error.status },
);
}
console.error("[BoxCutout] 处理失败:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "框选抠图失败",
},
{ status: 500 },
);
}
} }

View File

@ -1,13 +1,5 @@
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download"; import { NextRequest, NextResponse } from "next/server";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
import { randomUUID } from "node:crypto";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger";
import { after, NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
import { import {
buildAttachmentContentDisposition, buildAttachmentContentDisposition,
fetchCanvasDownloadAsset, fetchCanvasDownloadAsset,
@ -16,50 +8,6 @@ import {
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 120; export const maxDuration = 120;
const logger = createLogger("canvas.download");
function failed(failure: DownloadFailure, requestId: string) {
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
status: failure.status,
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
});
}
function preparing(requestId: string) {
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
message: "正在准备原图,完成后将自动开始下载" }, {
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
});
}
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
// A proxy candidate for the same original must not repeat the same slow
// upstream request through our own server (and create another HTTP timeout).
const sources = [...new Set(input.urls.map(url => {
const source = extractImageProxySourceUrl(url) || url;
return inferOriginalImageUrl(source) || source;
}))];
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt++) {
signal.throwIfAborted();
for (const url of sources) {
signal.throwIfAborted();
const result = await fetchCanvasDownloadAsset({
file: { url, name: "original" }, index: 0,
requestOrigin: input.requestOrigin, cookie: input.cookie,
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal,
});
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
lastError = result.error;
}
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
// No AI/model request is involved and a completed source fetch is shared.
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
else break;
}
throw new Error(String(lastError || "Original image sources unavailable"));
}
function getRequestOrigin(request: NextRequest): string { function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
@ -98,63 +46,21 @@ function resolveDownloadUrls(body: Record<string, unknown>, origin: string): str
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = randomUUID();
try { try {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return failed(getDownloadFailure("未登录"), requestId); return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
} }
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request); const requestOrigin = getRequestOrigin(request);
const candidates = resolveDownloadUrls(body, requestOrigin); const urls = resolveDownloadUrls(body, requestOrigin);
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
}
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
const requestedUrls = [...urls];
if (urls.length === 0) { if (urls.length === 0) {
return failed(getDownloadFailure("图片地址无效"), requestId); return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
} }
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image"); const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
if (body.preferDirect === true) {
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
logger.warn("cdn_lookup_failed", error, { requestId });
return { task: null, direct: null, canPrepareDirect: false };
});
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
if (task && canPrepareDirect && body.preparation === "async") {
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
return failed(asset.failure || getDownloadFailure(null), task.id);
}
scheduleImageDownloadPreparation(task.id);
return preparing(task.id);
}
if (task?.targetUrl) {
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
scheduleImageDownloadPreparation(task.id);
}
}
const cookie = request.headers.get("cookie") || ""; const cookie = request.headers.get("cookie") || "";
if (body.preparation === "async") {
const result = await getDownloadPreparationQueue(prepareDownload, after).request({
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
});
if (result.status === "preparing") {
return preparing(result.requestId);
}
if (result.status === "failed") {
return failed(result.failure, result.requestId);
}
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
"Content-Type": result.asset.contentType,
"Content-Length": String(result.asset.buffer.length),
"Content-Disposition": buildAttachmentContentDisposition(`${name}.png`),
"Cache-Control": "no-store",
} });
}
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null; let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
const failures: string[] = []; const failures: string[] = [];
@ -167,14 +73,17 @@ export async function POST(request: NextRequest) {
includeSequencePrefix: false, includeSequencePrefix: false,
}); });
if (result.ok) break; if (result.ok) break;
failures.push(result.error); failures.push(`${url}: ${result.error}`);
} }
if (!result?.ok) { if (!result?.ok) {
if (failures.length > 0) { if (failures.length > 0) {
logger.warn("all_sources_failed", { requestId, failures }); console.warn("[CanvasDownloadImage] All candidates failed:", failures);
} }
return failed(getDownloadFailure(result?.error), requestId); return NextResponse.json(
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
{ status: 502 }
);
} }
return new NextResponse(result.bytes, { return new NextResponse(result.bytes, {
@ -187,7 +96,7 @@ export async function POST(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
logger.error("download_failed", error, { requestId }); console.error("[CanvasDownloadImage] Failed:", error);
return failed(getDownloadFailure(error), requestId); return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
} }
} }

View File

@ -1,24 +1,54 @@
import { writeDownloadZip, readDownloadZip } from "@/lib/canvas/download-zip-stream";
import { claimZipTask, loadZipTask, saveZipTask, zipTaskPath, assertZipDiskSpace, cleanupZipTasks, assertZipTaskCapacity, type ZipTask } from "@/lib/canvas/download-zip-task-store";
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
import { createReadStream } from "node:fs";
import { mkdir, stat, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { after, NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import JSZip from "jszip"; import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
import { trackRuntimeWork } from "@/lib/observability/active-work";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 300; export const maxDuration = 300;
type DownloadFileItem = { url: string; name?: string };
type DownloadFileItem = {
url: string;
name?: string;
};
type ZipTaskStatus = "pending" | "running" | "completed" | "failed";
type ZipTask = {
id: string;
userId: string;
folderName: string;
files: DownloadFileItem[];
totalCount: number;
processedCount: number;
successCount: number;
failedCount: number;
compressPercent: number;
status: ZipTaskStatus;
stage: string;
error?: string;
zipBuffer?: Buffer;
contentDisposition?: string;
createdAt: number;
updatedAt: number;
expiresAt: number;
};
const MAX_CONCURRENCY = 8;
const MAX_DOWNLOAD_FILES = 200; const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 24 * 60 * 60_000; const TASK_TTL_MS = 30 * 60 * 1000;
const PREPARATION_TTL_MS = 6 * 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> }); const globalTaskStore = globalThis as typeof globalThis & {
const active = running.__canvasZipRunning ??= new Set<string>(); __canvasDownloadZipTasks?: Map<string, ZipTask>;
};
function getTaskStore() {
if (!globalTaskStore.__canvasDownloadZipTasks) {
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
}
return globalTaskStore.__canvasDownloadZipTasks;
}
function sanitizeFolderName(value: string): string { function sanitizeFolderName(value: string): string {
const cleaned = String(value || "") const cleaned = String(value || "")
.replace(/[\\/:*?"<>|]+/g, "-") .replace(/[\\/:*?"<>|]+/g, "-")
@ -26,7 +56,7 @@ function sanitizeFolderName(value: string): string {
.replace(/-+/g, "-") .replace(/-+/g, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
.trim(); .trim();
return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`; return cleaned || `canvas-images-${Date.now()}`;
} }
function buildContentDisposition(folderName: string): string { function buildContentDisposition(folderName: string): string {
@ -80,172 +110,258 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
.slice(0, MAX_DOWNLOAD_FILES); .slice(0, MAX_DOWNLOAD_FILES);
} }
function cleanupExpiredTasks() {
const now = Date.now();
const store = getTaskStore();
for (const [taskId, task] of store.entries()) {
if (task.expiresAt <= now) {
store.delete(taskId);
}
}
}
function getTaskProgress(task: ZipTask) {
if (task.status === "completed") {
return 100;
}
if (task.status === "failed") {
return Math.max(
0,
Math.min(100, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
);
}
if (task.processedCount >= task.totalCount && task.totalCount > 0) {
return Math.min(99, 85 + Math.round((task.compressPercent / 100) * 14));
}
return Math.max(
4,
Math.min(85, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
);
}
function serializeTask(task: ZipTask) { function serializeTask(task: ZipTask) {
return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage, const downloadUrl =
totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount, task.status === "completed"
failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99, ? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1`
Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)), : "";
error: task.error || "", expiresAt: task.expiresAt, return {
downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" }; id: task.id,
folderName: task.folderName,
status: task.status,
stage: task.stage,
totalCount: task.totalCount,
processedCount: task.processedCount,
successCount: task.successCount,
failedCount: task.failedCount,
progress: getTaskProgress(task),
error: task.error || "",
downloadUrl,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
expiresAt: task.expiresAt,
};
} }
async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) {
if (active.has(id) || active.size >= 2) return; async function processZipTask(params: {
active.add(id); task: ZipTask;
let release: (() => Promise<void>) | null = null; requestOrigin: string;
let task: ZipTask | null = null; cookie: string;
}) {
const { task, requestOrigin, cookie } = params;
const store = getTaskStore();
try { try {
release = await claimZipTask(id); task.status = "running";
if (!release) return; task.stage = "正在读取源图";
task = await loadZipTask(id, userId); task.updatedAt = Date.now();
if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace(); const zip = new JSZip();
const existingZip = task.zipFile && task.contentDisposition && await stat(task.zipFile.path).catch(() => null); const folder = zip.folder(task.folderName) || zip;
const reuseZip = existingZip && existingZip.size === task.zipFile!.bytes; const results: Array<
task.status = "running"; task.stage = reuseZip ? "正在准备加速下载" : "正在准备原图"; task.updatedAt = Date.now(); | Awaited<ReturnType<typeof fetchCanvasDownloadAsset>>
await saveZipTask(task); | null
if (!reuseZip) { > = new Array(task.files.length).fill(null);
delete task.zipFile; delete task.contentDisposition; delete task.cdnUrl;
// Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session.
let cursor = 0; let cursor = 0;
let writes = Promise.resolve(); const workerCount = Math.min(MAX_CONCURRENCY, task.files.length);
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => { await Promise.all(
while (cursor < task!.files.length) { Array.from({ length: workerCount }, async () => {
const index = cursor++; const file = task!.files[index]; while (true) {
const path = join(zipTaskPath(id), `${index}.image`); const index = cursor;
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue; cursor += 1;
delete file.prepared; delete file.filename; if (index >= task.files.length) break;
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" })); const file = task.files[index]!;
if (result.ok) { results[index] = await fetchCanvasDownloadAsset({
await assertZipDiskSpace(result.bytes.length); file,
await writeFile(path, result.bytes, { mode: 0o600 }); index,
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error; requestOrigin,
} else file.error = result.error; cookie,
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
task!.successCount = task!.files.filter(f => f.prepared).length;
task!.failedCount = task!.files.filter(f => f.error).length;
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
task!.updatedAt = Date.now(); await persist();
}
}));
const rejected = results.find(result => result.status === "rejected");
if (rejected?.status === "rejected") throw rejected.reason;
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
for (const file of task.files) if (file.prepared && file.filename)
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName);
}
task.stage = "正在准备下载链接"; await saveZipTask(task);
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!,
onProgress: async bytes => {
task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
task!.updatedAt = Date.now(); await saveZipTask(task!);
} });
if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
task.cdnUrl = cdn.url;
}
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes.
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
bytes: task.zipFile!.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
} catch (error) {
if (task) {
task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
}
console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error });
} finally { await release?.().catch(() => undefined); active.delete(id); }
}
function schedule(task: ZipTask, request: NextRequest) {
const run = () => trackRuntimeWork(() => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""));
after(run);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = await request.json();
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
let task = await loadZipTask(id, session.user.id);
if (!task) {
await assertZipTaskCapacity(session.user.id);
if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
const files = toDownloadItems(body.files, getRequestOrigin(request));
if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length)
return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 });
// Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest.
await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
if (error.code !== "ENOENT") throw error;
await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
await mkdir(zipTaskPath(id), { mode: 0o700 });
}); });
const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files, const result = results[index];
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0, task.processedCount += 1;
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + PREPARATION_TTL_MS }; if (result?.ok) {
await saveZipTask(task); task.successCount += 1;
} else if ((task.status === "failed" || task.status === "completed" && task.cdnObject && !task.cdnUrl) && body.retry === true) { } else {
task.status = "pending"; task.error = undefined; task.failedCount += 1;
await assertZipTaskCapacity(session.user.id);
if (!task.zipFile) {
for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0;
} }
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS; task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`;
await saveZipTask(task); task.updatedAt = Date.now();
} }
if (task.status === "pending" || task.status === "running") schedule(task, request); })
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); })); );
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
const failures: Array<{ index: number; url: string; name?: string; error: string }> = [];
for (const [index, item] of results.entries()) {
if (!item || !item.ok) {
failures.push({
index: index + 1,
url: item?.ok === false ? item.url : task.files[index]?.url || "",
name: item?.ok === false ? item.name : task.files[index]?.name,
error: item?.ok === false ? item.error : "未知错误",
});
continue;
}
folder.file(item.filename, item.bytes);
}
if (failures.length > 0) {
folder.file(
"_download-failures.txt",
[
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。",
"",
...failures.map((item) => {
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`;
return `${label}\nURL: ${item.url}\n原因: ${item.error}`;
}),
].join("\n\n")
);
console.warn("[CanvasDownloadZipTask] Partial download failures:", failures);
}
task.stage = "正在生成 ZIP 文件";
task.updatedAt = Date.now();
task.zipBuffer = await zip.generateAsync(
{
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
},
(metadata) => {
task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0));
task.updatedAt = Date.now();
}
);
task.contentDisposition = buildContentDisposition(task.folderName);
task.status = "completed";
task.stage = "打包完成";
task.compressPercent = 100;
task.updatedAt = Date.now();
task.expiresAt = Date.now() + TASK_TTL_MS;
} catch (error) { } catch (error) {
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message }); console.error("[CanvasDownloadZipTask] Failed:", error);
return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 }); task.status = "failed";
task.stage = "打包失败";
task.error = error instanceof Error ? error.message : "打包下载失败";
task.updatedAt = Date.now();
task.expiresAt = Date.now() + TASK_TTL_MS;
} finally {
store.set(task.id, task);
} }
} }
export async function GET(request: NextRequest) {
const session = await auth(); export async function POST(request: NextRequest) {
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try { try {
const id = request.nextUrl.searchParams.get("taskId") || ""; cleanupExpiredTasks();
const task = await loadZipTask(id, session.user.id); const session = await auth();
if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 }); if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request);
const files = toDownloadItems(body.files, requestOrigin);
const folderName = sanitizeFolderName(String(body.folderName || ""));
if (files.length === 0) {
return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 });
}
const task: ZipTask = {
id: randomUUID(),
userId: session.user.id,
folderName,
files,
totalCount: files.length,
processedCount: 0,
successCount: 0,
failedCount: 0,
compressPercent: 0,
status: "pending",
stage: "等待服务端开始打包",
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + TASK_TTL_MS,
};
getTaskStore().set(task.id, task);
void processZipTask({
task,
requestOrigin,
cookie: request.headers.get("cookie") || "",
});
return NextResponse.json({
success: true,
task: serializeTask(task),
});
} catch (error) {
console.error("[CanvasDownloadZipTask] Create failed:", error);
return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
try {
cleanupExpiredTasks();
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim();
if (!taskId) {
return NextResponse.json({ success: false, error: "缺少任务 ID" }, { status: 400 });
}
const task = getTaskStore().get(taskId);
if (!task || task.userId !== session.user.id) {
return NextResponse.json({ success: false, error: "打包任务不存在或已过期" }, { status: 404 });
}
if (request.nextUrl.searchParams.get("download") === "1") { if (request.nextUrl.searchParams.get("download") === "1") {
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 }); if (task.status !== "completed" || !task.zipBuffer) {
if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1") return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
return NextResponse.redirect(task.cdnUrl, 307);
const info = await stat(task.zipFile.path).catch(() => null);
if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 });
const etag = `"${task.id}-${info.size}"`;
const headers: Record<string, string> = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!,
"Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store",
"X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) };
const ifRange = request.headers.get("if-range");
let range;
try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); }
catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); }
headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size);
if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`;
return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers });
} }
if (task.status === "pending" || task.status === "running") schedule(task, request); return new NextResponse(task.zipBuffer, {
return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } }); status: 200,
} catch { headers: {
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 }); "Content-Type": "application/zip",
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName),
"Content-Length": String(task.zipBuffer.length),
"Cache-Control": "no-store",
"X-Download-Success-Count": String(task.successCount),
"X-Download-Failed-Count": String(task.failedCount),
},
});
}
return NextResponse.json({
success: true,
task: serializeTask(task),
});
} catch (error) {
console.error("[CanvasDownloadZipTask] Query failed:", error);
return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 });
} }
} }

View File

@ -1,4 +1,3 @@
import { streamDownloadZip } from "@/lib/canvas/download-zip-stream";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import JSZip from "jszip"; import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
@ -160,7 +159,13 @@ export async function POST(request: NextRequest) {
console.warn("[CanvasDownloadZip] Partial download failures:", failures); console.warn("[CanvasDownloadZip] Partial download failures:", failures);
} }
return new NextResponse(streamDownloadZip(zip), { const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
return new NextResponse(zipBuffer, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/zip", "Content-Type": "application/zip",

View File

@ -2,7 +2,6 @@ import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue"; import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
export const maxDuration = 1200; export const maxDuration = 1200;
@ -24,15 +23,13 @@ export async function GET(request: Request) {
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 }); return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
} }
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
task: { task: {
id: task.id, id: task.id,
status: task.status, status: task.status,
attempts: task.attempts, attempts: task.attempts,
error: failure ? formatRequestFailure(failure, task.id) : null, error: task.error || null,
failure,
result: task.result || null, result: task.result || null,
createdAt: task.createdAt, createdAt: task.createdAt,
startedAt: task.startedAt, startedAt: task.startedAt,

View File

@ -14,7 +14,6 @@ import {
ownsTaskProject, ownsTaskProject,
} from "@/lib/video-canvas/task-store"; } from "@/lib/video-canvas/task-store";
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker"; import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 900; export const maxDuration = 900;
@ -29,7 +28,7 @@ async function owner(projectId: unknown) {
} }
const fail = (error: unknown) => const fail = (error: unknown) =>
NextResponse.json( NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "任务操作失败", code: (error as any)?.code }, { success: false, error: error instanceof Error ? error.message : "任务操作失败" },
{ status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) }, { status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) },
); );
const kick = (userId: string, projectId: string) => const kick = (userId: string, projectId: string) =>
@ -66,8 +65,7 @@ export async function POST(request: Request) {
throw Object.assign(Error("剪辑参数无效"), { status: 400 }); throw Object.assign(Error("剪辑参数无效"), { status: 400 });
if (parsed.data.kind === "video-upscale" && !videoUpscaleSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频高清参数无效"), {status:400}); if (parsed.data.kind === "video-upscale" && !videoUpscaleSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频高清参数无效"), {status:400});
const userId = await owner(parsed.data.projectId); const userId = await owner(parsed.data.projectId);
const task = await createCanvasGenerationTask(userId, parsed.data, const task = await createCanvasGenerationTask(userId, parsed.data);
() => preflightCanvasProductionRequests([parsed.data]));
kick(userId, parsed.data.projectId); kick(userId, parsed.data.projectId);
return NextResponse.json({ success: true, task }, { status: 202 }); return NextResponse.json({ success: true, task }, { status: 202 });
} catch (error) { } catch (error) {

View File

@ -6,7 +6,6 @@ import {
ownsTaskProject, ownsTaskProject,
} from "@/lib/video-canvas/task-store"; } from "@/lib/video-canvas/task-store";
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker"; import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
export const runtime = "nodejs", export const runtime = "nodejs",
maxDuration = 900; maxDuration = 900;
export async function POST(request: Request) { export async function POST(request: Request) {
@ -28,13 +27,12 @@ export async function POST(request: Request) {
userId = session.user.id; userId = session.user.id;
if (!(await ownsTaskProject(userId, projectId))) if (!(await ownsTaskProject(userId, projectId)))
return NextResponse.json({ error: "画布不存在" }, { status: 404 }); return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const tasks = await createCanvasGroupTasks(userId, parsed.data, const tasks = await createCanvasGroupTasks(userId, parsed.data);
() => preflightCanvasProductionRequests(parsed.data));
after(() => runCanvasGenerationQueue(userId, projectId)); after(() => runCanvasGenerationQueue(userId, projectId));
return NextResponse.json({ success: true, tasks }, { status: 202 }); return NextResponse.json({ success: true, tasks }, { status: 202 });
} catch (e) { } catch (e) {
return NextResponse.json( return NextResponse.json(
{ error: e instanceof Error ? e.message : "编组执行失败", code: (e as any)?.code }, { error: e instanceof Error ? e.message : "编组执行失败" },
{ status: Number((e as any)?.status) || 400 } { status: Number((e as any)?.status) || 400 }
); );
} }

View File

@ -1,78 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import {
readOwnedVideoCanvas,
saveVideoAgentCanvas,
} from "@/lib/ai/canvas-agent-runtime/video-store";
import { AgentRunError } from "@/lib/ai/canvas-agent-runtime/store";
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const projectId = z.string().uuid().parse(new URL(request.url).searchParams.get("projectId"));
await readOwnedVideoCanvas(session.user.id, projectId);
// One stable conversation per video workspace survives browser reloads.
const chat = await getChatById({ id: projectId });
if (chat && chat.userId !== session.user.id) throw new AgentRunError("对话不存在", 404);
const messages = chat ? (await getMessagesByChatId({ id: projectId })).slice(-40) : [];
return NextResponse.json(
{ messages: messages.map(({ id, role, parts }) => ({ id, role, parts })) },
{ headers: { "Cache-Control": "private, no-store" } },
);
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}
const saveSchema = z.object({
projectId: z.string().uuid(),
baseRevision: z.string().datetime(),
canvasData: z.object({
nodes: z.array(z.record(z.unknown())).max(2000),
edges: z
.array(
z.object({
id: z.string(),
source: z.string(),
target: z.string(),
type: z.string().optional(),
}),
)
.max(4000),
selectedNodeIds: z.array(z.string()).max(20),
viewport: z.object({
x: z.number().finite(),
y: z.number().finite(),
zoom: z.number().positive(),
}),
tool: z.literal("select"),
}),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = saveSchema.parse(await request.json());
await saveVideoAgentCanvas(session.user.id, body.projectId, body.baseRevision, body.canvasData);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}

View File

@ -1,8 +1,7 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage, user, aiModel } from "@/lib/db/schema"; import { generatedImage, user, aiModel } from "@/lib/db/schema";
import { and, eq, desc, sql } from "drizzle-orm"; import { eq, desc, sql } from "drizzle-orm";
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
import { cachedPublicJson } from "@/lib/public-api/performance"; import { cachedPublicJson } from "@/lib/public-api/performance";
const FEATURED_TIMEOUT_MS = 1800; const FEATURED_TIMEOUT_MS = 1800;
@ -58,17 +57,13 @@ export async function GET(request: NextRequest) {
userName: user.name, userName: user.name,
userEmail: user.email, userEmail: user.email,
userDepartment: user.department, userDepartment: user.department,
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel} modelName: aiModel.displayName,
where ${aiModel.modelId} = ${generatedImage.modelId}
order by ${aiModel.isEnabled} desc, ${aiModel.createdAt} desc, ${aiModel.id} asc limit 1)`,
totalCount: sql<number>`count(*) over()::int`, totalCount: sql<number>`count(*) over()::int`,
}) })
.from(generatedImage) .from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id)) .leftJoin(user, eq(generatedImage.userId, user.id))
.where(and( .leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
eq(generatedImage.isFeatured, true), .where(eq(generatedImage.isFeatured, true))
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
))
.orderBy(desc(generatedImage.featuredAt)) .orderBy(desc(generatedImage.featuredAt))
.limit(pageSize) .limit(pageSize)
.offset(offset); .offset(offset);
@ -79,7 +74,7 @@ export async function GET(request: NextRequest) {
return { return {
images: featuredImages.map((img) => ({ images: featuredImages.map((img) => ({
id: img.id, id: img.id,
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl, thumbnailUrl: img.thumbnailUrl,
originalUrl: img.originalUrl, originalUrl: img.originalUrl,
imageSize: img.imageSize, imageSize: img.imageSize,
prompt: img.prompt, prompt: img.prompt,

View File

@ -1,8 +1,4 @@
import type { import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema";
FlowBoardDraft,
FlowBoardSheetSnapshotData,
FlowBoardSnapshotData,
} from "@/lib/db/schema";
export function isRecord(value: unknown): value is Record<string, unknown> { export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
@ -12,73 +8,6 @@ function normalizeRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null; return isRecord(value) ? value : null;
} }
const DEFAULT_FLOW_BOARD_CANVAS = {
backgroundColor: "#f7f8fb",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
};
function normalizeCanvasColor(value: unknown, fallback: string) {
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value)
? value.toLowerCase()
: fallback;
}
function normalizeFlowBoardCanvas(value: unknown): Record<string, unknown> {
const canvas = isRecord(value) ? value : {};
const requestedGridSize =
typeof canvas.gridSize === "number" && Number.isFinite(canvas.gridSize)
? Math.round(canvas.gridSize)
: DEFAULT_FLOW_BOARD_CANVAS.gridSize;
return {
backgroundColor: normalizeCanvasColor(
canvas.backgroundColor,
DEFAULT_FLOW_BOARD_CANVAS.backgroundColor
),
gridColor: normalizeCanvasColor(
canvas.gridColor,
DEFAULT_FLOW_BOARD_CANVAS.gridColor
),
gridEnabled:
typeof canvas.gridEnabled === "boolean"
? canvas.gridEnabled
: DEFAULT_FLOW_BOARD_CANVAS.gridEnabled,
gridSize: Math.min(64, Math.max(8, requestedGridSize)),
};
}
function normalizeSheetId(value: unknown, index: number) {
const id = typeof value === "string" ? value.trim().slice(0, 128) : "";
return id || `sheet-${index + 1}`;
}
function normalizeSheetTitle(value: unknown, index: number) {
const title = typeof value === "string" ? value.trim().slice(0, 128) : "";
return title || `画布 ${index + 1}`;
}
function normalizeFlowBoardSheet(
value: unknown,
index: number
): FlowBoardSheetSnapshotData | null {
if (!isRecord(value)) {
return null;
}
return {
id: normalizeSheetId(value.id, index),
title: normalizeSheetTitle(value.title, index),
children: Array.isArray(value.children)
? value.children.filter(isRecord)
: [],
viewport: normalizeRecord(value.viewport),
theme: normalizeRecord(value.theme),
canvas: normalizeFlowBoardCanvas(value.canvas),
};
}
export function normalizeFlowBoardTitle(value: unknown) { export function normalizeFlowBoardTitle(value: unknown) {
const title = String(value || "").trim(); const title = String(value || "").trim();
return title.slice(0, 256) || "未命名流程白板"; return title.slice(0, 256) || "未命名流程白板";
@ -88,67 +17,15 @@ export function normalizeFlowBoardSnapshot(
value: unknown value: unknown
): FlowBoardSnapshotData { ): FlowBoardSnapshotData {
const raw = isRecord(value) ? value : {}; const raw = isRecord(value) ? value : {};
const legacyChildren = Array.isArray(raw.children)
? raw.children.filter(isRecord)
: [];
const normalizedSheets = Array.isArray(raw.sheets)
? raw.sheets
.map(normalizeFlowBoardSheet)
.filter(
(sheet): sheet is FlowBoardSheetSnapshotData => Boolean(sheet)
)
: [];
const sheets =
normalizedSheets.length > 0
? normalizedSheets
: [
{
id: "sheet-1",
title: "画布 1",
children: legacyChildren,
viewport: normalizeRecord(raw.viewport),
theme: normalizeRecord(raw.theme),
canvas: normalizeFlowBoardCanvas(raw.canvas),
},
];
const requestedActiveSheetId =
typeof raw.activeSheetId === "string" ? raw.activeSheetId.trim() : "";
const activeSheet =
sheets.find((sheet) => sheet.id === requestedActiveSheetId) || sheets[0];
const activeChildren = Array.isArray(raw.children)
? legacyChildren
: activeSheet.children;
const activeViewport = isRecord(raw.viewport)
? raw.viewport
: activeSheet.viewport;
const activeTheme = isRecord(raw.theme) ? raw.theme : activeSheet.theme;
const activeCanvas = isRecord(raw.canvas)
? normalizeFlowBoardCanvas(raw.canvas)
: normalizeFlowBoardCanvas(activeSheet.canvas);
const nextSheets = sheets.map((sheet) =>
sheet.id === activeSheet.id
? {
...sheet,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
}
: sheet
);
const updatedAt = const updatedAt =
typeof raw.updatedAt === "string" && raw.updatedAt.trim() typeof raw.updatedAt === "string" && raw.updatedAt.trim()
? raw.updatedAt ? raw.updatedAt
: new Date().toISOString(); : new Date().toISOString();
return { return {
version: 2, children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
activeSheetId: activeSheet.id, viewport: normalizeRecord(raw.viewport),
sheets: nextSheets, theme: normalizeRecord(raw.theme),
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
updatedAt, updatedAt,
}; };
} }

View File

@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { imageArchiveTask } from "@/lib/db/schema";
// The SHA-256 URL is a capability, like the provider's signed image URL. It must be
// readable by image nodes and reference-image providers without a session cookie.
export async function GET(request: NextRequest, { params }: { params: Promise<{ key: string }> }) {
const { key } = await params;
if (!/^[a-f0-9]{64}$/.test(key)) return new NextResponse(null, { status: 404 });
const configured =
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
const origin = configured
? new URL(configured).origin
: `http://localhost:${process.env.PORT || "3000"}`;
const sourceUrl = `${origin}/api/generated-image-preview/${key}`;
const [task] = await db
.select({ targetUrl: imageArchiveTask.targetUrl, metadata: imageArchiveTask.metadata })
.from(imageArchiveTask)
.where(eq(imageArchiveTask.sourceUrl, sourceUrl))
.limit(1);
if (task?.targetUrl) return NextResponse.redirect(task.targetUrl, 307);
const inline = (task?.metadata as { inlineSource?: string } | null)?.inlineSource;
const match = inline?.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
if (!match) return new NextResponse(null, { status: 404 });
return new NextResponse(Buffer.from(match[2], "base64"), {
headers: {
"Content-Type": match[1],
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
},
});
}

View File

@ -1,12 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger";
import { buildImageProxyUrl, isAlibabaModelImageUrl } from "@/lib/image-delivery";
export const runtime = "nodejs"; export const runtime = "nodejs";
const logger = createLogger("image.download");
const DEFAULT_QUALITY = 82; const DEFAULT_QUALITY = 82;
const MAX_WIDTH = 4096; const MAX_WIDTH = 4096;
@ -14,7 +9,6 @@ const MIN_WIDTH = 64;
const MIN_QUALITY = 40; const MIN_QUALITY = 40;
const MAX_QUALITY = 100; const MAX_QUALITY = 100;
const FETCH_TIMEOUT_MS = 15000; const FETCH_TIMEOUT_MS = 15000;
const SMALL_PREVIEW_FETCH_TIMEOUT_MS = 45000;
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024; const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024;
const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256; const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256;
@ -133,15 +127,13 @@ function buildImageResponse(
etag: string; etag: string;
vary?: string; vary?: string;
cacheStatus?: "HIT" | "MISS"; cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
} }
) { ) {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": params.contentType, "Content-Type": params.contentType,
"Content-Length": String(body.length), "Content-Length": String(body.length),
ETag: params.etag, ETag: params.etag,
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400", "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
}; };
if (params.vary) { if (params.vary) {
headers.Vary = params.vary; headers.Vary = params.vary;
@ -149,11 +141,6 @@ function buildImageResponse(
if (params.cacheStatus) { if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus; headers["X-Image-Proxy-Cache"] = params.cacheStatus;
} }
if (params.attachment) {
headers["Content-Disposition"] = "attachment";
headers["X-Content-Type-Options"] = "nosniff";
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
}
return new NextResponse(body, { return new NextResponse(body, {
status: 200, status: 200,
headers, headers,
@ -243,7 +230,6 @@ function resolveDirectImageTarget(
requestedQuality: number | null, requestedQuality: number | null,
requestedFormat: string | null requestedFormat: string | null
): URL | null { ): URL | null {
if (isAlibabaModelImageUrl(target.toString())) return null;
if (!isBrowserImageRequest(request)) { if (!isBrowserImageRequest(request)) {
return null; return null;
} }
@ -272,19 +258,7 @@ function redirectToDirectImage(target: URL): NextResponse {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl; const { searchParams } = request.nextUrl;
const requestId = crypto.randomUUID();
const downloadFailure = (error: unknown) => {
const failure = getDownloadFailure(error);
logger.warn("original_download_failed", error, { requestId, code: failure.code, status: failure.status });
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId }, {
status: failure.status, headers: { "Cache-Control": "no-store" },
});
};
const rawUrl = (searchParams.get("url") || "").trim(); const rawUrl = (searchParams.get("url") || "").trim();
if (isRetiredStorageUrl(rawUrl)) {
if (searchParams.get("original") === "1") return downloadFailure(RETIRED_STORAGE_MESSAGE);
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
if (!rawUrl) { if (!rawUrl) {
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
} }
@ -300,24 +274,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 }); return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
} }
if (normalizeLegacyAliyunStorageUrl(target.toString())) {
try {
if (searchParams.get("original") === "1") {
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
buildImageProxyUrl(target.toString(), { delivery: "original" });
}
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
target = new URL(await resolveLegacyAliyunStorageUrl(target.toString()));
} catch (error) {
if (searchParams.get("original") === "1") return downloadFailure(error);
const failure = getDownloadFailure(error);
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId,
code: failure.code === "STORAGE_RETIRED" ? "storage_retired" : failure.code }, {
status: failure.status, headers: { "Cache-Control": "no-store" },
});
}
}
if (isBlockedHost(target.hostname)) { if (isBlockedHost(target.hostname)) {
return NextResponse.json({ error: "Blocked target host" }, { status: 403 }); return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
} }
@ -329,47 +285,8 @@ export async function GET(request: NextRequest) {
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null; const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
const requestedFormat = searchParams.get("f"); const requestedFormat = searchParams.get("f");
const forceProxy = searchParams.get("proxy") === "1"; const forceProxy = searchParams.get("proxy") === "1";
const original = searchParams.get("original") === "1";
if (original && (requestedWidth || requestedQuality || requestedFormat)) { if (!forceProxy) {
return NextResponse.json(
{ error: "Original images cannot be transformed" },
{ status: 400 }
);
}
if (!original && isAlibabaModelImageUrl(target.toString())) {
try {
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
const mappings = await getArchivedImageUrlMappings([target.toString()]);
const archived = mappings[target.toString()];
if (archived) {
const preview = buildImageProxyUrl(archived, {
width: width || undefined,
quality,
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
});
if (/^https?:\/\//i.test(preview)) return redirectToDirectImage(new URL(preview));
target = new URL(archived);
}
} catch (error) {
logger.warn("preview_archive_lookup_failed", error, { requestId });
}
}
if (original && searchParams.get("download") === "resolve") {
try {
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
buildImageProxyUrl(target.toString(), { delivery: "original" });
const { resolveOriginalCdnDownload } = await import("@/lib/storage/original-image-download");
const direct = await resolveOriginalCdnDownload(target.toString(), searchParams.get("name") || "", request.signal);
return NextResponse.json(direct || { delivery: "proxy" }, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
return downloadFailure(error);
}
}
if (!forceProxy && !original) {
const directImageTarget = resolveDirectImageTarget( const directImageTarget = resolveDirectImageTarget(
request, request,
target, target,
@ -382,12 +299,10 @@ export async function GET(request: NextRequest) {
} }
} }
const cacheKey = original const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
? `${target.toString()}|original-v1`
: `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
const etag = buildEtag(cacheKey); const etag = buildEtag(cacheKey);
const ifNoneMatch = request.headers.get("if-none-match"); const ifNoneMatch = request.headers.get("if-none-match");
if (!original && ifNoneMatch && ifNoneMatch === etag) { if (ifNoneMatch && ifNoneMatch === etag) {
return new NextResponse(null, { return new NextResponse(null, {
status: 304, status: 304,
headers: { headers: {
@ -397,7 +312,7 @@ export async function GET(request: NextRequest) {
}); });
} }
const cached = original ? null : getCachedImageResponse(cacheKey); const cached = getCachedImageResponse(cacheKey);
if (cached) { if (cached) {
return buildImageResponse(cached.body, { return buildImageResponse(cached.body, {
contentType: cached.contentType, contentType: cached.contentType,
@ -407,73 +322,32 @@ export async function GET(request: NextRequest) {
}); });
} }
const fetchSignal = AbortSignal.any([
request.signal,
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
]);
const upstreamFailure = (error: unknown) => {
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
if (original) return downloadFailure(error);
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
return NextResponse.json(
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
{ status: timedOut ? 504 : 502 },
);
};
let contentType: string;
let originalBuffer: Buffer;
if (!original && isAlibabaModelImageUrl(target.toString())) {
try {
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache");
const data = await fetchImageBytes(target.toString(), { timeoutMs: SMALL_PREVIEW_FETCH_TIMEOUT_MS });
originalBuffer = data.buffer;
contentType = data.contentType;
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
if (!contentType.toLowerCase().startsWith("image/")) {
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
}
} catch (error) {
return upstreamFailure(error);
}
} else {
let upstreamResponse: Response; let upstreamResponse: Response;
try { try {
upstreamResponse = await fetch(target.toString(), { upstreamResponse = await fetch(target.toString(), {
signal: fetchSignal, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { headers: {
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}, },
}); });
} catch (error) { } catch (error) {
return upstreamFailure(error); console.error("[ImageProxy] Upstream fetch failed:", error);
return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
} }
if (!upstreamResponse.ok) { if (!upstreamResponse.ok) {
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
return NextResponse.json( return NextResponse.json(
{ error: `Upstream responded with ${upstreamResponse.status}` }, { error: `Upstream responded with ${upstreamResponse.status}` },
{ status: 502 } { status: 502 }
); );
} }
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
if (!contentType.toLowerCase().startsWith("image/")) { if (!contentType.toLowerCase().startsWith("image/")) {
if (original) return downloadFailure("Source is not an image");
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
} }
try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) {
return upstreamFailure(error);
}
}
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
}
const isSvg = contentType.toLowerCase().includes("image/svg"); const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif"); const isGif = contentType.toLowerCase().includes("image/gif");

View File

@ -6,7 +6,6 @@
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -70,6 +69,7 @@ const SCENE_FUSION_REQUEST_STAGGER_MS = 350;
const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000; const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000;
const SCENE_FUSION_AGENT_MAX_WORKERS = 3; const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
const SCENE_FUSION_PROCESS_MAX_WORKERS = 4; const SCENE_FUSION_PROCESS_MAX_WORKERS = 4;
const SCENE_FUSION_PRE_RMBG_TIMEOUT_MS = 60_000;
const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000; const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000;
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8; const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000; const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
@ -236,6 +236,41 @@ function isSceneFusionReviewRejectError(message: string): boolean {
); );
} }
function buildPreRmbgCandidateUrls(request: Request): string[] {
const urls: string[] = [];
const requestUrl = new URL(request.url);
const requestHost = requestUrl.hostname.toLowerCase();
const requestProtocol = requestUrl.protocol.replace(":", "");
if (!(requestProtocol === "https" && (requestHost === "localhost" || requestHost === "127.0.0.1"))) {
urls.push(new URL("/api/rmbg", request.url).toString());
}
const host = request.headers.get("host")?.trim();
const protoHint = request.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
const protocol = protoHint === "https" || protoHint === "http" ? protoHint : requestUrl.protocol.replace(":", "");
if (host) {
const normalizedHost = host.split(":")[0]?.toLowerCase() || "";
const isLocalHost = normalizedHost === "localhost" || normalizedHost === "127.0.0.1";
if (!(protocol === "https" && isLocalHost)) {
urls.push(`${protocol}://${host}/api/rmbg`);
}
if (isLocalHost) {
urls.push(`http://${host}/api/rmbg`);
}
}
const localPort = requestUrl.port || process.env.PORT || "3125";
urls.push(`http://127.0.0.1:${localPort}/api/rmbg`);
urls.push(`http://localhost:${localPort}/api/rmbg`);
return Array.from(new Set(urls));
}
function normalizeUrlForCompare(input: string): string { function normalizeUrlForCompare(input: string): string {
try { try {
const parsed = new URL(input); const parsed = new URL(input);
@ -953,6 +988,102 @@ async function getSceneFusionConfig() {
}; };
} }
async function preprocessProductImageWithRmbg(
request: Request,
requestId: string,
productImageUrl: string,
preRmbgEnabled: boolean
): Promise<{
productImageUrl: string;
usedPreRmbg: boolean;
provider?: string;
reason?: string;
}> {
if (!preRmbgEnabled) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "disabled_by_config",
};
}
const cookie = request.headers.get("cookie");
if (!cookie) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "missing_cookie",
};
}
const candidateUrls = buildPreRmbgCandidateUrls(request);
let lastReason = "no_candidate_url";
logger.info("pre_rmbg_started", {
requestId,
candidateCount: candidateUrls.length,
});
for (const rmbgUrl of candidateUrls) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SCENE_FUSION_PRE_RMBG_TIMEOUT_MS);
try {
const response = await fetch(rmbgUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie,
},
body: JSON.stringify({
imageUrl: productImageUrl,
prompt: "场景裂变预处理-抠图",
skipGallerySave: true,
}),
signal: controller.signal,
});
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
if (!response.ok) {
lastReason = `request_failed_${response.status}`;
logger.warn("pre_rmbg_candidate_failed", {
requestId,
status: response.status,
detail: payload?.detail || payload?.error || "unknown",
});
continue;
}
const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim();
if (!cleanedUrl || cleanedUrl === productImageUrl) {
lastReason = cleanedUrl ? "same_as_source" : "empty_result";
logger.warn("pre_rmbg_candidate_invalid", {
requestId,
reason: lastReason,
});
continue;
}
return {
productImageUrl: cleanedUrl,
usedPreRmbg: true,
provider: String(payload?.provider || "unknown"),
};
} catch (error) {
lastReason = error instanceof Error ? error.message : "exception";
logger.warn("pre_rmbg_candidate_exception", error, { requestId });
} finally {
clearTimeout(timeoutId);
}
}
return {
productImageUrl,
usedPreRmbg: false,
reason: lastReason,
};
}
/** /**
* 创建缩略图 * 创建缩略图
*/ */
@ -2305,13 +2436,12 @@ export async function POST(request: Request) {
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product"); productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
} }
const shouldForcePreRmbg = resolvedSceneMode === "random-bg"; const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
const preprocessedProduct = await preprocessProductImageWithRmbg({ const preprocessedProduct = await preprocessProductImageWithRmbg(
request, request,
requestId, requestId,
userId: session.user.id,
productImageUrl, productImageUrl,
preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled, shouldForcePreRmbg ? true : config.preRmbgEnabled
}); );
const fusionProductImageUrl = preprocessedProduct.productImageUrl; const fusionProductImageUrl = preprocessedProduct.productImageUrl;
logger.info("pre_rmbg_resolved", { logger.info("pre_rmbg_resolved", {
requestId, requestId,

View File

@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { resolveSiteBranding } from "@/lib/site-branding";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -153,7 +152,7 @@ import {
} from "@/lib/ai-wash/config"; } from "@/lib/ai-wash/config";
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config"; import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
import { withTimeout } from "@/lib/public-api/performance"; import { withTimeout } from "@/lib/public-api/performance";
import type { AgentCategoryTreeConfig, AgentWorkflowUiConfig } from "@/types/agent"; import type { AgentCategoryTreeConfig } from "@/types/agent";
// Historically stored as "home_surprise_categories" but used for the featured gallery categories. // Historically stored as "home_surprise_categories" but used for the featured gallery categories.
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories"; const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
@ -553,7 +552,6 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
type PublicSiteSettingsScope = type PublicSiteSettingsScope =
| "agent-brand-model" | "agent-brand-model"
| "agent-canvas" | "agent-canvas"
| "agent-entry"
| "agent-presets" | "agent-presets"
| "auth" | "auth"
| "branding" | "branding"
@ -565,7 +563,6 @@ type PublicSiteSettingsScope =
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
"agent-brand-model", "agent-brand-model",
"agent-canvas", "agent-canvas",
"agent-entry",
"agent-presets", "agent-presets",
"auth", "auth",
"branding", "branding",
@ -609,60 +606,22 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined), lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
}; };
const AGENT_CANVAS_SETTING_KEYS = [
REALTIME_CANVAS_CONFIG_KEY,
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
LAYER_SPLIT_CONFIG_KEY,
ANGLE_EDIT_CONFIG_KEY,
CANVAS_AGENT_MODE_ENABLED_KEY,
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
VIDEO_CANVAS_ENABLED_KEY,
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
ENHANCE_PRO_KREA_SETTING_KEY,
AI_WASH_CONFIG_KEY,
STYLE_PRO_CONFIG_KEY,
AGENT_WORKFLOW_UI_CONFIG_KEY,
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
];
async function getAgentCanvasSettingsEtag(scope: string) {
const [settings, rows, providers] = await Promise.all([
db.select({ id: siteSettings.id, updatedAt: siteSettings.updatedAt })
.from(siteSettings).limit(1),
db.select({ key: systemSettings.key, updatedAt: systemSettings.updatedAt })
.from(systemSettings)
.where(inArray(systemSettings.key, AGENT_CANVAS_SETTING_KEYS)),
db.select({ id: aiProvider.id, updatedAt: aiProvider.updatedAt, enabled: aiProvider.isEnabled })
.from(aiProvider)
.where(inArray(aiProvider.type, ["tripo-3d", "worldlabs-3d"])),
]);
const versions = [
...settings.map((row) => `${row.id}:${row.updatedAt.getTime()}`),
...rows.map((row) => `${row.key}:${row.updatedAt.getTime()}`),
...providers.map((row) => `${row.id}:${row.updatedAt.getTime()}:${row.enabled}`),
].sort();
return `W/"${scope}/${versions.join(",")}"`;
}
function createScopedSiteSettingsResponse( function createScopedSiteSettingsResponse(
scope: PublicSiteSettingsScope, scope: PublicSiteSettingsScope,
data: Record<string, unknown>, data: Record<string, unknown>,
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS", cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
cacheKey: string = scope, cacheKey: string = scope,
options: { fallback?: boolean; startedAt?: number; etag?: string } = {} options: { fallback?: boolean; startedAt?: number } = {}
) { ) {
if (cacheStatus === "MISS") { if (cacheStatus === "MISS") {
setScopedSiteSettingsCache( setScopedSiteSettingsCache(
cacheKey, cacheKey,
data, data,
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS, PUBLIC_SITE_SETTINGS_CACHE_TTL_MS
options.etag
); );
} }
const response = NextResponse.json(data); const response = NextResponse.json(data);
if (options.etag) response.headers.set("ETag", options.etag);
// Lightweight public settings change rarely; keep them browser-local briefly // Lightweight public settings change rarely; keep them browser-local briefly
// so page opens do not repeatedly wait on the large full settings endpoint. // so page opens do not repeatedly wait on the large full settings endpoint.
response.headers.set( response.headers.set(
@ -704,12 +663,12 @@ async function readScopedSiteSettings(keys: string[]) {
} }
function createBrandingSettings(settings: Record<string, unknown> | undefined) { function createBrandingSettings(settings: Record<string, unknown> | undefined) {
return resolveSiteBranding({ return {
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"), siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
siteLogo: String(settings?.siteLogo || ""), siteLogo: settings?.siteLogo || null,
siteLogoDark: String(settings?.siteLogoDark || ""), siteLogoDark: settings?.siteLogoDark || null,
favicon: String(settings?.favicon || ""), favicon: settings?.favicon || null,
}); };
} }
async function getScopedPublicSiteSettings( async function getScopedPublicSiteSettings(
@ -717,7 +676,6 @@ async function getScopedPublicSiteSettings(
options: { options: {
brandName?: string; brandName?: string;
cacheKey?: string; cacheKey?: string;
etag?: string;
} = {} } = {}
) { ) {
if (scope === "auth") { if (scope === "auth") {
@ -779,8 +737,23 @@ async function getScopedPublicSiteSettings(
}); });
} }
if (scope === "agent-canvas" || scope === "agent-entry") { if (scope === "agent-canvas") {
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS); const { settings, valueByKey } = await readScopedSiteSettings([
REALTIME_CANVAS_CONFIG_KEY,
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
LAYER_SPLIT_CONFIG_KEY,
ANGLE_EDIT_CONFIG_KEY,
CANVAS_AGENT_MODE_ENABLED_KEY,
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
VIDEO_CANVAS_ENABLED_KEY,
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
ENHANCE_PRO_KREA_SETTING_KEY,
AI_WASH_CONFIG_KEY,
STYLE_PRO_CONFIG_KEY,
AGENT_WORKFLOW_UI_CONFIG_KEY,
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
]);
const canvasFeatureAvailability = await readCanvasFeatureAvailability( const canvasFeatureAvailability = await readCanvasFeatureAvailability(
valueByKey valueByKey
); );
@ -788,8 +761,6 @@ async function getScopedPublicSiteSettings(
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled( const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY) valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
); );
const rawUiConfig = valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) as
Partial<AgentWorkflowUiConfig> | undefined;
return createScopedSiteSettingsResponse(scope, { return createScopedSiteSettingsResponse(scope, {
...createBrandingSettings(typedSettings), ...createBrandingSettings(typedSettings),
agentEnabled: typedSettings?.agentEnabled ?? true, agentEnabled: typedSettings?.agentEnabled ?? true,
@ -825,18 +796,12 @@ async function getScopedPublicSiteSettings(
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled, tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled, worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig( agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
scope === "agent-entry" valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
? {
steps: [],
pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
businessModeSettings: rawUiConfig?.businessModeSettings,
}
: rawUiConfig
), ),
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig( agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY) valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
), ),
}, "MISS", options.cacheKey || scope, { etag: options.etag }); });
} }
if (scope === "branding") { if (scope === "branding") {
@ -1090,23 +1055,13 @@ export async function GET(request: NextRequest) {
const cacheKey = brandName.trim() const cacheKey = brandName.trim()
? `${scopedScope}:${brandName.trim().toLowerCase()}` ? `${scopedScope}:${brandName.trim().toLowerCase()}`
: scopedScope; : scopedScope;
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry"
? await getAgentCanvasSettingsEtag(scopedScope)
: undefined;
if (etag && request.headers.get("If-None-Match") === etag) {
return new NextResponse(null, {
status: 304,
headers: { ETag: etag, "Cache-Control": "private, no-cache" },
});
}
const cached = getScopedSiteSettingsCache(cacheKey); const cached = getScopedSiteSettingsCache(cacheKey);
if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) { if (cached && cached.expiresAt > Date.now()) {
return createScopedSiteSettingsResponse( return createScopedSiteSettingsResponse(
scopedScope, scopedScope,
cached.data, cached.data,
"HIT", "HIT",
cacheKey, cacheKey
{ etag }
); );
} }
if (cached && scopedScope !== "home") { if (cached && scopedScope !== "home") {
@ -1117,7 +1072,6 @@ export async function GET(request: NextRequest) {
const responsePromise = getScopedPublicSiteSettings(scopedScope, { const responsePromise = getScopedPublicSiteSettings(scopedScope, {
brandName, brandName,
cacheKey, cacheKey,
etag,
}); });
return scopedScope === "home" return scopedScope === "home"
? await withTimeout(responsePromise, 1600, "site-settings:home") ? await withTimeout(responsePromise, 1600, "site-settings:home")
@ -1587,7 +1541,7 @@ export async function GET(request: NextRequest) {
}; };
const response = NextResponse.json({ const response = NextResponse.json({
...resolveSiteBranding(baseSettings), ...baseSettings,
lovartPromoText: lovartPromoText:
baseSettings.lovartPromoText || baseSettings.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",

File diff suppressed because it is too large Load Diff

View File

@ -48,13 +48,8 @@ export default function CanvasStyleProPage() {
const canvasData = result.data.canvasData; const canvasData = result.data.canvasData;
const latestState = useCanvasStore.getState(); const latestState = useCanvasStore.getState();
setProject(result.data.id, result.data.title); setProject(result.data.id, result.data.title);
if ( if (latestState.nodes.length === 0 && latestState.edges.length === 0) {
latestState.projectId !== id ||
(latestState.nodes.length === 0 && latestState.edges.length === 0)
) {
loadCanvas(canvasData); loadCanvas(canvasData);
const revision = String(result.data.updatedAt || "");
useCanvasStore.getState().setCanvasRevision(revision, revision);
return; return;
} }

View File

@ -1,17 +0,0 @@
import { notFound } from "next/navigation";
import { FlowBoardClient } from "@/components/flow-board/flow-board-client";
export default function FlowBoardPreviewPage() {
if (process.env.NODE_ENV === "production") {
notFound();
}
return (
<FlowBoardClient
previewMode
siteLogo={null}
siteLogoDark={null}
siteName="SnapGen"
/>
);
}

View File

@ -519,301 +519,3 @@
padding: 0 12px 12px; padding: 0 12px 12px;
} }
} }
/* Lovart surface typography and controls, scoped to the home and image canvas. */
.lovart-home-surface,
.lovart-canvas-surface {
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
}
.lovart-suggestion {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 8px;
height: 32px;
padding: 0 14px;
border: 1px solid #e5e5e5;
border-radius: 999px;
background: white;
color: #333;
font-size: 13px;
white-space: nowrap;
transition: background-color 150ms, border-color 150ms;
}
.lovart-suggestion:hover { background: #f7f7f7; border-color: #d5d5d5; }
.lovart-suggestion:focus-visible,
.lovart-start-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
.lovart-start-action {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 9px;
min-width: 172px;
height: 40px;
padding: 0 16px;
border: 1px solid #e5e5e5;
border-radius: 999px;
background: white;
color: #333;
font-size: 14px;
font-weight: 400;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
transition: background-color 150ms, border-color 150ms;
}
.lovart-start-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-bottom: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer { padding: 0 8px 8px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] {
min-height: 120px;
border-color: #e5e5e5;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
}
/* The promo backplate continues underneath the composer's rounded corners. */
.lovart-chat-promo-connected { padding: 0 8px; }
.lovart-chat-promo-surface { padding-bottom: 24px; border-radius: 24px 24px 0 0; background: #ebebeb; }
.lovart-chat-promo-row { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 10px 0 16px; }
.lovart-chat-promo-message { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; padding: 0; border: 0; background: transparent; color: #202020; font-size: 14px; font-weight: 400; line-height: 20px; text-align: left; cursor: pointer; }
.lovart-chat-promo-message:disabled { cursor: default; opacity: 1; }
.lovart-chat-promo-message > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lovart-chat-promo-badge { flex-shrink: 0; }
.lovart-chat-promo-close { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; border: 0; border-radius: 50%; background: transparent; color: #262626; cursor: pointer; }
.lovart-chat-promo-close:hover { background: rgb(0 0 0 / 5%); }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-promo-connected + .lovart-chat-composer { position: relative; z-index: 1; margin-top: -24px; background: transparent; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor][data-canvas-composer-mode="image"] { min-height: 184px; border-radius: 24px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-upload { margin: 8px 8px 0; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { flex-direction: column; gap: 8px; width: 56px; height: 68px; border: 0; border-radius: 18px; background: #f5f5f5; color: #aaa; font-size: 12px; font-weight: 400; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #ececec; color: #777; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile > svg { width: 14px; height: 14px; stroke-width: 1.5; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-text-region { padding: 12px 16px 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"] { min-height: 40px; font-size: 14px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"]:empty::before { color: #b0b0b0; }
.dark .lovart-chat-promo-surface { background: #27272a; }
.dark .lovart-chat-promo-message, .dark .lovart-chat-promo-close { color: #eee; }
.dark .lovart-chat-promo-close:hover { background: rgb(255 255 255 / 10%); }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { background: #27272a; color: #a1a1aa; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #3f3f46; color: #eee; }
.dark .lovart-suggestion,
.dark .lovart-start-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
.dark .lovart-suggestion:hover,
.dark .lovart-start-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-color: rgb(255 255 255 / 10%); }
.dark [data-canvas-right-panel="true"] > .border-l { border-left-color: #27272a; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] { border-color: rgb(255 255 255 / 12%); }
@media (max-width: 640px) {
.lovart-home-surface main { padding-top: calc(72px + var(--home-banner-offset, 0px)); }
.lovart-hero { margin-top: 16px; }
.lovart-home-composer { border-radius: 20px; }
.lovart-suggestion { padding: 0 11px; font-size: 12px; }
.lovart-start-action { min-width: 160px; }
.lovart-chat-welcome h1 { font-size: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.lovart-home-surface *, .lovart-canvas-surface * { scroll-behavior: auto !important; }
}
.lovart-home-surface :is(button, input, textarea, select),
.lovart-canvas-surface :is(button, input, textarea, select) { font-family: inherit; }
@media (max-width: 640px) {
.lovart-canvas-surface[data-canvas-panel-open="true"] :is([data-canvas-topbar], [data-canvas-floating-toolbar], [data-canvas-compact-controls]) { display: none; }
}
/* Reference icon sizing is independent of the application's root rem scale. */
.lovart-suggestion { gap: 8px; padding: 0 12px; }
.lovart-start-action { min-width: 172.71875px; gap: 8px; padding: 0 12px; }
.lovart-canvas-surface [data-canvas-floating-toolbar] button { min-width: 32px; height: 32px; border-radius: 8px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector { width: 90px; height: 32px; padding: 0 8px; background: transparent; font-size: 14px; font-weight: 400; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector > span { font-size: 13px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector:hover,
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params:hover,
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector:hover { background: rgb(0 0 0 / 4%); }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params { width: 105.3828125px; min-width: 105.3828125px; height: 32px; padding: 0 4px 0 12px; background: transparent; font-size: 13px; gap: 4px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector { flex: 0 0 121px; width: 121px; height: 32px; padding: 0 4px 0 8px; gap: 0; border: 0; background: transparent; box-shadow: none; font-size: 13px; font-weight: 400; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 8px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > span:not([data-reference-icon]) { max-width: 65px; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params) { color: #eee; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params, .lovart-composer-mode-selector):hover { background: rgb(255 255 255 / 8%); }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer { padding: 0 8px 8px; gap: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:first-child { flex: 0 0 90px; padding-right: 0; overflow: visible; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:last-child { flex: 1 1 0; min-width: 0; gap: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-send { min-width: 32px; width: auto; height: 32px; padding: 0 8px; gap: 2px; font-size: 13px; font-weight: 400; }
/* Adapt to the resizable chat panel, independently of the browser width. */
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] { container: canvas-composer / inline-size; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer { gap: 4px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer > div:first-child { flex: 0 0 auto; overflow: visible; padding-right: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer > div:last-child { flex: 0 1 auto; min-width: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-mode-selector { width: auto; max-width: none; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params { width: auto; min-width: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params-label { overflow: visible; text-overflow: clip; white-space: nowrap; }
.lovart-image-params-icon { display: none; }
@container canvas-composer (max-width: 480px) {
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-mode-selector { width: 32px; min-width: 32px; padding: 0; }
.lovart-composer-mode-selector .lovart-composer-mode-label,
.lovart-composer-mode-selector [data-reference-icon="chevron-down"] { display: none; }
.lovart-composer-mode-selector > span > :first-child { margin-right: 0; }
}
@container canvas-composer (max-width: 360px) {
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-model-selector { flex: 0 0 32px; width: 32px; padding: 0; justify-content: center; }
.lovart-model-selector > span:not([data-reference-icon]),
.lovart-model-selector > [data-reference-icon="chevron-down"] { display: none; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 0; }
}
@container canvas-composer (max-width: 480px) {
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params { width: 32px; min-width: 32px; padding: 0; justify-content: center; }
.lovart-image-params .lovart-image-params-icon { display: block; }
.lovart-image-params .lovart-image-params-label,
.lovart-image-params > [data-reference-icon="chevron-down"] { display: none; }
}
/* The existing image toolbar is scaled to 80%; these dimensions render at
the reference's 44px bar, 32px buttons, and 20px icons. */
.lovart-image-action-bar {
height: 55px;
gap: 5px;
padding: 0 7.5px;
border: 1.25px solid rgba(0, 0, 0, 0.07);
border-radius: 17.5px;
background: #fff !important;
color: #000;
box-shadow: 0 1.25px 2.5px rgba(0, 0, 0, 0.04);
backdrop-filter: none;
}
.lovart-image-action-bar::before { display: none; }
.lovart-image-action-bar > button {
width: 40px;
min-width: 40px;
height: 40px;
margin: 0;
gap: 0;
padding: 0;
border-radius: 10px;
background: transparent;
color: #000;
font-size: 0;
font-weight: 400;
box-shadow: none;
}
.lovart-image-action-bar > button:hover,
.lovart-image-action-bar > button[data-state="open"],
.lovart-image-action-bar > button[data-reference-active="true"] {
background: rgba(0, 0, 0, 0.04);
color: #000;
}
.lovart-image-action-bar > button:disabled {
background: transparent;
color: rgba(0, 0, 0, 0.3);
}
.lovart-image-action-bar > button > svg,
.lovart-image-action-bar > button > [data-reference-icon],
.lovart-image-action-bar > button > span[aria-hidden="true"],
.lovart-image-action-bar > button > img {
width: 25px;
height: 25px;
margin: 0;
flex-shrink: 0;
}
.lovart-image-action-bar > button > img { filter: brightness(0); }
.lovart-image-action-bar > div.h-px,
.lovart-image-action-bar > div.w-px {
width: 1.25px;
height: 55px;
margin: 0 2.5px;
background: rgba(0, 0, 0, 0.07);
}
.lovart-image-action-menu [role="menuitem"] > svg,
.lovart-image-action-menu [role="menuitem"] > [data-reference-icon] {
width: 18px;
height: 18px;
}
.dark .lovart-image-action-bar {
border-color: rgba(255, 255, 255, 0.1);
background: #18191c !important;
color: #fff;
}
.dark .lovart-image-action-bar > button { color: #fff; }
.dark .lovart-image-action-bar > button:hover,
.dark .lovart-image-action-bar > button[data-state="open"],
.dark .lovart-image-action-bar > button[data-reference-active="true"] {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.dark .lovart-image-action-bar > button:disabled { color: rgba(255, 255, 255, 0.3); }
.dark .lovart-image-action-bar > button > img { filter: brightness(0) invert(1); }
.dark .lovart-image-action-bar > div.w-px { background: rgba(255, 255, 255, 0.1); }
.lovart-image-action-bar[data-show-tool-names="true"] > button[data-tool-key] { width: auto; gap: 7.5px; padding: 0 12.5px; font-size: 16.25px; }
.lovart-image-tool-label { white-space: nowrap; }
.lovart-image-tool-tooltip { z-index: 10030; border: 0; border-radius: 6px; background: #1b1b1b; padding: 6px 9px; color: #fff; font-size: 13px; line-height: 18px; box-shadow: 0 4px 10px #00000012; --tw-ring-shadow: 0 0 transparent; }
.lovart-image-custom-more { min-width: 172px; padding: 6px 12px; border: 1px solid #e8e8e8; border-radius: 12px; background: #fff; }
.lovart-image-custom-more [role="menuitem"] { min-height: 32px; gap: 10px; padding: 4px 0; font-size: 14px; font-weight: 400; }
.lovart-image-custom-more .lovart-customize-entry { justify-content: center; gap: 5px; }
.lovart-toolbar-customizer-overlay { z-index: 10010; background: #0006; }
.lovart-toolbar-customizer { z-index: 10020; display: flex; flex-direction: column; width: min(720px, calc(100vw - 32px)); max-width: 720px; max-height: calc(100dvh - 48px); gap: 0; padding: 20px 24px 24px; border: 1px solid #e5e5e5; border-radius: 16px; background: #fff; color: #242424; box-shadow: 0 8px 24px #00000012; }
.lovart-customizer-header { display: flex; flex-shrink: 0; justify-content: space-between; align-items: flex-start; gap: 16px; }
.lovart-customizer-header h2 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 600; }
.lovart-customizer-header p { margin-top: 2px; color: inherit; font-size: 14px; line-height: 22px; }
.lovart-customizer-header-actions { display: flex; gap: 4px; }
.lovart-customizer-header-actions button { display: flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 7px; }
.lovart-customizer-header-actions button:last-child { background: #f1f1f1; }
.lovart-customizer-header-actions button:hover { background: #e9e9e9; }
.lovart-customizer-body { min-height: 0; overflow-y: auto; margin: 24px 0 20px; }
.lovart-customizer-preview { display: flex; height: 180px; align-items: center; overflow: hidden; padding: 16px; border-radius: 10px; background: #e5edef url("/icons/lovart/toolbar-preview.png") center / cover; }
.lovart-customizer-preview-bar { display: flex; align-items: center; flex: 1; min-width: 0; gap: 4px; padding: 5px 8px; border: 1px solid #e5e5e5; border-radius: 14px; background: #fff; overflow-x: auto; scrollbar-width: thin; }
.lovart-preview-fixed,.lovart-preview-chip { display: flex; align-items: center; flex-shrink: 0; height: 32px; gap: 8px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
.lovart-preview-chip { position: relative; margin-top: 5px; margin-bottom: 5px; border-radius: 8px; background: #f4f4f4; cursor: grab; }
.lovart-preview-chip:active { cursor: grabbing; }
.lovart-preview-chip > button:last-child { position: absolute; top: -5px; right: -3px; display: flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid #e6e6e6; border-radius: 50%; background: #fff; color: #888; }
.lovart-customizer-tools { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; margin-top: 24px; }
.lovart-customizer-tools > button { display: flex; align-items: center; gap: 10px; height: 36px; min-width: 0; padding: 0 12px; border: 1px solid #e2e2e2; border-radius: 8px; background: #fff; font-size: 14px; text-align: left; }
.lovart-customizer-tools > button > span:not([aria-hidden]) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lovart-customizer-tools > button > svg:last-child { flex-shrink: 0; }
.lovart-customizer-tools > button:hover { background: #f7f7f7; }
.lovart-customizer-footer { display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; gap: 16px; }
.lovart-customizer-footer > label,.lovart-customizer-footer > div { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.lovart-customizer-switch { width: 32px; height: 18px; padding: 2px; border-radius: 20px; background: #d5d5d5; }
.lovart-customizer-switch > span { display: block; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s; }
.lovart-customizer-switch[aria-checked="true"] { background: #2c2c2c; }
.lovart-customizer-switch[aria-checked="true"] > span { transform: translateX(14px); }
.lovart-customizer-footer > div > button { width: 80px; height: 36px; border: 1px solid #e3e3e3; border-radius: 8px; font-size: 14px; }
.lovart-customizer-footer .lovart-customizer-save { border-color: #2c2c2c; background: #2c2c2c; color: #fff; }
.lovart-customizer-footer > div > button:hover { opacity: .8; }
.lovart-toolbar-customizer button:focus-visible,.lovart-preview-chip:focus-visible { outline: 2px solid #397dff; outline-offset: 2px; }
.dark .lovart-toolbar-customizer,.dark .lovart-image-custom-more { background: #202124; border-color: #3d3e40; color: #f5f5f5; }
.dark .lovart-customizer-preview-bar,.dark .lovart-customizer-tools > button { background: #26272a; border-color: #46474a; }
.dark .lovart-preview-chip,.dark .lovart-customizer-header-actions button:last-child { background: #35363a; }
.dark .lovart-preview-chip > button:last-child { background: #26272a; border-color: #46474a; color: #ccc; }
.dark .lovart-customizer-tools > button:hover { background: #35363a; }
@media(max-width:600px) { .lovart-toolbar-customizer { padding: 18px; } .lovart-customizer-tools { grid-template-columns: repeat(2,minmax(0,1fr)); } .lovart-customizer-preview { height: 140px; } }
.lovart-preview-handle { display: flex; align-items: center; gap: 8px; white-space: nowrap; cursor: grab; }
.lovart-image-tool-tooltip { display: flex; align-items: center; gap: 8px; }
.lovart-image-tool-cost { margin-left: auto; padding: 0; background: transparent; color: #999; font-size: 12px; font-weight: 400; box-shadow: none; }
.lovart-image-tool-cost > svg { width: 12px; height: 12px; }
/* Preserve the canvas's own skills as the primary welcome content. */
.canvas-welcome-action {
display: inline-flex;
min-height: 36px;
min-width: 0;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid #e5e5e5;
border-radius: 999px;
background: white;
color: #333;
font-size: 12px;
line-height: 16px;
transition: background-color 150ms, border-color 150ms;
}
.canvas-welcome-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
.canvas-welcome-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
.dark .canvas-welcome-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
.dark .canvas-welcome-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }

View File

@ -3,7 +3,6 @@ import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { FaviconUpdater } from "@/components/favicon-updater"; import { FaviconUpdater } from "@/components/favicon-updater";
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer"; import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import { AppUpdateNotice } from "@/components/app-update-notice";
import { getSiteSettings } from "@/lib/db/queries"; import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css"; import "./globals.css";
@ -95,32 +94,17 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
} }
} }
// Keep one attempt per route for the browser session. A cooldown alone can function safeRemoveSessionStorage(key) {
// turn a recurring editor exception into an endless reload cycle. try {
var RECOVERY_KEY = 'chunk-reload-attempted-v3:' + window.location.pathname; sessionStorage.removeItem(key);
return true;
} catch (e) {
return false;
}
}
var RECOVERY_KEY = 'chunk-reload-attempted-v2';
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0; var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
var recoveryNoticeShown = false;
function isFlowBoardEditing() {
var path = window.location.pathname;
return path === '/flow-board' || path.indexOf('/flow-board/') === 0;
}
function showManualRecoveryNotice() {
if (recoveryNoticeShown) return;
recoveryNoticeShown = true;
function show() {
if (!document.body || !isFlowBoardEditing()) return;
var notice = document.createElement('div');
notice.setAttribute('role', 'alert');
notice.setAttribute('data-runtime-recovery-notice', 'true');
notice.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:calc(100vw - 32px);padding:12px 16px;border:1px solid var(--border,#e4e4e7);border-radius:12px;background:var(--background,#fff);color:var(--foreground,#27272a);box-shadow:0 4px 20px #0002;font:14px/1.5 system-ui;';
notice.textContent = '部分页面资源加载失败,请先保存白板后手动刷新页面。';
document.body.appendChild(notice);
}
if (document.body) show();
else window.addEventListener('DOMContentLoaded', show, { once: true });
}
function shouldAutoRecover(message) { function shouldAutoRecover(message) {
if (!message) return false; if (!message) return false;
@ -133,26 +117,20 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
normalized.indexOf('error loading dynamically imported module') !== -1 || normalized.indexOf('error loading dynamically imported module') !== -1 ||
normalized.indexOf('importing a module script failed') !== -1 || normalized.indexOf('importing a module script failed') !== -1 ||
normalized.indexOf('module script load failed') !== -1 || normalized.indexOf('module script load failed') !== -1 ||
normalized.indexOf('failed to find server action') !== -1 normalized.indexOf('/_next/static/chunks/') !== -1 ||
normalized.indexOf('failed to find server action') !== -1 ||
normalized.indexOf('server components render') !== -1 ||
normalized.indexOf('application error: a client-side exception') !== -1
); );
} }
function triggerAutoRecover(reason) { function triggerAutoRecover(reason) {
if (isFlowBoardEditing()) {
showManualRecoveryNotice();
return;
}
if (lastReloadAt) return;
var now = Date.now(); var now = Date.now();
// If storage is blocked, a reload cannot remember its attempt and must not run. if (lastReloadAt && now - lastReloadAt < 30000) return;
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
lastReloadAt = now; lastReloadAt = now;
console.warn('[RuntimeRecover] 检测到页面资源加载失败,尝试恢复一次...', reason || ''); console.warn('[RuntimeRecover] 检测到前端运行时异常,自动刷新页面...', reason || '');
safeSetSessionStorage(RECOVERY_KEY, String(now));
setTimeout(function() { setTimeout(function() {
if (isFlowBoardEditing()) {
showManualRecoveryNotice();
return;
}
window.location.reload(); window.location.reload();
}, 800); }, 800);
} }
@ -160,6 +138,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
window.addEventListener('error', function(event) { window.addEventListener('error', function(event) {
var message = ''; var message = '';
if (event && event.message) message += event.message; if (event && event.message) message += event.message;
if (event && event.filename) message += ' ' + event.filename;
if (event && event.error && event.error.message) { if (event && event.error && event.error.message) {
message += ' ' + event.error.message; message += ' ' + event.error.message;
} }
@ -178,6 +157,12 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
} }
}); });
// 页面稳定一段时间后清除标记,避免旧资源异常无限刷新。
window.addEventListener('load', function() {
setTimeout(function() {
safeRemoveSessionStorage(RECOVERY_KEY);
}, 120000);
});
})();`; })();`;
export default function RootLayout({ export default function RootLayout({
@ -216,7 +201,6 @@ export default function RootLayout({
enableSystem enableSystem
> >
<Toaster position="top-center" /> <Toaster position="top-center" />
<AppUpdateNotice />
<SessionProvider>{children}</SessionProvider> <SessionProvider>{children}</SessionProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@ -1,6 +1,303 @@
#!/usr/bin/env bash #!/bin/bash
set -euo pipefail
printf '%s\n' 'Legacy deployment entry point disabled: it targets an obsolete host/runtime.' >&2 # 自动部署脚本 - 包含密码
printf '%s\n' 'Use: python scripts/production48.py status' >&2 # 使用方法: ./auto-deploy.sh
printf '%s\n' 'Then follow docs/production-48.md for build, stage, activate and rollback.' >&2
exit 2 set -e
# 服务器配置
SERVER="156.225.27.52"
PORT="5204"
USER="root"
PASSWORD="${DEPLOY_SSH_PASSWORD:?set DEPLOY_SSH_PASSWORD}"
REMOTE_DIR="/var/www/ai-chatbot"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${GREEN}开始自动部署到服务器...${NC}"
# 检查是否安装了 sshpass
if ! command -v sshpass &> /dev/null; then
echo -e "${YELLOW}安装 sshpass...${NC}"
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
if ! command -v brew &> /dev/null; then
echo -e "${RED}请先安装 Homebrew: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"${NC}"
exit 1
fi
brew install hudochenkov/sshpass/sshpass
else
# Linux
sudo apt-get update && sudo apt-get install -y sshpass
fi
fi
# SSH 连接函数
ssh_cmd() {
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -p $PORT $USER@$SERVER "$1"
}
scp_cmd() {
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -P $PORT "$1" $USER@$SERVER:"$2"
}
scp_dir() {
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -P $PORT -r "$1" $USER@$SERVER:"$2"
}
echo -e "${GREEN}步骤 1: 检查服务器连接...${NC}"
ssh_cmd "echo '连接成功' && uname -a"
echo -e "${GREEN}步骤 2: 检查并安装必要软件...${NC}"
ssh_cmd "
# 检查 Node.js
if ! command -v node &> /dev/null; then
echo '安装 Node.js...'
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
export NVM_DIR=\"\$HOME/.nvm\"
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
nvm install 20
nvm use 20
nvm alias default 20
fi
# 检查 pnpm
if ! command -v pnpm &> /dev/null; then
echo '安装 pnpm...'
npm install -g pnpm
fi
# 检查 PM2
if ! command -v pm2 &> /dev/null; then
echo '安装 PM2...'
npm install -g pm2
fi
# 检查 PostgreSQL
if ! command -v psql &> /dev/null; then
echo '安装 PostgreSQL...'
apt-get update -y
apt-get install -y postgresql postgresql-contrib
systemctl start postgresql
systemctl enable postgresql
fi
# 检查 Nginx
if ! command -v nginx &> /dev/null; then
echo '安装 Nginx...'
apt-get install -y nginx
systemctl start nginx
systemctl enable nginx
fi
# 创建项目目录
mkdir -p $REMOTE_DIR
mkdir -p /var/log/pm2
mkdir -p /root/backups/ai-chatbot
"
echo -e "${GREEN}步骤 3: 配置数据库...${NC}"
ssh_cmd "
# 检查并创建/更新数据库用户
echo '配置数据库用户...'
sudo -u postgres psql << 'PSQL'
-- 删除已存在的用户(如果存在)
DROP USER IF EXISTS chatbot_user;
-- 创建新用户
CREATE USER chatbot_user WITH PASSWORD 'ChatBot@2024!Secure';
-- 检查数据库是否存在
SELECT 1 FROM pg_database WHERE datname='ai_chatbot'\\gexec
-- 如果不存在则创建
DO \\$\\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'ai_chatbot') THEN
CREATE DATABASE ai_chatbot;
END IF;
END
\\$\\$;
-- 授予权限
GRANT ALL PRIVILEGES ON DATABASE ai_chatbot TO chatbot_user;
-- 连接到数据库并授予schema权限
\\c ai_chatbot
GRANT ALL ON SCHEMA public TO chatbot_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO chatbot_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO chatbot_user;
\\q
PSQL
echo '数据库配置完成'
"
echo -e "${GREEN}步骤 4: 上传项目文件...${NC}"
# 创建临时部署包
echo "准备部署文件..."
tar --exclude='node_modules' \
--exclude='.next' \
--exclude='.git' \
--exclude='.env.local' \
--exclude='*.log' \
--exclude='deploy.tar.gz' \
-czf /tmp/deploy.tar.gz .
# 上传部署包
scp_cmd "/tmp/deploy.tar.gz" "/tmp/"
# 在服务器上解压
ssh_cmd "
cd $REMOTE_DIR
tar -xzf /tmp/deploy.tar.gz
rm /tmp/deploy.tar.gz
if [ -f \"$REMOTE_DIR/scripts/security/install-security-stack.sh\" ]; then
echo '安装 security-stack...'
bash \"$REMOTE_DIR/scripts/security/install-security-stack.sh\" || echo 'security-stack 安装失败,继续部署'
elif [ -f \"$REMOTE_DIR/scripts/security/install-perf-guard.sh\" ]; then
echo '安装 perf-guard...'
bash \"$REMOTE_DIR/scripts/security/install-perf-guard.sh\" || echo 'perf-guard 安装失败,继续部署'
fi
"
echo -e "${GREEN}步骤 5: 配置环境变量...${NC}"
ssh_cmd "
cd $REMOTE_DIR
# 生成 AUTH_SECRET
AUTH_SECRET=\$(openssl rand -base64 32)
# 创建 .env.local 文件
cat > .env.local << EOF
POSTGRES_URL=postgresql://chatbot_user:ChatBot@2024!Secure@localhost:5432/ai_chatbot
AUTH_SECRET=\$AUTH_SECRET
AUTH_URL=http://$SERVER
NODE_ENV=production
EOF
echo '环境变量文件已创建'
"
echo -e "${GREEN}步骤 6: 安装依赖和构建...${NC}"
ssh_cmd "
cd $REMOTE_DIR
export NVM_DIR=\"\$HOME/.nvm\"
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
echo '安装依赖...'
pnpm install --production=false
echo '运行数据库迁移...'
pnpm db:migrate || echo '迁移可能已存在,继续...'
echo '构建项目...'
pnpm build
"
echo -e "${GREEN}步骤 7: 配置 PM2...${NC}"
# 上传 PM2 配置文件
scp_cmd "ecosystem.config.js" "$REMOTE_DIR/"
ssh_cmd "
cd $REMOTE_DIR
export NVM_DIR=\"\$HOME/.nvm\"
[ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"
# 停止旧进程(如果存在)
pm2 delete ai-chatbot 2>/dev/null || true
# 启动应用
pm2 start ecosystem.config.js
pm2 save
# 设置开机自启
pm2 startup systemd -u root --hp /root 2>/dev/null || echo 'PM2 startup 已配置'
"
echo -e "${GREEN}步骤 8: 配置 Nginx...${NC}"
ssh_cmd "
# 创建 Nginx 配置
cat > /etc/nginx/sites-available/ai-chatbot << 'NGINX'
server {
listen 80;
server_name $SERVER;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}
NGINX
# 启用站点
ln -sf /etc/nginx/sites-available/ai-chatbot /etc/nginx/sites-enabled/
# 删除默认配置(如果存在)
rm -f /etc/nginx/sites-enabled/default
# 测试配置
nginx -t
# 重启 Nginx
systemctl restart nginx
"
echo -e "${GREEN}步骤 9: 配置防火墙...${NC}"
ssh_cmd "
ufw --force enable
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 5204/tcp
ufw allow 3000/tcp
"
echo -e "${GREEN}步骤 10: 检查部署状态...${NC}"
ssh_cmd "
echo '=== PM2 状态 ==='
pm2 status
echo ''
echo '=== 应用日志(最后20行)==='
pm2 logs ai-chatbot --lines 20 --nostream
echo ''
echo '=== Nginx 状态 ==='
systemctl status nginx --no-pager -l
echo ''
echo '=== 端口监听 ==='
netstat -tulpn | grep -E ':(80|3000|5432)' || ss -tulpn | grep -E ':(80|3000|5432)'
"
# 清理本地临时文件
rm -f /tmp/deploy.tar.gz
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}部署完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "${YELLOW}访问地址: http://$SERVER${NC}"
echo -e "${YELLOW}管理员设置: http://$SERVER/admin/settings${NC}"
echo -e "${YELLOW}查看日志: ssh -p $PORT $USER@$SERVER 'pm2 logs ai-chatbot'${NC}"
echo -e "${YELLOW}查看状态: ssh -p $PORT $USER@$SERVER 'pm2 status'${NC}"
echo ""
echo -e "${YELLOW}数据库信息:${NC}"
echo -e " 数据库名: ai_chatbot"
echo -e " 用户名: chatbot_user"
echo -e " 密码: ChatBot@2024!Secure"
echo ""
echo -e "${YELLOW}注意: 请尽快修改数据库密码和服务器密码!${NC}"

View File

@ -1,30 +0,0 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
export function AdminSurface({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let context: { revert: () => void } | undefined;
let disposed = false;
import("gsap").then(({ gsap }) => {
if (disposed) return;
context = gsap.context(() => {
gsap.fromTo(
ref.current,
{ y: 6, opacity: 0.85 },
{ y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" },
);
}, ref);
});
return () => {
disposed = true;
context?.revert();
};
}, []);
return (
<div ref={ref} className="space-y-6">
{children}
</div>
);
}

View File

@ -1,603 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import useSWR from "swr";
import {
RefreshCw,
Search,
ChevronLeft,
ChevronRight,
ExternalLink,
Download,
Star,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner";
import { MediaThumbnail } from "./media-thumbnail";
import { AdminSurface } from "./admin-surface";
import { formatRecordTime } from "@/lib/admin/record-time";
import { buildImageProxyUrl } from "@/lib/image-delivery";
export type MediaRecord = {
id: string;
recordType: "image" | "video";
userName: string | null;
userEmail: string | null;
userDepartment: string | null;
createdAt: string;
modelId: string | null;
chatId: string | null;
thumbnailUrl: string;
originalUrl: string;
videoUrl?: string | null;
prompt: string | null;
imageSize: string | null;
isFeatured: boolean;
featuredCategory: string | null;
featuredAt?: string | null;
};
type MediaResponse = {
images: MediaRecord[];
pagination: {
total: number;
totalPages: number;
totalImages: number;
totalVideos: number;
page: number;
};
};
export async function adminFetcher(url: string) {
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试");
return data;
}
const selectClass =
"h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
function recentDate() {
const d = new Date();
d.setDate(d.getDate() - 6);
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d);
}
export function MediaRecordsPanel({ categories }: { categories: string[] }) {
const [view, setView] = useState("assets");
const [search, setSearch] = useState("");
const [filters, setFilters] = useState({
search: "",
type: "all",
startDate: recentDate(),
endDate: "",
model: "",
featured: "false",
status: "all",
});
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<MediaRecord | null>(null);
const [saving, setSaving] = useState(false);
const [originalFailed, setOriginalFailed] = useState(false);
const update = (changes: Partial<typeof filters>) => {
setFilters((f) => ({ ...f, ...changes }));
setPage(1);
};
useEffect(() => {
const timer = setTimeout(() => {
setFilters((f) => (f.search === search ? f : { ...f, search }));
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [search]);
const { data: options } = useSWR<{
models: Array<{ id: string; modelId: string; displayName: string; providerName: string }>;
}>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false });
const modelNames = new Map<string, string>();
for (const model of options?.models || [])
if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName);
const recordModelNames = new Map(modelNames);
for (const model of options?.models || []) recordModelNames.set(model.id, model.displayName);
const openDetails = (item: MediaRecord) => {
setSelected(item);
setOriginalFailed(false);
};
const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" });
const { data, error, isLoading, isValidating, mutate } = useSWR<MediaResponse>(
view === "assets" ? `/api/admin/images?${query}` : null,
adminFetcher,
{ keepPreviousData: true, revalidateOnFocus: false },
);
const tasks = useSWR<{
tasks: Array<{
id: string;
userName: string;
status: string;
createdAt: string;
error: string | null;
model: string | null;
elapsedMs: number | null;
}>;
total: number;
}>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, {
keepPreviousData: true,
revalidateOnFocus: false,
});
const busy = view === "assets" ? isValidating : tasks.isValidating;
const failure = view === "assets" ? error : tasks.error;
const loading = view === "assets" ? isLoading : tasks.isLoading;
const total = view === "assets" ? data?.pagination.total : tasks.data?.total;
const pages = Math.max(1, Math.ceil((total || 0) / 40));
async function save(changes: Partial<MediaRecord>) {
if (!selected || saving) return;
setSaving(true);
try {
const response = await fetch("/api/admin/images", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageId: selected.id, ...changes }),
});
if (!response.ok) throw new Error("保存失败,请重试");
setSelected({ ...selected, ...changes });
toast.success("已保存");
await mutate();
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
return (
<AdminSurface>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-tight">生成记录</h2>
<p className="mt-2 text-sm text-muted-foreground">
查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。
</p>
</div>
<Button
variant="outline"
disabled={busy}
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
>
<RefreshCw className={`mr-2 size-4 ${busy ? "motion-safe:animate-spin" : ""}`} />
刷新
</Button>
</div>
<div className="flex gap-2" role="tablist" aria-label="记录视图">
{[
["assets", "产物记录"],
["tasks", "画布任务"],
].map(([key, label]) => (
<Button
key={key}
role="tab"
aria-selected={view === key}
variant={view === key ? "default" : "ghost"}
onClick={() => {
setView(key);
setPage(1);
}}
>
{label}
</Button>
))}
</div>
<div className="flex flex-wrap items-end gap-3 rounded-xl border bg-background p-4">
<label className="min-w-48 flex-1 text-xs font-medium">
用户
<div className="relative mt-2">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
className="h-10 pl-9"
placeholder="搜索姓名或邮箱"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</label>
{view === "assets" ? (
<label className="text-xs font-medium">
类型
<select
aria-label="产物类型"
className={`${selectClass} mt-2 block`}
value={filters.type}
onChange={(e) => update({ type: e.target.value })}
>
<option value="all">图片与视频</option>
<option value="image">只看图片</option>
<option value="video">只看视频</option>
</select>
</label>
) : (
<label className="text-xs font-medium">
任务状态
<select
aria-label="任务状态"
className={`${selectClass} mt-2 block`}
value={filters.status}
onChange={(e) => update({ status: e.target.value })}
>
<option value="all">全部状态</option>
<option value="failed">失败</option>
<option value="running">运行中</option>
<option value="pending">排队中</option>
<option value="succeeded">成功</option>
</select>
</label>
)}
<label className="text-xs font-medium">
开始日期
<Input
aria-label="开始日期"
className="mt-2 h-10 w-40"
type="date"
value={filters.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
</label>
<label className="text-xs font-medium">
结束日期
<Input
aria-label="结束日期"
className="mt-2 h-10 w-40"
type="date"
value={filters.endDate}
onChange={(e) => update({ endDate: e.target.value })}
/>
</label>
<Button
variant="ghost"
onClick={() => {
setSearch("");
update({
search: "",
startDate: "",
endDate: "",
model: "",
featured: "false",
type: "all",
status: "all",
});
}}
>
全部时间 / 重置
</Button>
{view === "assets" && (
<div className="flex w-full flex-wrap items-center gap-3">
<select
aria-label="记录模型"
className={`${selectClass} max-w-full sm:max-w-96`}
value={filters.model}
onChange={(e) => update({ model: e.target.value })}
>
<option value="">全部模型</option>
{Array.from(modelNames, ([id, name]) => (
<option key={id} value={id}>
{name} · {id}
</option>
))}
</select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={filters.featured === "true"}
onChange={(e) => update({ featured: String(e.target.checked) })}
/>
只看精选
</label>
</div>
)}
</div>
<div
aria-live="polite"
className="flex items-center justify-between text-sm text-muted-foreground"
>
<span>
{total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`}
{view === "assets" && data
? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}`
: ""}
</span>
{busy && !loading && <span>正在更新…</span>}
</div>
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
第 {page} / {pages} 页 · 每页 40 条
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={busy || page === 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={busy || page >= pages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
{failure ? (
<div role="alert" className="rounded-xl border border-destructive/30 p-6 text-sm">
<p>{failure.message}</p>
<Button
className="mt-3"
variant="outline"
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
>
重试加载
</Button>
</div>
) : loading ? (
<div role="status" className="space-y-3 rounded-xl border p-6">
{[0, 1, 2, 3].map((x) => (
<div key={x} className="h-16 rounded-lg bg-muted motion-safe:animate-pulse" />
))}
<span className="sr-only">正在加载记录</span>
</div>
) : total === 0 ? (
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
没有符合条件的记录,请调整筛选条件。
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-background" aria-busy={busy}>
{view === "assets" ? (
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
{["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => (
<th key={i} className="px-4 py-3 font-medium">
{v}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{data?.images.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="p-4">
<div>
<MediaThumbnail
url={item.thumbnailUrl}
video={item.recordType === "video"}
onOpen={() => openDetails(item)}
/>
</div>
</td>
<td className="px-4 py-3">
<div className="font-medium">{item.userName || item.userEmail}</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.userDepartment || "未设置部门"}
</div>
</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums">
{formatRecordTime(item.createdAt)}
</td>
<td className="max-w-64 px-4 py-3">
<div className="truncate" title={item.modelId || ""}>
{recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.recordType === "video" ? "视频" : "图片"} ·{" "}
{item.imageSize || "未知尺寸"}
</div>
</td>
<td className="px-4 py-3">
{item.isFeatured ? (
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
<Star className="size-3" />
{item.featuredCategory || "精选"}
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-3">
<Button variant="ghost" size="sm" onClick={() => openDetails(item)}>
详情
</Button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-muted/50">
<tr>
{["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => (
<th key={x} className="px-4 py-3 font-medium">
{x}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{tasks.data?.tasks.map((task) => (
<tr key={task.id}>
<td className="px-4 py-4">
{task.userName}
<div className="mt-1 text-xs text-muted-foreground">
{formatRecordTime(task.createdAt)}
</div>
</td>
<td className="px-4 py-4">
{{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[
task.status
] || task.status}
</td>
<td className="max-w-48 break-words px-4 py-4">{task.model || "自动选择"}</td>
<td className="px-4 py-4 tabular-nums">
{task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`}
</td>
<td className="max-w-xl break-words px-4 py-4">
<p className={task.error ? "text-destructive" : "text-muted-foreground"}>
{task.error || "—"}
</p>
<code className="mt-2 block text-xs text-muted-foreground">{task.id}</code>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
每页 40 条 · 第 {page} / {pages} 页
</span>
<div className="flex items-center gap-2">
<Button variant="outline" disabled={busy || page === 1} onClick={() => setPage(1)}>
首页
</Button>
<Button
aria-label="上一页"
variant="outline"
disabled={busy || page === 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="下一页"
variant="outline"
disabled={busy || page >= pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="size-4" />
</Button>
<Button variant="outline" disabled={busy || page >= pages} onClick={() => setPage(pages)}>
末页
</Button>
</div>
</div>
<Dialog
open={!!selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto bg-background">
<DialogHeader>
<DialogTitle>{selected?.recordType === "video" ? "视频详情" : "图片详情"}</DialogTitle>
</DialogHeader>
{selected && (
<div className="space-y-6">
<div className="flex min-h-40 justify-center rounded-xl bg-muted">
{selected.recordType === "video" ? (
<video
src={selected.videoUrl || selected.originalUrl}
controls
preload="none"
className="max-h-[45vh] w-full"
/>
) : originalFailed ? (
<div role="alert" className="p-8 text-sm">
原图无法加载,源文件可能已过期或存储不可达。
<Button
className="ml-3"
variant="outline"
onClick={() => setOriginalFailed(false)}
>
重试
</Button>
</div>
) : (
<img
src={selected.originalUrl}
alt="生成原图"
className="max-h-[45vh] object-contain"
onError={() => setOriginalFailed(true)}
/>
)}
</div>
<dl className="grid gap-4 text-sm sm:grid-cols-2">
{[
["用户", selected.userName || selected.userEmail],
["部门", selected.userDepartment],
["生成时间", formatRecordTime(selected.createdAt)],
["尺寸", selected.imageSize],
["模型", recordModelNames.get(selected.modelId || "") || selected.modelId],
["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"],
].map(([k, v]) => (
<div key={k}>
<dt className="text-xs text-muted-foreground">{k}</dt>
<dd className="mt-1 break-words">{v || "未记录"}</dd>
</div>
))}
</dl>
<div>
<h3 className="text-sm font-medium">提示词</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
{selected.prompt || "未记录提示词"}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" asChild>
<a href={selected.originalUrl} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 size-4" />
打开原文件
</a>
</Button>
{selected.recordType === "image" && (
<Button variant="outline" asChild>
<a
href={buildImageProxyUrl(selected.originalUrl, { delivery: "original" })}
download
>
<Download className="mr-2 size-4" />
下载原图
</a>
</Button>
)}
</div>
{selected.recordType === "image" && (
<div className="flex flex-wrap items-center gap-3 rounded-xl border p-4">
<select
aria-label="精选分类"
className={selectClass}
value={selected.featuredCategory || ""}
onChange={(e) =>
setSelected({ ...selected, featuredCategory: e.target.value || null })
}
>
<option value="">未分类</option>
{[...new Set([...categories, selected.featuredCategory].filter(Boolean))].map(
(c) => (
<option key={c} value={c!}>
{c}
</option>
),
)}
</select>
<Button
variant="outline"
disabled={saving}
onClick={() => save({ featuredCategory: selected.featuredCategory })}
>
保存分类
</Button>
<Button
disabled={saving}
onClick={() => save({ isFeatured: !selected.isFeatured })}
>
<Star className="mr-2 size-4" />
{selected.isFeatured ? "取消精选" : "设为精选"}
</Button>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</AdminSurface>
);
}

View File

@ -1,150 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ImageOff, Video, RotateCw } from "lucide-react";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
import { queuePreview } from "@/lib/admin/preview-queue";
export function MediaThumbnail({
url,
video = false,
onOpen,
}: {
url: string;
video?: boolean;
onOpen?: () => void;
}) {
const container = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting");
const [attempt, setAttempt] = useState(0);
const [source, setSource] = useState("");
const [failure, setFailure] = useState("");
const retired = isRetiredStorageUrl(url);
useEffect(() => {
setState("waiting");
setSource("");
setFailure("");
if (video || retired || !url || !container.current) return;
let dispose: (() => void) | undefined;
let abort = () => {};
let objectUrl = "";
let alive = true;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || dispose) return;
observer.disconnect();
dispose = queuePreview((release) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 48000);
setState("loading");
// Fetch is abortable, so leaving a page also stops in-flight previews.
const parsed = new URL(url, window.location.origin);
const target =
parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search
? `${url}?imageView2/2/w/192/q/72/format/webp`
: buildImageProxyUrl(url, {
width: 192,
quality: 72,
format: "webp",
delivery: "proxy",
});
fetch(target, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) {
const detail = await response.json().catch(() => null);
throw new Error(`${detail?.error || "预览请求失败"}(HTTP ${response.status})`);
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) throw new Error("Not an image");
if (alive) {
objectUrl = URL.createObjectURL(blob);
setSource(objectUrl);
setState("ready");
}
})
.catch((error) => {
if (alive) {
setFailure(
error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败",
);
setState("error");
}
})
.finally(() => {
clearTimeout(timeout);
release();
});
abort = () => {
controller.abort();
clearTimeout(timeout);
};
});
},
{ rootMargin: "80px" },
);
observer.observe(container.current);
return () => {
alive = false;
observer.disconnect();
abort();
dispose?.();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [url, video, retired, attempt]);
return (
<div
ref={container}
className="relative flex h-16 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
data-preview-state={state}
>
{video ? (
<button
type="button"
aria-label="查看视频详情"
className="flex h-full w-full items-center justify-center"
onClick={onOpen}
>
<Video className="size-5 text-muted-foreground" aria-label="视频,点击查看后播放" />
</button>
) : retired ? (
<span className="text-center text-xs text-muted-foreground">
旧存储
<br />
已停用
</span>
) : source && state === "ready" ? (
<button type="button" aria-label="查看图片详情" className="h-full w-full" onClick={onOpen}>
<img
src={source}
alt="生成结果预览"
className="h-full w-full object-contain"
onError={() => {
setFailure("图片内容无法解码");
setState("error");
}}
/>
</button>
) : state === "error" ? (
<button
type="button"
className="flex h-full w-full flex-col items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground"
title={`${failure},点击重试`}
onClick={(e) => {
e.stopPropagation();
setAttempt((x) => x + 1);
}}
>
<ImageOff className="size-4" />
加载失败
</button>
) : (
<RotateCw
className={`size-4 text-muted-foreground ${state === "loading" ? "motion-safe:animate-spin" : ""}`}
aria-label={state === "loading" ? "正在加载预览" : "等待进入可见区域"}
/>
)}
</div>
);
}

View File

@ -1,191 +0,0 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetcher } from "./media-records-panel";
import { formatRecordTime } from "@/lib/admin/record-time";
type Event = {
id: string;
modelDisplayName: string;
modelRuntimeId: string;
providerName: string;
eventType: string;
createdAt: string;
errorMessage: string;
elapsedMs: number | null;
consecutiveFailures: number;
autoDisabled: boolean;
};
export function ModelHealthPanel({
providers,
models,
initialModelId = "",
}: {
initialModelId?: string;
providers: Array<{ id: string; name: string }>;
models: Array<{ id: string; displayName: string; providerId: string }>;
}) {
const [filters, setFilters] = useState({
providerId: "",
modelId: initialModelId,
eventType: "",
startDate: "",
endDate: "",
});
const [page, setPage] = useState(1);
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
const { data, error, isLoading, isValidating, mutate } = useSWR<{
events: Event[];
hasMore: boolean;
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
keepPreviousData: true,
revalidateOnFocus: false,
});
const update = (change: Partial<typeof filters>) => {
setFilters((f) => ({ ...f, ...change }));
setPage(1);
};
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">运行健康</h3>
<p className="mt-2 text-sm text-muted-foreground">
按上游和模型追踪故障,查看完整原因与发生时间。
</p>
</div>
<div className="flex flex-wrap gap-3">
{[
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
[
"modelId",
"全部模型",
models
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
.map((m) => [m.id, m.displayName]),
],
[
"eventType",
"全部错误",
[
["quota", "额度 / 余额"],
["timeout", "超时"],
["unavailable", "不可用"],
["invalid_model", "模型不可用"],
["download_error", "结果下载失败"],
["error", "其他错误"],
["success", "成功"],
],
],
].map(([key, label, options]) => (
<select
key={key as string}
aria-label={label as string}
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
value={filters[key as keyof typeof filters]}
onChange={(e) =>
update({
[key as string]: e.target.value,
...(key === "providerId" ? { modelId: "" } : {}),
})
}
>
<option value="">{label as string}</option>
{(options as string[][]).map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
))}
<Input
aria-label="故障开始日期"
className="h-10 w-40"
type="date"
value={filters.startDate}
onChange={(e) => update({ startDate: e.target.value })}
/>
<Input
aria-label="故障结束日期"
className="h-10 w-40"
type="date"
value={filters.endDate}
onChange={(e) => update({ endDate: e.target.value })}
/>
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
刷新日志
</Button>
</div>
{error ? (
<p role="alert" className="p-6 text-sm text-destructive">
日志加载失败,请点击刷新重试。
</p>
) : isLoading ? (
<p role="status" className="p-6 text-sm">
正在加载故障日志…
</p>
) : (
<div className="divide-y rounded-xl border">
{data?.events.map((event) => (
<div key={event.id} className="p-4">
<div className="flex flex-wrap items-center gap-3 text-sm">
<strong className="font-semibold">
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
</strong>
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
<span className="rounded bg-muted px-2 py-1 text-xs">
{{
quota: "额度 / 余额",
timeout: "超时",
unavailable: "不可用",
error: "服务错误",
invalid_model: "模型不可用",
download_error: "结果下载失败",
success: "成功",
}[event.eventType] || event.eventType}
</span>
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
{formatRecordTime(event.createdAt)}
</time>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
{event.errorMessage || "未记录详细原因"}
</p>
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
<span>连续失败 {event.consecutiveFailures} 次</span>
{event.elapsedMs !== null && (
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
)}
{event.autoDisabled && <span>已自动停用</span>}
</div>
</div>
))}
{!data?.events.length && (
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
)}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
第 {page} 页{isValidating && " · 正在更新…"}
</span>
<div className="flex gap-2">
<Button
variant="outline"
disabled={page === 1 || isValidating}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<Button
variant="outline"
disabled={!data?.hasMore || isValidating}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,326 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { AiModel } from "@/lib/db/schema";
import {
is3DModelDescriptor,
isVideoModelDescriptor,
isImageGenerationModelDescriptor,
} from "@/lib/ai/model-classification";
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
export function ModelManagementList({
models,
providers,
error,
loading,
onEdit,
onDelete,
onRestore,
onRefresh,
onHealth,
}: {
models: Model[];
providers: Provider[];
error?: Error;
loading: boolean;
onEdit: (model: Model) => Promise<void>;
onDelete: (id: string) => void;
onRestore: (model: Model) => void;
onRefresh: () => void;
onHealth?: (id: string) => void;
}) {
const [search, setSearch] = useState("");
const [provider, setProvider] = useState("");
const [status, setStatus] = useState("enabled");
const [kind, setKind] = useState("");
const [page, setPage] = useState(1);
const [opening, setOpening] = useState<string | null>(null);
const rows = useMemo(
() =>
models
.map((model) => {
const upstream = providers.find((p) => p.id === model.providerId);
const descriptor = { ...model, providerType: upstream?.type };
const type = is3DModelDescriptor(descriptor)
? "3d"
: isVideoModelDescriptor(descriptor)
? "video"
: isImageGenerationModelDescriptor(descriptor)
? "image"
: "chat";
return { model, upstream, type };
})
.filter(({ model, upstream, type }) => {
const text =
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
return (
text.includes(search.trim().toLowerCase()) &&
(!provider || model.providerId === provider) &&
(!kind || kind === type) &&
(status === "all" ||
(status === "enabled" && model.isEnabled) ||
(status === "disabled" && !model.isEnabled) ||
(status === "unhealthy" && model.healthStatus !== "healthy") ||
(status === "visible" && !model.isHidden))
);
}),
[models, providers, search, provider, status, kind],
);
const pages = Math.max(1, Math.ceil(rows.length / 25));
const currentPage = Math.min(page, pages);
const change = (fn: () => void) => {
fn();
setPage(1);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<div className="relative min-w-56 flex-1">
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
className="h-10 pl-9"
aria-label="搜索模型"
placeholder="搜索模型名称、ID 或上游"
value={search}
onChange={(e) => change(() => setSearch(e.target.value))}
/>
</div>
<select
aria-label="模型上游"
className={selectClass}
value={provider}
onChange={(e) => change(() => setProvider(e.target.value))}
>
<option value="">全部上游</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<select
aria-label="模型类型"
className={selectClass}
value={kind}
onChange={(e) => change(() => setKind(e.target.value))}
>
<option value="">全部类型</option>
<option value="image">生图</option>
<option value="video">视频</option>
<option value="chat">对话</option>
<option value="3d">3D / CAD</option>
</select>
<select
aria-label="模型状态"
className={selectClass}
value={status}
onChange={(e) => change(() => setStatus(e.target.value))}
>
<option value="enabled">已启用</option>
<option value="all">全部模型</option>
<option value="disabled">已停用</option>
<option value="unhealthy">运行异常</option>
<option value="visible">前台可见</option>
</select>
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
<RefreshCw className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{loading
? "正在加载模型…"
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
</p>
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
<span>
第 {currentPage} / {pages} 页
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage === pages}
onClick={() => setPage(currentPage + 1)}
>
下一页
</Button>
</div>
{error ? (
<div role="alert" className="rounded-xl border p-6 text-sm">
模型加载失败。
<Button variant="outline" onClick={onRefresh}>
重试
</Button>
</div>
) : loading ? (
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
) : (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full min-w-[850px] text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
<th key={x} className="px-4 py-3 font-medium">
{x}
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{rows
.slice((currentPage - 1) * 25, currentPage * 25)
.map(({ model, upstream, type }) => (
<tr key={model.id} className="hover:bg-muted/30">
<td className="max-w-64 px-4 py-4">
<div className="font-medium">{model.displayName}</div>
<div
className="mt-1 truncate text-xs text-muted-foreground"
title={model.modelId}
>
{model.modelId}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{model.isDefault && <span>默认模型</span>}
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
{model.isAgentModel && <span>Agent</span>}
{model.isMultimodal && <span>多模态</span>}
{model.isSubModel && <span>子模型</span>}
</div>
</td>
<td className="px-4 py-4">
{upstream?.name || "未知上游"}
<div className="mt-1 text-xs text-muted-foreground">
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
</div>
</td>
<td className="whitespace-nowrap px-4 py-4">
<span
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
>
{model.isEnabled ? "已启用" : "已停用"}
</span>
</td>
<td className="px-4 py-4">
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
</td>
<td className="max-w-72 px-4 py-4">
<span
className={
model.healthStatus !== "healthy"
? "text-amber-700 dark:text-amber-300"
: "text-muted-foreground"
}
>
{model.healthStatus === "auto_disabled"
? "自动停用"
: model.healthStatus === "degraded"
? "观察中"
: "正常"}
</span>
{model.healthConsecutiveFailures > 0 && (
<span className="ml-2 text-xs text-muted-foreground">
连续失败 {model.healthConsecutiveFailures} 次
</span>
)}
{model.healthLastError && (
<details className="mt-2 text-xs">
<summary className="cursor-pointer text-muted-foreground">
查看最近原因
</summary>
<p className="mt-2 whitespace-pre-wrap break-words">
{model.healthLastError}
</p>
</details>
)}
<button
type="button"
className="mt-2 block text-xs underline underline-offset-4"
onClick={() => onHealth?.(model.id)}
>
故障记录
</button>
<div className="mt-2 text-xs text-muted-foreground">
{model.excludeFromHealthBan && "不自动封禁 "}
{model.excludeFromFailover && "不参与迁移"}
</div>
</td>
<td className="px-4 py-4">
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={opening !== null}
onClick={async () => {
setOpening(model.id);
try {
await onEdit(model);
} finally {
setOpening(null);
}
}}
>
{opening === model.id ? "加载中…" : "编辑"}
</Button>
{model.healthStatus === "auto_disabled" && (
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
恢复
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => onDelete(model.id)}
>
删除
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
)}
</div>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
每页 25 个 · 第 {currentPage} / {pages} 页
</span>
<div className="flex gap-2">
<Button
aria-label="上一页模型"
variant="outline"
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
aria-label="下一页模型"
variant="outline"
disabled={currentPage === pages}
onClick={() => setPage(currentPage + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@ -576,11 +575,31 @@ export function AgentGenerationWorkspace({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) }); const response = await fetch(raw);
toast.success("原图已开始下载"); if (!response.ok) {
} catch (error) { throw new Error(`download failed: ${response.status}`);
toast.error(getCanvasImageDownloadErrorMessage(error)); }
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

@ -1,10 +1,7 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery"; import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch"; import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { import {
@ -4172,7 +4169,6 @@ export function AgentGenerationWorkspaceDarkV2({
? "agent-anchor-materials" ? "agent-anchor-materials"
: "agent-anchor-generate"; : "agent-anchor-generate";
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
const { const {
messages, messages,
setMessages, setMessages,
@ -4193,11 +4189,7 @@ export function AgentGenerationWorkspaceDarkV2({
void refreshHistory(); void refreshHistory();
} }
}, },
onFinish({ messages: finishedMessages }) { onFinish() { void refreshHistory(); },
const lastUser = finishedMessages.filter((message) => message.role === "user").at(-1);
setFinishedSceneRequestId(lastUser?.id || null);
void refreshHistory();
},
experimental_throttle: 100, experimental_throttle: 100,
generateId: generateUUID, generateId: generateUUID,
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
@ -4880,14 +4872,6 @@ export function AgentGenerationWorkspaceDarkV2({
return recoverReplacementRun({ messages, ...pendingGeneration }); return recoverReplacementRun({ messages, ...pendingGeneration });
}, [messages, pendingGeneration]); }, [messages, pendingGeneration]);
const transportIsGenerating = status === "streaming" || status === "submitted"; const transportIsGenerating = status === "streaming" || status === "submitted";
useEffect(() => {
const beforeRefresh = (event: Event) => {
if (transportIsGenerating || pendingGeneration)
(event as CustomEvent<PageRefreshRequest>).detail.block("当前生图任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [transportIsGenerating, pendingGeneration]);
const pendingGenerationProgressCount = useMemo(() => { const pendingGenerationProgressCount = useMemo(() => {
if (!pendingGeneration) { if (!pendingGeneration) {
return 0; return 0;
@ -5498,17 +5482,6 @@ export function AgentGenerationWorkspaceDarkV2({
setActiveResultModeType(currentResultModeType); setActiveResultModeType(currentResultModeType);
}, [currentResultModeType]); }, [currentResultModeType]);
useEffect(() => {
if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return;
const result = getSceneRunResult(messages, pendingGeneration.requestMessageId);
const persisted = getSceneRunResult(historyMessages, pendingGeneration.requestMessageId);
if (!result || (finishedSceneRequestId !== pendingGeneration.requestMessageId && !persisted?.count)) return;
setPendingGeneration(null);
if (result.count < pendingGeneration.expectedCount) {
toast.warning(`本次生成已结束,收到 ${result.count}/${pendingGeneration.expectedCount} 张图片。缺少的图片未继续生成,可调整张数后重新提交。`);
}
}, [pendingGeneration, transportIsGenerating, finishedSceneRequestId, messages, historyMessages]);
useEffect(() => { useEffect(() => {
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return; if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
// The backend has settled every requested card; an open SSE connection is no longer work. // The backend has settled every requested card; an open SSE connection is no longer work.
@ -6803,11 +6776,31 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) }); const response = await fetch(raw);
toast.success("原图已开始下载"); if (!response.ok) {
} catch (error) { throw new Error(`download failed: ${response.status}`);
toast.error(getCanvasImageDownloadErrorMessage(error)); }
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
} }
}, []); }, []);
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => { const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@ -726,11 +725,30 @@ export function AgentGenerationWorkspaceNew({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) }); const response = await fetch(raw);
toast.success("原图已开始下载"); if (!response.ok) {
} catch (error) { throw new Error(`download failed: ${response.status}`);
toast.error(getCanvasImageDownloadErrorMessage(error)); }
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

@ -112,8 +112,6 @@ const PREVIEW_DIMENSION_HANDLE_RADIUS = 3.2;
const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2; const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2;
const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2; const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2;
const PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH = 0.85; const PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH = 0.85;
const DIMENSION_FONT_FAMILY =
'"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif';
const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i; const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i;
const NUMBER_REGEX = /\d+(?:\.\d+)?/g; const NUMBER_REGEX = /\d+(?:\.\d+)?/g;
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/; const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
@ -570,54 +568,87 @@ function drawAnnotationToCanvas(params: {
context: CanvasRenderingContext2D; context: CanvasRenderingContext2D;
annotation: DimensionAnnotation; annotation: DimensionAnnotation;
imageSize: ImageSize; imageSize: ImageSize;
previewRect: PreviewRect;
annotations: DimensionAnnotation[]; annotations: DimensionAnnotation[];
autoDimensionValues?: AutoDimensionValues | null; autoDimensionValues?: AutoDimensionValues | null;
}) { }) {
const { context, annotation } = params; const { context, annotation, imageSize, annotations, autoDimensionValues } =
params;
const annotationColor = getAnnotationColor(annotation); const annotationColor = getAnnotationColor(annotation);
const geometry = getPreviewAnnotationGeometry(params); const label = getDimensionLabel({
annotation,
annotations,
autoDimensionValues,
});
const vector = getAnnotationVector(
annotation.x1,
annotation.y1,
annotation.x2,
annotation.y2
);
const rawAngleRadians = Math.atan2(vector.dy, vector.dx);
const angleRadians =
rawAngleRadians > Math.PI / 2
? rawAngleRadians - Math.PI
: rawAngleRadians < -Math.PI / 2
? rawAngleRadians + Math.PI
: rawAngleRadians;
const strokeWidth = clamp(
Math.min(imageSize.width, imageSize.height) * 0.001_35,
1,
2.4
);
const capLength = strokeWidth * 5.3;
const fontSize = clamp(
Math.round(Math.min(imageSize.width, imageSize.height) * 0.0176),
11,
27
);
const labelOffset = fontSize * 0.95;
const centerX = (annotation.x1 + annotation.x2) / 2 + vector.nx * labelOffset;
const centerY = (annotation.y1 + annotation.y2) / 2 + vector.ny * labelOffset;
const textOutlineColor = getAnnotationTextOutlineColor(annotation); const textOutlineColor = getAnnotationTextOutlineColor(annotation);
const textOutlineWidth = annotation.glow
? Math.max(1.5, fontSize * 0.18)
: Math.max(0.85, fontSize * 0.08);
context.save(); context.save();
context.strokeStyle = annotationColor; context.strokeStyle = annotationColor;
context.lineWidth = PREVIEW_DIMENSION_STROKE_WIDTH; context.lineWidth = strokeWidth;
context.lineCap = "round"; context.lineCap = "round";
context.lineJoin = "round"; context.lineJoin = "round";
context.shadowColor = annotation.glow context.shadowColor = annotation.glow
? colorWithAlpha(annotationColor, 0.42) ? colorWithAlpha(annotationColor, 0.52)
: "transparent"; : "transparent";
context.shadowBlur = annotation.glow ? 4 : 0; context.shadowBlur = annotation.glow ? Math.max(8, fontSize * 0.7) : 0;
context.beginPath(); context.beginPath();
context.moveTo(geometry.start.x, geometry.start.y); context.moveTo(annotation.x1, annotation.y1);
context.lineTo(geometry.end.x, geometry.end.y); context.lineTo(annotation.x2, annotation.y2);
context.stroke(); context.stroke();
context.lineWidth = PREVIEW_DIMENSION_CAP_STROKE_WIDTH;
drawDimensionCap({ drawDimensionCap({
context, context,
point: geometry.start, point: { x: annotation.x1, y: annotation.y1 },
size: PREVIEW_DIMENSION_TICK_LENGTH, size: capLength,
vector: geometry.vector, vector,
}); });
drawDimensionCap({ drawDimensionCap({
context, context,
point: geometry.end, point: { x: annotation.x2, y: annotation.y2 },
size: PREVIEW_DIMENSION_TICK_LENGTH, size: capLength,
vector: geometry.vector, vector,
}); });
context.translate(geometry.labelCenterX, geometry.labelCenterY); context.translate(centerX, centerY);
context.rotate((geometry.angleDegrees * Math.PI) / 180); context.rotate(angleRadians);
context.font = `500 ${PREVIEW_DIMENSION_FONT_SIZE}px ${DIMENSION_FONT_FAMILY}`; context.font = `500 ${fontSize}px "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif`;
context.textAlign = "center"; context.textAlign = "center";
context.textBaseline = "middle"; context.textBaseline = "middle";
context.lineWidth = getAnnotationTextOutlineWidth(annotation); context.lineWidth = textOutlineWidth;
context.strokeStyle = textOutlineColor; context.strokeStyle = textOutlineColor;
context.strokeText(geometry.label, 0, 0.5); context.strokeText(label, 0, 0);
context.fillStyle = annotationColor; context.fillStyle = annotationColor;
context.fillText(geometry.label, 0, 0.5); context.fillText(label, 0, 0);
context.restore(); context.restore();
} }
@ -1036,10 +1067,6 @@ export function AgentPsdTemplateExportPreview({
return; return;
} }
if (!imageSize || !previewRect) {
throw new Error("请等待预览图片加载完成后再下载");
}
const { image, dispose } = await loadImageFromCandidates([ const { image, dispose } = await loadImageFromCandidates([
buildImageProxyUrl(imageUrl, { delivery: "proxy" }), buildImageProxyUrl(imageUrl, { delivery: "proxy" }),
imageUrl, imageUrl,
@ -1055,25 +1082,18 @@ export function AgentPsdTemplateExportPreview({
} }
context.drawImage(image, 0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height);
// Draw in the same coordinates as the SVG, then map its image area to
// the original bitmap. The preview can use a smaller image variant.
context.save();
context.scale(
canvas.width / previewRect.width,
canvas.height / previewRect.height
);
context.translate(-previewRect.left, -previewRect.top);
for (const annotation of annotations) { for (const annotation of annotations) {
drawAnnotationToCanvas({ drawAnnotationToCanvas({
annotation, annotation,
annotations, annotations,
autoDimensionValues, autoDimensionValues,
context, context,
imageSize, imageSize: {
previewRect, width: canvas.width,
height: canvas.height,
},
}); });
} }
context.restore();
const blob = await new Promise<Blob>((resolve, reject) => { const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
@ -1105,8 +1125,6 @@ export function AgentPsdTemplateExportPreview({
downloadName, downloadName,
enableDimensionAnnotations, enableDimensionAnnotations,
imageUrl, imageUrl,
imageSize,
previewRect,
]); ]);
return ( return (
@ -1287,7 +1305,6 @@ export function AgentPsdTemplateExportPreview({
<text <text
dominantBaseline="middle" dominantBaseline="middle"
fill={annotationColor} fill={annotationColor}
fontFamily={DIMENSION_FONT_FAMILY}
fontSize={PREVIEW_DIMENSION_FONT_SIZE} fontSize={PREVIEW_DIMENSION_FONT_SIZE}
fontWeight={500} fontWeight={500}
paintOrder="stroke" paintOrder="stroke"

View File

@ -1,76 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
import { preparePageRefresh } from "@/lib/client/page-refresh";
export function AppUpdateNotice() {
const [availableVersion, setAvailableVersion] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let disposed = false;
let controller: AbortController | undefined;
let checking = false;
const check = async () => {
if (disposed || checking || document.visibilityState !== "visible") return;
checking = true;
controller = new AbortController();
const current = controller;
const timeout = window.setTimeout(() => current.abort(), 8000);
try {
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal });
if (!response.ok) return;
const data = await response.json();
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version)
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
} catch {
// A failed version read leaves the current workspace available.
} finally {
window.clearTimeout(timeout);
checking = false;
}
};
void check();
const interval = window.setInterval(check, 60_000);
window.addEventListener("focus", check);
window.addEventListener("online", check);
document.addEventListener("visibilitychange", check);
return () => {
disposed = true;
controller?.abort();
clearInterval(interval);
window.removeEventListener("focus", check);
window.removeEventListener("online", check);
document.removeEventListener("visibilitychange", check);
};
}, []);
const refresh = async () => {
if (refreshing) return;
setRefreshing(true);
setError("");
try {
await preparePageRefresh();
window.location.reload();
setRefreshing(false);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "当前内容同步失败,请保留此页并重试。");
setRefreshing(false);
}
};
return <AlertDialog open={Boolean(availableVersion)}>
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>平台已更新</AlertDialogTitle>
<AlertDialogDescription>需要刷新页面后继续使用。点击后会先同步当前画布并保留输入;正在处理的任务完成后再刷新。</AlertDialogDescription>
</AlertDialogHeader>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
<AlertDialogFooter>
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>;
}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react"; import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -21,8 +21,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_TOO_LARGE_MESSAGE } from "@/lib/image-upload-limits";
import { imageGenerationService } from "@/services/canvas";
import type { import type {
AgentWorkflowMaterialImage, AgentWorkflowMaterialImage,
AgentWorkflowMaterialQueryType, AgentWorkflowMaterialQueryType,
@ -165,8 +163,6 @@ export function CanvasAddImageDialog({
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]); const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const localUploadInputRef = useRef<HTMLInputElement>(null);
const [errorText, setErrorText] = useState<string | null>(null); const [errorText, setErrorText] = useState<string | null>(null);
const images = useMemo(() => normalizeImages(result), [result]); const images = useMemo(() => normalizeImages(result), [result]);
@ -186,13 +182,12 @@ export function CanvasAddImageDialog({
const handleDialogOpenChange = useCallback( const handleDialogOpenChange = useCallback(
(nextOpen: boolean) => { (nextOpen: boolean) => {
if (!nextOpen && (isUploading || isAdding)) return;
if (!nextOpen) { if (!nextOpen) {
resetDialogState(); resetDialogState();
} }
onOpenChange(nextOpen); onOpenChange(nextOpen);
}, },
[isAdding, isUploading, onOpenChange, resetDialogState] [onOpenChange, resetDialogState]
); );
const closeDialog = useCallback(() => { const closeDialog = useCallback(() => {
@ -268,51 +263,6 @@ export function CanvasAddImageDialog({
} }
}; };
const handleLocalUpload = async (files: File[]) => {
if (!files.length || isUploading || isAdding) return;
if (files.some((file) => !file.type.startsWith("image/"))) {
setErrorText("请选择图片文件");
return;
}
if (files.some((file) => file.size > IMAGE_UPLOAD_MAX_BYTES)) {
setErrorText(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
return;
}
setIsUploading(true);
setErrorText(null);
try {
const uploadedImages: CanvasAddImageDialogImage[] = [];
const failures: string[] = [];
for (const [index, file] of files.entries()) {
const upload = await imageGenerationService.uploadImage(file);
if (!upload.success || !upload.data?.url) {
failures.push(`${file.name}:${upload.error || "图片上传失败"}`);
continue;
}
uploadedImages.push({
id: upload.data.assetId || crypto.randomUUID(),
key: upload.data.pathname || file.name,
label: file.name.replace(/\.[^.]+$/, "") || file.name,
fileName: file.name,
imageUrl: upload.data.url,
assetId: upload.data.assetId,
mimeType: file.type,
sourceType: "upload",
group: "other",
sortOrder: index,
});
}
if (uploadedImages.length) await onAddImages(uploadedImages);
if (failures.length) setErrorText(failures.join(";"));
else closeDialog();
} catch (error) {
setErrorText(error instanceof Error ? error.message : "图片上传失败,请重试");
} finally {
setIsUploading(false);
}
};
return ( return (
<Dialog onOpenChange={handleDialogOpenChange} open={open}> <Dialog onOpenChange={handleDialogOpenChange} open={open}>
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0"> <DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
@ -322,38 +272,15 @@ export function CanvasAddImageDialog({
添加图片 添加图片
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。 添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[62vh] overflow-y-auto px-5 py-4"> <div className="max-h-[62vh] overflow-y-auto px-5 py-4">
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3"> <div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
<div className="flex flex-col gap-2.5 sm:flex-row"> <div className="flex flex-col gap-2.5 sm:flex-row">
<input
accept="image/*"
className="hidden"
multiple
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
void handleLocalUpload(files);
}}
ref={localUploadInputRef}
type="file"
/>
<Button <Button
className="h-9 shrink-0 justify-start gap-2" className="h-9 justify-start gap-2"
disabled={isUploading || isAdding}
onClick={() => localUploadInputRef.current?.click()}
type="button"
variant="secondary"
>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{isUploading ? "上传中…" : "本地上传"}
</Button>
<Button
className="h-9 shrink-0 justify-start gap-2"
disabled={isUploading || isAdding}
onClick={() => { onClick={() => {
onCreateBlankImage(); onCreateBlankImage();
closeDialog(); closeDialog();
@ -428,13 +355,13 @@ export function CanvasAddImageDialog({
</div> </div>
{!materialSearchEnabled ? ( {!materialSearchEnabled ? (
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground"> <div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
风格化功能或素材搜索步骤未开启,可从本地上传或添加空白图片。 风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
</div> </div>
) : null} ) : null}
</div> </div>
{errorText ? ( {errorText ? (
<div role="alert" className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive"> <div className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
{errorText} {errorText}
</div> </div>
) : null} ) : null}
@ -513,7 +440,6 @@ export function CanvasAddImageDialog({
<DialogFooter className="border-t px-5 py-3"> <DialogFooter className="border-t px-5 py-3">
<Button <Button
disabled={isUploading || isAdding}
onClick={closeDialog} onClick={closeDialog}
type="button" type="button"
variant="outline" variant="outline"
@ -522,7 +448,7 @@ export function CanvasAddImageDialog({
</Button> </Button>
<Button <Button
className="gap-2" className="gap-2"
disabled={!selectedImages.length || isAdding || isUploading} disabled={!selectedImages.length || isAdding}
onClick={() => void handleAddSelected()} onClick={() => void handleAddSelected()}
type="button" type="button"
> >

View File

@ -1,115 +0,0 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { Position } from "@/types/canvas";
type Props = {
position: Position | null;
isMac: boolean;
videoUploading: boolean;
onClose: () => void;
onUploadImage: () => void;
onUploadVideo: () => void;
onPaste: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onFitAll: () => void;
onResetZoom: () => void;
};
export function CanvasBlankContextMenu({
position,
isMac,
videoUploading,
onClose,
onUploadImage,
onUploadVideo,
onPaste,
onZoomIn,
onZoomOut,
onFitAll,
onResetZoom,
}: Props) {
const modifier = isMac ? "⌘" : "Ctrl+";
const itemClass =
"h-8 cursor-pointer justify-between whitespace-nowrap rounded-[5px] px-0 text-[14px] font-normal focus:bg-[#f5f5f5] dark:focus:bg-white/10";
const shortcut = (label: string) => (
<span className="ml-2 whitespace-nowrap text-[13px] text-[#aaa]">
{label}
</span>
);
return (
<DropdownMenu
modal={false}
open={position !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DropdownMenuTrigger asChild>
<button
aria-hidden="true"
tabIndex={-1}
data-canvas-no-drag="true"
className="pointer-events-none fixed h-px w-px opacity-0"
style={{ left: position?.x ?? 0, top: position?.y ?? 0 }}
type="button"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
aria-label="画布右键菜单"
align="start"
sideOffset={0}
collisionPadding={8}
data-canvas-blank-menu="true"
data-canvas-no-drag="true"
className="z-[10005] w-[200px] rounded-[12px] border-[#ededed] bg-white px-3 py-[5px] text-[#242424] shadow-[0_8px_20px_rgba(0,0,0,0.06)] ring-0 dark:border-white/10 dark:bg-zinc-900 dark:text-white"
style={{
fontFamily:
'Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
}}
onCloseAutoFocus={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem className={itemClass} onSelect={onUploadImage}>
上传图片
</DropdownMenuItem>
<DropdownMenuItem
className={itemClass}
disabled={videoUploading}
onSelect={onUploadVideo}
>
上传视频
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
<DropdownMenuItem className={itemClass} onSelect={onPaste}>
粘贴{shortcut(`${modifier}V`)}
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
<DropdownMenuItem className={itemClass} onSelect={onZoomIn}>
放大{shortcut(`${modifier}+`)}
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={onZoomOut}>
缩小{shortcut(`${modifier}-`)}
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={onFitAll}>
显示画布所有元素{shortcut(isMac ? "⇧1" : "Shift+1")}
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={onResetZoom}>
缩放至100%{shortcut(`${modifier}0`)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +0,0 @@
"use client";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
type CanvasComposerPromoProps = {
text: string;
onDismiss: () => void;
onActivate?: () => void;
};
export function CanvasComposerPromo({
text,
onDismiss,
onActivate,
}: CanvasComposerPromoProps) {
return (
<div className="lovart-chat-promo lovart-chat-promo-connected">
<div className="lovart-chat-promo-surface">
<div className="lovart-chat-promo-row">
<button
type="button"
className="lovart-chat-promo-message"
onClick={onActivate}
disabled={!onActivate}
title={text}
>
<svg
aria-hidden="true"
width="16"
height="18"
viewBox="0 0 24 28"
className="lovart-chat-promo-badge"
>
<path d="M10 1.15a4 4 0 0 1 4 0l8 4.6a4 4 0 0 1 2 3.46v9.58a4 4 0 0 1-2 3.46l-8 4.6a4 4 0 0 1-4 0l-8-4.6A4 4 0 0 1 0 18.79V9.21a4 4 0 0 1 2-3.46Z" fill="#ddef65" />
<path
d="M12 5.5c1.2 5.3 2.2 6.3 7.5 7.5-5.3 1.2-6.3 2.2-7.5 7.5C10.8 15.2 9.8 14.2 4.5 13c5.3-1.2 6.3-2.2 7.5-7.5Z"
fill="#111"
/>
</svg>
<span>{text}</span>
</button>
<button
type="button"
className="lovart-chat-promo-close"
aria-label="关闭优惠广告"
onClick={onDismiss}
>
<ReferenceIcon name="close" size={18} />
</button>
</div>
</div>
</div>
);
}

View File

@ -19,7 +19,6 @@ const PROJECT_LIST_PAGE_SIZE = 24;
interface CanvasFileListPanelProps { interface CanvasFileListPanelProps {
currentId: string; currentId: string;
onClose: () => void; onClose: () => void;
onNavigate?: (url: string) => void;
workspaceType?: CanvasWorkspaceType; workspaceType?: CanvasWorkspaceType;
workspaceLabel?: string; workspaceLabel?: string;
createUrl?: string; createUrl?: string;
@ -29,7 +28,6 @@ interface CanvasFileListPanelProps {
export function CanvasFileListPanel({ export function CanvasFileListPanel({
currentId, currentId,
onClose, onClose,
onNavigate,
workspaceType = "canvas", workspaceType = "canvas",
workspaceLabel = "画布", workspaceLabel = "画布",
createUrl = "/canvas?new=1", createUrl = "/canvas?new=1",
@ -90,10 +88,6 @@ export function CanvasFileListPanel({
// 创建新画布 // 创建新画布
const handleCreateNew = async () => { const handleCreateNew = async () => {
if (onNavigate) {
onNavigate(createUrl);
return;
}
router.push(createUrl); router.push(createUrl);
}; };
@ -106,10 +100,6 @@ export function CanvasFileListPanel({
} }
const targetUrl = buildProjectUrl(id); const targetUrl = buildProjectUrl(id);
onClose(); // 关闭文件列表面板 onClose(); // 关闭文件列表面板
if (onNavigate) {
onNavigate(targetUrl);
return;
}
// 强制整页跳转,避免仅更新 URL 不刷新 // 强制整页跳转,避免仅更新 URL 不刷新
setTimeout(() => { setTimeout(() => {
window.location.assign(targetUrl); window.location.assign(targetUrl);

View File

@ -1,26 +0,0 @@
"use client";
import { createContext, useContext } from "react";
import type { CanvasState } from "@/types/canvas";
export type CanvasRemoteOperationResult = {
projectId: string;
canvasData: CanvasState;
updatedAt: string;
};
export type CanvasRemoteOperation = (() => Promise<void>) | (() => Promise<CanvasRemoteOperationResult | undefined>);
export const CanvasRemoteOperationContext = createContext<
{
run: (operation: CanvasRemoteOperation) => Promise<void>;
applySnapshot: (result: CanvasRemoteOperationResult) => boolean;
} | null
>(null);
export function useCanvasRemoteOperation() {
return useContext(CanvasRemoteOperationContext)?.run || null;
}
export function useCanvasRemoteSnapshot() {
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
}

View File

@ -1,76 +0,0 @@
"use client";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
type CanvasSurfaceHeaderProps = {
onAddImage: () => void;
onHome: () => void;
onOpenFiles: () => void;
projectTitle: string;
rightInset: number;
};
const iconButtonClass =
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
export function CanvasSurfaceHeader({
onAddImage,
onHome,
onOpenFiles,
projectTitle,
rightInset,
}: CanvasSurfaceHeaderProps) {
return (
<div
className="absolute left-0 top-0 z-20 flex h-[40px] items-center justify-between border-b border-[#ededed] bg-white px-3 dark:border-white/10 dark:bg-zinc-950"
data-canvas-topbar="true"
style={{ right: rightInset }}
>
<div className="flex min-w-0 items-center gap-2">
<button
aria-label="返回首页"
className={iconButtonClass}
onClick={onHome}
title="返回首页"
type="button"
>
<ReferenceIcon name="branding" size={16} />
</button>
<button
aria-label="添加参考素材"
className={iconButtonClass}
onClick={onAddImage}
title="添加参考素材"
type="button"
>
<ReferenceIcon name="reference" size={16} />
</button>
<button
aria-label="查看项目文件"
className={iconButtonClass}
onClick={onOpenFiles}
title="查看项目文件"
type="button"
>
<ReferenceIcon name="brand" size={16} />
</button>
<div className="mx-1 h-4 w-px bg-[#ededed] dark:bg-white/10" />
<span className="inline-flex h-7 min-w-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[13px] text-[#333333] dark:bg-white/10 dark:text-white">
<ReferenceIcon name="page" size={16} />
<span className="max-w-[160px] truncate">
{projectTitle || "页面 1"}
</span>
</span>
<button
aria-label="添加到画布"
className={iconButtonClass}
onClick={onAddImage}
title="添加到画布"
type="button"
>
<ReferenceIcon name="plus" size={16} />
</button>
</div>
</div>
);
}

View File

@ -1,10 +1,6 @@
"use client"; "use client";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import {
ReferenceIcon,
type ReferenceIconName,
} from "@/components/lovart/reference-icon";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config"; import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -16,12 +12,13 @@ type CanvasToolbarIconProps = {
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
active?: boolean; active?: boolean;
referenceName?: ReferenceIconName;
referenceSize?: number;
}; };
function isSvgIconUrl(url: string) { function isSvgIconUrl(url: string) {
return /^data:image\/svg\+xml/i.test(url) || /\.svg(?:[?#].*)?$/i.test(url); return (
/^data:image\/svg\+xml/i.test(url) ||
/\.svg(?:[?#].*)?$/i.test(url)
);
} }
function stripImageFilterClasses(className?: string) { function stripImageFilterClasses(className?: string) {
@ -45,8 +42,6 @@ export function CanvasToolbarIcon({
className, className,
style, style,
active = false, active = false,
referenceName,
referenceSize = 18,
}: CanvasToolbarIconProps) { }: CanvasToolbarIconProps) {
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim(); const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
@ -80,9 +75,7 @@ export function CanvasToolbarIcon({
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
"object-contain", "object-contain",
active active ? "brightness-0 invert opacity-95" : "brightness-0 opacity-80 dark:invert",
? "brightness-0 invert opacity-95"
: "brightness-0 opacity-80 dark:invert",
className className
)} )}
src={resolvedIconUrl} src={resolvedIconUrl}
@ -91,15 +84,5 @@ export function CanvasToolbarIcon({
); );
} }
if (referenceName) {
return (
<ReferenceIcon
name={referenceName}
size={referenceSize}
className={className}
style={style}
/>
);
}
return <FallbackIcon className={className} style={style} />; return <FallbackIcon className={className} style={style} />;
} }

View File

@ -12,13 +12,8 @@ import { UiScreenNode } from "./nodes/UiScreenNode";
import { VideoNode } from "./nodes/VideoNode"; import { VideoNode } from "./nodes/VideoNode";
import { WorkflowNode } from "./nodes/WorkflowNode"; import { WorkflowNode } from "./nodes/WorkflowNode";
import { CanvasToolbarIcon } from "./CanvasToolbarIcon"; import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu";
import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit";
import { downloadCanvasSelectionZip } from "@/lib/client/canvas-zip-download";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner"; import { toast } from "sonner";
import { imageGenerationService } from "@/services/canvas"; import { imageGenerationService } from "@/services/canvas";
import { import {
@ -33,7 +28,6 @@ import {
LayoutTemplate, LayoutTemplate,
LayoutGrid, LayoutGrid,
Lightbulb, Lightbulb,
Loader2,
Minus, Minus,
Pin, Pin,
Plus, Plus,
@ -396,11 +390,6 @@ export function CanvasWorkspace({
} | null; } | null;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const contentLayerRef = useRef<HTMLDivElement>(null); const contentLayerRef = useRef<HTMLDivElement>(null);
const contextImageInputRef = useRef<HTMLInputElement>(null);
const contextVideoInputRef = useRef<HTMLInputElement>(null);
const contextCanvasPointRef = useRef<Position>({ x: 0, y: 0 });
const [blankContextPosition, setBlankContextPosition] = useState<Position | null>(null);
const [isContextVideoUploading, setIsContextVideoUploading] = useState(false);
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 }); const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false); const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
@ -738,6 +727,10 @@ export function CanvasWorkspace({
canvasToolbarConfig, canvasToolbarConfig,
"imageLayoutReplace" "imageLayoutReplace"
); );
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
const selectedImageLocalEditEnabled = true;
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
const selectedImageLayoutReplaceEnabled = true;
// Store actions // Store actions
const selectNode = useCanvasStore((state) => state.selectNode); const selectNode = useCanvasStore((state) => state.selectNode);
@ -1113,61 +1106,42 @@ export function CanvasWorkspace({
); );
const fitAllImageNodesIntoViewport = useCallback(() => { const fitAllImageNodesIntoViewport = useCallback(() => {
const fit = getCanvasFitViewport( const imageNodes = nodes.filter(
nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)), (node) => node.type === "image" && Boolean((node.data as any)?.src)
containerSize, occlusionRight
); );
if (fit) setViewport(fit); if (imageNodes.length === 0) return;
}, [containerSize, nodes, occlusionRight, setViewport]); if (containerSize.width <= 0 || containerSize.height <= 0) return;
const fitAllCanvasNodesIntoViewport = useCallback(() => { const padding = 80;
const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight); const minX = Math.min(...imageNodes.map((node) => node.position.x));
if (fit) setViewport(fit); const minY = Math.min(...imageNodes.map((node) => node.position.y));
}, [containerSize, nodes, occlusionRight, setViewport]); const maxX = Math.max(
...imageNodes.map((node) => node.position.x + node.size.width)
);
const maxY = Math.max(
...imageNodes.map((node) => node.position.y + node.size.height)
);
const handleBlankCanvasContextMenu = useCallback((event: React.MouseEvent) => { const contentWidth = Math.max(1, maxX - minX + padding * 2);
if (isWorkflowCanvas || hasBlockingDialogOpen() || canvasImageSelectionSession) return; const contentHeight = Math.max(1, maxY - minY + padding * 2);
if (!(event.target instanceof Element) || event.target.closest(
'[data-canvas-node-id], [data-canvas-overlay], [data-canvas-no-drag="true"], button, input, textarea, select, a, [contenteditable="true"]'
)) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.stopPropagation();
contextCanvasPointRef.current = {
x: (event.clientX - rect.left - viewportRef.current.x) / viewportRef.current.zoom,
y: (event.clientY - rect.top - viewportRef.current.y) / viewportRef.current.zoom,
};
setBlankContextPosition({ x: event.clientX, y: event.clientY });
setAgentSlotContextMenu(null);
}, [canvasImageSelectionSession, hasBlockingDialogOpen, isWorkflowCanvas]);
const zoomCanvasFromContextMenu = useCallback((factor: number) => { const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight));
const current = useCanvasStore.getState().viewport; const fitZoomX = visibleWidth / contentWidth;
const zoom = Math.max(0.1, Math.min(4, current.zoom * factor)); const fitZoomY = containerSize.height / contentHeight;
const centerX = Math.max(1, containerSize.width - occlusionRight) / 2; const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY)));
const centerY = containerSize.height / 2;
const ratio = zoom / current.zoom;
setViewport({ x: centerX - (centerX - current.x) * ratio, y: centerY - (centerY - current.y) * ratio, zoom });
}, [containerSize, occlusionRight, setViewport]);
React.useEffect(() => { const centerX = (minX + maxX) / 2;
if (isWorkflowCanvas) return; const centerY = (minY + maxY) / 2;
const handleFitShortcut = (event: KeyboardEvent) => { const visibleCenterX = visibleWidth / 2;
if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return; const nextViewportX = visibleCenterX - centerX * targetZoom;
if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest( const nextViewportY = containerSize.height / 2 - centerY * targetZoom;
'input, textarea, [contenteditable="true"], [role="textbox"]'
))) return; setViewport({
const active = document.activeElement; x: nextViewportX,
if (active && active !== document.body && !containerRef.current?.contains(active) y: nextViewportY,
&& !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return; zoom: targetZoom,
event.preventDefault(); });
fitAllCanvasNodesIntoViewport(); }, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
setBlankContextPosition(null);
};
window.addEventListener("keydown", handleFitShortcut);
return () => window.removeEventListener("keydown", handleFitShortcut);
}, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]);
const getNodeInputAnchor = useCallback( const getNodeInputAnchor = useCallback(
(node: CanvasNode): Position => ({ (node: CanvasNode): Position => ({
@ -1284,7 +1258,6 @@ export function CanvasWorkspace({
if (canvasImageSelectionSession) return; if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return; if (hasBlockingDialogOpen()) return;
setAgentSlotContextMenu(null); setAgentSlotContextMenu(null);
setBlankContextPosition(null);
if (!isEventFromCanvasContainer(e.target)) return; if (!isEventFromCanvasContainer(e.target)) return;
if (suppressCanvasClickRef.current) { if (suppressCanvasClickRef.current) {
suppressCanvasClickRef.current = false; suppressCanvasClickRef.current = false;
@ -1321,7 +1294,7 @@ export function CanvasWorkspace({
!isPointOnNodeElement(e.target); !isPointOnNodeElement(e.target);
if ( if (
(tool === 'pan' && e.button === 0) || tool === 'pan' ||
e.button === 1 || e.button === 1 ||
(e.button === 0 && e.altKey) || (e.button === 0 && e.altKey) ||
shouldPanBlankWorkflowCanvas shouldPanBlankWorkflowCanvas
@ -1684,105 +1657,6 @@ export function CanvasWorkspace({
} }
}, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]); }, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
const getContextUploadClientPoint = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect();
const current = viewportRef.current;
return {
x: (rect?.left ?? 0) + current.x + contextCanvasPointRef.current.x * current.zoom,
y: (rect?.top ?? 0) + current.y + contextCanvasPointRef.current.y * current.zoom,
};
}, []);
const pasteCanvasText = useCallback((text: string, point: Position) => {
if (!text.trim()) return;
addNode({
type: "text", position: point, size: { width: 320, height: 160 },
data: { content: text, color: "#111827" }, connections: [],
});
}, [addNode]);
const uploadContextVideos = useCallback(async (files: File[]) => {
if (!files.length || isContextVideoUploading) return;
const position = { ...contextCanvasPointRef.current };
setIsContextVideoUploading(true);
try {
for (const [index, file] of files.entries()) {
if (!["video/mp4", "video/quicktime", "video/webm"].includes(file.type)) {
toast.error("请选择 MP4、MOV 或 WebM 视频");
continue;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("视频文件不能超过 50MB");
continue;
}
const loadingToast = toast.loading(`正在上传 ${file.name}`);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-video-process");
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result.url) throw new Error(result.error || "视频上传失败");
addNode({
type: "video", position: { x: position.x + index * 24, y: position.y + index * 24 },
size: { width: 320, height: 180 },
data: { src: result.url, assetId: result.assetId, status: "completed", prompt: file.name,
metadata: { source: "local-upload", fileName: file.name, contentType: file.type } },
connections: [],
});
toast.success("视频已添加到画布");
} catch (error) {
toast.error(error instanceof Error ? error.message : "视频上传失败,请重试");
} finally {
toast.dismiss(loadingToast);
}
}
} finally {
setIsContextVideoUploading(false);
}
}, [addNode, isContextVideoUploading]);
const pasteFromContextMenu = useCallback(async () => {
const pastePoint = { ...contextCanvasPointRef.current };
const clientPoint = getContextUploadClientPoint();
try {
const files: File[] = [];
let text = "";
if (typeof navigator.clipboard?.read === "function") {
const items = await navigator.clipboard.read();
for (const [index, item] of items.entries()) {
const imageType = item.types.find((type) => type.startsWith("image/"));
if (imageType) {
const blob = await item.getType(imageType);
files.push(new File([blob], `粘贴图片-${Date.now()}-${index}.${imageType.split("/")[1]}`, { type: imageType }));
} else if (item.types.includes("text/plain")) {
text += await (await item.getType("text/plain")).text();
}
}
} else if (typeof navigator.clipboard?.readText === "function") {
text = await navigator.clipboard.readText();
} else {
throw new Error("Clipboard unavailable");
}
if (files.length) {
await processDroppedFiles(files, clientPoint.x, clientPoint.y);
} else if (text.trim()) {
pasteCanvasText(text, pastePoint);
} else {
toast.info("剪贴板中没有可粘贴的图片或文字");
}
} catch {
toast.info(`无法读取剪贴板,请使用 ${isMacPlatform ? "⌘V" : "Ctrl+V"} 粘贴`);
}
}, [getContextUploadClientPoint, isMacPlatform, pasteCanvasText, processDroppedFiles]);
React.useEffect(() => {
if (!blankContextPosition) return;
const close = () => setBlankContextPosition(null);
window.addEventListener("resize", close);
return () => window.removeEventListener("resize", close);
}, [blankContextPosition]);
React.useEffect(() => { React.useEffect(() => {
const handleWindowDrop = async (e: DragEvent) => { const handleWindowDrop = async (e: DragEvent) => {
if (!isFileDrag(e.dataTransfer)) return; if (!isFileDrag(e.dataTransfer)) return;
@ -1845,44 +1719,24 @@ export function CanvasWorkspace({
React.useEffect(() => { React.useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => { const handlePaste = async (e: ClipboardEvent) => {
const activeElement = document.activeElement; if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return;
const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]'));
if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return;
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
const activeElement = document.activeElement;
if ( if (
activeElement instanceof HTMLElement && activeElement instanceof HTMLElement &&
activeElement !== document.body && activeElement !== document.body &&
!container.contains(activeElement) && !fromContextMenu !container.contains(activeElement)
) { ) {
return; return;
} }
const files = extractImageFilesFromDataTransfer(e.clipboardData); const files = extractImageFilesFromDataTransfer(e.clipboardData);
const text = e.clipboardData?.getData("text/plain") || ""; if (files.length === 0) return;
if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return;
e.preventDefault(); e.preventDefault();
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
if (fromContextMenu) {
setBlankContextPosition(null);
if (files.length) {
const point = getContextUploadClientPoint();
await processDroppedFiles(files, point.x, point.y);
} else {
pasteCanvasText(text, { ...contextCanvasPointRef.current });
}
return;
}
if (files.length === 0) {
const current = viewportRef.current;
pasteCanvasText(text, {
x: (rect.width / 2 - current.x) / current.zoom,
y: (rect.height / 2 - current.y) / current.zoom,
});
return;
}
await processDroppedFiles( await processDroppedFiles(
files, files,
rect.left + rect.width / 2, rect.left + rect.width / 2,
@ -1892,7 +1746,7 @@ export function CanvasWorkspace({
window.addEventListener("paste", handlePaste); window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste); return () => window.removeEventListener("paste", handlePaste);
}, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]); }, [hasBlockingDialogOpen, processDroppedFiles]);
const handleDownloadSelectedImages = useCallback(async () => { const handleDownloadSelectedImages = useCallback(async () => {
if (selectedImageNodes.length === 0) { if (selectedImageNodes.length === 0) {
@ -1915,10 +1769,10 @@ export function CanvasWorkspace({
const rawImageUrl = String(nodeData?.src || "").trim(); const rawImageUrl = String(nodeData?.src || "").trim();
if (!rawImageUrl) return null; if (!rawImageUrl) return null;
let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl; let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl;
if (browserOrigin && rawImageUrl.startsWith("//")) { if (browserOrigin && rawImageUrl.startsWith("/")) {
imageUrl = `${window.location.protocol}${rawImageUrl}`;
} else if (browserOrigin && rawImageUrl.startsWith("/")) {
imageUrl = `${browserOrigin}${rawImageUrl}`; imageUrl = `${browserOrigin}${rawImageUrl}`;
} else if (browserOrigin && rawImageUrl.startsWith("//")) {
imageUrl = `${window.location.protocol}${rawImageUrl}`;
} }
const name = const name =
String(nodeData?.title || "").trim() || String(nodeData?.title || "").trim() ||
@ -1934,18 +1788,32 @@ export function CanvasWorkspace({
} }
setIsDownloadSubmitting(true); setIsDownloadSubmitting(true);
const toastId = toast.loading("正在准备所选图片...");
try { try {
const result = await downloadCanvasSelectionZip({ folderName, files, const form = document.createElement("form");
selectionKey: `${useCanvasStore.getState().projectId}:${selectedImageNodes.map(node => node.id).sort().join(",")}`, form.method = "POST";
onProgress: task => toast.loading(`${task.stage}(${task.progress}%)`, { id: toastId }), form.action = "/api/canvas/download-zip";
form.style.display = "none";
const payloadInput = document.createElement("input");
payloadInput.type = "hidden";
payloadInput.name = "payload";
payloadInput.value = JSON.stringify({
folderName,
files,
}); });
if (result.failedCount) toast.warning(`已开始下载 ${result.successCount} 张图片,${result.failedCount} 张未能读取;ZIP 内附失败清单`, { id: toastId, duration: 10000 }); form.appendChild(payloadInput);
else toast.success(`ZIP 已准备完成,正在下载 ${result.successCount} 张图片`, { id: toastId });
} catch (error) { document.body.appendChild(form);
toast.error(error instanceof Error ? error.message : "打包下载失败,请稍后重试", { id: toastId }); form.submit();
form.remove();
toast.success("已开始打包下载,请留意浏览器下载栏");
} catch (_error) {
toast.error("下载失败,请稍后重试");
} finally { } finally {
window.setTimeout(() => {
setIsDownloadSubmitting(false); setIsDownloadSubmitting(false);
}, 800);
} }
}, [isDownloadSubmitting, selectedImageNodes]); }, [isDownloadSubmitting, selectedImageNodes]);
@ -2401,47 +2269,12 @@ export function CanvasWorkspace({
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
onDoubleClick={handleBlankCanvasDoubleClick} onDoubleClick={handleBlankCanvasDoubleClick}
onContextMenu={handleBlankCanvasContextMenu}
onWheel={handleWheel} onWheel={handleWheel}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
> >
{!isWorkflowCanvas && (
<>
<input
ref={contextImageInputRef} type="file" accept="image/*" multiple className="hidden"
aria-label="从右键菜单上传图片"
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
const point = getContextUploadClientPoint();
void processDroppedFiles(files, point.x, point.y);
}}
/>
<input
ref={contextVideoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" multiple className="hidden"
aria-label="从右键菜单上传视频"
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
void uploadContextVideos(files);
}}
/>
<CanvasBlankContextMenu
position={blankContextPosition} isMac={isMacPlatform} videoUploading={isContextVideoUploading}
onClose={() => setBlankContextPosition(null)}
onUploadImage={() => contextImageInputRef.current?.click()}
onUploadVideo={() => contextVideoInputRef.current?.click()}
onPaste={() => { void pasteFromContextMenu(); }}
onZoomIn={() => zoomCanvasFromContextMenu(1.2)}
onZoomOut={() => zoomCanvasFromContextMenu(1 / 1.2)}
onFitAll={fitAllCanvasNodesIntoViewport}
onResetZoom={() => setViewport({ x: 0, y: 0, zoom: 1 })}
/>
</>
)}
{isWorkflowCanvas ? ( {isWorkflowCanvas ? (
<> <>
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" /> <div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
@ -2733,14 +2566,10 @@ export function CanvasWorkspace({
)} )}
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && ( {!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
<div <div
className={cn( className="absolute bottom-16 left-4 z-30 flex items-center gap-2"
"absolute left-4 z-30 flex items-center gap-2",
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
)}
data-canvas-overlay data-canvas-overlay
data-canvas-selection-actions="true"
> >
{isWorkflowCanvas && selectedImageNodes.length === 1 ? ( {selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
<Button <Button
variant={isWorkflowCanvas ? "outline" : "secondary"} variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm" size="sm"
@ -2762,7 +2591,7 @@ export function CanvasWorkspace({
{selectedImageLocalEditItem.label} {selectedImageLocalEditItem.label}
</Button> </Button>
) : null} ) : null}
{isWorkflowCanvas && {selectedImageLayoutReplaceEnabled &&
selectedImageNodes.length >= 1 ? ( selectedImageNodes.length >= 1 ? (
<Button <Button
variant={isWorkflowCanvas ? "outline" : "secondary"} variant={isWorkflowCanvas ? "outline" : "secondary"}
@ -2791,48 +2620,27 @@ export function CanvasWorkspace({
{selectedImageLayoutReplaceItem.label} {selectedImageLayoutReplaceItem.label}
</Button> </Button>
) : null} ) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant={isWorkflowCanvas ? "outline" : "secondary"} variant={isWorkflowCanvas ? "outline" : "secondary"}
size={isWorkflowCanvas ? "sm" : "icon"} size="sm"
className={cn( className={cn(
isWorkflowCanvas "h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
? "h-8 gap-1.5 rounded-full border border-[#dbe2ee] bg-white px-3 text-xs text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]" isWorkflowCanvas &&
: "h-[32px] w-[32px] rounded-[8px] border border-[#e9e9e9] bg-white p-0 text-[#333333] shadow-[0_1px_3px_rgba(0,0,0,0.035)] hover:bg-[#f5f5f5] dark:border-white/10 dark:bg-zinc-950 dark:text-white dark:hover:bg-white/10" "rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)} )}
onClick={handleDownloadSelectedImages} onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting} disabled={isDownloadSubmitting}
aria-label={ title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
}
> >
{!isWorkflowCanvas && isDownloadSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<CanvasToolbarIcon <CanvasToolbarIcon
className={isWorkflowCanvas ? "h-3.5 w-3.5" : "h-5 w-5"} className="h-3.5 w-3.5"
fallback={Download} fallback={Download}
referenceName={isWorkflowCanvas ? undefined : "image-download"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")} item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
/> />
)}
{isWorkflowCanvas
? isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
: null}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isDownloadSubmitting {isDownloadSubmitting
? "打包中..." ? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`} : `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
</TooltipContent> </Button>
</Tooltip>
</div> </div>
)} )}

View File

@ -15,9 +15,7 @@ import { AudioGenerationComposer } from "./AudioGenerationComposer";
import { TVstarPanel, type TVstarGenerationModel, type TVstarGenerationSelection, type TVstarModel, type TVstarNodeContext, type TVstarReference, type TVstarShot } from "./tvstar/TVstarPanel"; import { TVstarPanel, type TVstarGenerationModel, type TVstarGenerationSelection, type TVstarModel, type TVstarNodeContext, type TVstarReference, type TVstarShot } from "./tvstar/TVstarPanel";
import { resolveTVstarGenerationReferences } from "./tvstar/generation-references"; import { resolveTVstarGenerationReferences } from "./tvstar/generation-references";
import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing"; import { tvstarTiming } from "@/lib/video-canvas/tvstar-timing";
import { tvstarOutputRequirements } from "@/lib/video-canvas/tvstar-output";
import { CharacterStudioPanel, type StudioCharacter, type StudioView } from "./tvstar/CharacterStudioPanel"; import { CharacterStudioPanel, type StudioCharacter, type StudioView } from "./tvstar/CharacterStudioPanel";
import { tvstarShotComposition } from "@/lib/video-canvas/tvstar-shot-input";
import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice"; import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice";
import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story"; import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -104,11 +102,6 @@ import {
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ZCODE_VIDEO_CANVAS_AUTHORIZED } from "@/lib/ai/canvas-agent-runtime/authorization";
import { CanvasAgentUiListener } from "./chat-panel/canvas-agent-ui-listener";
import type { CanvasAgentUiCommand } from "@/lib/ai/canvas-agent-runtime/ui-command";
import { VideoCanvasAgentPanel } from "./chat-panel/video-canvas-agent-panel";
import { mergeVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-protocol";
import useSWR from "swr"; import useSWR from "swr";
import { import {
getGenerationCreditCostFromImageGenConfig, getGenerationCreditCostFromImageGenConfig,
@ -821,28 +814,28 @@ const STARTER_CARDS = [
title: "故事脚本生成", title: "故事脚本生成",
subtitle: "脚本到分镜", subtitle: "脚本到分镜",
coverUrl: coverUrl:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"], steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"],
}, },
{ {
title: "角色三视图", title: "角色三视图",
subtitle: "角色资产沉淀", subtitle: "角色资产沉淀",
coverUrl: coverUrl:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"], steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"],
}, },
{ {
title: "全能参考生视频", title: "全能参考生视频",
subtitle: "参考图到成片", subtitle: "参考图到成片",
coverUrl: coverUrl:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
steps: ["上传图片", "首帧图", "可灵 3.0", "预览"], steps: ["上传图片", "首帧图", "可灵 3.0", "预览"],
}, },
{ {
title: "音频生视频", title: "音频生视频",
subtitle: "音频驱动画面", subtitle: "音频驱动画面",
coverUrl: coverUrl:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
icon: "music", icon: "music",
steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"], steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"],
}, },
@ -865,7 +858,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "预设", badge: "预设",
category: "周星驰经典名场面", category: "周星驰经典名场面",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。", description: "左弧滑行,镜头从人物左侧弧线推进,带轻微喜剧节奏和夸张表情停顿。",
duration: "5s", duration: "5s",
id: "left-arc-slide", id: "left-arc-slide",
@ -880,7 +873,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "商用", badge: "商用",
category: "电商展示", category: "电商展示",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。", description: "电商手机弹出效果,产品从画面中心弹出并旋转定格,适合主图视频开场。",
duration: "5s", duration: "5s",
id: "phone-pop-product", id: "phone-pop-product",
@ -895,7 +888,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "商用", badge: "商用",
category: "电商展示", category: "电商展示",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。", description: "咖啡杯出场,杯身从桌面滑入,蒸汽和柔光形成温暖的品牌氛围。",
duration: "5s", duration: "5s",
id: "coffee-cup-entry", id: "coffee-cup-entry",
@ -910,7 +903,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "热门", badge: "热门",
category: "镜头运动", category: "镜头运动",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。", description: "360旋转展示,围绕主体做一圈稳定环绕,适合产品、人物和空间展示。",
duration: "6s", duration: "6s",
id: "orbit-360-showcase", id: "orbit-360-showcase",
@ -924,7 +917,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
author: "工业镜头", author: "工业镜头",
category: "镜头运动", category: "镜头运动",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-image.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。", description: "机械臂视角,以第一视角穿过机械臂与产品线,增强科技感和空间纵深。",
duration: "5s", duration: "5s",
id: "robot-arm-pov", id: "robot-arm-pov",
@ -939,7 +932,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "NEW", badge: "NEW",
category: "人物动画", category: "人物动画",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/character-three-view.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png",
description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。", description: "Live 2D,让单张角色图产生眨眼、微笑和轻微身体摆动,适合口播开场。",
duration: "4s", duration: "4s",
id: "live-2d-character", id: "live-2d-character",
@ -953,7 +946,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
author: "电影运镜", author: "电影运镜",
category: "镜头运动", category: "镜头运动",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png",
description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。", description: "瞳孔拉近,镜头快速推向眼部并在反光里展示下一场景,适合悬疑转场。",
duration: "5s", duration: "5s",
id: "pupil-zoom", id: "pupil-zoom",
@ -968,7 +961,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "热门", badge: "热门",
category: "人物动画", category: "人物动画",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-story-script-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-story-script-cover.png",
description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。", description: "飞鸟解体,主体由细碎羽片散开再重组,适合视觉冲击强的短视频片头。",
duration: "6s", duration: "6s",
id: "bird-dissolve", id: "bird-dissolve",
@ -983,7 +976,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
badge: "商用", badge: "商用",
category: "电商展示", category: "电商展示",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png",
description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。", description: "商品震撼登场,产品带冲击波与局部高光进入画面,适合活动主推款。",
duration: "5s", duration: "5s",
id: "product-hero-entry", id: "product-hero-entry",
@ -997,7 +990,7 @@ const TOOLBOX_TEMPLATE_PRESETS: ToolboxTemplatePreset[] = [
author: "旅拍模板", author: "旅拍模板",
category: "镜头运动", category: "镜头运动",
cover: cover:
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-audio-to-video-cover.png", "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-audio-to-video-cover.png",
description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。", description: "旅拍转场,前景遮挡切换到新地点,带自然手持感和轻微速度变化。",
duration: "5s", duration: "5s",
id: "travel-whip-transition", id: "travel-whip-transition",
@ -1160,9 +1153,9 @@ const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [
]; ];
const TARGET_AVATAR = const TARGET_AVATAR =
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/target-avatar.jpg"; "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/target-avatar.jpg";
const PUBLISH_MODAL_BG_URL = const PUBLISH_MODAL_BG_URL =
"https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/publish-modal-bg.png"; "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/publish-modal-bg.png";
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本"; const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
const AUDIO_TO_VIDEO_PROMPT = const AUDIO_TO_VIDEO_PROMPT =
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。"; "根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
@ -4573,8 +4566,6 @@ export function LiblibVideoCanvasReplica({
); );
const [nodes, setNodes] = useState<CloneNode[]>([]); const [nodes, setNodes] = useState<CloneNode[]>([]);
const activeGenerationSubmissions = useRef(new Set<string>()); const activeGenerationSubmissions = useRef(new Set<string>());
const [isVideoAgentOpen, setIsVideoAgentOpen] = useState(false);
const receivedAgentNodeIds = useRef(new Set<string>());
const [edges, setEdges] = useState<CloneEdge[]>([]); const [edges, setEdges] = useState<CloneEdge[]>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@ -4767,7 +4758,6 @@ export function LiblibVideoCanvasReplica({
setNodes(migrateLegacyGroups(restored)); setNodes(migrateLegacyGroups(restored));
setMultiSelection([]); setMultiSelection([]);
setGroupTemplates((project.canvasData as any)?.groupTemplates||[]); setGroupTemplates((project.canvasData as any)?.groupTemplates||[]);
receivedAgentNodeIds.current = new Set(restored.map((node) => node.id));
setEdges(restoreEdges(project.canvasData)); setEdges(restoreEdges(project.canvasData));
const storedViewport = data?.viewport; const storedViewport = data?.viewport;
setViewport(storedViewport && [storedViewport.x, storedViewport.y, storedViewport.zoom].every(Number.isFinite) setViewport(storedViewport && [storedViewport.x, storedViewport.y, storedViewport.zoom].every(Number.isFinite)
@ -4873,66 +4863,6 @@ export function LiblibVideoCanvasReplica({
applyMergedSnapshot, applyMergedSnapshot,
); );
const refreshVideoAgentResults = useCallback(async (projectId: string, resultNodeIds: string[]) => {
if (projectId !== canvasId) return;
const result = await canvasService.getProject(canvasId);
if (!result.success || !result.data) return;
const incoming = restoreNodes(result.data.canvasData).filter((node) => resultNodeIds.includes(node.id));
const unseen = incoming.filter((node) => !receivedAgentNodeIds.current.has(node.id));
for (const node of incoming) receivedAgentNodeIds.current.add(node.id);
setNodes((current) => mergeVideoAgentResults(current.map((node) => {
const remote = incoming.find((item) => item.id === node.id);
return remote && remote.draftValues?.temporaryVideoUrl === node.draftValues?.mediaUrl && remote.draftValues?.mediaUrl !== node.draftValues?.mediaUrl ? { ...node, draftValues: { ...node.draftValues, mediaUrl: remote.draftValues?.mediaUrl, url: remote.draftValues?.url } } : node;
}), unseen));
setEdges((current) => mergeVideoAgentResults(current, restoreEdges(result.data!.canvasData).filter((edge) => unseen.some((node) => node.id === edge.target))));
}, [canvasId]);
useEffect(() => {
const handler = async (event: Event) => {
const change = (event as CustomEvent<{ projectId: string; createdNodeIds?: string[]; updatedNodeIds?: string[]; deletedNodeIds?: string[]; action?: string; nodeIds?: string[]; viewportChanged?: boolean }>).detail;
if (change?.projectId !== canvasId) return;
const response = await canvasService.getProject(canvasId);
if (!response.success || !response.data) return;
const remote = restoreNodes(response.data.canvasData);
const changed = new Set([...(change.createdNodeIds || []), ...(change.updatedNodeIds || [])]);
const deleted = new Set(change.deletedNodeIds || []);
setNodes((current) => mergeVideoAgentResults(current.filter((node) => !deleted.has(node.id)).map((node) => changed.has(node.id) ? remote.find((item) => item.id === node.id) || node : node), remote.filter((node) => changed.has(node.id))));
setEdges((current) => {
const kept = current.filter((edge) => !deleted.has(edge.source) && !deleted.has(edge.target) && !(change.action === "disconnect" && edge.source === change.nodeIds?.[0] && edge.target === change.nodeIds?.[1]));
return change.action === "connect" ? mergeVideoAgentResults(kept, restoreEdges(response.data!.canvasData).filter((edge) => edge.source === change.nodeIds?.[0] && edge.target === change.nodeIds?.[1])) : kept;
});
if (change.viewportChanged && response.data.canvasData?.viewport) setViewport(response.data.canvasData.viewport);
};
window.addEventListener("video-canvas-agent-mutation", handler);
return () => window.removeEventListener("video-canvas-agent-mutation", handler);
}, [canvasId]);
const saveBeforeVideoAgent = useCallback(async () => {
const statusResponse = await fetch(`/api/canvas/agent-runs?${new URLSearchParams({ chatId: canvasId, projectId: canvasId })}`, { cache: "no-store" });
if (statusResponse.ok && (await statusResponse.json()).run?.status === "running") throw new Error("当前视频任务还在执行,请使用补充要求。");
const remote = await canvasService.getProject(canvasId);
if (!remote.success || !remote.data) throw new Error("无法读取视频画布,请稍后重试");
const remoteNodes = restoreNodes(remote.data.canvasData);
const unseen = remoteNodes.filter((node) => node.draftValues?.canvasAgentRunId && !receivedAgentNodeIds.current.has(node.id));
const syncedNodes = nodes.map((node) => {
const archived = remoteNodes.find((item) => item.id === node.id && item.draftValues?.temporaryVideoUrl === node.draftValues?.mediaUrl);
return archived ? { ...node, draftValues: { ...node.draftValues, mediaUrl: archived.draftValues?.mediaUrl, url: archived.draftValues?.url, temporaryVideoUrl: archived.draftValues?.temporaryVideoUrl } } : node;
});
const mergedNodes = mergeVideoAgentResults(syncedNodes, unseen);
const mergedEdges = mergeVideoAgentResults(edges, restoreEdges(remote.data.canvasData).filter((edge) => unseen.some((node) => node.id === edge.target)));
const response = await fetch("/api/canvas/video-agent", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: canvasId, baseRevision: remote.data.updatedAt, canvasData: {
nodes: serializeNodes(mergedNodes), edges: serializeEdges(mergedEdges),
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], viewport, tool: "select",
} }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "画布保存失败,请稍后重试");
if (result.success) { setNodes(mergedNodes); setEdges(mergedEdges); for (const node of unseen) receivedAgentNodeIds.current.add(node.id); }
if (!result.success) throw new Error(result.error || "画布保存失败,请稍后重试");
}, [canvasId, nodes, edges, selectedNodeId, viewport]);
const commitTitle = useCallback(() => { const commitTitle = useCallback(() => {
const nextTitle = draftTitle.trim() || "未命名"; const nextTitle = draftTitle.trim() || "未命名";
setProjectTitle(nextTitle); setProjectTitle(nextTitle);
@ -5620,7 +5550,7 @@ export function LiblibVideoCanvasReplica({
const draft = (d.smartEditDraft || {}) as any, plan = draft.plans?.[draft.selectedPlanIndex || 0] || d.editPlan; const draft = (d.smartEditDraft || {}) as any, plan = draft.plans?.[draft.selectedPlanIndex || 0] || d.editPlan;
const changes = sourceEdges.some(e => e.target === node.id && groupMembers(sourceNodes, groupId).some(m => m.id === e.source && (/GENERATE|UPSCALE/.test(String(m.draftValues?.action)) || m.draftValues?.generationTaskId))); const changes = sourceEdges.some(e => e.target === node.id && groupMembers(sourceNodes, groupId).some(m => m.id === e.source && (/GENERATE|UPSCALE/.test(String(m.draftValues?.action)) || m.draftValues?.generationTaskId)));
if (plan && !changes) return { kind: "smart-edit", params: { operation: "render", plan } }; if (plan && !changes) return { kind: "smart-edit", params: { operation: "render", plan } };
return { kind: "smart-edit", params: { operation: "plan", autoRender: true, mode: draft.mode || "sequence", targetDuration: draft.targetDuration || 300, shotDurations: draft.shotDurations, shotDescriptions: draft.shotDescriptions, ratio: draft.ratio || "16:9", resolution: draft.resolution || "720p", semantic: draft.semantic ?? false, muteAudio: draft.muteAudio === true, preferredModelRef: draft.preferredModelRef, prompt: draft.prompt || "", removeSilence: draft.removeSilence || false, transcribe: draft.transcribe || false, music: draft.music, beatSync: draft.beatSync || false, variants: 1 } }; return { kind: "smart-edit", params: { operation: "plan", autoRender: true, mode: draft.mode || "sequence", targetDuration: draft.targetDuration || 300, shotDurations: draft.shotDurations, shotDescriptions: draft.shotDescriptions, ratio: draft.ratio || "16:9", resolution: draft.resolution || "720p", semantic: draft.semantic ?? false, preferredModelRef: draft.preferredModelRef, prompt: draft.prompt || "", removeSilence: draft.removeSilence || false, transcribe: draft.transcribe || false, music: draft.music, beatSync: draft.beatSync || false, variants: 1 } };
} }
const key = String(p.functionKey || d.functionKey || (node.inputType === "video" ? "text-to-video" : node.inputType === "image" ? "" : "story-script")); const key = String(p.functionKey || d.functionKey || (node.inputType === "video" ? "text-to-video" : node.inputType === "image" ? "" : "story-script"));
const definition = key ? getConfiguredFunction(videoCanvasFunctionConfig, key) : { key: "image-generate", kind: "image-edit" as const, route: "canvas-generate-image" as const, enabled: true, label: "图片生成", prompt: "", description: "" }; const definition = key ? getConfiguredFunction(videoCanvasFunctionConfig, key) : { key: "image-generate", kind: "image-edit" as const, route: "canvas-generate-image" as const, enabled: true, label: "图片生成", prompt: "", description: "" };
@ -5638,10 +5568,6 @@ export function LiblibVideoCanvasReplica({
const applyTVstarShots = useCallback(async (shots: TVstarShot[], goal: string, references: TVstarReference[] = [], selection: TVstarGenerationSelection = {}, runMode: "submit" | "prepare" = "submit") => { const applyTVstarShots = useCallback(async (shots: TVstarShot[], goal: string, references: TVstarReference[] = [], selection: TVstarGenerationSelection = {}, runMode: "submit" | "prepare" = "submit") => {
if (!shots.length) return false; if (!shots.length) return false;
let output: ReturnType<typeof tvstarOutputRequirements>;
let timing: ReturnType<typeof tvstarTiming>;
try { output = tvstarOutputRequirements(goal); timing = tvstarTiming(goal, shots); }
catch (error) { toast.error(error instanceof Error ? error.message : "成片要求无效"); return false; }
const videoDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "first-frame-video"); const videoDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "first-frame-video");
if (runMode === "submit" && (!videoCanvasFunctionConfig.enabled || !videoDefinition.enabled)) { if (runMode === "submit" && (!videoCanvasFunctionConfig.enabled || !videoDefinition.enabled)) {
toast.error("首帧生视频功能尚未启用,无法创建可执行分镜"); toast.error("首帧生视频功能尚未启用,无法创建可执行分镜");
@ -5657,9 +5583,8 @@ export function LiblibVideoCanvasReplica({
if (selection.videoModelRef && !filterCanvasModels(availableModels, "video", "first-frame") if (selection.videoModelRef && !filterCanvasModels(availableModels, "video", "first-frame")
.some(model => model.id === selection.videoModelRef || model.modelId === selection.videoModelRef)) .some(model => model.id === selection.videoModelRef || model.modelId === selection.videoModelRef))
throw new Error("所选视频模型不支持首帧生视频,请换一个模型"); throw new Error("所选视频模型不支持首帧生视频,请换一个模型");
videoSettings = shots.map((_shot, index) => chooseStoryVideoSettings( videoSettings = shots.map(shot => chooseStoryVideoSettings(
availableModels, true, timing.shotDurations[index], preferredModel, availableModels, true, Number.parseFloat(shot.duration) || 5, preferredModel,
{ ...output, minimumDuration: true, minimumResolution: true, strictModel: Boolean(selection.videoModelRef) },
)); ));
} catch (error) { } catch (error) {
if (runMode === "submit") { if (runMode === "submit") {
@ -5669,7 +5594,7 @@ export function LiblibVideoCanvasReplica({
videoSettings = shots.map(shot => ({ videoSettings = shots.map(shot => ({
mode: "first-frame" as const, model: selection.videoModelRef || "", mode: "first-frame" as const, model: selection.videoModelRef || "",
duration: Math.max(1, Math.min(10, Math.ceil(Number.parseFloat(shot.duration) || 5))), duration: Math.max(1, Math.min(10, Math.ceil(Number.parseFloat(shot.duration) || 5))),
ratio: output.ratio, resolution: output.resolution, generateAudio: output.generateAudio ?? false, ratio: "16:9", resolution: "720p", generateAudio: false,
})); }));
} }
let referenceImages: string[]; let referenceImages: string[];
@ -5699,35 +5624,31 @@ export function LiblibVideoCanvasReplica({
x, y, x, y,
}); });
script.draftValues = { ...script.draftValues, tvstarShots: shots, tvstarReferences: references, source: "tvstar" }; script.draftValues = { ...script.draftValues, tvstarShots: shots, tvstarReferences: references, source: "tvstar" };
const timing = tvstarTiming(goal, shots);
const nextNodes: CloneNode[] = [script]; const nextNodes: CloneNode[] = [script];
const nextEdges: CloneEdge[] = []; const nextEdges: CloneEdge[] = [];
const videoNodeIds: string[] = []; const videoNodeIds: string[] = [];
shots.forEach((shot, index) => { shots.forEach((shot, index) => {
const settings = videoSettings[index]; const settings = videoSettings[index];
const rowY = y + index * 450; const rowY = y + index * 450;
const imagePrompt = [`仅生成镜头 ${shot.shot} 的一张电影关键帧。`, shot.imagePrompt || shot.sceneDescription, tvstarShotComposition(goal)].join("\n"); const imagePrompt = shot.imagePrompt || shot.sceneDescription;
const image = createTargetModuleNode("image", x + 560, rowY, nodes.length + index * 2 + 1); const image = createTargetModuleNode("image", x + 560, rowY, nodes.length + index * 2 + 1);
image.title = `镜头 ${shot.shot} · 关键帧`; image.title = `镜头 ${shot.shot} · 关键帧`;
image.draftValues = { image.draftValues = {
...image.draftValues, ...image.draftValues,
prompt: imagePrompt, prompt: imagePrompt,
params: { prompt: imagePrompt, aspectRatio: output.ratio, referenceImages, ...(imageModelRef ? { model: imageModelRef } : {}) }, params: { prompt: imagePrompt, aspectRatio: "16:9", referenceImages, ...(imageModelRef ? { model: imageModelRef } : {}) },
tvstarShot: shot.shot, tvstarShot: shot.shot,
source: "tvstar", source: "tvstar",
}; };
const video = createTargetModuleNode("video", x + 1020, rowY, nodes.length + index * 2 + 2); const video = createTargetModuleNode("video", x + 1020, rowY, nodes.length + index * 2 + 2);
video.title = `镜头 ${shot.shot} · 视频`; video.title = `镜头 ${shot.shot} · 视频`;
const videoPrompt = [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement,
...(settings.generateAudio ? [shot.dialogue, shot.backgroundMusic, shot.voiceAndSfx] : []),
`整片背景要求(当前只拍镜头 ${shot.shot},不执行其他镜头):${goal}`,
tvstarShotComposition(goal), "保持当前镜头连续运动,不生成片中片,不切换到其他分镜。",
].filter(Boolean).join("。");
video.draftValues = { video.draftValues = {
...video.draftValues, ...video.draftValues,
prompt: videoPrompt, prompt: [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。"),
params: { params: {
mode: "first-frame", mode: "first-frame",
prompt: videoPrompt, prompt: [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。"),
model: settings.model, model: settings.model,
duration: settings.duration, duration: settings.duration,
ratio: settings.ratio, ratio: settings.ratio,
@ -5751,9 +5672,8 @@ export function LiblibVideoCanvasReplica({
source: "tvstar", source: "tvstar",
smartEditDraft: { smartEditDraft: {
mode: "sequence", mode: "sequence",
ratio: output.ratio, ratio: "16:9",
resolution: output.resolution, resolution: "720p",
muteAudio: output.generateAudio === false,
targetDuration: timing.targetDuration, targetDuration: timing.targetDuration,
shotDurations: timing.shotDurations, shotDurations: timing.shotDurations,
shotDescriptions: shots.map(shot => [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。").slice(0, 600)), shotDescriptions: shots.map(shot => [shot.sceneDescription, shot.motionPrompt, shot.cameraMovement].filter(Boolean).join("。").slice(0, 600)),
@ -5807,9 +5727,6 @@ export function LiblibVideoCanvasReplica({
const applyTVstarEdit = useCallback(async (references: TVstarReference[], goal: string) => { const applyTVstarEdit = useCallback(async (references: TVstarReference[], goal: string) => {
const videos = references.filter(item => item.type === "video").slice(0, 12); const videos = references.filter(item => item.type === "video").slice(0, 12);
if (!videos.length) { toast.error("请先引用至少一个视频片段"); return false; } if (!videos.length) { toast.error("请先引用至少一个视频片段"); return false; }
let output: ReturnType<typeof tvstarOutputRequirements>;
try { output = tvstarOutputRequirements(goal); }
catch (error) { toast.error(error instanceof Error ? error.message : "成片要求无效"); return false; }
const timing = tvstarTiming(goal, videos.map(item => ({ duration: `${Math.min(item.duration || 5, 5)}s` }))); const timing = tvstarTiming(goal, videos.map(item => ({ duration: `${Math.min(item.duration || 5, 5)}s` })));
const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
const x = Math.round(center.x - 380); const x = Math.round(center.x - 380);
@ -5830,7 +5747,7 @@ export function LiblibVideoCanvasReplica({
const result = createTargetModuleNode("smart-edit", x + 1140, y + 90, nodes.length + videos.length + 1); const result = createTargetModuleNode("smart-edit", x + 1140, y + 90, nodes.length + videos.length + 1);
result.title = `TVstar · ${timing.targetDuration} 秒智能成片`; result.title = `TVstar · ${timing.targetDuration} 秒智能成片`;
result.draftValues = { ...result.draftValues, source: "tvstar", smartEditDraft: { result.draftValues = { ...result.draftValues, source: "tvstar", smartEditDraft: {
mode: "sequence", ratio: output.ratio, resolution: output.resolution, muteAudio: output.generateAudio === false, mode: "sequence", ratio: "16:9", resolution: "720p",
targetDuration: timing.targetDuration, shotDurations: timing.shotDurations, targetDuration: timing.targetDuration, shotDurations: timing.shotDurations,
shotDescriptions: videos.map(item => `从《${item.title}》选取符合用户要求的真实高光画面:${goal}`.slice(0, 600)), shotDescriptions: videos.map(item => `从《${item.title}》选取符合用户要求的真实高光画面:${goal}`.slice(0, 600)),
semantic: true, preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel || undefined, semantic: true, preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel || undefined,
@ -5862,18 +5779,9 @@ export function LiblibVideoCanvasReplica({
const applyCharacterStudio = useCallback(async (character: StudioCharacter, style: string, views: StudioView[], referenceUrl?: string): Promise<boolean> => { const applyCharacterStudio = useCallback(async (character: StudioCharacter, style: string, views: StudioView[], referenceUrl?: string): Promise<boolean> => {
if (!views.length) return false; if (!views.length) return false;
const requiresIdentityReference = Boolean(referenceUrl) || views.length > 1; const imageCandidates = filterCanvasModels(canvasModelOptions, referenceUrl ? "image-edit" : "image");
// Recover a missed initial catalog load before deciding that generation is unavailable. if (referenceUrl && !imageCandidates.length) {
let availableModels = canvasModelOptions; toast.error("没有支持参考图的图片模型,无法按角色参考图出图");
if (!availableModels.length) {
const response = await fetch("/api/models", { cache: "no-store" });
if (!response.ok) throw Error("角色出图模型暂时无法读取,请稍后重试");
const catalog = await response.json();
availableModels = Array.isArray(catalog.models) ? catalog.models : [];
}
const imageCandidates = filterCanvasModels(availableModels, requiresIdentityReference ? "image-edit" : "image");
if (!imageCandidates.length) {
toast.error(requiresIdentityReference ? "没有支持参考图的图片模型,无法保持多视图角色一致性" : "没有可执行的图片模型");
return false; return false;
} }
const imageModelRef = imageCandidates.find(model => model.id === videoCanvasFunctionConfig.defaultImageModel const imageModelRef = imageCandidates.find(model => model.id === videoCanvasFunctionConfig.defaultImageModel
@ -5881,44 +5789,31 @@ export function LiblibVideoCanvasReplica({
const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2); const center = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
const x = Math.round(center.x - 360); const x = Math.round(center.x - 360);
const y = Math.round(center.y - 160); const y = Math.round(center.y - 160);
// Appearance is shared; generated prompt framing must not override each view's composition. const identity = [character.name, character.description, character.look, character.wardrobe, character.prompt, `风格:${style}`].filter(Boolean).join("。 ");
const identity = [character.name, character.description, character.look, character.wardrobe,
...(character.look || character.wardrobe ? [] : [character.prompt]), `风格:${style}`].filter(Boolean).join("。 ");
const source = createTextResourceNode({ content: identity, title: `${character.name} · 角色设定`, x, y }); const source = createTextResourceNode({ content: identity, title: `${character.name} · 角色设定`, x, y });
source.draftValues = { ...source.draftValues, source: "character-studio", characterIdentity: identity }; source.draftValues = { ...source.draftValues, source: "character-studio", characterIdentity: identity };
const viewPrompts: Record<StudioView, string> = { const viewPrompts: Record<StudioView, string> = {
"角色立绘": "全身角色立绘,站姿自然,人物完整入画,纯净背景,清晰展示稳定面部特征、服装和道具。", "角色立绘": "全身角色立绘,站姿自然,人物完整入画,纯净背景,清晰展示稳定面部特征、服装和道具。",
"脸部近景": "只拍同一角色脸部近景,脸部占据画幅主体,最多露出肩部,不出现全身或半身立绘;保持五官、发型与标志性特征完全一致,柔和电影布光。", "脸部近景": "同一角色脸部近景,保持五官、发型与标志性特征完全一致,柔和电影布光。",
"表情参考": "同一角色表情设定图,四宫格分别展示平静、喜悦、惊讶、愤怒,五官与服装细节一致。", "表情参考": "同一角色表情设定图,四宫格分别展示平静、喜悦、惊讶、愤怒,五官与服装细节一致。",
"角色三视图": "同一角色正面、侧面、背面三视图并排,站姿和服装统一,清晰标注轮廓,纯净背景。", "角色三视图": "同一角色正面、侧面、背面三视图并排,站姿和服装统一,清晰标注轮廓,纯净背景。",
}; };
const nextNodes: CloneNode[] = [source]; const nextNodes: CloneNode[] = [source];
const nextEdges: CloneEdge[] = []; const nextEdges: CloneEdge[] = [];
// A single generated identity anchor feeds every other view. Text alone views.forEach((view, index) => {
// does not constrain an image model to reproduce the same face. const prompt = `${identity}\n${viewPrompts[view]} 不改变角色身份,不增加多余人物。`;
const orderedViews: StudioView[] = views.includes("角色立绘")
? ["角色立绘", ...views.filter(view => view !== "角色立绘")]
: views;
let identityAnchorId: string | undefined;
orderedViews.forEach((view, index) => {
const singleView = view === "角色立绘" || view === "脸部近景"
? "只生成一张单一完整构图,禁止拼贴、画中画、分屏、宫格和附加小窗;参考图只用于人物身份,不复用其构图。" : "";
const prompt = `${identity}\n${viewPrompts[view]} 不改变角色身份,不增加多余人物。${singleView}`;
const node = createTargetModuleNode("image", x + 500 + index * 440, y, nodes.length + index + 1); const node = createTargetModuleNode("image", x + 500 + index * 440, y, nodes.length + index + 1);
node.title = `${character.name} · ${view}`; node.title = `${character.name} · ${view}`;
node.draftValues = { node.draftValues = {
...node.draftValues, ...node.draftValues,
prompt, prompt,
params: { prompt, aspectRatio: view === "角色三视图" || view === "表情参考" ? "16:9" : "3:4", ...(referenceUrl && index === 0 ? { referenceImages: [referenceUrl] } : {}), ...(imageModelRef ? { model: imageModelRef } : {}) }, params: { prompt, aspectRatio: view === "角色三视图" || view === "表情参考" ? "16:9" : "3:4", ...(referenceUrl ? { referenceImages: [referenceUrl] } : {}), ...(imageModelRef ? { model: imageModelRef } : {}) },
characterIdentity: identity, characterIdentity: identity,
characterView: view, characterView: view,
characterAnchorId: identityAnchorId,
source: "character-studio", source: "character-studio",
}; };
nextNodes.push(node); nextNodes.push(node);
nextEdges.push({ id: makeId("edge"), source: source.id, target: node.id }); nextEdges.push({ id: makeId("edge"), source: source.id, target: node.id });
if (identityAnchorId) nextEdges.push({ id: makeId("edge"), source: identityAnchorId, target: node.id });
else identityAnchorId = node.id;
}); });
const groupId = makeId("character-group"); const groupId = makeId("character-group");
const groupNodes = makeCanvasGroup(nextNodes, nextNodes.map(node => node.id), groupId, `${character.name} · 角色造型`); const groupNodes = makeCanvasGroup(nextNodes, nextNodes.map(node => node.id), groupId, `${character.name} · 角色造型`);
@ -10966,50 +10861,6 @@ export function LiblibVideoCanvasReplica({
}; };
}, [lineConnectState, nodes]); }, [lineConnectState, nodes]);
const handleAgentVideoUiCommand = async (command: CanvasAgentUiCommand) => {
const target = nodes.find((node) => node.id === (command.nodeIds[0] || selectedNodeId));
if (command.nodeIds.some((id) => !nodes.some((node) => node.id === id))) throw new Error("目标节点不属于当前视频画布");
if (target) { setSelectedNodeId(target.id); setSelectedEdgeId(null); }
if (command.featureId.startsWith("video.function.")) {
const key = command.featureId.slice("video.function.".length);
const tool = configuredTools.find((item) => item.toolKey === key);
if (!tool) throw new Error("此视频功能已关闭或尚未配置");
if ((key === "grid-split" || key === "image-crop") && target) { await runSelectedImageToolAction(target.id, "crop"); return; }
if (key === "angle-edit" && target) { setAngleEditorNodeId(target.id); return; }
const created = createToolNode(tool, target ? target.x + target.width + 72 : 360, target?.y || 300);
addNodes([created], command.nodeIds.map((id) => ({ id: makeId("edge"), source: id, target: created.id })));
setInspectorNodeId(created.id);
return;
}
const action = command.featureId.replace("video.ui.", "");
const panels: Record<string, Exclude<ActivePanel, null>> = { assets: "assets", roles: "roleLibrary", styles: "styleLibrary", effects: "effectLibrary", history: "history", workflows: "workflow", share: "share", publish: "publish", skills: "skills", help: "help" };
if (panels[action]) { setActivePanel(panels[action]); return; }
if (action.startsWith("upload-")) { addTargetModule(action === "upload-video" ? "video" : action === "upload-audio" ? "audio" : "image"); toast.message("已添加上传模块,请选择本机文件"); return; }
if (action === "save") { if (!(await persistence.saveAndConfirm())) throw new Error("画布保存尚未确认,请稍后重试"); return; }
if (action === "export") {
const url = URL.createObjectURL(new Blob([JSON.stringify({ title: projectTitle, workspaceType: "video", canvasData: { nodes: serializeNodes(nodes), edges: serializeEdges(edges), viewport } }, null, 2)], { type: "application/json" }));
const link = document.createElement("a"); link.href = url; link.download = `${projectTitle}.json`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return;
}
if (action === "import") {
const picker = document.createElement("input"); picker.type = "file"; picker.accept = ".json,application/json";
picker.onchange = async () => { try { const file = picker.files?.[0]; if (!file) return; const data = JSON.parse(await file.text()); const imported = restoreNodes(data.canvasData || data); if (!imported.length) throw new Error("文件中没有可用的视频画布节点"); if (!window.confirm("确认将文件里的节点添加到当前画布?")) return; const ids = new Map(imported.map((node) => [node.id, makeId("import")])); addNodes(imported.map((node) => ({ ...node, id: ids.get(node.id)! })), restoreEdges(data.canvasData || data).filter((edge) => ids.has(edge.source) && ids.has(edge.target)).map((edge) => ({ id: makeId("edge"), source: ids.get(edge.source)!, target: ids.get(edge.target)! }))); } catch (error) { toast.error(error instanceof Error ? error.message : "导入失败"); } }; picker.click(); return;
}
if (!target) throw new Error("请先选中具体素材节点");
if (action === "download") { downloadMediaNode(target); return; }
if (action === "preview") { setPreviewNode(target); return; }
if (action === "local-edit") { await runSelectedImageToolAction(target.id, "localEdit"); return; }
if (action === "annotate") { await runSelectedImageToolAction(target.id, "annotate"); return; }
if (action === "director") { setDirectorConsoleNodeId(target.id); return; }
if (action === "storyboard") { openStoryScriptWorkflow(target.id); return; }
if (action === "batch-storyboard" || action === "batch-video") { openStoryScriptWorkflow(target.id); toast.message("请检查分镜及费用后在工作台提交批量任务"); return; }
if (action === "video-hd") { setInspectorNodeId(target.id); toast.message("当前视频 AI 高清尚缺真实后端,不能将原视频冒充高清结果"); return; }
if (action === "video-clip") { setActiveVideoToolbarMode("clip"); return; }
if (action === "video-crop") { setActiveVideoToolbarMode("crop"); toast.message("当前裁剪导出后端尚未接入,不能当作已完成的视频裁剪"); return; }
if (action === "subtitle-erase") { setActiveVideoToolbarMode("subtitleErase"); toast.message("当前去字幕尚未接入真实处理后端"); return; }
if (["audio-split", "voice-split", "background-split", "frame-first", "frame-last", "frame-current"].includes(action)) { setPreviewNode(target); toast.message("已授权入口;当前页面此功能仍为模拟流程,Agent 不会生成占位素材或伪报完成"); return; }
throw new Error("此入口需要在相应的视频工作区打开");
};
return ( return (
<div <div
className="video-canvas-workspace fixed inset-0 h-dvh w-full overflow-clip bg-[#151515] text-white" className="video-canvas-workspace fixed inset-0 h-dvh w-full overflow-clip bg-[#151515] text-white"
@ -11052,7 +10903,6 @@ export function LiblibVideoCanvasReplica({
void addUploadedFilesAtPoint(files, toCanvasPoint(event.clientX, event.clientY)); void addUploadedFilesAtPoint(files, toCanvasPoint(event.clientX, event.clientY));
}} }}
> >
<CanvasAgentUiListener projectId={canvasId} workspace="video" onVideoAction={handleAgentVideoUiCommand} />
<div className="pointer-events-none absolute inset-0" style={{backgroundImage:"radial-gradient(circle,rgba(255,255,255,0.14) 0.7px,transparent 0.7px)",backgroundPosition:"0.5px 0.5px",backgroundSize:"16px 16px"}} /> <div className="pointer-events-none absolute inset-0" style={{backgroundImage:"radial-gradient(circle,rgba(255,255,255,0.14) 0.7px,transparent 0.7px)",backgroundPosition:"0.5px 0.5px",backgroundSize:"16px 16px"}} />
{fileDragActive && ( {fileDragActive && (
<div className="pointer-events-none absolute inset-3 z-[1800] flex items-center justify-center rounded-2xl border-2 border-dashed border-cyan-300 bg-cyan-300/5" role="status" aria-live="polite"> <div className="pointer-events-none absolute inset-3 z-[1800] flex items-center justify-center rounded-2xl border-2 border-dashed border-cyan-300 bg-cyan-300/5" role="status" aria-live="polite">
@ -11941,29 +11791,6 @@ export function LiblibVideoCanvasReplica({
onResolve={persistence.resolveConflict} onResolve={persistence.resolveConflict}
onDownload={persistence.downloadDraft} onDownload={persistence.downloadDraft}
/> />
{ZCODE_VIDEO_CANVAS_AUTHORIZED && (isVideoAgentOpen ? (
<VideoCanvasAgentPanel
key={canvasId}
projectId={canvasId}
selectedNodeIds={selectedNodeId ? [selectedNodeId] : []}
videoModels={canvasModelOptions.filter((model) => isVideoModelDescriptor(model))}
imageModels={canvasModelOptions.filter((model) => isImageGenerationModelDescriptor(model))}
beforeSend={saveBeforeVideoAgent}
onResults={(projectId, ids) => void refreshVideoAgentResults(projectId, ids)}
onClose={() => setIsVideoAgentOpen(false)}
/>
) : (
<button
type="button"
data-canvas-overlay="true"
onClick={() => { setInspectorNodeId(null); setIsVideoAgentOpen(true); }}
className="absolute right-4 top-[76px] z-40 flex items-center gap-2 rounded-xl border border-white/10 bg-[#252525] px-4 py-3 text-sm text-white shadow-lg hover:bg-[#303030]"
aria-label="打开视频创作 Agent"
>
<FileText className="h-4 w-4" />视频 Agent
</button>
))}
<TopBar <TopBar
canvasId={canvasId} canvasId={canvasId}
canvasViewMode={canvasViewMode} canvasViewMode={canvasViewMode}
@ -21834,8 +21661,8 @@ const STYLE_MARKET_CATEGORIES = [
"全部", "全部",
]; ];
const STYLE_MARKET_THUMBNAIL_URL = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-character-three-view-cover.png"; const STYLE_MARKET_THUMBNAIL_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-character-three-view-cover.png";
const EFFECT_MARKET_THUMBNAIL_URL = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/video-canvas-demo/starter-first-frame-video-cover.png"; const EFFECT_MARKET_THUMBNAIL_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/starter-first-frame-video-cover.png";
const STYLE_MARKET_CARDS: AssetMarketCard[] = [ const STYLE_MARKET_CARDS: AssetMarketCard[] = [
["复古马卡龙", "算力潮汐", 177], ["复古马卡龙", "算力潮汐", 177],

View File

@ -1,452 +0,0 @@
"use client";
import { ChevronRight, X } from "lucide-react";
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useState,
type HTMLAttributes,
type MouseEvent,
type ReactElement,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
saveImageToolbarPreferences,
useImageToolbarPreferences,
type ImageToolbarPreferences,
} from "@/lib/canvas-image-toolbar-preferences";
type ToolProps = HTMLAttributes<HTMLButtonElement> & {
"data-tool-key"?: string;
disabled?: boolean;
children?: ReactNode;
};
type Tool = ReactElement<ToolProps>;
type Props = HTMLAttributes<HTMLDivElement> & {
referencePresentation: boolean;
defaultPinnedKeys: string[];
};
function toolLabel(tool: Tool) {
return tool.props.title || "";
}
function toolIcon(tool: Tool, size: number) {
const icon = Children.toArray(tool.props.children).find(isValidElement);
if (
!isValidElement<{
className?: string;
style?: object;
referenceSize?: number;
}>(icon)
)
return null;
return cloneElement(icon, {
className: "shrink-0",
style: { width: size, height: size },
...(icon.type === CanvasToolbarIcon ? { referenceSize: size } : {}),
});
}
function toolCredit(tool: Tool) {
return Children.toArray(tool.props.children).find(
(child) =>
isValidElement<{ compact?: boolean; label?: string }>(child) &&
child.props.compact &&
child.props.label
) as
| ReactElement<{ compact?: boolean; label?: string; className?: string }>
| undefined;
}
function tooltipButton(tool: Tool, showNames: boolean) {
const name = toolLabel(tool);
// Clone the existing action so its handler, busy state and administrator icon override remain intact.
const button = cloneElement(tool, {
title: undefined,
"aria-label": name,
children: (
<>
{toolIcon(tool, 25)}
{showNames && <span className="lovart-image-tool-label">{name}</span>}
</>
),
});
return (
<Tooltip key={tool.props["data-tool-key"] || name}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
className="lovart-image-tool-tooltip"
side="top"
sideOffset={8}
>
{name}
{toolCredit(tool) &&
cloneElement(toolCredit(tool)!, {
compact: false,
className: "lovart-image-tool-cost",
})}
</TooltipContent>
</Tooltip>
);
}
export const ReferenceImageToolbar = forwardRef<HTMLDivElement, Props>(
function ReferenceImageToolbar(
{ referencePresentation, defaultPinnedKeys, children, ...rootProps },
ref
) {
const saved = useImageToolbarPreferences();
const [draft, setDraft] = useState<ImageToolbarPreferences | null>(null);
const [draggedKey, setDraggedKey] = useState<string | null>(null);
const elements = Children.toArray(children).filter(
isValidElement
) as ReactElement<ToolProps>[];
const tools = elements.filter(
(element) =>
element.props["data-tool-key"] &&
!["addToChat", "download"].includes(element.props["data-tool-key"]!)
);
const toolMap = new Map(
tools.map((tool) => [tool.props["data-tool-key"]!, tool])
);
const preferences = saved || {
pinnedKeys: defaultPinnedKeys,
showNames: false,
};
const pinned = preferences.pinnedKeys.filter((key) => toolMap.has(key));
const moreTools = tools.filter(
(tool) => !pinned.includes(tool.props["data-tool-key"]!)
);
const addToChat = elements.find(
(element) => element.props["data-tool-key"] === "addToChat"
);
const download = elements.find(
(element) => element.props["data-tool-key"] === "download"
);
const originalMenu = elements.find(
(element) => element.type === DropdownMenu
);
const menuChildren = originalMenu
? (Children.toArray(originalMenu.props.children).filter(
isValidElement
) as ReactElement<{ children?: ReactNode }>[])
: [];
const menuTrigger = menuChildren.find(
(element) => element.type === DropdownMenuTrigger
);
const moreButton =
menuTrigger &&
(Children.toArray(menuTrigger.props.children).find(isValidElement) as
| Tool
| undefined);
const extras = menuChildren.find(
(element) => element.type === DropdownMenuContent
)?.props.children;
const openCustomizer = () =>
setDraft({
pinnedKeys: [...preferences.pinnedKeys],
showNames: preferences.showNames,
});
const toggle = (key: string) =>
setDraft((current) =>
current
? {
...current,
pinnedKeys: current.pinnedKeys.includes(key)
? current.pinnedKeys.filter((item) => item !== key)
: [...current.pinnedKeys, key],
}
: current
);
const move = (key: string, target: string) =>
setDraft((current) => {
if (!current || key === target) return current;
const next = [...current.pinnedKeys];
const from = next.indexOf(key),
to = next.indexOf(target);
if (from < 0 || to < 0) return current;
next.splice(from, 1);
next.splice(to, 0, key);
return { ...current, pinnedKeys: next };
});
if (!referencePresentation)
return (
<div {...rootProps} ref={ref}>
{children}
</div>
);
return (
<>
<div
{...rootProps}
ref={ref}
style={{ ...rootProps.style, overflowX: "auto" }}
data-show-tool-names={preferences.showNames ? "true" : "false"}
>
{addToChat && tooltipButton(addToChat, preferences.showNames)}
{addToChat && <div className="w-px" />}
{pinned.map((key) =>
tooltipButton(toolMap.get(key)!, preferences.showNames)
)}
<DropdownMenu>
{moreButton && (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
{cloneElement(moreButton, {
title: undefined,
"aria-label": toolLabel(moreButton),
children: toolIcon(moreButton, 25),
})}
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent
className="lovart-image-tool-tooltip"
sideOffset={8}
>
{toolLabel(moreButton)}
</TooltipContent>
</Tooltip>
)}
<DropdownMenuContent
className="lovart-image-action-menu lovart-image-custom-more"
side="bottom"
align="end"
>
{moreTools.map((tool) => (
<DropdownMenuItem
key={tool.props["data-tool-key"]}
disabled={tool.props.disabled}
onClick={(event) =>
tool.props.onClick?.(
event as unknown as MouseEvent<HTMLButtonElement>
)
}
>
{toolIcon(tool, 18)}
<span>{toolLabel(tool)}</span>
{toolCredit(tool) &&
cloneElement(toolCredit(tool)!, {
compact: false,
className: "lovart-image-tool-cost",
})}
</DropdownMenuItem>
))}
{extras}
<DropdownMenuSeparator />
<DropdownMenuItem
className="lovart-customize-entry"
onSelect={openCustomizer}
>
自定义工具栏
<ChevronRight size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{download && (
<>
<div className="w-px" />
{tooltipButton(download, preferences.showNames)}
</>
)}
</div>
<Dialog
open={Boolean(draft)}
onOpenChange={(open) => {
if (!open) setDraft(null);
}}
>
<DialogContent
hideCloseButton
className="lovart-toolbar-customizer"
overlayClassName="lovart-toolbar-customizer-overlay"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<header className="lovart-customizer-header">
<div>
<DialogTitle>自定义工具栏</DialogTitle>
<DialogDescription>
选择你想在编辑栏中使用的工具
</DialogDescription>
</div>
<div className="lovart-customizer-header-actions">
<button
type="button"
aria-label="恢复默认工具栏"
onClick={() =>
setDraft({
pinnedKeys: [...defaultPinnedKeys],
showNames: false,
})
}
>
<ReferenceIcon name="toolbar-reset" size={16} />
</button>
<button
type="button"
aria-label="关闭自定义工具栏"
onClick={() => setDraft(null)}
>
<ReferenceIcon name="toolbar-close" size={16} />
</button>
</div>
</header>
<div className="lovart-customizer-body">
<div
className="lovart-customizer-preview"
aria-label="工具栏预览"
>
<div className="lovart-customizer-preview-bar">
{addToChat && (
<span className="lovart-preview-fixed">
{toolIcon(addToChat, 18)}
{draft?.showNames && toolLabel(addToChat)}
</span>
)}
{draft?.pinnedKeys
.filter((key) => toolMap.has(key))
.map((key, index, keys) => {
const tool = toolMap.get(key)!;
return (
<div
className="lovart-preview-chip"
key={key}
draggable
onDragStart={(event) => {
setDraggedKey(key);
event.dataTransfer.setData("text/plain", key);
event.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => setDraggedKey(null)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
if (draggedKey) move(draggedKey, key);
setDraggedKey(null);
}}
>
<button
type="button"
className="lovart-preview-handle"
aria-label={`${toolLabel(tool)},按左右方向键调整顺序`}
onKeyDown={(event) => {
const offset =
event.key === "ArrowLeft"
? -1
: event.key === "ArrowRight"
? 1
: 0;
if (offset && keys[index + offset]) {
event.preventDefault();
move(key, keys[index + offset]);
}
}}
>
{toolIcon(tool, 18)}
{draft.showNames && <span>{toolLabel(tool)}</span>}
</button>
<button
type="button"
aria-label={`移除${toolLabel(tool)}`}
onClick={() => toggle(key)}
>
<X size={12} />
</button>
</div>
);
})}
</div>
</div>
<div className="lovart-customizer-tools">
{tools.map((tool) => {
const key = tool.props["data-tool-key"]!;
const isPinned = Boolean(draft?.pinnedKeys.includes(key));
return (
<button
key={key}
type="button"
aria-label={`${isPinned ? "取消固定" : "固定"}${toolLabel(tool)}`}
aria-pressed={isPinned}
onClick={() => toggle(key)}
>
{toolIcon(tool, 18)}
<span>{toolLabel(tool)}</span>
<ReferenceIcon
name={
isPinned
? "toolbar-pin-filled"
: "toolbar-pin-outline"
}
size={13}
/>
</button>
);
})}
</div>
</div>
<footer className="lovart-customizer-footer">
<label>
<button
type="button"
role="switch"
aria-label="显示工具名称"
aria-checked={Boolean(draft?.showNames)}
className="lovart-customizer-switch"
onClick={() =>
setDraft((current) =>
current
? { ...current, showNames: !current.showNames }
: current
)
}
>
<span />
</button>
显示工具名称
</label>
<div>
<button type="button" onClick={() => setDraft(null)}>
取消
</button>
<button
type="button"
className="lovart-customizer-save"
onClick={() => {
if (draft && saveImageToolbarPreferences(draft))
setDraft(null);
else
toast.error("无法保存工具栏设置,请检查浏览器存储权限");
}}
>
保存
</button>
</div>
</footer>
</DialogContent>
</Dialog>
</>
);
}
);

View File

@ -1,47 +0,0 @@
"use client";
import { CircleAlert, Check, ChevronDown, Loader2, RotateCcw, Square } from "lucide-react";
import type { AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol";
import { canvasAgentProgressLabel, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
export function CanvasAgentProgressMessage(props: {
progress: CanvasAgentProgress; run?: AgentRunView | null; busy?: boolean;
stopping?: boolean; sending?: boolean; onCancel?: () => void; onResume?: () => void;
}) {
const { progress, run } = props;
const running = progress.status === "running";
const label = progress.status === "completed" ? "处理完成"
: props.stopping && running ? "正在停止后续步骤,已完成的结果会保留"
: canvasAgentProgressLabel(progress.message || "正在处理你的任务");
return (
<div className="my-2 w-full min-w-0 text-xs text-zinc-500 dark:text-zinc-400" data-testid="canvas-agent-progress" data-agent-message-id={run?.assistantMessageId}>
<div className="flex items-start gap-2">
<details className="min-w-0 flex-1" open={running}>
<summary className="flex cursor-pointer list-none items-center gap-2 py-1 [&::-webkit-details-marker]:hidden">
{running ? <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" /> : progress.status === "completed" ? <Check className="h-3.5 w-3.5 shrink-0" /> : <CircleAlert className="h-3.5 w-3.5 shrink-0" />}
<span className="min-w-0 flex-1" role="status" aria-live="polite">{label}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</summary>
{progress.activities?.length ? (
<ol className="ml-1.5 mt-1 space-y-1.5 border-l border-zinc-200 pl-4 dark:border-zinc-800" aria-label="任务执行步骤">
{progress.activities.map(activity => (
<li key={activity.id} className="flex items-center gap-2">
{activity.status === "started" ? <Loader2 className="h-3 w-3 shrink-0 animate-spin" /> : <span className="h-1 w-1 shrink-0 rounded-full bg-current" />}
<span>{canvasAgentProgressLabel(activity.title)}</span>
<span className="text-[11px] opacity-75">{activity.status === "succeeded" ? "已完成" : activity.status === "failed" ? "未完成" : activity.status === "queued" ? "待执行" : "进行中"}</span>
</li>
))}
</ol>
) : null}
{run?.inputs.slice(-2).map(input => (
<p key={input.id} className="mt-2 whitespace-pre-wrap break-words">{input.applied ? "已应用补充要求" : "已收到补充要求"}:{input.text}</p>
))}
</details>
{running && props.onCancel ? (
<button type="button" onClick={props.onCancel} disabled={props.sending || props.stopping} className="rounded-md p-1.5 hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-white/10" title="停止后续步骤" aria-label="停止后续步骤"><Square className="h-3.5 w-3.5" /></button>
) : run?.resumable && props.onResume ? (
<button type="button" onClick={props.onResume} disabled={props.busy} className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-white/10"><RotateCcw className="h-3.5 w-3.5" />继续任务</button>
) : null}
</div>
</div>
);
}

View File

@ -1,13 +0,0 @@
"use client";
// Compatibility wrapper for the paused video entry. Image chat observes the run
// once and renders its activity in the corresponding assistant message.
import { CanvasAgentProgressMessage } from "./canvas-agent-progress";
import { useCanvasAgentRun, type CanvasAgentRunOptions } from "./use-canvas-agent-run";
import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
export function CanvasAgentRunPanel(props: CanvasAgentRunOptions & { onResume: (runId: string) => void }) {
const task = useCanvasAgentRun(props);
if (!task.run) return null;
return <CanvasAgentProgressMessage progress={canvasAgentProgress(task.run)} run={task.run} busy={props.busy}
stopping={task.stopPending} sending={task.sending} onCancel={() => void task.control("cancel")}
onResume={() => props.onResume(task.run!.id)} />;
}

View File

@ -152,7 +152,16 @@ export function shouldRetryCanvasViewOperationAfterRefresh(
); );
} }
export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status"; export type ImageGenerationStatusStreamPayload = {
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
providerType?: string;
modelName?: string;
imageCount?: number;
queuePosition?: number;
sameModelRunningCount?: number;
concurrencyLimit?: number;
estimatedWaitMs?: number;
};
export type AgentTargetLockRefPreview = { export type AgentTargetLockRefPreview = {
url: string; url: string;

View File

@ -1,78 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { getCanvasAgentAccessManifest } from "@/lib/ai/canvas-agent-runtime/capability-access";
import {
canvasAgentUiCommandSchema,
CANVAS_AGENT_UI_COMMAND_EVENT,
type CanvasAgentUiCommand,
} from "@/lib/ai/canvas-agent-runtime/ui-command";
import { useCanvasStore } from "@/store/canvas";
export function CanvasAgentUiListener(props: {
projectId: string;
workspace: "canvas" | "video";
onVideoAction?: (command: CanvasAgentUiCommand) => void | Promise<void>;
}) {
const latest = useRef(props);
latest.current = props;
const seen = useRef(new Set<string>());
useEffect(() => {
const handler = async (event: Event) => {
const parsed = canvasAgentUiCommandSchema.safeParse((event as CustomEvent).detail);
if (!parsed.success) return;
const command = parsed.data,
current = latest.current;
if (
command.projectId !== current.projectId ||
command.workspace !== current.workspace ||
seen.current.has(command.requestId)
)
return;
seen.current.add(command.requestId);
try {
if (current.workspace === "video") {
await current.onVideoAction?.(command);
return;
}
const feature = getCanvasAgentAccessManifest().find(
(item) => item.id === command.featureId && item.workspace === "canvas",
);
if (!feature || !feature.id.startsWith("canvas.ui.")) throw new Error("这个界面入口不存在");
const key = feature.id.slice("canvas.ui.".length);
if (/chatSend|chatStartGeneration/.test(key)) {
toast.message("已定位对话提交入口,请由你确认并发送;Agent 不会伪造下一条用户消息");
return;
}
const state = useCanvasStore.getState();
if (state.projectId !== current.projectId) return;
if (command.nodeIds.length)
state.selectNodes(
command.nodeIds.filter((id) => state.nodes.some((node) => node.id === id)),
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const element = Array.from(
document.querySelectorAll<HTMLElement>("[data-canvas-feature-key]"),
).find((item) => item.dataset.canvasFeatureKey === key && item.getClientRects().length);
const button = element?.closest<HTMLElement>("button,[role=button],a");
if (!button || button.hasAttribute("disabled"))
throw new Error(
"此入口当前未显示或不可用;请切换相应工作区。工具授权不会恢复被隐藏的按钮。",
);
if (
/Delete|Generate|Confirm|Cancel|SaveText|realtimeSend/.test(key) &&
!window.confirm(`Agent 请求操作「${feature.title}」。确认在当前画布执行?`)
)
return;
button.click();
} catch (error) {
toast.error(error instanceof Error ? error.message : "无法打开画布入口");
}
};
window.addEventListener(CANVAS_AGENT_UI_COMMAND_EVENT, handler);
return () => window.removeEventListener(CANVAS_AGENT_UI_COMMAND_EVENT, handler);
}, []);
return null;
}

View File

@ -448,7 +448,7 @@ function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
} }
} }
const markdownLinkPattern = /(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi; const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
while ((match = markdownLinkPattern.exec(text)) !== null) { while ((match = markdownLinkPattern.exec(text)) !== null) {
const label = String(match[1] || ""); const label = String(match[1] || "");
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label) const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
@ -491,7 +491,7 @@ export function stripMediaMarkup(text: string): string {
.replace(/<audio[\s\S]*?<\/audio>/gi, "") .replace(/<audio[\s\S]*?<\/audio>/gi, "")
.replace(/<audio\b[^>]*\/?\s*>/gi, "") .replace(/<audio\b[^>]*\/?\s*>/gi, "")
.replace(/<a[\s\S]*?<\/a>/gi, "") .replace(/<a[\s\S]*?<\/a>/gi, "")
.replace(/(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => { .replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test( const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
String(label || "") String(label || "")
); );

View File

@ -1,70 +0,0 @@
import { normalizeImageIdentityUrl } from "./canvas-chat-media";
import type { ArchivedImageUrlMap } from "@/lib/storage/image-archive-urls";
type ImageResultNode = {
type: string;
data?: unknown;
};
function imageUrls(node: ImageResultNode): string[] {
if (node.type !== "image") return [];
const data = node.data as { src?: string; metadata?: { sourceImageKey?: string } } | undefined;
return [data?.src, data?.metadata?.sourceImageKey].filter(
(url): url is string => typeof url === "string" && Boolean(url.trim()),
);
}
export function getCanvasImageResultUrlKeys(
nodes: readonly ImageResultNode[],
aliases: ArchivedImageUrlMap = {},
) {
const keys = new Set<string>();
for (const node of nodes) {
for (const url of imageUrls(node)) {
keys.add(normalizeImageIdentityUrl(url));
if (aliases[url]) keys.add(normalizeImageIdentityUrl(aliases[url]));
}
}
keys.delete("");
return keys;
}
// A history refresh may already contain bucket URLs while this tab still shows
// provider previews. Resolve aliases only for an existing result of this message;
// a new generation never waits for storage before appearing on the canvas.
export async function resolveCanvasImageResultAliases(params: {
nodes: readonly ImageResultNode[];
messageId?: string;
imageUrls: string[];
resolveAliases: (urls: string[]) => Promise<ArchivedImageUrlMap>;
}): Promise<ArchivedImageUrlMap> {
const keys = getCanvasImageResultUrlKeys(params.nodes);
if (!params.messageId || params.imageUrls.every((url) => keys.has(normalizeImageIdentityUrl(url)))) {
return {};
}
const urls = [...new Set(params.nodes.flatMap((node) => {
const data = node.data as { metadata?: { sourceMessageId?: string } } | undefined;
return data?.metadata?.sourceMessageId === params.messageId ? imageUrls(node) : [];
}))].filter((url) => /^https?:\/\//i.test(url));
if (!urls.length) return {};
const aliases: ArchivedImageUrlMap = {};
for (let index = 0; index < urls.length; index += 100) {
Object.assign(aliases, await params.resolveAliases(urls.slice(index, index + 100)));
}
return aliases;
}
// Call synchronously immediately before changing the store, after all image
// loading/network awaits. Both stream and history writers share this check.
export function commitCanvasImageResultIfMissing<T>(params: {
getNodes: () => readonly ImageResultNode[];
url: string;
aliases: ArchivedImageUrlMap;
commit: () => T;
}): T | undefined {
const key = normalizeImageIdentityUrl(params.url);
if (!key || getCanvasImageResultUrlKeys(params.getNodes(), params.aliases).has(key)) {
return undefined;
}
return params.commit();
}

View File

@ -1,64 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import type { AgentRunView } from "@/lib/ai/canvas-agent-runtime/protocol";
export type CanvasAgentRunOptions = {
enabled: boolean; chatId: string; projectId: string; busy: boolean;
onResults: (projectId: string, nodeIds: string[]) => void;
onRunChange?: (run: AgentRunView | null) => void;
};
export function useCanvasAgentRun(props: CanvasAgentRunOptions) {
const [sending, setSending] = useState(false);
const [stopping, setStopping] = useState(false);
const sendingRef = useRef(false);
const scope = `${props.chatId}:${props.projectId}`;
const latest = useRef({ props, scope }); latest.current = { props, scope };
const resultSignature = useRef("");
const key = props.enabled ? `/api/canvas/agent-runs?${new URLSearchParams({ chatId: props.chatId, projectId: props.projectId })}` : null;
const { data, mutate } = useSWR<{ run: AgentRunView | null }>(key, async (url: string) => {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return { run: null };
return response.json();
}, { refreshInterval: latest => latest?.run?.status === "running" || props.busy ? 2000 : 0,
revalidateOnFocus: true, shouldRetryOnError: false });
const run = props.enabled && data?.run?.chatId === props.chatId && data.run.projectId === props.projectId ? data.run : null;
useEffect(() => { latest.current.props.onRunChange?.(run); }, [run]);
useEffect(() => { setSending(false); sendingRef.current = false; setStopping(false); resultSignature.current = ""; }, [scope]);
useEffect(() => {
if (!props.enabled) return;
const handler = (event: Event) => {
const update = (event as CustomEvent<AgentRunView>).detail;
if (update?.chatId === props.chatId && update?.projectId === props.projectId) void mutate({ run: update }, false);
};
window.addEventListener("canvas-agent-run:update", handler);
return () => window.removeEventListener("canvas-agent-run:update", handler);
}, [props.enabled, props.chatId, props.projectId, mutate]);
useEffect(() => {
if (!run?.resultNodeIds.length) return;
const signature = `${run.id}:${run.resultNodeIds.join(",")}:${run.status}`;
if (signature === resultSignature.current) return;
resultSignature.current = signature;
latest.current.props.onResults(run.projectId, run.resultNodeIds);
}, [run]);
const control = useCallback(async (action: "steer" | "cancel", text?: string) => {
if (!run || sendingRef.current) return false;
sendingRef.current = true;
const startedScope = scope;
setSending(true);
try {
const response = await fetch("/api/canvas/agent-runs", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, chatId: props.chatId, projectId: props.projectId, runId: run.id,
...(action === "steer" ? { inputId: crypto.randomUUID(), text: text?.trim() } : {}) }) });
const result = await response.json();
if (!response.ok) throw new Error(result.error || "操作失败,请重试");
if (startedScope !== latest.current.scope) return false;
await mutate(result, false);
if (action === "cancel") setStopping(true);
toast.message(action === "steer" ? "补充要求已收到,将在下一步应用" : "正在停止后续步骤,已完成的结果会保留");
return true;
} catch (error) { toast.error(error instanceof Error ? error.message : "操作失败"); return false; }
finally { if (startedScope === latest.current.scope) { sendingRef.current = false; setSending(false); } }
}, [run, sending, scope, props.chatId, props.projectId, mutate]);
return { run, sending, stopPending: stopping || Boolean(run?.cancelRequested), control };
}

View File

@ -1,343 +0,0 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { ArrowUp, MessageSquare, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel";
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
import {
videoAgentSettingsSchema,
type VideoAgentSettings,
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
type ModelOption = { id: string; displayName: string };
export function VideoCanvasAgentPanel(props: {
projectId: string;
selectedNodeIds: string[];
videoModels: ModelOption[];
imageModels: ModelOption[];
beforeSend: () => Promise<void>;
onResults: (projectId: string, nodeIds: string[]) => void;
onClose: () => void;
}) {
const [input, setInput] = useState("");
const [settings, setSettings] = useState<VideoAgentSettings>(() =>
videoAgentSettingsSchema.parse({}),
);
const [preparing, setPreparing] = useState(false);
const [loading, setLoading] = useState(true);
const [backgroundRunning, setBackgroundRunning] = useState(false);
const latest = useRef({ props, settings });
latest.current = { props, settings };
const resumeRef = useRef<string | undefined>(undefined);
const mounted = useRef(true);
const resultIds = useRef<string[]>([]);
const restoredRunId = useRef("");
const { messages, setMessages, sendMessage, status } = useChat({
id: `video-agent-${props.projectId}`,
generateId: () => crypto.randomUUID(),
transport: new DefaultChatTransport({
api: "/api/canvas/agent-chat",
prepareSendMessagesRequest: ({ messages }) => {
const current = latest.current;
const resumeRunId = resumeRef.current;
resumeRef.current = undefined;
return {
body: {
id: current.props.projectId,
message: messages[messages.length - 1],
selectedChatModel: "agent",
selectedVisibilityType: "private",
composerMode: "agent",
workspaceVariant: "video",
videoAgentSettings: current.settings,
resumeRunId,
contextPayload: {
source: "canvas",
projectId: current.props.projectId,
selectedNodeIds: current.props.selectedNodeIds,
},
},
};
},
}),
onData: (part) => {
if (part.type === "data-videoCanvasMutation") window.dispatchEvent(new CustomEvent("video-canvas-agent-mutation", { detail: part.data }));
if (part.type === "data-canvasAgentUiCommand") window.dispatchEvent(new CustomEvent("canvas-agent-ui-command", { detail: part.data }));
if (part.type === "data-canvasAgentRun")
window.dispatchEvent(new CustomEvent("canvas-agent-run:update", { detail: part.data }));
},
onError: (error) => toast.error(error.message || "视频任务启动失败,请重试"),
});
useEffect(() => {
mounted.current = true;
const controller = new AbortController();
void fetch(`/api/canvas/video-agent?projectId=${props.projectId}`, {
cache: "no-store",
signal: controller.signal,
})
.then(async (response) => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "读取视频对话失败");
if (!controller.signal.aborted) setMessages(data.messages as UIMessage[]);
})
.catch((error) => {
if (!controller.signal.aborted) toast.error(error.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => {
mounted.current = false;
controller.abort();
};
}, [props.projectId, setMessages]);
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming";
useEffect(() => {
const draft = readCanvasComposerDraft(`video-agent-${props.projectId}`);
if (draft) setInput(draft.text);
}, [props.projectId]);
useEffect(() => {
const beforeRefresh = (event: Event) => {
const request = (event as CustomEvent<PageRefreshRequest>).detail;
if (busy) request.block("当前视频任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
request.save(async () => {
if (input.trim() && !writeCanvasComposerDraft(`video-agent-${props.projectId}`, { text: input, attachments: [] }))
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
});
};
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
}, [busy, input, props.projectId]);
const submit = useCallback(
async (resumeRunId?: string) => {
if (busy || loading) return;
const text =
input.trim() ||
(resumeRunId ? "请继续尚未完成的视频创作步骤,保留已完成的脚本、关键帧和视频。" : "");
if (!text) return;
setPreparing(true);
try {
await latest.current.props.beforeSend();
if (!mounted.current) return;
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
resumeRef.current = resumeRunId;
setInput("");
await sendMessage({ text });
} catch (error) {
toast.error(error instanceof Error ? error.message : "视频任务启动失败");
} finally {
resumeRef.current = undefined;
if (mounted.current) setPreparing(false);
}
},
[busy, loading, input, sendMessage],
);
const onRunChange = useCallback(
(run: import("@/lib/ai/canvas-agent-runtime/protocol").AgentRunView | null) => {
resultIds.current = run?.resultNodeIds || [];
if (run?.videoSettings && restoredRunId.current !== run.id) {
restoredRunId.current = run.id;
setSettings(run.videoSettings);
}
setBackgroundRunning(run?.status === "running");
},
[],
);
const onResults = useCallback(
(projectId: string, ids: string[]) => latest.current.props.onResults(projectId, ids),
[],
);
useEffect(() => {
const timer = setInterval(() => {
if (resultIds.current.length)
latest.current.props.onResults(latest.current.props.projectId, resultIds.current);
}, 10_000);
return () => clearInterval(timer);
}, []);
return (
<aside
className="dark absolute bottom-3 right-3 top-[72px] z-40 flex w-[390px] max-w-[calc(100vw-24px)] flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#202020] text-zinc-100 shadow-2xl"
data-canvas-overlay="true"
data-testid="video-agent-panel"
aria-label="视频创作 Agent"
>
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3">
<MessageSquare className="h-4 w-4" />
<span className="flex-1 font-medium">视频创作 Agent</span>
<button
type="button"
onClick={props.onClose}
aria-label="收起视频 Agent"
className="rounded-lg p-1 hover:bg-white/10"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4" aria-live="polite">
{loading ? (
<p className="text-sm text-zinc-400">正在读取对话…</p>
) : messages.length === 0 ? (
<p className="text-sm leading-7 text-zinc-400">
选择画布素材,告诉我你想制作的视频。我可以分析参考、编写分镜、生成关键帧并继续生成视频。
</p>
) : null}
{messages.map((message) => (
<div
key={message.id}
className={
message.role === "user"
? "ml-5 whitespace-pre-wrap rounded-2xl bg-white/10 px-3 py-2 text-sm leading-6"
: "whitespace-pre-wrap text-sm leading-6 text-zinc-300"
}
>
{message.parts
.filter((part) => part.type === "text")
.map((part, i) => (
<span key={`${message.id}-${i}`}>{part.type === "text" ? part.text : ""}</span>
))}
</div>
))}
</div>
<div className="border-t border-white/10 p-3">
<CanvasAgentRunPanel
enabled={!loading}
chatId={props.projectId}
projectId={props.projectId}
busy={busy}
onResume={(id) => void submit(id)}
onResults={onResults}
onRunChange={onRunChange}
/>
<div className="mb-2 grid grid-cols-2 gap-2 text-xs">
<select
aria-label="视频模型"
disabled={busy}
value={settings.modelRef || ""}
onChange={(e) => setSettings({ ...settings, modelRef: e.target.value || undefined })}
className="min-w-0 rounded-lg border border-white/10 bg-zinc-800 p-2"
>
<option value="">默认视频模型</option>
{props.videoModels.map((m) => (
<option key={m.id} value={m.id}>
{m.displayName}
</option>
))}
</select>
<select
aria-label="关键帧模型"
disabled={busy}
value={settings.imageModelRef || ""}
onChange={(e) =>
setSettings({ ...settings, imageModelRef: e.target.value || undefined })
}
className="min-w-0 rounded-lg border border-white/10 bg-zinc-800 p-2"
>
<option value="">默认关键帧模型</option>
{props.imageModels.map((m) => (
<option key={m.id} value={m.id}>
{m.displayName}
</option>
))}
</select>
<select
aria-label="视频模式"
disabled={busy}
value={settings.mode}
onChange={(e) =>
setSettings({ ...settings, mode: e.target.value as VideoAgentSettings["mode"] })
}
className="rounded-lg border border-white/10 bg-zinc-800 p-2"
>
{[
["auto", "按参考图匹配模式"],
["text-to-video", "文生视频"],
["first-frame", "首帧视频"],
["first-last-frame", "首尾帧视频"],
["reference-images", "参考图视频"],
].map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<div className="flex items-center gap-1 rounded-lg border border-white/10 bg-zinc-800 px-2">
<input
aria-label="视频时长(秒)"
type="number"
min={1}
max={120}
disabled={busy}
value={settings.duration}
onChange={(e) => setSettings({ ...settings, duration: Number(e.target.value) })}
className="w-12 min-w-0 bg-transparent py-2"
/>
<span>秒</span>
<select
aria-label="视频比例"
disabled={busy}
value={settings.ratio}
onChange={(e) =>
setSettings({ ...settings, ratio: e.target.value as VideoAgentSettings["ratio"] })
}
className="min-w-0 flex-1 bg-zinc-800"
>
{["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
</div>
<select
aria-label="视频分辨率"
disabled={busy}
value={settings.resolution}
onChange={(e) =>
setSettings({
...settings,
resolution: e.target.value as VideoAgentSettings["resolution"],
})
}
className="rounded-lg border border-white/10 bg-zinc-800 p-2"
>
{["480p", "720p", "1080p"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
className="rounded-2xl border border-white/10 bg-[#151515] p-3"
>
<textarea
aria-label="视频创作要求"
value={input}
onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }}
maxLength={2000}
rows={3}
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"
className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-zinc-500"
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-zinc-500">
已选择 {props.selectedNodeIds.length} 个素材
</span>
<button
type="submit"
aria-label="开始视频任务"
disabled={busy || loading || !input.trim()}
className="rounded-full bg-zinc-100 p-2 text-zinc-900 disabled:opacity-30"
>
<ArrowUp className="h-4 w-4" />
</button>
</div>
</form>
</div>
</aside>
);
}

View File

@ -1,7 +1,4 @@
"use client"; "use client";
import { CanvasPreviewImage } from "./image-node/canvas-preview-image";
import { canvasImagePreviewWidth } from "@/lib/canvas/image-preview-width";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { import {
Box, Box,
@ -45,7 +42,7 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@ -59,10 +56,8 @@ import { CanvasLayoutReplaceBatchDialog } from "@/components/canvas/CanvasLayout
import { TextEditColorField } from "@/components/canvas/TextEditColorField"; import { TextEditColorField } from "@/components/canvas/TextEditColorField";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer"; import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer"; import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
import { import {
areImageAdjustmentsEqual, areImageAdjustmentsEqual,
buildImageAdjustmentFilterStyle, buildImageAdjustmentFilterStyle,
@ -189,7 +184,7 @@ import {
normalizeDetailUiSubjectAnalysis, normalizeDetailUiSubjectAnalysis,
} from "@/lib/detail-ui-subject-analysis"; } from "@/lib/detail-ui-subject-analysis";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId, getDefaultThreeDModelAliasId,
@ -235,8 +230,6 @@ import {
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors"; import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state"; import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capability";
import { useCanvasRemoteOperation } from "@/components/canvas/CanvasRemoteOperationContext";
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry"; import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types"; import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
@ -1314,8 +1307,6 @@ export function ImageNode({
}: ImageNodeProps) { }: ImageNodeProps) {
const router = useRouter(); const router = useRouter();
const routeParams = useParams(); const routeParams = useParams();
const pathname = usePathname();
const useReferenceImageToolbar = /^\/canvas\/[^/]+\/?$/.test(pathname);
const canvasId = Array.isArray(routeParams?.id) const canvasId = Array.isArray(routeParams?.id)
? routeParams.id[0] || "" ? routeParams.id[0] || ""
: typeof routeParams?.id === "string" : typeof routeParams?.id === "string"
@ -1541,11 +1532,9 @@ export function ImageNode({
.map((action) => action.key) .map((action) => action.key)
); );
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) => const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
useReferenceImageToolbar selectedImageInlineActionKeys.has(key);
? enabledSelectedImageToolbarActions.some((action) => action.key === key)
: selectedImageInlineActionKeys.has(key);
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) => const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
!useReferenceImageToolbar && selectedImageMoreActionKeys.has(key); selectedImageMoreActionKeys.has(key);
const hasHoverImageMorePrimaryActions = const hasHoverImageMorePrimaryActions =
imageRemoveBackgroundEnabled || imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled || imageRemoveWatermarkEnabled ||
@ -1571,7 +1560,6 @@ export function ImageNode({
const hasSelectedImageMorePrimaryActions = const hasSelectedImageMorePrimaryActions =
selectedImageMoreActionKeys.size > 0; selectedImageMoreActionKeys.size > 0;
const hasSelectedImageMoreMenu = const hasSelectedImageMoreMenu =
useReferenceImageToolbar ||
hasSelectedImageMorePrimaryActions || hasSelectedImageMorePrimaryActions ||
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) || (imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
(imageNodeMoreEnabled && imageDeleteEnabled); (imageNodeMoreEnabled && imageDeleteEnabled);
@ -1714,7 +1702,6 @@ export function ImageNode({
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
const [downloadProgress, setDownloadProgress] = const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null); useState<CanvasImageDownloadProgress | null>(null);
const downloadAbortControllerRef = useRef<AbortController | null>(null); const downloadAbortControllerRef = useRef<AbortController | null>(null);
@ -2084,7 +2071,7 @@ export function ImageNode({
const selectNode = useCanvasStore((state) => state.selectNode); const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId); const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState); const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const runCanvasRemoteOperation = useCanvasRemoteOperation(); const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const addPendingChatAttachment = useCanvasStore( const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment (state) => state.addPendingChatAttachment
); );
@ -2504,13 +2491,13 @@ export function ImageNode({
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05; const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640; const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600; const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
return canvasImagePreviewWidth(Math.max( return Math.max(
minWidth, minWidth,
Math.min( Math.min(
maxWidth, maxWidth,
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier) Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
) )
), maxWidth); );
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]); }, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88; const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
const displayImageSrc = useMemo( const displayImageSrc = useMemo(
@ -4685,44 +4672,106 @@ export function ImageNode({
return; return;
} }
if (!projectId || !runCanvasRemoteOperation) { setIsCreatingDetachedResultNode(true);
toast.error("画布还在加载,请稍后再试");
return;
}
setIsCreatingDetachedResultNode(false);
setIsProcessing(true); setIsProcessing(true);
const actionName = "框选抠图"; const actionName = "框选抠图";
const newNodeId = addNode({
try { type: "image",
const command = createCanvasCapabilityClientCommand({ position: {
capabilityId: "image.box_cutout", x: node.position.x + node.size.width + 20,
nodeIds: [node.id], y: node.position.y,
projectId, },
source: "toolbar", size: { width: node.size.width, height: node.size.height },
args: { data: {
...boxCutoutSelection, src: "",
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
}, },
},
connections: [node.id],
});
try {
const result = await imageGenerationService.boxCutout({
imageUrl: data.src,
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
sourceNodeId: node.id,
});
if (result.success && result.data?.url) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
cropRectApplied: result.data.cropRectApplied,
paddedCropRectApplied: result.data.paddedCropRectApplied,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
editMode: "box-cutout",
},
},
}); });
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
toast.success(`${actionName}完成`); toast.success(`${actionName}完成`);
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(result.error || `${actionName}失败`);
}
} catch (error) { } catch (error) {
console.error("框选抠图失败:", error); console.error("框选抠图失败:", error);
toast.error(error instanceof Error ? error.message : `${actionName}失败`); updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(`${actionName}失败,请重试`);
} finally { } finally {
setIsCreatingDetachedResultNode(false); setIsCreatingDetachedResultNode(false);
setIsProcessing(false); setIsProcessing(false);
resetBoxCutoutState(); resetBoxCutoutState();
} }
}, [ }, [
addNode,
boxCutoutSelection, boxCutoutSelection,
data.src, data.src,
displayTitle,
isProcessing, isProcessing,
node.id, node.id,
projectId, node.position.x,
node.position.y,
node.size.height,
node.size.width,
resetBoxCutoutState, resetBoxCutoutState,
runCanvasRemoteOperation, updateNode,
]); ]);
const animateAddToChat = useCallback(async (previewUrl?: string) => { const animateAddToChat = useCallback(async (previewUrl?: string) => {
@ -6652,7 +6701,7 @@ export function ImageNode({
const executeSharedToolbarCapability = async ( const executeSharedToolbarCapability = async (
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split" action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
) => { ) => {
if (!projectId || !runCanvasRemoteOperation) { if (!projectId) {
toast.error("画布还在加载,请稍后再试"); toast.error("画布还在加载,请稍后再试");
return; return;
} }
@ -6671,7 +6720,21 @@ export function ImageNode({
projectId, projectId,
source: "toolbar", source: "toolbar",
}); });
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command)); const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || `${capability.title}失败`);
}
const projectResult = await canvasService.getProject(projectId);
if (!projectResult.success || !projectResult.data?.canvasData) {
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
}
loadCanvas(projectResult.data.canvasData);
toast.success(`${capability.title}完成`); toast.success(`${capability.title}完成`);
} catch (error) { } catch (error) {
const message = const message =
@ -8266,7 +8329,7 @@ export function ImageNode({
signal: controller.signal, signal: controller.signal,
onProgress: setDownloadProgress, onProgress: setDownloadProgress,
}); });
toast.success("原图已开始下载"); toast.success("原图下载成功");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
toast.message("已取消下载"); toast.message("已取消下载");
@ -8370,11 +8433,7 @@ export function ImageNode({
{/* 图片内容 - 使用 object-contain 保持原始比例 */} {/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? ( {data.src ? (
<div className="relative h-full w-full"> <div className="relative h-full w-full">
{isRetiredStorageUrl(data.src) ? ( {!shouldRequestDisplayImage ? (
<div role="status" className="flex h-full w-full items-center justify-center bg-muted p-4 text-center text-sm text-muted-foreground">
{RETIRED_STORAGE_MESSAGE}
</div>
) : !shouldRequestDisplayImage ? (
<ImageNodeDeferredLoadState /> <ImageNodeDeferredLoadState />
) : isCompareMode && hasCompareSource ? ( ) : isCompareMode && hasCompareSource ? (
<div <div
@ -8401,16 +8460,15 @@ export function ImageNode({
}} }}
> >
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" /> <div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<CanvasPreviewImage <img
sourceKey={data.src}
alt={displayTitle} alt={displayTitle}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="eager" loading="lazy"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) { if (event.currentTarget.src !== data.src) {
event.currentTarget.src = buildImageFallbackUrl(data.src); event.currentTarget.src = data.src;
} }
}} }}
onLoad={handleDisplayImageLoad} onLoad={handleDisplayImageLoad}
@ -8431,16 +8489,15 @@ export function ImageNode({
}} }}
> >
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" /> <div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<CanvasPreviewImage <img
sourceKey={compareSource}
alt={`${displayTitle}-原图`} alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="eager" loading="lazy"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) { if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = buildImageFallbackUrl(compareSource); event.currentTarget.src = compareSource;
} }
}} }}
src={displayCompareSrc} src={displayCompareSrc}
@ -8482,8 +8539,7 @@ export function ImageNode({
</div> </div>
) : ( ) : (
<> <>
<CanvasPreviewImage <img
sourceKey={data.src}
alt={displayTitle} alt={displayTitle}
className={cn( className={cn(
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900", "h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
@ -8495,7 +8551,7 @@ export function ImageNode({
)} )}
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="eager" loading="lazy"
onClick={ onClick={
isThisNodePicking && !boxCutoutMode isThisNodePicking && !boxCutoutMode
? handlePickerClick ? handlePickerClick
@ -8504,17 +8560,12 @@ export function ImageNode({
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) { if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
e.stopPropagation(); e.stopPropagation();
setFullscreenPreviewImage({
src: e.currentTarget.currentSrc || e.currentTarget.src,
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
});
setFullscreenPreviewOpen(true); setFullscreenPreviewOpen(true);
} }
}} }}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) { if (event.currentTarget.src !== data.src) {
event.currentTarget.src = buildImageFallbackUrl(data.src); event.currentTarget.src = data.src;
} }
}} }}
onLoad={handleDisplayImageLoad} onLoad={handleDisplayImageLoad}
@ -9425,8 +9476,6 @@ export function ImageNode({
) : ( ) : (
<ImageNodeEmptyState <ImageNodeEmptyState
detailProgressLabel={detailProgressLabel} detailProgressLabel={detailProgressLabel}
failure={data.metadata?.generationFailure}
requestId={data.metadata?.generationRequestId}
fileInputRef={fileInputRef} fileInputRef={fileInputRef}
generationProgress={generationProgress} generationProgress={generationProgress}
onImageUpload={handleImageUpload} onImageUpload={handleImageUpload}
@ -9591,8 +9640,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={LayoutTemplate} fallback={LayoutTemplate}
item={imageLayoutReplaceItem} item={imageLayoutReplaceItem}
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
referenceSize={18}
/> />
<span>{imageLayoutReplaceItem.label}</span> <span>{imageLayoutReplaceItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} /> <ImageActionCreditBadge label={localEditCostShortLabel} />
@ -10145,12 +10192,8 @@ export function ImageNode({
typeof document !== "undefined" && typeof document !== "undefined" &&
createPortal( createPortal(
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<ReferenceImageToolbar <div
referencePresentation={useReferenceImageToolbar} className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62"
defaultPinnedKeys={enabledSelectedImageToolbarActions.slice(0, selectedImageInlineActionCount).map((action) => action.key)}
className={cn("fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62", useReferenceImageToolbar && "lovart-image-action-bar")}
data-selected-image-toolbar="true"
data-reference-presentation={useReferenceImageToolbar ? "true" : undefined}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
@ -10174,7 +10217,6 @@ export function ImageNode({
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200" className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
disabled={isProcessing || isAddingToChat} disabled={isProcessing || isAddingToChat}
onClick={handleAddToChat} onClick={handleAddToChat}
data-tool-key="addToChat"
title={imageAddToChatItem.label} title={imageAddToChatItem.label}
variant="ghost" variant="ghost"
> >
@ -10186,8 +10228,6 @@ export function ImageNode({
className="h-4 w-4 dark:invert-0" className="h-4 w-4 dark:invert-0"
fallback={MessageSquarePlus} fallback={MessageSquarePlus}
item={imageAddToChatItem} item={imageAddToChatItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={25}
/> />
)} )}
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span> <span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
@ -10202,7 +10242,6 @@ export function ImageNode({
{isSelectedImageActionInline("pickObject") ? ( {isSelectedImageActionInline("pickObject") ? (
<Button <Button
data-tool-key="pickObject"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isPickerLoading || isThisNodePicking} disabled={isProcessing || isPickerLoading || isThisNodePicking}
onClick={handleStartPicking} onClick={handleStartPicking}
@ -10213,8 +10252,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Crosshair} fallback={Crosshair}
item={imagePickObjectItem} item={imagePickObjectItem}
referenceName={useReferenceImageToolbar ? "image-move-object" : undefined}
referenceSize={25}
/> />
{imagePickObjectItem.label} {imagePickObjectItem.label}
</Button> </Button>
@ -10222,7 +10259,6 @@ export function ImageNode({
{isSelectedImageActionInline("enhance") ? ( {isSelectedImageActionInline("enhance") ? (
<Button <Button
data-tool-key="enhance"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("enhance")} onClick={() => handleAction("enhance")}
@ -10233,8 +10269,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Sparkles} fallback={Sparkles}
item={imageEnhanceItem} item={imageEnhanceItem}
referenceName={useReferenceImageToolbar ? "image-upscale" : undefined}
referenceSize={25}
/> />
{imageEnhanceItem.label} {imageEnhanceItem.label}
<ImageActionCreditBadge compact label={enhanceCostShortLabel} /> <ImageActionCreditBadge compact label={enhanceCostShortLabel} />
@ -10243,7 +10277,6 @@ export function ImageNode({
{isSelectedImageActionInline("removeBackground") ? ( {isSelectedImageActionInline("removeBackground") ? (
<Button <Button
data-tool-key="removeBackground"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("rmbg")} onClick={() => handleAction("rmbg")}
@ -10254,8 +10287,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Scissors} fallback={Scissors}
item={imageRemoveBackgroundItem} item={imageRemoveBackgroundItem}
referenceName={useReferenceImageToolbar ? "image-remove-background" : undefined}
referenceSize={25}
/> />
{imageRemoveBackgroundItem.label} {imageRemoveBackgroundItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10267,7 +10298,6 @@ export function ImageNode({
{isSelectedImageActionInline("aiWashQuick") ? ( {isSelectedImageActionInline("aiWashQuick") ? (
<Button <Button
data-tool-key="aiWashQuick"
className={cn( className={cn(
"group relative h-10 rounded-xl px-3 font-medium text-[13px] transition", "group relative h-10 rounded-xl px-3 font-medium text-[13px] transition",
aiWashQuickPanelOpen aiWashQuickPanelOpen
@ -10277,7 +10307,6 @@ export function ImageNode({
disabled={isProcessing || isSubmittingAiWashQuick} disabled={isProcessing || isSubmittingAiWashQuick}
onClick={() => handleAction("ai-wash")} onClick={() => handleAction("ai-wash")}
title={aiWashQuickModeLabel} title={aiWashQuickModeLabel}
data-reference-active={aiWashQuickPanelOpen ? "true" : undefined}
variant="ghost" variant="ghost"
> >
{isSubmittingAiWashQuick ? ( {isSubmittingAiWashQuick ? (
@ -10288,8 +10317,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Sparkles} fallback={Sparkles}
iconUrl={aiWashQuickConfig.modeIconUrl} iconUrl={aiWashQuickConfig.modeIconUrl}
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
referenceSize={25}
/> />
)} )}
{aiWashQuickModeLabel} {aiWashQuickModeLabel}
@ -10302,7 +10329,6 @@ export function ImageNode({
{isSelectedImageActionInline("textEdit") ? ( {isSelectedImageActionInline("textEdit") ? (
<Button <Button
data-tool-key="textEdit"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isDetectingText || isApplyingTextEdit} disabled={isProcessing || isDetectingText || isApplyingTextEdit}
onClick={() => handleAction("text-edit")} onClick={() => handleAction("text-edit")}
@ -10313,8 +10339,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Type} fallback={Type}
item={imageTextEditItem} item={imageTextEditItem}
referenceName={useReferenceImageToolbar ? "image-edit-text" : undefined}
referenceSize={25}
/> />
{imageTextEditItem.label} {imageTextEditItem.label}
</Button> </Button>
@ -10322,7 +10346,6 @@ export function ImageNode({
{isSelectedImageActionInline("boxCutout") ? ( {isSelectedImageActionInline("boxCutout") ? (
<Button <Button
data-tool-key="boxCutout"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("box-cutout")} onClick={() => handleAction("box-cutout")}
@ -10333,8 +10356,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Scan} fallback={Scan}
item={imageBoxCutoutItem} item={imageBoxCutoutItem}
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
referenceSize={25}
/> />
{imageBoxCutoutItem.label} {imageBoxCutoutItem.label}
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} /> <ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
@ -10343,7 +10364,6 @@ export function ImageNode({
{isSelectedImageActionInline("localEdit") ? ( {isSelectedImageActionInline("localEdit") ? (
<Button <Button
data-tool-key="localEdit"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isSubmittingLocalEdit} disabled={isProcessing || isSubmittingLocalEdit}
onClick={() => handleAction("local-edit")} onClick={() => handleAction("local-edit")}
@ -10354,8 +10374,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Wand2} fallback={Wand2}
item={imageLocalEditItem} item={imageLocalEditItem}
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
referenceSize={25}
/> />
{imageLocalEditItem.label} {imageLocalEditItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10367,7 +10385,6 @@ export function ImageNode({
{isSelectedImageActionInline("layoutReplace") ? ( {isSelectedImageActionInline("layoutReplace") ? (
<Button <Button
data-tool-key="layoutReplace"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("layout-replace")} onClick={() => handleAction("layout-replace")}
@ -10378,8 +10395,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={LayoutTemplate} fallback={LayoutTemplate}
item={imageLayoutReplaceItem} item={imageLayoutReplaceItem}
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
referenceSize={18}
/> />
{imageLayoutReplaceItem.label} {imageLayoutReplaceItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10391,7 +10406,6 @@ export function ImageNode({
{isSelectedImageActionInline("removeWatermark") ? ( {isSelectedImageActionInline("removeWatermark") ? (
<Button <Button
data-tool-key="removeWatermark"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("watermark")} onClick={() => handleAction("watermark")}
@ -10402,8 +10416,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Eraser} fallback={Eraser}
item={imageRemoveWatermarkItem} item={imageRemoveWatermarkItem}
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
referenceSize={25}
/> />
{imageRemoveWatermarkItem.label} {imageRemoveWatermarkItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10415,7 +10427,6 @@ export function ImageNode({
{isSelectedImageActionInline("resize") ? ( {isSelectedImageActionInline("resize") ? (
<Button <Button
data-tool-key="resize"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("resize")} onClick={() => handleAction("resize")}
@ -10433,7 +10444,6 @@ export function ImageNode({
{isSelectedImageActionInline("multiAngle") ? ( {isSelectedImageActionInline("multiAngle") ? (
<Button <Button
data-tool-key="multiAngle"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("angle")} onClick={() => handleAction("angle")}
@ -10444,8 +10454,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Box} fallback={Box}
item={imageMultiAngleItem} item={imageMultiAngleItem}
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
referenceSize={25}
/> />
{imageMultiAngleItem.label} {imageMultiAngleItem.label}
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10457,7 +10465,6 @@ export function ImageNode({
{isSelectedImageActionInline("generate3dModel") ? ( {isSelectedImageActionInline("generate3dModel") ? (
<Button <Button
data-tool-key="generate3dModel"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("generate-3d-model")} onClick={() => handleAction("generate-3d-model")}
@ -10479,7 +10486,6 @@ export function ImageNode({
{isSelectedImageActionInline("generate3dWorld") ? ( {isSelectedImageActionInline("generate3dWorld") ? (
<Button <Button
data-tool-key="generate3dWorld"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("generate-3d-world")} onClick={() => handleAction("generate-3d-world")}
@ -10501,7 +10507,6 @@ export function ImageNode({
{isSelectedImageActionInline("layerSplit") ? ( {isSelectedImageActionInline("layerSplit") ? (
<Button <Button
data-tool-key="layerSplit"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("layer-split")} onClick={() => handleAction("layer-split")}
@ -10512,8 +10517,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Layers} fallback={Layers}
item={imageLayerSplitItem} item={imageLayerSplitItem}
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
referenceSize={25}
/> />
{imageLayerSplitItem.label} {imageLayerSplitItem.label}
</Button> </Button>
@ -10521,7 +10524,6 @@ export function ImageNode({
{isSelectedImageActionInline("extend") ? ( {isSelectedImageActionInline("extend") ? (
<Button <Button
data-tool-key="extend"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("extend")} onClick={() => handleAction("extend")}
@ -10532,8 +10534,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Expand} fallback={Expand}
item={imageExtendItem} item={imageExtendItem}
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
referenceSize={25}
/> />
{imageExtendItem.label} {imageExtendItem.label}
<ImageActionCreditBadge compact label={extendCostShortLabel} /> <ImageActionCreditBadge compact label={extendCostShortLabel} />
@ -10542,7 +10542,6 @@ export function ImageNode({
{isSelectedImageActionInline("crop") ? ( {isSelectedImageActionInline("crop") ? (
<Button <Button
data-tool-key="crop"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("crop")} onClick={() => handleAction("crop")}
@ -10553,8 +10552,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={Crop} fallback={Crop}
item={imageCropItem} item={imageCropItem}
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
referenceSize={25}
/> />
{imageCropItem.label} {imageCropItem.label}
</Button> </Button>
@ -10562,7 +10559,6 @@ export function ImageNode({
{isSelectedImageActionInline("adjust") ? ( {isSelectedImageActionInline("adjust") ? (
<Button <Button
data-tool-key="adjust"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("adjust")} onClick={() => handleAction("adjust")}
@ -10573,8 +10569,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={SlidersHorizontal} fallback={SlidersHorizontal}
item={imageAdjustItem} item={imageAdjustItem}
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
referenceSize={25}
/> />
{imageAdjustItem.label} {imageAdjustItem.label}
</Button> </Button>
@ -10582,7 +10576,6 @@ export function ImageNode({
{isSelectedImageActionInline("flipRotate") ? ( {isSelectedImageActionInline("flipRotate") ? (
<Button <Button
data-tool-key="flipRotate"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("flip-rotate")} onClick={() => handleAction("flip-rotate")}
@ -10593,8 +10586,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={FlipHorizontal2} fallback={FlipHorizontal2}
item={imageFlipRotateItem} item={imageFlipRotateItem}
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
referenceSize={25}
/> />
{imageFlipRotateItem.label} {imageFlipRotateItem.label}
</Button> </Button>
@ -10602,7 +10593,6 @@ export function ImageNode({
{isSelectedImageActionInline("promptRegenerate") ? ( {isSelectedImageActionInline("promptRegenerate") ? (
<Button <Button
data-tool-key="promptRegenerate"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("prompt-regenerate")} onClick={() => handleAction("prompt-regenerate")}
@ -10624,7 +10614,6 @@ export function ImageNode({
{isSelectedImageActionInline("note") ? ( {isSelectedImageActionInline("note") ? (
<Button <Button
data-tool-key="note"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800" className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing} disabled={isProcessing}
onClick={() => handleAction("note")} onClick={() => handleAction("note")}
@ -10635,8 +10624,6 @@ export function ImageNode({
className="mr-1.5 h-4 w-4" className="mr-1.5 h-4 w-4"
fallback={StickyNote} fallback={StickyNote}
item={imageNoteItem} item={imageNoteItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={25}
/> />
{imageNoteItem.label} {imageNoteItem.label}
</Button> </Button>
@ -10661,14 +10648,12 @@ export function ImageNode({
className="h-4 w-4" className="h-4 w-4"
fallback={MoreHorizontal} fallback={MoreHorizontal}
item={imageNodeMoreItem} item={imageNodeMoreItem}
referenceName={useReferenceImageToolbar ? "image-more" : undefined}
referenceSize={25}
/> />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="center" align="center"
className={cn("bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100", useReferenceImageToolbar && "lovart-image-action-menu")} className="bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100"
side="bottom" side="bottom"
> >
{isSelectedImageActionInMore("boxCutout") ? ( {isSelectedImageActionInMore("boxCutout") ? (
@ -10677,8 +10662,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Scan} fallback={Scan}
item={imageBoxCutoutItem} item={imageBoxCutoutItem}
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
referenceSize={18}
/> />
<span>{imageBoxCutoutItem.label}</span> <span>{imageBoxCutoutItem.label}</span>
<ImageActionCreditBadge label={boxCutoutCostShortLabel} /> <ImageActionCreditBadge label={boxCutoutCostShortLabel} />
@ -10690,8 +10673,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Wand2} fallback={Wand2}
item={imageLocalEditItem} item={imageLocalEditItem}
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
referenceSize={18}
/> />
<span>{imageLocalEditItem.label}</span> <span>{imageLocalEditItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} /> <ImageActionCreditBadge label={localEditCostShortLabel} />
@ -10705,8 +10686,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={LayoutTemplate} fallback={LayoutTemplate}
item={imageLayoutReplaceItem} item={imageLayoutReplaceItem}
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
referenceSize={18}
/> />
<span>{imageLayoutReplaceItem.label}</span> <span>{imageLayoutReplaceItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} /> <ImageActionCreditBadge label={localEditCostShortLabel} />
@ -10718,8 +10697,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Eraser} fallback={Eraser}
item={imageRemoveWatermarkItem} item={imageRemoveWatermarkItem}
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
referenceSize={18}
/> />
<span>{imageRemoveWatermarkItem.label}</span> <span>{imageRemoveWatermarkItem.label}</span>
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10743,8 +10720,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Box} fallback={Box}
item={imageMultiAngleItem} item={imageMultiAngleItem}
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
referenceSize={18}
/> />
<span>{imageMultiAngleItem.label}</span> <span>{imageMultiAngleItem.label}</span>
<ImageActionCreditBadge <ImageActionCreditBadge
@ -10786,8 +10761,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Layers} fallback={Layers}
item={imageLayerSplitItem} item={imageLayerSplitItem}
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
referenceSize={18}
/> />
{imageLayerSplitItem.label} {imageLayerSplitItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10798,8 +10771,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Expand} fallback={Expand}
item={imageExtendItem} item={imageExtendItem}
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
referenceSize={18}
/> />
<span>{imageExtendItem.label}</span> <span>{imageExtendItem.label}</span>
<ImageActionCreditBadge label={extendCostShortLabel} /> <ImageActionCreditBadge label={extendCostShortLabel} />
@ -10811,8 +10782,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={Crop} fallback={Crop}
item={imageCropItem} item={imageCropItem}
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
referenceSize={18}
/> />
{imageCropItem.label} {imageCropItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10823,8 +10792,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={SlidersHorizontal} fallback={SlidersHorizontal}
item={imageAdjustItem} item={imageAdjustItem}
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
referenceSize={18}
/> />
{imageAdjustItem.label} {imageAdjustItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10835,8 +10802,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={FlipHorizontal2} fallback={FlipHorizontal2}
item={imageFlipRotateItem} item={imageFlipRotateItem}
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
referenceSize={18}
/> />
{imageFlipRotateItem.label} {imageFlipRotateItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10862,8 +10827,6 @@ export function ImageNode({
className="mr-2 h-4 w-4" className="mr-2 h-4 w-4"
fallback={StickyNote} fallback={StickyNote}
item={imageNoteItem} item={imageNoteItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={18}
/> />
{imageNoteItem.label} {imageNoteItem.label}
</DropdownMenuItem> </DropdownMenuItem>
@ -10903,7 +10866,6 @@ export function ImageNode({
void handleDownload(); void handleDownload();
}} }}
size="icon" size="icon"
data-tool-key="download"
title={imageDownloadItem.label} title={imageDownloadItem.label}
variant="ghost" variant="ghost"
> >
@ -10914,13 +10876,11 @@ export function ImageNode({
className="h-4 w-4" className="h-4 w-4"
fallback={Download} fallback={Download}
item={imageDownloadItem} item={imageDownloadItem}
referenceName={useReferenceImageToolbar ? "image-download" : undefined}
referenceSize={25}
/> />
)} )}
</Button> </Button>
) : null} ) : null}
</ReferenceImageToolbar> </div>
</TooltipProvider>, </TooltipProvider>,
document.body document.body
)} )}
@ -11916,7 +11876,6 @@ export function ImageNode({
{/* 双击放大预览弹窗 */} {/* 双击放大预览弹窗 */}
{fullscreenPreviewOpen && {fullscreenPreviewOpen &&
data.src && data.src &&
fullscreenPreviewImage &&
createPortal( createPortal(
<div <div
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90" className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
@ -11924,7 +11883,7 @@ export function ImageNode({
> >
{/* 关闭按钮 */} {/* 关闭按钮 */}
<button <button
className="absolute top-4 right-4 z-10 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black" className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
onClick={() => setFullscreenPreviewOpen(false)} onClick={() => setFullscreenPreviewOpen(false)}
> >
<svg <svg
@ -11942,7 +11901,7 @@ export function ImageNode({
</svg> </svg>
</button> </button>
<button <button
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60" className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={isDownloadingOriginalImage} disabled={isDownloadingOriginalImage}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
@ -11961,11 +11920,13 @@ export function ImageNode({
</button> </button>
{/* 图片 */} {/* 图片 */}
<ProgressiveImagePreview <img
alt={data.prompt || "图片预览"} alt={data.prompt || "图片预览"}
className="max-h-[95vh] max-w-[95vw] object-contain" className="max-h-[95vh] max-w-[95vw] object-contain"
src={resolveOriginalImageUrl(data.src) || data.src} decoding="async"
preview={fullscreenPreviewImage} draggable={false}
onClick={(e) => e.stopPropagation()}
src={fullscreenPreviewSrc}
style={activeImageAdjustmentsStyle} style={activeImageAdjustmentsStyle}
/> />

View File

@ -1,32 +0,0 @@
"use client";
import { forwardRef, type ImgHTMLAttributes, useState } from "react";
type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & { src: string; sourceKey: string };
export const CanvasPreviewImage = forwardRef<HTMLImageElement, Props>(function CanvasPreviewImage(
{ src, sourceKey, onLoad, style, ...props }, ref,
) {
const [displayed, setDisplayed] = useState({ sourceKey, src });
const visibleSrc = displayed.sourceKey === sourceKey ? displayed.src : src;
const sources = visibleSrc === src ? [src] : [visibleSrc, src];
return <>{sources.map(candidate => {
const visible = candidate === visibleSrc;
return <img
{...props}
key={candidate}
ref={visible ? ref : undefined}
src={candidate}
alt={visible ? props.alt : ""}
aria-hidden={visible ? props["aria-hidden"] : true}
style={visible ? style : { ...style, position: "absolute", inset: 0, opacity: 0, pointerEvents: "none" }}
onLoad={event => {
const image = event.currentTarget;
onLoad?.(event);
void image.decode().then(() => {
if (image.isConnected && candidate === src) setDisplayed({ sourceKey, src: candidate });
}).catch(() => undefined);
}}
/>;
})}</>;
});

View File

@ -3,14 +3,11 @@
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react"; import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
import type React from "react"; import type React from "react";
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder"; import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
import type { RequestFailureDetails } from "@/lib/request-error";
interface ImageNodeEmptyStateProps { interface ImageNodeEmptyStateProps {
detailProgressLabel?: string; detailProgressLabel?: string;
fileInputRef: React.Ref<HTMLInputElement>; fileInputRef: React.Ref<HTMLInputElement>;
generationProgress?: number | null; generationProgress?: number | null;
failure?: RequestFailureDetails;
requestId?: string;
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void; onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void; onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
status?: string; status?: string;
@ -55,8 +52,6 @@ export function ImageNodeEmptyState({
detailProgressLabel, detailProgressLabel,
fileInputRef, fileInputRef,
generationProgress, generationProgress,
failure,
requestId,
onImageUpload, onImageUpload,
onSelectFile, onSelectFile,
status, status,
@ -94,7 +89,7 @@ export function ImageNodeEmptyState({
if (status === "failed") { if (status === "failed") {
const failedTitle = detailProgressLabel || "生成失败"; const failedTitle = detailProgressLabel || "生成失败";
return ( return (
<div role="alert" className="flex h-full w-full flex-col items-center justify-center overflow-auto bg-[#fff7f8] px-6 text-center dark:bg-red-950/40"> <div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]"> <span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
<AlertCircle className="h-5 w-5" /> <AlertCircle className="h-5 w-5" />
</span> </span>
@ -102,9 +97,8 @@ export function ImageNodeEmptyState({
{failedTitle} {failedTitle}
</span> </span>
<span className="mt-1 text-xs text-[#b77480]"> <span className="mt-1 text-xs text-[#b77480]">
{failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")} {failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
</span> </span>
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
</div> </div>
); );
} }

View File

@ -89,12 +89,6 @@ function isShot(value: unknown): value is TVstarShot {
return Boolean(value && typeof value === "object" && typeof (value as TVstarShot).sceneDescription === "string"); return Boolean(value && typeof value === "object" && typeof (value as TVstarShot).sceneDescription === "string");
} }
function productionShots(value: unknown) {
const shots = Array.isArray(value) ? value.filter(isShot) : [];
if (shots.length > 12) throw Error("自动视频制作最多支持12个镜头,请拆分制作;不会截掉多余镜头");
return shots;
}
export function TVstarPanel({ export function TVstarPanel({
canvasId, canvasId,
models, models,
@ -181,7 +175,6 @@ export function TVstarPanel({
const active = sessions.find(item => item.id === activeId); const active = sessions.find(item => item.id === activeId);
const activeTasks = active?.groupId ? tasks.filter(task => task.request.workflow?.groupId === active.groupId) : []; const activeTasks = active?.groupId ? tasks.filter(task => task.request.workflow?.groupId === active.groupId) : [];
const finalTask = activeTasks.find(task => task.request.kind === "smart-edit" && task.request.params.operation === "render"); const finalTask = activeTasks.find(task => task.request.kind === "smart-edit" && task.request.params.operation === "render");
const failedTask = activeTasks.find(task => (task.status === "failed" || task.status === "interrupted") && task.error && !task.error.startsWith("上游任务失败")) || finalTask;
const finalUrl = typeof finalTask?.result?.url === "string" ? finalTask.result.url : ""; const finalUrl = typeof finalTask?.result?.url === "string" ? finalTask.result.url : "";
const productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作" const productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作"
: activeTasks.some(task => task.status === "failed" || task.status === "interrupted") : activeTasks.some(task => task.status === "failed" || task.status === "interrupted")
@ -372,7 +365,7 @@ export function TVstarPanel({
} }
setError(""); setError("");
if (active) { await reviseSession(active, input); return; } if (active) { await reviseSession(active, input); return; }
const directShots = mode === "script" || mode === "storyboard" || mode === "full"; const directShots = mode === "script" || mode === "storyboard";
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input); const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input);
setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan"); setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan");
let item: Session = { let item: Session = {
@ -403,14 +396,13 @@ export function TVstarPanel({
return; return;
} }
const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []); const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []);
const shots = directShots ? productionShots(data.analysisRows) : undefined; const shots = directShots && Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : undefined;
if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试"); if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试");
item = { item = {
...item, ...item,
plan: directShots && mode !== "full" ? undefined : { plan: directShots ? undefined : {
summary: String(data.summary || ""), summary: String(data.summary || ""),
agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String) agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String) : [],
: Array.isArray(data.recommendedNextSteps) ? data.recommendedNextSteps.map(String) : [],
deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [], deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [],
recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [], recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [],
model: typeof data.model === "string" ? data.model : undefined, model: typeof data.model === "string" ? data.model : undefined,
@ -432,9 +424,9 @@ export function TVstarPanel({
} }
if (mode === "full") { if (mode === "full") {
setBusy("shots"); setBusy("shots");
const storyboard = item.shots?.length ? { analysisRows: item.shots } : await runAnalysis("story-script", [input, item.plan?.summary, const storyboard = await runAnalysis("story-script", [input, item.plan?.summary,
item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []); item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []);
const generated = productionShots(storyboard.analysisRows); const generated = Array.isArray(storyboard.analysisRows) ? storyboard.analysisRows.filter(isShot).slice(0, 12) : [];
if (!generated.length) throw new Error("模型没有返回有效分镜,请重试"); if (!generated.length) throw new Error("模型没有返回有效分镜,请重试");
item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [ item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [
...(item.messages || []), message("assistant", allowAutoRun ...(item.messages || []), message("assistant", allowAutoRun
@ -518,7 +510,7 @@ export function TVstarPanel({
return; return;
} }
const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts); const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts);
const shots = productionShots(data.analysisRows); const shots = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : [];
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求"); if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求");
let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined, let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined,
messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] }; messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] };
@ -553,7 +545,7 @@ export function TVstarPanel({
try { try {
const data = await runAnalysis("story-script", [active.goal, active.plan?.summary, const data = await runAnalysis("story-script", [active.goal, active.plan?.summary,
active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []); active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []);
const shots = productionShots(data.analysisRows); const shots = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : [];
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试"); if (!shots.length) throw new Error("模型没有返回有效分镜,请重试");
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal; const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest); const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest);
@ -795,7 +787,7 @@ export function TVstarPanel({
].map(([label, action]) => <button className="flex h-10 w-full items-center justify-between rounded-xl px-3 text-left text-[13px] text-white/60 hover:bg-white/5 hover:text-white" key={label as string} onClick={action as () => void}><span><Sparkles className="mr-2 inline size-3.5" />{label as string}</span><span>→</span></button>)}</div> ].map(([label, action]) => <button className="flex h-10 w-full items-center justify-between rounded-xl px-3 text-left text-[13px] text-white/60 hover:bg-white/5 hover:text-white" key={label as string} onClick={action as () => void}><span><Sparkles className="mr-2 inline size-3.5" />{label as string}</span><span>→</span></button>)}</div>
</div> : <div className="space-y-4 pt-4"> </div> : <div className="space-y-4 pt-4">
{(active.messages || []).length ? <div aria-label="TVstar 对话记录" className="space-y-2">{active.messages?.map(item => <p className={`max-w-[92%] whitespace-pre-wrap rounded-xl px-3 py-2 text-xs leading-5 ${item.role === "user" ? "ml-auto bg-[#343b48] text-white" : "bg-[#292929] text-white/70"}`} key={item.id}>{item.content}</p>)}</div> : null} {(active.messages || []).length ? <div aria-label="TVstar 对话记录" className="space-y-2">{active.messages?.map(item => <p className={`max-w-[92%] whitespace-pre-wrap rounded-xl px-3 py-2 text-xs leading-5 ${item.role === "user" ? "ml-auto bg-[#343b48] text-white" : "bg-[#292929] text-white/70"}`} key={item.id}>{item.content}</p>)}</div> : null}
{active.groupId ? <div aria-label="TVstar 制作进度" className={`${panel} p-3`}><p className="text-xs text-cyan-200" role="status">{productionStatus}</p>{!active.preparedOnly ? <p className="mt-1 text-[11px] text-white/45">{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成</p> : <p className="mt-1 text-[11px] text-white/45">画布节点已保存,尚未启动模型或扣费</p>}{finalUrl ? <video className="mt-3 w-full rounded-lg bg-black" controls playsInline preload="metadata" src={finalUrl} /> : null}{failedTask?.error ? <p className="mt-2 text-xs text-red-200" role="alert">{failedTask.request.node.title}:{failedTask.error}</p> : null}</div> : null} {active.groupId ? <div aria-label="TVstar 制作进度" className={`${panel} p-3`}><p className="text-xs text-cyan-200" role="status">{productionStatus}</p>{!active.preparedOnly ? <p className="mt-1 text-[11px] text-white/45">{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成</p> : <p className="mt-1 text-[11px] text-white/45">画布节点已保存,尚未启动模型或扣费</p>}{finalUrl ? <video className="mt-3 w-full rounded-lg bg-black" controls playsInline preload="metadata" src={finalUrl} /> : null}{finalTask?.error ? <p className="mt-2 text-xs text-red-200">{finalTask.error}</p> : null}</div> : null}
<div className={`${panel} p-3`}><p className="mb-2 text-[11px] text-white/40">创作目标</p><p className="whitespace-pre-wrap text-[13px] leading-6">{active.goal}</p></div> <div className={`${panel} p-3`}><p className="mb-2 text-[11px] text-white/40">创作目标</p><p className="whitespace-pre-wrap text-[13px] leading-6">{active.goal}</p></div>
{active.plan ? <div className={`${panel} p-4`}><div className="mb-3 flex items-center gap-2 text-[13px] font-medium"><Sparkles className="size-4 text-cyan-300" />创作规划</div><p className="mb-3 text-[13px] leading-6 text-white/75">{active.plan.summary}</p><ol className="space-y-2">{active.plan.agentPlan.map((step, index) => <li className="flex gap-2 text-xs leading-5 text-white/70" key={`${index}-${step}`}><span className="text-cyan-300">{String(index + 1).padStart(2, "0")}</span><span>{step}</span></li>)}</ol>{active.plan.deliverables.length ? <p className="mt-4 border-t border-white/10 pt-3 text-[11px] text-white/45">交付物 · {active.plan.deliverables.join(" / ")}</p> : null}</div> : null} {active.plan ? <div className={`${panel} p-4`}><div className="mb-3 flex items-center gap-2 text-[13px] font-medium"><Sparkles className="size-4 text-cyan-300" />创作规划</div><p className="mb-3 text-[13px] leading-6 text-white/75">{active.plan.summary}</p><ol className="space-y-2">{active.plan.agentPlan.map((step, index) => <li className="flex gap-2 text-xs leading-5 text-white/70" key={`${index}-${step}`}><span className="text-cyan-300">{String(index + 1).padStart(2, "0")}</span><span>{step}</span></li>)}</ol>{active.plan.deliverables.length ? <p className="mt-4 border-t border-white/10 pt-3 text-[11px] text-white/45">交付物 · {active.plan.deliverables.join(" / ")}</p> : null}</div> : null}
{active.shots?.length ? <div className="space-y-2"><h3 className="text-[13px] font-medium">分镜脚本 · {active.shots.length} 镜头</h3>{active.shots.map((shot, index) => <article className={`${panel} p-3`} key={`${index}-${shot.shot}`}><div className="mb-1 flex items-center justify-between text-xs"><span className="text-white/90">镜头 {shot.shot} · {shot.scene || "场景"}</span><span className="text-white/40">{shot.duration}</span></div><p className="text-xs leading-5 text-white/60">{shot.sceneDescription}</p><p className="mt-2 text-[11px] text-cyan-200/70">{shot.shotSize} · {shot.cameraMovement}</p></article>)}</div> : null} {active.shots?.length ? <div className="space-y-2"><h3 className="text-[13px] font-medium">分镜脚本 · {active.shots.length} 镜头</h3>{active.shots.map((shot, index) => <article className={`${panel} p-3`} key={`${index}-${shot.shot}`}><div className="mb-1 flex items-center justify-between text-xs"><span className="text-white/90">镜头 {shot.shot} · {shot.scene || "场景"}</span><span className="text-white/40">{shot.duration}</span></div><p className="text-xs leading-5 text-white/60">{shot.sceneDescription}</p><p className="mt-2 text-[11px] text-cyan-200/70">{shot.shotSize} · {shot.cameraMovement}</p></article>)}</div> : null}

View File

@ -15,10 +15,7 @@ import {
splitOpenClawChartBlocks, splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card"; } from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download"; import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@ -29,7 +26,6 @@ import { cn } from "@/lib/utils";
type ResponseProps = ComponentProps<typeof Streamdown> & { type ResponseProps = ComponentProps<typeof Streamdown> & {
collapsibleImageAnalysis?: boolean; collapsibleImageAnalysis?: boolean;
isMarkdownStreaming?: boolean;
enablePdfDownload?: boolean; enablePdfDownload?: boolean;
pdfFileName?: string; pdfFileName?: string;
pdfSiteLogo?: string | null; pdfSiteLogo?: string | null;
@ -366,6 +362,43 @@ function normalizeMarkdownContent(value: string) {
.trim(); .trim();
} }
function splitMarkdownImages(content: string) {
const segments: Array<
| { type: "text"; value: string }
| { type: "image"; alt?: string; src: string }
> = [];
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = imageRegex.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({
type: "text",
value: content.slice(lastIndex, match.index),
});
}
segments.push({
type: "image",
alt: match[1],
src: match[2],
});
lastIndex = imageRegex.lastIndex;
}
if (lastIndex < content.length) {
segments.push({
type: "text",
value: content.slice(lastIndex),
});
}
return segments;
}
function isLikelyImageUrl(value?: string | null) { function isLikelyImageUrl(value?: string | null) {
return IMAGE_URL_REGEX.test(String(value || "").trim()); return IMAGE_URL_REGEX.test(String(value || "").trim());
} }
@ -442,8 +475,8 @@ function MarkdownTableImagePreview({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(src)) { if (event.currentTarget.src !== src) {
event.currentTarget.src = buildImageFallbackUrl(src); event.currentTarget.src = src;
} }
}} }}
/> />
@ -473,8 +506,8 @@ function MarkdownTableImagePreview({
decoding="async" decoding="async"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) { if (event.currentTarget.src !== originalSrc) {
event.currentTarget.src = buildImageFallbackUrl(originalSrc); event.currentTarget.src = originalSrc;
} }
}} }}
/> />
@ -764,7 +797,6 @@ function ResponseReportTableDownloadButton({
export const Response = ({ export const Response = ({
className, className,
collapsibleImageAnalysis = false, collapsibleImageAnalysis = false,
isMarkdownStreaming = false,
enablePdfDownload = false, enablePdfDownload = false,
pdfFileName, pdfFileName,
pdfSiteLogo, pdfSiteLogo,
@ -1029,10 +1061,7 @@ export const Response = ({
const rawText = const rawText =
typeof props.children === "string" typeof props.children === "string"
? normalizeMarkdownContent(stableStreamingMarkdown( ? normalizeMarkdownContent(props.children)
getGatewayErrorPageMessage(props.children) || props.children,
isMarkdownStreaming
))
: null; : null;
const shouldShowPdfDownload = const shouldShowPdfDownload =
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText)); Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
@ -1062,7 +1091,7 @@ export const Response = ({
); );
}; };
if (rawText !== null) { if (rawText) {
const analysisSection = collapsibleImageAnalysis const analysisSection = collapsibleImageAnalysis
? extractImageAnalysisSection(rawText) ? extractImageAnalysisSection(rawText)
: null; : null;

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,5 @@
"use client"; "use client";
import "@/public/vendor/drawnix.css";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@ -9,7 +7,6 @@ type FlowBoardClientProps = {
siteName: string; siteName: string;
siteLogo: string | null; siteLogo: string | null;
siteLogoDark: string | null; siteLogoDark: string | null;
previewMode?: boolean;
}; };
const DrawnixWorkspace = dynamic( const DrawnixWorkspace = dynamic(

View File

@ -1,206 +0,0 @@
import {
addSelectedElement,
BoardTransforms,
clearSelectedElement,
PlaitBoard,
type PlaitElement,
PlaitHistoryBoard,
PlaitPointerType,
type Point,
Transforms,
} from "@plait/core";
import {
ArrowLineMarkerType,
ArrowLineShape,
BasicShapes,
createArrowLineElement,
createGeometryElementWithoutText,
} from "@plait/draw";
import { getRectangleByNode, MindElement } from "@plait/mind";
import {
type AnnotatedElement,
getBoundaryPoints,
getRelationshipPoints,
isMindAnnotation,
type MindAnnotation,
type TopicRectangle,
} from "@/lib/flow-board/mind-annotations";
function getTopics(board: PlaitBoard) {
const topics = new Map<
string,
{ element: MindElement; visible: MindElement }
>();
const visit = (elements: PlaitElement[], collapsedAncestor?: MindElement) => {
for (const element of elements) {
if (!MindElement.isMindElement(board, element)) continue;
topics.set(element.id, {
element,
visible: collapsedAncestor || element,
});
visit(
element.children || [],
collapsedAncestor || (element.isCollapsed ? element : undefined)
);
}
};
visit(board.children);
return topics;
}
function rectangle(element: MindElement): TopicRectangle | null {
const node = MindElement.getNode(element);
return node ? getRectangleByNode(node) : null;
}
function annotationPoints(
board: PlaitBoard,
annotation: MindAnnotation
): Point[] | null {
const topics = getTopics(board);
if (annotation.kind === "relationship") {
const source = topics.get(annotation.sourceId);
const target = topics.get(annotation.targetId);
if (!source || !target) return null;
const a = rectangle(source.visible);
const b = rectangle(target.visible);
return a && b ? getRelationshipPoints(a, b) : null;
}
const rectangles: TopicRectangle[] = [];
const seen = new Set<string>();
const collect = (element: MindElement) => {
if (seen.has(element.id)) return;
seen.add(element.id);
const rect = rectangle(element);
if (rect) rectangles.push(rect);
if (!element.isCollapsed) element.children?.forEach(collect);
};
for (const id of annotation.topicIds) {
const topic = topics.get(id);
if (topic) collect(topic.visible);
}
return getBoundaryPoints(rectangles, annotation.padding);
}
export function insertMindAnnotation(
board: PlaitBoard,
annotation: MindAnnotation
) {
const points = annotationPoints(board, annotation);
if (!points) return null;
const endpoints: [Point, Point] = [points[0], points[points.length - 1]];
const label = { type: "paragraph", children: [{ text: "联系" }] };
const shape =
annotation.kind === "relationship"
? createArrowLineElement(
ArrowLineShape.curve,
endpoints,
{ marker: ArrowLineMarkerType.none },
{ marker: ArrowLineMarkerType.arrow },
[{ position: 0.5, text: label }],
{ strokeColor: "#64748b", strokeWidth: 2 }
)
: createGeometryElementWithoutText(
BasicShapes.roundRectangle,
endpoints,
{
fill: "transparent",
strokeColor: "#8b9bb8",
strokeWidth: 2,
}
);
const element: AnnotatedElement = {
...shape,
points,
mindAnnotation: annotation,
};
PlaitHistoryBoard.withNewBatch(board, () => {
// Boundaries sit behind topics, so clicking within them still selects topics.
Transforms.insertNode(board, element, [
annotation.kind === "boundary" ? 0 : board.children.length,
]);
});
BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
clearSelectedElement(board);
addSelectedElement(board, element);
return element;
}
const installed = new WeakSet<PlaitBoard>();
/** Keep native drawable shapes linked to topic IDs, using the existing undo stack. */
export function withMindAnnotations(board: PlaitBoard) {
if (installed.has(board)) return;
installed.add(board);
const { afterChange, undo, redo, apply } = board;
board.apply = (operation) => {
// Plait keeps a hover reference after deleting or collapsing a topic. Clear it
// while the node's DOM still exists, otherwise the next pointerLeave throws.
if (
operation.type === "remove_node" ||
(operation.type === "set_node" &&
("isCollapsed" in operation.newProperties ||
"children" in operation.newProperties))
) {
board.pointerLeave(new PointerEvent("pointerleave"));
}
apply(operation);
};
let pending = false;
let replaying = true;
const synchronize = () => {
pending = false;
const replay = replaying;
replaying = false;
const topics = getTopics(board);
const changes = board.children
.filter(isMindAnnotation)
.flatMap((element) => {
const annotation = element.mindAnnotation;
const missing =
annotation.kind === "relationship"
? !topics.has(annotation.sourceId) ||
!topics.has(annotation.targetId)
: !annotation.topicIds.some((id) => topics.has(id));
const points = missing ? null : annotationPoints(board, annotation);
if (
!missing &&
(!points || JSON.stringify(points) === JSON.stringify(element.points))
)
return [];
return [{ element, points, missing }];
});
if (!changes.length) return;
const update = () => {
for (const { element, points, missing } of changes) {
const index = board.children.findIndex(
(child) => child.id === element.id
);
if (index < 0) continue;
if (missing) Transforms.removeNode(board, [index]);
else if (points) Transforms.setNode(board, { points }, [index]);
}
};
if (replay) PlaitHistoryBoard.withoutSaving(board, update);
else PlaitHistoryBoard.withMerging(board, update);
};
board.undo = () => {
replaying = board.history.undos.length > 0;
undo();
};
board.redo = () => {
replaying = board.history.redos.length > 0;
redo();
};
board.afterChange = () => {
afterChange();
if (
!pending &&
board.operations.some((op) => op.type !== "set_selection")
) {
pending = true;
queueMicrotask(synchronize);
}
};
queueMicrotask(synchronize);
}

View File

@ -1,30 +0,0 @@
import type { PlaitBoard } from "@plait/core";
import type { withEmoji } from "@plait/mind";
import { getXmindMarker } from "@/lib/flow-board/xmind-markers";
/** Use the native marker artwork in Plait's existing emoji geometry and hit targets. */
export function withXmindMarkers(board: PlaitBoard) {
const markerBoard = board as ReturnType<typeof withEmoji>;
const renderEmoji = markerBoard.renderEmoji;
markerBoard.renderEmoji = (container, props) => {
const marker = getXmindMarker(props.emojiItem.name);
if (!marker) return renderEmoji(container, props);
const image = document.createElement("img");
image.className = "mind-node-emoji xmind-topic-marker";
image.draggable = false;
let currentProps = props;
const update = (next: Partial<typeof props>) => {
currentProps = { ...currentProps, ...next };
const current = getXmindMarker(currentProps.emojiItem.name);
image.src = current?.asset || marker.asset;
image.alt = current?.name || marker.name;
image.dataset.markerId = current?.id || marker.id;
image.style.width = `${currentProps.fontSize}px`;
image.style.height = `${currentProps.fontSize}px`;
};
update(props);
container.append(image);
return { update, destroy: () => image.remove() };
};
return board;
}

View File

@ -1,506 +0,0 @@
import type { PlaitBoard } from "@plait/core";
import {
MindElement,
MindNodeComponent,
PlaitMind,
PlaitMindComponent,
getBranchColorByMindElement,
getBranchWidthByMindElement,
type MindNode,
} from "@plait/mind";
import {
arrangeSpecialMind,
isSpecialMindLayout,
isSummaryNode,
specialNodeRect,
specialSubtreeBounds,
} from "@/lib/flow-board/mind-special-layout";
import { measureXmindTopics } from "@/lib/flow-board/xmind-typography";
const ns = "http://www.w3.org/2000/svg";
type TableCell = {
x: number;
y: number;
width: number;
height: number;
depth: number;
branch: number;
};
type TableGrid = {
kind: "spreadsheet";
x: number;
y: number;
width: number;
height: number;
columns: string[];
headerHeight: number;
columnHeaderHeight: number;
rowHeaderWidth: number;
columnWidth: number;
rows: { y: number; height: number; branch: number }[];
};
const tableLayouts = new Set(["treeTable", "treeTableSide", "spreadsheet"]);
const tableTopicState = new WeakMap<
SVGGElement,
{
foreignObject: SVGForeignObjectElement;
x: string | null;
y: string | null;
width: string | null;
height: string | null;
textStyle: string | null;
contentStyle: string | null;
shape: SVGElement | null;
shapeVisibility: string;
}
>();
function ensureTreeTableStyles() {
const id = "xmind-tree-table-topic-styles";
if (document.getElementById(id)) return;
const style = document.createElement("style");
style.id = id;
style.textContent = `
[data-special-table-topic] .plait-text-container {
align-items: center !important;
display: flex !important;
height: 100% !important;
justify-content: center !important;
overflow-wrap: normal !important;
text-align: center !important;
white-space: nowrap !important;
width: 100% !important;
}
[data-special-table-topic] .plait-text-container > [data-slate-node="element"] {
text-align: center !important;
white-space: nowrap !important;
width: 100% !important;
}
[data-special-table-topic="0"] .plait-text-container {
color: #fff !important;
}
`;
document.head.append(style);
}
function frameTreeTableTopic(
component: MindNodeComponent,
node: MindNode,
enabled: boolean
) {
const host = component.getElementG();
const saved = tableTopicState.get(host);
if (!enabled) {
delete host.dataset.specialTableTopic;
if (!saved) return;
for (const [name, value] of [
["x", saved.x],
["y", saved.y],
["width", saved.width],
["height", saved.height],
] as const) {
if (value === null) saved.foreignObject.removeAttribute(name);
else saved.foreignObject.setAttribute(name, value);
}
const text = saved.foreignObject.firstElementChild as HTMLElement | null;
if (text) {
if (saved.textStyle === null) text.removeAttribute("style");
else text.setAttribute("style", saved.textStyle);
const content = text.firstElementChild as HTMLElement | null;
if (content) {
if (saved.contentStyle === null) content.removeAttribute("style");
else content.setAttribute("style", saved.contentStyle);
}
}
if (saved.shape) saved.shape.style.visibility = saved.shapeVisibility;
tableTopicState.delete(host);
return;
}
const cell = (node as MindNode & { tableCell?: TableCell }).tableCell;
const foreignObject = host.querySelector<SVGForeignObjectElement>(
":scope > .text foreignObject"
);
if (!cell || !foreignObject) return;
ensureTreeTableStyles();
host.dataset.specialTableTopic = String(cell.depth);
const currentShape = (
component.nodeShapeGenerator as unknown as { g?: SVGGElement }
).g;
if (!saved) {
tableTopicState.set(host, {
foreignObject,
x: foreignObject.getAttribute("x"),
y: foreignObject.getAttribute("y"),
width: foreignObject.getAttribute("width"),
height: foreignObject.getAttribute("height"),
textStyle: foreignObject.firstElementChild?.getAttribute("style") ?? null,
contentStyle:
foreignObject.firstElementChild?.firstElementChild?.getAttribute(
"style"
) ?? null,
shape: currentShape || null,
shapeVisibility: currentShape?.style.visibility || "",
});
} else if (currentShape && currentShape !== saved.shape) {
saved.shape = currentShape;
saved.shapeVisibility = currentShape.style.visibility || "";
}
if (currentShape) currentShape.style.visibility = "hidden";
foreignObject.setAttribute("x", String(cell.x + 14));
foreignObject.setAttribute("y", String(cell.y + 8));
foreignObject.setAttribute("width", String(Math.max(1, cell.width - 28)));
foreignObject.setAttribute("height", String(Math.max(1, cell.height - 16)));
const text = foreignObject.firstElementChild as HTMLElement | null;
if (text) {
text.style.alignItems = "center";
text.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
text.style.display = "flex";
text.style.height = "100%";
text.style.justifyContent = "center";
text.style.overflowWrap = "normal";
text.style.textAlign = "center";
text.style.whiteSpace = "nowrap";
text.style.width = "100%";
const content = text.firstElementChild as HTMLElement | null;
if (content) {
content.style.alignItems = "center";
content.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
content.style.display = "flex";
content.style.height = "100%";
content.style.justifyContent = "center";
content.style.textAlign = "center";
content.style.whiteSpace = "nowrap";
content.style.width = "100%";
}
}
}
function specialLink(component: MindNodeComponent) {
const node = component.node;
let root: MindNode = node;
while (root.parent) root = root.parent;
const kind = root.origin.specialLayout;
const tableLayout = tableLayouts.has(String(kind));
frameTreeTableTopic(component, node, tableLayout);
if (tableLayout)
requestAnimationFrame(() => {
if (component.getElementG().isConnected)
frameTreeTableTopic(component, node, true);
});
if (!isSpecialMindLayout(kind) || isSummaryNode(node)) return false;
if (
kind !== "brace" &&
kind !== "verticalTimeline" &&
!tableLayout &&
node !== root &&
node.parent !== root
)
return false;
component.linkLineG?.remove();
const group = document.createElementNS(ns, "g");
group.dataset.specialStructure = kind;
const color =
getBranchColorByMindElement(component.board, component.element) ||
"#64748b";
const width =
getBranchWidthByMindElement(component.board, component.element) || 2;
const path = (d: string) => {
const shape = document.createElementNS(ns, "path");
shape.setAttribute("d", d);
shape.setAttribute("fill", "none");
shape.setAttribute("stroke", color);
shape.setAttribute("stroke-width", String(width));
group.append(shape);
};
const blendWithWhite = (value: string, amount: number) => {
const match = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (!match) return value;
const channels = match.slice(1).map((channel) => Number.parseInt(channel, 16));
return `#${channels
.map((channel) =>
Math.round(channel + (255 - channel) * amount)
.toString(16)
.padStart(2, "0")
)
.join("")}`;
};
const solidColor = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback;
const short = value.match(/^#([\da-f])([\da-f])([\da-f])$/i);
if (short) return `#${short.slice(1).map((channel) => channel + channel).join("")}`;
if (/^#[\da-f]{6}$/i.test(value)) return value;
const rgba = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (!rgba) return fallback;
const alpha = Number.parseInt(rgba[4], 16) / 255;
return `#${rgba
.slice(1, 4)
.map((channel) =>
Math.round(Number.parseInt(channel, 16) * alpha + 255 * (1 - alpha))
.toString(16)
.padStart(2, "0")
)
.join("")}`;
};
const rootRect = specialNodeRect(root);
const axisY = rootRect.y + rootRect.height / 2;
const branches = root.children.filter((child) => !isSummaryNode(child));
if (kind === "treeTable" || kind === "treeTableSide") {
if (node === root) {
group.setAttribute("pointer-events", "none");
const palette = ["#ffbc9f", "#d8ac8f", "#83c5be", "#f3c78e", "#8ab7d9"];
const tableNodes: MindNode[] = [];
const collect = (item: MindNode) => {
if (!isSummaryNode(item)) tableNodes.push(item);
item.children.forEach(collect);
};
collect(root);
const rootFill = solidColor(root.origin.fill, "#006d77");
tableNodes.forEach((item) => {
const cell = (item as typeof item & { tableCell?: TableCell }).tableCell;
if (!cell) return;
const rectangle = document.createElementNS(ns, "rect");
rectangle.dataset.specialTableCell = item.origin.id || "topic";
rectangle.dataset.depth = String(cell.depth);
rectangle.setAttribute("x", String(cell.x));
rectangle.setAttribute("y", String(cell.y));
rectangle.setAttribute("width", String(cell.width));
rectangle.setAttribute("height", String(cell.height));
const branchColor = palette[cell.branch % palette.length] || palette[0];
const explicit = solidColor(item.origin.fill, "");
rectangle.setAttribute(
"fill",
cell.depth === 0
? rootFill
: explicit ||
blendWithWhite(branchColor, cell.depth === 1 ? 0.08 : 0.72)
);
rectangle.setAttribute("stroke", rootFill);
rectangle.setAttribute("stroke-width", "2");
rectangle.setAttribute("vector-effect", "non-scaling-stroke");
group.append(rectangle);
});
group.dataset.specialTable = kind;
} else {
group.dataset.specialConnector = kind;
}
} else if (kind === "spreadsheet") {
if (node === root) {
group.setAttribute("pointer-events", "none");
const grid = (root as MindNode & { tableGrid?: TableGrid }).tableGrid;
if (grid) {
const palette = ["#ff9d8e", "#f6c75d", "#70c8af", "#68a7dd", "#9c8bd8"];
const rootFill = solidColor(root.origin.fill, "#1f2766");
const rectangle = (
x: number,
y: number,
width: number,
height: number,
fill: string,
dataset?: Record<string, string>
) => {
const rect = document.createElementNS(ns, "rect");
rect.setAttribute("x", String(x));
rect.setAttribute("y", String(y));
rect.setAttribute("width", String(width));
rect.setAttribute("height", String(height));
rect.setAttribute("fill", fill);
rect.setAttribute("stroke", rootFill);
rect.setAttribute("stroke-width", "2");
rect.setAttribute("vector-effect", "non-scaling-stroke");
Object.assign(rect.dataset, dataset);
group.append(rect);
};
rectangle(grid.x, grid.y, grid.width, grid.headerHeight, rootFill, {
specialTableCell: root.origin.id || "topic",
depth: "0",
});
rectangle(
grid.x,
grid.y + grid.headerHeight,
grid.rowHeaderWidth,
grid.columnHeaderHeight,
rootFill
);
grid.columns.forEach((column, index) => {
const x = grid.x + grid.rowHeaderWidth + index * grid.columnWidth;
const y = grid.y + grid.headerHeight;
rectangle(x, y, grid.columnWidth, grid.columnHeaderHeight, rootFill, {
specialMatrixColumn: column,
});
const label = document.createElementNS(ns, "text");
label.dataset.specialMatrixColumnLabel = column;
label.setAttribute("x", String(x + grid.columnWidth / 2));
label.setAttribute("y", String(y + grid.columnHeaderHeight / 2 + 5));
label.setAttribute("fill", "#ffffff");
label.setAttribute("font-size", "14");
label.setAttribute("font-weight", "600");
label.setAttribute("text-anchor", "middle");
label.textContent = column;
group.append(label);
});
grid.rows.forEach((row, rowIndex) => {
const rowNode = branches[rowIndex];
const rowColor = solidColor(
rowNode?.origin.fill,
palette[rowIndex % palette.length]
);
rectangle(grid.x, row.y, grid.rowHeaderWidth, row.height, rowColor, {
specialTableCell: rowNode?.origin.id || `row-${rowIndex}`,
depth: "1",
});
grid.columns.forEach((column, columnIndex) =>
rectangle(
grid.x + grid.rowHeaderWidth + columnIndex * grid.columnWidth,
row.y,
grid.columnWidth,
row.height,
"#ffffff",
{ specialMatrixCell: `${rowIndex}:${columnIndex}` }
)
);
rowNode?.children
.filter((child) => !isSummaryNode(child))
.forEach((entry) => {
const cell = (entry as typeof entry & { tableCell?: TableCell }).tableCell;
if (!cell) return;
rectangle(cell.x, cell.y, cell.width, cell.height, blendWithWhite(rowColor, 0.2), {
specialMatrixEntry: entry.origin.id || "topic",
});
});
});
group.dataset.specialTable = kind;
}
} else group.dataset.specialConnector = kind;
} else if (kind === "verticalTimeline") {
const axisX = rootRect.x + rootRect.width / 2;
if (node === root) {
if (branches.length) {
const end = Math.max(
...branches.map((child) => {
const rect = specialNodeRect(child);
return rect.y + rect.height / 2;
})
);
path(`M${axisX} ${rootRect.y + rootRect.height}V${end}`);
group.dataset.specialAxis = kind;
}
} else {
const rect = specialNodeRect(node);
const right = rect.x > axisX;
const endX = right ? rect.x : rect.x + rect.width;
const endY = rect.y + rect.height / 2;
if (node.parent === root) {
path(`M${axisX} ${endY}H${endX}`);
} else if (node.parent) {
const parent = specialNodeRect(node.parent);
const startX = right ? parent.x + parent.width : parent.x;
const startY = parent.y + parent.height / 2;
const midX = (startX + endX) / 2;
path(
`M${startX} ${startY}C${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`
);
}
group.dataset.specialConnector = kind;
}
} else if (kind === "brace") {
const parent = node.parent;
const siblings = parent?.children.filter((child) => !isSummaryNode(child));
if (parent && siblings?.[0] === node) {
const owner = specialNodeRect(parent);
const rects = siblings.map(specialNodeRect);
const top = Math.min(...rects.map((rect) => rect.y + rect.height / 2));
const bottom = Math.max(...rects.map((rect) => rect.y + rect.height / 2));
const middle = (top + bottom) / 2;
const right = Math.min(...rects.map((rect) => rect.x)) - 10;
const x = right - 18;
const left = Math.max(owner.x + owner.width + 6, x - 18);
const radius = Math.min(18, (bottom - top) / 4);
path(
top === bottom
? `M${left} ${top}H${right}`
: `M${right} ${top}Q${x} ${top} ${x} ${top + radius}V${middle - radius}Q${x} ${middle} ${left} ${middle}Q${x} ${middle} ${x} ${middle + radius}V${bottom - radius}Q${x} ${bottom} ${right} ${bottom}`
);
}
} else if (node === root) {
if (branches.length) {
const end =
Math.max(
...branches.map((child) => {
const rect = specialSubtreeBounds(child);
return rect.x + rect.width;
})
) + 32;
path(`M${rootRect.x + rootRect.width} ${axisY}H${end}`);
group.dataset.specialAxis = kind;
}
} else {
const rect = specialNodeRect(node);
const above = rect.y + rect.height / 2 < axisY;
const endX = rect.x + rect.width / 2;
const startX = endX - (kind === "fishbone" ? 48 : 0);
path(
`M${startX} ${axisY}L${endX} ${above ? rect.y + rect.height : rect.y}`
);
group.dataset.specialConnector = kind;
if (kind === "timeline") {
const circle = document.createElementNS(ns, "circle");
circle.setAttribute("cx", String(startX));
circle.setAttribute("cy", String(axisY));
circle.setAttribute("r", "4");
circle.setAttribute("fill", color);
group.append(circle);
}
}
component.linkLineG = group;
const host = component.getElementG();
if (tableLayout && node === root && host.parentElement)
host.parentElement.insertBefore(group, host.parentElement.firstChild);
else host.append(group);
return true;
}
class SpecialMindRoot extends PlaitMindComponent {
override updateMindLayout(element = this.element) {
measureXmindTopics(this.board, element);
super.updateMindLayout(element);
if (isSpecialMindLayout(element.specialLayout))
arrangeSpecialMind(this.root, element.specialLayout);
}
override drawLink() {
if (!specialLink(this)) {
this.linkLineG?.remove();
this.linkLineG = undefined;
super.drawLink();
}
}
}
class SpecialMindNode extends MindNodeComponent {
private lastSpecialLayout: unknown;
private rootLayout() {
let root = this.node;
while (root.parent) root = root.parent;
return root.origin.specialLayout;
}
override onContextChanged(
...args: Parameters<MindNodeComponent["onContextChanged"]>
) {
super.onContextChanged(...args);
// A structure switch can leave descendant positions and parent identity unchanged.
if (this.rootLayout() !== this.lastSpecialLayout) this.drawLink();
}
override drawLink() {
if (!specialLink(this)) super.drawLink();
this.lastSpecialLayout = this.rootLayout();
}
}
const installed = new WeakSet<PlaitBoard>();
export function withSpecialMindLayouts(board: PlaitBoard) {
if (installed.has(board)) return board;
installed.add(board);
const drawElement = board.drawElement;
board.drawElement = (context) => {
if (PlaitMind.isMind(context.element)) return SpecialMindRoot;
if (MindElement.isMindElement(board, context.element))
return SpecialMindNode;
return drawElement(context);
};
return board;
}

View File

@ -1,129 +0,0 @@
"use client";
import { Trash2, X } from "lucide-react";
import type { AnnotatedElement } from "@/lib/flow-board/mind-annotations";
export function XmindAnnotationPanel({
element,
onChange,
onDelete,
onClose,
}: {
element: AnnotatedElement;
onChange: (patch: Record<string, unknown>) => void;
onDelete: () => void;
onClose: () => void;
}) {
const relationship = element.mindAnnotation.kind === "relationship";
const label = (element.texts?.[0]?.text?.children || [])
.map((leaf: { text?: string }) => leaf.text || "")
.join("");
return (
<aside
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
data-flow-board-shell-control="true"
>
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
<h2 className="text-sm font-semibold">
{relationship ? "联系格式" : "外框格式"}
</h2>
<button aria-label="关闭格式面板" onClick={onClose} type="button">
<X size={16} />
</button>
</div>
<div className="space-y-5 p-4 text-xs">
{relationship ? (
<label className="block space-y-2">
<span>联系文字</span>
<input
className="h-9 w-full rounded border border-[#dfe2e7] px-2 text-sm"
value={label}
onChange={(event) =>
onChange({
texts: event.target.value
? [
{
position: 0.5,
text: {
type: "paragraph",
children: [{ text: event.target.value }],
},
},
]
: [],
})
}
/>
</label>
) : (
<label className="flex items-center justify-between">
<span>外框留白</span>
<select
aria-label="外框留白"
className="h-8 rounded border border-[#dfe2e7] px-2"
value={
element.mindAnnotation.kind === "boundary"
? element.mindAnnotation.padding
: 18
}
onChange={(event) =>
onChange({
mindAnnotation: {
...element.mindAnnotation,
padding: Number(event.target.value),
},
})
}
>
{[12, 18, 24, 32].map((value) => (
<option key={value} value={value}>
{value} px
</option>
))}
</select>
</label>
)}
<label className="flex items-center justify-between">
<span>线条颜色</span>
<input
aria-label="联系或外框颜色"
className="h-7 w-12 cursor-pointer"
type="color"
value={element.strokeColor || "#64748b"}
onChange={(event) => onChange({ strokeColor: event.target.value })}
/>
</label>
<label className="flex items-center justify-between">
<span>线条粗细</span>
<select
aria-label="联系或外框粗细"
className="h-8 rounded border border-[#dfe2e7] px-2"
value={element.strokeWidth || 2}
onChange={(event) =>
onChange({ strokeWidth: Number(event.target.value) })
}
>
{[1, 2, 3, 4].map((value) => (
<option key={value} value={value}>
{value} pt
</option>
))}
</select>
</label>
<p className="leading-5 text-[#8b919c]">
{relationship
? "联系线会跟随主题移动。双击线上的文字也可以编辑。"
: "外框包含所选主题及其展开的子主题,随分支布局自动调整。"}
</p>
<button
className="flex h-9 w-full items-center justify-center gap-2 rounded border border-[#e5e7eb] text-[#b44343] hover:bg-red-50"
onClick={onDelete}
type="button"
>
<Trash2 size={14} />
删除{relationship ? "联系" : "外框"}
</button>
</div>
</aside>
);
}

View File

@ -1,70 +0,0 @@
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { ChevronDown, type LucideIcon } from "lucide-react";
export type XmindCommand = {
label: string;
icon?: LucideIcon;
shortcut?: string;
disabled?: boolean;
checked?: boolean;
separator?: boolean;
run: () => void;
};
export function XmindCommandMenu({ label, children, commands, className = "", align = "left" }: {
label: string;
children: ReactNode;
commands: XmindCommand[];
className?: string;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const host = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const dismiss = (event: PointerEvent) => {
if (!host.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [open]);
return (
<div className={`xmind-menu-host ${className}`} ref={host} data-flow-board-shell-control="true">
<button type="button" ref={trigger} aria-label={label} aria-haspopup="menu" aria-expanded={open}
className="xmind-menu-trigger" onClick={() => setOpen(value => !value)}
onKeyDown={event => {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
requestAnimationFrame(() => host.current?.querySelector<HTMLButtonElement>("[role=menuitem]:not(:disabled)")?.focus());
}
}}>
{children}<ChevronDown size={12} />
</button>
{open ? <div role="menu" aria-label={label} className={`xmind-command-menu ${align === "right" ? "xmind-menu-right" : ""}`}
onKeyDown={event => {
if (event.key === "Escape") {
event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus();
}
const items = Array.from(host.current?.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)") || []);
const index = items.indexOf(document.activeElement as HTMLButtonElement);
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[next]?.focus();
}
}}>
{commands.map(({ label: title, icon: Icon, shortcut, disabled, checked, separator, run }) => <div key={title}>
{separator ? <hr className="xmind-menu-separator" /> : null}
<button type="button" role="menuitem" disabled={disabled} className="xmind-command" onClick={() => { setOpen(false); run(); }}>
{Icon ? <Icon size={16} /> : <span className="xmind-command-check">{checked ? "✓" : ""}</span>}
<span>{title}</span>{shortcut ? <kbd>{shortcut}</kbd> : null}
</button>
</div>)}
</div> : null}
</div>
);
}

View File

@ -1,470 +0,0 @@
"use client";
import {
CalendarDays,
ChartGantt,
ArrowLeft,
ArrowUpRight,
Bold,
ChevronDown,
Copy,
FileText,
FolderOpen,
GitBranchPlus,
GitMerge,
Hand,
Image as ImageIcon,
Italic,
ListPlus,
Link2,
StickyNote,
Maximize,
Minus,
MousePointer2,
Network,
PanelRight,
Pencil,
Plus,
Redo2,
Save,
Shapes,
Tag,
Trash2,
Type,
Underline,
Undo2,
Upload,
Download,
X,
type LucideIcon,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { Home, Smile, Scan, Search, MoreHorizontal, AlignLeft, AlignCenter, AlignRight, Presentation, SlidersHorizontal } from "lucide-react";
import { XmindCommandMenu } from "./xmind-command-menu";
export type FlowBoardToolId =
| "selection"
| "hand"
| "mind"
| "text"
| "arrow"
| "shape"
| "image";
export type FlowBoardSheetTab = {
id: string;
title: string;
};
export type FlowBoardLayoutOption = {
id: string;
label: string;
preview:
| "both"
| "right"
| "left"
| "down"
| "up"
| "indent"
| "fishbone"
| "timeline"
| "brace"
| "verticalTimeline"
| "treeTable"
| "treeTableSide"
| "spreadsheet";
};
export type FlowBoardThemeOption = {
id: string;
label: string;
colors: [string, string, string];
};
function LayoutPreview({
direction,
}: {
direction: FlowBoardLayoutOption["preview"];
}) {
const horizontal = direction === "right" || direction === "left";
const vertical = direction === "down" || direction === "up";
const leaf = (x: number, y: number) => (
<rect key={`${x}-${y}`} x={x - 3} y={y - 3} width="6" height="6" fill="white" />
);
const root = (x: number, y: number) => (
<rect x={x - 4.5} y={y - 4.5} width="9" height="9" fill="currentColor" stroke="none" />
);
return (
<svg aria-hidden="true" className="block h-9 w-12 shrink-0" data-layout-preview={direction}
viewBox="0 0 48 36" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
{direction === "both" ? (
<>
<path d="M19.5 18H14V8H8M14 18V28H8M28.5 18H34V8H40M34 18V28H40" />
{root(24, 18)}
{[leaf(5, 8), leaf(5, 28), leaf(43, 8), leaf(43, 28)]}
</>
) : null}
{horizontal ? (
<g transform={direction === "left" ? "translate(48 0) scale(-1 1)" : undefined}>
<path d="M13.5 18H24V8H35M24 18V28H35" />
{root(9, 18)}
{[leaf(38, 8), leaf(38, 28)]}
</g>
) : null}
{vertical ? (
<g transform={direction === "up" ? "translate(0 36) scale(1 -1)" : undefined}>
<path d="M24 10.5V19M7 25V19H41V25M24 19V25" />
{root(24, 6)}
{[leaf(7, 28), leaf(24, 28), leaf(41, 28)]}
</g>
) : null}
{direction === "indent" ? (
<>
<path d="M9 10.5V30H27M9 18H27" />
{root(9, 6)}
{[leaf(30, 18), leaf(30, 30)]}
<path d="M34 18H44M34 30H44" />
</>
) : null}
{direction === "fishbone" || direction === "timeline" ? <>
{root(6, 18)}<path d="M10.5 18H46" />
<path d={direction === "fishbone" ? "M17 18L23 7M29 18L35 29M34 18L40 7" : "M20 18V7M29 18V29M40 18V7"} />
{[leaf(direction === "fishbone" ? 23 : 20, 5), leaf(direction === "fishbone" ? 35 : 29, 31), leaf(40, 5)]}
</> : null}
{direction === "verticalTimeline" ? <>
{root(24, 5)}<path d="M24 9.5V32M24 15H35M24 23H13M24 31H35" />
{[leaf(38, 15), leaf(10, 23), leaf(38, 31)]}
</> : null}
{direction === "treeTable" ? <>
<rect x="7" y="4" width="34" height="8" fill="currentColor" stroke="none" />
<path d="M7 12H41V32H7ZM7 22H41M25 12V32" />
</> : null}
{direction === "treeTableSide" ? <>
<rect x="5" y="5" width="10" height="26" fill="currentColor" stroke="none" />
<path d="M15 5H43V31H15ZM15 14H43M15 23H43M29 5V31" />
</> : null}
{direction === "spreadsheet" ? <>
<rect x="5" y="4" width="38" height="8" fill="currentColor" stroke="none" />
<path d="M5 12H43V32H5ZM5 19H43M5 25.5H43M16 12V32M25 12V32M34 12V32" />
<rect x="6" y="20" width="9" height="5" fill="currentColor" stroke="none" opacity=".55" />
</> : null}
{direction === "brace" ? <>{root(8,18)}<path d="M30 5Q23 5 23 11V13Q23 18 17 18Q23 18 23 23V25Q23 31 30 31" />{[leaf(37,5),leaf(37,18),leaf(37,31)]}</> : null}
</svg>
);
}
export function XmindEditorTopBar({
activeTool, canEditMind, currentTitle, formatPanelOpen, markerPanelOpen, isSaving, onBack,
onImport, onExport, onExportImage, isExporting, onInsertChild, onInsertSibling,
onRelationship, onBoundary, relationshipPending, onOpenFiles, onRedo,
onSave, onSummary, onTopicDetails, onTitleChange, onTitleCommit,
onToggleFormat, onToggleMarkers, onToolChange, onUndo, siteName,
onSearch, onToggleZen, onToggleOutline, onPresentation, onTask, onGantt,
}: {
activeTool: FlowBoardToolId;
canEditMind: boolean;
currentTitle: string;
formatPanelOpen: boolean;
markerPanelOpen: boolean;
isSaving: boolean;
onBack: () => void;
onImport: () => void;
onExport: () => void;
onExportImage: () => void;
isExporting: boolean;
onInsertChild: () => void;
onInsertSibling: () => void;
onRelationship: () => void;
onBoundary: () => void;
relationshipPending: boolean;
onOpenFiles: () => void;
onRedo: () => void;
onSave: () => void;
onSummary: () => void;
onTopicDetails: (section: "notes" | "links" | "labels") => void;
onTitleChange: (value: string) => void;
onTitleCommit: () => void;
onToggleFormat: () => void;
onToggleMarkers: () => void;
onToolChange: (tool: FlowBoardToolId) => void;
onUndo: () => void;
siteLogo: string | null;
siteName: string;
onTask: () => void;
onGantt: () => void;
onSearch: () => void;
onToggleZen: () => void;
onToggleOutline: () => void;
onPresentation: () => void;
}) {
const nativeIcons: Record<string,string> = {"同级主题":"topic","子主题":"subtopic","联系":"relationship","概要":"summary","外框":"boundary","ZEN 模式":"zen","演示":"pitch","格式":"sidebar","标记":"marker","导出 XMind":"export"};
const action = (label: string, Icon: LucideIcon, run: () => void, disabled = false, active = false) => (
<button aria-label={label} title={label} type="button" disabled={disabled} aria-pressed={active}
className={`xmind-icon-button ${active ? "is-active" : ""}`} onClick={run}>{nativeIcons[label] ? <span aria-hidden="true" className="xmind-native-icon" style={{maskImage:`url(/icons/xmind/${nativeIcons[label]}.svg)`}} /> : <Icon size={20} strokeWidth={1.8} />}</button>
);
return <header className="xmind-topbar" data-flow-board-shell-control="true">
<div className="xmind-document-heading">
<button type="button" aria-label="返回首页" title={`返回 ${siteName}`} className="xmind-home-button" onClick={onBack}><Home size={21} strokeWidth={1.8} /></button>
<div className="xmind-document-name">
<input aria-label="白板标题" value={currentTitle} maxLength={256} onChange={e => onTitleChange(e.target.value)}
onBlur={onTitleCommit} onKeyDown={e => { if (e.key === "Enter") e.currentTarget.blur(); }} />
<span role="status">{isSaving ? "正在保存…" : "已编辑"}</span>
</div>
<XmindCommandMenu label="文件与编辑" className="xmind-document-menu" commands={[
{label:"保存",icon:Save,shortcut:"⌘S",run:onSave},
{label:"云文件",icon:FolderOpen,run:onOpenFiles},
{label:"导入",icon:Upload,run:onImport},
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
{label:"导出图片",icon:ImageIcon,disabled:isExporting,run:onExportImage},
{label:"撤销",icon:Undo2,shortcut:"⌘Z",separator:true,run:onUndo},
{label:"重做",icon:Redo2,shortcut:"⇧⌘Z",run:onRedo},
{label:"甘特图",icon:ChartGantt,run:onGantt},
{label:"查找与替换",icon:Search,shortcut:"⌘F",separator:true,run:onSearch},
{label:"选择",icon:MousePointer2,checked:activeTool === "selection",separator:true,run:() => onToolChange("selection")},
{label:"画布",icon:Hand,checked:activeTool === "hand",run:() => onToolChange("hand")},
]}><MoreHorizontal size={17} /></XmindCommandMenu>
</div>
<nav className="xmind-pointer-toolbar" aria-label="画布工具">
<button type="button" aria-label="选择工具" title="选择 · V(空白处拖动框选)"
aria-pressed={activeTool === "selection"} className={`xmind-icon-button ${activeTool === "selection" ? "is-active" : ""}`} onClick={() => onToolChange("selection")}><MousePointer2 size={17} strokeWidth={1.8} /></button>
<button type="button" aria-label="拖动画布" title="拖动画布 · H(也可按住空格拖动)"
aria-pressed={activeTool === "hand"} className={`xmind-icon-button ${activeTool === "hand" ? "is-active" : ""}`} onClick={() => onToolChange("hand")}><Hand size={17} strokeWidth={1.8} /></button>
</nav>
<nav className="xmind-primary-tools" aria-label="主题工具">
<div className="xmind-toolbar-pill">
{action("同级主题", ListPlus, onInsertSibling, !canEditMind)}
{action("子主题", GitBranchPlus, onInsertChild, !canEditMind)}
</div>
<div className="xmind-toolbar-pill">
{action("联系", ArrowUpRight, onRelationship, !canEditMind && !relationshipPending, relationshipPending)}
{action("概要", GitMerge, onSummary, !canEditMind)}
{action("外框", Shapes, onBoundary, !canEditMind)}
<XmindCommandMenu label="插入" commands={[
{label:"主题",icon:Network,run:() => onToolChange("mind")},
{label:"笔记",icon:StickyNote,shortcut:"⇧⌘N",disabled:!canEditMind,separator:true,run:() => onTopicDetails("notes")},
{label:"任务",icon:CalendarDays,disabled:!canEditMind,run:onTask},
{label:"链接",icon:Link2,disabled:!canEditMind,run:() => onTopicDetails("links")},
{label:"标签",icon:Tag,disabled:!canEditMind,run:() => onTopicDetails("labels")},
{label:"图片",icon:ImageIcon,separator:true,run:() => onToolChange("image")},
{label:"文本",icon:Type,run:() => onToolChange("text")},
{label:"形状",icon:Shapes,run:() => onToolChange("shape")},
{label:"箭头",icon:ArrowUpRight,run:() => onToolChange("arrow")},
]}><Plus size={23} strokeWidth={1.7} /></XmindCommandMenu>
</div>
</nav>
<div className="xmind-utility-tools">
<div className="xmind-toolbar-pill">{action("导出 XMind", Download, onExport, isExporting)}<XmindCommandMenu label="更多导出选项" align="right" commands={[
{label:"导出图片",icon:ImageIcon,disabled:isExporting,run:onExportImage},
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
]}><span className="sr-only">导出</span></XmindCommandMenu></div>
<div className="xmind-toolbar-pill">
{action("ZEN 模式", Scan, onToggleZen)}
{action("大纲视图", FileText, onToggleOutline)}
{action("演示", Presentation, onPresentation)}
</div>
<div className="xmind-toolbar-pill">
{action("标记", Smile, onToggleMarkers, false, markerPanelOpen)}
{action("格式", PanelRight, onToggleFormat, false, formatPanelOpen)}
</div>
</div>
</header>;
}
export function XmindFormatPanel({
activeLayoutId,
activeThemeId,
branchColor,
branchShape,
branchWidth,
canvasBackgroundColor,
canvasGridColor,
canvasGridEnabled,
canvasGridSize,
fontSize,
isBold,
isItalic,
isUnderlined,
layouts,
nodeFill,
nodeShape,
onBranchColorChange,
onBranchShapeChange,
onBranchWidthChange,
onCanvasBackgroundColorChange,
onCanvasGridColorChange,
onCanvasGridEnabledChange,
onCanvasGridSizeChange,
onClose,
onFontSizeChange,
onLayoutChange,
onNodeFillChange,
onNodeShapeChange,
onTextColorChange,
onThemeChange,
onToggleTextMark,
selectedCount,
textColor,
themes,
}: {
activeLayoutId: string;
activeThemeId: string;
branchColor: string;
branchShape: "bight" | "polyline";
branchWidth: number;
canvasBackgroundColor: string;
canvasGridColor: string;
canvasGridEnabled: boolean;
canvasGridSize: number;
fontSize: string;
isBold: boolean;
isItalic: boolean;
isUnderlined: boolean;
layouts: FlowBoardLayoutOption[];
nodeFill: string;
nodeShape: "round-rectangle" | "underline";
onBranchColorChange: (value: string) => void;
onBranchShapeChange: (value: "bight" | "polyline") => void;
onBranchWidthChange: (value: number) => void;
onCanvasBackgroundColorChange: (value: string) => void;
onCanvasGridColorChange: (value: string) => void;
onCanvasGridEnabledChange: (value: boolean) => void;
onCanvasGridSizeChange: (value: number) => void;
onClose: () => void;
onFontSizeChange: (value: string) => void;
onLayoutChange: (value: string) => void;
onNodeFillChange: (value: string) => void;
onNodeShapeChange: (value: "round-rectangle" | "underline") => void;
onTextColorChange: (value: string) => void;
onThemeChange: (value: string) => void;
onToggleTextMark: (mark: "bold" | "italic" | "underlined") => void;
selectedCount: number;
textColor: string;
themes: FlowBoardThemeOption[];
}) {
const [activePanel, setActivePanel] = useState<"format" | "canvas">("format");
const [structuresOpen, setStructuresOpen] = useState(false);
const activeLayout = layouts.find(layout => layout.id === activeLayoutId) || layouts[0];
const colorControl = (label: string, value: string, change: (value: string) => void, disabled = false) => (
<input type="color" aria-label={label} value={/^#[0-9a-f]{6}$/i.test(value) ? value : "#ffffff"}
disabled={disabled} onChange={e => change(e.target.value)} className="xmind-color-control" />
);
return <aside className="xmind-format-panel" aria-label="格式面板" data-flow-board-shell-control="true">
<div className="xmind-panel-heading"><span>格式</span><button type="button" aria-label="关闭格式面板" onClick={onClose}><X size={16} /></button></div>
<div className="xmind-panel-tabs" role="tablist" aria-label="格式分类">
<button type="button" role="tab" aria-selected={activePanel === "format"} onClick={() => setActivePanel("format")}>样式</button>
<button type="button" role="tab" aria-selected={activePanel === "canvas"} onClick={() => setActivePanel("canvas")}>画布</button>
</div>
<div className="xmind-panel-content">
{activePanel === "format" ? <>
<div className="xmind-topic-preview"><span style={{ backgroundColor: nodeFill, color: textColor,
fontWeight: isBold ? 700 : 500, fontStyle: isItalic ? "italic" : "normal", borderRadius: nodeShape === "underline" ? 0 : 8 }}>{selectedCount ? "分支主题" : "中心主题"}</span><ChevronDown size={14} /></div>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />形状</h3>
<div className="xmind-segmented">
<button type="button" aria-label="圆角矩形节点" aria-pressed={nodeShape === "round-rectangle"} disabled={!selectedCount} onClick={() => onNodeShapeChange("round-rectangle")}><span className="xmind-shape-rectangle" /></button>
<button type="button" aria-label="下划线节点" aria-pressed={nodeShape === "underline"} disabled={!selectedCount} onClick={() => onNodeShapeChange("underline")}><span className="xmind-shape-line" /></button>
</div>
<label className="xmind-setting-row"><span>填充</span>{colorControl("节点填充颜色", nodeFill, onNodeFillChange, !selectedCount)}</label>
</section>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />文本</h3>
<label className="xmind-setting-row"><span>字号</span><select aria-label="节点字号" value={fontSize} disabled={!selectedCount} onChange={e => onFontSizeChange(e.target.value)}>
{Array.from(new Set([12,13,14,15,16,18,20,24,28,30,32,40,48,Number(fontSize)].filter(size => size > 0))).sort((a,b) => a-b).map(size => <option value={size} key={size}>{size}</option>)}
</select></label>
<label className="xmind-setting-row"><span>颜色</span>{colorControl("文字颜色", textColor, onTextColorChange, !selectedCount)}</label>
<div className="xmind-segmented xmind-text-marks">
{[{icon:Bold,id:"bold" as const,label:"粗体",active:isBold},{icon:Italic,id:"italic" as const,label:"斜体",active:isItalic},{icon:Underline,id:"underlined" as const,label:"下划线",active:isUnderlined}].map(({icon:Icon,id,label,active}) =>
<button type="button" key={id} aria-label={label} aria-pressed={active} disabled={!selectedCount} onClick={() => onToggleTextMark(id)}><Icon size={18} /></button>)}
</div>
</section>
<section className="xmind-format-section">
<div className="xmind-setting-row"><h3><ChevronDown size={13} />结构</h3><button type="button" aria-label="选择结构" aria-expanded={structuresOpen} className="xmind-structure-trigger" onClick={() => setStructuresOpen(value => !value)}><LayoutPreview direction={activeLayout.preview} /><ChevronDown size={12} /></button></div>
<div className="xmind-current-structure">{activeLayout.label}</div>
{structuresOpen ? <div className="xmind-structure-grid">
{layouts.map(layout => <button type="button" key={layout.id} aria-label={layout.label} aria-pressed={activeLayoutId === layout.id}
onClick={() => { onLayoutChange(layout.id); setStructuresOpen(false); }}><LayoutPreview direction={layout.preview} /><span>{layout.label}</span></button>)}
</div> : null}
</section>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />分支</h3>
<label className="xmind-setting-row"><span>线条</span><select aria-label="分支形状" value={branchShape} disabled={!selectedCount} onChange={e => onBranchShapeChange(e.target.value as "bight" | "polyline")}><option value="bight">曲线</option><option value="polyline">折线</option></select></label>
<label className="xmind-setting-row"><span>粗细</span><select aria-label="分支粗细" value={branchWidth} disabled={!selectedCount} onChange={e => onBranchWidthChange(Number(e.target.value))}>{[1,2,3,4,5,6,8].map(width => <option key={width} value={width}>{width} px</option>)}</select></label>
<label className="xmind-setting-row"><span>颜色</span>{colorControl("分支颜色", branchColor, onBranchColorChange, !selectedCount)}</label>
</section>
</> : <>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />背景</h3>
<div className="xmind-background-presets">{["#ffffff","#f7f8fb","#f1f5f9","#fff8e7","#eef8f1","#f5f0ff"].map(color => <button type="button" key={color} aria-label={`画布背景 ${color}`} aria-pressed={color === canvasBackgroundColor} style={{backgroundColor:color}} onClick={() => onCanvasBackgroundColorChange(color)} />)}</div>
<label className="xmind-setting-row"><span>自定义颜色</span>{colorControl("画布背景颜色",canvasBackgroundColor,onCanvasBackgroundColorChange)}</label>
</section>
<section className="xmind-format-section"><h3><ChevronDown size={13} />彩虹色</h3><div className="xmind-theme-grid">{themes.map(theme => <button type="button" key={theme.id} aria-label={theme.label} aria-pressed={theme.id === activeThemeId} onClick={() => onThemeChange(theme.id)}><span>{theme.colors.map(color => <i key={color} style={{backgroundColor:color}} />)}</span>{theme.label}</button>)}</div></section>
<section className="xmind-format-section">
<div className="xmind-setting-row"><h3><ChevronDown size={13} />网格</h3><button type="button" role="switch" aria-label="显示画布网格" aria-checked={canvasGridEnabled} className="xmind-switch" onClick={() => onCanvasGridEnabledChange(!canvasGridEnabled)}><i /></button></div>
<label className="xmind-setting-row"><span>网格颜色</span>{colorControl("网格颜色",canvasGridColor,onCanvasGridColorChange,!canvasGridEnabled)}</label>
<label className="xmind-setting-row"><span>网格间距</span><select aria-label="网格间距" value={canvasGridSize} disabled={!canvasGridEnabled} onChange={e => onCanvasGridSizeChange(Number(e.target.value))}>{[8,16,24,32,40,48,64].map(size => <option key={size} value={size}>{size} px</option>)}</select></label>
<div aria-label="画布样式预览" className="xmind-canvas-preview" style={{backgroundColor:canvasBackgroundColor,backgroundImage:canvasGridEnabled ? `radial-gradient(${canvasGridColor} 1px, transparent 1px)` : "none",backgroundSize:`${canvasGridSize}px ${canvasGridSize}px`}} />
</section>
</>}
</div>
</aside>;
}
export function XmindSheetBar({
outlineOpen, onToggleOutline, activeSheetId, formatPanelOpen, onAddSheet,
onDeleteSheet, onDuplicateSheet, onFit, onRenameSheet, onSwitchSheet,
onZoomIn, onZoomOut, onZoomTo, sheets, zoomPercent, topicCount, selectedCount,
}: {
outlineOpen: boolean;
onToggleOutline: () => void;
activeSheetId: string;
formatPanelOpen: boolean;
onAddSheet: () => void;
onDeleteSheet: (id: string) => void;
onDuplicateSheet: (id: string) => void;
onFit: () => void;
onRenameSheet: (id: string, title: string) => void;
onSwitchSheet: (id: string) => void;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomTo: (value: number) => void;
sheets: FlowBoardSheetTab[];
zoomPercent: number;
topicCount: number;
selectedCount: number;
}) {
const [renamingSheetId, setRenamingSheetId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [contextId, setContextId] = useState<string | null>(null);
const activeSheet = sheets.find(sheet => sheet.id === activeSheetId);
const rename = (id: string) => { const sheet = sheets.find(item => item.id === id); if (sheet) { setRenamingSheetId(id); setRenameDraft(sheet.title); } };
const commitRename = () => { if (renamingSheetId && renameDraft.trim()) onRenameSheet(renamingSheetId,renameDraft.trim()); setRenamingSheetId(null); };
return <footer className="xmind-sheetbar" data-flow-board-shell-control="true">
<div className="xmind-sheet-tabs" role="tablist" aria-label="工作表">
{sheets.map(sheet => <div className="xmind-sheet-tab-host" key={sheet.id}>
{renamingSheetId === sheet.id ? <input aria-label="重命名画布" autoFocus value={renameDraft} maxLength={80} onChange={e => setRenameDraft(e.target.value)} onBlur={commitRename} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); commitRename(); } if (e.key === "Escape") { e.preventDefault(); setRenamingSheetId(null); } }} /> :
<button type="button" aria-label={sheet.title} aria-pressed={sheet.id === activeSheetId} onClick={() => onSwitchSheet(sheet.id)} onDoubleClick={() => rename(sheet.id)} onContextMenu={e => { e.preventDefault(); setContextId(contextId === sheet.id ? null : sheet.id); }}>{sheet.title}</button>}
{contextId === sheet.id ? <div className="xmind-sheet-context" data-flow-board-shell-control="true">
<button type="button" onClick={() => { rename(sheet.id); setContextId(null); }}>重命名</button>
<button type="button" onClick={() => { onDuplicateSheet(sheet.id); setContextId(null); }}>复制</button>
<button type="button" disabled={sheets.length <= 1} onClick={() => { onDeleteSheet(sheet.id); setContextId(null); }}>删除</button>
<button type="button" onClick={() => setContextId(null)}>关闭菜单</button>
</div> : null}
</div>)}
</div>
<span className="xmind-footer-divider" />
<button type="button" aria-label="新增画布" title="新增画布" className="xmind-footer-icon" onClick={onAddSheet}><Plus size={21} strokeWidth={1.5} /></button>
<XmindCommandMenu label="画布操作" className="xmind-sheet-actions" commands={[
{label:"重命名当前画布",icon:Pencil,disabled:!activeSheet,run:() => activeSheet && rename(activeSheet.id)},
{label:"复制当前画布",icon:Copy,disabled:!activeSheet,run:() => activeSheet && onDuplicateSheet(activeSheet.id)},
{label:"删除当前画布",icon:Trash2,disabled:sheets.length <= 1,run:() => activeSheet && onDeleteSheet(activeSheet.id)},
]}><MoreHorizontal size={17} /></XmindCommandMenu>
<div className="xmind-sheet-status"><span className="xmind-topic-count">主题: {selectedCount} / {topicCount}</span><span className="xmind-footer-divider" />
<XmindCommandMenu label="缩放" align="right" className="xmind-zoom-menu" commands={[
{label:"放大",icon:Plus,run:onZoomIn},{label:"缩小",icon:Minus,run:onZoomOut},
...[50,75,100,125,150,200].map(value => ({label:`${value}%`,checked:value === zoomPercent,separator:value === 50,run:() => onZoomTo(value)})),
{label:"适应画布",icon:Maximize,separator:true,run:onFit},
]}><span>{zoomPercent}%</span></XmindCommandMenu>
<span className="xmind-footer-divider" /><button type="button" aria-label={outlineOpen ? "切换到思维导图" : "切换到大纲"} aria-pressed={outlineOpen} className="xmind-outline-toggle" onClick={onToggleOutline}>{outlineOpen ? "思维导图" : "大纲"}</button>
</div>
</footer>;
}

View File

@ -1,778 +0,0 @@
"use client";
import {
CalendarDays,
ChevronDown,
ChevronRight,
Download,
Link2,
Minus,
Plus,
Printer,
Search,
UserRound,
X,
} from "lucide-react";
import type { PlaitElement } from "@plait/core";
import { useEffect, useMemo, useRef, useState } from "react";
import {
addDays,
dateIndex,
getTaskRows,
indexDate,
todayDate,
type TopicTask,
} from "@/lib/flow-board/topic-tasks";
import { getTopicEntries } from "@/lib/flow-board/topic-details";
import {
activeWorkingCalendar,
getProjectCalendar,
isWorkingDate,
preserveNonWorkingDue,
taskDueDate,
workingDaysBetween,
} from "@/lib/flow-board/project-calendar";
const ROW = 38,
HEADER = 64;
export function XmindGantt({
elements,
title,
onClose,
onEdit,
onCalendar,
onChange,
onUndo,
onRedo,
}: {
elements: PlaitElement[];
title: string;
onClose: () => void;
onEdit: (id: string) => void;
onCalendar: () => void;
onChange: (id: string, task: TopicTask) => void;
onUndo: () => void;
onRedo: () => void;
}) {
const panelRef = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
panelRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, []);
const [scale, setScale] = useState(34),
[query, setQuery] = useState(""),
[assignee, setAssignee] = useState(""),
[status, setStatus] = useState("all");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<string[]>([]);
const [drag, setDrag] = useState<{
id: string;
x: number;
kind: "move" | "start" | "due";
task: TopicTask;
days: number;
} | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const rowScrollRef = useRef<HTMLDivElement>(null);
const projectCalendar = getProjectCalendar(elements);
const calendar = activeWorkingCalendar(projectCalendar);
const rows = getTaskRows(elements);
const visible = rows.filter(
(row) =>
row.title.toLocaleLowerCase().includes(query.toLocaleLowerCase()) &&
(!assignee || row.task.assignees.includes(assignee)) &&
(status === "all" ||
(status === "done" && row.task.progress === 100) ||
(status === "open" && row.task.progress < 100)) &&
!rows.some(
(parent) =>
collapsed.has(parent.element.id) &&
parent.path.length < row.path.length &&
parent.path.every((n, i) => row.path[i] === n)
)
);
const candidates = getTopicEntries(elements).filter(
(e) => !rows.some((r) => r.element.id === e.element.id)
);
const [candidate, setCandidate] = useState("");
const [notice, setNotice] = useState("");
const today = dateIndex(todayDate());
const dated = rows.filter((r) => r.task.start && r.task.due);
const from =
Math.min(today, ...dated.map((r) => dateIndex(r.task.start))) - 3;
const last =
Math.max(today + 20, ...dated.map((r) => dateIndex(r.task.due))) + 4;
// Very long plans use a virtual day grid; no thousands of day DOM nodes.
const width = Math.max(800, (last - from + 1) * scale);
const [scrollX, setScrollX] = useState(0),
[viewportWidth, setViewportWidth] = useState(1000);
const tickStep = scale >= 24 ? 1 : scale >= 10 ? 7 : 30;
const monthBands = useMemo(() => {
const start = from + Math.max(0, Math.floor(scrollX / scale) - 31),
end = Math.min(
last,
from + Math.ceil((scrollX + viewportWidth) / scale) + 31
);
const bands: { date: string; offset: number; days: number }[] = [];
for (let day = start; day <= end; ) {
const date = indexDate(day),
next = dateIndex(`${date.slice(0, 7)}-01`);
const d = new Date(next * 86_400_000);
d.setUTCMonth(d.getUTCMonth() + 1);
const monthEnd = d.getTime() / 86_400_000;
const bandStart = Math.max(from, next),
bandEnd = Math.min(last + 1, monthEnd);
bands.push({
date: date.slice(0, 7),
offset: bandStart - from,
days: bandEnd - bandStart,
});
day = monthEnd;
}
return bands;
}, [from, last, scrollX, scale, viewportWidth]);
const ticks = useMemo(() => {
const result: number[] = [];
const begin = Math.max(
0,
Math.floor(scrollX / scale / tickStep) * tickStep - tickStep
);
const end = Math.min(
last - from,
Math.ceil((scrollX + viewportWidth) / scale) + tickStep
);
for (let i = begin; i <= end; i += tickStep) result.push(i);
return result;
}, [scrollX, scale, tickStep, viewportWidth, last, from]);
const moveTask = (task: TopicTask, days: number) => {
const start = addDays(task.start, days);
return {
...task,
start,
due: preserveNonWorkingDue(
addDays(task.due, days),
taskDueDate(start, task.duration, calendar),
calendar
),
};
};
const effective = (row: (typeof rows)[number]) => {
if (drag?.id !== row.element.id || !drag.days) return row.task;
const task = row.task;
if (drag.kind === "move") return moveTask(task, drag.days);
const start =
drag.kind === "start" ? addDays(task.start, drag.days) : task.start;
const due = drag.kind === "due" ? addDays(task.due, drag.days) : task.due;
return {
...task,
start,
due,
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
};
};
const goToday = () =>
scrollRef.current?.scrollTo({
left: Math.max(0, (today - from) * scale - 200),
behavior: "smooth",
});
const downloadCsv = () => {
const quote = (v: unknown) => `"${String(v ?? "").replaceAll('"', '""')}"`;
const content =
"\ufeff" +
[
[
"任务名称",
"开始日期",
"截止日期",
"时长(天)",
"进度(%)",
"优先级",
"负责人",
"前置任务",
],
...visible.map((r) => [
r.title,
r.task.start,
r.task.due,
r.task.duration,
r.task.progress,
r.task.priority == null ? "" : `P${r.task.priority}`,
r.task.assignees.join(", "),
r.task.dependencies
.map(
(d) =>
`${rows.find((t) => t.element.id === d.id)?.title || d.id} ${d.type}`
)
.join(", "),
]),
]
.map((row) => row.map(quote).join(","))
.join("\r\n");
const url = URL.createObjectURL(
new Blob([content], { type: "text/csv;charset=utf-8" })
);
const a = document.createElement("a");
a.href = url;
a.download = `${title}-甘特图.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const linkTasks = () => {
if (selected.length !== 2) return;
const target = rows.find((r) => r.element.id === selected[1]);
if (!target) return;
onChange(target.element.id, {
...target.task,
dependencies: [
...target.task.dependencies.filter((d) => d.id !== selected[0]),
{ id: selected[0], type: "FS", lag: 0 },
],
});
};
return (
<section
role="dialog"
aria-modal="true"
aria-label="甘特图"
className="xmind-gantt"
ref={panelRef}
data-flow-board-shell-control="true"
onKeyUp={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
if (e.key === "Tab") {
const controls = Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled),input,select,[tabindex="0"]'
) || []
);
const first = controls[0],
last = controls.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
}
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
if (
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === "z" &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLSelectElement)
) {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) onRedo();
else onUndo();
}
}}
>
<header className="xmind-gantt-toolbar">
<div className="xmind-gantt-title">
<button type="button" aria-label="关闭甘特图" onClick={onClose}>
<X size={18} />
</button>
<h2>甘特图</h2>
<span>{title}</span>
</div>
<div className="xmind-gantt-actions">
<button
type="button"
aria-label="连接选中任务"
title="按选择顺序建立完成 → 开始依赖"
disabled={selected.length !== 2}
onClick={linkTasks}
>
<Link2 size={18} />
</button>
<button
type="button"
aria-label="项目工作日"
title="项目工作日"
onClick={onCalendar}
>
<CalendarDays size={18} />
</button>
<button
type="button"
aria-label="导出甘特图 CSV"
onClick={downloadCsv}
>
<Download size={18} />
</button>
<button
type="button"
aria-label="打印甘特图"
onClick={() => window.print()}
>
<Printer size={18} />
</button>
<button type="button" aria-label="定位今天" onClick={goToday}>
<CalendarDays size={18} />
</button>
<div className="xmind-gantt-zoom">
<button
type="button"
aria-label="缩小甘特图"
onClick={() => setScale((s) => Math.max(2, s - 4))}
>
<Minus size={16} />
</button>
<input
type="range"
aria-label="甘特图缩放"
min={2}
max={64}
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
/>
<button
type="button"
aria-label="放大甘特图"
onClick={() => setScale((s) => Math.min(64, s + 4))}
>
<Plus size={16} />
</button>
</div>
</div>
</header>
<div className="xmind-gantt-filters">
<label>
<Search size={14} />
<input
aria-label="搜索甘特图任务"
placeholder="搜索任务"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<select
aria-label="按负责人筛选"
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
>
<option value="">全部负责人</option>
{[...new Set(rows.flatMap((r) => r.task.assignees))].map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<select
aria-label="按任务进度筛选"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="all">全部任务</option>
<option value="open">未完成</option>
<option value="done">已完成</option>
</select>
<select
aria-label="选择新增任务主题"
value={candidate}
onChange={(e) => setCandidate(e.target.value)}
>
<option value="">选择主题添加任务</option>
{candidates.map((e) => (
<option key={e.element.id} value={e.element.id}>
{e.title}
</option>
))}
</select>
<button
type="button"
disabled={!candidate}
onClick={() => {
onEdit(candidate);
setCandidate("");
}}
>
添加任务
</button>
<span>
{calendar ? `${calendar.name} · 跳过非工作日 · ` : ""}
{visible.length} 个任务
</span>
</div>
<div className="xmind-gantt-body">
<div className="xmind-gantt-table">
<div className="xmind-gantt-table-head">
<span>任务名称</span>
<span>开始日期</span>
<span>时长</span>
</div>
<div
className="xmind-gantt-row-scroll"
ref={rowScrollRef}
onScroll={(e) => {
const node = scrollRef.current;
if (node && node.scrollTop !== e.currentTarget.scrollTop)
node.scrollTop = e.currentTarget.scrollTop;
}}
data-gantt-row-scroll="true"
>
{visible.map((row) => (
<div
className={`xmind-gantt-row ${selected.includes(row.element.id) ? "is-selected" : ""}`}
key={row.element.id}
style={{ height: ROW }}
>
<div
className="xmind-gantt-row-name"
style={{ paddingLeft: 8 + Math.min(row.depth, 5) * 12 }}
>
<input
type="checkbox"
aria-label={`选择任务 ${row.title}`}
checked={selected.includes(row.element.id)}
onChange={(e) =>
setSelected((s) =>
e.target.checked
? [...s, row.element.id]
: s.filter((id) => id !== row.element.id)
)
}
/>
{row.summary ? (
<button
type="button"
aria-label={`折叠任务 ${row.title}`}
aria-expanded={!collapsed.has(row.element.id)}
onClick={() =>
setCollapsed((old) => {
const next = new Set(old);
if (next.has(row.element.id))
next.delete(row.element.id);
else next.add(row.element.id);
return next;
})
}
>
{collapsed.has(row.element.id) ? (
<ChevronRight size={12} />
) : (
<ChevronDown size={12} />
)}
</button>
) : null}
<button
type="button"
className="xmind-gantt-task-title"
title={row.title}
onClick={() => onEdit(row.element.id)}
>
{row.title}
</button>
</div>
<button
type="button"
className="xmind-gantt-date"
onClick={() => onEdit(row.element.id)}
>
{effective(row).start || "未排期"}
</button>
<span>{effective(row).duration} 天</span>
</div>
))}
{!visible.length ? (
<div className="xmind-gantt-empty">
<CalendarDays size={52} />
<strong>{rows.length ? "没有匹配的任务" : "没有任务"}</strong>
<p>
{rows.length
? "调整搜索或筛选条件。"
: "请选择一个主题以添加任务。"}
</p>
</div>
) : null}
</div>
</div>
<div
className="xmind-gantt-timeline"
ref={scrollRef}
onScroll={(e) => {
setScrollX(e.currentTarget.scrollLeft);
setViewportWidth(e.currentTarget.clientWidth);
const rowScroll = rowScrollRef.current;
if (rowScroll && rowScroll.scrollTop !== e.currentTarget.scrollTop)
rowScroll.scrollTop = e.currentTarget.scrollTop;
}}
>
<div
className="xmind-gantt-grid"
style={{
width,
height: Math.max(600, HEADER + visible.length * ROW),
backgroundSize: `${scale}px ${ROW}px`,
}}
>
{calendar
? Array.from(
{ length: Math.ceil(viewportWidth / scale) + 3 },
(_, index) =>
Math.max(0, Math.floor(scrollX / scale) - 1) + index
)
.filter(
(offset) =>
offset <= last - from &&
!isWorkingDate(indexDate(from + offset), calendar)
)
.map((offset) => (
<div
key={`off-${offset}`}
className="xmind-gantt-day-off"
data-gantt-day-off={indexDate(from + offset)}
style={{
left: offset * scale,
width: scale,
top: HEADER,
bottom: 0,
}}
/>
))
: null}
<div
className="xmind-gantt-calendar"
style={{ width, height: HEADER }}
>
{ticks.map((offset) => {
const date = indexDate(from + offset);
return (
<div
className={`xmind-gantt-day ${calendar && !isWorkingDate(date, calendar) ? "is-day-off" : ""}`}
key={offset}
style={{ left: offset * scale, width: tickStep * scale }}
>
<b>
{tickStep >= 30
? date.slice(5, 7) + "月"
: tickStep >= 7
? date.slice(5)
: String(Number(date.slice(8)))}
</b>
</div>
);
})}
{monthBands.map((band) => (
<div
key={band.date}
className="xmind-gantt-month"
style={{
left: band.offset * scale,
width: band.days * scale,
}}
>
{band.date.replace("-", "年")}月
</div>
))}
</div>
<div
className="xmind-gantt-today"
aria-label="今天"
style={{ left: (today - from) * scale, top: HEADER }}
/>
<svg
className="xmind-gantt-connections"
aria-label="任务依赖关系"
role="img"
width={width}
height={Math.max(600, HEADER + visible.length * ROW)}
>
<defs>
<marker
id="gantt-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8d94a1" />
</marker>
</defs>
{visible.flatMap((row, i) =>
row.task.dependencies.map((d) => {
const fromIndex = visible.findIndex(
(r) => r.element.id === d.id
);
if (fromIndex < 0) return null;
const before = effective(visible[fromIndex]),
after = effective(row);
if (!before.start || !after.start) return null;
const x1 =
(dateIndex(
d.type[0] === "F" ? before.due : before.start
) -
from +
(d.type[0] === "F" ? 1 : 0)) *
scale,
x2 =
(dateIndex(d.type[1] === "F" ? after.due : after.start) -
from +
(d.type[1] === "F" ? 1 : 0)) *
scale,
y1 = HEADER + fromIndex * ROW + ROW / 2,
y2 = HEADER + i * ROW + ROW / 2;
return (
<path
key={`${row.element.id}-${d.id}`}
data-gantt-dependency={`${d.id}:${row.element.id}`}
d={`M${x1},${y1} H${x1 + 9} V${y2} H${x2}`}
fill="none"
stroke="#8d94a1"
strokeWidth={1.3}
markerEnd="url(#gantt-arrow)"
/>
);
})
)}
</svg>
{visible.map((row, i) =>
selected.includes(row.element.id) ? (
<div
key={`selection-${row.element.id}`}
className="xmind-gantt-selection"
style={{ top: HEADER + i * ROW, height: ROW, width }}
/>
) : null
)}
{visible.map((row, i) => {
const t = effective(row);
if (!t.start || !t.due) return null;
const left = (dateIndex(t.start) - from) * scale,
barWidth = Math.max(
6,
(dateIndex(t.due) - dateIndex(t.start) + 1) * scale
);
return (
<div
key={row.element.id}
className={`xmind-gantt-bar ${row.summary ? "is-summary" : ""} ${t.progress === 100 ? "is-complete" : ""}`}
data-gantt-task-id={row.element.id}
style={{ left, top: HEADER + i * ROW + 6, width: barWidth }}
role="button"
tabIndex={0}
aria-label={`任务时间条 ${row.title}`}
aria-pressed={selected.includes(row.element.id)}
title={`${row.title}\n${t.start} — ${t.due}\n进度 ${t.progress}%`}
onDoubleClick={() => onEdit(row.element.id)}
onKeyDown={(e) => {
if (e.key === "Enter") onEdit(row.element.id);
if (
!row.summary &&
["ArrowLeft", "ArrowRight"].includes(e.key)
) {
e.preventDefault();
const days = e.key === "ArrowLeft" ? -1 : 1;
onChange(row.element.id, moveTask(t, days));
}
}}
onPointerDown={(e) => {
if (row.summary || e.button !== 0) return;
e.preventDefault();
setSelected((ids) =>
e.metaKey || e.ctrlKey
? ids.includes(row.element.id)
? ids.filter((id) => id !== row.element.id)
: [...ids, row.element.id]
: [row.element.id]
);
e.currentTarget.setPointerCapture(e.pointerId);
const kind =
(e.target as HTMLElement).dataset.resize === "start"
? "start"
: (e.target as HTMLElement).dataset.resize === "due"
? "due"
: "move";
setDrag({
id: row.element.id,
x: e.clientX,
kind,
task: row.task,
days: 0,
});
}}
onPointerMove={(e) => {
if (drag?.id !== row.element.id) return;
let days = Math.round((e.clientX - drag.x) / scale);
if (drag.kind === "start")
days = Math.min(
dateIndex(drag.task.due) - dateIndex(drag.task.start),
days
);
if (drag.kind === "due")
days = Math.max(
dateIndex(drag.task.start) - dateIndex(drag.task.due),
days
);
setDrag({ ...drag, days });
}}
onPointerUp={() => {
if (drag?.id !== row.element.id) return;
if (drag.days) onChange(row.element.id, t);
setDrag(null);
}}
onPointerCancel={() => setDrag(null)}
>
<div
className="xmind-gantt-progress"
style={{ width: `${t.progress}%` }}
/>
{!row.summary ? (
<>
<span
data-resize="start"
className="xmind-gantt-resize is-start"
/>
<span
data-resize="due"
className="xmind-gantt-resize is-due"
/>
</>
) : null}
<span className="xmind-gantt-bar-label">
<span
className="xmind-gantt-avatar"
title={t.assignees.join("、") || "未分配负责人"}
>
<UserRound size={12} />
</span>
<span>{row.title}</span>
</span>
</div>
);
})}
</div>
</div>
</div>
<footer className="xmind-gantt-footer">
<span>
{drag
? `调整 ${drag.days > 0 ? "+" : ""}${drag.days} 天`
: "拖动调整日期 · 拉伸修改工期 · 双击编辑任务"}
</span>
<span>{notice}</span>
<button
type="button"
onClick={() => {
setQuery("");
setAssignee("");
setStatus("all");
setNotice("");
}}
>
清除筛选
</button>
</footer>
</section>
);
}

View File

@ -1,94 +0,0 @@
"use client";
import { Download, Loader2, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ImageExportOptions } from "@/lib/flow-board/image-export";
export function XmindImageExport({
busy,
onExport,
onClose,
}: {
busy: boolean;
onExport: (
options: Pick<ImageExportOptions, "scale" | "transparent">
) => void;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [scale, setScale] = useState<1 | 2 | 3>(2);
const [transparent, setTransparent] = useState(false);
useEffect(() => {
const node = dialog.current;
node?.showModal();
return () => node?.close();
}, []);
return (
<dialog
ref={dialog}
className="xmind-image-export"
aria-labelledby="image-export-title"
data-flow-board-shell-control="true"
onKeyDown={(event) => event.stopPropagation()}
onCancel={(event) => {
event.preventDefault();
if (!busy) onClose();
}}
>
<header>
<h2 id="image-export-title">导出图片</h2>
<button
type="button"
aria-label="关闭图片导出"
disabled={busy}
onClick={onClose}
>
<X size={18} />
</button>
</header>
<p>导出当前工作表的完整内容为 PNG 图片。</p>
<label className="xmind-image-export-field">
清晰度
<select
aria-label="图片清晰度"
value={scale}
disabled={busy}
onChange={(event) =>
setScale(Number(event.target.value) as 1 | 2 | 3)
}
>
<option value={1}>标准 · 1 倍</option>
<option value={2}>高清 · 2 倍</option>
<option value={3}>超清 · 3 倍</option>
</select>
</label>
<label className="xmind-image-export-check">
<input
type="checkbox"
checked={transparent}
disabled={busy}
onChange={(event) => setTransparent(event.target.checked)}
/>
透明背景
</label>
<footer>
<button type="button" disabled={busy} onClick={onClose}>
取消
</button>
<button
type="button"
className="xmind-primary-button"
disabled={busy}
onClick={() => onExport({ scale, transparent })}
>
{busy ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
{busy ? "正在导出…" : "导出 PNG"}
</button>
</footer>
</dialog>
);
}

View File

@ -1,46 +0,0 @@
"use client";
import { useState } from "react";
import { ChevronDown, Search, X } from "lucide-react";
import { XMIND_MARKER_GROUPS, type XmindMarker } from "@/lib/flow-board/xmind-markers";
export function XmindMarkerPanel({ selectedCount, activeIds, mixedIds, onApply, onClear, onClose }: {
selectedCount: number;
activeIds: string[];
mixedIds: string[];
onApply: (marker: XmindMarker) => void;
onClear: () => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [showAll, setShowAll] = useState(false);
const [collapsed, setCollapsed] = useState<string[]>([]);
const needle = query.trim().toLowerCase();
const groups = XMIND_MARKER_GROUPS.filter(group => showAll || needle || !group.hidden).map(group => ({
...group, markers: group.markers.filter(marker => (showAll || needle || !marker.hidden) && (!needle || `${group.label} ${marker.name} ${marker.id}`.toLowerCase().includes(needle))),
})).filter(group => group.markers.length);
return <aside className="xmind-format-panel xmind-marker-panel" aria-label="标记面板" data-flow-board-shell-control="true"
onPointerDownCapture={event => event.stopPropagation()} onMouseDownCapture={event => event.stopPropagation()}>
<div className="xmind-panel-heading"><span>标记</span><button type="button" aria-label="关闭标记面板" onClick={onClose}><X size={16} /></button></div>
<div className="xmind-marker-search"><Search size={14} /><input aria-label="搜索标记" placeholder="搜索标记" value={query} onChange={event => setQuery(event.target.value)} /></div>
<p className="xmind-marker-selection" role="status">{selectedCount ? `已选中 ${selectedCount} 个主题` : "先选中主题,再添加标记"}</p>
<div className="xmind-panel-content">
{groups.map(group => <section className="xmind-marker-group" key={group.id}>
<button type="button" className="xmind-marker-group-heading" aria-expanded={!collapsed.includes(group.id) || Boolean(needle)} onClick={() => setCollapsed(ids => ids.includes(group.id) ? ids.filter(id => id !== group.id) : [...ids, group.id])}>
<ChevronDown size={13} /><span>{group.label}</span><span>{group.markers.length}</span>
</button>
{!collapsed.includes(group.id) || needle ? <div className="xmind-marker-grid">
{group.markers.map(marker => <button type="button" key={marker.id} data-marker-id={marker.id}
aria-label={marker.id.startsWith("priority-") ? marker.name : `${group.label} ${marker.name}`}
title={`${group.label} · ${marker.name}`} aria-pressed={activeIds.includes(marker.id) ? true : mixedIds.includes(marker.id) ? "mixed" : false}
disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={() => onApply(marker)}>
<img src={marker.asset} alt="" draggable={false} loading="lazy" />
</button>)}
</div> : null}
</section>)}
{!groups.length ? <p className="xmind-marker-empty">没有匹配的标记</p> : null}
{!needle ? <button type="button" className="xmind-marker-more" aria-expanded={showAll} onClick={() => setShowAll(value => !value)}>{showAll ? "收起更多标记" : "更多标记"}</button> : null}
</div>
<div className="xmind-marker-footer"><button type="button" disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={onClear}>清空所选节点标记</button></div>
</aside>;
}

View File

@ -1,280 +0,0 @@
/* Xmind editing controls use the project's home/canvas typography and density. */
.xmind-workspace {
--xmind-panel-width: 240px;
--xmind-ink: #24282b;
--xmind-muted: #888b8e;
--xmind-panel: #f3f4f5;
--xmind-border: #e3e5e8;
color: var(--xmind-ink);
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
background: white;
font-size: 13px;
}
.xmind-workspace button { cursor: pointer; }
.xmind-workspace :is(button,input,textarea,select) { font-family: inherit; }
.xmind-workspace button:disabled { cursor: default; opacity: .3; }
.xmind-workspace button:focus-visible { outline: 2px solid #35b5ff; outline-offset: 2px; }
.xmind-topic-marker { display:inline-block; object-fit:contain; flex-shrink:0; vertical-align:middle; }
.xmind-marker-search { display:flex; align-items:center; gap:6px; margin:4px 12px; padding:6px 8px; border:1px solid var(--xmind-border); border-radius:7px; background:white; }
.xmind-marker-search input { width:100%; min-width:0; outline:none; background:transparent; font-size:12px; }
.xmind-marker-selection { margin:6px 12px 10px; font-size:11px; color:var(--xmind-muted); }
.xmind-marker-panel .xmind-panel-content { flex:1; overflow-x:hidden; }
.xmind-marker-group { margin-bottom:12px; }
.xmind-marker-group-heading { display:flex; align-items:center; gap:5px; width:100%; height:26px; font-size:12px; font-weight:600; }
.xmind-marker-group-heading > span:last-child { margin-left:auto; font-size:10px; font-weight:400; color:var(--xmind-muted); }
.xmind-marker-group-heading[aria-expanded=false] svg { transform:rotate(-90deg); }
.xmind-marker-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }
.xmind-marker-grid button { display:grid; place-items:center; width:100%; height:28px; border:1px solid transparent; border-radius:5px; background:transparent; }
.xmind-marker-grid button:hover:not(:disabled) { background:#e3e6ea; }
.xmind-marker-grid button[aria-pressed=true] { border-color:#387cff; background:#e9f0ff; }
.xmind-marker-grid button[aria-pressed=mixed] { border:1px dashed #387cff; background:#e9f0ff80; }
.xmind-marker-grid img { width:20px; height:20px; object-fit:contain; }
.xmind-marker-more { width:100%; padding:7px; border-radius:6px; color:#3569e8; background:#e9edf4; font-size:12px; }
.xmind-marker-empty { padding:24px 0; text-align:center; color:var(--xmind-muted); }
.xmind-marker-footer { padding:10px 12px; border-top:1px solid var(--xmind-border); }
.xmind-marker-footer button { width:100%; padding:7px; border:1px solid var(--xmind-border); border-radius:6px; background:white; font-size:12px; }
.xmind-topbar { position:absolute; top:0; left:0; right:var(--xmind-panel-offset,0px); height:56px; z-index:50; display:flex; align-items:flex-start; padding:12px 12px 0; gap:8px; pointer-events:none; }
.xmind-topbar > * { pointer-events:auto; }
.xmind-document-heading { display:flex; align-items:center; gap:8px; min-width:200px; }
.xmind-home-button { display:grid; place-items:center; width:32px; height:32px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; flex-shrink:0; }
.xmind-document-name { height:32px; display:flex; flex-direction:column; justify-content:center; gap:0; min-width:100px; }
.xmind-document-name input { background:transparent; border:1px solid transparent; border-radius:5px; font-size:13px; font-weight:600; width:140px; padding:0 3px; outline:none; }
.xmind-document-name input:focus { border-color:#35b5ff; }
.xmind-document-name span { font-size:11px; padding-left:4px; }
.xmind-document-menu { margin-left:-8px; opacity:0; transition:opacity .15s; }
.xmind-document-heading:hover .xmind-document-menu,.xmind-document-heading:focus-within .xmind-document-menu { opacity:1; }
.xmind-pointer-toolbar { position:absolute; top:64px; left:12px; display:flex; flex-direction:column; gap:2px; padding:3px; border:1px solid var(--xmind-border); border-radius:10px; background:#fffffff5; box-shadow:0 2px 8px #00000008; }
.xmind-pointer-toolbar .xmind-icon-button { width:28px; height:28px; }
.xmind-primary-tools { display:flex; align-items:center; gap:8px; margin:auto; margin-top:0; }
.xmind-toolbar-pill { display:flex; align-items:center; padding:2px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; backdrop-filter:blur(24px); }
.xmind-icon-button { width:30px; height:28px; display:grid; place-items:center; border-radius:6px; color:var(--xmind-ink); background:transparent; flex-shrink:0; }
.xmind-icon-button > svg { width:18px; height:18px; }
.xmind-icon-button:hover:not(:disabled),.xmind-home-button:hover,.xmind-menu-trigger:hover { background:#00000009; }
.xmind-icon-button.is-active { background:#363b3e; color:white; }
.xmind-utility-tools { display:flex; align-items:center; gap:8px; }
.xmind-menu-host { position:relative; }
.xmind-menu-trigger { display:flex; align-items:center; justify-content:center; gap:4px; min-height:28px; padding:2px 5px; border-radius:24px; }
.xmind-document-menu .xmind-menu-trigger > svg:last-child,.xmind-sheet-actions .xmind-menu-trigger > svg:last-child { display:none; }
.xmind-command-menu { position:absolute; top:calc(100% + 10px); left:0; min-width:220px; max-height:min(70vh,540px); overflow-y:auto; padding:5px; border:1px solid #dedfe2; border-radius:10px; background:#fcfcfcf5; backdrop-filter:blur(24px); box-shadow:0 12px 32px #00000020,0 2px 6px #0000000a; z-index:70; }
.xmind-menu-right { left:auto; right:0; }
.xmind-command { width:100%; display:flex; align-items:center; gap:10px; border-radius:5px; padding:7px 9px; text-align:left; min-height:32px; color:#34393c; font-size:13px; }
.xmind-command:hover:not(:disabled),.xmind-command:focus-visible { background:#35a6fc; color:white; outline:none; }
.xmind-command kbd { font:inherit; opacity:.5; margin-left:auto; font-size:11px; }
.xmind-command-check { display:block; width:16px; }
.xmind-menu-separator { height:1px; background:#e1e2e4; margin:4px 6px; }
.xmind-format-panel { position:absolute; top:8px; right:8px; bottom:8px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid #e5e7e9; box-shadow:-5px 0 20px #00000005; display:flex; flex-direction:column; z-index:35; overflow:hidden; }
.xmind-panel-heading { display:flex; justify-content:space-between; align-items:center; min-height:36px; padding:0 12px; font-weight:600; }
.xmind-panel-heading button { border-radius:6px; padding:4px; color:#7a7e83; }
.xmind-panel-heading button:hover { background:#e2e4e6; }
.xmind-panel-tabs { display:flex; margin:4px 10px 10px; padding:3px; height:28px; border-radius:8px; background:#e7e8eb; flex-shrink:0; }
.xmind-panel-tabs button { flex:1; border-radius:6px; font-size:12px; color:#6d7276; }
.xmind-panel-tabs button[aria-selected=true] { background:#ffffffd9; color:#23282b; font-weight:600; }
.xmind-panel-content { overflow-y:auto; min-height:0; padding:0 12px 12px; scrollbar-width:thin; scrollbar-color:#b5b5b5 transparent; }
.xmind-topic-preview { min-height:48px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; display:flex; align-items:center; gap:8px; padding:8px; margin-bottom:12px; }
.xmind-topic-preview > span { flex:1; text-align:center; font-size:14px; padding:5px; }
.xmind-topic-preview svg { color:#5c6267; flex-shrink:0; }
.xmind-format-section { padding:12px 0; border-top:1px solid var(--xmind-border); }
.xmind-format-section h3 { display:flex; align-items:center; gap:5px; margin:0 0 10px; font-size:12px; font-weight:600; }
.xmind-setting-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; font-size:12px; }
.xmind-setting-row h3 { margin:0; }
.xmind-setting-row > span { flex-shrink:0; }
.xmind-setting-row select { height:25px; background:#fff; border:1px solid var(--xmind-border); border-radius:8px; min-width:74px; max-width:170px; padding:0 8px; font-size:12px; }
.xmind-color-control { height:25px; width:74px; padding:0; border:1px solid var(--xmind-border); border-radius:8px; overflow:hidden; background:white; cursor:pointer; }
.xmind-color-control::-webkit-color-swatch-wrapper { padding:0; }
.xmind-color-control::-webkit-color-swatch { border:0; border-radius:7px; }
.xmind-segmented { display:flex; border:1px solid var(--xmind-border); background:white; border-radius:8px; overflow:hidden; height:27px; }
.xmind-segmented button { flex:1; display:grid; place-items:center; margin:2px; border-radius:6px; }
.xmind-segmented button[aria-pressed=true] { background:#e4e6e8; }
.xmind-segmented button:hover:not(:disabled) { background:#f1f2f3; }
.xmind-shape-rectangle { width:18px; height:12px; border:1.5px solid currentColor; border-radius:3px; }
.xmind-shape-line { width:24px; border-bottom:1.5px solid currentColor; }
.xmind-text-marks { height:28px; margin-top:12px; }
.xmind-structure-trigger { display:flex; height:26px; align-items:center; padding:0 8px; background:white; border:1px solid var(--xmind-border); border-radius:8px; }
.xmind-structure-trigger svg[data-layout-preview] { width:35px; height:24px; }
.xmind-current-structure { padding:4px 10px; margin-top:14px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; font-size:12px; color:#7e8388; }
.xmind-structure-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; margin-top:12px; }
.xmind-structure-grid button { display:flex; flex-direction:column; justify-content:center; align-items:center; gap:4px; min-height:64px; border:1px solid #e0e3e7; border-radius:7px; font-size:9px; padding:4px 2px; background:white; }
.xmind-structure-grid button[aria-pressed=true] { border-color:#36b4ff; background:#edf8ff; color:#179cdc; }
.xmind-background-presets { display:grid; grid-template-columns:repeat(6,1fr); gap:6px; }
.xmind-background-presets button { height:28px; border:1px solid #d6d8dc; border-radius:6px; }
.xmind-background-presets button[aria-pressed=true] { outline:2px solid #35b5ff; }
.xmind-theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
.xmind-theme-grid button { background:white; border:1px solid #e1e3e6; border-radius:8px; font-size:11px; padding:8px; }
.xmind-theme-grid button[aria-pressed=true] { border-color:#35b5ff; }
.xmind-theme-grid button > span { display:flex; margin-bottom:8px; height:12px; overflow:hidden; border-radius:3px; }
.xmind-theme-grid i { flex:1; }
.xmind-switch { width:30px; height:18px; border-radius:12px; background:#bfc3c8; position:relative; }
.xmind-switch i { position:absolute; top:2px; left:2px; height:14px; width:14px; background:white; border-radius:50%; transition:transform .15s; }
.xmind-switch[aria-checked=true] { background:#35b5ff; }
.xmind-switch[aria-checked=true] i { transform:translateX(12px); }
.xmind-canvas-preview { height:96px; border:1px solid var(--xmind-border); border-radius:8px; margin-top:12px; }
.xmind-sheetbar { position:absolute; bottom:10px; left:16px; right:calc(var(--xmind-panel-offset,0px) + 16px); height:30px; z-index:40; display:flex; align-items:center; gap:8px; pointer-events:none; }
.xmind-sheetbar > * { pointer-events:auto; }
.xmind-sheet-tabs { display:flex; gap:4px; max-width:60%; overflow-x:auto; scrollbar-width:none; }
.xmind-sheet-tab-host { flex-shrink:0; }
.xmind-sheet-tab-host > button { border-radius:16px; padding:4px 18px; font-size:12px; font-weight:500; background:#ffffffd9; max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.xmind-sheet-tab-host > button[aria-pressed=true] { background:#e4e5e6; font-weight:600; }
.xmind-sheet-tab-host input { width:130px; height:25px; border-radius:6px; background:white; border:1px solid #35b5ff; padding:0 8px; }
.xmind-sheet-context { position:absolute; bottom:36px; min-width:140px; padding:5px; border:1px solid #e2e4e7; border-radius:10px; background:white; box-shadow:0 10px 25px #00000015; }
.xmind-sheet-context button { display:block; width:100%; text-align:left; padding:7px 10px; border-radius:6px; }
.xmind-sheet-context button:hover { background:#f0f3f5; }
.xmind-footer-divider { width:1px; height:13px; background:#dfe2e5; flex-shrink:0; }
.xmind-footer-icon { display:grid; place-items:center; width:26px; height:26px; border-radius:6px; }
.xmind-footer-icon:hover { background:#f2f3f5; }
.xmind-sheet-actions { opacity:0; }
.xmind-sheetbar:hover .xmind-sheet-actions,.xmind-sheet-actions:focus-within { opacity:1; }
.xmind-sheet-status { margin-left:auto; display:flex; gap:12px; align-items:center; background:#ffffffd9; }
.xmind-topic-count { font-size:11px; white-space:nowrap; }
.xmind-zoom-menu .xmind-command-menu,.xmind-sheet-actions .xmind-command-menu { top:auto; bottom:calc(100% + 8px); }
.xmind-zoom-menu .xmind-menu-trigger { font-size:12px; padding:0; min-width:50px; }
.xmind-outline-toggle { font-weight:600; font-size:13px; white-space:nowrap; }
.xmind-workspace .flow-board-canvas-shell { top:0; bottom:0; right:var(--xmind-panel-offset,0px); }
.xmind-workspace .board-host-svg { background:transparent !important; }
.xmind-workspace .board-active-svg { z-index:2; pointer-events:none; }
.xmind-workspace .active-host-g path[stroke="#6698FF"] { stroke:#387cff; stroke-width:2px; vector-effect:non-scaling-stroke; }
.xmind-workspace .active-host-g path[fill="#6698FF25"] { fill:rgba(56,124,255,.12); }
.xmind-outline-shell { position:absolute; inset:56px var(--xmind-panel-offset,0px) 48px 0; z-index:20; }
.xmind-workspace .xmind-aux-panel { top:8px; bottom:8px; right:8px; width:var(--xmind-panel-width); max-width:var(--xmind-panel-width); border-radius:12px; border:1px solid var(--xmind-border); background:var(--xmind-panel); }
.xmind-zen-exit { position:absolute; top:16px; right:16px; z-index:45; display:flex; gap:8px; align-items:center; padding:9px 14px; border-radius:24px; background:#fffffff0; box-shadow:0 4px 20px #0000000c; font-size:12px; }
.xmind-empty-canvas { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:20; border:1px solid #333; background:white; border-radius:8px; padding:8px 16px; font-size:16px; font-weight:600; }
.xmind-search-panel { position:absolute; top:64px; left:8px; bottom:48px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid var(--xmind-border); z-index:45; display:flex; flex-direction:column; box-shadow:5px 0 20px #00000008; }
.xmind-workspace:has(.xmind-search-panel) .xmind-pointer-toolbar { visibility:hidden; }
.xmind-search-panel .xmind-panel-content { padding:0 14px 14px; }
.xmind-search-box { display:flex; gap:8px; border-radius:8px; border:1px solid var(--xmind-border); background:white; padding:8px; align-items:center; margin:8px 0; }
.xmind-search-box input { outline:0; width:100%; background:transparent; font-size:13px; }
.xmind-search-options { display:flex; align-items:center; gap:12px; margin:10px 0; font-size:11px; }
.xmind-search-panel select { width:100%; height:30px; border-radius:8px; background:white; border:1px solid var(--xmind-border); padding:0 8px; font-size:12px; }
.xmind-search-navigation { display:flex; align-items:center; gap:8px; margin:12px 0; font-size:11px; color:#81868b; }
.xmind-search-navigation span { flex:1; }
.xmind-search-navigation button { padding:4px; border-radius:5px; }
.xmind-search-navigation button:hover { background:#e5e7e9; }
.xmind-search-result { display:block; width:100%; text-align:left; padding:10px; border-radius:8px; margin:4px 0; background:#ffffffa6; }
.xmind-search-result.is-active { outline:1px solid #35b5ff; background:#ecf8ff; }
.xmind-search-result > span { display:block; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.xmind-search-result small { font-size:10px; color:#8a8e91; }
.xmind-search-replace-buttons { display:flex; gap:8px; margin:10px 0 16px; }
.xmind-search-replace-buttons button { flex:1; border:1px solid var(--xmind-border); border-radius:6px; background:white; padding:5px; font-size:11px; }
.xmind-presentation { position:absolute; inset:0; z-index:60; background:#fff; display:flex; flex-direction:column; }
.xmind-presentation > header { display:flex; align-items:center; justify-content:space-between; padding:20px 28px; color:#8b9095; font-size:12px; }
.xmind-presentation > header button { display:flex; align-items:center; gap:6px; }
.xmind-presentation-slide { display:flex; flex:1; flex-direction:column; align-items:center; justify-content:center; padding:60px; overflow:auto; }
.xmind-presentation-slide h1 { font-size:clamp(28px,4vw,56px); font-weight:650; text-align:center; }
.xmind-presentation-slide ul { display:flex; flex-direction:column; gap:20px; margin-top:40px; font-size:clamp(18px,2vw,28px); }
.xmind-presentation-slide li { display:flex; align-items:center; gap:15px; }
.xmind-presentation-slide li::before { content:""; width:8px; height:8px; border-radius:50%; background:#ff7160; }
.xmind-presentation > footer { display:flex; justify-content:center; align-items:center; gap:20px; padding:20px; font-size:12px; color:#72787e; }
.xmind-presentation > footer button { padding:8px; border:1px solid #e6e7e9; border-radius:50%; }
@media (max-width:1100px) { .xmind-window-dots { display:none; } .xmind-document-heading { min-width:160px; gap:8px; } .xmind-document-name input { width:100px; } .xmind-utility-tools { gap:8px; } .xmind-primary-tools { gap:8px; } .xmind-topbar { gap:8px; padding-left:12px; } }
@media (max-width:850px) { .xmind-document-name { display:none; } .xmind-document-heading { min-width:auto; } .xmind-document-menu { opacity:1; } .xmind-utility-tools > .xmind-toolbar-pill:nth-child(2) { display:none; } .xmind-topbar { justify-content:space-between; } }
@media (max-width:640px) { .xmind-workspace { --xmind-panel-offset:0px !important; } .xmind-format-panel,.xmind-workspace .xmind-aux-panel { width:min(var(--xmind-panel-width),calc(100vw - 16px)); } .xmind-topbar { gap:4px; padding:12px 8px; } .xmind-home-button { width:32px; } .xmind-primary-tools { gap:4px; margin:0 auto; } .xmind-icon-button { width:28px; } .xmind-document-menu { display:none; } .xmind-topic-count { display:none; } .xmind-sheetbar { left:10px; right:10px; gap:5px; } .xmind-sheet-status { gap:6px; } .xmind-sheet-tabs { max-width:50%; } }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Regular.ttf') format('truetype'); font-weight:400; font-style:normal; font-display:swap; }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Medium.ttf') format('truetype'); font-weight:500; font-style:normal; font-display:swap; }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Bold.ttf') format('truetype'); font-weight:700; font-style:normal; font-display:swap; }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Extrabold.ttf') format('truetype'); font-weight:800; font-style:normal; font-display:swap; }
@layer base {
.xmind-workspace .drawnix,.xmind-workspace .drawnix * { --ui-font:Arial,"PingFang SC",sans-serif; font-family:Arial,"PingFang SC",sans-serif !important; }
.xmind-workspace .drawnix .plait-text-container,.xmind-workspace .drawnix .plait-text-container * { font-family:NeverMind,Arial,"PingFang SC",sans-serif !important; }
}
.xmind-native-icon { display:block; width:18px; height:18px; background:currentColor; mask-size:contain; mask-position:center; mask-repeat:no-repeat; }
/* Topic notes follow the native compact formatting toolbar. */
.xmind-notes-editor { border:1px solid #e2e5eb; border-radius:12px; background:#fafbfc; overflow:hidden; }
.xmind-notes-toolbar { display:flex; justify-content:center; align-items:center; gap:2px; padding:7px; border-bottom:1px solid #eceef1; }
.xmind-notes-toolbar button { display:grid; place-items:center; width:28px; height:28px; border-radius:6px; }
.xmind-notes-toolbar button:hover,.xmind-notes-toolbar button[aria-pressed=true] { background:#e9edf5; color:#3569e8; }
.xmind-note-content { min-height:180px; max-height:380px; overflow:auto; padding:12px; outline:none; font-size:13px; line-height:1.7; color:#30343b; counter-reset:note-number; }
.xmind-note-content p { margin:0 0 4px; position:relative; }
.xmind-note-content p[data-note-list] { padding-left:20px; }
.xmind-note-content p[data-note-list=bullet]::before { content:"•"; position:absolute; left:3px; }
.xmind-note-content p[data-note-list=number] { counter-increment:note-number; }
.xmind-note-content p[data-note-list=number]::before { content:counter(note-number) "."; position:absolute; left:0; }
.xmind-note-content a { color:#3569e8; text-decoration:underline; }
.xmind-note-link-form { padding:8px; display:flex; flex-wrap:wrap; gap:6px; }
.xmind-note-link-form input { min-width:0; flex:1; border:1px solid #dfe2e7; padding:4px; border-radius:5px; }
.xmind-note-link-form p { width:100%; color:#c0392b; font-size:11px; }
.xmind-task-backdrop { position:absolute; inset:0; background:rgb(20 27 40 / .16); z-index:85; display:grid; place-items:center; padding:20px; }
.xmind-task-editor { width:min(460px,100%); max-height:calc(100vh - 40px); overflow:auto; border:1px solid #e0e3e8; border-radius:16px; background:#fafbfc; color:#343b48; padding:18px; box-shadow:0 16px 64px rgb(20 27 40 / .18); font-size:12px; }
.xmind-task-editor header,.xmind-task-editor header h2 { display:flex; align-items:center; gap:8px; }
.xmind-task-editor header { justify-content:space-between; }.xmind-task-editor h2 { font-size:14px; font-weight:600; }
.xmind-task-name { margin:14px 0; font-size:15px; font-weight:600; overflow-wrap:anywhere; }
.xmind-task-fields { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.xmind-task-fields label { display:flex; flex-direction:column; gap:5px; }
.xmind-task-editor input,.xmind-task-editor select { width:100%; min-width:0; height:32px; padding:0 8px; border:1px solid #dfe2e7; background:white; border-radius:6px; outline:none; }
.xmind-task-editor input:focus,.xmind-task-editor select:focus { border-color:#799ce8; }
.xmind-task-editor h3 { font-weight:600; margin:16px 0 8px; }
.xmind-task-dependencies>div { display:flex; justify-content:space-between; padding:6px 0; }.xmind-task-add-dependency { display:grid; grid-template-columns:1fr 1fr 50px 34px; gap:5px; }
.xmind-task-editor footer { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:20px; }.xmind-task-editor footer button { border-radius:6px; padding:7px 11px; }.xmind-task-editor footer button:first-child { margin-right:auto; }
.xmind-primary-button { background:#3569e8; color:white; }.xmind-danger,.xmind-task-error { color:#c23c36; }.xmind-task-error { margin-top:10px; }
.xmind-gantt { position:absolute; inset:0; z-index:75; display:flex; flex-direction:column; background:#edf0f3; color:#30343b; font-size:12px; }
.xmind-gantt-toolbar { display:flex; align-items:center; justify-content:space-between; min-height:58px; padding:8px 16px; gap:10px; }.xmind-gantt-title,.xmind-gantt-actions { display:flex; align-items:center; gap:12px; }.xmind-gantt-title h2 { font-size:15px; font-weight:600; white-space:nowrap; }.xmind-gantt-title>span { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#7d8592; }
.xmind-gantt-actions>button,.xmind-gantt-title>button { display:grid; place-items:center; width:32px; height:32px; background:#fafbfc; border-radius:12px; }.xmind-gantt button:disabled { opacity:.35; cursor:default; }.xmind-gantt-zoom { display:flex; align-items:center; gap:8px; padding:6px 10px; background:#fafbfc; border-radius:16px; }.xmind-gantt-zoom input { width:90px; }
.xmind-gantt-filters { display:flex; align-items:center; gap:8px; padding:0 16px 10px; flex-wrap:wrap; }.xmind-gantt-filters label { display:flex; align-items:center; gap:6px; background:#fafbfc; padding:0 8px; border:1px solid #e0e3e8; border-radius:6px; }.xmind-gantt-filters input { width:130px; height:28px; outline:none; background:transparent; }.xmind-gantt-filters select { max-width:210px; height:30px; border:1px solid #e0e3e8; background:#fafbfc; padding:0 8px; border-radius:6px; }.xmind-gantt-filters>span { margin-left:auto; color:#7d8592; }
.xmind-gantt-body { min-height:0; flex:1; display:grid; grid-template-columns:minmax(290px,34%) minmax(0,1fr); gap:12px; padding:0 12px; }.xmind-gantt-table,.xmind-gantt-timeline { border:1px solid #dfe3e9; border-radius:12px; background:#fafbfc; overflow:hidden; min-width:0; position:relative; }.xmind-gantt-table { display:flex; flex-direction:column; }.xmind-gantt-table-head,.xmind-gantt-row { display:grid; grid-template-columns:minmax(0,1fr) 104px 54px; align-items:center; }.xmind-gantt-table-head { height:64px; min-height:64px; font-weight:600; border-bottom:1px solid #e2e5eb; }.xmind-gantt-table-head>span { display:flex; align-items:center; height:100%; padding:0 12px; border-right:1px solid #e2e5eb; }.xmind-gantt-row-scroll { overflow:auto; scrollbar-width:none; flex:1; position:relative; }.xmind-gantt-row { border-bottom:1px solid #e2e5eb; }.xmind-gantt-row.is-selected { background:#edf3ff; }.xmind-gantt-row>span { padding:0 7px; }.xmind-gantt-row-name { display:flex; align-items:center; gap:5px; overflow:hidden; }.xmind-gantt-row-name input { flex-shrink:0; }.xmind-gantt-task-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }.xmind-gantt-date { font-size:11px; text-align:left; padding:0 8px; }
.xmind-gantt-timeline { overflow:auto; }.xmind-gantt-grid { position:relative; background-image:linear-gradient(to right,#e7e9ee 1px,transparent 1px),linear-gradient(to bottom,#e7e9ee 1px,transparent 1px); background-position:0 64px; }.xmind-gantt-calendar { position:sticky; top:0; z-index:5; background:#fafbfc; border-bottom:1px solid #e2e5eb; }.xmind-gantt-day { position:absolute; top:0; height:64px; display:flex; flex-direction:column; justify-content:space-around; align-items:center; border-right:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }.xmind-gantt-day span { font-size:10px; color:#8b929d; }.xmind-gantt-day b { font-weight:500; }.xmind-gantt-today { position:absolute; bottom:0; width:2px; background:#fa603b; pointer-events:none; z-index:2; }.xmind-gantt-connections { position:absolute; inset:0; pointer-events:none; z-index:2; }.xmind-gantt-bar { position:absolute; height:26px; border-radius:16px; background:#fac28a; cursor:grab; touch-action:none; z-index:3; overflow:hidden; min-width:6px; border:1px solid #f5b978; }.xmind-gantt-bar:focus { outline:2px solid #3569e8; outline-offset:2px; }.xmind-gantt-progress { position:absolute; inset:0 auto 0 0; background:#f3a660; }.xmind-gantt-bar.is-complete { background:#b8dcca; border-color:#68a986; }.xmind-gantt-bar.is-complete .xmind-gantt-progress { background:#86b99b; }.xmind-gantt-bar.is-summary { height:10px; margin-top:4px; background:#667387; border-color:#667387; cursor:default; }.xmind-gantt-bar-label { position:relative; display:flex; align-items:center; gap:8px; padding:0 6px; height:24px; color:#72491f; font-size:11px; pointer-events:none; white-space:nowrap; }
.xmind-gantt-bar-label>span:last-child { flex:1; overflow:hidden; text-overflow:ellipsis; text-align:center; }
.xmind-gantt-avatar { width:18px; height:18px; border-radius:50%; background:#eceff1; color:#939ba2; display:grid; place-items:center; flex-shrink:0; }
.xmind-gantt-selection { position:absolute; left:0; background:rgb(125 136 148 / .15); pointer-events:none; z-index:1; }
.xmind-gantt-bar[aria-pressed=true] { outline:2px solid #539efa; outline-offset:1px; }.xmind-gantt-bar.is-summary .xmind-gantt-bar-label { display:none; }.xmind-gantt-resize { position:absolute; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:4; }.xmind-gantt-resize.is-start { left:0; }.xmind-gantt-resize.is-due { right:0; }
.xmind-gantt-empty { min-height:300px; height:70%; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#a0a6af; gap:10px; }.xmind-gantt-empty strong { font-size:15px; color:#424852; }.xmind-gantt-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:32px; padding:0 16px; color:#7d8592; font-size:11px; }
@media(max-width:800px) { .xmind-gantt-title>span { display:none; }.xmind-gantt-body { grid-template-columns:260px minmax(0,1fr); gap:6px; padding:0 6px; }.xmind-gantt-table-head,.xmind-gantt-row { grid-template-columns:minmax(0,1fr) 84px 42px; }.xmind-gantt-actions { gap:5px; }.xmind-gantt-toolbar { padding:8px; }.xmind-gantt-zoom input { width:50px; } }
@media print { .xmind-workspace:has(.xmind-gantt)>:not(.xmind-gantt) { display:none!important; }.xmind-gantt { position:fixed; background:white; }.xmind-gantt-toolbar button,.xmind-gantt-actions,.xmind-gantt-filters,.xmind-gantt-footer { display:none; }.xmind-gantt-timeline,.xmind-gantt-row-scroll { overflow:visible; }.xmind-gantt-body { overflow:visible; }.xmind-gantt-table { overflow:visible; }.xmind-gantt-row-name input { display:none; } }
.xmind-gantt-grid { min-height:100%; }
.xmind-gantt-day { top:28px; height:36px; }
.xmind-gantt-month { position:absolute; top:0; height:28px; padding:6px 12px; text-align:center; color:#7d8592; border-right:1px solid #e2e5eb; border-bottom:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }
.xmind-topic-badges { position:absolute; inset:0; pointer-events:none; z-index:12; overflow:hidden; }
.xmind-topic-badge { position:absolute; display:flex; align-items:center; gap:2px; pointer-events:auto; }
.xmind-topic-badge button { display:grid; place-items:center; width:19px; height:19px; background:rgb(255 255 255 / .9); border-radius:4px; color:#8b929d; }
.xmind-topic-badge button:hover { background:#edf3ff; color:#3569e8; }
.xmind-note-content p:not([data-note-list=number]) { counter-reset:note-number; }
.xmind-gantt-day-off { position:absolute; background:rgb(111 124 139 / .09); pointer-events:none; z-index:0; }
.xmind-gantt-day.is-day-off { color:#929aa6; background:#eef0f3; }
.xmind-task-calendar-info { display:flex; justify-content:space-between; align-items:center; gap:10px; margin:12px 0; color:#7d8592; font-size:11px; }
.xmind-task-calendar-info button { padding:6px 10px; border:1px solid #dce1e8; border-radius:6px; background:#f7f8fa; color:#3b4452; }
.xmind-project-calendar-backdrop,.xmind-calendar-exception-backdrop { position:absolute; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; background:rgb(29 36 46 / .22); padding:16px; }
.xmind-calendar-exception-backdrop { position:fixed; z-index:110; }
.xmind-project-calendar,.xmind-calendar-exception { display:flex; flex-direction:column; width:740px; max-width:100%; max-height:calc(100svh - 32px); color:#343b48; font-size:12px; background:#f7f8fa; border:1px solid #dce1e8; border-radius:14px; box-shadow:0 18px 60px rgb(0 0 0 / .17); overflow:hidden; }
.xmind-project-calendar>header,.xmind-calendar-exception>header { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; padding:18px 20px; }
.xmind-project-calendar h2,.xmind-calendar-exception h2 { display:flex; align-items:center; gap:9px; font-size:16px; font-weight:600; }
.xmind-project-calendar>header p { margin-top:6px; font-size:11px; color:#8b929d; }
.xmind-project-calendar>header button,.xmind-calendar-exception>header button { padding:4px; border-radius:5px; }
.xmind-project-calendar-content { display:grid; grid-template-columns:290px minmax(0,1fr); gap:12px; padding:0 20px 18px; min-height:0; overflow:auto; }
.xmind-calendar-preview,.xmind-calendar-settings { padding:15px; border:1px solid #e0e4ea; border-radius:10px; background:#fff; }
.xmind-project-calendar label,.xmind-calendar-exception label { display:flex; flex-direction:column; gap:6px; font-size:11px; }
.xmind-project-calendar input:not([type=checkbox]),.xmind-project-calendar select,.xmind-calendar-exception input,.xmind-calendar-exception select { height:31px; padding:0 9px; border:1px solid #dce1e8; background:#fff; border-radius:6px; font-size:12px; outline:none; min-width:0; width:100%; }
.xmind-project-calendar input:focus,.xmind-project-calendar select:focus,.xmind-calendar-exception input:focus,.xmind-calendar-exception select:focus { border-color:#6592ef; box-shadow:0 0 0 2px rgb(53 105 232 / .1); }
.xmind-project-calendar h3 { font-size:12px; font-weight:600; }
.xmind-calendar-import { display:flex; align-items:flex-end; gap:8px; margin-bottom:18px; }.xmind-calendar-import label { flex:1; min-width:0; }.xmind-calendar-import>button { color:#3569e8; font-size:11px; margin-bottom:8px; }
.xmind-calendar-month-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:9px; }.xmind-calendar-month-header button { padding:5px; }.xmind-calendar-month-header strong { font-weight:500; }
.xmind-calendar-month-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }.xmind-calendar-month-grid>button,.xmind-calendar-weekday { height:29px; display:grid; place-items:center; border-radius:5px; font-size:11px; }
.xmind-calendar-weekday { color:#8b929d; }.xmind-calendar-month-grid .is-day-off { background:repeating-linear-gradient(135deg,#f1f3f6,#f1f3f6 3px,#e9ecf0 3px,#e9ecf0 4px); color:#89929e; }.xmind-calendar-month-grid .is-other-month { opacity:.4; }.xmind-calendar-month-grid>button[aria-pressed=true] { background:#edf3ff; box-shadow:inset 0 0 0 1px #6592ef; color:#3569e8; }
.xmind-calendar-hint { color:#929aa6; font-size:10px; line-height:1.6; margin-top:9px; }
.xmind-project-calendar .xmind-calendar-skip { flex-direction:row; align-items:center; gap:7px; margin:18px 0; font-size:12px; }.xmind-calendar-working-week { display:flex; justify-content:space-between; gap:5px; padding:12px 0 18px; border-bottom:1px solid #e9edf2; }.xmind-calendar-working-week label { align-items:center; }
.xmind-calendar-rule-heading { display:flex; align-items:center; justify-content:space-between; margin:16px 0 9px; }.xmind-calendar-rule-heading button { padding:5px; background:#f2f4f7; border-radius:5px; }.xmind-calendar-rules { max-height:225px; overflow:auto; }.xmind-calendar-rules>div { display:flex; justify-content:space-between; align-items:center; gap:7px; padding:9px 0; border-bottom:1px solid #eef0f4; }.xmind-calendar-rules>div>button:first-child { flex:1; text-align:left; min-width:0; }.xmind-calendar-rules strong,.xmind-calendar-rules span { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.xmind-calendar-rules strong { font-weight:500; }.xmind-calendar-rules span { font-size:10px; color:#8b929d; margin-top:5px; }.xmind-calendar-rules>div>button:last-child { padding:6px; color:#8b929d; }.xmind-calendar-rules>p { color:#929aa6; font-size:11px; padding:10px 0; }
.xmind-project-calendar>footer,.xmind-calendar-exception>footer { padding:13px 20px; display:flex; justify-content:space-between; gap:10px; border-top:1px solid #e2e6ec; background:#fff; }.xmind-project-calendar>footer>div { display:flex; gap:8px; }.xmind-project-calendar>footer button,.xmind-calendar-exception>footer button,.xmind-calendar-personal>button { padding:8px 12px; border:1px solid #dce1e8; border-radius:6px; font-size:11px; }.xmind-project-calendar>footer .xmind-primary-button,.xmind-calendar-exception>footer .xmind-primary-button { border-color:#3569e8; }
.xmind-calendar-personal { border-top:1px solid #e6e9ee; margin-top:14px; padding-top:12px; }.xmind-calendar-personal p { color:#929aa6; font-size:10px; margin:7px 0; }.xmind-calendar-personal>div { display:flex; justify-content:space-between; align-items:center; margin:9px 0; }.xmind-calendar-personal>label { margin:12px 0 8px; }
.xmind-calendar-exception { width:420px; }.xmind-calendar-exception-fields { display:flex; flex-direction:column; gap:12px; padding:0 20px 20px; overflow:auto; }.xmind-calendar-range { display:grid; grid-template-columns:1fr 1fr; gap:12px; }.xmind-calendar-exception>footer { justify-content:flex-end; }
@media(max-width:700px) { .xmind-project-calendar-content { grid-template-columns:1fr; }.xmind-calendar-preview { max-width:360px; width:100%; justify-self:center; }.xmind-project-calendar>footer { flex-wrap:wrap; }.xmind-project-calendar>footer>div:last-child { margin-left:auto; } }
.xmind-notes-popover { position:absolute; z-index:40; width:min(360px,calc(100vw - 32px)); padding:8px; background:#fafbfc; border:1px solid #e5e8ed; border-radius:18px; box-shadow:0 14px 44px rgb(0 0 0 / .14); color:#30343b; }
.xmind-notes-popover>header { display:flex; align-items:center; justify-content:space-between; padding:0 6px; height:24px; font-size:11px; color:#9198a3; }.xmind-notes-popover>header>span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.xmind-notes-popover>header>div { display:flex; align-items:center; gap:8px; }
.xmind-notes-popover .xmind-notes-editor { border:0; border-radius:12px; }.xmind-notes-popover .xmind-notes-toolbar { border-bottom:0; padding:4px; }.xmind-notes-popover .xmind-note-content { min-height:220px; max-height:280px; padding:8px; }.xmind-note-empty { min-height:220px; padding:16px; font-size:12px; color:#8b929d; }
.xmind-image-export { width:400px; max-width:calc(100vw - 32px); border:1px solid #e5e5e8; border-radius:14px; padding:24px; color:#303034; background:white; box-shadow:0 18px 60px #0002; font:14px NeverMind,Arial,"PingFang SC",sans-serif; }
.xmind-image-export::backdrop { background:#0004; }
.xmind-image-export header,.xmind-image-export footer { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.xmind-image-export h2 { margin:0; font-size:18px; font-weight:600; }
.xmind-image-export p { color:#737378; margin:18px 0 24px; line-height:1.6; }
.xmind-image-export button { display:inline-flex; gap:7px; align-items:center; justify-content:center; min-height:34px; border:0; border-radius:7px; padding:7px 12px; background:#f2f2f4; color:inherit; cursor:pointer; }
.xmind-image-export button:disabled { opacity:.55; cursor:wait; }
.xmind-image-export button.xmind-primary-button { background:#2463eb; color:white; }
.xmind-image-export-field { display:flex; align-items:center; justify-content:space-between; gap:20px; }
.xmind-image-export select { width:220px; height:36px; background:white; border:1px solid #dcdce2; border-radius:7px; padding:0 10px; color:inherit; }
.xmind-image-export-check { display:flex; align-items:center; gap:8px; margin:20px 0 28px; }
.xmind-image-export-check input { accent-color:#2463eb; }
.xmind-image-export footer { justify-content:flex-end; }

View File

@ -1,318 +0,0 @@
"use client";
import {
Bold,
Italic,
Underline,
Strikethrough,
List,
ListOrdered,
Link2,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
createEditor,
Editor,
Element,
Transforms,
type Descendant,
} from "slate";
import { withHistory } from "slate-history";
import {
Editable,
Slate,
withReact,
ReactEditor,
type RenderElementProps,
type RenderLeafProps,
} from "slate-react";
import {
type NoteDocument,
type NoteParagraph,
safeNoteLink,
serializeNoteDocument,
} from "@/lib/flow-board/topic-notes";
export function XmindNotesEditor({
value,
onChange,
}: {
value: NoteDocument;
onChange: (value: NoteDocument) => void;
}) {
const [editor] = useState(() => withHistory(withReact(createEditor())));
const sourceRef = useRef(value);
sourceRef.current = value;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const [revision, setRevision] = useState(0);
const [linkOpen, setLinkOpen] = useState(false),
[link, setLink] = useState("");
const [linkError, setLinkError] = useState("");
const savedSelection = useRef(editor.selection);
useEffect(() => {
if (
JSON.stringify(serializeNoteDocument(editor.children as NoteDocument)) ===
JSON.stringify(serializeNoteDocument(value))
)
return;
editor.children = value as Descendant[];
editor.selection = null;
editor.history = { undos: [], redos: [] };
editor.onChange();
}, [editor, value]);
const marks = Editor.marks(editor) as Record<string, unknown> | null;
// Toolbar presses can arrive before Slate's throttled selection listener.
const syncDOMSelection = () => {
const selection = window.getSelection();
if (
!selection?.anchorNode ||
!ReactEditor.hasDOMNode(editor, selection.anchorNode)
)
return;
const range = ReactEditor.toSlateRange(editor, selection, {
exactMatch: false,
suppressThrow: true,
});
if (range) Transforms.select(editor, range);
};
const toggleMark = (
mark: "bold" | "italic" | "underline" | "strikethrough"
) => {
syncDOMSelection();
const currentMarks = Editor.marks(editor) as Record<string, unknown> | null;
if (currentMarks?.[mark]) Editor.removeMark(editor, mark);
else Editor.addMark(editor, mark, true);
setRevision((v) => v + 1);
};
const toggleList = (list: "bullet" | "number") => {
syncDOMSelection();
const match = Array.from(
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
)[0];
Transforms.setNodes(
editor,
{
list: (match?.[0] as NoteParagraph)?.list === list ? undefined : list,
} as Partial<Descendant>,
{ match: (n) => Element.isElement(n) }
);
};
const applyLink = () => {
const href = link.trim() ? safeNoteLink(link.trim()) : "";
if (href === null) {
setLinkError("请输入完整的网页或邮件链接。");
return;
}
if (savedSelection.current)
Transforms.select(editor, savedSelection.current);
if (href) Editor.addMark(editor, "href", href);
else Editor.removeMark(editor, "href");
setLinkOpen(false);
editor.onChange();
};
const leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
const t = leaf as {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
href?: string;
};
const href = safeNoteLink(t.href || "");
return (
<span
{...attributes}
style={{
fontWeight: t.bold ? 700 : undefined,
fontStyle: t.italic ? "italic" : undefined,
textDecoration:
[
t.underline ? "underline" : "",
t.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
}}
>
{href ? (
<a
href={href}
title={href}
rel="noopener noreferrer"
onClick={(e) => {
if (!e.metaKey && !e.ctrlKey) e.preventDefault();
}}
target="_blank"
>
{children}
</a>
) : (
children
)}
</span>
);
};
const element = ({ attributes, children, element }: RenderElementProps) => {
const p = element as NoteParagraph;
return (
<p
{...attributes}
data-note-list={p.list}
style={{ marginLeft: Math.max(0, Math.min(120, p.indent || 0)) }}
>
{children}
</p>
);
};
return (
<div className="xmind-notes-editor" data-editor-revision={revision}>
<Slate
editor={editor}
initialValue={value as Descendant[]}
onChange={(next) => {
setRevision((v) => v + 1);
if (editor.operations.every((o) => o.type === "set_selection"))
return;
const doc = next as NoteDocument;
if (
JSON.stringify(serializeNoteDocument(doc)) !==
JSON.stringify(serializeNoteDocument(sourceRef.current))
)
onChangeRef.current(doc);
}}
>
<div
className="xmind-notes-toolbar"
role="toolbar"
aria-label="笔记格式"
>
{(
[
["bold", "加粗", Bold],
["italic", "斜体", Italic],
["underline", "下划线", Underline],
["strikethrough", "删除线", Strikethrough],
] as const
).map(([mark, label, Icon]) => (
<button
key={mark}
type="button"
aria-label={`笔记${label}`}
aria-pressed={Boolean(marks?.[mark])}
onMouseDown={(e) => {
e.preventDefault();
toggleMark(mark);
}}
>
<Icon size={17} />
</button>
))}
<button
type="button"
aria-label="笔记无序列表"
onMouseDown={(e) => {
e.preventDefault();
toggleList("bullet");
}}
>
<List size={17} />
</button>
<button
type="button"
aria-label="笔记有序列表"
onMouseDown={(e) => {
e.preventDefault();
toggleList("number");
}}
>
<ListOrdered size={17} />
</button>
<button
type="button"
aria-label="笔记链接"
onMouseDown={(e) => {
e.preventDefault();
syncDOMSelection();
savedSelection.current = editor.selection;
setLink(String(marks?.href || ""));
setLinkError("");
setLinkOpen(!linkOpen);
}}
>
<Link2 size={17} />
</button>
</div>
{linkOpen ? (
<div className="xmind-note-link-form">
<input
aria-label="笔记链接地址"
placeholder="https://…"
value={link}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
<button type="button" onClick={applyLink}>
确定
</button>
<button
type="button"
aria-label="取消笔记链接"
onClick={() => setLinkOpen(false)}
>
<X size={14} />
</button>
{linkError ? <p role="alert">{linkError}</p> : null}
</div>
) : null}
<Editable
aria-label="主题备注"
className="xmind-note-content"
placeholder="为这个主题补充说明…"
renderLeaf={leaf}
renderElement={element}
onKeyDown={(e) => {
if (
(e.metaKey || e.ctrlKey) &&
["b", "i", "u"].includes(e.key.toLowerCase())
) {
e.preventDefault();
toggleMark(
(
{ b: "bold", i: "italic", u: "underline" } as Record<
string,
"bold" | "italic" | "underline"
>
)[e.key.toLowerCase()]
);
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
e.stopPropagation();
if (e.key === "Tab") {
e.preventDefault();
const match = Array.from(
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
)[0];
const indent = (match?.[0] as NoteParagraph)?.indent || 0;
Transforms.setNodes(
editor,
{
indent: Math.max(
0,
Math.min(120, indent + (e.shiftKey ? -20 : 20))
),
} as Partial<Descendant>,
{ match: (n) => Element.isElement(n) }
);
}
}}
/>
</Slate>
</div>
);
}

View File

@ -1,111 +0,0 @@
"use client";
import {
type PlaitBoard,
type PlaitElement,
toHostPointFromViewBoxPoint,
toScreenPointFromHostPoint,
} from "@plait/core";
import { getRectangleByNode, MindElement } from "@plait/mind";
import { MoreHorizontal, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getNoteDocument,
type NoteDocument,
} from "@/lib/flow-board/topic-notes";
import { getTopicTitle } from "@/lib/flow-board/topic-details";
import { XmindNotesEditor } from "./xmind-notes-editor";
export function XmindNotesPopover({
board,
topic,
viewport,
onChange,
onDetails,
onClose,
}: {
board: PlaitBoard | null;
topic: PlaitElement | null;
viewport: unknown;
onChange: (doc: NoteDocument) => void;
onDetails: () => void;
onClose: () => void;
}) {
const ref = useRef<HTMLElement>(null);
const [position, setPosition] = useState({ left: 400, top: 300 });
useEffect(() => {
if (!board || !topic) return;
const update = () => {
const node = MindElement.getNode(topic as MindElement);
if (!node) return;
const rect = getRectangleByNode(node),
point = toScreenPointFromHostPoint(
board,
toHostPointFromViewBoxPoint(board, [
rect.x + rect.width / 2,
rect.y + rect.height,
])
);
const host = ref.current?.parentElement?.getBoundingClientRect();
const width = Math.min(360, window.innerWidth - 32);
const right = window.innerWidth > 800 ? 240 : 0;
const left = Math.max(
12,
Math.min(window.innerWidth - right - width - 16, point[0] - width / 2)
);
const top = Math.max(
78,
Math.min(window.innerHeight - 340, point[1] + 12)
);
setPosition({ left: left - (host?.x || 0), top: top - (host?.y || 0) });
};
const frame = requestAnimationFrame(update);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
};
}, [board, topic?.id, topic?.data, viewport]);
return (
<aside
ref={ref}
className="xmind-notes-popover"
aria-label="主题笔记"
style={position}
data-flow-board-shell-control="true"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
}}
>
<header>
<span title={topic ? getTopicTitle(topic) : "笔记"}>
{topic ? getTopicTitle(topic) : "笔记"}
</span>
<div>
<button
type="button"
aria-label="主题详情"
title="主题详情"
onClick={onDetails}
>
<MoreHorizontal size={15} />
</button>
<button type="button" aria-label="关闭笔记" onClick={onClose}>
<X size={15} />
</button>
</div>
</header>
{topic ? (
<XmindNotesEditor
key={topic.id}
value={getNoteDocument(topic)}
onChange={onChange}
/>
) : (
<p className="xmind-note-empty">选择一个主题以编辑笔记。</p>
)}
</aside>
);
}

View File

@ -1,310 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronRight,
GripVertical,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from "lucide-react";
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
getTopicEntries,
type TopicEntry,
} from "@/lib/flow-board/topic-details";
import type {
OutlineCommand,
OutlineDrop,
} from "@/lib/flow-board/mind-outline";
type Props = {
zoomPercent: number;
elements: PlaitElement[];
selectedId: string | null;
onSelect: (id: string) => void;
onRename: (id: string, title: string) => void;
onToggle: (id: string) => void;
onCommand: (
id: string | null,
command: OutlineCommand | OutlineDrop
) => string | null;
onUndo: () => void;
onRedo: () => void;
};
function OutlineRow({
entry,
selected,
onSelect,
onRename,
onToggle,
onCommand,
onFocusTopic,
}: {
entry: TopicEntry;
selected: boolean;
onSelect: Props["onSelect"];
onRename: Props["onRename"];
onToggle: Props["onToggle"];
onCommand: Props["onCommand"];
onFocusTopic: (id: string | null, direction?: number) => void;
}) {
const { element, title, depth } = entry;
const [draft, setDraft] = useState(title);
const [drop, setDrop] = useState<OutlineDrop["placement"] | null>(null);
const ref = useRef<HTMLInputElement>(null);
const details = getTopicDetails(element);
useEffect(() => setDraft(title), [title]);
const commit = () => {
if (draft !== title) onRename(element.id, draft);
};
const act = (command: OutlineCommand) => {
commit();
onFocusTopic(onCommand(element.id, command));
};
return (
<div
data-outline-id={element.id}
data-depth={depth}
className={`group flex min-h-11 items-center gap-1 border-b border-[#f1f2f5] px-3 ${selected ? "bg-[#f0f5ff]" : "hover:bg-[#fafbfc]"} ${drop === "child" ? "ring-1 ring-inset ring-blue-400" : drop === "before" ? "border-t-2 border-t-blue-400" : drop === "after" ? "border-b-2 border-b-blue-400" : ""}`}
style={{ paddingLeft: 12 + Math.min(depth, 20) * 24 }}
onDragOver={(event) => {
event.preventDefault();
const box = event.currentTarget.getBoundingClientRect();
const y = (event.clientY - box.top) / box.height;
setDrop(y < 0.25 ? "before" : y > 0.75 ? "after" : "child");
}}
onDragLeave={() => setDrop(null)}
onDrop={(event) => {
event.preventDefault();
const source = event.dataTransfer.getData("application/x-mind-topic");
if (source && drop)
onFocusTopic(
onCommand(source, { targetId: element.id, placement: drop })
);
setDrop(null);
}}
>
<span
draggable
aria-label={`拖动 ${title}`}
className="cursor-grab text-[#9ca3af] opacity-30 group-hover:opacity-100"
onDragStart={(event) => {
event.dataTransfer.setData("application/x-mind-topic", element.id);
event.dataTransfer.effectAllowed = "move";
}}
>
<GripVertical size={13} />
</span>
{element.children?.length ? (
<button
type="button"
className="p-1 text-[#64748b]"
aria-label={`${element.isCollapsed ? "展开" : "折叠"} ${title}`}
onClick={() => onToggle(element.id)}
>
{element.isCollapsed ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
</button>
) : (
<span className="w-[22px] text-center text-[#a2a9b3]">·</span>
)}
<input
ref={ref}
aria-label={`大纲主题 ${title}`}
data-outline-input={element.id}
className={`min-w-0 flex-1 bg-transparent py-2 text-sm outline-none ${depth === 0 ? "font-semibold text-[#253046]" : "text-[#404958]"}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onFocus={() => onSelect(element.id)}
onBlur={commit}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter") {
event.preventDefault();
act(event.shiftKey || depth === 0 ? "child" : "sibling");
} else if (event.key === "Tab") {
event.preventDefault();
act(event.shiftKey ? "outdent" : "indent");
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
commit();
if (event.altKey) act(event.key === "ArrowUp" ? "up" : "down");
else onFocusTopic(element.id, event.key === "ArrowUp" ? -1 : 1);
} else if (event.key === "Escape") {
event.preventDefault();
setDraft(title);
} else if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z" &&
draft !== title
) {
event.preventDefault();
event.stopPropagation();
setDraft(title);
} else if (event.key === "Backspace" && !draft) {
event.preventDefault();
onFocusTopic(onCommand(element.id, "delete"));
}
}}
/>
{Number.isInteger(element.start) ? (
<span className="text-[10px] text-[#8d79b2]">概要</span>
) : null}
{details.notes ? (
<span title={details.notes} className="text-[10px] text-[#8a94a5]">
备注
</span>
) : null}
{details.labels.map((label) => (
<span
key={label}
className="hidden rounded bg-[#e8effc] px-1.5 py-0.5 text-[10px] text-[#5879b4] lg:inline"
>
{label}
</span>
))}
<button
type="button"
aria-label={`为 ${title} 添加子主题`}
className="p-1 text-[#8a94a5] opacity-0 focus:opacity-100 group-hover:opacity-100"
onClick={() => act("child")}
>
<Plus size={14} />
</button>
</div>
);
}
export function XmindOutline(props: Props) {
const { elements, selectedId, onCommand } = props;
const container = useRef<HTMLElement>(null);
const [focusId, setFocusId] = useState<string | null>(null);
const entries = getTopicEntries(elements);
const collapsedPaths = entries
.filter(({ element }) => element.isCollapsed)
.map((entry) => entry.path);
const visible = entries.filter(
(entry) =>
!collapsedPaths.some(
(path) =>
path.length < entry.path.length &&
path.every((index, i) => index === entry.path[i])
)
);
useEffect(() => {
if (!focusId) return;
const input = Array.from(
container.current?.querySelectorAll<HTMLInputElement>(
"[data-outline-input]"
) || []
).find((node) => node.dataset.outlineInput === focusId);
if (input) {
input.focus();
input.select();
setFocusId(null);
}
}, [focusId, elements]);
const focusTopic = (id: string | null, direction?: number) => {
if (!id) return;
const target = direction
? visible[
visible.findIndex((entry) => entry.element.id === id) + direction
]?.element.id
: id;
if (target) setFocusId(target);
};
const action = (command: OutlineCommand) =>
focusTopic(onCommand(selectedId, command));
return (
<section
ref={container}
aria-label="大纲视图"
className="h-full overflow-auto bg-white"
data-flow-board-shell-control="true"
onKeyDown={(event) => {
event.stopPropagation();
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z"
) {
event.preventDefault();
if (event.shiftKey) props.onRedo();
else props.onUndo();
}
}}
>
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-[#e5e8ee] bg-white px-5 py-3">
<h2 className="mr-2 text-sm font-semibold">大纲</h2>
<span className="mr-auto text-xs text-[#949ba7]">
{entries.length} 个主题
</span>
{(
[
{ command: "child", label: "大纲新增子主题", icon: Plus },
{ command: "indent", label: "大纲缩进", icon: IndentIncrease },
{ command: "outdent", label: "大纲提升层级", icon: IndentDecrease },
{ command: "up", label: "大纲上移", icon: ArrowUp },
{ command: "down", label: "大纲下移", icon: ArrowDown },
{ command: "delete", label: "大纲删除主题", icon: Trash2 },
] as const
).map(({ command, label, icon: Icon }) => (
<button
key={command}
type="button"
title={label}
aria-label={label}
className="rounded p-1.5 text-[#64748b] hover:bg-[#edf3ff] disabled:opacity-30"
disabled={
(!selectedId && entries.length > 0) ||
(!selectedId && command !== "child")
}
onClick={() => action(command)}
>
<Icon size={16} />
</button>
))}
</div>
<div
className="mx-auto max-w-4xl px-4 py-6"
style={{ zoom: props.zoomPercent / 100 }}
>
{!entries.length ? (
<button
type="button"
onClick={() => action("child")}
className="w-full rounded border border-dashed border-[#cbd5e1] p-8 text-sm text-[#64748b]"
>
添加中心主题
</button>
) : (
visible.map((entry) => (
<OutlineRow
key={entry.element.id}
entry={entry}
selected={entry.element.id === selectedId}
onSelect={props.onSelect}
onRename={props.onRename}
onToggle={props.onToggle}
onCommand={onCommand}
onFocusTopic={focusTopic}
/>
))
)}
<p className="mt-6 text-xs text-[#a1a8b4]">
Enter 新增同级 · Shift+Enter 新增子主题 · Tab 缩进 · Shift+Tab
提升层级 · Alt+↑↓ 调整顺序
</p>
</div>
</section>
);
}

View File

@ -1,28 +0,0 @@
"use client";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { useEffect, useState } from "react";
import type { PlaitElement } from "@plait/core";
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
export function XmindPresentation({ elements, title, onClose }: { elements: PlaitElement[]; title: string; onClose: () => void }) {
const slides = getTopicEntries(elements).filter(({ element, depth }) => depth === 0 || element.children?.length);
const [index,setIndex] = useState(0);
const entry = slides[Math.min(index,slides.length - 1)];
const next = () => setIndex(value => Math.min(slides.length - 1,value + 1));
const previous = () => setIndex(value => Math.max(0,value - 1));
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); }
if (["ArrowRight","ArrowDown"," ","PageDown"].includes(event.key)) { event.preventDefault(); next(); }
if (["ArrowLeft","ArrowUp","PageUp"].includes(event.key)) { event.preventDefault(); previous(); }
};
window.addEventListener("keydown",onKey);
return () => window.removeEventListener("keydown",onKey);
}, [onClose,slides.length]);
return <section className="xmind-presentation" aria-label="演示视图" data-flow-board-shell-control="true">
<header><span>{title}</span><button type="button" aria-label="退出演示" onClick={onClose}><X size={16} />退出演示</button></header>
<div className="xmind-presentation-slide"><h1>{entry?.title || title}</h1><ul>{(entry?.element.children || []).map(child => <li key={child.id}>{getTopicTitle(child)}</li>)}</ul></div>
<footer><button type="button" aria-label="上一页" disabled={index === 0} onClick={previous}><ChevronLeft size={18} /></button><span>{Math.min(index + 1,slides.length)} / {slides.length}</span><button type="button" aria-label="下一页" disabled={index >= slides.length - 1} onClick={next}><ChevronRight size={18} /></button></footer>
</section>;
}

View File

@ -1,650 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
CalendarDays,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
X,
} from "lucide-react";
import {
CALENDAR_FREQUENCIES,
defaultWorkingCalendar,
isWorkingDate,
readWorkingCalendar,
validateWorkingCalendar,
type CalendarRule,
type ProjectCalendarConfig,
type ProjectWorkingCalendar,
} from "@/lib/flow-board/project-calendar";
import {
addDays,
dateIndex,
DAY,
indexDate,
todayDate,
} from "@/lib/flow-board/calendar-dates";
const STORAGE_KEY = "flow-board.personal-calendars.v1";
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
const FREQUENCY_LABELS: Record<CalendarRule["frequency"], string> = {
NONE: "不重复",
DAILY: "每天",
WEEKLY: "每周",
MONTHLY_BY_DATE: "每月(按日期)",
MONTHLY_BY_WEEKDAY: "每月(按星期)",
YEARLY: "每年",
};
const copy = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
function useCalendarDialogFocus(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
ref.current?.querySelector<HTMLElement>("input,button")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, [ref]);
}
function trapCalendarTab(
event: React.KeyboardEvent,
panel: HTMLElement | null
) {
if (event.key !== "Tab") return;
const controls = Array.from(
panel?.querySelectorAll<HTMLElement>(
"button:not(:disabled),input:not(:disabled),select:not(:disabled)"
) || []
);
const first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
function CalendarExceptionEditor({
rule,
onSave,
onClose,
}: {
rule: CalendarRule;
onSave: (rule: CalendarRule) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState(() => copy(rule)),
[error, setError] = useState("");
const ref = useRef<HTMLElement>(null);
useCalendarDialogFocus(ref);
const patch = (changes: Partial<CalendarRule>) =>
setDraft((value) => ({ ...value, ...changes }));
const save = () => {
const problem = validateWorkingCalendar({
id: "validation",
name: "",
defaultWorkingDays: [1],
rules: [draft],
});
if (problem) {
setError(problem);
return;
}
const selected = draft.selectedDates[0];
if (
(draft.effectiveRange?.start && selected < draft.effectiveRange.start) ||
(draft.effectiveRange?.end && selected > draft.effectiveRange.end)
) {
setError("例外日期必须在生效范围内。");
return;
}
onSave({ ...draft, name: draft.name.trim() || "例外" });
onClose();
};
return (
<div
className="xmind-calendar-exception-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-calendar-exception"
role="dialog"
aria-modal="true"
aria-label="工作日例外"
>
<header>
<h2>工作日例外</h2>
<button type="button" aria-label="关闭工作日例外" onClick={onClose}>
<X size={17} />
</button>
</header>
<div className="xmind-calendar-exception-fields">
<label>
名称
<input
aria-label="例外名称"
value={draft.name}
onChange={(event) => patch({ name: event.target.value })}
/>
</label>
<label>
日期
<input
type="date"
aria-label="例外日期"
value={draft.selectedDates[0] || ""}
onChange={(event) =>
patch({ selectedDates: [event.target.value] })
}
/>
</label>
<label>
类型
<select
aria-label="例外类型"
value={draft.type}
onChange={(event) =>
patch({ type: event.target.value as CalendarRule["type"] })
}
>
<option value="NON_WORKING_DAY">休息日</option>
<option value="WORKING_DAY">工作日(补班)</option>
</select>
</label>
<label>
重复
<select
aria-label="例外重复规则"
value={draft.frequency}
onChange={(event) =>
patch({
frequency: event.target.value as CalendarRule["frequency"],
})
}
>
{CALENDAR_FREQUENCIES.map((frequency) => (
<option key={frequency} value={frequency}>
{FREQUENCY_LABELS[frequency]}
</option>
))}
</select>
</label>
{draft.frequency !== "NONE" ? (
<label>
每隔
<input
type="number"
min={1}
max={100}
aria-label="例外重复间隔"
value={draft.interval || 1}
onChange={(event) =>
patch({ interval: Number(event.target.value) })
}
/>
</label>
) : null}
<div className="xmind-calendar-range">
<label>
生效开始
<input
type="date"
aria-label="例外生效开始"
value={draft.effectiveRange?.start || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
start: event.target.value,
},
})
}
/>
</label>
<label>
生效结束
<input
type="date"
aria-label="例外生效结束"
value={draft.effectiveRange?.end || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
end: event.target.value,
},
})
}
/>
</label>
</div>
<p className="xmind-calendar-hint">
留空表示不限制生效日期。后添加的例外优先。
</p>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
</div>
<footer>
<button type="button" onClick={onClose}>
取消
</button>
<button type="button" className="xmind-primary-button" onClick={save}>
保存例外
</button>
</footer>
</section>
</div>
);
}
export function XmindProjectCalendar({
config,
onSave,
onClose,
}: {
config: ProjectCalendarConfig;
onSave: (config: ProjectCalendarConfig) => boolean;
onClose: () => void;
}) {
const ref = useRef<HTMLElement>(null);
useCalendarDialogFocus(ref);
const [draft, setDraft] = useState(() =>
copy(config.settings || defaultWorkingCalendar())
);
const [skip, setSkip] = useState(config.settings ? config.skip : true),
[error, setError] = useState("");
const [selectedDate, setSelectedDate] = useState(todayDate()),
[month, setMonth] = useState(todayDate().slice(0, 7));
const [exception, setException] = useState<CalendarRule | null>(null),
[manage, setManage] = useState(false);
const [templates, setTemplates] = useState<ProjectWorkingCalendar[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
return Array.isArray(stored)
? stored
.map(readWorkingCalendar)
.filter((item): item is ProjectWorkingCalendar => Boolean(item))
: [];
} catch {
return [];
}
});
const [templateName, setTemplateName] = useState("");
const [message, setMessage] = useState("");
const storeTemplates = (value: ProjectWorkingCalendar[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
setTemplates(value);
return true;
} catch {
setError("个人日历保存失败,请检查浏览器存储空间。");
return false;
}
};
const save = () => {
const problem = validateWorkingCalendar(draft);
if (problem) {
setError(problem);
return;
}
if (
onSave({
settings: { ...draft, name: draft.name.trim() || "项目日历" },
skip,
})
)
onClose();
};
const saveTemplate = () => {
const problem = validateWorkingCalendar(draft);
if (problem) {
setError(problem);
return;
}
if (!templateName.trim()) {
setError("请输入个人日历名称。");
return;
}
const value = {
...copy(draft),
id: crypto.randomUUID(),
name: templateName.trim(),
};
if (storeTemplates([...templates, value])) {
setTemplateName("");
setError("");
setMessage(`已保存个人日历:${value.name}`);
}
};
const moveMonth = (step: number) => {
const date = new Date(`${month}-01T00:00:00Z`);
date.setUTCMonth(date.getUTCMonth() + step);
setMonth(date.toISOString().slice(0, 7));
};
const first = dateIndex(`${month}-01`),
offset = (new Date(first * DAY).getUTCDay() + 6) % 7;
const days = Array.from({ length: 42 }, (_, index) =>
indexDate(first - offset + index)
);
const addException = () =>
setException({
id: crypto.randomUUID(),
name: "",
type: isWorkingDate(selectedDate, draft)
? "NON_WORKING_DAY"
: "WORKING_DAY",
frequency: "NONE",
selectedDates: [selectedDate],
effectiveRange: { start: selectedDate, end: selectedDate },
});
return (
<div
className="xmind-project-calendar-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
if (exception) return;
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-project-calendar"
role="dialog"
aria-modal="true"
aria-label="项目工作日"
>
<header>
<div>
<h2>
<CalendarDays size={19} />
项目工作日
</h2>
<p>只应用于当前工作表,不影响其他工作表。</p>
</div>
<button type="button" aria-label="关闭项目工作日" onClick={onClose}>
<X size={18} />
</button>
</header>
<div className="xmind-project-calendar-content">
<div className="xmind-calendar-preview">
<div className="xmind-calendar-import">
<label>
导入日历
<select
aria-label="导入个人日历"
value=""
onChange={(event) => {
const value =
event.target.value === "basic"
? { ...defaultWorkingCalendar(), name: "基本日历" }
: templates.find(
(item) => item.id === event.target.value
);
if (value) {
setDraft(copy(value));
setError("");
}
}}
>
<option value="">当前项目:{draft.name}</option>
<option value="basic">基本日历(周一至周五)</option>
{templates.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => setManage((value) => !value)}
>
管理
</button>
</div>
<div className="xmind-calendar-month-header">
<button
type="button"
aria-label="上个月"
onClick={() => moveMonth(-1)}
>
<ChevronLeft size={16} />
</button>
<strong>{month.replace("-", "年")}月</strong>
<button
type="button"
aria-label="下个月"
onClick={() => moveMonth(1)}
>
<ChevronRight size={16} />
</button>
</div>
<div className="xmind-calendar-month-grid" aria-label="工作日预览">
{WEEKDAYS.map((day) => (
<span className="xmind-calendar-weekday" key={day}>
{day}
</span>
))}
{days.map((date) => (
<button
type="button"
key={date}
data-calendar-date={date}
data-working={isWorkingDate(date, draft)}
aria-label={`${date} ${isWorkingDate(date, draft) ? "工作日" : "休息日"}`}
aria-pressed={selectedDate === date}
className={`${date.slice(0, 7) !== month ? "is-other-month" : ""} ${isWorkingDate(date, draft) ? "" : "is-day-off"}`}
onClick={() => setSelectedDate(date)}
>
{Number(date.slice(8))}
</button>
))}
</div>
<p className="xmind-calendar-hint">
阴影为休息日。选择日期后可添加例外。
</p>
{manage ? (
<div className="xmind-calendar-personal">
<h3>个人日历</h3>
<p>保存在当前浏览器,可用于其他工作表。</p>
{templates.length ? (
templates.map((item) => (
<div key={item.id}>
<span>{item.name}</span>
<button
type="button"
aria-label={`删除个人日历 ${item.name}`}
onClick={() =>
storeTemplates(
templates.filter((value) => value.id !== item.id)
)
}
>
<Trash2 size={13} />
</button>
</div>
))
) : (
<p>还没有个人日历。</p>
)}
<label>
名称
<input
aria-label="个人日历名称"
value={templateName}
onChange={(event) => setTemplateName(event.target.value)}
/>
</label>
<button type="button" onClick={saveTemplate}>
保存为个人日历
</button>
</div>
) : null}
</div>
<div className="xmind-calendar-settings">
<label>
日历名称
<input
aria-label="项目日历名称"
value={draft.name}
onChange={(event) =>
setDraft((value) => ({ ...value, name: event.target.value }))
}
/>
</label>
<label className="xmind-calendar-skip">
<input
type="checkbox"
aria-label="跳过非工作日"
checked={skip}
onChange={(event) => setSkip(event.target.checked)}
/>
跳过非工作日
</label>
<h3>每周工作日</h3>
<div className="xmind-calendar-working-week">
{WEEKDAYS.map((day, index) => (
<label key={day}>
<input
type="checkbox"
aria-label={`周${day}为工作日`}
checked={draft.defaultWorkingDays.includes(index + 1)}
onChange={(event) => {
const value = event.target.checked
? [...draft.defaultWorkingDays, index + 1].sort()
: draft.defaultWorkingDays.filter(
(value) => value !== index + 1
);
if (!value.length) {
setError("请至少保留一个每周工作日。");
return;
}
setDraft((settings) => ({
...settings,
defaultWorkingDays: value,
}));
setError("");
}}
/>
{day}
</label>
))}
</div>
<div className="xmind-calendar-rule-heading">
<h3>例外</h3>
<button
type="button"
aria-label="添加工作日例外"
onClick={addException}
>
<Plus size={16} />
</button>
</div>
<div className="xmind-calendar-rules">
{[...draft.rules].reverse().map((rule) => (
<div key={rule.id}>
<button
type="button"
aria-label={`编辑工作日例外 ${rule.name}`}
onClick={() => setException(copy(rule))}
>
<strong>{rule.name}</strong>
<span>
{rule.type === "WORKING_DAY" ? "补班" : "休息日"} ·{" "}
{rule.selectedDates.join("、")} ·{" "}
{FREQUENCY_LABELS[rule.frequency]}
{rule.interval && rule.interval > 1
? `(间隔 ${rule.interval})`
: ""}
</span>
</button>
<button
type="button"
aria-label={`删除工作日例外 ${rule.name}`}
onClick={() =>
setDraft((value) => ({
...value,
rules: value.rules.filter(
(item) => item.id !== rule.id
),
}))
}
>
<Trash2 size={14} />
</button>
</div>
))}
{!draft.rules.length ? (
<p>可添加节假日、调休或重复例外。</p>
) : null}
</div>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
{message ? <p role="status">{message}</p> : null}
</div>
</div>
<footer>
<div>
{config.settings ? (
<button
type="button"
className="xmind-danger"
onClick={() => {
if (onSave({ settings: null, skip: false })) onClose();
}}
>
移除项目日历
</button>
) : null}
<button type="button" onClick={() => setManage(true)}>
保存为日历
</button>
</div>
<div>
<button type="button" onClick={onClose}>
取消
</button>
<button
type="button"
className="xmind-primary-button"
onClick={save}
>
保存工作日
</button>
</div>
</footer>
</section>
{exception ? (
<CalendarExceptionEditor
rule={exception}
onSave={(rule) =>
setDraft((value) => ({
...value,
rules: value.rules.some((item) => item.id === rule.id)
? value.rules.map((item) => (item.id === rule.id ? rule : item))
: [...value.rules, rule],
}))
}
onClose={() => setException(null)}
/>
) : null}
</div>
);
}

View File

@ -1,45 +0,0 @@
"use client";
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { searchTopics, type SearchSheet, type TopicSearchResult } from "@/lib/flow-board/topic-search";
export function XmindSearchPanel({ sheets, activeSheetId, onJump, onReplace, onClose }: {
sheets: SearchSheet[];
activeSheetId: string;
onJump: (result: TopicSearchResult) => void;
onReplace: (results: TopicSearchResult[], query: string, replacement: string, caseSensitive: boolean) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [replacement, setReplacement] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [scope, setScope] = useState("current");
const [activeIndex, setActiveIndex] = useState(0);
const input = useRef<HTMLInputElement>(null);
const results = searchTopics(scope === "all" ? sheets : sheets.filter(sheet => sheet.id === activeSheetId),query,caseSensitive);
useEffect(() => { input.current?.focus(); }, []);
useEffect(() => { setActiveIndex(0); }, [query,caseSensitive,scope]);
const navigate = (step: number) => {
if (!results.length) return;
const next = (Math.min(activeIndex,results.length - 1) + step + results.length) % results.length;
setActiveIndex(next); onJump(results[next]);
};
return <aside className="xmind-search-panel" aria-label="查找与替换" data-flow-board-shell-control="true" onKeyDown={event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); }
}}>
<div className="xmind-panel-heading"><span>查找与替换</span><button type="button" aria-label="关闭查找" onClick={onClose}><X size={16} /></button></div>
<div className="xmind-panel-content">
<label className="xmind-search-box"><Search size={15} /><input ref={input} aria-label="查找主题" placeholder="查找主题" value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); navigate(e.shiftKey ? -1 : 1); } }} /></label>
<div className="xmind-search-options"><label><input type="checkbox" checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} /> 区分大小写</label></div>
<select aria-label="查找范围" value={scope} onChange={e => setScope(e.target.value)}><option value="current">当前画布</option><option value="all">所有画布</option></select>
<label className="xmind-search-box"><input aria-label="替换为" placeholder="替换为" value={replacement} onChange={e => setReplacement(e.target.value)} /></label>
<div className="xmind-search-replace-buttons">
<button type="button" disabled={!results.length} onClick={() => onReplace([results[Math.min(activeIndex,results.length - 1)]],query,replacement,caseSensitive)}>替换</button>
<button type="button" disabled={!results.length} onClick={() => onReplace(results,query,replacement,caseSensitive)}>全部替换</button>
</div>
<div className="xmind-search-navigation"><span role="status">{query ? `${results.length} 个匹配主题` : "输入内容开始查找"}</span><button type="button" aria-label="上一个结果" disabled={!results.length} onClick={() => navigate(-1)}><ChevronUp size={16} /></button><button type="button" aria-label="下一个结果" disabled={!results.length} onClick={() => navigate(1)}><ChevronDown size={16} /></button></div>
<div aria-label="查找结果">{results.map((result,index) => <button type="button" key={`${result.sheetId}:${result.id}`} aria-label={`定位 ${result.title}`} className={`xmind-search-result ${index === activeIndex ? "is-active" : ""}`} onClick={() => { setActiveIndex(index); onJump(result); }}><span>{result.title}</span><small>{result.sheetTitle} · {result.occurrences} 处匹配</small></button>)}</div>
</div>
</aside>;
}

View File

@ -1,384 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CalendarDays, X } from "lucide-react";
import type { PlaitElement } from "@plait/core";
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
import {
activeWorkingCalendar,
getProjectCalendar,
taskDueDate,
workingDaysBetween,
} from "@/lib/flow-board/project-calendar";
import {
DAY,
getTopicTask,
newTopicTask,
validateTopicTask,
type TopicTask,
type TaskDependency,
} from "@/lib/flow-board/topic-tasks";
export function XmindTaskEditor({
topic,
elements,
onSave,
onClose,
onGantt,
onCalendar,
}: {
topic: PlaitElement;
elements: PlaitElement[];
onSave: (id: string, task: TopicTask | null) => void;
onClose: () => void;
onGantt: () => void;
onCalendar: () => void;
}) {
const dialogRef = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialogRef.current?.querySelector<HTMLInputElement>("input")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, []);
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
const existing = getTopicTask(topic);
const [draft, setDraft] = useState<TopicTask>(
() => existing || newTopicTask()
);
const [error, setError] = useState("");
const [assigneesDraft, setAssigneesDraft] = useState(
(existing?.assignees || []).join(", ")
);
const [dependency, setDependency] = useState("");
const [dependencyType, setDependencyType] =
useState<TaskDependency["type"]>("FS");
const [lag, setLag] = useState("0");
const source = JSON.stringify(existing);
useEffect(() => {
setDraft(getTopicTask(topic) || newTopicTask());
setAssigneesDraft((getTopicTask(topic)?.assignees || []).join(", "));
setError("");
}, [topic.id, source]);
const tasks = getTopicEntries(elements).filter(
(e) => e.element.id !== topic.id && getTopicTask(e.element)
);
const patch = (p: Partial<TopicTask>) => setDraft((t) => ({ ...t, ...p }));
const withAssignees = () => ({
...draft,
assignees: [
...new Set(
assigneesDraft
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean)
),
],
});
const save = () => {
const task = withAssignees();
const problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
};
return (
<div
className="xmind-task-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
if (e.key === "Tab") {
const controls = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(
"button:not(:disabled),input,select"
) || []
);
const first = controls[0],
last = controls.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
}
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="任务信息"
className="xmind-task-editor"
ref={dialogRef}
>
<header>
<h2>
<CalendarDays size={17} />
任务信息
</h2>
<button type="button" aria-label="关闭任务信息" onClick={onClose}>
<X size={17} />
</button>
</header>
<p className="xmind-task-name">{getTopicTitle(topic)}</p>
<div className="xmind-task-fields">
<label>
开始日期
<input
type="date"
aria-label="任务开始日期"
value={draft.start}
onChange={(e) => {
const start = e.target.value;
patch({
start,
due:
start && draft.duration >= 1
? taskDueDate(start, draft.duration, calendar)
: "",
});
}}
/>
</label>
<label>
截止日期
<input
type="date"
aria-label="任务截止日期"
value={draft.due}
onChange={(e) => {
const due = e.target.value;
patch({
due,
...(draft.start && due && due >= draft.start
? {
duration: Math.max(
1,
workingDaysBetween(draft.start, due, calendar)
),
}
: {}),
});
}}
/>
</label>
<label>
{calendar ? "时长(工作日)" : "时长(天)"}
<input
type="number"
aria-label="任务工期"
min={1}
max={36500}
value={draft.duration}
onChange={(e) => {
const duration = Number(e.target.value);
patch({
duration,
...(draft.start && duration >= 1 && duration <= 36500
? { due: taskDueDate(draft.start, duration, calendar) }
: {}),
});
}}
/>
</label>
<label>
进度(%)
<input
type="number"
aria-label="任务进度"
min={0}
max={100}
value={draft.progress}
onChange={(e) => patch({ progress: Number(e.target.value) })}
/>
</label>
<label>
优先级
<select
aria-label="任务优先级"
value={draft.priority ?? ""}
onChange={(e) =>
patch({
priority:
e.target.value === "" ? null : Number(e.target.value),
})
}
>
<option value="">无</option>
{Array.from({ length: 10 }, (_, i) => (
<option value={i} key={i}>
P{i}
</option>
))}
</select>
</label>
<label>
负责人
<input
aria-label="任务负责人"
placeholder="多人用逗号分隔"
value={assigneesDraft}
onChange={(e) => setAssigneesDraft(e.target.value)}
/>
</label>
</div>
<h3>前置任务</h3>
<div className="xmind-task-dependencies">
{draft.dependencies.map((dep, i) => (
<div key={`${dep.id}-${i}`}>
<span>
{tasks.find((e) => e.element.id === dep.id)?.title ||
"已删除的任务"}{" "}
· {dep.type}
{dep.lag ? ` · ${dep.lag / DAY} 天` : ""}
</span>
<button
type="button"
aria-label={`移除前置任务 ${tasks.find((e) => e.element.id === dep.id)?.title || dep.id}`}
onClick={() =>
patch({
dependencies: draft.dependencies.filter((_, n) => n !== i),
})
}
>
<X size={13} />
</button>
</div>
))}
</div>
<div className="xmind-task-add-dependency">
<select
aria-label="前置任务"
value={dependency}
onChange={(e) => setDependency(e.target.value)}
>
<option value="">选择任务</option>
{tasks.map((e) => (
<option value={e.element.id} key={e.element.id}>
{e.title}
</option>
))}
</select>
<select
aria-label="依赖类型"
value={dependencyType}
onChange={(e) =>
setDependencyType(e.target.value as TaskDependency["type"])
}
>
<option value="FS">完成 → 开始</option>
<option value="SS">开始 → 开始</option>
<option value="FF">完成 → 完成</option>
<option value="SF">开始 → 完成</option>
</select>
<input
type="number"
aria-label="依赖间隔天数"
value={lag}
onChange={(e) => setLag(e.target.value)}
title="间隔天数(负数为提前)"
/>
<button
type="button"
disabled={!dependency}
onClick={() => {
const next = {
...draft,
dependencies: [
...draft.dependencies.filter((d) => d.id !== dependency),
{
id: dependency,
type: dependencyType,
lag: Number(lag) * DAY,
},
],
};
const problem = validateTopicTask(topic.id, next, elements);
if (problem) setError(problem);
else {
setDraft(next);
setDependency("");
setError("");
}
}}
>
添加
</button>
</div>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
<div className="xmind-task-calendar-info">
<span>
{calendar ? `已跳过非工作日 · ${calendar.name}` : "包含非工作日"}
</span>
<button
type="button"
onClick={() => {
const task = withAssignees(),
problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
onCalendar();
}}
>
项目工作日
</button>
</div>
<footer>
{existing ? (
<button
type="button"
className="xmind-danger"
onClick={() => {
onSave(topic.id, null);
onClose();
}}
>
移除任务
</button>
) : (
<button type="button" onClick={onClose}>
取消
</button>
)}
<button
type="button"
onClick={() => {
const task = withAssignees();
const problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
onGantt();
}}
>
查看甘特图
</button>
<button type="button" className="xmind-primary-button" onClick={save}>
保存任务
</button>
</footer>
</section>
</div>
);
}

View File

@ -1,139 +0,0 @@
"use client";
import {
type PlaitBoard,
type PlaitElement,
toHostPointFromViewBoxPoint,
toScreenPointFromHostPoint,
} from "@plait/core";
import { MindElement, getRectangleByNode } from "@plait/mind";
import { FileText } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { getTopicDetails, getTopicTitle } from "@/lib/flow-board/topic-details";
import { getTopicTask } from "@/lib/flow-board/topic-tasks";
export function XmindTopicBadges({
board,
elements,
viewport,
onNotes,
onTask,
}: {
board: PlaitBoard | null;
elements: PlaitElement[];
viewport: unknown;
onNotes: (id: string) => void;
onTask: (id: string) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const [badges, setBadges] = useState<
{
id: string;
title: string;
notes: boolean;
progress: number | null;
left: number;
top: number;
}[]
>([]);
useEffect(() => {
if (!board) return;
const update = () => {
const host = hostRef.current;
if (!host) return;
const origin = host.getBoundingClientRect();
const result: typeof badges = [];
const visit = (children: PlaitElement[]) => {
for (const element of children) {
const notes = Boolean(getTopicDetails(element).notes.trim()),
task = getTopicTask(element);
if (notes || task) {
const node = MindElement.getNode(element as MindElement);
if (node) {
const rect = getRectangleByNode(node);
const point = toScreenPointFromHostPoint(
board,
toHostPointFromViewBoxPoint(board, [
rect.x + rect.width,
rect.y + rect.height / 2,
])
);
result.push({
id: element.id,
title: getTopicTitle(element),
notes,
progress: task?.progress ?? null,
left: point[0] - origin.x + 5,
top: point[1] - origin.y - 9,
});
}
}
if (!element.isCollapsed && element.children) visit(element.children);
}
};
visit(elements);
setBadges(result);
};
const frame = requestAnimationFrame(update);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
};
}, [board, elements, viewport]);
return (
<div ref={hostRef} className="xmind-topic-badges" aria-label="主题内容提示">
{badges.map((b) => (
<div
key={b.id}
className="xmind-topic-badge"
style={{ left: b.left, top: b.top }}
data-flow-board-shell-control="true"
>
{b.progress !== null ? (
<button
type="button"
aria-label={`编辑任务 ${b.title}`}
title={`任务进度 ${b.progress}%`}
onClick={() => onTask(b.id)}
>
<svg
aria-hidden="true"
width={16}
height={16}
viewBox="0 0 16 16"
>
<circle
cx={8}
cy={8}
r={6}
fill="#f8fafc"
stroke="#afb7c4"
strokeWidth={1.4}
/>
<circle
cx={8}
cy={8}
r={6}
fill="none"
stroke="#6b8ee4"
strokeWidth={3}
strokeDasharray={`${(b.progress * 37.7) / 100} 37.7`}
transform="rotate(-90 8 8)"
/>
</svg>
</button>
) : null}
{b.notes ? (
<button
type="button"
aria-label={`查看笔记 ${b.title}`}
title="查看笔记"
onClick={() => onNotes(b.id)}
>
<FileText size={15} />
</button>
) : null}
</div>
))}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More