Compare commits
12 Commits
main
...
codex/vide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9f340a89 | ||
|
|
e8db66c6d5 | ||
|
|
1f3f02e136 | ||
|
|
cb499612f4 | ||
|
|
e18413a4dd | ||
|
|
68f62dae58 | ||
|
|
431ca03fac | ||
|
|
048d50c357 | ||
|
|
f25e78e570 | ||
|
|
41e2db9ca7 | ||
|
|
9b78fbc9f7 | ||
|
|
aa8a3011e4 |
@ -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
20
.gitattributes
vendored
@ -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
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -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/
|
||||||
@ -126,15 +120,7 @@ services/smart-edit/__pycache__/
|
|||||||
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
|
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
|
||||||
.qa/
|
.qa/
|
||||||
.next-video-*/
|
.next-video-*/
|
||||||
.next-tvstar-*/
|
|
||||||
|
|
||||||
services/video-upscale/dist/
|
services/video-upscale/dist/
|
||||||
services/video-upscale/__pycache__/
|
services/video-upscale/__pycache__/
|
||||||
services/video-upscale/vendor/__pycache__/
|
services/video-upscale/vendor/__pycache__/
|
||||||
|
|
||||||
/services/audio-processing/dist/
|
|
||||||
/services/audio-processing/__pycache__/
|
|
||||||
|
|
||||||
# Python release-tool and test bytecode.
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|||||||
@ -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
@ -1,6 +1,6 @@
|
|||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRequestErrorMessage, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
import { getRequestErrorMessage } from "@/lib/request-error";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.errors");
|
const chatApiLogger = createLogger("chat.errors");
|
||||||
@ -20,14 +20,6 @@ export function getChatStreamErrorMessage(error: unknown): string {
|
|||||||
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
|
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
|
||||||
const normalized = rawMessage.toLowerCase();
|
const normalized = rawMessage.toLowerCase();
|
||||||
|
|
||||||
if (isProviderBalanceError(error)) {
|
|
||||||
return "当前模型服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRateLimitError(error)) {
|
|
||||||
return "当前模型服务请求过于频繁,请稍后重试或切换模型。";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalized.includes("token quota is not enough") ||
|
normalized.includes("token quota is not enough") ||
|
||||||
normalized.includes("pre_consume_token_quota_failed") ||
|
normalized.includes("pre_consume_token_quota_failed") ||
|
||||||
@ -55,16 +47,6 @@ function resolveChatApiFailure(error: unknown) {
|
|||||||
const detailMessage = getRequestErrorMessage(error);
|
const detailMessage = getRequestErrorMessage(error);
|
||||||
const normalized = String(detailMessage || "").toLowerCase();
|
const normalized = String(detailMessage || "").toLowerCase();
|
||||||
|
|
||||||
if (isProviderBalanceError(error)) {
|
|
||||||
return {
|
|
||||||
code: "server_error:chat",
|
|
||||||
message: "模型服务商线路余额或额度不足,请切换模型或联系管理员。",
|
|
||||||
status: 503,
|
|
||||||
category: "provider_balance",
|
|
||||||
detailMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalized.includes("payload too large") ||
|
normalized.includes("payload too large") ||
|
||||||
normalized.includes("request entity too large") ||
|
normalized.includes("request entity too large") ||
|
||||||
|
|||||||
@ -4,8 +4,6 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
|||||||
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
|
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
|
||||||
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 { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.imageFailover");
|
const chatApiLogger = createLogger("chat.imageFailover");
|
||||||
|
|
||||||
@ -84,7 +82,6 @@ export function normalizeFailoverErrorMessage(error: unknown): string {
|
|||||||
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
|
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
|
||||||
const msg = errorMessage.toLowerCase();
|
const msg = errorMessage.toLowerCase();
|
||||||
return (
|
return (
|
||||||
isProviderBalanceError(errorMessage) ||
|
|
||||||
msg.includes("model not found") ||
|
msg.includes("model not found") ||
|
||||||
msg.includes("model_not_found") ||
|
msg.includes("model_not_found") ||
|
||||||
msg.includes("no available channel for model") ||
|
msg.includes("no available channel for model") ||
|
||||||
@ -326,9 +323,9 @@ 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)) {
|
||||||
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 || "cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -488,9 +485,9 @@ 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)) {
|
||||||
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 || "cooldown"}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
@ -182,7 +179,6 @@ import { ChatSDKError } from "@/lib/errors";
|
|||||||
import {
|
import {
|
||||||
getFriendlyRateLimitMessage,
|
getFriendlyRateLimitMessage,
|
||||||
getRequestErrorMessage,
|
getRequestErrorMessage,
|
||||||
getUserFacingImageGenerationErrorMessage,
|
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
} from "@/lib/request-error";
|
} from "@/lib/request-error";
|
||||||
import {
|
import {
|
||||||
@ -197,7 +193,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 +332,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 +340,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,
|
||||||
@ -372,10 +364,7 @@ import {
|
|||||||
resolveReplacementReferenceGeometry,
|
resolveReplacementReferenceGeometry,
|
||||||
resolveReplacementScenePlanningRuntime,
|
resolveReplacementScenePlanningRuntime,
|
||||||
} from "./_lib/detail-replacement";
|
} from "./_lib/detail-replacement";
|
||||||
import {
|
import { selectDetailReplacementGenerationInputs } from "@/lib/ai/detail-replacement-inputs";
|
||||||
selectDetailReplacementGenerationInputs,
|
|
||||||
shouldIncludeDetailReplacementModelImage,
|
|
||||||
} from "@/lib/ai/detail-replacement-inputs";
|
|
||||||
import {
|
import {
|
||||||
normalizeAgentReportForDisplay,
|
normalizeAgentReportForDisplay,
|
||||||
sanitizeAgentOutputSettingsSection,
|
sanitizeAgentOutputSettingsSection,
|
||||||
@ -426,14 +415,12 @@ 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;
|
||||||
|
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
|
||||||
|
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
|
||||||
|
|
||||||
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
||||||
const normalizedMarkdown = String(markdown || "").trim();
|
const normalizedMarkdown = String(markdown || "").trim();
|
||||||
@ -448,6 +435,44 @@ 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 || "");
|
||||||
|
if (!message || INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message)) {
|
||||||
|
return "图片生成失败,请稍后重试";
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacementScenePlanShouldIncludeModelImage(scenePlan: any) {
|
||||||
|
if (!scenePlan) return false;
|
||||||
|
if (scenePlan.useModelImage === true) return true;
|
||||||
|
if (
|
||||||
|
scenePlan.peoplePolicy &&
|
||||||
|
scenePlan.peoplePolicy !== "none"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (scenePlan.generationRoute === "people_relation_locked") return true;
|
||||||
|
if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
scenePlan.modelImageReason,
|
||||||
|
scenePlan.routeReason,
|
||||||
|
scenePlan.sceneSummary,
|
||||||
|
scenePlan.cameraSummary,
|
||||||
|
scenePlan.placementSummary,
|
||||||
|
scenePlan.preserveSummary,
|
||||||
|
scenePlan.styleSummary,
|
||||||
|
scenePlan.avoidSummary,
|
||||||
|
scenePlan.generationPrompt,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
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 +930,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 +1224,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 +1344,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 +5687,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,
|
||||||
@ -5934,7 +5940,7 @@ export async function POST(request: Request) {
|
|||||||
const shouldIncludeModelImage =
|
const shouldIncludeModelImage =
|
||||||
detailReplacementStrategy !== "product_only" &&
|
detailReplacementStrategy !== "product_only" &&
|
||||||
Boolean(modelInputImageUrl) &&
|
Boolean(modelInputImageUrl) &&
|
||||||
(shouldIncludeDetailReplacementModelImage(
|
(replacementScenePlanShouldIncludeModelImage(
|
||||||
replacementExecutionPlan.scenePlan,
|
replacementExecutionPlan.scenePlan,
|
||||||
) ||
|
) ||
|
||||||
selectedProductContextImageUrls.length === 0);
|
selectedProductContextImageUrls.length === 0);
|
||||||
@ -6023,7 +6029,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 +6184,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 +6416,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 ||
|
||||||
@ -6830,7 +6833,7 @@ export async function POST(request: Request) {
|
|||||||
!(isDetailReplacementPreset &&
|
!(isDetailReplacementPreset &&
|
||||||
detailReplacementStrategy === "product_only") &&
|
detailReplacementStrategy === "product_only") &&
|
||||||
Boolean(modelInputImageUrl) &&
|
Boolean(modelInputImageUrl) &&
|
||||||
(shouldIncludeDetailReplacementModelImage(
|
(replacementScenePlanShouldIncludeModelImage(
|
||||||
replacementExecutionPlan.scenePlan,
|
replacementExecutionPlan.scenePlan,
|
||||||
) ||
|
) ||
|
||||||
(isDetailReplacementPreset &&
|
(isDetailReplacementPreset &&
|
||||||
@ -6964,7 +6967,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 +7292,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 +7520,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 +7838,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 +8047,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 +8085,6 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
userMessageId: message.id,
|
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
...directImageStatusPayload,
|
...directImageStatusPayload,
|
||||||
},
|
},
|
||||||
@ -8180,7 +8177,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 +8197,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 +8774,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 +8891,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 +9181,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 +9268,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 +9342,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 +9409,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 +9473,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 +9550,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 +9615,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 +9852,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 +9958,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 +10007,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 +10030,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 +10051,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 +10078,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 +10162,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 +10183,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 +10250,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 +10314,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 +10479,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 +10524,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 +10690,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 +10754,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 +11248,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 +11363,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 +11454,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 +11625,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 +11684,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 +12277,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),
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -1,75 +1,79 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { VideoCanvasHome, type VideoCanvasHomeProject } from "@/components/canvas/VideoCanvasHome";
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getSiteSettings } from "@/lib/db/queries";
|
|
||||||
import { canvasProject } from "@/lib/db/schema";
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
type Props = { searchParams?: Promise<Record<string, string | string[] | undefined>> };
|
type VideoCanvasRedirectPageProps = {
|
||||||
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
};
|
||||||
|
|
||||||
function first(value: string | string[] | undefined) {
|
function getSearchParam(
|
||||||
|
params: Record<string, string | string[] | undefined>,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
const value = params[key];
|
||||||
return Array.isArray(value) ? value[0] : value;
|
return Array.isArray(value) ? value[0] : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canvasHref(id: string, incoming: Record<string, string | string[] | undefined>) {
|
function buildVideoCanvasUrl(
|
||||||
const params = new URLSearchParams({ mode: "video", workspaceType: "video" });
|
canvasId: string,
|
||||||
for (const [key, value] of Object.entries(incoming)) {
|
params: Record<string, string | string[] | undefined>
|
||||||
if (["new", "mode", "workspaceType"].includes(key) || value === undefined) continue;
|
) {
|
||||||
for (const item of Array.isArray(value) ? value : [value]) params.append(key, item);
|
const query = new URLSearchParams();
|
||||||
|
query.set("mode", "video");
|
||||||
|
query.set("workspaceType", "video");
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (
|
||||||
|
key === "new" ||
|
||||||
|
key === "mode" ||
|
||||||
|
key === "workspaceType" ||
|
||||||
|
value === undefined
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => query.append(key, item));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
query.set(key, value);
|
||||||
}
|
}
|
||||||
return `/canvas/${id}?${params.toString()}`;
|
|
||||||
|
return `/canvas/${canvasId}?${query.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectPreview(thumbnail: string | null, canvasData: unknown) {
|
async function getLatestVideoCanvasId(userId: string) {
|
||||||
const nodes = canvasData && typeof canvasData === "object" && "nodes" in canvasData
|
const [latestProject] = await db
|
||||||
? (canvasData as { nodes?: unknown }).nodes : null;
|
.select({ id: canvasProject.id })
|
||||||
const candidates = [thumbnail];
|
.from(canvasProject)
|
||||||
if (Array.isArray(nodes)) for (const node of [...nodes].reverse()) {
|
.where(
|
||||||
if (!node || typeof node !== "object") continue;
|
and(
|
||||||
const item = node as { inputType?: string; draftValues?: Record<string, unknown> };
|
eq(canvasProject.userId, userId),
|
||||||
const draft = item.draftValues || {};
|
eq(canvasProject.workspaceType, "video")
|
||||||
candidates.push(
|
)
|
||||||
String(draft.poster || draft.previewImageUrl || draft.thumbnailUrl || draft.resultImage || (item.inputType === "image" ? draft.mediaUrl || (Array.isArray(draft.url) ? draft.url[0] : "") : "") || ""),
|
)
|
||||||
);
|
.orderBy(desc(canvasProject.updatedAt))
|
||||||
}
|
.limit(1);
|
||||||
return candidates.find(value => typeof value === "string" && (value.startsWith("https://") || value.startsWith("/"))) || null;
|
|
||||||
|
return latestProject?.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function VideoCanvasHomePage({ searchParams }: Props) {
|
export default async function VideoCanvasRedirectPage({
|
||||||
const session = await auth();
|
searchParams,
|
||||||
if (!session?.user?.id) redirect("/login?callbackUrl=%2Fvideo-canvas");
|
}: VideoCanvasRedirectPageProps) {
|
||||||
|
|
||||||
const params = (await searchParams) || {};
|
const params = (await searchParams) || {};
|
||||||
if (first(params.new) === "1") {
|
const shouldCreateNew = getSearchParam(params, "new") === "1";
|
||||||
const [created] = await db.insert(canvasProject).values({
|
|
||||||
userId: session.user.id,
|
if (shouldCreateNew) {
|
||||||
title: "未命名视频画布",
|
redirect(buildVideoCanvasUrl(randomUUID(), params));
|
||||||
workspaceType: "video",
|
|
||||||
canvasData: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
|
||||||
}).returning({ id: canvasProject.id });
|
|
||||||
redirect(canvasHref(created.id, params));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows, siteSettings] = await Promise.all([db.select({
|
const session = await auth();
|
||||||
id: canvasProject.id,
|
const latestCanvasId = session?.user?.id
|
||||||
title: canvasProject.title,
|
? await getLatestVideoCanvasId(session.user.id).catch(() => null)
|
||||||
thumbnail: canvasProject.thumbnail,
|
: null;
|
||||||
canvasData: canvasProject.canvasData,
|
|
||||||
createdAt: canvasProject.createdAt,
|
|
||||||
updatedAt: canvasProject.updatedAt,
|
|
||||||
}).from(canvasProject).where(and(
|
|
||||||
eq(canvasProject.userId, session.user.id),
|
|
||||||
eq(canvasProject.workspaceType, "video"),
|
|
||||||
)).orderBy(desc(canvasProject.updatedAt)).limit(25), getSiteSettings()]);
|
|
||||||
|
|
||||||
const projects: VideoCanvasHomeProject[] = rows.slice(0, 24).map(row => ({
|
redirect(buildVideoCanvasUrl(latestCanvasId || randomUUID(), params));
|
||||||
id: row.id,
|
|
||||||
title: row.title,
|
|
||||||
thumbnail: projectPreview(row.thumbnail, row.canvasData),
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return <VideoCanvasHome initialHasMore={rows.length > 24} initialProjects={projects} siteLogo={siteSettings.siteLogo} siteLogoDark={siteSettings.siteLogoDark} siteName={siteSettings.siteName} userName={session.user.name || ""} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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[] = [];
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -1,295 +0,0 @@
|
|||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
|
|
||||||
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
|
|
||||||
import {
|
|
||||||
createCanvasImageTask,
|
|
||||||
enqueueCanvasImageTask,
|
|
||||||
ensureCanvasImageTaskActive,
|
|
||||||
getCanvasImageTaskForUser,
|
|
||||||
} from "@/lib/canvas/image-task-queue";
|
|
||||||
import {
|
|
||||||
checkUserImageQuota,
|
|
||||||
getChatById,
|
|
||||||
getEnabledAiModelsWithProvider,
|
|
||||||
getPointsSystemConfig,
|
|
||||||
getUserById,
|
|
||||||
saveChat,
|
|
||||||
} from "@/lib/db/queries";
|
|
||||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
|
||||||
import { resolveDirectReplacementModelRef } from "@/app/(chat)/api/chat/_lib/detail-replacement";
|
|
||||||
import {
|
|
||||||
buildAgentSlotPrompt,
|
|
||||||
normalizeAgentSupplementPrompt,
|
|
||||||
} from "@/app/(chat)/api/chat/_lib/agent-scene-workflow";
|
|
||||||
import {
|
|
||||||
detailReplacementSubmissionKey,
|
|
||||||
isDetailReplacementBatchRequest,
|
|
||||||
isDetailReplacementUuid,
|
|
||||||
type DetailReplacementBatchReference,
|
|
||||||
type DetailReplacementBatchRequest,
|
|
||||||
type DetailReplacementBatchResult,
|
|
||||||
} from "@/lib/agents/detail-replacement-batch";
|
|
||||||
|
|
||||||
export const maxDuration = 1200;
|
|
||||||
|
|
||||||
function scopedChatId(projectId: string) {
|
|
||||||
const normalized = projectId.toLowerCase().replace(/-/g, "");
|
|
||||||
const scoped = `${normalized.slice(0, 20)}a93e17a60001`;
|
|
||||||
return [scoped.slice(0, 8), scoped.slice(8, 12), scoped.slice(12, 16), scoped.slice(16, 20), scoped.slice(20, 32)].join("-");
|
|
||||||
}
|
|
||||||
|
|
||||||
function taskResponse(task: Awaited<ReturnType<typeof getCanvasImageTaskForUser>>) {
|
|
||||||
const result = task?.result as DetailReplacementBatchResult | null;
|
|
||||||
const request = task?.request as DetailReplacementBatchRequest | null;
|
|
||||||
return {
|
|
||||||
id: task?.id,
|
|
||||||
status: task?.status,
|
|
||||||
attempts: task?.attempts,
|
|
||||||
error: task?.error || null,
|
|
||||||
cards: Array.isArray(result?.cards) ? result.cards : [],
|
|
||||||
currentCardId: result?.currentCardId || null,
|
|
||||||
requestedCardIds: request?.references?.map((item) => item.id) || [],
|
|
||||||
createdAt: task?.createdAt || null,
|
|
||||||
queueStatus: task?.queueStatus || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findActiveBatchId(params: {
|
|
||||||
userId: string;
|
|
||||||
projectId: string;
|
|
||||||
submissionKey: string;
|
|
||||||
}) {
|
|
||||||
const [row] = await db.select({ id: canvasImageTask.id })
|
|
||||||
.from(canvasImageTask)
|
|
||||||
.where(and(
|
|
||||||
eq(canvasImageTask.userId, params.userId),
|
|
||||||
inArray(canvasImageTask.status, ["pending", "running"]),
|
|
||||||
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
|
||||||
sql`${canvasImageTask.request}->>'projectId' = ${params.projectId}`,
|
|
||||||
sql`${canvasImageTask.request}->>'submissionKey' = ${params.submissionKey}`,
|
|
||||||
))
|
|
||||||
.orderBy(desc(canvasImageTask.createdAt)).limit(1);
|
|
||||||
return row?.id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUniqueViolation(error: unknown) {
|
|
||||||
for (let cursor: unknown = error; cursor && typeof cursor === "object"; ) {
|
|
||||||
if ((cursor as { code?: unknown }).code === "23505") return true;
|
|
||||||
cursor = (cursor as { cause?: unknown }).cause;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
const params = new URL(request.url).searchParams;
|
|
||||||
const projectId = String(params.get("projectId") || "").trim();
|
|
||||||
const taskId = String(params.get("taskId") || "").trim();
|
|
||||||
if (!isDetailReplacementUuid(projectId)) {
|
|
||||||
return NextResponse.json({ error: "项目编号无效" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const [project] = await db.select({ id: canvasProject.id }).from(canvasProject).where(and(
|
|
||||||
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
|
||||||
)).limit(1);
|
|
||||||
if (!project) return NextResponse.json({ error: "项目不存在" }, { status: 404 });
|
|
||||||
|
|
||||||
const [candidate] = await db.select({ id: canvasImageTask.id, request: canvasImageTask.request })
|
|
||||||
.from(canvasImageTask)
|
|
||||||
.where(and(
|
|
||||||
eq(canvasImageTask.userId, session.user.id),
|
|
||||||
...(isDetailReplacementUuid(taskId) ? [eq(canvasImageTask.id, taskId)] : []),
|
|
||||||
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
|
|
||||||
sql`${canvasImageTask.request}->>'projectId' = ${projectId}`,
|
|
||||||
))
|
|
||||||
// A batch that is still working matters more than a newer finished one, so the
|
|
||||||
// workspace keeps showing live progress after a rerun of a single card.
|
|
||||||
.orderBy(
|
|
||||||
sql`case when ${canvasImageTask.status} in ('pending', 'running') then 0 else 1 end`,
|
|
||||||
desc(canvasImageTask.createdAt),
|
|
||||||
).limit(1);
|
|
||||||
if (!candidate || !isDetailReplacementBatchRequest(candidate.request)) {
|
|
||||||
return NextResponse.json({ task: null });
|
|
||||||
}
|
|
||||||
let task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
|
||||||
if (task && (task.status === "failed" ||
|
|
||||||
(task.status === "running" && Date.now() - task.updatedAt.getTime() > 90_000))) {
|
|
||||||
await ensureCanvasImageTaskActive({ taskId: task.id, userId: session.user.id });
|
|
||||||
task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ task: taskResponse(task) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
const body = await request.json();
|
|
||||||
const projectId = String(body.projectId || "").trim();
|
|
||||||
const taskId = String(body.requestId || "").trim();
|
|
||||||
if (!isDetailReplacementUuid(projectId) || !isDetailReplacementUuid(taskId)) {
|
|
||||||
return NextResponse.json({ error: "任务编号或项目编号无效" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const [project] = await db.select().from(canvasProject).where(and(
|
|
||||||
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
|
|
||||||
)).limit(1);
|
|
||||||
if (!project || !isAgentProjectRow(project)) {
|
|
||||||
return NextResponse.json({ error: "风格化项目不存在" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = extractAgentProjectStateFromCanvasData(project.canvasData);
|
|
||||||
const knownReferences = new Map(
|
|
||||||
(state.workflowState.agentModeState?.detailReplacement?.references || [])
|
|
||||||
.map((item) => [String(item.id), String(item.imageUrl)]),
|
|
||||||
);
|
|
||||||
const requested = Array.isArray(body.references) ? body.references : [];
|
|
||||||
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
|
|
||||||
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
|
||||||
return {
|
|
||||||
id: String(item.id || "").slice(0, 128),
|
|
||||||
label: String(item.label || "详情参考图").slice(0, 255),
|
|
||||||
imageUrl: String(item.imageUrl || "").trim(),
|
|
||||||
candidateId: String(item.candidateId || "").slice(0, 128) || null,
|
|
||||||
passthrough: item.passthrough === true,
|
|
||||||
rerunReferenceImages: Array.isArray(item.rerunReferenceImages)
|
|
||||||
? item.rerunReferenceImages.slice(0, 6).map((entry: { url?: string }) => ({ url: String(entry?.url || "") }))
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
references.length === 0 || references.length > 40 ||
|
|
||||||
new Set(references.map((item) => item.id)).size !== references.length ||
|
|
||||||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: "详情参考图尚未保存或已发生变化,请刷新后重试" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const productImageUrls = Array.isArray(body.productImageUrls)
|
|
||||||
? Array.from(new Set(body.productImageUrls.map((url: unknown) => String(url || "").trim())
|
|
||||||
.filter((url: string) => /^https?:\/\//i.test(url)))).slice(0, 16) as string[]
|
|
||||||
: [];
|
|
||||||
if (productImageUrls.length === 0 && !references.every((item) => item.passthrough)) {
|
|
||||||
return NextResponse.json({ error: "请先在输入图区选择至少一张产品图" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const strategy = body.replacementStrategy === "product_only" ? "product_only" : "scene_replace";
|
|
||||||
const prompt = String(body.prompt || "").trim().slice(0, 24_000);
|
|
||||||
if (strategy !== "product_only" && !prompt) {
|
|
||||||
return NextResponse.json({ error: "请先配置风格化提示词" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const models = await getEnabledAiModelsWithProvider();
|
|
||||||
const model = await resolveDirectReplacementModelRef({
|
|
||||||
selectedChatModel: String(body.model || "auto"),
|
|
||||||
finalModelId: String(body.model || "auto"),
|
|
||||||
models,
|
|
||||||
});
|
|
||||||
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
|
|
||||||
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const quota = await checkUserImageQuota(session.user.id);
|
|
||||||
if (!quota.allowed) {
|
|
||||||
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });
|
|
||||||
}
|
|
||||||
const requestedImageCount = references.filter((item) => !item.passthrough).length;
|
|
||||||
if (quota.limit != null && quota.used + requestedImageCount > quota.limit) {
|
|
||||||
return NextResponse.json({ error: `本月剩余生图配额不足 ${requestedImageCount} 张` }, { status: 429 });
|
|
||||||
}
|
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
|
||||||
if (pointsConfig.enabled && pointsConfig.blockOnInsufficient !== false) {
|
|
||||||
const selectedModel = models.find((entry) => entry.model.id === model)?.model;
|
|
||||||
const unitCost = getModelGenerationCreditCost(
|
|
||||||
selectedModel as Parameters<typeof getModelGenerationCreditCost>[0], "image", {
|
|
||||||
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
|
||||||
});
|
|
||||||
const user = await getUserById({ id: session.user.id });
|
|
||||||
const expectedCost = unitCost * requestedImageCount;
|
|
||||||
if (!user?.isHighVip && expectedCost > Number(user?.pointsBalance || 0)) {
|
|
||||||
return NextResponse.json({ error: `积分不足,本批次预计消耗 ${expectedCost} 积分` }, { status: 402 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const chatId = scopedChatId(projectId);
|
|
||||||
const existingChat = await getChatById({ id: chatId });
|
|
||||||
if (existingChat && existingChat.userId !== session.user.id) {
|
|
||||||
return NextResponse.json({ error: "无权访问项目对话" }, { status: 403 });
|
|
||||||
}
|
|
||||||
if (!existingChat) await saveChat({
|
|
||||||
id: chatId, userId: session.user.id, title: project.title || "详情替换", visibility: "private",
|
|
||||||
});
|
|
||||||
const taskRequest: DetailReplacementBatchRequest = {
|
|
||||||
taskKind: "agent-detail-replacement-batch",
|
|
||||||
projectId,
|
|
||||||
chatId,
|
|
||||||
model,
|
|
||||||
prompt: strategy === "product_only"
|
|
||||||
? "仅替换参考详情图中的产品主体"
|
|
||||||
: buildAgentSlotPrompt({
|
|
||||||
workflowPrompt: prompt,
|
|
||||||
slotPrompt: "",
|
|
||||||
userSupplementPrompt: normalizeAgentSupplementPrompt(
|
|
||||||
String(body.userPrompt || ""), "详情参考图替换",
|
|
||||||
),
|
|
||||||
includeSlotTitle: false,
|
|
||||||
modeType: "detail_replacement",
|
|
||||||
}),
|
|
||||||
userPrompt: String(body.userPrompt || "").slice(0, 12_000),
|
|
||||||
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
|
|
||||||
replacementStrategy: strategy,
|
|
||||||
productImageUrls,
|
|
||||||
modelImageUrl: /^https?:\/\//i.test(String(body.modelImageUrl || ""))
|
|
||||||
? String(body.modelImageUrl) : null,
|
|
||||||
references,
|
|
||||||
};
|
|
||||||
const submissionKey = detailReplacementSubmissionKey(taskRequest);
|
|
||||||
taskRequest.submissionKey = submissionKey;
|
|
||||||
|
|
||||||
const duplicateId = await findActiveBatchId({
|
|
||||||
userId: session.user.id, projectId, submissionKey,
|
|
||||||
});
|
|
||||||
if (duplicateId) {
|
|
||||||
await ensureCanvasImageTaskActive({ taskId: duplicateId, userId: session.user.id });
|
|
||||||
const duplicate = await getCanvasImageTaskForUser({ taskId: duplicateId, userId: session.user.id });
|
|
||||||
return NextResponse.json({ task: taskResponse(duplicate) });
|
|
||||||
}
|
|
||||||
|
|
||||||
let task: Awaited<ReturnType<typeof createCanvasImageTask>>;
|
|
||||||
try {
|
|
||||||
task = await createCanvasImageTask({
|
|
||||||
taskId, userId: session.user.id, request: taskRequest as unknown as Record<string, unknown>,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (!isUniqueViolation(error)) throw error;
|
|
||||||
// Two identical submissions raced past the lookup above; the database
|
|
||||||
// unique index kept one of them, so answer with that batch.
|
|
||||||
const racedId = await findActiveBatchId({
|
|
||||||
userId: session.user.id, projectId, submissionKey,
|
|
||||||
});
|
|
||||||
if (!racedId) throw error;
|
|
||||||
const raced = await getCanvasImageTaskForUser({ taskId: racedId, userId: session.user.id });
|
|
||||||
return NextResponse.json({ task: taskResponse(raced) });
|
|
||||||
}
|
|
||||||
const existingTask = await getCanvasImageTaskForUser({ taskId: task.id, userId: session.user.id });
|
|
||||||
const storedRequest = existingTask?.request as DetailReplacementBatchRequest | null;
|
|
||||||
if (
|
|
||||||
!isDetailReplacementBatchRequest(storedRequest) ||
|
|
||||||
storedRequest.projectId !== projectId ||
|
|
||||||
(storedRequest.submissionKey && storedRequest.submissionKey !== submissionKey)
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: "任务编号已被其他请求使用" }, { status: 409 });
|
|
||||||
}
|
|
||||||
await db.insert(message).values({
|
|
||||||
id: taskId,
|
|
||||||
chatId,
|
|
||||||
role: "user",
|
|
||||||
parts: [{ type: "text", text: taskRequest.userPrompt || `批量替换 ${references.length} 张详情图` }],
|
|
||||||
attachments: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
}).onConflictDoNothing({ target: message.id });
|
|
||||||
await enqueueCanvasImageTask(task.id);
|
|
||||||
const current = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
|
|
||||||
return NextResponse.json({ task: taskResponse(current) });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Detail Replacement Batch] Failed to start", error);
|
|
||||||
return NextResponse.json({ error: error instanceof Error ? error.message : "提交详情替换任务失败" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
import { and, desc, eq, lte } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { tvstarSession } from "@/lib/db/schema";
|
|
||||||
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
|
|
||||||
import { tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
async function authorize(projectId: string) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
|
|
||||||
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
|
|
||||||
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
|
|
||||||
}
|
|
||||||
return { userId: session.user.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await params;
|
|
||||||
const access = await authorize(id);
|
|
||||||
if (access.error) return access.error;
|
|
||||||
const rows = await db.select({ snapshot: tvstarSession.snapshot, shareToken: tvstarSession.shareToken })
|
|
||||||
.from(tvstarSession)
|
|
||||||
.where(and(eq(tvstarSession.userId, access.userId!), eq(tvstarSession.projectId, id)))
|
|
||||||
.orderBy(desc(tvstarSession.clientUpdatedAt)).limit(20);
|
|
||||||
return NextResponse.json({ sessions: rows.map(({ snapshot, shareToken }) => ({
|
|
||||||
...(snapshot as Record<string, unknown>), shareToken: shareToken || undefined,
|
|
||||||
})) }, { headers: { "Cache-Control": "no-store" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await params;
|
|
||||||
const access = await authorize(id);
|
|
||||||
if (access.error) return access.error;
|
|
||||||
const raw = await request.text();
|
|
||||||
if (raw.length > 900_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
|
|
||||||
let body: unknown;
|
|
||||||
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
|
|
||||||
const parsed = z.object({ sessions: z.array(tvstarSessionSchema).max(20) }).safeParse(body);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
|
|
||||||
for (const session of parsed.data.sessions) {
|
|
||||||
const clientUpdatedAt = new Date(Math.min(session.updatedAt, Date.now() + 60_000));
|
|
||||||
await db.insert(tvstarSession).values({
|
|
||||||
id: session.id, projectId: id, userId: access.userId!, title: session.title,
|
|
||||||
snapshot: session, clientUpdatedAt,
|
|
||||||
}).onConflictDoNothing({ target: tvstarSession.id });
|
|
||||||
await db.update(tvstarSession).set({ title: session.title, snapshot: session, clientUpdatedAt, updatedAt: new Date() })
|
|
||||||
.where(and(
|
|
||||||
eq(tvstarSession.id, session.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!),
|
|
||||||
lte(tvstarSession.clientUpdatedAt, clientUpdatedAt),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { tvstarSession } from "@/lib/db/schema";
|
|
||||||
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
|
|
||||||
import { publicTVstarSnapshot, tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
async function owner(projectId: string) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
|
|
||||||
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
|
|
||||||
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
|
|
||||||
}
|
|
||||||
return { userId: session.user.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await params;
|
|
||||||
const access = await owner(id);
|
|
||||||
if (access.error) return access.error;
|
|
||||||
const raw = await request.text();
|
|
||||||
if (raw.length > 300_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
|
|
||||||
let body: unknown;
|
|
||||||
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
|
|
||||||
const parsed = z.object({ session: tvstarSessionSchema }).safeParse(body);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
|
|
||||||
const current = parsed.data.session;
|
|
||||||
const condition = and(eq(tvstarSession.id, current.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!));
|
|
||||||
await db.insert(tvstarSession).values({
|
|
||||||
id: current.id, projectId: id, userId: access.userId!, title: current.title,
|
|
||||||
snapshot: current, clientUpdatedAt: new Date(Math.min(current.updatedAt, Date.now() + 60_000)),
|
|
||||||
}).onConflictDoNothing({ target: tvstarSession.id });
|
|
||||||
const existing = (await db.select({ shareToken: tvstarSession.shareToken }).from(tvstarSession).where(condition).limit(1))[0];
|
|
||||||
if (!existing) return NextResponse.json({ error: "对话不存在" }, { status: 404 });
|
|
||||||
const token = existing.shareToken || randomBytes(24).toString("base64url");
|
|
||||||
if (!existing.shareToken) {
|
|
||||||
await db.update(tvstarSession).set({
|
|
||||||
shareToken: token, sharedSnapshot: publicTVstarSnapshot(current), sharedAt: new Date(), updatedAt: new Date(),
|
|
||||||
}).where(condition);
|
|
||||||
}
|
|
||||||
return NextResponse.json({ url: `/tvstar/share/${token}`, token });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await params;
|
|
||||||
const access = await owner(id);
|
|
||||||
if (access.error) return access.error;
|
|
||||||
const sessionId = new URL(request.url).searchParams.get("sessionId") || "";
|
|
||||||
if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "对话 ID 无效" }, { status: 400 });
|
|
||||||
await db.update(tvstarSession).set({ shareToken: null, sharedSnapshot: null, sharedAt: null, updatedAt: new Date() })
|
|
||||||
.where(and(eq(tvstarSession.id, sessionId), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!)));
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@ -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" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,492 +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,
|
|
||||||
priorCalls: snapshot.calls,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
|
||||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
|
||||||
import { ffmpeg, mediaSafety, probeMedia } from "@/lib/video-canvas/smart-edit/local";
|
|
||||||
import {
|
|
||||||
remoteAudio,
|
|
||||||
remoteAudioConfig,
|
|
||||||
} from "@/lib/video-canvas/smart-edit/remote-audio";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 600;
|
|
||||||
|
|
||||||
const running = new Set<string>();
|
|
||||||
const allowedStems = ["vocals", "background"] as const;
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
const userId = session?.user?.id;
|
|
||||||
if (!userId)
|
|
||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
||||||
const body = (await request.json().catch(() => null)) as {
|
|
||||||
url?: unknown;
|
|
||||||
stem?: unknown;
|
|
||||||
} | null;
|
|
||||||
const url = typeof body?.url === "string" ? body.url.trim() : "";
|
|
||||||
const stem = typeof body?.stem === "string" ? body.stem : "";
|
|
||||||
if (!/^https?:\/\//i.test(url) || !allowedStems.includes(stem as (typeof allowedStems)[number]))
|
|
||||||
return NextResponse.json({ success: false, error: "参数不完整" }, { status: 400 });
|
|
||||||
if (!remoteAudioConfig())
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "音频分离服务未配置,请联系管理员" },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
const lockKey = `${userId}:${url}:${stem}`;
|
|
||||||
if (running.has(lockKey) || running.size >= 3)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "已有分离任务在进行,请稍候" },
|
|
||||||
{ status: 429 }
|
|
||||||
);
|
|
||||||
running.add(lockKey);
|
|
||||||
let dir = "";
|
|
||||||
try {
|
|
||||||
dir = await mkdtemp(path.join(tmpdir(), "canvas-separate-"));
|
|
||||||
const input = path.join(dir, "input");
|
|
||||||
const response = await fetch(url, {
|
|
||||||
signal: AbortSignal.timeout(180000),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("素材下载失败,请确认素材仍可访问");
|
|
||||||
const bytes = Buffer.from(await response.arrayBuffer());
|
|
||||||
if (!bytes.length || bytes.length > 150 * 1024 * 1024)
|
|
||||||
throw new Error("素材必须非空且不超过 125 MiB");
|
|
||||||
await writeFile(input, bytes);
|
|
||||||
const info = await probeMedia(input, 600);
|
|
||||||
if (!info.hasAudio) throw new Error("当前视频没有音轨");
|
|
||||||
const inputAudio = path.join(dir, "separation-input.wav");
|
|
||||||
const durationSeconds = Math.round(info.duration / 1e6);
|
|
||||||
await ffmpeg(
|
|
||||||
[
|
|
||||||
...mediaSafety,
|
|
||||||
"-i",
|
|
||||||
input,
|
|
||||||
"-vn",
|
|
||||||
"-t",
|
|
||||||
String(durationSeconds),
|
|
||||||
"-ac",
|
|
||||||
"2",
|
|
||||||
"-ar",
|
|
||||||
"44100",
|
|
||||||
"-c:a",
|
|
||||||
"pcm_f32le",
|
|
||||||
inputAudio,
|
|
||||||
],
|
|
||||||
undefined,
|
|
||||||
240000
|
|
||||||
);
|
|
||||||
const requestKey = createHash("sha256")
|
|
||||||
.update(`${url}:${stem}:${(await stat(inputAudio)).size}`)
|
|
||||||
.digest("hex");
|
|
||||||
await remoteAudio("separate", inputAudio, userId, requestKey, dir);
|
|
||||||
const stemFile = path.join(dir, `${stem}.wav`);
|
|
||||||
const wav = await readFile(stemFile);
|
|
||||||
if (wav.length < 1024) throw new Error("分离结果异常,请稍后重试");
|
|
||||||
const uploaded = await uploadFile(
|
|
||||||
`canvas-separate-${randomUUID()}.wav`,
|
|
||||||
await new Blob([wav]).arrayBuffer()
|
|
||||||
);
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
url: uploaded.url,
|
|
||||||
duration: Math.round(info.duration / 1e6),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "人声分离失败,原音轨已保留",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
running.delete(lockKey);
|
|
||||||
if (dir) await rm(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 = {
|
||||||
const session = await auth();
|
imageUrl?: string;
|
||||||
if (!session?.user?.id) {
|
cropRect?: {
|
||||||
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
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();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return executeBoxCutoutPOST(request, session.user.id);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
let cursor = 0;
|
||||||
// Stage each image on disk, not all PNG buffers in the web process heap.
|
const workerCount = Math.min(MAX_CONCURRENCY, task.files.length);
|
||||||
// A restart resumes from these files using the current owner's session.
|
|
||||||
let cursor = 0;
|
await Promise.all(
|
||||||
let writes = Promise.resolve();
|
Array.from({ length: workerCount }, async () => {
|
||||||
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
|
while (true) {
|
||||||
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
|
const index = cursor;
|
||||||
while (cursor < task!.files.length) {
|
cursor += 1;
|
||||||
const index = cursor++; const file = task!.files[index];
|
if (index >= task.files.length) break;
|
||||||
const path = join(zipTaskPath(id), `${index}.image`);
|
|
||||||
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
|
const file = task.files[index]!;
|
||||||
delete file.prepared; delete file.filename;
|
results[index] = await fetchCanvasDownloadAsset({
|
||||||
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
|
file,
|
||||||
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
|
index,
|
||||||
if (result.ok) {
|
requestOrigin,
|
||||||
await assertZipDiskSpace(result.bytes.length);
|
cookie,
|
||||||
await writeFile(path, result.bytes, { mode: 0o600 });
|
});
|
||||||
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
|
|
||||||
} else file.error = result.error;
|
const result = results[index];
|
||||||
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
|
task.processedCount += 1;
|
||||||
task!.successCount = task!.files.filter(f => f.prepared).length;
|
if (result?.ok) {
|
||||||
task!.failedCount = task!.files.filter(f => f.error).length;
|
task.successCount += 1;
|
||||||
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
|
} else {
|
||||||
task!.updatedAt = Date.now(); await persist();
|
task.failedCount += 1;
|
||||||
|
}
|
||||||
|
task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`;
|
||||||
|
task.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
}));
|
})
|
||||||
const rejected = results.find(result => result.status === "rejected");
|
);
|
||||||
if (rejected?.status === "rejected") throw rejected.reason;
|
|
||||||
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
|
const failures: Array<{ index: number; url: string; name?: string; error: string }> = [];
|
||||||
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
|
for (const [index, item] of results.entries()) {
|
||||||
for (const file of task.files) if (file.prepared && file.filename)
|
if (!item || !item.ok) {
|
||||||
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
|
failures.push({
|
||||||
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
|
index: index + 1,
|
||||||
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
|
url: item?.ok === false ? item.url : task.files[index]?.url || "",
|
||||||
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
|
name: item?.ok === false ? item.name : task.files[index]?.name,
|
||||||
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
|
error: item?.ok === false ? item.error : "未知错误",
|
||||||
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
|
});
|
||||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
continue;
|
||||||
}
|
|
||||||
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,
|
|
||||||
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
|
|
||||||
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + PREPARATION_TTL_MS };
|
|
||||||
await saveZipTask(task);
|
|
||||||
} else if ((task.status === "failed" || task.status === "completed" && task.cdnObject && !task.cdnUrl) && body.retry === true) {
|
|
||||||
task.status = "pending"; task.error = undefined;
|
|
||||||
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;
|
folder.file(item.filename, item.bytes);
|
||||||
await saveZipTask(task);
|
|
||||||
}
|
}
|
||||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
|
||||||
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
|
if (failures.length > 0) {
|
||||||
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
|
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) {
|
||||||
if (request.nextUrl.searchParams.get("download") === "1") {
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 });
|
|
||||||
if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1")
|
|
||||||
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 NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
} catch {
|
const requestOrigin = getRequestOrigin(request);
|
||||||
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
|
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 (task.status !== "completed" || !task.zipBuffer) {
|
||||||
|
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
|
||||||
|
}
|
||||||
|
return new NextResponse(task.zipBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,30 +33,15 @@ export async function POST(request: Request) {
|
|||||||
const input = join(dir, "input");
|
const input = join(dir, "input");
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
await writeFile(input, Buffer.from(buffer));
|
await writeFile(input, Buffer.from(buffer));
|
||||||
let stdout: string;
|
const { stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm,mp3,wav,ogg,flac", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 });
|
||||||
try {
|
|
||||||
({ stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm,mp3,wav,ogg,flac", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 }));
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
||||||
return NextResponse.json({ success: false, error: "服务器缺少 ffprobe 视频解析组件,请联系管理员安装 FFmpeg" }, { status: 503 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ success: false, error: "视频文件无法解析,请确认素材完整且格式受支持" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const info = JSON.parse(stdout);
|
const info = JSON.parse(stdout);
|
||||||
const kind = file.type.startsWith("video/") ? "video" : "audio";
|
const kind = file.type.startsWith("video/") ? "video" : "audio";
|
||||||
const stream = info.streams?.find((item: { codec_type: string }) => item.codec_type === kind);
|
const stream = info.streams?.find((item: { codec_type: string }) => item.codec_type === kind);
|
||||||
const duration = Number(info.format?.duration);
|
const duration = Number(info.format?.duration);
|
||||||
if (!stream || !Number.isFinite(duration) || duration <= 0 || duration > 600) return NextResponse.json({ success: false, error: "请上传内容完整、10 分钟以内的音视频" }, { status: 400 });
|
if (!stream || !Number.isFinite(duration) || duration <= 0 || duration > 600) return NextResponse.json({ success: false, error: "请上传内容完整、10 分钟以内的音视频" }, { status: 400 });
|
||||||
let uploaded;
|
const uploaded = await uploadFile(`canvas-${randomUUID()}.${formats[file.type]}`, buffer);
|
||||||
try {
|
|
||||||
uploaded = await uploadFile(`canvas-${randomUUID()}.${formats[file.type]}`, buffer);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Canvas media upload] Storage unavailable:", error);
|
|
||||||
return NextResponse.json({ success: false, error: "媒体存储暂不可用,请稍后重试" }, { status: 503 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ success: true, data: { url: uploaded.url, duration, width: stream.width, height: stream.height } });
|
return NextResponse.json({ success: true, data: { url: uploaded.url, duration, width: stream.width, height: stream.height } });
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("[Canvas media upload] Failed:", error);
|
|
||||||
return NextResponse.json({ success: false, error: "素材上传失败,请确认文件完整或稍后重试" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "素材上传失败,请确认文件完整或稍后重试" }, { status: 400 });
|
||||||
} finally {
|
} finally {
|
||||||
running.delete(session.user.id);
|
running.delete(session.user.id);
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { remoteAudioCapabilities } from "@/lib/video-canvas/smart-edit/remote-audio";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
@ -9,8 +8,7 @@ import { isCanvasVisionModel } from "@/lib/video-canvas/model-matching";
|
|||||||
import { remoteRenderConfig } from "@/lib/video-canvas/smart-edit/remote-render";
|
import { remoteRenderConfig } from "@/lib/video-canvas/smart-edit/remote-render";
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id)
|
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
try {
|
try {
|
||||||
let capabilities = {
|
let capabilities = {
|
||||||
separation: false,
|
separation: false,
|
||||||
@ -24,12 +22,10 @@ export async function GET() {
|
|||||||
const { stdout } = await promisify(execFile)(
|
const { stdout } = await promisify(execFile)(
|
||||||
process.env.SMART_EDIT_PYTHON || "python3",
|
process.env.SMART_EDIT_PYTHON || "python3",
|
||||||
[path.resolve("services/smart-edit/analyze.py"), "--check"],
|
[path.resolve("services/smart-edit/analyze.py"), "--check"],
|
||||||
{ timeout: 10000, maxBuffer: 65536 }
|
{ timeout: 10000, maxBuffer: 65536 },
|
||||||
);
|
);
|
||||||
capabilities = JSON.parse(stdout);
|
capabilities = JSON.parse(stdout);
|
||||||
} catch {}
|
} catch {}
|
||||||
const audio = await remoteAudioCapabilities();
|
|
||||||
if (audio) capabilities = { ...capabilities, ...audio };
|
|
||||||
let renderAvailable = capabilities.ffmpeg;
|
let renderAvailable = capabilities.ffmpeg;
|
||||||
const remote = remoteRenderConfig();
|
const remote = remoteRenderConfig();
|
||||||
if (remote) {
|
if (remote) {
|
||||||
@ -40,29 +36,21 @@ export async function GET() {
|
|||||||
redirect: "error",
|
redirect: "error",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
renderAvailable =
|
renderAvailable = response.ok && (await response.json()).status === "ok";
|
||||||
response.ok && (await response.json()).status === "ok";
|
|
||||||
} catch {
|
} catch {
|
||||||
renderAvailable = false;
|
renderAvailable = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const models = (await getEnabledAiModelsWithProvider(false))
|
const models = (await getEnabledAiModelsWithProvider(false))
|
||||||
.filter((item) =>
|
.filter((item) =>
|
||||||
isCanvasVisionModel({ ...item.model, providerType: item.provider.type })
|
isCanvasVisionModel({ ...item.model, providerType: item.provider.type }),
|
||||||
)
|
)
|
||||||
.map(({ model }) => ({
|
.map(({ model }) => ({ id: model.id, name: model.displayName, modelId: model.modelId }));
|
||||||
id: model.id,
|
|
||||||
name: model.displayName,
|
|
||||||
modelId: model.modelId,
|
|
||||||
}));
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ capabilities: { ...capabilities, render: renderAvailable }, models },
|
{ capabilities: { ...capabilities, render: renderAvailable }, models },
|
||||||
{ headers: { "Cache-Control": "no-store" } }
|
{ headers: { "Cache-Control": "no-store" } },
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "剪辑服务配置读取失败" }, { status: 503 });
|
||||||
{ error: "剪辑服务配置读取失败" },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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([
|
let upstreamResponse: Response;
|
||||||
request.signal,
|
try {
|
||||||
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
upstreamResponse = await fetch(target.toString(), {
|
||||||
]);
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
const upstreamFailure = (error: unknown) => {
|
headers: {
|
||||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
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;
|
|
||||||
try {
|
|
||||||
upstreamResponse = await fetch(target.toString(), {
|
|
||||||
signal: fetchSignal,
|
|
||||||
headers: {
|
|
||||||
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return upstreamFailure(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!upstreamResponse.ok) {
|
|
||||||
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ImageProxy] Upstream fetch failed:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!upstreamResponse.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
||||||
|
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||||
|
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
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");
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 折优惠",
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import JSZip from "jszip";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const packageFiles = [
|
|
||||||
"package.json", "package-lock.json", "api-client.mjs", "cli.mjs", "mcp-server.mjs",
|
|
||||||
"yedir-canvas/SKILL.md", "yedir-canvas/agents/openai.yaml",
|
|
||||||
"yedir-canvas/references/operation-protocol.md",
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id || session.user.type === "guest") {
|
|
||||||
return NextResponse.json({ error: "请先登录正式账号" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const zip = new JSZip();
|
|
||||||
const base = path.join(process.cwd(), "integrations", "openclaw");
|
|
||||||
try {
|
|
||||||
await Promise.all(packageFiles.map(async file => {
|
|
||||||
zip.file(`tvstar-mcp/${file}`, await readFile(path.join(base, file)));
|
|
||||||
}));
|
|
||||||
const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
|
||||||
return new NextResponse(archive, { headers: {
|
|
||||||
"Content-Type": "application/zip",
|
|
||||||
"Content-Disposition": 'attachment; filename="tvstar-mcp.zip"',
|
|
||||||
"Cache-Control": "private, no-store",
|
|
||||||
} });
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "插件安装包暂不可用" }, { status: 503 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
307
app/globals.css
307
app/globals.css
@ -408,15 +408,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */
|
/* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */
|
||||||
.video-canvas-workspace {
|
|
||||||
/* Match the compact typography used by the reference canvas. */
|
|
||||||
--canvas-font-caption: 12px;
|
|
||||||
--canvas-font-body: 13px;
|
|
||||||
--canvas-font-heading: 14px;
|
|
||||||
font-size: var(--canvas-font-body);
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-workspace {
|
.settings-workspace {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
@ -519,301 +510,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%); }
|
|
||||||
|
|||||||
@ -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,33 +94,18 @@ 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);
|
||||||
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
|
return true;
|
||||||
var recoveryNoticeShown = false;
|
} catch (e) {
|
||||||
|
return 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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var RECOVERY_KEY = 'chunk-reload-attempted-v2';
|
||||||
|
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
|
||||||
|
|
||||||
function shouldAutoRecover(message) {
|
function shouldAutoRecover(message) {
|
||||||
if (!message) return false;
|
if (!message) return false;
|
||||||
var normalized = String(message).toLowerCase();
|
var normalized = String(message).toLowerCase();
|
||||||
@ -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>
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
type KeyItem = { id: string; name: string; tokenPrefix: string; scopes: string[]; revokedAt: string | null; expiresAt: string | null };
|
|
||||||
|
|
||||||
export default function TVstarPluginPage() {
|
|
||||||
const [keys, setKeys] = useState<KeyItem[]>([]);
|
|
||||||
const [createdToken, setCreatedToken] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [origin, setOrigin] = useState("https://ai.yydd.ai");
|
|
||||||
useEffect(() => { setOrigin(window.location.origin); }, []);
|
|
||||||
const loadKeys = useCallback(async () => {
|
|
||||||
const response = await fetch("/api/user/access-keys", { cache: "no-store" });
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || "读取密钥失败");
|
|
||||||
setKeys(Array.isArray(data.data) ? data.data : []);
|
|
||||||
}, []);
|
|
||||||
useEffect(() => { void loadKeys().catch(error => toast.error(error.message)); }, [loadKeys]);
|
|
||||||
|
|
||||||
const createKey = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/user/access-keys", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: "TVstar MCP", scopes: ["canvas:read", "canvas:write"], expiresInDays: 90 }) });
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success || !data.data?.token) throw new Error(data.error || "创建密钥失败");
|
|
||||||
setCreatedToken(data.data.token);
|
|
||||||
await loadKeys();
|
|
||||||
toast.success("密钥已创建;请立即复制,关闭后不会再次显示");
|
|
||||||
} catch (error) { toast.error(error instanceof Error ? error.message : "创建密钥失败"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
const revokeKey = async (id: string) => {
|
|
||||||
if (!window.confirm("撤销后使用此密钥的 Agent 将失去画布访问权限,确定继续吗?")) return;
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/user/access-keys/${id}`, { method: "DELETE" });
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || "撤销失败");
|
|
||||||
setCreatedToken("");
|
|
||||||
await loadKeys();
|
|
||||||
toast.success("密钥已撤销");
|
|
||||||
} catch (error) { toast.error(error instanceof Error ? error.message : "撤销失败"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const example = JSON.stringify({ mcpServers: { tvstar: { command: "node", args: ["/替换为解压目录/tvstar-mcp/mcp-server.mjs"],
|
|
||||||
env: { YEDIR_BASE_URL: origin, YEDIR_ACCESS_TOKEN: "粘贴新创建的 Access Key" } } } }, null, 2);
|
|
||||||
|
|
||||||
return <main className="min-h-screen bg-[#151515] px-4 py-10 text-white">
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<p className="text-sm text-cyan-300">TVstar Plugin</p>
|
|
||||||
<h1 className="mt-2 text-3xl font-semibold">让 Agent 操作视频画布</h1>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-white/60">当前插件提供画布列表、读取和有版本保护的节点操作。视频生成与合成仍由 TVstar 画布内的任务系统执行,MCP 暂未提供直接发起生成的工具。</p>
|
|
||||||
<div className="mt-8 grid gap-4 md:grid-cols-2">
|
|
||||||
<section className="rounded-2xl border border-white/10 bg-[#222] p-5">
|
|
||||||
<h2 className="font-medium">1. 下载并安装</h2>
|
|
||||||
<p className="mt-2 text-sm text-white/50">需要 Node.js 20 或更新版本。解压后在 tvstar-mcp 目录运行:</p>
|
|
||||||
<a className="mt-4 inline-block rounded-lg bg-white px-4 py-2 text-sm font-medium text-black" href="/api/tvstar/plugin/download">下载 TVstar MCP 安装包</a>
|
|
||||||
<pre className="mt-4 overflow-x-auto rounded-lg bg-black/40 p-3 text-xs">cd tvstar-mcp{"\n"}npm ci</pre>
|
|
||||||
</section>
|
|
||||||
<section className="rounded-2xl border border-white/10 bg-[#222] p-5">
|
|
||||||
<h2 className="font-medium">2. 创建画布 Access Key</h2>
|
|
||||||
<p className="mt-2 text-sm text-white/50">读取和修改画布需要对应权限。密钥只在创建时显示一次。</p>
|
|
||||||
<button className="mt-4 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black disabled:opacity-50" disabled={busy} onClick={() => void createKey()}>{busy ? "处理中…" : "创建 Access Key"}</button>
|
|
||||||
{createdToken ? <div className="mt-3 break-all rounded-lg border border-amber-300/30 bg-amber-300/5 p-3 text-xs text-amber-100"><p>{createdToken}</p><button className="mt-2 underline" onClick={() => void navigator.clipboard.writeText(createdToken).then(() => toast.success("已复制"))}>复制密钥</button></div> : null}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<section className="mt-4 rounded-2xl border border-white/10 bg-[#222] p-5">
|
|
||||||
<h2 className="font-medium">3. 配置支持 STDIO MCP 的 Agent</h2>
|
|
||||||
<p className="mt-2 text-sm text-white/50">将安装包的绝对路径和密钥填入客户端配置。不同 Agent 的配置文件位置和外层字段可能不同,请以所用客户端的说明为准。</p>
|
|
||||||
<pre className="mt-4 overflow-x-auto rounded-lg bg-black/40 p-4 text-xs leading-6 text-cyan-100">{example}</pre>
|
|
||||||
<button className="mt-3 text-sm text-cyan-300 underline" onClick={() => void navigator.clipboard.writeText(example).then(() => toast.success("配置示例已复制"))}>复制配置示例</button>
|
|
||||||
</section>
|
|
||||||
<section className="mt-4 rounded-2xl border border-white/10 bg-[#222] p-5">
|
|
||||||
<h2 className="font-medium">已创建的 Access Key</h2>
|
|
||||||
<div className="mt-3 space-y-2">{keys.length ? keys.map(key => <div className="flex items-center justify-between gap-4 rounded-lg bg-black/20 p-3 text-sm" key={key.id}><span className="min-w-0 truncate">{key.name} · {key.tokenPrefix} · {key.revokedAt ? "已撤销" : "有效"}</span>{!key.revokedAt ? <button className="shrink-0 text-rose-300 disabled:opacity-40" disabled={busy} onClick={() => void revokeKey(key.id)}>撤销</button> : null}</div>) : <p className="text-sm text-white/40">暂无密钥</p>}</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>;
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { tvstarSession } from "@/lib/db/schema";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type SharedSnapshot = {
|
|
||||||
title: string;
|
|
||||||
goal: string;
|
|
||||||
createdAt: number;
|
|
||||||
messages: { role: "user" | "assistant"; content: string; createdAt: number }[];
|
|
||||||
plan: { summary: string; deliverables: string[] } | null;
|
|
||||||
shots: { shot: string; duration: string; sceneDescription: string }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TVstarSharedConversation({ params }: { params: Promise<{ token: string }> }) {
|
|
||||||
const { token } = await params;
|
|
||||||
if (!/^[A-Za-z0-9_-]{32}$/.test(token || "")) notFound();
|
|
||||||
const row = (await db.select({ snapshot: tvstarSession.sharedSnapshot }).from(tvstarSession)
|
|
||||||
.where(eq(tvstarSession.shareToken, token)).limit(1))[0];
|
|
||||||
if (!row?.snapshot) notFound();
|
|
||||||
const shared = row.snapshot as SharedSnapshot;
|
|
||||||
return <main className="min-h-screen bg-[#141414] px-4 py-12 text-white">
|
|
||||||
<article className="mx-auto max-w-2xl rounded-2xl border border-white/10 bg-[#1d1d1d] p-6 shadow-2xl">
|
|
||||||
<p className="text-xs tracking-widest text-cyan-300">TVstar · 只读分享</p>
|
|
||||||
<h1 className="mt-3 text-2xl font-semibold">{shared.title}</h1>
|
|
||||||
<p className="mt-2 text-sm text-white/50">{new Date(shared.createdAt).toLocaleString("zh-CN")}</p>
|
|
||||||
<p className="mt-6 whitespace-pre-wrap border-l-2 border-cyan-400 pl-4 text-sm text-white/70">{shared.goal}</p>
|
|
||||||
<section aria-label="分享的对话" className="mt-8 space-y-3">
|
|
||||||
{(shared.messages || []).map((message, index) => <div className={`rounded-xl p-4 text-sm leading-6 ${message.role === "user" ? "ml-8 bg-[#303846]" : "mr-8 bg-[#282828]"}`} key={`${message.createdAt}-${index}`}>
|
|
||||||
<p className="mb-1 text-xs text-white/40">{message.role === "user" ? "用户" : "TVstar"}</p>
|
|
||||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
|
||||||
</div>)}
|
|
||||||
</section>
|
|
||||||
{shared.plan ? <section className="mt-8 border-t border-white/10 pt-5"><h2 className="text-lg">创作规划</h2><p className="mt-2 whitespace-pre-wrap text-sm text-white/70">{shared.plan.summary}</p></section> : null}
|
|
||||||
{shared.shots?.length ? <section className="mt-8 border-t border-white/10 pt-5"><h2 className="text-lg">分镜</h2><ol className="mt-3 space-y-3">{shared.shots.map((shot, index) => <li className="rounded-lg bg-white/5 p-3 text-sm" key={`${shot.shot}-${index}`}><strong>{shot.shot} · {shot.duration}</strong><p className="mt-1 whitespace-pre-wrap text-white/65">{shot.sceneDescription}</p></li>)}</ol></section> : null}
|
|
||||||
<p className="mt-8 border-t border-white/10 pt-5 text-xs text-white/40">此页面是分享时的对话快照,无法编辑画布;对话正文中的文字和链接会原样显示。</p>
|
|
||||||
</article>
|
|
||||||
</main>;
|
|
||||||
}
|
|
||||||
309
auto-deploy.sh
309
auto-deploy.sh
@ -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}"
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +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 { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
@ -2568,23 +2564,10 @@ interface LocalGenerationPendingState {
|
|||||||
expectedCount: number;
|
expectedCount: number;
|
||||||
userTurnCountAtStart: number;
|
userTurnCountAtStart: number;
|
||||||
requestMessageId?: string | null;
|
requestMessageId?: string | null;
|
||||||
backendTaskId?: string | null;
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
requestedCardIds?: string[] | null;
|
requestedCardIds?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DetailReplacementBatchTaskView = {
|
|
||||||
id: string;
|
|
||||||
status: "pending" | "running" | "succeeded" | "failed";
|
|
||||||
error: string | null;
|
|
||||||
attempts: number;
|
|
||||||
cards: DetailReplacementBatchCard[];
|
|
||||||
currentCardId: string | null;
|
|
||||||
requestedCardIds: string[];
|
|
||||||
createdAt: string;
|
|
||||||
queueStatus?: { queuePosition?: number } | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
|
const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations";
|
||||||
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
|
const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000;
|
||||||
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
||||||
@ -2636,7 +2619,6 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
|
|||||||
expectedCount: Number(record.expectedCount),
|
expectedCount: Number(record.expectedCount),
|
||||||
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
||||||
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
||||||
backendTaskId: typeof record.backendTaskId === "string" ? record.backendTaskId : null,
|
|
||||||
timestamp: Number(record.timestamp),
|
timestamp: Number(record.timestamp),
|
||||||
requestedCardIds: Array.isArray(record.requestedCardIds)
|
requestedCardIds: Array.isArray(record.requestedCardIds)
|
||||||
? record.requestedCardIds
|
? record.requestedCardIds
|
||||||
@ -2669,6 +2651,16 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DETAIL_RESULT_PROCESSING_STAGES = [
|
||||||
|
"解码图像",
|
||||||
|
"定位选区",
|
||||||
|
"分离主体",
|
||||||
|
"重绘背景",
|
||||||
|
"融合边缘",
|
||||||
|
"检查细节",
|
||||||
|
"输出结果",
|
||||||
|
];
|
||||||
|
|
||||||
function DetailResultProcessingScanOverlay({
|
function DetailResultProcessingScanOverlay({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
label,
|
label,
|
||||||
@ -2676,6 +2668,29 @@ function DetailResultProcessingScanOverlay({
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [progress, setProgress] = useState(6);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const durationMs = 240_000;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const elapsed = Date.now() - startedAt;
|
||||||
|
const linear = Math.min(1, elapsed / durationMs);
|
||||||
|
const eased = 1 - Math.pow(1 - linear, 1.8);
|
||||||
|
const drift = Math.sin(elapsed / 3200) * 1.2;
|
||||||
|
const next = Math.max(6, Math.min(96, Math.round(eased * 96 + drift)));
|
||||||
|
setProgress((current) => Math.max(current, next));
|
||||||
|
}, 720);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [imageUrl, label]);
|
||||||
|
|
||||||
|
const stageIndex = Math.min(
|
||||||
|
DETAIL_RESULT_PROCESSING_STAGES.length - 1,
|
||||||
|
Math.floor((progress / 100) * DETAIL_RESULT_PROCESSING_STAGES.length)
|
||||||
|
);
|
||||||
|
const stageName = DETAIL_RESULT_PROCESSING_STAGES[stageIndex] || "处理中";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
||||||
<style>{`
|
<style>{`
|
||||||
@ -2789,6 +2804,26 @@ function DetailResultProcessingScanOverlay({
|
|||||||
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
<span className="min-w-[34px] text-right tabular-nums text-slate-300">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-3 left-3 z-[70] isolate inline-flex items-center gap-2 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-slate-100 shadow-[0_12px_28px_rgba(0,0,0,0.32)]"
|
||||||
|
style={{
|
||||||
|
WebkitFontSmoothing: "antialiased",
|
||||||
|
textShadow: "0 1px 1px rgba(0,0,0,0.72)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="tabular-nums text-sky-300">{String(stageIndex + 1).padStart(2, "0")}</span>
|
||||||
|
<span>{stageName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 z-[60] h-0.5 bg-white/5">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-r-full shadow-[0_0_8px_rgba(125,211,252,0.6)] transition-[width] duration-300 ease-linear"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(90deg, #7dd3fc, #a78bfa)",
|
||||||
|
width: `${progress}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -3131,8 +3166,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
||||||
const detailGenerationCancelRef = useRef(false);
|
const detailGenerationCancelRef = useRef(false);
|
||||||
const workspaceStateSignatureRef = useRef<string>("");
|
const workspaceStateSignatureRef = useRef<string>("");
|
||||||
const detailBatchSubmittingRef = useRef(false);
|
|
||||||
const reportedDetailBatchFailureRef = useRef<string | null>(null);
|
|
||||||
const generationRequestSnapshotRef = useRef<{
|
const generationRequestSnapshotRef = useRef<{
|
||||||
hiddenPrompt: string | null;
|
hiddenPrompt: string | null;
|
||||||
replacementExecutionCardIds: string[] | null;
|
replacementExecutionCardIds: string[] | null;
|
||||||
@ -3467,75 +3500,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
||||||
const isReplacementPreset =
|
const isReplacementPreset =
|
||||||
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
||||||
const { data: detailBatchData, mutate: refreshDetailBatch } = useSWR<{
|
|
||||||
task: DetailReplacementBatchTaskView | null;
|
|
||||||
}>(
|
|
||||||
projectId && currentResultModeType === "detail_replacement"
|
|
||||||
? `/api/agents/detail-replacement/batches?projectId=${encodeURIComponent(projectId)}`
|
|
||||||
: null,
|
|
||||||
fetcher,
|
|
||||||
{ refreshInterval: 3000, revalidateOnFocus: true },
|
|
||||||
);
|
|
||||||
const detailBatchTask = detailBatchData?.task || null;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!detailBatchData || currentResultModeType !== "detail_replacement") return;
|
|
||||||
const task = detailBatchData.task;
|
|
||||||
if (task && (task.status === "pending" || task.status === "running")) {
|
|
||||||
setPendingGeneration((current) => {
|
|
||||||
if (current?.backendTaskId === task.id) return current;
|
|
||||||
return {
|
|
||||||
chatId,
|
|
||||||
requestMessageId: task.id,
|
|
||||||
backendTaskId: task.id,
|
|
||||||
modeType: "detail_replacement",
|
|
||||||
expectedCount: task.requestedCardIds.length,
|
|
||||||
requestedCardIds: task.requestedCardIds,
|
|
||||||
userTurnCountAtStart: 0,
|
|
||||||
timestamp: Date.parse(task.createdAt) || Date.now(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setPendingGeneration((current) =>
|
|
||||||
current?.modeType === "detail_replacement" &&
|
|
||||||
(!current.backendTaskId || current.backendTaskId === task?.id)
|
|
||||||
? null
|
|
||||||
: current,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [chatId, currentResultModeType, detailBatchData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cards = detailBatchTask?.cards || [];
|
|
||||||
if (cards.length === 0) return;
|
|
||||||
setReplacementResultState((current) => {
|
|
||||||
const next = { ...current.detail_replacement };
|
|
||||||
let changed = false;
|
|
||||||
for (const card of cards) {
|
|
||||||
const reference = detailReplacementReferenceCards.find((item) => item.id === card.cardId);
|
|
||||||
if (!reference || !card.imageUrl || next[card.cardId]?.url === card.imageUrl) continue;
|
|
||||||
next[card.cardId] = {
|
|
||||||
url: card.imageUrl,
|
|
||||||
text: card.text,
|
|
||||||
status: card.status,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
compareSourceUrl: reference.imageUrl,
|
|
||||||
referenceImageUrl: reference.imageUrl,
|
|
||||||
label: reference.label,
|
|
||||||
};
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
return changed ? { ...current, detail_replacement: next } : current;
|
|
||||||
});
|
|
||||||
}, [detailBatchTask?.cards, detailReplacementReferenceCards]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
detailBatchTask?.status !== "failed" ||
|
|
||||||
detailBatchTask.attempts < 3 ||
|
|
||||||
reportedDetailBatchFailureRef.current === detailBatchTask.id
|
|
||||||
) return;
|
|
||||||
reportedDetailBatchFailureRef.current = detailBatchTask.id;
|
|
||||||
toast.error(detailBatchTask.error || "详情替换批次中断,已保留生成成功的图片,可继续处理剩余图片");
|
|
||||||
}, [detailBatchTask]);
|
|
||||||
const isProductOnlyDetailReplacementMode =
|
const isProductOnlyDetailReplacementMode =
|
||||||
currentResultModeType === "detail_replacement" &&
|
currentResultModeType === "detail_replacement" &&
|
||||||
detailReplacementStrategy === "product_only";
|
detailReplacementStrategy === "product_only";
|
||||||
@ -4172,7 +4136,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 +4156,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 +4839,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;
|
||||||
@ -5045,17 +4996,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
Array.isArray(pendingGeneration.requestedCardIds)
|
Array.isArray(pendingGeneration.requestedCardIds)
|
||||||
? pendingGeneration.requestedCardIds.length
|
? pendingGeneration.requestedCardIds.length
|
||||||
: null,
|
: null,
|
||||||
status: detailBatchTask?.requestedCardIds.includes(card.id)
|
status: getReplacementCardStatus({
|
||||||
? resultMeta?.url
|
|
||||||
? resultMeta.status
|
|
||||||
: detailBatchTask.status === "running"
|
|
||||||
? detailBatchTask.currentCardId === card.id ? "generating" : "queued"
|
|
||||||
: detailBatchTask.status === "pending"
|
|
||||||
? "queued"
|
|
||||||
: detailBatchTask.status === "failed"
|
|
||||||
? "missing"
|
|
||||||
: "waiting"
|
|
||||||
: getReplacementCardStatus({
|
|
||||||
index,
|
index,
|
||||||
resultUrl,
|
resultUrl,
|
||||||
resultText,
|
resultText,
|
||||||
@ -5069,7 +5010,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
detailReplacementReferenceCards,
|
detailReplacementReferenceCards,
|
||||||
detailBatchTask,
|
|
||||||
isDetailResultModeGenerating,
|
isDetailResultModeGenerating,
|
||||||
replacementRunRecovery.completedCardIds,
|
replacementRunRecovery.completedCardIds,
|
||||||
latestReplacementResults,
|
latestReplacementResults,
|
||||||
@ -5224,7 +5164,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const detailRemainingCount = useMemo(
|
const detailRemainingCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
detailResultCards.filter(
|
detailResultCards.filter(
|
||||||
(card) => !["completed", "passthrough"].includes(card.status)
|
(card) => !["completed", "passthrough", "fallback"].includes(card.status)
|
||||||
).length,
|
).length,
|
||||||
[detailResultCards]
|
[detailResultCards]
|
||||||
);
|
);
|
||||||
@ -5499,18 +5439,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [currentResultModeType]);
|
}, [currentResultModeType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return;
|
if (!pendingGeneration || !replacementRunRecovery.done) 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(() => {
|
|
||||||
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.
|
||||||
stop();
|
stop();
|
||||||
transportGenerationStartedAtRef.current = null;
|
transportGenerationStartedAtRef.current = null;
|
||||||
@ -5518,7 +5447,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [pendingGeneration, replacementRunRecovery.done, stop]);
|
}, [pendingGeneration, replacementRunRecovery.done, stop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
if (!pendingGeneration) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5528,7 +5457,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !chatError) {
|
if (!pendingGeneration || !chatError) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5543,7 +5472,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [chatError, clearError, pendingGeneration]);
|
}, [chatError, clearError, pendingGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
if (!pendingGeneration) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -6269,6 +6198,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const processedStatuses = new Set<ReplacementCardStatus>([
|
const processedStatuses = new Set<ReplacementCardStatus>([
|
||||||
"completed",
|
"completed",
|
||||||
"passthrough",
|
"passthrough",
|
||||||
|
"fallback",
|
||||||
]);
|
]);
|
||||||
const remainingIds = resultCards
|
const remainingIds = resultCards
|
||||||
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
||||||
@ -6374,86 +6304,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (replacementModeType === "detail_replacement") {
|
|
||||||
if (detailBatchSubmittingRef.current) return;
|
|
||||||
if (!isValidChatUuid(projectId)) {
|
|
||||||
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const productImageUrls = Array.from(new Set(
|
|
||||||
(detailReplacementProductSourceImages.length > 0
|
|
||||||
? detailReplacementProductSourceImages.map((item) => item.url)
|
|
||||||
: selectedProductAttachments.map((item) => item.url))
|
|
||||||
.filter((url) => isValidChatHttpUrl(url)),
|
|
||||||
));
|
|
||||||
if (productImageUrls.length === 0 && !replacementExecutionCards.every((card) => card.passthrough)) {
|
|
||||||
toast.error("请先在输入图区选择至少一张产品图");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
detailBatchSubmittingRef.current = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const saved = await onPersistProject?.();
|
|
||||||
if (saved === false) throw new Error("项目尚未保存,请重试");
|
|
||||||
const requestId = generateUUID();
|
|
||||||
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
projectId,
|
|
||||||
requestId,
|
|
||||||
model: resolvedModelId,
|
|
||||||
imageSize: imageParams.imageSize,
|
|
||||||
replacementStrategy: detailReplacementStrategy,
|
|
||||||
prompt: detailReplacementStrategy === "product_only"
|
|
||||||
? "仅替换参考详情图中的产品主体"
|
|
||||||
: String(resolvedWorkflowContext?.promptText || "").trim(),
|
|
||||||
userPrompt: String(options?.promptText || input || "").trim(),
|
|
||||||
productImageUrls,
|
|
||||||
modelImageUrl: detailReplacementModelSourceImage?.url || selectedModelAttachment?.url || null,
|
|
||||||
references: replacementExecutionCards.map((card) => ({
|
|
||||||
id: card.id,
|
|
||||||
label: card.label,
|
|
||||||
imageUrl: card.imageUrl,
|
|
||||||
candidateId: card.candidateId,
|
|
||||||
passthrough: card.passthrough,
|
|
||||||
rerunReferenceImages:
|
|
||||||
options?.replacementRerunReferenceImages?.cardId === card.id
|
|
||||||
? options.replacementRerunReferenceImages.images
|
|
||||||
: [],
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok || !payload?.task?.id) {
|
|
||||||
throw new Error(payload?.error || "详情替换任务提交失败");
|
|
||||||
}
|
|
||||||
setPendingGeneration({
|
|
||||||
chatId,
|
|
||||||
requestMessageId: requestId,
|
|
||||||
backendTaskId: payload.task.id,
|
|
||||||
modeType: "detail_replacement",
|
|
||||||
expectedCount: replacementExecutionCards.length,
|
|
||||||
userTurnCountAtStart: userMessageCount,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
requestedCardIds: replacementExecutionCards.map((card) => card.id),
|
|
||||||
});
|
|
||||||
setReplacementExecutionContext({
|
|
||||||
modeType: "detail_replacement",
|
|
||||||
cardIds: replacementExecutionCards.map((card) => card.id),
|
|
||||||
});
|
|
||||||
setActiveResultModeType("detail_replacement");
|
|
||||||
setSelectedResultUrl(null);
|
|
||||||
await refreshDetailBatch();
|
|
||||||
toast.success(`已提交 ${replacementExecutionCards.length} 张详情图,离开页面后仍会继续处理`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "详情替换任务提交失败");
|
|
||||||
} finally {
|
|
||||||
detailBatchSubmittingRef.current = false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
|
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
|
||||||
const sanitizedAttachments = sceneMessageAttachments;
|
const sanitizedAttachments = sceneMessageAttachments;
|
||||||
if (
|
if (
|
||||||
@ -6603,8 +6453,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
detailResultCards,
|
detailResultCards,
|
||||||
detailReplacementReferenceCards.length,
|
detailReplacementReferenceCards.length,
|
||||||
detailReplacementStrategy,
|
detailReplacementStrategy,
|
||||||
detailReplacementProductSourceImages,
|
|
||||||
detailReplacementModelSourceImage,
|
|
||||||
handleRunAiDetailGeneration,
|
handleRunAiDetailGeneration,
|
||||||
currentResultModeType,
|
currentResultModeType,
|
||||||
getReplacementExecutionCards,
|
getReplacementExecutionCards,
|
||||||
@ -6635,10 +6483,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
scenePrimaryReferenceCount,
|
scenePrimaryReferenceCount,
|
||||||
sceneSecondaryReferenceCount,
|
sceneSecondaryReferenceCount,
|
||||||
selectedModelAttachment,
|
selectedModelAttachment,
|
||||||
selectedProductAttachments,
|
|
||||||
onPersistProject,
|
|
||||||
projectId,
|
|
||||||
refreshDetailBatch,
|
|
||||||
]);
|
]);
|
||||||
const handleStopGeneration = useCallback(() => {
|
const handleStopGeneration = useCallback(() => {
|
||||||
detailGenerationCancelRef.current = true;
|
detailGenerationCancelRef.current = true;
|
||||||
@ -6803,11 +6647,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) => {
|
||||||
@ -9475,7 +9339,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{isAnyGenerationRunning && !pendingGeneration?.backendTaskId ? (
|
{isAnyGenerationRunning ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -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("已打开原图,可右键另存为");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
setIsDownloadSubmitting(false);
|
window.setTimeout(() => {
|
||||||
|
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>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
<Button
|
size="sm"
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
className={cn(
|
||||||
size={isWorkflowCanvas ? "sm" : "icon"}
|
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
|
||||||
className={cn(
|
isWorkflowCanvas &&
|
||||||
isWorkflowCanvas
|
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||||
? "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]"
|
)}
|
||||||
: "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"
|
onClick={handleDownloadSelectedImages}
|
||||||
)}
|
disabled={isDownloadSubmitting}
|
||||||
onClick={handleDownloadSelectedImages}
|
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
|
||||||
disabled={isDownloadSubmitting}
|
>
|
||||||
aria-label={
|
<CanvasToolbarIcon
|
||||||
isDownloadSubmitting
|
className="h-3.5 w-3.5"
|
||||||
? "打包中..."
|
fallback={Download}
|
||||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
||||||
}
|
/>
|
||||||
>
|
{isDownloadSubmitting
|
||||||
{!isWorkflowCanvas && isDownloadSubmitting ? (
|
? "打包中..."
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
||||||
) : (
|
</Button>
|
||||||
<CanvasToolbarIcon
|
|
||||||
className={isWorkflowCanvas ? "h-3.5 w-3.5" : "h-5 w-5"}
|
|
||||||
fallback={Download}
|
|
||||||
referenceName={isWorkflowCanvas ? undefined : "image-download"}
|
|
||||||
referenceSize={20}
|
|
||||||
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isWorkflowCanvas
|
|
||||||
? isDownloadSubmitting
|
|
||||||
? "打包中..."
|
|
||||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
|
||||||
: null}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{isDownloadSubmitting
|
|
||||||
? "打包中..."
|
|
||||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import {
|
|
||||||
ArrowLeft, Clapperboard, FileText, Film, FolderOpen, Image as ImageIcon,
|
|
||||||
LayoutGrid, MoreHorizontal, Music2, Plus, Scissors, Sparkles, Video,
|
|
||||||
WandSparkles, Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { filterCanvasModels, type CanvasModelDescriptor } from "@/lib/video-canvas/model-matching";
|
|
||||||
|
|
||||||
export type VideoCanvasHomeProject = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
thumbnail: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = "home" | "projects";
|
|
||||||
type Starter = "image" | "video" | "audio" | "script" | "smart-edit";
|
|
||||||
type BrandingSettings = {
|
|
||||||
siteName?: string | null;
|
|
||||||
siteLogo?: string | null;
|
|
||||||
siteLogoDark?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const quickStarts: Array<{ label: string; icon: typeof Video; starter: Starter; mode?: string; model?: string }> = [
|
|
||||||
{ label: "文生视频", icon: WandSparkles, starter: "video", mode: "text-to-video" },
|
|
||||||
{ label: "首帧视频", icon: Video, starter: "video", mode: "first-frame" },
|
|
||||||
{ label: "多参考视频", icon: Film, starter: "video", mode: "reference-images" },
|
|
||||||
{ label: "视频生成", icon: Clapperboard, starter: "video" },
|
|
||||||
{ label: "图片生成", icon: ImageIcon, starter: "image" },
|
|
||||||
{ label: "音频生成", icon: Music2, starter: "audio" },
|
|
||||||
{ label: "剧本生成", icon: FileText, starter: "script" },
|
|
||||||
{ label: "智能剪辑", icon: Scissors, starter: "smart-edit" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function projectHref(id: string, starter?: Starter, mode?: string, model?: string) {
|
|
||||||
const params = new URLSearchParams({ mode: "video", workspaceType: "video" });
|
|
||||||
if (starter) params.set("starter", starter);
|
|
||||||
if (mode) params.set("starterMode", mode);
|
|
||||||
if (model) params.set("starterModel", model);
|
|
||||||
return `/canvas/${encodeURIComponent(id)}?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectDate(value: string) {
|
|
||||||
const parts = new Intl.DateTimeFormat("zh-CN", {
|
|
||||||
timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit",
|
|
||||||
}).formatToParts(new Date(value));
|
|
||||||
const get = (name: string) => parts.find(part => part.type === name)?.value || "";
|
|
||||||
return `${get("year")}-${get("month")}-${get("day")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectTitle(value: string) {
|
|
||||||
return !value || value === "未命名视频画布" ? "未命名" : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoCanvasHome({
|
|
||||||
initialHasMore, initialProjects, siteLogo, siteLogoDark, siteName, userName,
|
|
||||||
}: {
|
|
||||||
initialHasMore: boolean;
|
|
||||||
initialProjects: VideoCanvasHomeProject[];
|
|
||||||
siteLogo: string | null;
|
|
||||||
siteLogoDark: string | null;
|
|
||||||
siteName: string;
|
|
||||||
userName: string;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [section, setSection] = useState<Section>("home");
|
|
||||||
const [projects, setProjects] = useState(initialProjects);
|
|
||||||
const [hasMore, setHasMore] = useState(initialHasMore);
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [menuId, setMenuId] = useState<string | null>(null);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
|
||||||
const [draftTitle, setDraftTitle] = useState("");
|
|
||||||
const { data: branding } = useSWR<BrandingSettings>(
|
|
||||||
"/api/site-settings?scope=branding",
|
|
||||||
async (url: string) => {
|
|
||||||
const response = await fetch(url, { cache: "no-store" });
|
|
||||||
if (!response.ok) throw new Error("品牌信息不可用");
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
{ refreshInterval: 30_000, revalidateOnFocus: true },
|
|
||||||
);
|
|
||||||
const brandName = String(branding?.siteName ?? siteName).trim() || "AI Chatbot";
|
|
||||||
const brandLogo = (branding ? branding.siteLogoDark : siteLogoDark)?.trim()
|
|
||||||
|| (branding ? branding.siteLogo : siteLogo)?.trim();
|
|
||||||
const { data: membership } = useSWR<{ summary?: { currentCredits?: number } }>(
|
|
||||||
"/api/user/membership-summary",
|
|
||||||
async (url: string) => {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error("积分信息不可用");
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const { data: modelData } = useSWR<{ models?: CanvasModelDescriptor[] }>("/api/models", async (url: string) => {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error("模型目录不可用");
|
|
||||||
return response.json();
|
|
||||||
});
|
|
||||||
const availableVideoModels = filterCanvasModels(modelData?.models || [], "video", "text-to-video").slice(0, 3);
|
|
||||||
const shortcuts = quickStarts.map((shortcut, index) => {
|
|
||||||
const model = index < 3 ? availableVideoModels[index] : undefined;
|
|
||||||
return model ? {
|
|
||||||
...shortcut,
|
|
||||||
label: model.displayName || model.modelId || shortcut.label,
|
|
||||||
model: model.modelId || model.id,
|
|
||||||
mode: "text-to-video",
|
|
||||||
} : shortcut;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
async function refreshRecent() {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/canvas?workspaceType=video&limit=24&page=1", { cache: "no-store" });
|
|
||||||
const result = await response.json();
|
|
||||||
if (!active || !response.ok || !result.success) return;
|
|
||||||
setProjects(current => result.data.map((item: VideoCanvasHomeProject) => ({
|
|
||||||
...item,
|
|
||||||
thumbnail: item.thumbnail || current.find(existing => existing.id === item.id)?.thumbnail || null,
|
|
||||||
})));
|
|
||||||
setHasMore(Boolean(result.pagination?.hasMore));
|
|
||||||
} catch {
|
|
||||||
// The server-rendered project list remains available if refreshing fails.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void refreshRecent();
|
|
||||||
return () => { active = false; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function createProject(starter?: Starter, mode?: string, model?: string) {
|
|
||||||
if (creating) return;
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/canvas", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ workspaceType: "video", title: "未命名视频画布" }),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || !result.success || !result.data?.id) throw new Error(result.error || "创建项目失败");
|
|
||||||
setProjects(current => [{
|
|
||||||
id: result.data.id,
|
|
||||||
title: result.data.title || "未命名视频画布",
|
|
||||||
thumbnail: null,
|
|
||||||
createdAt: result.data.createdAt || new Date().toISOString(),
|
|
||||||
updatedAt: result.data.updatedAt || new Date().toISOString(),
|
|
||||||
}, ...current]);
|
|
||||||
router.push(projectHref(result.data.id, starter, mode, model));
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "创建项目失败,请重试");
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (loadingMore || !hasMore) return;
|
|
||||||
setLoadingMore(true);
|
|
||||||
try {
|
|
||||||
const page = Math.floor(projects.length / 24) + 1;
|
|
||||||
const response = await fetch(`/api/canvas?workspaceType=video&limit=24&page=${page}`, { cache: "no-store" });
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || !result.success) throw new Error(result.error || "加载项目失败");
|
|
||||||
setProjects(current => [...current, ...result.data.filter((item: VideoCanvasHomeProject) => !current.some(existing => existing.id === item.id))]);
|
|
||||||
setHasMore(Boolean(result.pagination?.hasMore));
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "加载项目失败");
|
|
||||||
} finally {
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveTitle(id: string) {
|
|
||||||
const title = draftTitle.trim();
|
|
||||||
if (!title) { toast.error("项目名称不能为空"); return; }
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/canvas/${encodeURIComponent(id)}`, {
|
|
||||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ title, workspaceType: "video" }),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || !result.success) throw new Error(result.error || "重命名失败");
|
|
||||||
setProjects(current => current.map(item => item.id === id ? { ...item, title, updatedAt: result.data.updatedAt || item.updatedAt } : item));
|
|
||||||
setEditingId(null);
|
|
||||||
toast.success("项目已重命名");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "重命名失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleProjects = section === "home" ? projects.slice(0, 4) : projects;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-svh min-w-0 overflow-hidden bg-[#191919] text-[#f7f7f7]">
|
|
||||||
<aside className="flex w-[210px] shrink-0 flex-col border-r border-white/[0.06] bg-[#1c1c1c] px-3 py-[18px] max-sm:w-[60px] max-sm:px-2">
|
|
||||||
<a aria-label={`返回${brandName}首页`} className="mb-4 flex h-7 min-w-0 items-center gap-2 px-2 text-[18px] font-semibold" href="/">
|
|
||||||
{brandLogo ? <img alt="" className="size-6 shrink-0 object-contain" src={brandLogo} /> : <span className="flex size-5 shrink-0 items-center justify-center rounded bg-white text-[#191919]"><Film className="size-3.5" /></span>}
|
|
||||||
<span className="truncate max-sm:hidden">{brandName}</span>
|
|
||||||
</a>
|
|
||||||
<button className="flex h-[33px] items-center gap-2 rounded-[6px] bg-[#15bde4] px-2.5 text-left text-[13px] font-medium text-black hover:bg-[#3ed0ed] disabled:opacity-55" disabled={creating} onClick={() => void createProject()} type="button">
|
|
||||||
<Plus className="size-4" /><span className="max-sm:hidden">{creating ? "正在创建…" : "新建项目"}</span>
|
|
||||||
</button>
|
|
||||||
<div className="mt-5 space-y-1 border-b border-white/[0.06] pb-4">
|
|
||||||
<button className="flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] text-white/75 hover:bg-white/[0.06]" onClick={() => void createProject("video")} type="button"><Sparkles className="size-4" /><span className="max-sm:hidden">视频创作</span></button>
|
|
||||||
<button className="flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] text-white/75 hover:bg-white/[0.06]" onClick={() => void createProject("smart-edit")} type="button"><Scissors className="size-4" /><span className="max-sm:hidden">智能剪辑</span></button>
|
|
||||||
</div>
|
|
||||||
<nav aria-label="视频画布导航" className="mt-3 space-y-1">
|
|
||||||
<button aria-current={section === "home" ? "page" : undefined} className={`flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] ${section === "home" ? "bg-[#282828] text-white" : "text-white/75 hover:bg-white/[0.06]"}`} onClick={() => setSection("home")} type="button"><LayoutGrid className="size-4" /><span className="max-sm:hidden">首页</span></button>
|
|
||||||
<button aria-current={section === "projects" ? "page" : undefined} className={`flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] ${section === "projects" ? "bg-[#282828] text-white" : "text-white/75 hover:bg-white/[0.06]"}`} onClick={() => setSection("projects")} type="button"><FolderOpen className="size-4" /><span className="max-sm:hidden">项目</span></button>
|
|
||||||
<a className="flex h-8 items-center gap-2 rounded-md px-2.5 text-[12px] text-white/75 hover:bg-white/[0.06]" href="/gallery"><ImageIcon className="size-4" /><span className="max-sm:hidden">资产</span></a>
|
|
||||||
</nav>
|
|
||||||
<a aria-label="返回生图画布首页" className="mt-3 flex h-8 items-center gap-2 rounded-md border border-white/10 px-2.5 text-[12px] text-white/75 hover:bg-white/[0.06] hover:text-white" href="/" title="返回生图画布首页"><ArrowLeft className="size-4 shrink-0" /><span className="truncate max-sm:hidden">返回生图画布首页</span></a>
|
|
||||||
<div className="mt-auto flex items-center gap-2 border-t border-white/[0.06] px-2 pt-4 text-[12px] text-white/70"><span className="flex size-7 items-center justify-center rounded-full bg-[#414a62] font-medium text-white">{(userName || "用").slice(0, 1)}</span><span className="truncate max-sm:hidden">{userName || "我的工作区"}</span></div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="min-w-0 flex-1 overflow-y-auto">
|
|
||||||
<header className="flex h-[46px] items-center justify-end gap-2 border-b border-white/[0.04] px-5 md:px-9">
|
|
||||||
<span className="flex h-8 items-center gap-1 rounded-lg border border-white/10 px-3 text-[12px] text-white/75"><Zap className="size-3.5" />{membership?.summary?.currentCredits ?? "—"}</span>
|
|
||||||
<span aria-label={userName || "当前用户"} className="flex size-8 items-center justify-center rounded-full bg-[#414a62] text-[12px] font-medium">{(userName || "用").slice(0, 1)}</span>
|
|
||||||
</header>
|
|
||||||
<div className="mx-auto max-w-[1500px] px-5 pb-12 pt-[10px] md:px-9">
|
|
||||||
{section === "home" ? <>
|
|
||||||
<button aria-label="新建画布创作" className="flex h-[140px] w-full flex-col items-center justify-center gap-3 rounded-[12px] border border-white/[0.14] bg-[radial-gradient(circle_at_center,rgba(255,255,255,.045),transparent_65%),radial-gradient(#5c5c5c_0.8px,transparent_0.8px)] bg-[length:100%_100%,18px_18px] text-[14px] font-semibold hover:border-white/30" disabled={creating} onClick={() => void createProject()} type="button">
|
|
||||||
<span className="flex h-[48px] w-[80px] items-center justify-center rounded-[15px] bg-[linear-gradient(#fff,#e9e9e9)] text-black shadow-[0_8px_25px_rgba(255,255,255,.1)]"><Plus className="size-8 stroke-[1.8]" /></span>
|
|
||||||
新建画布创作
|
|
||||||
</button>
|
|
||||||
<div className="mt-7 grid grid-cols-4 gap-3 lg:grid-cols-8 lg:gap-5">
|
|
||||||
{shortcuts.map(({ label, icon: Icon, starter, mode, model }, index) => <button className="group min-w-0 text-center disabled:opacity-50" disabled={creating} key={`${index}-${label}`} onClick={() => void createProject(starter, mode, model)} title={label} type="button"><span className="mx-auto flex h-[50px] w-[86px] max-w-full items-center justify-center rounded-[16px] bg-[#262626] transition-colors group-hover:bg-[#343434]"><Icon className="size-5 text-white/90" /></span><span className="mt-2 block truncate text-[12px] font-medium">{label}</span></button>)}
|
|
||||||
</div>
|
|
||||||
</> : null}
|
|
||||||
|
|
||||||
<div className={section === "home" ? "mt-10" : "mt-1"}>
|
|
||||||
<div className="mb-4 flex items-center justify-between"><h1 className="text-[14px] font-semibold">{section === "home" ? "最近项目" : "全部项目"}</h1>{section === "home" && projects.length > 0 ? <button className="text-[12px] text-white/75 hover:text-white" onClick={() => setSection("projects")} type="button">查看全部 ›</button> : null}</div>
|
|
||||||
{visibleProjects.length ? <div className={`grid grid-cols-1 gap-3 ${section === "home" ? "sm:grid-cols-2 lg:grid-cols-4" : "sm:grid-cols-2"}`}>{visibleProjects.map(project => <div className="group relative flex h-[74px] min-w-0 items-center gap-3 rounded-[24px] border border-white/[0.09] bg-[#202020] pr-11 transition-colors hover:border-white/[0.19] hover:bg-[#292929]" key={project.id}>
|
|
||||||
<button aria-label={`打开项目 ${projectTitle(project.title)}`} className="absolute inset-0 rounded-[24px]" onClick={() => router.push(projectHref(project.id))} type="button" />
|
|
||||||
<span className="pointer-events-none z-[1] ml-1 flex h-[66px] w-[88px] shrink-0 items-center justify-center overflow-hidden rounded-[20px] bg-[#292929] text-white/30">{project.thumbnail ? <img alt="" className="size-full object-cover" src={project.thumbnail} /> : <Sparkles className="size-5" />}</span>
|
|
||||||
<span className="pointer-events-none z-[1] min-w-0 flex-1"><span className="block truncate text-[13px] font-semibold">{projectTitle(project.title)}</span><span className="mt-1 block text-[11px] text-white/50">{projectDate(project.updatedAt)}</span></span>
|
|
||||||
<button aria-label={`项目 ${projectTitle(project.title)} 的更多操作`} aria-expanded={menuId === project.id} className="absolute right-4 top-4 z-[2] flex size-7 items-center justify-center rounded-lg text-white/60 opacity-0 hover:bg-white/10 hover:text-white focus:opacity-100 group-hover:opacity-100" onClick={() => setMenuId(current => current === project.id ? null : project.id)} type="button"><MoreHorizontal className="size-4" /></button>
|
|
||||||
{menuId === project.id ? <div className="absolute right-3 top-11 z-[4] min-w-[110px] rounded-lg border border-white/10 bg-[#303030] p-1 shadow-xl"><button className="w-full rounded px-3 py-2 text-left text-[12px] hover:bg-white/10" onClick={() => { setEditingId(project.id); setDraftTitle(projectTitle(project.title)); setMenuId(null); }} type="button">重命名</button></div> : null}
|
|
||||||
{editingId === project.id ? <form className="absolute inset-1 z-[5] flex items-center gap-2 rounded-[20px] bg-[#292929] px-3" onSubmit={event => { event.preventDefault(); void saveTitle(project.id); }}><input aria-label="项目名称" autoFocus className="min-w-0 flex-1 rounded-md border border-white/20 bg-[#202020] px-3 py-2 text-[13px] outline-none focus:border-white/60" maxLength={256} onChange={event => setDraftTitle(event.target.value)} value={draftTitle} /><button className="text-[12px] text-cyan-300" type="submit">保存</button><button className="text-[12px] text-white/50" onClick={() => setEditingId(null)} type="button">取消</button></form> : null}
|
|
||||||
</div>)}</div> : <div className="rounded-2xl border border-dashed border-white/10 px-5 py-14 text-center text-[13px] text-white/55">还没有视频项目,点击上方“新建画布创作”开始。</div>}
|
|
||||||
{section === "projects" && hasMore ? <button className="mx-auto mt-6 block rounded-lg border border-white/15 px-5 py-2 text-[12px] text-white/70 hover:bg-white/10 disabled:opacity-50" disabled={loadingMore} onClick={() => void loadMore()} type="button">{loadingMore ? "加载中…" : "加载更多"}</button> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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)} />;
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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 || "")
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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 };
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/lib/video-canvas/director-extensions";
|
} from "@/lib/video-canvas/director-extensions";
|
||||||
import { directorPoses, type DirectorPoseName } from "@/lib/video-canvas/director-poses";
|
import { directorPoses, type DirectorPoseName } from "@/lib/video-canvas/director-poses";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ArrowUp, Camera, Plus, Upload, X, Trash2 } from "lucide-react";
|
import { Camera, Plus, Upload, X, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DirectorViewport, type DirectorViewportHandle } from "./DirectorViewport";
|
import { DirectorViewport, type DirectorViewportHandle } from "./DirectorViewport";
|
||||||
import {
|
import {
|
||||||
@ -53,7 +53,7 @@ async function upload(file: File) {
|
|||||||
);
|
);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
const url = result.url || result.data?.url;
|
const url = result.url || result.data?.url;
|
||||||
if (!response.ok || !url) throw new Error(result.error || "上传失败");
|
if (!response.ok || !url) throw Error(result.error || "上传失败");
|
||||||
return String(url);
|
return String(url);
|
||||||
}
|
}
|
||||||
export function DirectorStudioDialog({
|
export function DirectorStudioDialog({
|
||||||
@ -86,12 +86,7 @@ export function DirectorStudioDialog({
|
|||||||
onChange(next);
|
onChange(next);
|
||||||
};
|
};
|
||||||
const [objectSearch, setObjectSearch] = useState("");
|
const [objectSearch, setObjectSearch] = useState("");
|
||||||
const [scenePrompt, setScenePrompt] = useState("");
|
const [selected, setSelected] = useState(scene.objects[0]?.id || "");
|
||||||
const [sceneReferenceUrl, setSceneReferenceUrl] = useState("");
|
|
||||||
const [scenePromptBusy, setScenePromptBusy] = useState(false);
|
|
||||||
const sceneReferenceInput = useRef<HTMLInputElement>(null);
|
|
||||||
const [selected, setSelected] = useState("");
|
|
||||||
const [rightTab, setRightTab] = useState<"properties" | "pose">("properties");
|
|
||||||
const [mode, setMode] = useState<"translate" | "rotate" | "scale">("translate");
|
const [mode, setMode] = useState<"translate" | "rotate" | "scale">("translate");
|
||||||
const [cameraView, setCameraView] = useState(false);
|
const [cameraView, setCameraView] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@ -161,7 +156,7 @@ export function DirectorStudioDialog({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const pose = sampleDirectorMotion(motion, 0);
|
const pose = sampleDirectorMotion(motion, 0);
|
||||||
if (!pose || motion.keyframes.length < 2) throw new Error("至少需要两个关键帧");
|
if (!pose || motion.keyframes.length < 2) throw Error("至少需要两个关键帧");
|
||||||
const image = await viewport.current!.capture(pose, 0);
|
const image = await viewport.current!.capture(pose, 0);
|
||||||
const url = await upload(
|
const url = await upload(
|
||||||
new File([image.blob], `director-motion-${Date.now()}.png`, {
|
new File([image.blob], `director-motion-${Date.now()}.png`, {
|
||||||
@ -284,8 +279,8 @@ export function DirectorStudioDialog({
|
|||||||
className="fixed inset-0 z-[600] flex flex-col bg-[#141414] text-white"
|
className="fixed inset-0 z-[600] flex flex-col bg-[#141414] text-white"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<header className="flex h-11 shrink-0 items-center justify-between border-b border-white/10 px-4">
|
<header className="flex h-16 shrink-0 items-center justify-between border-b border-white/10 px-5">
|
||||||
<strong className="text-[13px]">3D导演台</strong>
|
<strong>3D导演台</strong>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
className={button}
|
className={button}
|
||||||
@ -315,9 +310,9 @@ export function DirectorStudioDialog({
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-[248px_minmax(0,1fr)_248px]">
|
<div className="grid min-h-0 flex-1 grid-cols-[210px_minmax(0,1fr)_260px]">
|
||||||
<aside inert={busy} className="overflow-y-auto border-r border-white/10 bg-[#1c1c1c] p-3">
|
<aside inert={busy} className="overflow-y-auto border-r border-white/10 p-4">
|
||||||
<div className="mb-3 text-sm text-white/70">场景</div>
|
<div className="mb-3 text-sm text-white/60">场景对象</div>
|
||||||
<input
|
<input
|
||||||
aria-label="搜索场景对象"
|
aria-label="搜索场景对象"
|
||||||
className={`${field} mb-3`}
|
className={`${field} mb-3`}
|
||||||
@ -325,12 +320,7 @@ export function DirectorStudioDialog({
|
|||||||
value={objectSearch}
|
value={objectSearch}
|
||||||
onChange={(e) => setObjectSearch(e.target.value)}
|
onChange={(e) => setObjectSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<button className={`${button} w-full text-left ${!selected?"bg-white/10":""}`} onClick={()=>setSelected("")} type="button">▤ 场景</button>
|
|
||||||
{scene.cameras.map((cameraItem) => (
|
|
||||||
<button className={`${button} w-full text-left ${scene.activeCameraId===cameraItem.id?"bg-white/10":""}`} key={cameraItem.id}
|
|
||||||
onClick={()=>{commit({...current.current,activeCameraId:cameraItem.id});setCameraView(true)}} type="button">◧ {cameraItem.name}</button>
|
|
||||||
))}
|
|
||||||
{scene.objects
|
{scene.objects
|
||||||
.filter((o) => o.name.toLowerCase().includes(objectSearch.trim().toLowerCase()))
|
.filter((o) => o.name.toLowerCase().includes(objectSearch.trim().toLowerCase()))
|
||||||
.map((o) => (
|
.map((o) => (
|
||||||
@ -343,9 +333,7 @@ export function DirectorStudioDialog({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<details className="mt-5 border-t border-white/10 pt-2">
|
<div className="mt-5 flex flex-col gap-2">
|
||||||
<summary className="cursor-pointer text-xs text-white/55">添加与场景资源</summary>
|
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
|
||||||
<button className={button} onClick={() => addObject("actor", "基础人偶")}>
|
<button className={button} onClick={() => addObject("actor", "基础人偶")}>
|
||||||
添加人偶
|
添加人偶
|
||||||
</button>
|
</button>
|
||||||
@ -398,7 +386,6 @@ export function DirectorStudioDialog({
|
|||||||
AI 识图重建布景
|
AI 识图重建布景
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
|
||||||
<p className="mt-4 text-xs leading-5 text-white/45">
|
<p className="mt-4 text-xs leading-5 text-white/45">
|
||||||
左键旋转视角,右键平移,滚轮缩放。选择对象后使用变换轴调整。
|
左键旋转视角,右键平移,滚轮缩放。选择对象后使用变换轴调整。
|
||||||
</p>
|
</p>
|
||||||
@ -423,7 +410,7 @@ export function DirectorStudioDialog({
|
|||||||
onCamera={(position, target) => patchCamera({ position, target })}
|
onCamera={(position, target) => patchCamera({ position, target })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-4 left-[39%] flex -translate-x-1/2 gap-1 rounded-xl border border-white/10 bg-[#242424] p-2">
|
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-1 rounded-xl bg-black/75 p-2">
|
||||||
{(["translate", "rotate", "scale"] as const).map((m, i) => (
|
{(["translate", "rotate", "scale"] as const).map((m, i) => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
@ -438,64 +425,9 @@ export function DirectorStudioDialog({
|
|||||||
{busy ? "处理中..." : "截图"}
|
{busy ? "处理中..." : "截图"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{onReconstruct ? (
|
|
||||||
<div className="absolute bottom-4 left-[65%] flex h-[42px] w-[min(360px,42%)] -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-[#242424] px-2 text-white shadow-lg">
|
|
||||||
<button aria-label="上传场景参考图" className="flex size-8 shrink-0 items-center justify-center rounded-full hover:bg-white/10" disabled={scenePromptBusy} onClick={()=>sceneReferenceInput.current?.click()} type="button"><Plus size={16}/></button>
|
|
||||||
<input ref={sceneReferenceInput} hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={async event=>{
|
|
||||||
const file=event.target.files?.[0];event.target.value="";if(!file)return;
|
|
||||||
if(file.size>20*1024*1024){toast.error("参考图需小于 20 MiB");return}
|
|
||||||
setScenePromptBusy(true);try{setSceneReferenceUrl(await upload(file));toast.success("已添加场景参考图")}catch(cause){toast.error(cause instanceof Error?cause.message:"参考图上传失败")}finally{setScenePromptBusy(false)}
|
|
||||||
}}/>
|
|
||||||
<input aria-label="描述想搭建的场景" className="min-w-0 flex-1 bg-transparent text-[12px] text-white outline-none placeholder:text-white/35" placeholder={sceneReferenceUrl?"描述想搭建的场景 · 已添加参考图":"描述想搭建的场景"} value={scenePrompt} onChange={event=>setScenePrompt(event.target.value)}/>
|
|
||||||
<button aria-label="创建场景" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-white/12 disabled:opacity-35" disabled={!sceneReferenceUrl||scenePromptBusy||busy} onClick={async()=>{
|
|
||||||
if(!sceneReferenceUrl)return;setScenePromptBusy(true);try{await onReconstruct({imageUrl:sceneReferenceUrl,prompt:scenePrompt});timelineDrawer.current!.open=true;toast.success("重建任务已提交")}catch(cause){toast.error(cause instanceof Error?cause.message:"重建提交失败")}finally{setScenePromptBusy(false)}
|
|
||||||
}} type="button"><ArrowUp size={16}/></button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
<aside className="space-y-4 overflow-y-auto border-l border-white/10 bg-[#222] p-3">
|
<aside className="space-y-4 overflow-y-auto border-l border-white/10 p-4">
|
||||||
{!object ? (
|
<fieldset disabled={busy || previewTime !== null} className="space-y-4">
|
||||||
<div className="space-y-4 text-[12px]">
|
|
||||||
<div className="border-b border-white/10 pb-2 text-[14px] font-medium">3D场景</div>
|
|
||||||
<label className="flex items-center justify-between gap-2">天空颜色
|
|
||||||
<input aria-label="天空颜色" type="color" value={scene.skyColor || "#080808"} onChange={event=>commit({...current.current,skyColor:event.target.value})}/>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center justify-between gap-2">网格吸附
|
|
||||||
<input aria-label="显示场景网格" type="checkbox" checked={scene.gridVisible !== false} onChange={event=>commit({...current.current,gridVisible:event.target.checked})}/>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center justify-between gap-2">地面
|
|
||||||
<input aria-label="显示场景地面" type="checkbox" checked={scene.groundVisible !== false} onChange={event=>commit({...current.current,groundVisible:event.target.checked})}/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3 text-[12px]">
|
|
||||||
<div className="border-b border-white/10 pb-2 text-[14px] font-medium">{object.kind==="actor"?"角色":"场景对象"}</div>
|
|
||||||
<div className="flex gap-2 border-b border-white/10 pb-2">
|
|
||||||
<button className={rightTab==="properties"?"rounded bg-white/10 px-3 py-1":"px-3 py-1 text-white/45"} onClick={()=>setRightTab("properties")} type="button">属性</button>
|
|
||||||
<button className={rightTab==="pose"?"rounded bg-white/10 px-3 py-1":"px-3 py-1 text-white/45"} onClick={()=>setRightTab("pose")} type="button">姿势</button>
|
|
||||||
</div>
|
|
||||||
{rightTab==="properties" ? <>
|
|
||||||
<label className="block text-white/55">名称<input aria-label="对象名称" className={`${field} mt-1`} value={object.name} onChange={event=>patchObject(object.id,{name:event.target.value})}/></label>
|
|
||||||
{(["position","rotation","scale"] as const).map(key=>(
|
|
||||||
<div key={key} className="text-white/55">
|
|
||||||
<span>{key==="position"?"位置":key==="rotation"?"旋转":"缩放"}</span>
|
|
||||||
<div className="mt-1 grid grid-cols-3 gap-1">
|
|
||||||
{object[key].map((value,index)=>(<label className="flex items-center rounded bg-[#353535] px-1" key={index}>
|
|
||||||
<span className="text-[10px] text-white/40">{"XYZ"[index]}</span>
|
|
||||||
<input aria-label={`${key}-${"XYZ"[index]}`} className="w-full min-w-0 bg-transparent px-1 py-1 text-[11px] text-white outline-none" type="number" step="0.1"
|
|
||||||
value={Number((key==="rotation"?value*180/Math.PI:value).toFixed(2))}
|
|
||||||
onChange={event=>{const next=Number(event.target.value);if(!Number.isFinite(next)||(key==="scale"&&next<=0))return;const values=[...object[key]] as DirectorVector;values[index]=key==="rotation"?next*Math.PI/180:next;patchObject(object.id,{[key]:values})}}/>
|
|
||||||
</label>))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</> : <select aria-label="角色姿势" className={field} value={object.pose||""} onChange={event=>patchObject(object.id,{pose:event.target.value as DirectorPoseName})}>
|
|
||||||
<option value="">默认姿势</option>{Object.keys(directorPoses).map(pose=><option key={pose} value={pose}>{pose}</option>)}
|
|
||||||
</select>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<details className="border-t border-white/10 pt-2"><summary className="cursor-pointer text-xs text-white/55">相机与高级属性</summary>
|
|
||||||
<fieldset disabled={busy || previewTime !== null} className="mt-3 space-y-4">
|
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
机位
|
机位
|
||||||
<select
|
<select
|
||||||
@ -838,7 +770,6 @@ export function DirectorStudioDialog({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</details>
|
|
||||||
{exportedVideo ? (
|
{exportedVideo ? (
|
||||||
<section className="space-y-2 border-t border-white/10 pt-3" aria-label="运镜导出结果">
|
<section className="space-y-2 border-t border-white/10 pt-3" aria-label="运镜导出结果">
|
||||||
<video
|
<video
|
||||||
@ -903,6 +834,7 @@ export function DirectorStudioDialog({
|
|||||||
</div>
|
</div>
|
||||||
<details
|
<details
|
||||||
ref={timelineDrawer}
|
ref={timelineDrawer}
|
||||||
|
open
|
||||||
className="max-h-[32vh] shrink-0 overflow-auto border-t border-white/15 bg-[#1c1c1c] px-4 pb-3"
|
className="max-h-[32vh] shrink-0 overflow-auto border-t border-white/15 bg-[#1c1c1c] px-4 pb-3"
|
||||||
>
|
>
|
||||||
<summary className="cursor-pointer py-2 text-sm">
|
<summary className="cursor-pointer py-2 text-sm">
|
||||||
@ -983,7 +915,7 @@ export function DirectorStudioDialog({
|
|||||||
const bitmap = await createImageBitmap(file);
|
const bitmap = await createImageBitmap(file);
|
||||||
const ratio = bitmap.width / bitmap.height;
|
const ratio = bitmap.width / bitmap.height;
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
if (Math.abs(ratio - 2) > 0.02) throw new Error("请上传 2:1 比例的等距柱状全景图");
|
if (Math.abs(ratio - 2) > 0.02) throw Error("请上传 2:1 比例的等距柱状全景图");
|
||||||
const url = await upload(file);
|
const url = await upload(file);
|
||||||
commit({
|
commit({
|
||||||
...current.current,
|
...current.current,
|
||||||
@ -1016,7 +948,7 @@ export function DirectorStudioDialog({
|
|||||||
try {
|
try {
|
||||||
const header = new DataView(await file.slice(0, 12).arrayBuffer());
|
const header = new DataView(await file.slice(0, 12).arrayBuffer());
|
||||||
if (header.byteLength < 12 || header.getUint32(0, true) !== 0x46546c67)
|
if (header.byteLength < 12 || header.getUint32(0, true) !== 0x46546c67)
|
||||||
throw new Error("文件不是有效 GLB 模型");
|
throw Error("文件不是有效 GLB 模型");
|
||||||
const url = await upload(file);
|
const url = await upload(file);
|
||||||
addObject("model", file.name, url);
|
addObject("model", file.name, url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -56,7 +56,6 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
orbit: OrbitControls;
|
orbit: OrbitControls;
|
||||||
transform: TransformControls;
|
transform: TransformControls;
|
||||||
helper: THREE.Object3D;
|
helper: THREE.Object3D;
|
||||||
floor: THREE.Mesh;
|
|
||||||
grid: THREE.GridHelper;
|
grid: THREE.GridHelper;
|
||||||
path: THREE.Group;
|
path: THREE.Group;
|
||||||
objects: THREE.Group;
|
objects: THREE.Group;
|
||||||
@ -70,12 +69,6 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [backgroundPending, setBackgroundPending] = useState(false);
|
const [backgroundPending, setBackgroundPending] = useState(false);
|
||||||
const [backgroundError, setBackgroundError] = useState("");
|
const [backgroundError, setBackgroundError] = useState("");
|
||||||
useEffect(() => {
|
|
||||||
const r = runtime.current;
|
|
||||||
if (!r) return;
|
|
||||||
r.grid.visible = props.scene.gridVisible !== false;
|
|
||||||
r.floor.visible = props.scene.groundVisible !== false;
|
|
||||||
}, [props.scene.gridVisible, props.scene.groundVisible, ready]);
|
|
||||||
const applyObjectTime = (time: number | null) => {
|
const applyObjectTime = (time: number | null) => {
|
||||||
const objects = sampleDirectorObjects(latest.current.scene, time),
|
const objects = sampleDirectorObjects(latest.current.scene, time),
|
||||||
r = runtime.current;
|
r = runtime.current;
|
||||||
@ -106,7 +99,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
}
|
}
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
renderer.setClearColor("#080808");
|
renderer.setClearColor("#c5cbd3");
|
||||||
host.current.appendChild(renderer.domElement);
|
host.current.appendChild(renderer.domElement);
|
||||||
renderer.domElement.setAttribute("aria-label", "3D 导演台场景");
|
renderer.domElement.setAttribute("aria-label", "3D 导演台场景");
|
||||||
Object.assign(renderer.domElement.style, {
|
Object.assign(renderer.domElement.style, {
|
||||||
@ -116,19 +109,19 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
transform: "translate(-50%, -50%)",
|
transform: "translate(-50%, -50%)",
|
||||||
});
|
});
|
||||||
const world = new THREE.Scene();
|
const world = new THREE.Scene();
|
||||||
world.background = new THREE.Color(props.scene.skyColor || "#080808");
|
world.background = new THREE.Color("#c5cbd3");
|
||||||
world.add(new THREE.HemisphereLight(0xffffff, 0x647080, 2.4));
|
world.add(new THREE.HemisphereLight(0xffffff, 0x647080, 2.4));
|
||||||
const light = new THREE.DirectionalLight(0xffffff, 3);
|
const light = new THREE.DirectionalLight(0xffffff, 3);
|
||||||
light.position.set(4, 8, 5);
|
light.position.set(4, 8, 5);
|
||||||
world.add(light);
|
world.add(light);
|
||||||
const floor = new THREE.Mesh(
|
const floor = new THREE.Mesh(
|
||||||
new THREE.PlaneGeometry(100, 100),
|
new THREE.PlaneGeometry(100, 100),
|
||||||
new THREE.MeshBasicMaterial({ color: 0x0b0f15 }),
|
new THREE.MeshStandardMaterial({ color: 0xb1b6be, roughness: 1 }),
|
||||||
);
|
);
|
||||||
floor.rotation.x = -Math.PI / 2;
|
floor.rotation.x = -Math.PI / 2;
|
||||||
floor.position.y = -0.02;
|
floor.position.y = -0.02;
|
||||||
world.add(floor);
|
world.add(floor);
|
||||||
const grid = new THREE.GridHelper(30, 30, 0x21445b, 0x183346);
|
const grid = new THREE.GridHelper(30, 30, 0x596573, 0x929daa);
|
||||||
world.add(grid);
|
world.add(grid);
|
||||||
const path = new THREE.Group();
|
const path = new THREE.Group();
|
||||||
world.add(path);
|
world.add(path);
|
||||||
@ -178,7 +171,6 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
orbit,
|
orbit,
|
||||||
transform,
|
transform,
|
||||||
helper,
|
helper,
|
||||||
floor,
|
|
||||||
grid,
|
grid,
|
||||||
path,
|
path,
|
||||||
objects,
|
objects,
|
||||||
@ -308,7 +300,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
!size.toArray().every(Number.isFinite) ||
|
!size.toArray().every(Number.isFinite) ||
|
||||||
size.length() < 0.000001
|
size.length() < 0.000001
|
||||||
) {
|
) {
|
||||||
throw new Error("模型没有可显示的有效几何内容");
|
throw Error("模型没有可显示的有效几何内容");
|
||||||
}
|
}
|
||||||
const scale = 2 / Math.max(size.x, size.y, size.z, 0.001);
|
const scale = 2 / Math.max(size.x, size.y, size.z, 0.001);
|
||||||
g.scene.scale.setScalar(scale);
|
g.scene.scale.setScalar(scale);
|
||||||
@ -423,7 +415,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
let active = true;
|
let active = true;
|
||||||
let texture: THREE.Texture | undefined;
|
let texture: THREE.Texture | undefined;
|
||||||
setBackgroundError("");
|
setBackgroundError("");
|
||||||
r.world.background = new THREE.Color(props.scene.skyColor || "#080808");
|
r.world.background = new THREE.Color("#c5cbd3");
|
||||||
if (!props.scene.backgroundUrl) {
|
if (!props.scene.backgroundUrl) {
|
||||||
setBackgroundPending(false);
|
setBackgroundPending(false);
|
||||||
return;
|
return;
|
||||||
@ -462,10 +454,10 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (r.world.background === texture) r.world.background = new THREE.Color(latest.current.scene.skyColor || "#080808");
|
if (r.world.background === texture) r.world.background = new THREE.Color("#c5cbd3");
|
||||||
texture?.dispose();
|
texture?.dispose();
|
||||||
};
|
};
|
||||||
}, [props.scene.backgroundUrl, props.scene.skyColor, ready, resourceRevision]);
|
}, [props.scene.backgroundUrl, ready, resourceRevision]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const r = runtime.current;
|
const r = runtime.current;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
@ -612,16 +604,16 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
},
|
},
|
||||||
record: async (signal, onProgress) => {
|
record: async (signal, onProgress) => {
|
||||||
if (backgroundPending || backgroundError)
|
if (backgroundPending || backgroundError)
|
||||||
throw new Error(backgroundError || "全景背景仍在加载,请稍后导出");
|
throw Error(backgroundError || "全景背景仍在加载,请稍后导出");
|
||||||
const r = runtime.current;
|
const r = runtime.current;
|
||||||
if (!r || !ready || r.pending || error) throw new Error(error || "3D 场景尚未准备好");
|
if (!r || !ready || r.pending || error) throw Error(error || "3D 场景尚未准备好");
|
||||||
if (
|
if (
|
||||||
latest.current.scene.motion.keyframes.length < 2 &&
|
latest.current.scene.motion.keyframes.length < 2 &&
|
||||||
!Object.values(latest.current.scene.objectTracks || {}).some(
|
!Object.values(latest.current.scene.objectTracks || {}).some(
|
||||||
(t) => !t.muted && t.keyframes.length >= 2,
|
(t) => !t.muted && t.keyframes.length >= 2,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
throw new Error("相机或对象轨道至少需要两个关键帧");
|
throw Error("相机或对象轨道至少需要两个关键帧");
|
||||||
const motion = latest.current.scene.motion;
|
const motion = latest.current.scene.motion;
|
||||||
const size = r.renderer.getSize(new THREE.Vector2()),
|
const size = r.renderer.getSize(new THREE.Vector2()),
|
||||||
pixelRatio = r.renderer.getPixelRatio();
|
pixelRatio = r.renderer.getPixelRatio();
|
||||||
@ -647,7 +639,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
onProgress,
|
onProgress,
|
||||||
render: (time) => {
|
render: (time) => {
|
||||||
if (r.renderer.getContext().isContextLost())
|
if (r.renderer.getContext().isContextLost())
|
||||||
throw new Error("3D 显示连接中断,已取消视频导出");
|
throw Error("3D 显示连接中断,已取消视频导出");
|
||||||
const pose = resolveDirectorCamera(
|
const pose = resolveDirectorCamera(
|
||||||
applyObjectTime(time),
|
applyObjectTime(time),
|
||||||
sampleDirectorMotion(motion, time) || undefined,
|
sampleDirectorMotion(motion, time) || undefined,
|
||||||
@ -678,9 +670,9 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
},
|
},
|
||||||
capture: async (pose, time) => {
|
capture: async (pose, time) => {
|
||||||
if (backgroundPending || backgroundError)
|
if (backgroundPending || backgroundError)
|
||||||
throw new Error(backgroundError || "全景背景仍在加载,请稍后截图");
|
throw Error(backgroundError || "全景背景仍在加载,请稍后截图");
|
||||||
const r = runtime.current;
|
const r = runtime.current;
|
||||||
if (!r || !ready || r.pending || error) throw new Error(error || "3D 场景尚未准备好");
|
if (!r || !ready || r.pending || error) throw Error(error || "3D 场景尚未准备好");
|
||||||
const { width, height } = directorImageSize(latest.current.scene.ratio);
|
const { width, height } = directorImageSize(latest.current.scene.ratio);
|
||||||
const previousSize = r.renderer.getSize(new THREE.Vector2());
|
const previousSize = r.renderer.getSize(new THREE.Vector2());
|
||||||
const pixelRatio = r.renderer.getPixelRatio();
|
const pixelRatio = r.renderer.getPixelRatio();
|
||||||
@ -693,10 +685,10 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
r.orbit.enabled = false;
|
r.orbit.enabled = false;
|
||||||
const capturedScene = applyObjectTime(time ?? latest.current.previewTime);
|
const capturedScene = applyObjectTime(time ?? latest.current.previewTime);
|
||||||
if (pose) {
|
if (pose) {
|
||||||
const resolvedPose = resolveDirectorCamera(capturedScene, pose);
|
pose = resolveDirectorCamera(capturedScene, pose);
|
||||||
r.camera.position.fromArray(resolvedPose.position);
|
r.camera.position.fromArray(pose.position);
|
||||||
r.orbit.target.fromArray(resolvedPose.target);
|
r.orbit.target.fromArray(pose.target);
|
||||||
r.camera.fov = resolvedPose.fov;
|
r.camera.fov = pose.fov;
|
||||||
r.camera.lookAt(r.orbit.target);
|
r.camera.lookAt(r.orbit.target);
|
||||||
}
|
}
|
||||||
r.path.visible = false;
|
r.path.visible = false;
|
||||||
@ -710,7 +702,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
|||||||
try {
|
try {
|
||||||
const blob = await new Promise<Blob>((resolve, reject) =>
|
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||||
r.renderer.domElement.toBlob(
|
r.renderer.domElement.toBlob(
|
||||||
(b) => (b ? resolve(b) : reject(new Error("截图失败"))),
|
(b) => (b ? resolve(b) : reject(Error("截图失败"))),
|
||||||
"image/png",
|
"image/png",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
|
||||||
toast.success(`${actionName}完成`);
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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,8 +10217,7 @@ 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"
|
||||||
>
|
>
|
||||||
{isAddingToChat ? (
|
{isAddingToChat ? (
|
||||||
@ -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,8 +10866,7 @@ 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"
|
||||||
>
|
>
|
||||||
{isDownloadingOriginalImage ? (
|
{isDownloadingOriginalImage ? (
|
||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}}
|
|
||||||
/>;
|
|
||||||
})}</>;
|
|
||||||
});
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,9 +50,9 @@ export function SmartEditDialog({
|
|||||||
assets: [],
|
assets: [],
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
prompt: "",
|
prompt: "",
|
||||||
targetDuration: 30,
|
targetDuration: 20,
|
||||||
ratio: "16:9",
|
ratio: "16:9",
|
||||||
resolution: "720p",
|
resolution: "1080p",
|
||||||
semantic: true,
|
semantic: true,
|
||||||
removeSilence: false,
|
removeSilence: false,
|
||||||
transcribe: false,
|
transcribe: false,
|
||||||
@ -90,23 +90,6 @@ export function SmartEditDialog({
|
|||||||
[zoom, setZoom] = useState(1),
|
[zoom, setZoom] = useState(1),
|
||||||
[snap, setSnap] = useState(true);
|
[snap, setSnap] = useState(true);
|
||||||
const [configure, setConfigure] = useState(false);
|
const [configure, setConfigure] = useState(false);
|
||||||
const [videoDragActive, setVideoDragActive] = useState(false);
|
|
||||||
const videoDragDepthRef = useRef(0);
|
|
||||||
const uploadVideoFiles = async (files: File[]) => {
|
|
||||||
if (!files.length) return;
|
|
||||||
if (files.length + draft.assets.length > 12) {
|
|
||||||
setError("最多 12 个素材");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
for (const file of files) await upload(file, "video");
|
|
||||||
} catch (e) {
|
|
||||||
setError((e as Error).message);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const applied = useRef(planTaskId);
|
const applied = useRef(planTaskId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (planTaskId && planTaskId !== applied.current) {
|
if (planTaskId && planTaskId !== applied.current) {
|
||||||
@ -129,7 +112,7 @@ export function SmartEditDialog({
|
|||||||
fetch("/api/canvas/smart-edit/capabilities", { signal: controller.signal })
|
fetch("/api/canvas/smart-edit/capabilities", { signal: controller.signal })
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (!r.ok) throw new Error(data.error);
|
if (!r.ok) throw Error(data.error);
|
||||||
setCap(data);
|
setCap(data);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@ -202,26 +185,17 @@ export function SmartEditDialog({
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
};
|
};
|
||||||
const upload = async (file: File, kind: "video" | "audio") => {
|
const upload = async (file: File, kind: "video" | "audio") => {
|
||||||
if (file.size > 125 * 1024 * 1024) throw new Error("单个素材不得超过 125 MiB");
|
if (file.size > 125 * 1024 * 1024) throw Error("单个素材不得超过 125 MiB");
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
let response: Response;
|
const response = await fetch("/api/canvas/media/upload", {
|
||||||
try {
|
method: "POST",
|
||||||
response = await fetch("/api/canvas/media/upload", {
|
body: form,
|
||||||
method: "POST",
|
signal: AbortSignal.timeout(120000),
|
||||||
body: form,
|
});
|
||||||
signal: AbortSignal.timeout(600000),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError")
|
|
||||||
? "上传超时,请检查网络后重试,或改用更小的素材"
|
|
||||||
: "上传失败,请检查网络后重试",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
const url = result.url || result.data?.url;
|
const url = result.url || result.data?.url;
|
||||||
if (!response.ok || !url) throw new Error(result.error || "上传失败");
|
if (!response.ok || !url) throw Error(result.error || "上传失败");
|
||||||
if (kind === "audio") patch({ music: { url, gain: 0.2, start: 0 } });
|
if (kind === "audio") patch({ music: { url, gain: 0.2, start: 0 } });
|
||||||
else
|
else
|
||||||
patch({
|
patch({
|
||||||
@ -334,43 +308,7 @@ export function SmartEditDialog({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="智能剪辑"
|
aria-label="智能剪辑"
|
||||||
className="fixed inset-0 z-[160] flex flex-col bg-[#171717] text-white"
|
className="fixed inset-0 z-[160] flex flex-col bg-[#171717] text-white"
|
||||||
onDragEnter={(e) => {
|
|
||||||
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
videoDragDepthRef.current += 1;
|
|
||||||
setVideoDragActive(true);
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = "copy";
|
|
||||||
}}
|
|
||||||
onDragLeave={(e) => {
|
|
||||||
videoDragDepthRef.current = Math.max(0, videoDragDepthRef.current - 1);
|
|
||||||
if (!videoDragDepthRef.current) setVideoDragActive(false);
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
videoDragDepthRef.current = 0;
|
|
||||||
setVideoDragActive(false);
|
|
||||||
const files = Array.from(e.dataTransfer.files).filter((file) =>
|
|
||||||
file.type.startsWith("video/"),
|
|
||||||
);
|
|
||||||
if (!files.length) {
|
|
||||||
setError("请拖入 MP4、MOV 或 WebM 视频素材");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void uploadVideoFiles(files);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{videoDragActive ? (
|
|
||||||
<div className="pointer-events-none fixed inset-0 z-[300] flex items-center justify-center border-4 border-dashed border-cyan-300/70 bg-cyan-300/5">
|
|
||||||
<span className="rounded-lg bg-black/70 px-4 py-2 text-sm">
|
|
||||||
松开以上传视频素材
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<header className="flex shrink-0 items-center justify-between border-b border-white/15 px-5 py-3">
|
<header className="flex shrink-0 items-center justify-between border-b border-white/15 px-5 py-3">
|
||||||
<h2>智能剪辑 · {plan?.title || "从素材到成片"}</h2>
|
<h2>智能剪辑 · {plan?.title || "从素材到成片"}</h2>
|
||||||
{plan && (
|
{plan && (
|
||||||
@ -433,7 +371,18 @@ export function SmartEditDialog({
|
|||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
await uploadVideoFiles(files);
|
if (files.length + draft.assets.length > 12) {
|
||||||
|
setError("最多 12 个素材");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) await upload(file, "video");
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@ -27,33 +27,6 @@
|
|||||||
border-radius: 10px 10px 0 0;
|
border-radius: 10px 10px 0 0;
|
||||||
background: #202020;
|
background: #202020;
|
||||||
}
|
}
|
||||||
.emptyCard .preview {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
.emptyActions {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 24px;
|
|
||||||
left: 28px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
color: #a5a5a5;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.emptyActions button {
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #eee;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.emptyActions button:hover {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.preview > video {
|
.preview > video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -274,15 +247,6 @@
|
|||||||
left: 112px;
|
left: 112px;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
}
|
}
|
||||||
.musicDetails {
|
|
||||||
margin-top: 10px;
|
|
||||||
border-top: 1px solid #393939;
|
|
||||||
padding-top: 7px;
|
|
||||||
}
|
|
||||||
.musicDetails summary {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.settings label {
|
.settings label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -420,14 +384,3 @@
|
|||||||
.settings select { width: 100%; height: 30px; padding: 0 8px; border-radius: 6px; background: #333; color: #eee; border: 0; }
|
.settings select { width: 100%; height: 30px; padding: 0 8px; border-radius: 6px; background: #333; color: #eee; border: 0; }
|
||||||
.musicUpload { display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 12px; }
|
.musicUpload { display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 12px; }
|
||||||
.settings label.beatToggle { display: flex; gap: 8px; align-items: center; }
|
.settings label.beatToggle { display: flex; gap: 8px; align-items: center; }
|
||||||
|
|
||||||
.emptyCard .open {
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding-top: 94px;
|
|
||||||
gap: 16px;
|
|
||||||
color: #999;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.emptyCard .open svg {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import {
|
|||||||
import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
|
import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent";
|
||||||
import styles from "./SmartEditNode.module.css";
|
import styles from "./SmartEditNode.module.css";
|
||||||
export type SmartEditSource = { id: string; name: string; url: string; duration?: number };
|
export type SmartEditSource = { id: string; name: string; url: string; duration?: number };
|
||||||
export type SmartEditMode = "explain" | "ads" | "talk" | "montage";
|
|
||||||
const modes = [
|
const modes = [
|
||||||
{ id: "auto", name: "默认模式" },
|
{ id: "auto", name: "默认模式" },
|
||||||
{ id: "explain", name: "讲解视频" },
|
{ id: "explain", name: "讲解视频" },
|
||||||
@ -35,7 +34,6 @@ export function SmartEditNodeCard({
|
|||||||
previewUrl,
|
previewUrl,
|
||||||
plan,
|
plan,
|
||||||
onOpen,
|
onOpen,
|
||||||
onMode,
|
|
||||||
selected = false,
|
selected = false,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
@ -43,7 +41,6 @@ export function SmartEditNodeCard({
|
|||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
plan?: EditPlan;
|
plan?: EditPlan;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onMode?: (mode: SmartEditMode) => void;
|
|
||||||
}) {
|
}) {
|
||||||
let timeline: ReturnType<typeof planTimeline> | undefined;
|
let timeline: ReturnType<typeof planTimeline> | undefined;
|
||||||
try {
|
try {
|
||||||
@ -53,9 +50,8 @@ export function SmartEditNodeCard({
|
|||||||
}
|
}
|
||||||
const length = timeline?.duration || 0,
|
const length = timeline?.duration || 0,
|
||||||
tick = Math.max(1, Math.ceil(length / SECOND / 5));
|
tick = Math.max(1, Math.ceil(length / SECOND / 5));
|
||||||
const empty = !timeline && !previewUrl;
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.card} ${empty ? styles.emptyCard : ""}`} style={{ borderColor: selected ? "#888" : "#454545" }}>
|
<div className={styles.card} style={{ borderColor: selected ? "#888" : "#454545" }}>
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
<Play size={12} fill="currentColor" />
|
<Play size={12} fill="currentColor" />
|
||||||
{title}
|
{title}
|
||||||
@ -64,29 +60,11 @@ export function SmartEditNodeCard({
|
|||||||
{previewUrl && <video src={`${previewUrl}#t=0.1`} muted preload="metadata" />}
|
{previewUrl && <video src={`${previewUrl}#t=0.1`} muted preload="metadata" />}
|
||||||
<div className={styles.open}>
|
<div className={styles.open}>
|
||||||
<Scissors size={36} />
|
<Scissors size={36} />
|
||||||
{empty ? (
|
<button onPointerDown={(e) => e.stopPropagation()} onClick={onOpen}>
|
||||||
<span>空空如也,请连接视频节点后操作</span>
|
打开智能剪辑
|
||||||
) : (
|
</button>
|
||||||
<button onPointerDown={(e) => e.stopPropagation()} onClick={onOpen}>
|
|
||||||
打开智能剪辑
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{empty ? (
|
|
||||||
<div className={styles.emptyActions}>
|
|
||||||
<span>尝试:</span>
|
|
||||||
{([
|
|
||||||
["explain", "讲解视频"],
|
|
||||||
["ads", "批量广告"],
|
|
||||||
["talk", "口播视频"],
|
|
||||||
["montage", "素材混剪"],
|
|
||||||
] as const).map(([mode, label]) => (
|
|
||||||
<button key={mode} onPointerDown={(e) => e.stopPropagation()} onClick={() => onMode?.(mode)} type="button">{label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{!empty ? <>
|
|
||||||
<div className={styles.ruler} aria-label="剪辑时间刻度">
|
<div className={styles.ruler} aria-label="剪辑时间刻度">
|
||||||
{timeline ? (
|
{timeline ? (
|
||||||
Array.from({ length: Math.floor(length / SECOND / tick) + 1 }, (_, i) => {
|
Array.from({ length: Math.floor(length / SECOND / tick) + 1 }, (_, i) => {
|
||||||
@ -148,7 +126,6 @@ export function SmartEditNodeCard({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</> : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -195,8 +172,8 @@ export function SmartEditNodeComposer({
|
|||||||
return () => document.removeEventListener("pointerdown", close);
|
return () => document.removeEventListener("pointerdown", close);
|
||||||
}, []);
|
}, []);
|
||||||
const ratio = draft.ratio || "16:9",
|
const ratio = draft.ratio || "16:9",
|
||||||
resolution = draft.resolution || "720p",
|
resolution = draft.resolution || "1080p",
|
||||||
duration = draft.targetDuration ?? 30,
|
duration = draft.targetDuration ?? 20,
|
||||||
mode = modes.find((m) => m.id === draft.mode) || modes[0];
|
mode = modes.find((m) => m.id === draft.mode) || modes[0];
|
||||||
const toggle = (value: typeof menu) => setMenu(menu === value ? null : value);
|
const toggle = (value: typeof menu) => setMenu(menu === value ? null : value);
|
||||||
return (
|
return (
|
||||||
@ -259,23 +236,14 @@ export function SmartEditNodeComposer({
|
|||||||
try {
|
try {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
let response: Response;
|
const response = await fetch("/api/canvas/media/upload", {
|
||||||
try {
|
method: "POST",
|
||||||
response = await fetch("/api/canvas/media/upload", {
|
body,
|
||||||
method: "POST",
|
signal: AbortSignal.timeout(120000),
|
||||||
body,
|
});
|
||||||
signal: AbortSignal.timeout(600000),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError")
|
|
||||||
? "上传超时,请检查网络后重试,或改用更小的素材"
|
|
||||||
: "上传失败,请检查网络后重试",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await response.json(),
|
const data = await response.json(),
|
||||||
url = data.url || data.data?.url;
|
url = data.url || data.data?.url;
|
||||||
if (!response.ok || !url) throw new Error(data.error || "音乐上传失败");
|
if (!response.ok || !url) throw Error(data.error || "音乐上传失败");
|
||||||
onDraft({ music: { url, gain: 0.2, start: 0 } });
|
onDraft({ music: { url, gain: 0.2, start: 0 } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "音乐上传失败");
|
setError(e instanceof Error ? e.message : "音乐上传失败");
|
||||||
@ -403,8 +371,6 @@ export function SmartEditNodeComposer({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<details className={styles.musicDetails}>
|
|
||||||
<summary>背景音乐与卡点</summary>
|
|
||||||
<label htmlFor="smart-edit-music">背景音乐</label>
|
<label htmlFor="smart-edit-music">背景音乐</label>
|
||||||
<select
|
<select
|
||||||
id="smart-edit-music"
|
id="smart-edit-music"
|
||||||
@ -456,7 +422,6 @@ export function SmartEditNodeComposer({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</details>
|
|
||||||
<label>清晰度</label>
|
<label>清晰度</label>
|
||||||
<div className={styles.resolutions}>
|
<div className={styles.resolutions}>
|
||||||
{editResolutions.map((r) => (
|
{editResolutions.map((r) => (
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ArrowRight, Check, Loader2, Sparkles, Upload, X } from "lucide-react";
|
|
||||||
import { requestCreativeAnalysis, type CreativeAnalysisModel } from "./creativeAnalysisClient";
|
|
||||||
|
|
||||||
export type StudioCharacter = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
look?: string;
|
|
||||||
wardrobe?: string;
|
|
||||||
signatureAction?: string;
|
|
||||||
traits?: string[];
|
|
||||||
prompt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = ["电影写实", "国风动画", "二维动漫", "三维动画", "插画概念"];
|
|
||||||
const views = ["角色立绘", "脸部近景", "表情参考", "角色三视图"] as const;
|
|
||||||
export type StudioView = (typeof views)[number];
|
|
||||||
|
|
||||||
export function CharacterStudioPanel({
|
|
||||||
canvasId,
|
|
||||||
models,
|
|
||||||
onApply,
|
|
||||||
onClose,
|
|
||||||
onOpenLibrary,
|
|
||||||
onUpload,
|
|
||||||
}: {
|
|
||||||
canvasId: string;
|
|
||||||
models: CreativeAnalysisModel[];
|
|
||||||
onApply: (character: StudioCharacter, style: string, selectedViews: StudioView[], referenceUrl?: string) => Promise<boolean>;
|
|
||||||
onClose: () => void;
|
|
||||||
onOpenLibrary: () => void;
|
|
||||||
onUpload: (file: File) => Promise<string>;
|
|
||||||
}) {
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [style, setStyle] = useState(styles[0]);
|
|
||||||
const [characters, setCharacters] = useState<StudioCharacter[]>([]);
|
|
||||||
const [selected, setSelected] = useState(0);
|
|
||||||
const [selectedViews, setSelectedViews] = useState<StudioView[]>([...views]);
|
|
||||||
const [referenceUrl, setReferenceUrl] = useState<string>();
|
|
||||||
const [referenceName, setReferenceName] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [restoredDraftFor, setRestoredDraftFor] = useState("");
|
|
||||||
const character = characters[selected];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const draft = JSON.parse(localStorage.getItem(`tvstar:character-draft:${canvasId}`) || "null");
|
|
||||||
if (draft && typeof draft === "object") {
|
|
||||||
if (typeof draft.description === "string") setDescription(draft.description);
|
|
||||||
if (styles.includes(draft.style)) setStyle(draft.style);
|
|
||||||
if (Array.isArray(draft.characters)) setCharacters(draft.characters.filter((item: StudioCharacter) =>
|
|
||||||
item && typeof item.name === "string" && typeof item.prompt === "string").slice(0, 8));
|
|
||||||
if (Number.isInteger(draft.selected)) setSelected(Math.max(0, draft.selected));
|
|
||||||
if (Array.isArray(draft.selectedViews)) setSelectedViews(draft.selectedViews.filter((item: StudioView) =>
|
|
||||||
views.includes(item)));
|
|
||||||
if (typeof draft.referenceUrl === "string") setReferenceUrl(draft.referenceUrl);
|
|
||||||
if (typeof draft.referenceName === "string") setReferenceName(draft.referenceName);
|
|
||||||
}
|
|
||||||
} catch { /* a damaged local draft must not block the studio */ }
|
|
||||||
setRestoredDraftFor(canvasId);
|
|
||||||
}, [canvasId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (restoredDraftFor !== canvasId) return;
|
|
||||||
try { localStorage.setItem(`tvstar:character-draft:${canvasId}`, JSON.stringify({
|
|
||||||
description, style, characters, selected, selectedViews, referenceUrl, referenceName,
|
|
||||||
})); } catch { /* storage is optional */ }
|
|
||||||
}, [canvasId, characters, description, referenceName, referenceUrl, restoredDraftFor, selected, selectedViews, style]);
|
|
||||||
|
|
||||||
const resetDraft = () => {
|
|
||||||
setDescription(""); setStyle(styles[0]); setCharacters([]); setSelected(0);
|
|
||||||
setSelectedViews([...views]); setReferenceUrl(undefined); setReferenceName(""); setError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const analyze = async () => {
|
|
||||||
if (!description.trim() || busy) return;
|
|
||||||
setError(""); setBusy(true);
|
|
||||||
try {
|
|
||||||
const data = await requestCreativeAnalysis({
|
|
||||||
mode: "character-scene-extraction",
|
|
||||||
nodeTitle: "角色造型室",
|
|
||||||
models,
|
|
||||||
prompt: `视觉风格:${style}。请提取并具体定义角色身份、稳定的面部与服装特征,提供可复用的造型提示词。\n角色描述:${description.trim()}`,
|
|
||||||
imageUrls: referenceUrl ? [referenceUrl] : [],
|
|
||||||
});
|
|
||||||
const items = Array.isArray(data.characters) ? data.characters.filter((item): item is StudioCharacter => Boolean(item && typeof item === "object" && (item as StudioCharacter).name && (item as StudioCharacter).prompt)).slice(0, 8) : [];
|
|
||||||
if (!items.length) throw new Error("模型没有返回有效角色,请补充外观描述后重试");
|
|
||||||
setCharacters(items);
|
|
||||||
setSelected(0);
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "角色分析失败"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const upload = async (file?: File) => {
|
|
||||||
if (!file || !file.type.startsWith("image/")) return;
|
|
||||||
setError(""); setBusy(true);
|
|
||||||
try { setReferenceUrl(await onUpload(file)); setReferenceName(file.name); }
|
|
||||||
catch (cause) { setError(cause instanceof Error ? cause.message : "参考图上传失败"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleView = (view: StudioView) => setSelectedViews(current => current.includes(view) ? current.filter(item => item !== view) : [...current, view]);
|
|
||||||
|
|
||||||
const apply = async () => {
|
|
||||||
if (!character || !selectedViews.length || busy) return;
|
|
||||||
setError(""); setBusy(true);
|
|
||||||
try {
|
|
||||||
if (!await onApply(character, style, selectedViews, referenceUrl))
|
|
||||||
setError("角色视图任务未能启动,请检查模型与生成配置后重试");
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "角色视图任务未能启动"); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/65 p-4" data-canvas-overlay="true" role="presentation">
|
|
||||||
<section aria-label="角色造型室" aria-modal="true" className="flex h-[min(720px,calc(100vh-32px))] w-[min(1060px,calc(100vw-32px))] flex-col overflow-hidden rounded-[22px] border border-white/10 bg-[#202020] text-white shadow-[0_30px_90px_rgba(0,0,0,.6)]" role="dialog">
|
|
||||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-white/10 px-5"><div><h2 className="text-base font-medium">角色造型室</h2><p className="text-[11px] text-white/40">建立稳定角色设定,再送入画布生成视图</p></div><div className="flex items-center gap-2"><button className="rounded-lg px-2 py-1 text-xs text-white/50 hover:bg-white/10" disabled={busy} onClick={resetDraft}>新角色</button><button aria-label="关闭角色造型室" className="rounded-lg p-2 text-white/60 hover:bg-white/10" onClick={onClose}><X className="size-4" /></button></div></header>
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(260px,340px)_1fr] max-md:grid-cols-1">
|
|
||||||
<div className="flex min-h-0 flex-col border-r border-white/10 p-5 max-md:border-b max-md:border-r-0">
|
|
||||||
<label className="mb-2 text-xs text-white/65" htmlFor="tvstar-character-description">角色描述</label>
|
|
||||||
<textarea className="min-h-44 w-full resize-none rounded-xl border border-white/10 bg-[#292929] p-3 text-[13px] leading-6 outline-none placeholder:text-white/30 focus:border-cyan-300/50" id="tvstar-character-description" onChange={event => setDescription(event.target.value)} placeholder="例如:二十多岁的机械师,短银发,左眼下有一颗痣,穿旧皮夹克,性格沉着…" value={description} />
|
|
||||||
<label className="mb-2 mt-4 text-xs text-white/65" htmlFor="tvstar-character-style">视觉风格</label>
|
|
||||||
<select className="h-10 rounded-xl border border-white/10 bg-[#292929] px-3 text-[13px] outline-none" id="tvstar-character-style" onChange={event => setStyle(event.target.value)} value={style}>{styles.map(item => <option key={item}>{item}</option>)}</select>
|
|
||||||
<label className="mt-4 flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-white/15 bg-white/[.03] px-3 py-3 text-xs text-white/60 hover:bg-white/[.06]"><Upload className="size-4" />{referenceName || "上传角色参考图(可选)"}<input accept="image/*" className="hidden" onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; void upload(file); }} type="file" /></label>
|
|
||||||
{referenceUrl ? <button className="mt-2 self-start text-[11px] text-white/40 hover:text-white" onClick={() => { setReferenceUrl(undefined); setReferenceName(""); }}>移除参考图</button> : null}
|
|
||||||
{error ? <p className="mt-3 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-200" role="alert">{error}</p> : null}
|
|
||||||
<div className="mt-auto space-y-2 pt-5"><button className="flex h-10 w-full items-center justify-center gap-2 rounded-xl bg-white text-[13px] font-medium text-[#1f1f1f] disabled:opacity-40" disabled={!description.trim() || busy} onClick={() => void analyze()}>{busy ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}{busy ? "正在分析角色…" : "分析角色设定"}</button><button className="flex h-9 w-full items-center justify-center gap-1 rounded-xl text-xs text-white/55 hover:bg-white/5" onClick={onOpenLibrary}>浏览现有角色库 <ArrowRight className="size-3" /></button></div>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 overflow-y-auto p-5">
|
|
||||||
{character ? <>
|
|
||||||
<div className="mb-4 flex flex-wrap gap-2">{characters.map((item, index) => <button aria-pressed={selected === index} className={`rounded-lg px-3 py-1.5 text-xs ${selected === index ? "bg-white text-[#252525]" : "bg-white/10 text-white/65 hover:bg-white/15"}`} key={`${index}-${item.name}`} onClick={() => setSelected(index)}>{item.name}</button>)}</div>
|
|
||||||
<h3 className="mb-2 text-lg font-medium">{character.name}</h3><p className="text-[13px] leading-6 text-white/65">{character.description}</p>
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2 text-xs max-sm:grid-cols-1">{[["外观锚点", character.look], ["服装与道具", character.wardrobe], ["代表动作", character.signatureAction], ["性格关键词", character.traits?.join(" · ")]].filter(([, value]) => value).map(([label, value]) => <div className="rounded-xl bg-white/5 p-3" key={label}><span className="mb-1 block text-white/40">{label}</span><span className="leading-5 text-white/80">{value}</span></div>)}</div>
|
|
||||||
<div className="mt-5"><h4 className="mb-3 text-xs text-white/55">选择要创建的角色视图</h4><div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">{views.map((view, index) => <button aria-pressed={selectedViews.includes(view)} className={`relative flex h-32 flex-col items-center justify-center rounded-xl border text-xs ${selectedViews.includes(view) ? "border-cyan-300/70 bg-cyan-300/10" : "border-white/10 bg-white/5"}`} key={view} onClick={() => toggleView(view)}><span className="mb-3 text-2xl text-white/30">{["◉", "◌", "☺", "◧"][index]}</span>{view}{selectedViews.includes(view) ? <Check className="absolute right-2 top-2 size-3 text-cyan-300" /> : null}</button>)}</div></div>
|
|
||||||
<div className="mt-5 rounded-xl border border-white/10 bg-white/5 p-3"><p className="mb-2 text-[11px] text-white/40">一致性提示词</p><p className="text-xs leading-5 text-white/70">{character.prompt}</p></div>
|
|
||||||
<button className="mt-4 h-10 rounded-xl bg-white px-5 text-[13px] font-medium text-[#252525] disabled:opacity-40" disabled={!selectedViews.length || busy} onClick={() => void apply()}>{busy ? "正在启动角色出图…" : `生成并应用至画布 · ${selectedViews.length} 个视图`}</button>
|
|
||||||
</> : <div className="flex h-full min-h-56 flex-col items-center justify-center text-center"><div className="mb-5 flex size-20 items-center justify-center rounded-full bg-gradient-to-br from-cyan-300/15 via-indigo-400/15 to-violet-400/15 text-3xl text-cyan-200">☺</div><p className="text-sm text-white/75">从一个角色想法开始</p><p className="mt-2 max-w-sm text-xs leading-5 text-white/40">分析身份、面部锚点、服装与动作,再创建立绘、近景、表情和三视图节点。</p></div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
@ -1,832 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { ArrowUp, Check, ChevronDown, Clock3, Copy, Download, FilePlus2, Loader2, Minus, PanelRight, Paperclip, Plug, Plus, Share2, SlidersHorizontal, Sparkles } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { requestCreativeAnalysis, type CreativeAnalysisModel } from "./creativeAnalysisClient";
|
|
||||||
import { buildTVstarRevisionPrompt, tvstarAllowsGeneration, tvstarRequestsRunExisting, tvstarWantsDraftNodes, type TVstarMessage } from "./session-context";
|
|
||||||
import type { CanvasGenerationTask } from "@/lib/video-canvas/task-contract";
|
|
||||||
import { mergeTVstarSessions } from "@/lib/video-canvas/tvstar-session";
|
|
||||||
|
|
||||||
export type TVstarShot = {
|
|
||||||
shot: string;
|
|
||||||
duration: string;
|
|
||||||
scene: string;
|
|
||||||
sceneDescription: string;
|
|
||||||
shotSize: string;
|
|
||||||
lighting: string;
|
|
||||||
dialogue: string;
|
|
||||||
backgroundMusic: string;
|
|
||||||
voiceAndSfx: string;
|
|
||||||
cameraMovement: string;
|
|
||||||
imagePrompt: string;
|
|
||||||
motionPrompt: string;
|
|
||||||
keyframeUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TVstarReference = { id: string; title: string; url: string; type: "image" | "video"; duration?: number };
|
|
||||||
export type TVstarNodeContext = { id: string; title: string; type: string; description: string };
|
|
||||||
export type TVstarModel = CreativeAnalysisModel;
|
|
||||||
export type TVstarGenerationModel = { id: string; label: string; kind: "image" | "video"; supportsReference?: boolean };
|
|
||||||
export type TVstarGenerationSelection = { imageModelRef?: string; videoModelRef?: string };
|
|
||||||
|
|
||||||
type Plan = {
|
|
||||||
summary: string;
|
|
||||||
agentPlan: string[];
|
|
||||||
deliverables: string[];
|
|
||||||
recommendedNodes: string[];
|
|
||||||
model?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Session = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
goal: string;
|
|
||||||
mode: Mode;
|
|
||||||
plan?: Plan;
|
|
||||||
shots?: TVstarShot[];
|
|
||||||
references?: TVstarReference[];
|
|
||||||
nodeContexts?: TVstarNodeContext[];
|
|
||||||
messages?: TVstarMessage[];
|
|
||||||
imageModelRef?: string;
|
|
||||||
videoModelRef?: string;
|
|
||||||
autoGenerate?: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt?: number;
|
|
||||||
shareToken?: string;
|
|
||||||
pendingStage?: "plan" | "shots" | "submit";
|
|
||||||
runSubmitted?: boolean;
|
|
||||||
preparedOnly?: boolean;
|
|
||||||
groupId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Mode = "full" | "brainstorm" | "script" | "storyboard" | "edit";
|
|
||||||
|
|
||||||
const modes: { value: Mode; label: string; description: string }[] = [
|
|
||||||
{ value: "full", label: "全能创作", description: "自动规划、生成镜头、选片并合成视频" },
|
|
||||||
{ value: "brainstorm", label: "创意脑暴", description: "拆解方向与交付物" },
|
|
||||||
{ value: "script", label: "剧本创编", description: "直接生成结构化镜头脚本" },
|
|
||||||
{ value: "storyboard", label: "批量分镜", description: "将故事拆成画布镜头" },
|
|
||||||
{ value: "edit", label: "片段智能剪辑", description: "从引用的视频中选高光并直接合成" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const templates = [
|
|
||||||
{ name: "产品广告", category: "商业广告", description: "使用场景、卖点和品牌收尾", prompt: "创作一支 15 秒的产品广告。先明确产品、受众与核心卖点,用使用场景建立需求,安排产品特写和清晰的品牌收尾。" },
|
|
||||||
{ name: "新中式美学 TVC", category: "商业广告", description: "中式材质、留白和电影光影", prompt: "创作一支新中式美学 TVC。统一器物、空间、服饰与色彩;用留白、自然材质和克制的镜头运动呈现产品,不要无关人物。" },
|
|
||||||
{ name: "电商展示片", category: "商业广告", description: "卖点可见、动作清楚、适合竖屏", prompt: "创作一支电商商品展示视频。每个镜头只传达一个可见卖点,展示使用动作、细节特写和明确收尾;避免生成无法验证的功效。" },
|
|
||||||
{ name: "电影感短片", category: "剧情短片", description: "人物、冲突、情绪转折", prompt: "创作一支 30 秒的电影感短片。先明确人物、冲突与情绪转折,再安排景别变化、动作衔接和声音。" },
|
|
||||||
{ name: "角色出场", category: "剧情短片", description: "外观锚点与层层揭示", prompt: "设计角色首次出场的短片。保持面部、发型、服装和道具一致,通过景别和运镜逐步揭示人物。" },
|
|
||||||
{ name: "旅行 Vlog", category: "剧情短片", description: "地点、人物和环境声连贯", prompt: "创作电影感旅行 Vlog。建立地点与人物,按出发、探索、发现、收尾推进;保持时空顺序和环境声连续。" },
|
|
||||||
{ name: "参考片风格复刻", category: "素材改编", description: "分析参考视频后生成新内容", prompt: "分析我引用的参考视频的镜头节奏、构图、光影和情绪,生成主题不同但视听语言一致的新作品。不要复制原片人物、标识或台词。" },
|
|
||||||
{ name: "故事改编", category: "素材改编", description: "保留人物关系和关键情节", prompt: "将我上传或粘贴的故事改编为可生成的视频分镜,保留角色关系和关键情节,压缩次要情节并保持前后连贯。" },
|
|
||||||
{ name: "分镜预演", category: "分镜脚本", description: "先确定镜头与动作,再制作", prompt: "先拆出清楚的镜头目标、景别、时长、人物动作和前后衔接。每个镜头只描述一个可执行动作,优先保证角色和场景一致。" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const panel = "rounded-xl border border-white/10 bg-[#252525]";
|
|
||||||
|
|
||||||
function isShot(value: unknown): value is TVstarShot {
|
|
||||||
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({
|
|
||||||
canvasId,
|
|
||||||
models,
|
|
||||||
generationModels,
|
|
||||||
tasks,
|
|
||||||
references,
|
|
||||||
canvasNodes,
|
|
||||||
selectedCanvasNode,
|
|
||||||
onApply,
|
|
||||||
onPrepare,
|
|
||||||
onRunPrepared,
|
|
||||||
onEdit,
|
|
||||||
onClose,
|
|
||||||
onOpenPlugin,
|
|
||||||
docked,
|
|
||||||
dockWidth,
|
|
||||||
onDockChange,
|
|
||||||
onDockWidthChange,
|
|
||||||
onUpload,
|
|
||||||
}: {
|
|
||||||
canvasId: string;
|
|
||||||
models: TVstarModel[];
|
|
||||||
generationModels: TVstarGenerationModel[];
|
|
||||||
tasks: CanvasGenerationTask[];
|
|
||||||
references: TVstarReference[];
|
|
||||||
canvasNodes: TVstarNodeContext[];
|
|
||||||
selectedCanvasNode: TVstarNodeContext | null;
|
|
||||||
onApply: (shots: TVstarShot[], goal: string, references: TVstarReference[], selection: TVstarGenerationSelection) => Promise<string | false>;
|
|
||||||
onPrepare: (shots: TVstarShot[], goal: string, references: TVstarReference[], selection: TVstarGenerationSelection) => Promise<string | false>;
|
|
||||||
onRunPrepared: (groupId: string) => Promise<string | false>;
|
|
||||||
onEdit: (references: TVstarReference[], goal: string) => Promise<string | false>;
|
|
||||||
onClose: () => void;
|
|
||||||
onOpenPlugin: () => void;
|
|
||||||
docked: boolean;
|
|
||||||
dockWidth: number;
|
|
||||||
onDockChange: (value: boolean) => void;
|
|
||||||
onDockWidthChange: (value: number) => void;
|
|
||||||
onUpload: (file: File) => Promise<{ url: string; type: "image" | "video"; duration?: number }>;
|
|
||||||
}) {
|
|
||||||
const key = `tvstar:sessions:${canvasId}`;
|
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
|
||||||
const sessionsRef = useRef<Session[]>([]);
|
|
||||||
const [syncReady, setSyncReady] = useState(false);
|
|
||||||
const [syncError, setSyncError] = useState(false);
|
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
|
||||||
const [shareBusy, setShareBusy] = useState(false);
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
|
||||||
const [goal, setGoal] = useState("");
|
|
||||||
const [mode, setMode] = useState<Mode>("full");
|
|
||||||
const [modeOpen, setModeOpen] = useState(false);
|
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
|
||||||
const [modelRef, setModelRef] = useState("");
|
|
||||||
const [modelTab, setModelTab] = useState<"image" | "video" | "planning">("video");
|
|
||||||
const [imageModelRef, setImageModelRef] = useState("");
|
|
||||||
const [videoModelRef, setVideoModelRef] = useState("");
|
|
||||||
const [autoGenerate, setAutoGenerate] = useState(true);
|
|
||||||
const [policyOpen, setPolicyOpen] = useState(false);
|
|
||||||
const [templatesOpen, setTemplatesOpen] = useState(false);
|
|
||||||
const [skillSearch, setSkillSearch] = useState("");
|
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
|
||||||
const [headerMenu, setHeaderMenu] = useState<"share" | "settings" | null>(null);
|
|
||||||
const [notificationsEnabled, setNotificationsEnabled] = useState(false);
|
|
||||||
const [notificationSound, setNotificationSound] = useState(false);
|
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
|
||||||
const notifiedTaskRef = useRef<string | null>(null);
|
|
||||||
const [referencesOpen, setReferencesOpen] = useState(false);
|
|
||||||
const [selectedRefs, setSelectedRefs] = useState<TVstarReference[]>([]);
|
|
||||||
const [selectedContextNodes, setSelectedContextNodes] = useState<TVstarNodeContext[]>([]);
|
|
||||||
const lastSelectedCanvasNodeId = useRef<string | null>(null);
|
|
||||||
const [restoredDraftFor, setRestoredDraftFor] = useState("");
|
|
||||||
const [busy, setBusy] = useState<"plan" | "shots" | "submit" | "upload" | null>(null);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const onApplyRef = useRef(onApply);
|
|
||||||
const onPrepareRef = useRef(onPrepare);
|
|
||||||
const onRunPreparedRef = useRef(onRunPrepared);
|
|
||||||
const onEditRef = useRef(onEdit);
|
|
||||||
const modelsRef = useRef(models);
|
|
||||||
useEffect(() => { onApplyRef.current = onApply; }, [onApply]);
|
|
||||||
useEffect(() => { onPrepareRef.current = onPrepare; }, [onPrepare]);
|
|
||||||
useEffect(() => { onRunPreparedRef.current = onRunPrepared; }, [onRunPrepared]);
|
|
||||||
useEffect(() => { onEditRef.current = onEdit; }, [onEdit]);
|
|
||||||
useEffect(() => { modelsRef.current = models; }, [models]);
|
|
||||||
const active = sessions.find(item => item.id === activeId);
|
|
||||||
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 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 productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作"
|
|
||||||
: activeTasks.some(task => task.status === "failed" || task.status === "interrupted")
|
|
||||||
? "有任务需要处理,请查看画布中的失败节点"
|
|
||||||
: finalUrl ? "成片已完成" : activeTasks.some(task => task.status === "running")
|
|
||||||
? "正在生成或分析视频" : activeTasks.length ? "任务已排队" : "任务已提交,正在同步进度";
|
|
||||||
const mention = goal.match(/(?:^|\s)@([^@\s]*)$/)?.[1];
|
|
||||||
const mentionMatches = mention === undefined ? [] : references.filter(item =>
|
|
||||||
item.title.toLowerCase().includes(mention.toLowerCase()) &&
|
|
||||||
!selectedRefs.some(selected => selected.url === item.url),
|
|
||||||
).slice(0, 6);
|
|
||||||
const mentionModelMatches = mention === undefined ? [] : generationModels.filter(item =>
|
|
||||||
item.label.toLowerCase().includes(mention.toLowerCase()) &&
|
|
||||||
(item.kind !== "image" || !(selectedRefs.length || active?.references?.length) || item.supportsReference !== false),
|
|
||||||
).slice(0, 8);
|
|
||||||
const mentionNodeMatches = mention === undefined ? [] : canvasNodes.filter(item =>
|
|
||||||
item.title.toLowerCase().includes(mention.toLowerCase()) &&
|
|
||||||
!selectedContextNodes.some(selected => selected.id === item.id),
|
|
||||||
).slice(0, 6);
|
|
||||||
const message = (role: TVstarMessage["role"], content: string): TVstarMessage => ({
|
|
||||||
id: crypto.randomUUID(), role, content, createdAt: Date.now(),
|
|
||||||
});
|
|
||||||
const updateSession = (updated: Session) => saveSessions(sessionsRef.current.map(item => item.id === updated.id ? updated : item));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const saved = JSON.parse(localStorage.getItem(key) || "[]");
|
|
||||||
if (Array.isArray(saved)) {
|
|
||||||
const restoredSessions = saved.filter((item): item is Session => Boolean(item && typeof item.id === "string" && typeof item.createdAt === "number" && Array.isArray(item.messages))).slice(0, 20);
|
|
||||||
sessionsRef.current = restoredSessions;
|
|
||||||
setSessions(restoredSessions);
|
|
||||||
const remembered = localStorage.getItem(`tvstar:active:${canvasId}`);
|
|
||||||
const restored = saved.find((item: Session) => item.id === remembered) || saved[0];
|
|
||||||
setActiveId(restored?.id || null);
|
|
||||||
setImageModelRef(restored?.imageModelRef || "");
|
|
||||||
setVideoModelRef(restored?.videoModelRef || "");
|
|
||||||
}
|
|
||||||
setAutoGenerate(localStorage.getItem(`tvstar:auto:${canvasId}`) !== "manual");
|
|
||||||
setNotificationsEnabled(localStorage.getItem(`tvstar:notifications:${canvasId}`) === "1");
|
|
||||||
setNotificationSound(localStorage.getItem(`tvstar:notification-sound:${canvasId}`) === "1");
|
|
||||||
setGoal(localStorage.getItem(`tvstar:draft:${canvasId}`) || "");
|
|
||||||
const draftRefs = JSON.parse(localStorage.getItem(`tvstar:draft-refs:${canvasId}`) || "[]");
|
|
||||||
if (Array.isArray(draftRefs)) setSelectedRefs(draftRefs.filter((item): item is TVstarReference =>
|
|
||||||
Boolean(item && typeof item.url === "string" && (item.type === "image" || item.type === "video"))).slice(0, 6));
|
|
||||||
const draftNodes = JSON.parse(localStorage.getItem(`tvstar:draft-nodes:${canvasId}`) || "[]");
|
|
||||||
if (Array.isArray(draftNodes)) setSelectedContextNodes(draftNodes.filter((item): item is TVstarNodeContext =>
|
|
||||||
Boolean(item && typeof item.id === "string" && typeof item.title === "string")).slice(0, 6));
|
|
||||||
} catch { /* a damaged local draft must not block the canvas */ }
|
|
||||||
setRestoredDraftFor(canvasId);
|
|
||||||
}, [key]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (restoredDraftFor !== canvasId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
void fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions`, { cache: "no-store" })
|
|
||||||
.then(async response => {
|
|
||||||
if (!response.ok) throw new Error("读取对话失败");
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const rawRemote: unknown[] = Array.isArray(data.sessions) ? data.sessions : [];
|
|
||||||
const remote: Session[] = rawRemote.filter((item): item is Session =>
|
|
||||||
Boolean(item && typeof item === "object" && typeof (item as Session).id === "string" && typeof (item as Session).createdAt === "number"));
|
|
||||||
const sharedById = new Map(remote.map(item => [item.id, item.shareToken]));
|
|
||||||
const merged = mergeTVstarSessions(sessionsRef.current, remote).map(item =>
|
|
||||||
sharedById.has(item.id) ? { ...item, shareToken: sharedById.get(item.id) } : item);
|
|
||||||
sessionsRef.current = merged;
|
|
||||||
setSessions(merged);
|
|
||||||
try { localStorage.setItem(key, JSON.stringify(merged)); } catch { /* browser storage may be unavailable */ }
|
|
||||||
setActiveId(current => current || merged[0]?.id || null);
|
|
||||||
setSyncError(false);
|
|
||||||
}).catch(() => { if (!cancelled) setSyncError(true); })
|
|
||||||
.finally(() => { if (!cancelled) setSyncReady(true); });
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [canvasId, key, restoredDraftFor]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!syncReady || restoredDraftFor !== canvasId || !sessions.length) return;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
void fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions`, {
|
|
||||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ sessions: sessions.map(item => ({ ...item, updatedAt: item.updatedAt || item.createdAt })) }),
|
|
||||||
signal: controller.signal,
|
|
||||||
}).then(response => { if (!response.ok) throw new Error("保存对话失败"); setSyncError(false); })
|
|
||||||
.catch(error => { if (error?.name !== "AbortError") setSyncError(true); });
|
|
||||||
}, 450);
|
|
||||||
return () => { window.clearTimeout(timer); controller.abort(); };
|
|
||||||
}, [canvasId, restoredDraftFor, sessions, syncReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (restoredDraftFor !== canvasId) return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(`tvstar:draft:${canvasId}`, goal);
|
|
||||||
localStorage.setItem(`tvstar:draft-refs:${canvasId}`, JSON.stringify(selectedRefs));
|
|
||||||
localStorage.setItem(`tvstar:draft-nodes:${canvasId}`, JSON.stringify(selectedContextNodes));
|
|
||||||
} catch { /* storage is optional */ }
|
|
||||||
}, [canvasId, goal, restoredDraftFor, selectedRefs, selectedContextNodes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!finalTask || finalTask.status !== "succeeded" || !finalUrl || !notificationsEnabled) return;
|
|
||||||
if (notifiedTaskRef.current === finalTask.id || typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
|
||||||
notifiedTaskRef.current = finalTask.id;
|
|
||||||
new Notification("TVstar 成片已完成", { body: active?.title || "视频已完成,可以在画布中预览。" });
|
|
||||||
if (notificationSound) {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const context = audioContextRef.current || new AudioContext();
|
|
||||||
audioContextRef.current = context;
|
|
||||||
await context.resume();
|
|
||||||
const oscillator = context.createOscillator();
|
|
||||||
const gain = context.createGain();
|
|
||||||
oscillator.type = "sine";
|
|
||||||
oscillator.frequency.setValueAtTime(720, context.currentTime);
|
|
||||||
oscillator.frequency.setValueAtTime(960, context.currentTime + 0.12);
|
|
||||||
gain.gain.setValueAtTime(0.0001, context.currentTime);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.08, context.currentTime + 0.02);
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.32);
|
|
||||||
oscillator.connect(gain).connect(context.destination);
|
|
||||||
oscillator.start();
|
|
||||||
oscillator.stop(context.currentTime + 0.34);
|
|
||||||
} catch { /* Browser playback may require another user gesture. */ }
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
}, [active?.title, finalTask, finalUrl, notificationSound, notificationsEnabled]);
|
|
||||||
|
|
||||||
useEffect(() => () => { void audioContextRef.current?.close(); }, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedCanvasNode) { lastSelectedCanvasNodeId.current = null; return; }
|
|
||||||
if (lastSelectedCanvasNodeId.current === selectedCanvasNode.id) return;
|
|
||||||
lastSelectedCanvasNodeId.current = selectedCanvasNode.id;
|
|
||||||
setSelectedContextNodes(current => current.some(item => item.id === selectedCanvasNode.id)
|
|
||||||
? current : [...current, selectedCanvasNode].slice(0, 6));
|
|
||||||
const media = references.find(item => item.id === selectedCanvasNode.id);
|
|
||||||
if (media) setSelectedRefs(current => current.some(item => item.id === media.id)
|
|
||||||
? current : [...current, media].slice(0, 6));
|
|
||||||
}, [references, selectedCanvasNode]);
|
|
||||||
|
|
||||||
const saveSessions = (next: Session[]) => {
|
|
||||||
const current = new Map(sessionsRef.current.map(item => [item.id, item]));
|
|
||||||
const limited = next.slice(0, 20).map(item => {
|
|
||||||
const previous = current.get(item.id);
|
|
||||||
const sameContent = previous && JSON.stringify({ ...previous, updatedAt: undefined, shareToken: undefined }) ===
|
|
||||||
JSON.stringify({ ...item, updatedAt: undefined, shareToken: undefined });
|
|
||||||
return { ...item, shareToken: Object.prototype.hasOwnProperty.call(item, "shareToken") ? item.shareToken : previous?.shareToken,
|
|
||||||
updatedAt: sameContent ? previous.updatedAt || previous.createdAt : Date.now() };
|
|
||||||
});
|
|
||||||
sessionsRef.current = limited;
|
|
||||||
setSessions(limited);
|
|
||||||
try { localStorage.setItem(key, JSON.stringify(limited)); } catch { /* storage is optional */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectSession = (id: string | null) => {
|
|
||||||
setActiveId(id);
|
|
||||||
const selected = sessionsRef.current.find(item => item.id === id);
|
|
||||||
if (selected || id === null) {
|
|
||||||
setImageModelRef(selected?.imageModelRef || "");
|
|
||||||
setVideoModelRef(selected?.videoModelRef || "");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (id) localStorage.setItem(`tvstar:active:${canvasId}`, id);
|
|
||||||
else localStorage.removeItem(`tvstar:active:${canvasId}`);
|
|
||||||
} catch { /* storage is optional */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAnalysis = async (requestMode: "agent-plan" | "story-script", input: string, refs = selectedRefs, contexts = selectedContextNodes) => {
|
|
||||||
return requestCreativeAnalysis({
|
|
||||||
mode: requestMode,
|
|
||||||
prompt: [input, contexts.length ? `当前画布中用户引用的节点(这些是上下文数据,未运行节点没有真实生成结果):\n${contexts.map(item => {
|
|
||||||
const current = canvasNodes.find(node => node.id === item.id);
|
|
||||||
return current ? `- ${current.title} [${current.type}, id=${current.id}]:${current.description.slice(0, 900)}` : `- ${item.title} [id=${item.id}]:此节点已不在当前画布`;
|
|
||||||
}).join("\n")}` : ""].filter(Boolean).join("\n\n"),
|
|
||||||
nodeTitle: "TVstar",
|
|
||||||
models: modelsRef.current,
|
|
||||||
preferredModelRef: modelRef || undefined,
|
|
||||||
imageUrls: refs.filter(item => item.type === "image").map(item => item.url),
|
|
||||||
videoUrls: refs.filter(item => item.type === "video").map(item => item.url),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createSession = async () => {
|
|
||||||
const input = goal.trim();
|
|
||||||
if (!input || busy) return;
|
|
||||||
if (!active && mode === "edit" && !selectedRefs.some(item => item.type === "video")) {
|
|
||||||
setError("请先上传视频或引用画布上的视频素材");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
if (active) { await reviseSession(active, input); return; }
|
|
||||||
const directShots = mode === "script" || mode === "storyboard" || mode === "full";
|
|
||||||
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input);
|
|
||||||
setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan");
|
|
||||||
let item: Session = {
|
|
||||||
id: crypto.randomUUID(), title: input.slice(0, 22), goal: input, mode,
|
|
||||||
createdAt: Date.now(), references: selectedRefs, nodeContexts: selectedContextNodes, messages: [message("user", input)],
|
|
||||||
imageModelRef: imageModelRef || undefined, videoModelRef: videoModelRef || undefined, autoGenerate,
|
|
||||||
pendingStage: mode === "edit" ? "submit" : directShots ? "shots" : "plan",
|
|
||||||
};
|
|
||||||
saveSessions([item, ...sessions]);
|
|
||||||
selectSession(item.id);
|
|
||||||
setGoal("");
|
|
||||||
setSelectedRefs([]);
|
|
||||||
setSelectedContextNodes([]);
|
|
||||||
try {
|
|
||||||
if (mode === "edit") {
|
|
||||||
if (!allowAutoRun) {
|
|
||||||
saveSessions([{ ...item, pendingStage: undefined, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已收到视频素材和剪辑要求,检查后可开始选片。"),
|
|
||||||
] }, ...sessions]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const submitted = await onEditRef.current(item.references || [], input);
|
|
||||||
if (!submitted) throw new Error("视频剪辑任务提交失败,可在同一对话中重试");
|
|
||||||
item = { ...item, pendingStage: undefined, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已提交已有视频的高光选片与合成任务。"),
|
|
||||||
] };
|
|
||||||
saveSessions([item, ...sessions]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []);
|
|
||||||
const shots = directShots ? productionShots(data.analysisRows) : undefined;
|
|
||||||
if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试");
|
|
||||||
item = {
|
|
||||||
...item,
|
|
||||||
plan: directShots && mode !== "full" ? undefined : {
|
|
||||||
summary: String(data.summary || ""),
|
|
||||||
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) : [],
|
|
||||||
recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [],
|
|
||||||
model: typeof data.model === "string" ? data.model : undefined,
|
|
||||||
},
|
|
||||||
shots,
|
|
||||||
pendingStage: mode === "full" ? "shots" : undefined,
|
|
||||||
};
|
|
||||||
item.messages = [...(item.messages || []), message("assistant", directShots
|
|
||||||
? `已生成 ${shots?.length || 0} 个可执行分镜。`
|
|
||||||
: item.plan?.summary || "已完成创作规划。")];
|
|
||||||
saveSessions([item, ...sessions]);
|
|
||||||
if (directShots && shots?.length && (mode === "storyboard" || (tvstarWantsDraftNodes(input) && !allowAutoRun))) {
|
|
||||||
const prepared = await onPrepareRef.current(shots, input, item.references || [], item);
|
|
||||||
if (!prepared) throw new Error("待运行工作流创建失败,分镜已保留,可重试");
|
|
||||||
saveSessions([{ ...item, groupId: prepared, preparedOnly: true, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已在画布中创建未执行的分镜工作流,没有提交生成任务。"),
|
|
||||||
] }, ...sessions]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mode === "full") {
|
|
||||||
setBusy("shots");
|
|
||||||
const storyboard = item.shots?.length ? { analysisRows: item.shots } : await runAnalysis("story-script", [input, item.plan?.summary,
|
|
||||||
item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []);
|
|
||||||
const generated = productionShots(storyboard.analysisRows);
|
|
||||||
if (!generated.length) throw new Error("模型没有返回有效分镜,请重试");
|
|
||||||
item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [
|
|
||||||
...(item.messages || []), message("assistant", allowAutoRun
|
|
||||||
? `已生成 ${generated.length} 个分镜,正在提交画布制作。`
|
|
||||||
: `已生成 ${generated.length} 个分镜,检查后可点击“生成视频并合成成片”。`),
|
|
||||||
] };
|
|
||||||
saveSessions([item, ...sessions]);
|
|
||||||
if (!allowAutoRun) {
|
|
||||||
if (tvstarWantsDraftNodes(input)) {
|
|
||||||
const prepared = await onPrepareRef.current(generated, input, item.references || [], item);
|
|
||||||
if (!prepared) throw new Error("待运行工作流创建失败,分镜已保留,可重试");
|
|
||||||
saveSessions([{ ...item, groupId: prepared, preparedOnly: true, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已在画布中创建未执行的脚本、关键帧、视频和成片节点,并连好依赖;没有提交生成任务。"),
|
|
||||||
] }, ...sessions]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy("submit");
|
|
||||||
const submitted = await onApplyRef.current(generated, input, item.references || [], item);
|
|
||||||
if (!submitted) throw new Error("视频任务提交失败,分镜已保留,可再次制作");
|
|
||||||
saveSessions([{ ...item, pendingStage: undefined, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(item.messages || []), message("assistant", "已将分镜、生图、生视频和成片剪辑任务提交到画布。"),
|
|
||||||
] }, ...sessions]);
|
|
||||||
}
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "TVstar 分析失败");
|
|
||||||
} finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const reviseSession = async (session: Session, instruction: string) => {
|
|
||||||
setError("");
|
|
||||||
setBusy(session.mode === "brainstorm" ? "plan" : session.mode === "edit" ? "submit" : "shots");
|
|
||||||
const mergedRefs = [...(session.references || []), ...selectedRefs].filter((item, index, list) =>
|
|
||||||
list.findIndex(candidate => candidate.url === item.url) === index).slice(0, 6);
|
|
||||||
const mergedContexts = [...(session.nodeContexts || []), ...selectedContextNodes].filter((item, index, list) =>
|
|
||||||
list.findIndex(candidate => candidate.id === item.id) === index).slice(0, 6);
|
|
||||||
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(instruction);
|
|
||||||
const pending: Session = { ...session, references: mergedRefs, nodeContexts: mergedContexts,
|
|
||||||
imageModelRef: imageModelRef || undefined, videoModelRef: videoModelRef || undefined, autoGenerate,
|
|
||||||
messages: [
|
|
||||||
...(session.messages || []), message("user", instruction),
|
|
||||||
] };
|
|
||||||
updateSession(pending);
|
|
||||||
setGoal("");
|
|
||||||
setSelectedRefs([]);
|
|
||||||
setSelectedContextNodes([]);
|
|
||||||
try {
|
|
||||||
if (session.preparedOnly && session.groupId && tvstarRequestsRunExisting(instruction)) {
|
|
||||||
setBusy("submit");
|
|
||||||
const submitted = await onRunPreparedRef.current(session.groupId);
|
|
||||||
if (!submitted) throw new Error("现有画布工作流提交失败,请检查节点后重试");
|
|
||||||
updateSession({ ...pending, preparedOnly: false, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(pending.messages || []), message("assistant", "已启动画布中的待运行工作流。"),
|
|
||||||
] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.mode === "edit") {
|
|
||||||
if (!allowAutoRun) {
|
|
||||||
updateSession({ ...pending, pendingStage: undefined, runSubmitted: false, messages: [
|
|
||||||
...(pending.messages || []), message("assistant", "已更新剪辑要求,点击下方按钮开始选片。"),
|
|
||||||
] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const submitted = await onEditRef.current(mergedRefs, `${session.goal}\n最新修改:${instruction}`);
|
|
||||||
if (!submitted) throw new Error("视频剪辑任务提交失败,可重试");
|
|
||||||
updateSession({ ...pending, pendingStage: undefined, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(pending.messages || []), message("assistant", "已按新要求重新分析片段并提交成片。"),
|
|
||||||
] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prompt = buildTVstarRevisionPrompt(pending, instruction);
|
|
||||||
if (session.mode === "brainstorm") {
|
|
||||||
const data = await runAnalysis("agent-plan", prompt, mergedRefs, mergedContexts);
|
|
||||||
const plan: Plan = {
|
|
||||||
summary: String(data.summary || ""),
|
|
||||||
agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String) : [],
|
|
||||||
deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [],
|
|
||||||
recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [],
|
|
||||||
};
|
|
||||||
updateSession({ ...pending, plan, messages: [...(pending.messages || []), message("assistant", plan.summary || "已更新规划。")] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts);
|
|
||||||
const shots = productionShots(data.analysisRows);
|
|
||||||
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求");
|
|
||||||
let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined,
|
|
||||||
messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] };
|
|
||||||
updateSession(updated);
|
|
||||||
if (!allowAutoRun && tvstarWantsDraftNodes(instruction)) {
|
|
||||||
const prepared = await onPrepareRef.current(shots, `${session.goal}\n最新修改:${instruction}`, mergedRefs, pending);
|
|
||||||
if (!prepared) throw new Error("修订工作流创建失败,分镜已保留,可重试");
|
|
||||||
updated = { ...updated, groupId: prepared, preparedOnly: true, messages: [
|
|
||||||
...(updated.messages || []), message("assistant", "已在画布中创建一版待运行的修订工作流,没有提交生成任务。"),
|
|
||||||
] };
|
|
||||||
updateSession(updated);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.mode === "full" && allowAutoRun) {
|
|
||||||
setBusy("submit");
|
|
||||||
const submitted = await onApplyRef.current(shots, `${session.goal}\n最新修改:${instruction}`, mergedRefs, pending);
|
|
||||||
if (!submitted) throw new Error("新版视频任务提交失败,更新后的分镜已保留,可重试");
|
|
||||||
updated = { ...updated, pendingStage: undefined, runSubmitted: true, preparedOnly: false, groupId: submitted, messages: [
|
|
||||||
...(updated.messages || []), message("assistant", "已按新要求将视频和成片任务提交到画布。"),
|
|
||||||
] };
|
|
||||||
updateSession(updated);
|
|
||||||
}
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "修改创作方案失败");
|
|
||||||
} finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateShots = async () => {
|
|
||||||
if (!active || busy) return;
|
|
||||||
setError("");
|
|
||||||
setBusy("shots");
|
|
||||||
try {
|
|
||||||
const data = await runAnalysis("story-script", [active.goal, active.plan?.summary,
|
|
||||||
active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []);
|
|
||||||
const shots = productionShots(data.analysisRows);
|
|
||||||
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试");
|
|
||||||
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
|
|
||||||
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest);
|
|
||||||
const updated = { ...active, shots, pendingStage: active.mode === "full" && allowAutoRun ? "submit" as const : undefined };
|
|
||||||
saveSessions(sessions.map(item => item.id === active.id ? updated : item));
|
|
||||||
if (!allowAutoRun && tvstarWantsDraftNodes(latest)) {
|
|
||||||
const prepared = await onPrepareRef.current(shots, active.goal, active.references || [], active);
|
|
||||||
if (!prepared) throw new Error("待运行工作流创建失败,分镜已保留,可重试");
|
|
||||||
saveSessions(sessions.map(item => item.id === active.id ? { ...updated, groupId: prepared, preparedOnly: true } : item));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (active.mode === "full" && allowAutoRun) {
|
|
||||||
setBusy("submit");
|
|
||||||
const submitted = await onApplyRef.current(shots, active.goal, active.references || [], active);
|
|
||||||
if (!submitted) throw new Error("视频任务提交失败,分镜已保留,可再次制作");
|
|
||||||
saveSessions(sessions.map(item => item.id === active.id ? { ...updated, pendingStage: undefined, runSubmitted: true, preparedOnly: false, groupId: submitted } : item));
|
|
||||||
}
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "分镜生成失败");
|
|
||||||
} finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const newSession = () => { selectSession(null); setGoal(""); setSelectedRefs([]); setSelectedContextNodes([]); setError(""); setHistoryOpen(false); };
|
|
||||||
|
|
||||||
const submitExisting = async () => {
|
|
||||||
if (!active?.shots?.length || busy) return;
|
|
||||||
setError(""); setBusy("submit");
|
|
||||||
try {
|
|
||||||
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
|
|
||||||
const submitted = active.preparedOnly && active.groupId
|
|
||||||
? await onRunPreparedRef.current(active.groupId)
|
|
||||||
: await onApplyRef.current(active.shots,
|
|
||||||
latest === active.goal ? active.goal : `${active.goal}\n最新修改:${latest}`,
|
|
||||||
active.references || [], active);
|
|
||||||
if (!submitted) throw new Error("视频任务提交失败,分镜已保留,可重试");
|
|
||||||
saveSessions(sessions.map(item => item.id === active.id ? { ...item, pendingStage: undefined, runSubmitted: true, preparedOnly: false, groupId: submitted } : item));
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "视频制作失败"); }
|
|
||||||
finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitEditExisting = async () => {
|
|
||||||
if (!active?.references?.some(item => item.type === "video") || busy) return;
|
|
||||||
setError(""); setBusy("submit");
|
|
||||||
try {
|
|
||||||
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
|
|
||||||
const submitted = await onEditRef.current(active.references,
|
|
||||||
latest === active.goal ? active.goal : `${active.goal}\n最新修改:${latest}`);
|
|
||||||
if (!submitted) throw new Error("视频剪辑任务提交失败,可重试");
|
|
||||||
updateSession({ ...active, runSubmitted: true, groupId: submitted, messages: [
|
|
||||||
...(active.messages || []), message("assistant", "已开始分析真实视频并合成成片。"),
|
|
||||||
] });
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "视频剪辑失败"); }
|
|
||||||
finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const upload = async (file?: File) => {
|
|
||||||
if (!file) return;
|
|
||||||
setError(""); setBusy("upload");
|
|
||||||
try {
|
|
||||||
const result = await onUpload(file);
|
|
||||||
setSelectedRefs(current => [...current, { id: crypto.randomUUID(), title: file.name, url: result.url, type: result.type, duration: result.duration }].slice(0, 6));
|
|
||||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "上传失败"); }
|
|
||||||
finally { setBusy(null); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const chooseGenerationModel = (kind: "image" | "video", value: string) => {
|
|
||||||
if (kind === "image") setImageModelRef(value);
|
|
||||||
else setVideoModelRef(value);
|
|
||||||
if (active) updateSession({ ...active, [kind === "image" ? "imageModelRef" : "videoModelRef"]: value || undefined });
|
|
||||||
setModelOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const chooseGenerationPolicy = (value: boolean) => {
|
|
||||||
setAutoGenerate(value);
|
|
||||||
setPolicyOpen(false);
|
|
||||||
try { localStorage.setItem(`tvstar:auto:${canvasId}`, value ? "auto" : "manual"); } catch { /* optional */ }
|
|
||||||
if (active) updateSession({ ...active, autoGenerate: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyText = async (value: string, success: string) => {
|
|
||||||
try {
|
|
||||||
if (!navigator.clipboard?.writeText) throw new Error("当前浏览器不支持剪贴板");
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
setError("");
|
|
||||||
setHeaderMenu(null);
|
|
||||||
toast.success(success);
|
|
||||||
} catch {
|
|
||||||
setError("复制失败,请检查浏览器剪贴板权限");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportConversation = () => {
|
|
||||||
if (!active) return;
|
|
||||||
const content = [`# ${active.title}`, `创作目标:${active.goal}`, "", ...(active.messages || []).map(item => `${item.role === "user" ? "我" : "TVstar"}:${item.content}`)].join("\n\n");
|
|
||||||
const blobUrl = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" }));
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = `TVstar-${active.title.replace(/[\\/:*?"<>|]/g, "_").slice(0, 30) || "对话"}.txt`;
|
|
||||||
link.click();
|
|
||||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
|
||||||
setHeaderMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createShareLink = async () => {
|
|
||||||
if (!active || shareBusy) return;
|
|
||||||
setShareBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions/share`, {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ session: { ...active, updatedAt: active.updatedAt || active.createdAt } }),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || typeof result.token !== "string") throw new Error(result.error || "生成分享链接失败");
|
|
||||||
updateSession({ ...active, shareToken: result.token });
|
|
||||||
setShareConfirm(false);
|
|
||||||
setError("");
|
|
||||||
toast.success("只读分享链接已生成");
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "生成分享链接失败");
|
|
||||||
} finally { setShareBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const revokeShareLink = async () => {
|
|
||||||
if (!active || shareBusy) return;
|
|
||||||
setShareBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}/tvstar-sessions/share?sessionId=${encodeURIComponent(active.id)}`, { method: "DELETE" });
|
|
||||||
if (!response.ok) throw new Error("关闭分享失败");
|
|
||||||
updateSession({ ...active, shareToken: undefined });
|
|
||||||
toast.success("分享链接已失效");
|
|
||||||
} catch (cause) {
|
|
||||||
setError(cause instanceof Error ? cause.message : "关闭分享失败");
|
|
||||||
} finally { setShareBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDock = () => {
|
|
||||||
onDockChange(!docked);
|
|
||||||
setHeaderMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDockResize = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const onMove = (moveEvent: PointerEvent) => {
|
|
||||||
onDockWidthChange(Math.round(Math.min(560, Math.max(320, window.innerWidth - moveEvent.clientX))));
|
|
||||||
};
|
|
||||||
const onEnd = () => {
|
|
||||||
window.removeEventListener("pointermove", onMove);
|
|
||||||
window.removeEventListener("pointerup", onEnd);
|
|
||||||
};
|
|
||||||
window.addEventListener("pointermove", onMove);
|
|
||||||
window.addEventListener("pointerup", onEnd, { once: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleNotifications = async () => {
|
|
||||||
if (notificationsEnabled) {
|
|
||||||
setNotificationsEnabled(false);
|
|
||||||
setNotificationSound(false);
|
|
||||||
try { localStorage.setItem(`tvstar:notifications:${canvasId}`, "0"); } catch { /* optional */ }
|
|
||||||
try { localStorage.setItem(`tvstar:notification-sound:${canvasId}`, "0"); } catch { /* optional */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof Notification === "undefined") { setError("当前浏览器不支持桌面通知"); return; }
|
|
||||||
const permission = await Notification.requestPermission();
|
|
||||||
if (permission !== "granted") { setError("浏览器通知未获授权"); return; }
|
|
||||||
setNotificationsEnabled(true);
|
|
||||||
try { localStorage.setItem(`tvstar:notifications:${canvasId}`, "1"); } catch { /* optional */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleNotificationSound = async () => {
|
|
||||||
if (!notificationsEnabled) return;
|
|
||||||
const next = !notificationSound;
|
|
||||||
if (next) {
|
|
||||||
try {
|
|
||||||
const context = audioContextRef.current || new AudioContext();
|
|
||||||
audioContextRef.current = context;
|
|
||||||
await context.resume();
|
|
||||||
} catch { setError("浏览器未允许播放通知声音"); return; }
|
|
||||||
}
|
|
||||||
setNotificationSound(next);
|
|
||||||
try { localStorage.setItem(`tvstar:notification-sound:${canvasId}`, next ? "1" : "0"); } catch { /* optional */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="TVstar 创作助手" className={`fixed z-[100] flex flex-col overflow-hidden border border-white/10 bg-[#1c1c1c] text-white shadow-[0_24px_90px_rgba(0,0,0,.65)] ${docked ? "bottom-0 right-0 top-0 h-screen max-w-[calc(100vw-60px)] rounded-none" : "bottom-4 right-4 h-[min(870px,calc(100vh-40px))] w-[min(540px,calc(100vw-24px))] rounded-[22px]"}`} data-canvas-overlay="true" style={docked ? { width: dockWidth } : undefined}>
|
|
||||||
{docked ? <div aria-label="调整助手宽度" className="absolute bottom-0 left-0 top-0 z-30 w-1 cursor-ew-resize hover:bg-cyan-300/50" onPointerDown={startDockResize} role="separator" /> : null}
|
|
||||||
<header className="flex h-12 shrink-0 items-center justify-between px-4">
|
|
||||||
<span className="min-w-0 truncate text-[13px] font-medium">{active ? active.title : "新对话"}</span>
|
|
||||||
<div className="ml-2 flex shrink-0 items-center gap-0.5 text-white/55">
|
|
||||||
<button aria-label="新对话" className="rounded-lg p-1.5 hover:bg-white/10 disabled:opacity-40" disabled={!active || Boolean(busy)} onClick={newSession} title="新对话"><Plus className="size-4" /></button>
|
|
||||||
<button aria-label="历史对话" aria-expanded={historyOpen} className="rounded-lg p-1.5 hover:bg-white/10 disabled:opacity-40" disabled={Boolean(busy)} onClick={() => { setHeaderMenu(null); setHistoryOpen(value => !value); }} title="历史对话"><Clock3 className="size-4" /></button>
|
|
||||||
<button aria-label={active ? "分享" : "新对话无法分享"} aria-expanded={headerMenu === "share"} className="rounded-lg p-1.5 hover:bg-white/10 disabled:opacity-35" disabled={!active} onClick={() => { setHistoryOpen(false); setHeaderMenu(value => value === "share" ? null : "share"); }} title={active ? "分享" : "新对话无法分享"}><Share2 className="size-4" /></button>
|
|
||||||
<button aria-label="TVstar 全局设置" aria-expanded={headerMenu === "settings"} className="rounded-lg p-1.5 hover:bg-white/10" onClick={() => { setHistoryOpen(false); setHeaderMenu(value => value === "settings" ? null : "settings"); }} title="TVstar 全局设置"><SlidersHorizontal className="size-4" /></button>
|
|
||||||
<button aria-label="TVstar Plugin" className="rounded-lg p-1.5 hover:bg-white/10" onClick={onOpenPlugin} title="TVstar Plugin"><Plug className="size-4" /></button>
|
|
||||||
<button aria-label={docked ? "恢复浮窗" : "停靠到右侧"} className="rounded-lg p-1.5 hover:bg-white/10" onClick={toggleDock} title={docked ? "恢复浮窗" : "停靠到右侧"}><PanelRight className="size-4" /></button>
|
|
||||||
<button aria-label="收起 TVstar" className="rounded-lg p-1.5 hover:bg-white/10" onClick={onClose} title="收起 TVstar"><Minus className="size-4" /></button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{headerMenu === "share" ? <div aria-label="TVstar 分享菜单" className="absolute right-3 top-12 z-20 w-64 rounded-xl border border-white/10 bg-[#292929] p-2 shadow-xl">
|
|
||||||
<p className="px-2 py-1 text-xs text-white/50">导出与分享</p>
|
|
||||||
{active?.shareToken ? <>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => void copyText(new URL(`/tvstar/share/${active.shareToken}`, window.location.origin).toString(), "只读分享链接已复制")}><Share2 className="size-4" />复制只读分享链接</button>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs text-rose-300 hover:bg-white/10 disabled:opacity-40" disabled={shareBusy} onClick={() => void revokeShareLink()}>关闭公开分享</button>
|
|
||||||
</> : shareConfirm ? <div className="rounded-lg border border-amber-400/20 bg-amber-400/5 p-2 text-[11px] leading-5 text-amber-100/80">
|
|
||||||
<p>任何获得链接的人都能阅读当前对话快照、目标和分镜。正文中的链接也会显示;画布编辑权限不会开放。</p>
|
|
||||||
<div className="mt-2 flex justify-end gap-2"><button className="rounded px-2 py-1 hover:bg-white/10" onClick={() => setShareConfirm(false)}>取消</button><button className="rounded bg-white px-2 py-1 text-black disabled:opacity-50" disabled={shareBusy} onClick={() => void createShareLink()}>{shareBusy ? "生成中…" : "确认生成"}</button></div>
|
|
||||||
</div> : <button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => setShareConfirm(true)}><Share2 className="size-4" />生成只读分享链接</button>}
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10 disabled:opacity-40" disabled={!active} onClick={() => void copyText((active?.messages || []).map(item => `${item.role === "user" ? "我" : "TVstar"}:${item.content}`).join("\n\n"), "对话记录已复制") }><Copy className="size-4" />复制对话记录</button>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10 disabled:opacity-40" disabled={!active} onClick={exportConversation}><Download className="size-4" />下载对话记录</button>
|
|
||||||
<button className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => void copyText(window.location.href, "画布地址已复制")}><Share2 className="size-4" />复制画布地址</button>
|
|
||||||
<p className="px-2 py-1 text-[11px] leading-4 text-white/35">分享链接可公开阅读创建时的快照;画布地址仍需访问权限。</p>
|
|
||||||
</div> : null}
|
|
||||||
|
|
||||||
{headerMenu === "settings" && typeof document !== "undefined" ? createPortal(<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/65 p-4" onClick={() => setHeaderMenu(null)}>
|
|
||||||
<div aria-label="TVstar 全局设置" aria-modal="true" className="w-full max-w-[460px] rounded-2xl border border-white/15 bg-[#252525] p-5 shadow-[0_30px_100px_rgba(0,0,0,.65)]" onClick={event => event.stopPropagation()} role="dialog">
|
|
||||||
<div className="mb-4 flex items-center justify-between"><h2 className="text-base font-medium">TVstar 全局设置</h2><button aria-label="关闭设置" className="rounded-lg px-2 py-1 text-lg text-white/50 hover:bg-white/10" onClick={() => setHeaderMenu(null)}>×</button></div>
|
|
||||||
<button aria-pressed={autoGenerate} className="flex w-full items-center justify-between rounded-xl px-2 py-3 text-left text-sm hover:bg-white/10" onClick={() => chooseGenerationPolicy(!autoGenerate)}><span>自动执行视频工作流<br /><span className="text-xs text-white/40">根据创作目标提交已配置的图片与视频模型任务</span></span><span className={autoGenerate ? "text-cyan-200" : "text-white/40"}>{autoGenerate ? "已开启" : "已关闭"}</span></button>
|
|
||||||
<button aria-pressed={notificationsEnabled} className="flex w-full items-center justify-between rounded-xl px-2 py-3 text-left text-sm hover:bg-white/10" onClick={() => void toggleNotifications()}><span>成片完成通知<br /><span className="text-xs text-white/40">浏览器授权后发送桌面通知</span></span><span className={notificationsEnabled ? "text-cyan-200" : "text-white/40"}>{notificationsEnabled ? "已开启" : "已关闭"}</span></button>
|
|
||||||
<button aria-pressed={notificationSound} className="flex w-full items-center justify-between rounded-xl px-2 py-3 text-left text-sm hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-35" disabled={!notificationsEnabled} onClick={() => void toggleNotificationSound()}><span>通知声音<br /><span className="text-xs text-white/40">成片完成时播放提示音</span></span><span className={notificationSound ? "text-cyan-200" : "text-white/40"}>{notificationSound ? "已开启" : "已关闭"}</span></button>
|
|
||||||
<p className="mt-4 border-t border-white/10 pt-4 text-xs leading-5 text-white/40">项目积分用量提醒需要逐项目的准确结算数据,当前尚不可用。</p>
|
|
||||||
</div>
|
|
||||||
</div>, document.body) : null}
|
|
||||||
|
|
||||||
{historyOpen ? <div className="absolute inset-x-3 top-12 z-10 max-h-72 overflow-y-auto rounded-xl border border-white/10 bg-[#292929] p-2 shadow-xl">
|
|
||||||
{sessions.length ? sessions.map(item => <button className="block w-full rounded-lg px-3 py-2 text-left text-[13px] hover:bg-white/10" key={item.id} onClick={() => { selectSession(item.id); setHistoryOpen(false); }}><span className="block truncate">{item.title}</span><span className="text-[11px] text-white/40">{new Date(item.createdAt).toLocaleString("zh-CN")}</span></button>) : <p className="p-3 text-xs text-white/45">暂无历史对话</p>}
|
|
||||||
{syncError ? <p className="p-2 text-[11px] text-amber-200/70">云端同步暂不可用;当前对话仍保存在此浏览器。</p> : null}
|
|
||||||
</div> : null}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
|
||||||
{!active ? <div className="flex min-h-full flex-col justify-center pb-3">
|
|
||||||
<div className="mb-6 text-center"><div className="mx-auto mb-4 size-12 rounded-full border border-cyan-100/60 bg-[radial-gradient(circle_at_35%_32%,#8df9f2_0%,#39dce9_24%,#579cff_53%,#7259d6_78%,#2a647c_100%)] shadow-[0_0_24px_rgba(90,206,245,.6)]" /><h2 className="text-[16px] font-medium">让 TVstar 辅助你的无限创意</h2><p className="mt-2 text-xs text-white/40">从创意规划到自动生成可播放视频</p></div>
|
|
||||||
<div className="space-y-1">{[
|
|
||||||
["选择 Skill 开始创作", () => setTemplatesOpen(true)],
|
|
||||||
["从爆款预设开始剧本原创", () => { setMode("script"); setTemplatesOpen(true); }],
|
|
||||||
["上传故事来改编", () => { setMode("script"); fileRef.current?.click(); }],
|
|
||||||
["批量创建分镜", () => setMode("storyboard")],
|
|
||||||
["从已有视频选片剪辑", () => { setMode("edit"); setReferencesOpen(true); }],
|
|
||||||
].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">
|
|
||||||
{(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}
|
|
||||||
<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.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.plan || active.pendingStage) && !active.shots && active.mode !== "brainstorm" && active.mode !== "edit" ? <button className="w-full rounded-xl bg-white px-4 py-2.5 text-[13px] font-medium text-[#1c1c1c] disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void generateShots()}>{busy === "shots" ? "正在生成分镜…" : "继续生成分镜"}</button> : null}
|
|
||||||
{active.shots?.length ? <button className="w-full rounded-xl bg-white px-4 py-2.5 text-[13px] font-medium text-[#1c1c1c] disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void submitExisting()}>{busy === "submit" ? "正在提交视频制作…" : active.preparedOnly ? "运行画布中的待制作工作流" : active.runSubmitted ? "再次制作视频" : "生成视频并合成成片"}</button> : null}
|
|
||||||
{active.mode === "edit" && active.references?.some(item => item.type === "video") ? <button className="w-full rounded-xl bg-white px-4 py-2.5 text-[13px] font-medium text-[#1c1c1c] disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void submitEditExisting()}>{active.runSubmitted ? "再次剪辑已有视频" : "开始剪辑已有视频"}</button> : null}
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-white/10 p-3">
|
|
||||||
{busy && busy !== "upload" ? <p className="mb-2 px-2 text-xs text-cyan-200" role="status">{{ plan: "正在理解目标并规划制作", shots: "正在生成可执行分镜", submit: "正在提交画布视频任务" }[busy]}</p> : null}
|
|
||||||
{error ? <p className="mb-2 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-200" role="alert">{error}</p> : null}
|
|
||||||
{templatesOpen ? <div aria-label="TVstar Skill" className="mb-2 rounded-xl border border-white/10 bg-[#292929] p-2"><input aria-label="搜索 Skill" className="mb-2 w-full rounded-lg border border-white/10 bg-[#202020] px-2 py-1.5 text-xs outline-none" onChange={event => setSkillSearch(event.target.value)} placeholder="搜索商业广告、剧情或素材改编" value={skillSearch} /><div className="max-h-48 space-y-0.5 overflow-y-auto">{templates.filter(item => [item.name, item.category, item.description].join(" ").toLowerCase().includes(skillSearch.trim().toLowerCase())).map(item => <button className="block w-full rounded-lg px-2 py-2 text-left hover:bg-white/10" key={item.name} onClick={() => { if (active) selectSession(null); setGoal(item.prompt); setTemplatesOpen(false); setSkillSearch(""); }}><span className="block text-xs text-white/85">{item.name} <span className="text-[10px] text-white/35">· {item.category}</span></span><span className="text-[10px] text-white/45">{item.description}</span></button>)}</div></div> : null}
|
|
||||||
{referencesOpen ? <div className="mb-2 max-h-36 overflow-y-auto rounded-xl border border-white/10 bg-[#292929] p-2"><p className="px-2 pb-1 text-[11px] text-white/40">引用画布素材</p>{references.length ? references.map(item => <button className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setSelectedRefs(current => current.some(ref => ref.id === item.id) ? current.filter(ref => ref.id !== item.id) : [...current, item].slice(0, 6)); }}><span className="w-4">{selectedRefs.some(ref => ref.id === item.id) ? <Check className="size-3.5" /> : null}</span><span className="truncate">{item.title}</span></button>) : <p className="px-2 py-1 text-xs text-white/40">画布上暂无可引用的素材</p>}</div> : null}
|
|
||||||
{mention !== undefined ? <div aria-label="@ 引用画布节点、素材和模型" className="mb-2 max-h-48 overflow-y-auto rounded-xl border border-white/10 bg-[#292929] p-2">{mentionNodeMatches.length ? <><p className="px-2 py-1 text-[10px] text-white/35">画布节点</p>{mentionNodeMatches.map(item => <button className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setSelectedContextNodes(current => [...current, item].slice(0, 6)); const media = references.find(ref => ref.id === item.id); if (media) setSelectedRefs(current => current.some(ref => ref.id === media.id) ? current : [...current, media].slice(0, 6)); setGoal(current => current.replace(/(^|\s)@[^@\s]*$/, "$1")); }}>{item.type} · {item.title}</button>)}</> : null}{mentionMatches.length ? <><p className="px-2 py-1 text-[10px] text-white/35">素材</p>{mentionMatches.map(item => <button className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setSelectedRefs(current => [...current, item].slice(0, 6)); setGoal(current => current.replace(/(^|\s)@[^@\s]*$/, "$1")); }}>{item.type === "video" ? "视频" : "图片"} · {item.title}</button>)}</> : null}{mentionModelMatches.length ? <><p className="px-2 py-1 text-[10px] text-white/35">模型</p>{mentionModelMatches.map(item => <button className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-xs hover:bg-white/10" key={`${item.kind}-${item.id}`} onClick={() => { chooseGenerationModel(item.kind, item.id); setGoal(current => current.replace(/(^|\s)@[^@\s]*$/, "$1")); }}>{item.kind === "video" ? "视频" : "图片"} · {item.label}</button>)}</> : null}{!mentionNodeMatches.length && !mentionMatches.length && !mentionModelMatches.length ? <p className="px-2 py-1 text-xs text-white/40">没有匹配的节点、素材或模型</p> : null}</div> : null}
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-[#242424] p-2">
|
|
||||||
<textarea aria-label="TVstar 创作目标" className="min-h-[66px] w-full resize-none bg-transparent px-1.5 py-1 text-[13px] text-white outline-none placeholder:text-white/35" disabled={Boolean(busy)} onChange={event => setGoal(event.target.value)} onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); void createSession(); } }} placeholder={active ? "继续提要求,例如修改镜头、节奏或风格;输入 @ 引用素材" : "开始你的创作,或者输入 @ 引用画布素材"} value={goal} />
|
|
||||||
{selectedRefs.some(ref => !selectedContextNodes.some(node => node.id === ref.id)) ? <div className="mb-2 flex flex-wrap gap-1">{selectedRefs.filter(ref => !selectedContextNodes.some(node => node.id === ref.id)).map(item => <button className="max-w-36 truncate rounded-md bg-white/10 px-2 py-1 text-[10px] text-white/65" key={item.id} onClick={() => setSelectedRefs(current => current.filter(ref => ref.id !== item.id))}>{item.title} ×</button>)}</div> : null}
|
|
||||||
{selectedContextNodes.length ? <div className="mb-2 flex flex-wrap gap-1">{selectedContextNodes.map(item => <button className="max-w-36 truncate rounded-md bg-white/10 px-2 py-1 text-[10px] text-white/65" key={item.id} onClick={() => { setSelectedContextNodes(current => current.filter(node => node.id !== item.id)); setSelectedRefs(current => current.filter(ref => ref.id !== item.id)); }}>{item.title} ×</button>)}</div> : null}
|
|
||||||
{imageModelRef || videoModelRef ? <div className="mb-2 flex flex-wrap gap-1">{([ ["image", imageModelRef, "图片"], ["video", videoModelRef, "视频"] ] as const).filter(([, ref]) => ref).map(([kind, ref, label]) => <button className="max-w-40 truncate rounded-md bg-cyan-500/15 px-2 py-1 text-[10px] text-cyan-100" key={kind} onClick={() => chooseGenerationModel(kind, "")}>{label} · {generationModels.find(item => item.id === ref)?.label || ref} ×</button>)}</div> : null}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input accept="image/*,video/*,.txt,.md" className="hidden" onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; if (file.type.startsWith("text/") || /\.(txt|md)$/i.test(file.name)) { void file.text().then(text => setGoal(current => `${current}${current ? "\n" : ""}${text.slice(0, 12000)}`)); } else { void upload(file); } }} ref={fileRef} type="file" />
|
|
||||||
<button aria-label="上传故事或素材" className="rounded-lg p-1.5 text-white/60 hover:bg-white/10" disabled={Boolean(busy)} onClick={() => fileRef.current?.click()}><Paperclip className="size-4" /></button>
|
|
||||||
<button aria-label="引用画布素材" className="rounded-lg p-1.5 text-white/60 hover:bg-white/10" onClick={() => setReferencesOpen(value => !value)}><FilePlus2 className="size-4" /></button>
|
|
||||||
<div className="relative"><button aria-label="选择创作模式" className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-white/70 hover:bg-white/10" onClick={() => setModeOpen(value => !value)}>{modes.find(item => item.value === mode)?.label}<ChevronDown className="size-3" /></button>{modeOpen ? <div className="absolute bottom-9 left-0 z-20 w-52 rounded-xl border border-white/10 bg-[#303030] p-1 shadow-xl">{modes.map(item => <button className="block w-full rounded-lg px-2 py-2 text-left hover:bg-white/10" key={item.value} onClick={() => { setMode(item.value); setModeOpen(false); }}><span className="block text-xs">{item.label}</span><span className="text-[10px] text-white/40">{item.description}</span></button>)}</div> : null}</div>
|
|
||||||
<div className="relative"><button aria-label="选择生成模型" className="max-w-20 truncate rounded-lg px-2 py-1.5 text-xs text-white/60 hover:bg-white/10" onClick={() => setModelOpen(value => !value)}>选择模型</button>{modelOpen ? <div aria-label="TVstar 模型选择" className="absolute bottom-9 right-0 z-20 w-64 rounded-xl border border-white/10 bg-[#303030] p-2 shadow-xl"><div className="mb-1 flex gap-1">{([ ["image", "图片"], ["video", "视频"], ["planning", "规划"] ] as const).map(([tab, label]) => <button className={`rounded-md px-2 py-1 text-xs ${modelTab === tab ? "bg-white/15 text-white" : "text-white/50"}`} key={tab} onClick={() => setModelTab(tab)}>{label}</button>)}</div><div className="max-h-52 overflow-y-auto"><button className="block w-full rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => { if (modelTab === "planning") { setModelRef(""); setModelOpen(false); } else chooseGenerationModel(modelTab, ""); }}>自动选择</button>{modelTab === "planning" ? models.filter(item => !selectedRefs.length || item.supportsVision).map(item => <button className="block w-full truncate rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => { setModelRef(item.id); setModelOpen(false); }}>{item.label}</button>) : generationModels.filter(item => item.kind === modelTab && (modelTab !== "image" || !(selectedRefs.length || active?.references?.length) || item.supportsReference !== false)).map(item => <button className="block w-full truncate rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" key={item.id} onClick={() => chooseGenerationModel(modelTab, item.id)}>{item.label}</button>)}</div></div> : null}</div>
|
|
||||||
<div className="relative"><button aria-label="选择制作策略" className="whitespace-nowrap rounded-lg px-1.5 py-1.5 text-[11px] text-white/60 hover:bg-white/10" onClick={() => setPolicyOpen(value => !value)}>{autoGenerate ? "自动制作" : "先确认"}</button>{policyOpen ? <div className="absolute bottom-9 right-0 z-20 w-52 rounded-xl border border-white/10 bg-[#303030] p-1 shadow-xl"><button className="block w-full rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => chooseGenerationPolicy(true)}>自动制作 · 分镜完成后提交任务</button><button className="block w-full rounded-lg px-2 py-2 text-left text-xs hover:bg-white/10" onClick={() => chooseGenerationPolicy(false)}>先确认 · 查看分镜后再制作</button></div> : null}</div>
|
|
||||||
<button className="ml-auto flex size-8 items-center justify-center rounded-full bg-white text-black disabled:opacity-40" disabled={!goal.trim() || Boolean(busy)} onClick={() => void createSession()} title="发送创作目标">{busy ? <Loader2 className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
export type CreativeAnalysisModel = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
modelId: string;
|
|
||||||
providerType: string;
|
|
||||||
supportsVision: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CreativeAnalysisRequest = {
|
|
||||||
mode: "agent-plan" | "story-script" | "character-scene-extraction";
|
|
||||||
prompt: string;
|
|
||||||
nodeTitle: string;
|
|
||||||
models: CreativeAnalysisModel[];
|
|
||||||
preferredModelRef?: string;
|
|
||||||
imageUrls?: string[];
|
|
||||||
videoUrls?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function modelPriority(model: CreativeAnalysisModel) {
|
|
||||||
if (model.providerType === "alibaba" && model.modelId === "qwen3.8-max") return 0;
|
|
||||||
if (model.providerType === "alibaba" && model.modelId === "qwen3.8-omni-flash") return 1;
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestCreativeAnalysis(input: CreativeAnalysisRequest): Promise<Record<string, unknown>> {
|
|
||||||
const hasVisualInput = Boolean(input.imageUrls?.length || input.videoUrls?.length);
|
|
||||||
const compatible = input.models.filter(model => !hasVisualInput || model.supportsVision);
|
|
||||||
const selected = input.preferredModelRef
|
|
||||||
? compatible.find(model => model.id === input.preferredModelRef || model.modelId === input.preferredModelRef)
|
|
||||||
: undefined;
|
|
||||||
if (input.preferredModelRef && !selected) throw new Error("所选模型不支持当前素材,请重新选择模型");
|
|
||||||
const candidates = selected ? [selected] : [...compatible].sort((left, right) => modelPriority(left) - modelPriority(right));
|
|
||||||
const attempts: (CreativeAnalysisModel | undefined)[] = candidates.length ? candidates : [undefined];
|
|
||||||
let lastError = "分析失败,请稍后重试";
|
|
||||||
|
|
||||||
for (const [index, model] of attempts.entries()) {
|
|
||||||
const response = await fetch("/api/canvas/workflow/analyze", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
mode: input.mode,
|
|
||||||
prompt: input.prompt,
|
|
||||||
nodeTitle: input.nodeTitle,
|
|
||||||
preferredModelRef: model?.id,
|
|
||||||
imageUrls: input.imageUrls?.slice(0, 4) || [],
|
|
||||||
videoUrls: input.videoUrls?.slice(0, 2) || [],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const result = await response.json().catch(() => ({}));
|
|
||||||
if (response.ok && result.success) return result.data as Record<string, unknown>;
|
|
||||||
lastError = String(result.error || "分析失败,请稍后重试");
|
|
||||||
const authenticationFailure = /invalid authentication|invalid token|invalid api.?key|authentication failed|鉴权失败|认证失败/i.test(lastError);
|
|
||||||
if (selected || !authenticationFailure || index === attempts.length - 1) break;
|
|
||||||
}
|
|
||||||
throw new Error(lastError);
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
type Reference = { title: string; url: string; type: "image" | "video" };
|
|
||||||
|
|
||||||
export async function resolveTVstarGenerationReferences(
|
|
||||||
references: Reference[],
|
|
||||||
fetcher: typeof fetch = fetch,
|
|
||||||
) {
|
|
||||||
const images = references.filter(item => item.type === "image").map(item => item.url).slice(0, 4);
|
|
||||||
// The video processing endpoint accepts one active job per user, so extract sequentially.
|
|
||||||
for (const item of references.filter(item => item.type === "video").slice(0, 2)) {
|
|
||||||
const response = await fetcher("/api/canvas/video/process", {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ operation: "frame", url: item.url, kind: "first", time: 0 }),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || !result.success || !result.data?.url)
|
|
||||||
throw new Error(`${item.title}:${result.error || "参考视频首帧提取失败"}`);
|
|
||||||
images.push(String(result.data.url));
|
|
||||||
}
|
|
||||||
return [...new Set(images)].slice(0, 6);
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
export type TVstarMessage = {
|
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
createdAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function tvstarAllowsGeneration(instruction: string) {
|
|
||||||
const text = instruction.replace(/\s+/g, "");
|
|
||||||
return !/(?:只|仅)(?:做|要|进行|需要)?(?:分析|规划|出方案|写方案|写分镜|列步骤)|(?:不要|先不|暂不|无需|别|不)(?:提交|启动|运行|执行)(?:任何|所有)?(?:生成|制作|剪辑)?任务|(?:不要|先不|暂不|无需|别|不)(?:执行|运行|启动)(?:任何|所有)?(?:图片|视频|音频|媒体|成片|内容|、|和|或|及)*(?:生成|制作|剪辑)|(?:不要|先不|暂不|无需|别)(?:生成|制作|渲染|剪辑)(?:图片|视频|媒体|成片|内容)?|(?:不|不要)扣费|待运行|未运行/.test(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tvstarWantsDraftNodes(instruction: string) {
|
|
||||||
const text = instruction.replace(/\s+/g, "");
|
|
||||||
if (/(?:不要|无需|别|不)(?:创建|建立|搭建|添加)(?:任何|所有)?(?:画布|节点|工作流)/.test(text)) return false;
|
|
||||||
return /(?:创建|建立|搭建|添加|放入)/.test(text) && /(?:画布|节点|工作流)/.test(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tvstarRequestsRunExisting(instruction: string) {
|
|
||||||
const text = instruction.replace(/\s+/g, "");
|
|
||||||
return text.length <= 36 && tvstarAllowsGeneration(instruction) &&
|
|
||||||
!/(?:修改|改成|增加|删除|替换|换成|重做|重新|调整|优化|不要|先不)/.test(text) &&
|
|
||||||
/(?:开始|执行|运行|提交)(?:生成|制作|运行|执行|画布|分镜|工作流|节点|视频|成片|任务|这些|现有|吧|了|。|,)*$/.test(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RevisionContext = {
|
|
||||||
goal: string;
|
|
||||||
plan?: { summary: string };
|
|
||||||
shots?: Array<{
|
|
||||||
shot: string;
|
|
||||||
duration: string;
|
|
||||||
sceneDescription: string;
|
|
||||||
cameraMovement: string;
|
|
||||||
}>;
|
|
||||||
messages?: TVstarMessage[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildTVstarRevisionPrompt(session: RevisionContext, instruction: string) {
|
|
||||||
const priorShots = (session.shots || []).map((shot) =>
|
|
||||||
`${shot.shot}(${shot.duration}):${shot.sceneDescription};运镜:${shot.cameraMovement}`,
|
|
||||||
);
|
|
||||||
const recentTurns = (session.messages || []).slice(-8).map((message) =>
|
|
||||||
`${message.role === "user" ? "用户" : "TVstar"}:${message.content.slice(0, 1200)}`,
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
"这是同一个视频画布中的连续对话。请依据最新指令修改现有方案,不要把它当作全新项目。",
|
|
||||||
`最初目标:${session.goal}`,
|
|
||||||
session.plan?.summary ? `当前规划:${session.plan.summary}` : "",
|
|
||||||
priorShots.length ? `当前分镜:\n${priorShots.join("\n")}` : "",
|
|
||||||
recentTurns.length ? `最近对话:\n${recentTurns.join("\n")}` : "",
|
|
||||||
`最新用户指令:${instruction.trim()}`,
|
|
||||||
"如果最新指令没有要求修改的部分,请保持原有镜头、人物、风格、时长和画面比例。输出更新后的完整方案。",
|
|
||||||
].filter(Boolean).join("\n\n").slice(0, 16000);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user