Compare commits
2 Commits
main
...
codex/vide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39d58d0f2c | ||
|
|
85d678eaf4 |
@ -153,9 +153,8 @@ PRODUCT_PLANNING_DB_SSL=false
|
||||
# Home Performance / Queues
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
|
||||
CANVAS_IMAGE_TASK_CONCURRENCY=0
|
||||
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
|
||||
CANVAS_IMAGE_TASK_CONCURRENCY=3
|
||||
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
|
||||
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
|
||||
EXTERNAL_IMAGE_API_MAX_QUEUE=20
|
||||
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
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -13,7 +13,6 @@ coverage
|
||||
.next/
|
||||
.next-build/
|
||||
.next-previous/
|
||||
/.next-*/
|
||||
out/
|
||||
build
|
||||
|
||||
@ -68,11 +67,6 @@ migration-backup-complete/
|
||||
migration-*.log
|
||||
migration-*.txt
|
||||
|
||||
# Local release backups, model credentials and migration scratch data.
|
||||
/.model-config-backups/
|
||||
/backups/
|
||||
/tmp/
|
||||
|
||||
# 本地抓包/竞品参考产物,不能提交
|
||||
artifacts/liblib-*/
|
||||
analysis/qiaodou-extract/
|
||||
@ -126,7 +120,6 @@ services/smart-edit/__pycache__/
|
||||
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
|
||||
.qa/
|
||||
.next-video-*/
|
||||
.next-tvstar-*/
|
||||
|
||||
services/video-upscale/dist/
|
||||
services/video-upscale/__pycache__/
|
||||
@ -134,7 +127,3 @@ services/video-upscale/vendor/__pycache__/
|
||||
|
||||
/services/audio-processing/dist/
|
||||
/services/audio-processing/__pycache__/
|
||||
|
||||
# Python release-tool and test bytecode.
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@ -2377,9 +2377,7 @@ export function LocalAiImagePipelineSections({
|
||||
items={[
|
||||
{
|
||||
label:
|
||||
extendConfig.provider === "fal"
|
||||
? "Bria 扩图服务"
|
||||
: extendConfig.provider === "vip98k"
|
||||
extendConfig.provider === "vip98k"
|
||||
? "VIP98K 服务商"
|
||||
: "阿里云服务商",
|
||||
tone: "enabled",
|
||||
@ -2393,11 +2391,7 @@ export function LocalAiImagePipelineSections({
|
||||
}
|
||||
title="图像拓展API配置"
|
||||
>
|
||||
{extendConfig.provider === "fal" ? (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
使用 Bria 生成新增区域并保留原图主体和文字。密钥从已启用的 fal 官方服务商读取。
|
||||
</div>
|
||||
) : extendConfig.provider === "vip98k" ? (
|
||||
{extendConfig.provider === "vip98k" ? (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
|
||||
VIP98K 服务商读取,仅在这里维护扩展比例。
|
||||
|
||||
@ -1078,15 +1078,139 @@ export function LocalAiRuntimeSections({
|
||||
</Button>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
{/* 全站生图单模型并发 */}
|
||||
<LocalAiSectionShell
|
||||
description="生图任务直接发起,图片先显示,后台转存到存储桶。"
|
||||
description={
|
||||
<>
|
||||
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
|
||||
id="local-ai-section-canvas-image-task-queue"
|
||||
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
|
||||
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
|
||||
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
|
||||
summary={
|
||||
<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>
|
||||
|
||||
{/* 批量处理配置 */}
|
||||
|
||||
@ -274,7 +274,7 @@ export type EnhanceConfig = {
|
||||
};
|
||||
|
||||
export type ExtendConfig = {
|
||||
provider: "vip98k" | "dashscope" | "fal";
|
||||
provider: "vip98k" | "dashscope";
|
||||
xScale: string;
|
||||
yScale: string;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,8 +4,7 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import {
|
||||
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
|
||||
findConfiguredDetailReplacementImageModel,
|
||||
isDetailReplacementImageModel,
|
||||
normalizeDetailReplacementImageModelConfig,
|
||||
} from "@/lib/ai/detail-replacement-image-model-config";
|
||||
import {
|
||||
compositeReplacementWithReferenceLocks,
|
||||
@ -30,6 +29,7 @@ import {
|
||||
} from "@/lib/db/queries";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { bufferToArrayBuffer } from "./persistence";
|
||||
import { isVideoProviderType } from "./video-models";
|
||||
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
|
||||
import type { PostRequestBody } from "../schema";
|
||||
|
||||
@ -2161,10 +2161,22 @@ export async function resolveDirectReplacementModelRef(params: {
|
||||
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
|
||||
}) {
|
||||
const models = params.models || (await getEnabledAiModelsWithProvider(false));
|
||||
const imageModels = models.filter(
|
||||
({ model, provider }) =>
|
||||
model.supportsImageGen &&
|
||||
!model.isSubModel &&
|
||||
!isVideoProviderType(provider?.type, model),
|
||||
);
|
||||
const configuredModelSetting = await getSystemSetting({
|
||||
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const configuredModel = findConfiguredDetailReplacementImageModel(models, configuredModelSetting?.value);
|
||||
const configuredModelId = normalizeDetailReplacementImageModelConfig(
|
||||
configuredModelSetting?.value,
|
||||
).modelId;
|
||||
const configuredModel = imageModels.find(
|
||||
({ model }) =>
|
||||
model.id === configuredModelId || model.modelId === configuredModelId,
|
||||
);
|
||||
if (configuredModel) {
|
||||
return configuredModel.model.id;
|
||||
}
|
||||
@ -2178,7 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
|
||||
return routedModel;
|
||||
}
|
||||
|
||||
const imageModels = models.filter(isDetailReplacementImageModel);
|
||||
const preferred =
|
||||
imageModels.find(({ model }) => model.isDefault) ||
|
||||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
|
||||
|
||||
@ -5,7 +5,7 @@ import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-c
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { convertMessagesWithImages } from "./image-media";
|
||||
import { isProviderBalanceError } from "@/lib/request-error";
|
||||
import { isImageModelBalanceBlocked } from "@/lib/ai/runtime/image-balance";
|
||||
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
|
||||
|
||||
const chatApiLogger = createLogger("chat.imageFailover");
|
||||
|
||||
@ -326,7 +326,7 @@ export function buildImageFallbackCandidates(
|
||||
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
|
||||
return false;
|
||||
}
|
||||
if (isImageFallbackModelCoolingDown(item.model.id) || isImageModelBalanceBlocked(item.model)) {
|
||||
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
|
||||
const cooldown = imageFailoverModelCooldown.get(item.model.id);
|
||||
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||
return false;
|
||||
@ -488,7 +488,7 @@ export function getImageCrossProviderCandidates(
|
||||
}
|
||||
if (m.model.id === options.currentModelId) return false;
|
||||
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
|
||||
if (isImageFallbackModelCoolingDown(m.model.id) || isImageModelBalanceBlocked(m.model)) {
|
||||
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
|
||||
const cooldown = imageFailoverModelCooldown.get(m.model.id);
|
||||
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
|
||||
return false;
|
||||
|
||||
@ -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,
|
||||
params: {
|
||||
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||
userMessageId?: string;
|
||||
providerType?: string | null;
|
||||
modelName?: string | null;
|
||||
imageCount?: number | null;
|
||||
@ -224,7 +223,6 @@ export function writeImageGenerationStatusChunk(
|
||||
"data-imageGenerationStatus",
|
||||
{
|
||||
phase: params.phase,
|
||||
userMessageId: params.userMessageId,
|
||||
providerType: normalizeImageLoadingProviderType(params.providerType),
|
||||
modelName: String(params.modelName || "").trim() || undefined,
|
||||
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 { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||
import { geolocation } from "@vercel/functions";
|
||||
import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream as createAiUIMessageStream,
|
||||
createUIMessageStream,
|
||||
generateText,
|
||||
smoothStream,
|
||||
stepCountIs,
|
||||
@ -197,7 +194,6 @@ import {
|
||||
} from "@/lib/ai/runtime/agent-media-markdown";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
||||
import { withAuthenticationFailover } from "@/lib/ai/runtime/authentication-failover";
|
||||
import {
|
||||
recordAiModelHealthFailure,
|
||||
recordAiModelHealthSuccess,
|
||||
@ -337,7 +333,6 @@ import {
|
||||
} from "./_lib/image-media";
|
||||
import {
|
||||
buildImageFallbackCandidates,
|
||||
countChatMessageImageInputs,
|
||||
generateImageWithFailoverCandidate,
|
||||
getImageCrossProviderCandidates,
|
||||
IMAGE_FAILOVER_PROVIDER_ALLOWLIST,
|
||||
@ -346,8 +341,6 @@ import {
|
||||
setImageFallbackModelCooldown,
|
||||
shouldCooldownImageFallbackModel,
|
||||
} from "./_lib/image-failover";
|
||||
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
||||
import { isProviderBalanceError } from "@/lib/request-error";
|
||||
import {
|
||||
buildAgentSceneWorkflowPrompt,
|
||||
buildAgentSlotPrompt,
|
||||
@ -372,10 +365,7 @@ import {
|
||||
resolveReplacementReferenceGeometry,
|
||||
resolveReplacementScenePlanningRuntime,
|
||||
} from "./_lib/detail-replacement";
|
||||
import {
|
||||
selectDetailReplacementGenerationInputs,
|
||||
shouldIncludeDetailReplacementModelImage,
|
||||
} from "@/lib/ai/detail-replacement-inputs";
|
||||
import { selectDetailReplacementGenerationInputs } from "@/lib/ai/detail-replacement-inputs";
|
||||
import {
|
||||
normalizeAgentReportForDisplay,
|
||||
sanitizeAgentOutputSettingsSection,
|
||||
@ -426,14 +416,12 @@ import {
|
||||
type VideoModeType,
|
||||
} from "./_lib/video-models";
|
||||
|
||||
import { reportImageGenerationFailure } from "./_lib/image-generation-failure";
|
||||
|
||||
const chatApiLogger = createLogger("chat.route");
|
||||
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
|
||||
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
|
||||
|
||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
|
||||
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
|
||||
|
||||
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
|
||||
const normalizedMarkdown = String(markdown || "").trim();
|
||||
@ -448,6 +436,45 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
|
||||
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
|
||||
}
|
||||
|
||||
function getPublicImageGenerationErrorText(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
|
||||
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
|
||||
return "图片生成失败,请稍后重试";
|
||||
}
|
||||
return publicMessage;
|
||||
}
|
||||
|
||||
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(
|
||||
1,
|
||||
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
|
||||
@ -905,15 +932,6 @@ export async function POST(request: Request) {
|
||||
const json = await request.json();
|
||||
chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2));
|
||||
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) {
|
||||
chatApiLogger.error("[Chat API] Schema validation failed:", error);
|
||||
return new ChatSDKError("bad_request:api").toResponse();
|
||||
@ -1208,14 +1226,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) {
|
||||
const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => {
|
||||
chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError);
|
||||
@ -1336,7 +1346,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const allowAgentVideoGeneration =
|
||||
(contextPayload?.source === "canvas" && inferVideoTaskRequirements(requestTextContent).expectsVideo) ||
|
||||
Boolean(requestBody.videoParams) ||
|
||||
isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined);
|
||||
let smartAutoWorkflowDecision:
|
||||
@ -5680,7 +5689,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
modelName: resolvedModelRef,
|
||||
imageCount: replacementReferences.length,
|
||||
@ -5934,7 +5942,7 @@ export async function POST(request: Request) {
|
||||
const shouldIncludeModelImage =
|
||||
detailReplacementStrategy !== "product_only" &&
|
||||
Boolean(modelInputImageUrl) &&
|
||||
(shouldIncludeDetailReplacementModelImage(
|
||||
(replacementScenePlanShouldIncludeModelImage(
|
||||
replacementExecutionPlan.scenePlan,
|
||||
) ||
|
||||
selectedProductContextImageUrls.length === 0);
|
||||
@ -6023,7 +6031,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -6179,7 +6186,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: successCount > 0 ? "completed" : "failed",
|
||||
modelName: resolvedModelDisplayName,
|
||||
imageCount: successCount,
|
||||
@ -6412,7 +6418,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
modelName:
|
||||
selectedAgentPreset.defaultModel?.displayName ||
|
||||
@ -6830,7 +6835,7 @@ export async function POST(request: Request) {
|
||||
!(isDetailReplacementPreset &&
|
||||
detailReplacementStrategy === "product_only") &&
|
||||
Boolean(modelInputImageUrl) &&
|
||||
(shouldIncludeDetailReplacementModelImage(
|
||||
(replacementScenePlanShouldIncludeModelImage(
|
||||
replacementExecutionPlan.scenePlan,
|
||||
) ||
|
||||
(isDetailReplacementPreset &&
|
||||
@ -6964,7 +6969,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -7290,7 +7294,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -7519,7 +7522,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -7838,7 +7840,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: successCount > 0 ? "completed" : "failed",
|
||||
modelName:
|
||||
selectedAgentPreset.defaultModel?.displayName ||
|
||||
@ -8048,7 +8049,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: requestedModelProviderType,
|
||||
modelName: finalModelId,
|
||||
@ -8087,7 +8087,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
...directImageStatusPayload,
|
||||
},
|
||||
@ -8180,7 +8179,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: requestedModelProviderType,
|
||||
modelName: directImageResult.model,
|
||||
@ -8201,16 +8199,42 @@ export async function POST(request: Request) {
|
||||
);
|
||||
} catch (directToolError) {
|
||||
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
|
||||
const errorMessage = await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: assistantMessageId, error: directToolError,
|
||||
label: "agent:generate_image:error",
|
||||
const errorMessage = getDirectAgentToolErrorMessage(
|
||||
"generate_image",
|
||||
directToolError,
|
||||
);
|
||||
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(
|
||||
writer,
|
||||
buildDirectAgentImageGenerateFailedProgress(errorMessage),
|
||||
"agent:generate_image:progress-failed",
|
||||
);
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- IMAGE_GENERATION_END -->",
|
||||
"agent:generate_image:end-error",
|
||||
);
|
||||
} finally {
|
||||
stopDirectImageKeepalive();
|
||||
if (stopDirectImageStatus) {
|
||||
@ -8752,19 +8776,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)
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
@ -8882,7 +8893,7 @@ export async function POST(request: Request) {
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
let activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
|
||||
const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
|
||||
resolvedRuntimeModelLookupId,
|
||||
).catch(() => null);
|
||||
if (shouldRunAgentConversation && activeRuntimeModelDescriptor) {
|
||||
@ -9172,7 +9183,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: currentModelConfig?.displayName || actualModelId,
|
||||
@ -9260,7 +9270,6 @@ export async function POST(request: Request) {
|
||||
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);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9335,7 +9344,6 @@ export async function POST(request: Request) {
|
||||
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);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9403,7 +9411,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: usedModelDisplayName,
|
||||
@ -9468,14 +9475,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
} catch (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...");
|
||||
|
||||
// 故障转移:尝试使用火山引擎模型
|
||||
@ -9553,7 +9552,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: "volcano-vision",
|
||||
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId,
|
||||
@ -9619,14 +9617,46 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
|
||||
} catch (failoverError) {
|
||||
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
|
||||
finalError = failoverError;
|
||||
}
|
||||
|
||||
// 如果故障转移也失败,返回错误消息
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error: finalError, label: "gemini:error",
|
||||
});
|
||||
const errorMessageId = generateUUID();
|
||||
writeAssistantTextChunk(
|
||||
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 {
|
||||
stopKeepalive?.();
|
||||
}
|
||||
@ -9824,7 +9854,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "queued",
|
||||
providerType: "fal-image",
|
||||
modelName: modelDisplayName,
|
||||
@ -9931,7 +9960,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: alibabaModelConfig?.displayName || actualModelId,
|
||||
@ -9981,10 +10009,7 @@ export async function POST(request: Request) {
|
||||
|
||||
let fallbackSuccess = false;
|
||||
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) {
|
||||
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
||||
try {
|
||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||
@ -10007,7 +10032,6 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.info(`[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!`);
|
||||
break;
|
||||
} catch (fallbackError) {
|
||||
if (isProviderBalanceError(fallbackError)) balanceBlockedProviders.add(candidate.provider.id);
|
||||
const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError);
|
||||
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
@ -10029,7 +10053,6 @@ export async function POST(request: Request) {
|
||||
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);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10057,10 +10080,9 @@ export async function POST(request: Request) {
|
||||
currentModelKey: actualModelId,
|
||||
currentProviderId: alibabaModelConfig?.providerId,
|
||||
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) {
|
||||
if (balanceBlockedProviders.has(candidate.provider.id)) continue;
|
||||
try {
|
||||
triedFallbackModelIds.add(String(candidate.model.id || ""));
|
||||
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||
@ -10142,7 +10164,6 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.info(`[Chat API] ✅ Cross-provider failover succeeded: ${candidate.model.displayName} (${candidate.provider.type})`);
|
||||
break;
|
||||
} catch (crossError) {
|
||||
if (isProviderBalanceError(crossError)) balanceBlockedProviders.add(candidate.provider.id);
|
||||
const crossErrorMessage = normalizeFailoverErrorMessage(crossError);
|
||||
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
|
||||
@ -10164,7 +10185,6 @@ export async function POST(request: Request) {
|
||||
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);
|
||||
if (!shouldFailoverAiModelError(crossError)) throw crossError;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10232,7 +10252,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: usedModelDisplayName,
|
||||
@ -10297,10 +10316,43 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
|
||||
|
||||
// 返回错误消息
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "alibaba:error",
|
||||
});
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(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 {
|
||||
stopKeepalive?.();
|
||||
}
|
||||
@ -10429,7 +10481,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: "volcano-vision",
|
||||
modelName: volcanoModelConfig?.displayName || actualModelId,
|
||||
@ -10475,7 +10526,6 @@ export async function POST(request: Request) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
}
|
||||
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
if (!fallbackSuccess) {
|
||||
@ -10642,7 +10692,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: "volcano-vision",
|
||||
modelName: usedModelDisplayName,
|
||||
@ -10707,10 +10756,43 @@ export async function POST(request: Request) {
|
||||
}
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "volcano:error",
|
||||
});
|
||||
const errorMessageId = generateUUID();
|
||||
writeAssistantTextChunk(
|
||||
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 {
|
||||
stopKeepalive?.();
|
||||
}
|
||||
@ -11168,7 +11250,7 @@ export async function POST(request: Request) {
|
||||
requestHeaders: request.headers,
|
||||
requestUrl: request.url,
|
||||
defaultModelId: finalModelId,
|
||||
authorizeDestructive: (nodeIds) => isExplicitDestructiveIntent(requestTextContent) && nodeIds.length > 0 && nodeIds.every((nodeId) => selectedCanvasImageNodeIds.includes(nodeId)),
|
||||
destructiveConfirmed: isCanvasOperationConfirmText(requestTextContent),
|
||||
callbacks: {
|
||||
onClientCommand: (command, capability) => {
|
||||
writeCanvasCapabilityCommandChunk(
|
||||
@ -11283,7 +11365,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
dataStream as unknown as StreamWriter,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
|
||||
modelName: currentModelConfig?.displayName || actualModelId,
|
||||
@ -11375,7 +11456,6 @@ export async function POST(request: Request) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
}
|
||||
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11547,7 +11627,6 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
dataStream as unknown as StreamWriter,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: resolvedProviderType || providerType,
|
||||
modelName: usedModelDisplayName,
|
||||
@ -11607,10 +11686,54 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
||||
|
||||
await reportImageGenerationFailure({
|
||||
writer: dataStream as unknown as StreamWriter,
|
||||
chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "openai-compat:error",
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(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;
|
||||
}
|
||||
@ -12156,70 +12279,8 @@ export async function POST(request: Request) {
|
||||
const shouldDisableSmoothStream =
|
||||
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({
|
||||
model: streamModel,
|
||||
maxRetries: shouldRunAgentConversation ? 0 : undefined,
|
||||
model: languageModel,
|
||||
system: effectiveConversationSystemPrompt,
|
||||
messages: convertedMessages,
|
||||
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),
|
||||
|
||||
@ -10,10 +10,6 @@ import {
|
||||
} from "@/lib/db/queries";
|
||||
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||||
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
|
||||
import {
|
||||
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
|
||||
findConfiguredDetailReplacementImageModel,
|
||||
} from "@/lib/ai/detail-replacement-image-model-config";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
@ -161,17 +157,10 @@ export async function GET(request: Request) {
|
||||
: await getEnabledAiModelsWithProvider(false, true);
|
||||
const agentModelWithProvider = await getAgentAiModelWithProvider();
|
||||
const defaultModel = await getDefaultAiModel();
|
||||
const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([
|
||||
const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
|
||||
getSiteSettings(),
|
||||
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
|
||||
getSystemSetting({ key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
const detailReplacementModel = findConfiguredDetailReplacementImageModel(
|
||||
includeUnavailable ? modelsWithProvider.filter(({ model, provider }) =>
|
||||
provider.isEnabled && model.isEnabled && getAiModelFrontendAvailability(model).selectable !== false
|
||||
) : modelsWithProvider,
|
||||
detailReplacementModelSetting?.value,
|
||||
);
|
||||
type ModelWithProvider = (typeof modelsWithProvider)[number];
|
||||
const serializeModel = ({ model, provider }: ModelWithProvider) => ({
|
||||
id: model.id,
|
||||
@ -320,7 +309,6 @@ export async function GET(request: Request) {
|
||||
: null,
|
||||
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
|
||||
agentWorkflowConfiguredModels,
|
||||
detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null,
|
||||
agentModel: agentModelWithProvider
|
||||
? {
|
||||
id: agentModelWithProvider.model.id,
|
||||
|
||||
@ -8,8 +8,6 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Image from "next/image";
|
||||
import { formatBeijingTime } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
type FeaturedImage = {
|
||||
id: string;
|
||||
@ -76,11 +74,18 @@ export default function FeaturedPage() {
|
||||
|
||||
const handleDownload = async (url: string, prompt: string) => {
|
||||
try {
|
||||
await downloadOriginalImage({ url, name: "featured-image" });
|
||||
toast.success("原图已开始下载");
|
||||
const response = await fetch(url);
|
||||
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) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -3,5 +3,10 @@ export default function FlowBoardLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
<link href="/vendor/drawnix.css" rel="stylesheet" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@ -10,10 +9,9 @@ import { Input } from "@/components/ui/input";
|
||||
import Image from "next/image";
|
||||
import { toast } from "sonner";
|
||||
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 { formatBeijingTime } from "@/lib/utils";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
type GalleryImage = {
|
||||
id: string;
|
||||
@ -34,7 +32,7 @@ type Pagination = {
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
function buildGalleryDownloadFilename(url: string, alt: string, contentType: string) {
|
||||
function buildGalleryDownloadFilename(url: string, alt: string) {
|
||||
const rawUrl = String(url || "").trim();
|
||||
const rawAlt = String(alt || "").trim();
|
||||
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() {
|
||||
@ -105,7 +103,7 @@ export default function GalleryPage() {
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, alt: string) => {
|
||||
const originalUrl = String(url || "").trim();
|
||||
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
|
||||
if (!originalUrl) {
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
@ -114,17 +112,32 @@ export default function GalleryPage() {
|
||||
try {
|
||||
toast.info("正在准备下载...");
|
||||
|
||||
// Validate the supplied URL before any legacy thumbnail inference can
|
||||
// remove signed storage transformation parameters.
|
||||
buildImageProxyUrl(originalUrl, { delivery: "original" });
|
||||
const parsed = new URL(originalUrl, window.location.origin);
|
||||
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
||||
|
||||
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
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("图片已开始下载");
|
||||
} catch (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 flex-wrap justify-center gap-6 px-4">
|
||||
{images.map((img) => isRetiredStorageUrl(img.url) ? (
|
||||
<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>
|
||||
) : (
|
||||
{images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
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">
|
||||
<Image
|
||||
src={isRetiredStorageUrl(img.thumbnailUrl) ? img.url : img.thumbnailUrl || img.url}
|
||||
src={img.thumbnailUrl || img.url}
|
||||
alt={img.alt}
|
||||
width={200}
|
||||
height={270}
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
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 {
|
||||
listAiModelHealthEvents,
|
||||
restoreAiModelHealth,
|
||||
} from "@/lib/ai/runtime/model-health";
|
||||
|
||||
@ -17,30 +15,14 @@ export async function GET(request: Request) {
|
||||
await requireAdmin();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const modelId = searchParams.get("modelId");
|
||||
const providerId = searchParams.get("providerId");
|
||||
const rawLimit = Number(searchParams.get("limit") || 20);
|
||||
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
|
||||
const rawPage = Number(searchParams.get("page") || 1);
|
||||
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
|
||||
const filters = [];
|
||||
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId));
|
||||
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 });
|
||||
const modelId = searchParams.get("modelId") || undefined;
|
||||
const limit = Number(searchParams.get("limit") || 80);
|
||||
const events = await listAiModelHealthEvents({
|
||||
modelId,
|
||||
limit: Number.isFinite(limit) ? limit : 80,
|
||||
});
|
||||
|
||||
return Response.json({ events });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
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 {
|
||||
getAllAiModels,
|
||||
getAiModelById,
|
||||
getAiModelsByProviderId,
|
||||
createAiModel,
|
||||
updateAiModel,
|
||||
deleteAiModel,
|
||||
@ -155,14 +154,8 @@ export async function GET(request: Request) {
|
||||
const id = searchParams.get("id");
|
||||
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) {
|
||||
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
|
||||
const model = await getAiModelById({ id });
|
||||
if (!model) {
|
||||
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
||||
}
|
||||
@ -170,19 +163,12 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
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)) });
|
||||
}
|
||||
|
||||
const models = await db.select().from(aiModel);
|
||||
const normalized = 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) });
|
||||
const models = await getAllAiModels();
|
||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
|
||||
@ -1,31 +1,237 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage, user } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { chat, generatedImage, message, user } from "@/lib/db/schema";
|
||||
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
|
||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||
|
||||
function extractVideoUrlFromParts(parts: unknown): string | null {
|
||||
const text = Array.isArray(parts)
|
||||
? 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) {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
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 });
|
||||
const query = parseMediaQuery(request.nextUrl.searchParams);
|
||||
if (query.startDate && query.endDate && query.startDate > query.endDate) {
|
||||
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const [result] = await db.execute(mediaRecordsQuery(query));
|
||||
const images = await resolveArchivedImageUrls(result.records);
|
||||
const total = Number(result.total);
|
||||
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total,
|
||||
totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } });
|
||||
|
||||
// 检查是否为管理员
|
||||
const currentUser = await db.query.user.findFirst({
|
||||
where: eq(user.id, session.user.id),
|
||||
});
|
||||
|
||||
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) {
|
||||
if (error instanceof ChatSDKError) return error.toResponse();
|
||||
console.error("[Admin Images API] GET failed", error);
|
||||
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
|
||||
console.error("[Admin Images API] Error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch images" }, { 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;
|
||||
|
||||
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 updatedModels: AiModel[] = [];
|
||||
const disabledModels: AiModel[] = [];
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
@ -57,20 +56,14 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
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 description = String(body.description || "").trim() || null;
|
||||
const thumbnail = String(body.thumbnail || "").trim() || null;
|
||||
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
|
||||
|
||||
const [insertedProject] = await db
|
||||
const [project] = await db
|
||||
.insert(canvasProject)
|
||||
.values({
|
||||
id,
|
||||
userId: session.user.id,
|
||||
title,
|
||||
description,
|
||||
@ -85,22 +78,8 @@ export async function POST(request: NextRequest) {
|
||||
agentProjectState: projectState,
|
||||
} as any,
|
||||
})
|
||||
.onConflictDoNothing({ target: canvasProject.id })
|
||||
.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) });
|
||||
} catch (error) {
|
||||
console.error("[AgentProjects API] 创建项目失败:", error);
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { resolveAgentWorkflowComposerSelections } from "@/lib/agents/workflow-composer";
|
||||
import { AGENT_WORKFLOW_UI_CONFIG_KEY, normalizeAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
|
||||
const selectionsSchema = z.record(z.string().max(160), z.string().max(8192))
|
||||
.refine((selections) => Object.keys(selections).length <= 100);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return Response.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const selections = selectionsSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!selections.success) return Response.json({ error: "风格模板选择无效" }, { status: 400 });
|
||||
|
||||
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY });
|
||||
const config = normalizeAgentWorkflowUiConfig(setting?.value);
|
||||
return Response.json({ selections: resolveAgentWorkflowComposerSelections({ config, selections: selections.data }) });
|
||||
}
|
||||
@ -1,309 +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 { MAX_DETAIL_REPLACEMENT_REFERENCES } from "@/lib/agents/mode-workspace-state";
|
||||
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 : [];
|
||||
if (requested.length === 0) {
|
||||
return NextResponse.json({ error: "请先选择详情参考图" }, { status: 400 });
|
||||
}
|
||||
if (requested.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
|
||||
return NextResponse.json({
|
||||
error: `本次选中了 ${requested.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
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 (
|
||||
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 });
|
||||
}
|
||||
if (typeof body.expectedModelId === "string" && body.expectedModelId !== model) {
|
||||
return NextResponse.json({
|
||||
code: "DETAIL_REPLACEMENT_MODEL_CHANGED",
|
||||
error: "详情替换模型已更新,请确认页面显示的模型后重新提交",
|
||||
}, { status: 409 });
|
||||
}
|
||||
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 { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||||
|
||||
@ -13,6 +12,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
||||
"tripo3d.ai",
|
||||
"tripo3d.com",
|
||||
"worldlabs.ai",
|
||||
"s3.1080p.hk",
|
||||
"img.yydd.ai",
|
||||
"scqny.jiansun.vip",
|
||||
"blob.vercel-storage.com",
|
||||
@ -25,6 +25,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
||||
|
||||
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
|
||||
"bbyy.ai",
|
||||
"s3.1080p.hk",
|
||||
"img.yydd.ai",
|
||||
];
|
||||
|
||||
@ -164,9 +165,6 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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)) {
|
||||
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
|
||||
}
|
||||
|
||||
@ -2,14 +2,12 @@ import { NextResponse } from "next/server";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
||||
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
||||
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
||||
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
||||
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
||||
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
||||
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 600;
|
||||
@ -41,8 +39,7 @@ export async function GET(
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
||||
return NextResponse.json({ success: true, task: { ...task, failure, error: failure ? formatRequestFailure(failure, task.id) : task.error } });
|
||||
return NextResponse.json({ success: true, task });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
@ -54,11 +51,9 @@ export async function POST(
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
let requestId: string | undefined;
|
||||
try {
|
||||
const { id } = await params;
|
||||
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
||||
requestId = command.requestId;
|
||||
const result = await executeCanvasCapabilityRuntime({
|
||||
command,
|
||||
projectId: id,
|
||||
@ -72,9 +67,8 @@ export async function POST(
|
||||
error instanceof CanvasCapabilityRuntimeError ||
|
||||
error instanceof CanvasAgentMutationError
|
||||
) {
|
||||
const failure = getRequestFailureDetails(error);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
@ -88,10 +82,10 @@ export async function POST(
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const failure = getRequestFailureDetails(error, "执行画布能力失败");
|
||||
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
||||
return NextResponse.json(
|
||||
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
|
||||
{ status: error instanceof RmbgServiceError ? error.status : 500 }
|
||||
{ success: false, error: message },
|
||||
{ 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 { canvasProject } from '@/lib/db/schema';
|
||||
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
||||
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
buildStaleCanvasSaveConflictPayload,
|
||||
@ -123,8 +122,7 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
const { title, description, canvasData } = body;
|
||||
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
|
||||
const { title, description, thumbnail, canvasData } = body;
|
||||
const baseCanvasRevision =
|
||||
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||
const workspaceType =
|
||||
@ -207,11 +205,7 @@ export async function PUT(
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: saved,
|
||||
previousCanvasRevision: existingProject?.updatedAt.toISOString() ?? null,
|
||||
});
|
||||
return NextResponse.json({ success: true, data: saved });
|
||||
});
|
||||
} catch (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,15 +1,100 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
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 maxDuration = 60;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
||||
type BoxCutoutRequest = {
|
||||
imageUrl?: string;
|
||||
cropRect?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
paddingRatio?: number;
|
||||
prompt?: string;
|
||||
chatId?: string | null;
|
||||
messageId?: string | null;
|
||||
skipGallerySave?: boolean;
|
||||
sourceNodeId?: string;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
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 { 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 { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import {
|
||||
buildAttachmentContentDisposition,
|
||||
fetchCanvasDownloadAsset,
|
||||
@ -16,50 +8,6 @@ import {
|
||||
|
||||
export const runtime = "nodejs";
|
||||
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 {
|
||||
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) {
|
||||
const requestId = randomUUID();
|
||||
try {
|
||||
const session = await auth();
|
||||
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 requestOrigin = getRequestOrigin(request);
|
||||
const candidates = 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];
|
||||
const urls = resolveDownloadUrls(body, requestOrigin);
|
||||
if (urls.length === 0) {
|
||||
return failed(getDownloadFailure("图片地址无效"), requestId);
|
||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
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") || "";
|
||||
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;
|
||||
const failures: string[] = [];
|
||||
|
||||
@ -167,14 +73,17 @@ export async function POST(request: NextRequest) {
|
||||
includeSequencePrefix: false,
|
||||
});
|
||||
if (result.ok) break;
|
||||
failures.push(result.error);
|
||||
failures.push(`${url}: ${result.error}`);
|
||||
}
|
||||
|
||||
if (!result?.ok) {
|
||||
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, {
|
||||
@ -187,7 +96,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("download_failed", error, { requestId });
|
||||
return failed(getDownloadFailure(error), requestId);
|
||||
console.error("[CanvasDownloadImage] Failed:", error);
|
||||
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 { after, NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import JSZip from "jszip";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
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 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 TASK_TTL_MS = 24 * 60 * 60_000;
|
||||
const PREPARATION_TTL_MS = 6 * 60 * 60_000;
|
||||
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
|
||||
const active = running.__canvasZipRunning ??= new Set<string>();
|
||||
const TASK_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
const globalTaskStore = globalThis as typeof globalThis & {
|
||||
__canvasDownloadZipTasks?: Map<string, ZipTask>;
|
||||
};
|
||||
|
||||
function getTaskStore() {
|
||||
if (!globalTaskStore.__canvasDownloadZipTasks) {
|
||||
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
|
||||
}
|
||||
return globalTaskStore.__canvasDownloadZipTasks;
|
||||
}
|
||||
|
||||
function sanitizeFolderName(value: string): string {
|
||||
const cleaned = String(value || "")
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
@ -26,7 +56,7 @@ function sanitizeFolderName(value: string): string {
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.trim();
|
||||
return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`;
|
||||
return cleaned || `canvas-images-${Date.now()}`;
|
||||
}
|
||||
|
||||
function buildContentDisposition(folderName: string): string {
|
||||
@ -80,172 +110,258 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
|
||||
.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) {
|
||||
return { 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: task.status === "completed" ? 100 : Math.min(99,
|
||||
Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)),
|
||||
error: task.error || "", expiresAt: task.expiresAt,
|
||||
downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" };
|
||||
const downloadUrl =
|
||||
task.status === "completed"
|
||||
? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1`
|
||||
: "";
|
||||
return {
|
||||
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;
|
||||
active.add(id);
|
||||
let release: (() => Promise<void>) | null = null;
|
||||
let task: ZipTask | null = null;
|
||||
|
||||
async function processZipTask(params: {
|
||||
task: ZipTask;
|
||||
requestOrigin: string;
|
||||
cookie: string;
|
||||
}) {
|
||||
const { task, requestOrigin, cookie } = params;
|
||||
const store = getTaskStore();
|
||||
try {
|
||||
release = await claimZipTask(id);
|
||||
if (!release) return;
|
||||
task = await loadZipTask(id, userId);
|
||||
if (!task || task.status === "completed" || task.status === "failed") return;
|
||||
await assertZipDiskSpace();
|
||||
const existingZip = task.zipFile && task.contentDisposition && await stat(task.zipFile.path).catch(() => null);
|
||||
const reuseZip = existingZip && existingZip.size === task.zipFile!.bytes;
|
||||
task.status = "running"; task.stage = reuseZip ? "正在准备加速下载" : "正在准备原图"; task.updatedAt = Date.now();
|
||||
await saveZipTask(task);
|
||||
if (!reuseZip) {
|
||||
delete task.zipFile; delete task.contentDisposition; delete task.cdnUrl;
|
||||
// Stage each image on disk, not all PNG buffers in the web process heap.
|
||||
// A restart resumes from these files using the current owner's session.
|
||||
let cursor = 0;
|
||||
let writes = Promise.resolve();
|
||||
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
|
||||
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
|
||||
while (cursor < task!.files.length) {
|
||||
const index = cursor++; const file = task!.files[index];
|
||||
const path = join(zipTaskPath(id), `${index}.image`);
|
||||
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
|
||||
delete file.prepared; delete file.filename;
|
||||
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
|
||||
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
|
||||
if (result.ok) {
|
||||
await assertZipDiskSpace(result.bytes.length);
|
||||
await writeFile(path, result.bytes, { mode: 0o600 });
|
||||
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
|
||||
} else file.error = result.error;
|
||||
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
|
||||
task!.successCount = task!.files.filter(f => f.prepared).length;
|
||||
task!.failedCount = task!.files.filter(f => f.error).length;
|
||||
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
|
||||
task!.updatedAt = Date.now(); await persist();
|
||||
task.status = "running";
|
||||
task.stage = "正在读取源图";
|
||||
task.updatedAt = Date.now();
|
||||
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(task.folderName) || zip;
|
||||
const results: Array<
|
||||
| Awaited<ReturnType<typeof fetchCanvasDownloadAsset>>
|
||||
| null
|
||||
> = new Array(task.files.length).fill(null);
|
||||
let cursor = 0;
|
||||
const workerCount = Math.min(MAX_CONCURRENCY, task.files.length);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
if (index >= task.files.length) break;
|
||||
|
||||
const file = task.files[index]!;
|
||||
results[index] = await fetchCanvasDownloadAsset({
|
||||
file,
|
||||
index,
|
||||
requestOrigin,
|
||||
cookie,
|
||||
});
|
||||
|
||||
const result = results[index];
|
||||
task.processedCount += 1;
|
||||
if (result?.ok) {
|
||||
task.successCount += 1;
|
||||
} else {
|
||||
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 zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
|
||||
for (const file of task.files) if (file.prepared && file.filename)
|
||||
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
|
||||
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
|
||||
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
|
||||
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
|
||||
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
|
||||
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
|
||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
||||
}
|
||||
task.stage = "正在准备下载链接"; await saveZipTask(task);
|
||||
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!,
|
||||
onProgress: async bytes => {
|
||||
task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
|
||||
task!.updatedAt = Date.now(); await saveZipTask(task!);
|
||||
} });
|
||||
if (cdn) {
|
||||
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
|
||||
if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
|
||||
task.cdnUrl = cdn.url;
|
||||
}
|
||||
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
|
||||
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||
await saveZipTask(task);
|
||||
// The complete ZIP is enough for retries; release staged duplicate bytes.
|
||||
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
|
||||
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
|
||||
bytes: task.zipFile!.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
|
||||
} catch (error) {
|
||||
if (task) {
|
||||
task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成";
|
||||
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
|
||||
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
|
||||
}
|
||||
console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error });
|
||||
} finally { await release?.().catch(() => undefined); active.delete(id); }
|
||||
}
|
||||
function schedule(task: ZipTask, request: NextRequest) {
|
||||
const run = () => trackRuntimeWork(() => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""));
|
||||
after(run);
|
||||
}
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const body = await request.json();
|
||||
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
|
||||
let task = await loadZipTask(id, session.user.id);
|
||||
if (!task) {
|
||||
await assertZipTaskCapacity(session.user.id);
|
||||
if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
|
||||
const files = toDownloadItems(body.files, getRequestOrigin(request));
|
||||
if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length)
|
||||
return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 });
|
||||
// Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest.
|
||||
await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
|
||||
await mkdir(zipTaskPath(id), { mode: 0o700 });
|
||||
});
|
||||
const now = Date.now();
|
||||
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
|
||||
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;
|
||||
})
|
||||
);
|
||||
|
||||
const failures: Array<{ index: number; url: string; name?: string; error: string }> = [];
|
||||
for (const [index, item] of results.entries()) {
|
||||
if (!item || !item.ok) {
|
||||
failures.push({
|
||||
index: index + 1,
|
||||
url: item?.ok === false ? item.url : task.files[index]?.url || "",
|
||||
name: item?.ok === false ? item.name : task.files[index]?.name,
|
||||
error: item?.ok === false ? item.error : "未知错误",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS;
|
||||
await saveZipTask(task);
|
||||
folder.file(item.filename, item.bytes);
|
||||
}
|
||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
||||
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
|
||||
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
|
||||
|
||||
if (failures.length > 0) {
|
||||
folder.file(
|
||||
"_download-failures.txt",
|
||||
[
|
||||
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。",
|
||||
"",
|
||||
...failures.map((item) => {
|
||||
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`;
|
||||
return `${label}\nURL: ${item.url}\n原因: ${item.error}`;
|
||||
}),
|
||||
].join("\n\n")
|
||||
);
|
||||
console.warn("[CanvasDownloadZipTask] Partial download failures:", failures);
|
||||
}
|
||||
|
||||
task.stage = "正在生成 ZIP 文件";
|
||||
task.updatedAt = Date.now();
|
||||
task.zipBuffer = await zip.generateAsync(
|
||||
{
|
||||
type: "nodebuffer",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
},
|
||||
(metadata) => {
|
||||
task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0));
|
||||
task.updatedAt = Date.now();
|
||||
}
|
||||
);
|
||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
||||
task.status = "completed";
|
||||
task.stage = "打包完成";
|
||||
task.compressPercent = 100;
|
||||
task.updatedAt = Date.now();
|
||||
task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||
} catch (error) {
|
||||
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });
|
||||
return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 });
|
||||
console.error("[CanvasDownloadZipTask] Failed:", error);
|
||||
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();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const id = request.nextUrl.searchParams.get("taskId") || "";
|
||||
const task = await loadZipTask(id, session.user.id);
|
||||
if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 });
|
||||
if (request.nextUrl.searchParams.get("download") === "1") {
|
||||
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 });
|
||||
cleanupExpiredTasks();
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
||||
return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
const files = toDownloadItems(body.files, requestOrigin);
|
||||
const folderName = sanitizeFolderName(String(body.folderName || ""));
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 });
|
||||
}
|
||||
|
||||
const task: ZipTask = {
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
folderName,
|
||||
files,
|
||||
totalCount: files.length,
|
||||
processedCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
compressPercent: 0,
|
||||
status: "pending",
|
||||
stage: "等待服务端开始打包",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + TASK_TTL_MS,
|
||||
};
|
||||
getTaskStore().set(task.id, task);
|
||||
|
||||
void processZipTask({
|
||||
task,
|
||||
requestOrigin,
|
||||
cookie: request.headers.get("cookie") || "",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: serializeTask(task),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CanvasDownloadZipTask] Create failed:", error);
|
||||
return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
cleanupExpiredTasks();
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim();
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ success: false, error: "缺少任务 ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = getTaskStore().get(taskId);
|
||||
if (!task || task.userId !== session.user.id) {
|
||||
return NextResponse.json({ success: false, error: "打包任务不存在或已过期" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (request.nextUrl.searchParams.get("download") === "1") {
|
||||
if (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 JSZip from "jszip";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
@ -160,7 +159,13 @@ export async function POST(request: NextRequest) {
|
||||
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,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
|
||||
@ -2,7 +2,6 @@ import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||
|
||||
export const maxDuration = 1200;
|
||||
|
||||
@ -24,15 +23,13 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: {
|
||||
id: task.id,
|
||||
status: task.status,
|
||||
attempts: task.attempts,
|
||||
error: failure ? formatRequestFailure(failure, task.id) : null,
|
||||
failure,
|
||||
error: task.error || null,
|
||||
result: task.result || null,
|
||||
createdAt: task.createdAt,
|
||||
startedAt: task.startedAt,
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { videoUpscaleSchema } from "@/lib/video-canvas/video-upscale-contract";
|
||||
import { videoEditRequestSchema } from "@/lib/video-canvas/video-edit-contract";
|
||||
import { canvasLayerSplitSchema } from "@/lib/video-canvas/layer-split-contract";
|
||||
import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
|
||||
import { audioRequestSchema } from "@/lib/video-canvas/audio-contract";
|
||||
import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery";
|
||||
@ -14,7 +16,6 @@ import {
|
||||
ownsTaskProject,
|
||||
} from "@/lib/video-canvas/task-store";
|
||||
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
|
||||
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 900;
|
||||
|
||||
@ -29,7 +30,7 @@ async function owner(projectId: unknown) {
|
||||
}
|
||||
const fail = (error: unknown) =>
|
||||
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) },
|
||||
);
|
||||
const kick = (userId: string, projectId: string) =>
|
||||
@ -65,9 +66,10 @@ export async function POST(request: Request) {
|
||||
)
|
||||
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-edit" && !videoEditRequestSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("视频编辑参数无效"), {status:400});
|
||||
if (parsed.data.kind === "image-layer-split" && !canvasLayerSplitSchema.safeParse(parsed.data.params).success) throw Object.assign(Error("图层分离参数无效"), {status:400});
|
||||
const userId = await owner(parsed.data.projectId);
|
||||
const task = await createCanvasGenerationTask(userId, parsed.data,
|
||||
() => preflightCanvasProductionRequests([parsed.data]));
|
||||
const task = await createCanvasGenerationTask(userId, parsed.data);
|
||||
kick(userId, parsed.data.projectId);
|
||||
return NextResponse.json({ success: true, task }, { status: 202 });
|
||||
} catch (error) {
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
ownsTaskProject,
|
||||
} from "@/lib/video-canvas/task-store";
|
||||
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
|
||||
import { preflightCanvasProductionRequests } from "@/lib/video-canvas/production-services";
|
||||
export const runtime = "nodejs",
|
||||
maxDuration = 900;
|
||||
export async function POST(request: Request) {
|
||||
@ -28,13 +27,12 @@ export async function POST(request: Request) {
|
||||
userId = session.user.id;
|
||||
if (!(await ownsTaskProject(userId, projectId)))
|
||||
return NextResponse.json({ error: "画布不存在" }, { status: 404 });
|
||||
const tasks = await createCanvasGroupTasks(userId, parsed.data,
|
||||
() => preflightCanvasProductionRequests(parsed.data));
|
||||
const tasks = await createCanvasGroupTasks(userId, parsed.data);
|
||||
after(() => runCanvasGenerationQueue(userId, projectId));
|
||||
return NextResponse.json({ success: true, tasks }, { status: 202 });
|
||||
} catch (e) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -976,8 +976,7 @@ export async function POST(request: NextRequest) {
|
||||
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
|
||||
const annotatedUpload = await uploadFile(
|
||||
annotatedFilename,
|
||||
toArrayBuffer(annotatedBuffer),
|
||||
{ preferQiniuForm: true }
|
||||
toArrayBuffer(annotatedBuffer)
|
||||
);
|
||||
const prompt = buildLocalEditPrompt(regions);
|
||||
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import { executeConfiguredVip98kTask, getVip98kRuntimeProviders } from "@/lib/ai/vip98k-runtime";
|
||||
import { db } from "@/lib/db";
|
||||
import { aiModel } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import sharp from "sharp";
|
||||
|
||||
@ -94,39 +90,6 @@ function fallbackItemsFromRaw(raw: string): OcrItem[] {
|
||||
}));
|
||||
}
|
||||
|
||||
async function detectTextWithVip98k(imageUrl: string): Promise<OcrItem[] | null> {
|
||||
const providers = await getVip98kRuntimeProviders();
|
||||
if (!providers.length) return null;
|
||||
|
||||
const model = await db.query.aiModel.findFirst({
|
||||
columns: { providerId: true },
|
||||
where: and(
|
||||
inArray(aiModel.providerId, providers.map(({ id }) => id)),
|
||||
eq(aiModel.modelId, "ocr"),
|
||||
eq(aiModel.isEnabled, true),
|
||||
),
|
||||
});
|
||||
if (!model) return null;
|
||||
|
||||
const result = await executeConfiguredVip98kTask({
|
||||
modelId: "ocr",
|
||||
kind: "image-tool",
|
||||
imageUrls: [imageUrl],
|
||||
preferredProviderId: model.providerId,
|
||||
});
|
||||
const textUrl = result.outputUrls[0];
|
||||
if (!textUrl) throw new Error("98K 文字识别未返回文本文件");
|
||||
|
||||
const response = await fetch(textUrl, { signal: AbortSignal.timeout(15_000) });
|
||||
const type = response.headers.get("content-type") || "";
|
||||
if (!response.ok || !/^(?:text\/plain|application\/octet-stream)(?:;|$)/i.test(type)) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`无法读取 98K 文字识别结果(HTTP ${response.status})`);
|
||||
}
|
||||
return (await response.text()).split(/\r\n?|\n/).map((line) => line.trim())
|
||||
.filter(Boolean).slice(0, 50).map((text, index) => ({ id: String(index + 1), text }));
|
||||
}
|
||||
|
||||
function shouldUseDataUrlFallback(error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase();
|
||||
@ -184,11 +147,6 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
|
||||
}
|
||||
|
||||
const detected = await detectTextWithVip98k(imageUrl);
|
||||
if (detected !== null) {
|
||||
return NextResponse.json({ success: true, data: { items: detected, model: "ocr" } });
|
||||
}
|
||||
|
||||
logger.debug("model_selected", { model: "qwen3-vl" });
|
||||
const prompt = [
|
||||
"你是图片文字识别助手。",
|
||||
@ -233,9 +191,15 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const parsedJson = parseJsonText(text || "");
|
||||
const items = Array.isArray(parsedJson?.items)
|
||||
? sanitizeItems(parsedJson.items)
|
||||
: fallbackItemsFromRaw(text);
|
||||
let items: OcrItem[] = [];
|
||||
|
||||
if (parsedJson && Array.isArray(parsedJson.items)) {
|
||||
items = sanitizeItems(parsedJson.items);
|
||||
}
|
||||
|
||||
if (items.length === 0 && text) {
|
||||
items = fallbackItemsFromRaw(text);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@ -249,7 +213,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "图片文字识别暂不可用,请稍后重试",
|
||||
error: error instanceof Error ? error.message : "文字识别失败",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
|
||||
@ -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 { db } from "@/lib/db";
|
||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||
import { and, eq, desc, sql } from "drizzle-orm";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_URL_PATTERN } from "@/lib/storage/retired-storage";
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||
|
||||
const FEATURED_TIMEOUT_MS = 1800;
|
||||
@ -58,17 +57,13 @@ export async function GET(request: NextRequest) {
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userDepartment: user.department,
|
||||
modelName: sql<string | null>`(select ${aiModel.displayName} from ${aiModel}
|
||||
where ${aiModel.modelId} = ${generatedImage.modelId}
|
||||
order by ${aiModel.isEnabled} desc, ${aiModel.createdAt} desc, ${aiModel.id} asc limit 1)`,
|
||||
modelName: aiModel.displayName,
|
||||
totalCount: sql<number>`count(*) over()::int`,
|
||||
})
|
||||
.from(generatedImage)
|
||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||
.where(and(
|
||||
eq(generatedImage.isFeatured, true),
|
||||
sql`${generatedImage.originalUrl} !~* ${RETIRED_STORAGE_URL_PATTERN}`,
|
||||
))
|
||||
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
||||
.where(eq(generatedImage.isFeatured, true))
|
||||
.orderBy(desc(generatedImage.featuredAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
@ -79,7 +74,7 @@ export async function GET(request: NextRequest) {
|
||||
return {
|
||||
images: featuredImages.map((img) => ({
|
||||
id: img.id,
|
||||
thumbnailUrl: isRetiredStorageUrl(img.thumbnailUrl) ? img.originalUrl : img.thumbnailUrl,
|
||||
thumbnailUrl: img.thumbnailUrl,
|
||||
originalUrl: img.originalUrl,
|
||||
imageSize: img.imageSize,
|
||||
prompt: img.prompt,
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
import type {
|
||||
FlowBoardDraft,
|
||||
FlowBoardSheetSnapshotData,
|
||||
FlowBoardSnapshotData,
|
||||
} from "@/lib/db/schema";
|
||||
import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema";
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const title = String(value || "").trim();
|
||||
return title.slice(0, 256) || "未命名流程白板";
|
||||
@ -88,67 +17,15 @@ export function normalizeFlowBoardSnapshot(
|
||||
value: unknown
|
||||
): FlowBoardSnapshotData {
|
||||
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 =
|
||||
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
||||
? raw.updatedAt
|
||||
: new Date().toISOString();
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
activeSheetId: activeSheet.id,
|
||||
sheets: nextSheets,
|
||||
children: activeChildren,
|
||||
viewport: activeViewport,
|
||||
theme: activeTheme,
|
||||
canvas: activeCanvas,
|
||||
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
|
||||
viewport: normalizeRecord(raw.viewport),
|
||||
theme: normalizeRecord(raw.theme),
|
||||
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 { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
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";
|
||||
const logger = createLogger("image.download");
|
||||
|
||||
const DEFAULT_QUALITY = 82;
|
||||
const MAX_WIDTH = 4096;
|
||||
@ -14,7 +9,6 @@ const MIN_WIDTH = 64;
|
||||
const MIN_QUALITY = 40;
|
||||
const MAX_QUALITY = 100;
|
||||
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_ITEMS = 256;
|
||||
|
||||
@ -236,7 +230,6 @@ function resolveDirectImageTarget(
|
||||
requestedQuality: number | null,
|
||||
requestedFormat: string | null
|
||||
): URL | null {
|
||||
if (isAlibabaModelImageUrl(target.toString())) return null;
|
||||
if (!isBrowserImageRequest(request)) {
|
||||
return null;
|
||||
}
|
||||
@ -265,19 +258,7 @@ function redirectToDirectImage(target: URL): NextResponse {
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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();
|
||||
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) {
|
||||
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
||||
}
|
||||
@ -293,24 +274,6 @@ export async function GET(request: NextRequest) {
|
||||
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)) {
|
||||
return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
|
||||
}
|
||||
@ -322,47 +285,8 @@ export async function GET(request: NextRequest) {
|
||||
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
||||
const requestedFormat = searchParams.get("f");
|
||||
const forceProxy = searchParams.get("proxy") === "1";
|
||||
const original = searchParams.get("original") === "1";
|
||||
|
||||
if (original && (requestedWidth || requestedQuality || requestedFormat)) {
|
||||
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) {
|
||||
if (!forceProxy) {
|
||||
const directImageTarget = resolveDirectImageTarget(
|
||||
request,
|
||||
target,
|
||||
@ -375,12 +299,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = original
|
||||
? `${target.toString()}|original-v1`
|
||||
: `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
||||
const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
||||
const etag = buildEtag(cacheKey);
|
||||
const ifNoneMatch = request.headers.get("if-none-match");
|
||||
if (!original && ifNoneMatch && ifNoneMatch === etag) {
|
||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||
return new NextResponse(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
@ -390,7 +312,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const cached = original ? null : getCachedImageResponse(cacheKey);
|
||||
const cached = getCachedImageResponse(cacheKey);
|
||||
if (cached) {
|
||||
return buildImageResponse(cached.body, {
|
||||
contentType: cached.contentType,
|
||||
@ -400,82 +322,32 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const fetchSignal = AbortSignal.any([
|
||||
request.signal,
|
||||
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
||||
]);
|
||||
const upstreamFailure = (error: unknown) => {
|
||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
||||
if (original) return downloadFailure(error);
|
||||
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
|
||||
return NextResponse.json(
|
||||
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
|
||||
{ status: timedOut ? 504 : 502 },
|
||||
);
|
||||
};
|
||||
let contentType: string;
|
||||
let originalBuffer: Buffer;
|
||||
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
||||
try {
|
||||
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache");
|
||||
const data = await fetchImageBytes(target.toString(), { timeoutMs: SMALL_PREVIEW_FETCH_TIMEOUT_MS });
|
||||
originalBuffer = data.buffer;
|
||||
contentType = data.contentType;
|
||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
||||
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
return upstreamFailure(error);
|
||||
}
|
||||
} else {
|
||||
let upstreamResponse: Response;
|
||||
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 });
|
||||
}
|
||||
if (original) {
|
||||
if (upstreamResponse.status !== 200 || !upstreamResponse.body) {
|
||||
await upstreamResponse.body?.cancel();
|
||||
return downloadFailure("源图片传输中断,请重试");
|
||||
}
|
||||
const headers = new Headers({
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": "attachment",
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
});
|
||||
const length = upstreamResponse.headers.get("content-length");
|
||||
const encoding = upstreamResponse.headers.get("content-encoding");
|
||||
if (length && (!encoding || encoding.toLowerCase() === "identity")) headers.set("Content-Length", length);
|
||||
return new NextResponse(upstreamResponse.body, { headers });
|
||||
}
|
||||
try {
|
||||
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||
} catch (error) {
|
||||
return upstreamFailure(error);
|
||||
}
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(target.toString(), {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: {
|
||||
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
},
|
||||
});
|
||||
} 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 isGif = contentType.toLowerCase().includes("image/gif");
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
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_MAX_WORKERS = 3;
|
||||
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_THRESHOLD = 8;
|
||||
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 {
|
||||
try {
|
||||
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");
|
||||
}
|
||||
const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
|
||||
const preprocessedProduct = await preprocessProductImageWithRmbg({
|
||||
const preprocessedProduct = await preprocessProductImageWithRmbg(
|
||||
request,
|
||||
requestId,
|
||||
userId: session.user.id,
|
||||
productImageUrl,
|
||||
preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled,
|
||||
});
|
||||
shouldForcePreRmbg ? true : config.preRmbgEnabled
|
||||
);
|
||||
const fusionProductImageUrl = preprocessedProduct.productImageUrl;
|
||||
logger.info("pre_rmbg_resolved", {
|
||||
requestId,
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resolveSiteBranding } from "@/lib/site-branding";
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
@ -10,8 +9,7 @@ import {
|
||||
systemSettingSnapshot,
|
||||
systemSettings,
|
||||
} from "@/lib/db/schema";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { omitAgentWorkflowPrompts } from "@/lib/agents/workflow-loading";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin";
|
||||
import {
|
||||
@ -154,7 +152,7 @@ import {
|
||||
} from "@/lib/ai-wash/config";
|
||||
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
||||
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.
|
||||
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
|
||||
@ -554,9 +552,6 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
|
||||
type PublicSiteSettingsScope =
|
||||
| "agent-brand-model"
|
||||
| "agent-canvas"
|
||||
| "agent-canvas-base"
|
||||
| "agent-workflow-templates"
|
||||
| "agent-entry"
|
||||
| "agent-presets"
|
||||
| "auth"
|
||||
| "branding"
|
||||
@ -568,9 +563,6 @@ type PublicSiteSettingsScope =
|
||||
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
||||
"agent-brand-model",
|
||||
"agent-canvas",
|
||||
"agent-canvas-base",
|
||||
"agent-workflow-templates",
|
||||
"agent-entry",
|
||||
"agent-presets",
|
||||
"auth",
|
||||
"branding",
|
||||
@ -614,60 +606,22 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
||||
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(
|
||||
scope: PublicSiteSettingsScope,
|
||||
data: Record<string, unknown>,
|
||||
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
|
||||
cacheKey: string = scope,
|
||||
options: { fallback?: boolean; startedAt?: number; etag?: string } = {}
|
||||
options: { fallback?: boolean; startedAt?: number } = {}
|
||||
) {
|
||||
if (cacheStatus === "MISS") {
|
||||
setScopedSiteSettingsCache(
|
||||
cacheKey,
|
||||
data,
|
||||
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
|
||||
options.etag
|
||||
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
const response = NextResponse.json(data);
|
||||
if (options.etag) response.headers.set("ETag", options.etag);
|
||||
// Lightweight public settings change rarely; keep them browser-local briefly
|
||||
// so page opens do not repeatedly wait on the large full settings endpoint.
|
||||
response.headers.set(
|
||||
@ -687,7 +641,7 @@ function createScopedSiteSettingsResponse(
|
||||
return response;
|
||||
}
|
||||
|
||||
async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false) {
|
||||
async function readScopedSiteSettings(keys: string[]) {
|
||||
const [settings, settingRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@ -695,14 +649,8 @@ async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
keys.length > 0
|
||||
? (omitWorkflowSteps
|
||||
? db.select({
|
||||
key: systemSettings.key,
|
||||
value: sql<unknown>`case when ${systemSettings.key} = ${AGENT_WORKFLOW_UI_CONFIG_KEY}
|
||||
and jsonb_typeof(${systemSettings.value}) = 'object'
|
||||
then ${systemSettings.value} - 'steps' else ${systemSettings.value} end`,
|
||||
})
|
||||
: db.select())
|
||||
? db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(inArray(systemSettings.key, keys))
|
||||
: Promise.resolve([]),
|
||||
@ -715,12 +663,12 @@ async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false)
|
||||
}
|
||||
|
||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||
return resolveSiteBranding({
|
||||
return {
|
||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||
siteLogo: String(settings?.siteLogo || ""),
|
||||
siteLogoDark: String(settings?.siteLogoDark || ""),
|
||||
favicon: String(settings?.favicon || ""),
|
||||
});
|
||||
siteLogo: settings?.siteLogo || null,
|
||||
siteLogoDark: settings?.siteLogoDark || null,
|
||||
favicon: settings?.favicon || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getScopedPublicSiteSettings(
|
||||
@ -728,7 +676,6 @@ async function getScopedPublicSiteSettings(
|
||||
options: {
|
||||
brandName?: string;
|
||||
cacheKey?: string;
|
||||
etag?: string;
|
||||
} = {}
|
||||
) {
|
||||
if (scope === "auth") {
|
||||
@ -790,17 +737,23 @@ async function getScopedPublicSiteSettings(
|
||||
});
|
||||
}
|
||||
|
||||
if (scope === "agent-workflow-templates") {
|
||||
const [setting] = await db.select({ value: systemSettings.value })
|
||||
.from(systemSettings).where(eq(systemSettings.key, AGENT_WORKFLOW_UI_CONFIG_KEY)).limit(1);
|
||||
const config = normalizeAgentWorkflowUiConfig(setting?.value);
|
||||
return createScopedSiteSettingsResponse(scope, {
|
||||
steps: omitAgentWorkflowPrompts(config.steps),
|
||||
}, "MISS", options.cacheKey || scope, { etag: options.etag });
|
||||
}
|
||||
|
||||
if (scope === "agent-canvas" || scope === "agent-canvas-base" || scope === "agent-entry") {
|
||||
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS, scope !== "agent-canvas");
|
||||
if (scope === "agent-canvas") {
|
||||
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(
|
||||
valueByKey
|
||||
);
|
||||
@ -808,8 +761,6 @@ async function getScopedPublicSiteSettings(
|
||||
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
|
||||
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
|
||||
);
|
||||
const rawUiConfig = valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY) as
|
||||
Partial<AgentWorkflowUiConfig> | undefined;
|
||||
return createScopedSiteSettingsResponse(scope, {
|
||||
...createBrandingSettings(typedSettings),
|
||||
agentEnabled: typedSettings?.agentEnabled ?? true,
|
||||
@ -845,20 +796,12 @@ async function getScopedPublicSiteSettings(
|
||||
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
||||
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
||||
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
||||
scope === "agent-entry"
|
||||
? {
|
||||
steps: [],
|
||||
pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
|
||||
businessModeSettings: rawUiConfig?.businessModeSettings,
|
||||
}
|
||||
: scope === "agent-canvas-base"
|
||||
? { ...rawUiConfig, steps: [] }
|
||||
: rawUiConfig
|
||||
valueByKey.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
|
||||
),
|
||||
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
|
||||
valueByKey.get(AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY)
|
||||
),
|
||||
}, "MISS", options.cacheKey || scope, { etag: options.etag });
|
||||
});
|
||||
}
|
||||
|
||||
if (scope === "branding") {
|
||||
@ -1112,24 +1055,13 @@ export async function GET(request: NextRequest) {
|
||||
const cacheKey = brandName.trim()
|
||||
? `${scopedScope}:${brandName.trim().toLowerCase()}`
|
||||
: scopedScope;
|
||||
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry" ||
|
||||
scopedScope === "agent-canvas-base" || scopedScope === "agent-workflow-templates"
|
||||
? 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);
|
||||
if (cached && cached.expiresAt > Date.now() && (!etag || cached.etag === etag)) {
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return createScopedSiteSettingsResponse(
|
||||
scopedScope,
|
||||
cached.data,
|
||||
"HIT",
|
||||
cacheKey,
|
||||
{ etag }
|
||||
cacheKey
|
||||
);
|
||||
}
|
||||
if (cached && scopedScope !== "home") {
|
||||
@ -1140,7 +1072,6 @@ export async function GET(request: NextRequest) {
|
||||
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
|
||||
brandName,
|
||||
cacheKey,
|
||||
etag,
|
||||
});
|
||||
return scopedScope === "home"
|
||||
? await withTimeout(responsePromise, 1600, "site-settings:home")
|
||||
@ -1610,7 +1541,7 @@ export async function GET(request: NextRequest) {
|
||||
};
|
||||
|
||||
const response = NextResponse.json({
|
||||
...resolveSiteBranding(baseSettings),
|
||||
...baseSettings,
|
||||
lovartPromoText:
|
||||
baseSettings.lovartPromoText ||
|
||||
"现在升级,立享 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 });
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@ import { eq } from "drizzle-orm";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import {
|
||||
createMediaAssetRelation,
|
||||
getDefaultStorageConfig,
|
||||
getPointsSystemConfig,
|
||||
getMediaAssetsByUserAndUrls,
|
||||
upsertMediaAsset,
|
||||
@ -22,27 +21,6 @@ export const maxDuration = 600;
|
||||
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
|
||||
const WATERMARK_POLL_INTERVAL_MS = 1200;
|
||||
|
||||
async function resolveStoredQiniuImageUrl(source: string): Promise<string | null> {
|
||||
const config = await getDefaultStorageConfig().catch(() => null);
|
||||
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
|
||||
|
||||
try {
|
||||
const base = new URL(config.publicUrl);
|
||||
const url = new URL(source);
|
||||
const prefix = base.pathname.replace(/\/$/, "") + "/";
|
||||
if (base.protocol !== "https:" || url.origin !== base.origin ||
|
||||
url.username || url.password || url.search || url.hash ||
|
||||
!url.pathname.startsWith(prefix)) return null;
|
||||
|
||||
const objectKey = url.pathname.slice(prefix.length);
|
||||
return /^uploads\/(?:direct\/[0-9a-f-]{36}\/)?[\w.-]+$/i.test(objectKey)
|
||||
? url.toString()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取去水印API配置
|
||||
async function getWatermarkRemoveConfig() {
|
||||
try {
|
||||
@ -153,16 +131,13 @@ async function persistWatermarkResult(params: {
|
||||
processing_time?: number;
|
||||
}> {
|
||||
try {
|
||||
let resultUrl = await resolveStoredQiniuImageUrl(params.processedImageUrl);
|
||||
if (!resultUrl) {
|
||||
const imageResponse = await fetch(params.processedImageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error("下载处理后的图片失败");
|
||||
}
|
||||
const imageBuffer = await imageResponse.arrayBuffer();
|
||||
const filename = `watermark-removed-${Date.now()}.png`;
|
||||
resultUrl = (await uploadFile(filename, imageBuffer)).url;
|
||||
const imageResponse = await fetch(params.processedImageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error("下载处理后的图片失败");
|
||||
}
|
||||
const imageBuffer = await imageResponse.arrayBuffer();
|
||||
const filename = `watermark-removed-${Date.now()}.png`;
|
||||
const uploadResult = await uploadFile(filename, imageBuffer);
|
||||
|
||||
let saved = false;
|
||||
let saveError: unknown = null;
|
||||
@ -175,8 +150,8 @@ async function persistWatermarkResult(params: {
|
||||
try {
|
||||
([savedImage] = await db.insert(generatedImage).values({
|
||||
userId: params.userId,
|
||||
thumbnailUrl: resultUrl,
|
||||
originalUrl: resultUrl,
|
||||
thumbnailUrl: uploadResult.url,
|
||||
originalUrl: uploadResult.url,
|
||||
imageSize: "2K",
|
||||
prompt: "去水印处理",
|
||||
modelId: "watermark-remove",
|
||||
@ -209,9 +184,9 @@ async function persistWatermarkResult(params: {
|
||||
mediaType: "image",
|
||||
sourceType: "edited",
|
||||
status: "ready",
|
||||
originalUrl: resultUrl,
|
||||
previewUrl: resultUrl,
|
||||
thumbnailUrl: resultUrl,
|
||||
originalUrl: uploadResult.url,
|
||||
previewUrl: uploadResult.url,
|
||||
thumbnailUrl: uploadResult.url,
|
||||
displayName: "去水印处理",
|
||||
prompt: "去水印处理",
|
||||
modelId: "watermark-remove",
|
||||
@ -242,8 +217,8 @@ async function persistWatermarkResult(params: {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
original_url: resultUrl,
|
||||
thumbnail_url: resultUrl,
|
||||
original_url: uploadResult.url,
|
||||
thumbnail_url: uploadResult.url,
|
||||
processing_time:
|
||||
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
|
||||
};
|
||||
@ -387,7 +362,7 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const { image_url } = body;
|
||||
|
||||
if (typeof image_url !== "string" || !image_url) {
|
||||
if (!image_url) {
|
||||
return NextResponse.json(
|
||||
{ error: "请提供图片URL" },
|
||||
{ status: 400 }
|
||||
@ -438,24 +413,24 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
// 七牛上的公开图片可以直接交给去水印服务读取。
|
||||
// 如果是 HTTP/HTTPS URL,下载后重新上传确保公网可访问
|
||||
else if (image_url.startsWith("http://") || image_url.startsWith("https://")) {
|
||||
const storedImageUrl = await resolveStoredQiniuImageUrl(image_url);
|
||||
publicImageUrl = storedImageUrl || image_url;
|
||||
if (!storedImageUrl) {
|
||||
try {
|
||||
console.log("[WatermarkRemove] 下载原图并上传到S3...");
|
||||
const imageResponse = await fetch(image_url);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error("下载原图失败");
|
||||
}
|
||||
const imageBuffer = await imageResponse.arrayBuffer();
|
||||
const filename = `watermark-input-${Date.now()}.jpg`;
|
||||
publicImageUrl = (await uploadFile(filename, imageBuffer)).url;
|
||||
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
|
||||
} catch (downloadError) {
|
||||
console.error("[WatermarkRemove] 下载/上传原图失败,使用原始URL:", downloadError);
|
||||
try {
|
||||
console.log("[WatermarkRemove] 下载原图并上传到S3...");
|
||||
const imageResponse = await fetch(image_url);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error("下载原图失败");
|
||||
}
|
||||
const imageBuffer = await imageResponse.arrayBuffer();
|
||||
const filename = `watermark-input-${Date.now()}.jpg`;
|
||||
const uploadResult = await uploadFile(filename, imageBuffer);
|
||||
publicImageUrl = uploadResult.url;
|
||||
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
|
||||
} catch (downloadError) {
|
||||
console.error("[WatermarkRemove] 下载/上传原图失败:", downloadError);
|
||||
// 如果下载失败,尝试使用原始URL
|
||||
console.log("[WatermarkRemove] 尝试使用原始URL");
|
||||
publicImageUrl = image_url;
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -48,13 +48,8 @@ export default function CanvasStyleProPage() {
|
||||
const canvasData = result.data.canvasData;
|
||||
const latestState = useCanvasStore.getState();
|
||||
setProject(result.data.id, result.data.title);
|
||||
if (
|
||||
latestState.projectId !== id ||
|
||||
(latestState.nodes.length === 0 && latestState.edges.length === 0)
|
||||
) {
|
||||
if (latestState.nodes.length === 0 && latestState.edges.length === 0) {
|
||||
loadCanvas(canvasData);
|
||||
const revision = String(result.data.updatedAt || "");
|
||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
298
app/globals.css
298
app/globals.css
@ -519,301 +519,3 @@
|
||||
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,8 +3,6 @@ import { Toaster } from "sonner";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { FaviconUpdater } from "@/components/favicon-updater";
|
||||
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
||||
import { AppUpdateNotice } from "@/components/app-update-notice";
|
||||
import { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh";
|
||||
import { getSiteSettings } from "@/lib/db/queries";
|
||||
|
||||
import "./globals.css";
|
||||
@ -96,33 +94,18 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
||||
}
|
||||
}
|
||||
|
||||
// Keep one attempt per route for the browser session. A cooldown alone can
|
||||
// turn a recurring editor exception into an endless reload cycle.
|
||||
var RECOVERY_KEY = 'chunk-reload-attempted-v3:' + window.location.pathname;
|
||||
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
|
||||
var recoveryNoticeShown = false;
|
||||
|
||||
function isFlowBoardEditing() {
|
||||
var path = window.location.pathname;
|
||||
return path === '/flow-board' || path.indexOf('/flow-board/') === 0;
|
||||
}
|
||||
|
||||
function showManualRecoveryNotice() {
|
||||
if (recoveryNoticeShown) return;
|
||||
recoveryNoticeShown = true;
|
||||
function show() {
|
||||
if (!document.body || !isFlowBoardEditing()) return;
|
||||
var notice = document.createElement('div');
|
||||
notice.setAttribute('role', 'alert');
|
||||
notice.setAttribute('data-runtime-recovery-notice', 'true');
|
||||
notice.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:calc(100vw - 32px);padding:12px 16px;border:1px solid var(--border,#e4e4e7);border-radius:12px;background:var(--background,#fff);color:var(--foreground,#27272a);box-shadow:0 4px 20px #0002;font:14px/1.5 system-ui;';
|
||||
notice.textContent = '部分页面资源加载失败,请先保存白板后手动刷新页面。';
|
||||
document.body.appendChild(notice);
|
||||
function safeRemoveSessionStorage(key) {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
if (!message) return false;
|
||||
var normalized = String(message).toLowerCase();
|
||||
@ -134,30 +117,20 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
||||
normalized.indexOf('error loading dynamically imported module') !== -1 ||
|
||||
normalized.indexOf('importing a module script 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) {
|
||||
if (isFlowBoardEditing()) {
|
||||
showManualRecoveryNotice();
|
||||
return;
|
||||
}
|
||||
if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return;
|
||||
var now = Date.now();
|
||||
// If storage is blocked, a reload cannot remember its attempt and must not run.
|
||||
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
|
||||
if (lastReloadAt && now - lastReloadAt < 30000) return;
|
||||
lastReloadAt = now;
|
||||
console.warn('[RuntimeRecover] 检测到页面资源加载失败,尝试恢复一次...', reason || '');
|
||||
console.warn('[RuntimeRecover] 检测到前端运行时异常,自动刷新页面...', reason || '');
|
||||
safeSetSessionStorage(RECOVERY_KEY, String(now));
|
||||
setTimeout(function() {
|
||||
if (isFlowBoardEditing()) {
|
||||
showManualRecoveryNotice();
|
||||
return;
|
||||
}
|
||||
if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) {
|
||||
if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0;
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}, 800);
|
||||
}
|
||||
@ -165,6 +138,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
||||
window.addEventListener('error', function(event) {
|
||||
var message = '';
|
||||
if (event && event.message) message += event.message;
|
||||
if (event && event.filename) message += ' ' + event.filename;
|
||||
if (event && event.error && event.error.message) {
|
||||
message += ' ' + event.error.message;
|
||||
}
|
||||
@ -183,6 +157,12 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
||||
}
|
||||
});
|
||||
|
||||
// 页面稳定一段时间后清除标记,避免旧资源异常无限刷新。
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() {
|
||||
safeRemoveSessionStorage(RECOVERY_KEY);
|
||||
}, 120000);
|
||||
});
|
||||
})();`;
|
||||
|
||||
export default function RootLayout({
|
||||
@ -221,7 +201,6 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
>
|
||||
<Toaster position="top-center" />
|
||||
<AppUpdateNotice />
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</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
|
||||
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
|
||||
printf '%s\n' 'Then follow docs/production-48.md for build, stage, activate and rollback.' >&2
|
||||
exit 2
|
||||
#!/bin/bash
|
||||
|
||||
# 自动部署脚本 - 包含密码
|
||||
# 使用方法: ./auto-deploy.sh
|
||||
|
||||
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";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@ -576,11 +575,31 @@ export function AgentGenerationWorkspace({
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
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";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
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 { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
||||
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import {
|
||||
Fragment,
|
||||
@ -159,7 +155,6 @@ import type {
|
||||
} from "@/types/agent";
|
||||
import {
|
||||
createEmptyAgentModeWorkspaceState,
|
||||
MAX_DETAIL_REPLACEMENT_REFERENCES,
|
||||
} from "@/lib/agents/mode-workspace-state";
|
||||
import {
|
||||
createEmptyAgentGenerationProjectWorkspaceState,
|
||||
@ -177,7 +172,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
||||
workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null;
|
||||
workflowPromptOptimizationEnabled?: boolean;
|
||||
getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null;
|
||||
getWorkflowPromptStatus?: () => "ready" | "loading" | "error";
|
||||
onBack?: () => void;
|
||||
brandStorePanel?: ReactNode;
|
||||
materialSelectionPanel?: ReactNode;
|
||||
@ -214,7 +208,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
||||
interface AgentWorkflowModelOption {
|
||||
id: string;
|
||||
displayName: string;
|
||||
providerName?: string;
|
||||
providerType: string;
|
||||
supportsImageGen?: boolean;
|
||||
isMultimodal?: boolean;
|
||||
@ -2571,23 +2564,10 @@ interface LocalGenerationPendingState {
|
||||
expectedCount: number;
|
||||
userTurnCountAtStart: number;
|
||||
requestMessageId?: string | null;
|
||||
backendTaskId?: string | null;
|
||||
timestamp: number;
|
||||
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_EXPIRY_MS = 30 * 60 * 1000;
|
||||
const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000;
|
||||
@ -2639,15 +2619,12 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
|
||||
expectedCount: Number(record.expectedCount),
|
||||
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
||||
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
|
||||
backendTaskId: typeof record.backendTaskId === "string" ? record.backendTaskId : null,
|
||||
timestamp: Number(record.timestamp),
|
||||
requestedCardIds: Array.isArray(record.requestedCardIds)
|
||||
? record.requestedCardIds
|
||||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, record.modeType === "detail_replacement"
|
||||
? MAX_DETAIL_REPLACEMENT_REFERENCES
|
||||
: MAX_AGENT_MODE_REFERENCE_COUNT)
|
||||
.slice(0, MAX_AGENT_MODE_REFERENCE_COUNT)
|
||||
: null,
|
||||
} satisfies LocalGenerationPendingState;
|
||||
});
|
||||
@ -2674,6 +2651,16 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
|
||||
}
|
||||
}
|
||||
|
||||
const DETAIL_RESULT_PROCESSING_STAGES = [
|
||||
"解码图像",
|
||||
"定位选区",
|
||||
"分离主体",
|
||||
"重绘背景",
|
||||
"融合边缘",
|
||||
"检查细节",
|
||||
"输出结果",
|
||||
];
|
||||
|
||||
function DetailResultProcessingScanOverlay({
|
||||
imageUrl,
|
||||
label,
|
||||
@ -2681,6 +2668,29 @@ function DetailResultProcessingScanOverlay({
|
||||
imageUrl?: 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 (
|
||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
||||
<style>{`
|
||||
@ -2794,6 +2804,26 @@ function DetailResultProcessingScanOverlay({
|
||||
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
@ -3090,7 +3120,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
workflowFeatureSettings = null,
|
||||
workflowPromptOptimizationEnabled = true,
|
||||
getLatestWorkflowContext,
|
||||
getWorkflowPromptStatus,
|
||||
onBack,
|
||||
brandStorePanel,
|
||||
materialSelectionPanel,
|
||||
@ -3137,8 +3166,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
|
||||
const detailGenerationCancelRef = useRef(false);
|
||||
const workspaceStateSignatureRef = useRef<string>("");
|
||||
const detailBatchSubmittingRef = useRef(false);
|
||||
const reportedDetailBatchFailureRef = useRef<string | null>(null);
|
||||
const generationRequestSnapshotRef = useRef<{
|
||||
hiddenPrompt: string | null;
|
||||
replacementExecutionCardIds: string[] | null;
|
||||
@ -3388,13 +3415,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}
|
||||
);
|
||||
|
||||
const { data: modelsData, error: modelsError, mutate: refreshModels } = useSWR<{
|
||||
const { data: modelsData } = useSWR<{
|
||||
models: AgentWorkflowModelOption[];
|
||||
defaultModelId?: string | null;
|
||||
agentWorkflowConfiguredDefaultModelId?: string | null;
|
||||
agentWorkflowDefaultModelId?: string | null;
|
||||
agentWorkflowConfiguredModels?: AgentWorkflowModelOption[];
|
||||
detailReplacementModel?: AgentWorkflowModelOption | null;
|
||||
agentModel?: any;
|
||||
}>("/api/models", fetcher);
|
||||
|
||||
@ -3440,6 +3466,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const activeBusinessModeDefaultModelId = !requiresPresetSelection
|
||||
? String(activeBusinessMode?.defaultModelId || "").trim() || null
|
||||
: null;
|
||||
const lockedWorkflowModelId =
|
||||
activeBusinessModeDefaultModelId ||
|
||||
selectedAgentPreset?.defaultModelId ||
|
||||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
|
||||
modelsData?.agentWorkflowDefaultModelId ||
|
||||
null;
|
||||
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
|
||||
const effectiveDefaultModelId =
|
||||
lockedWorkflowModelId ||
|
||||
modelsData?.defaultModelId ||
|
||||
null;
|
||||
const presetDrivenResultModeType: AgentGenerationResultModeType =
|
||||
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"
|
||||
? detailReplacementMethod === "ai_detail_generate"
|
||||
@ -3455,19 +3492,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection
|
||||
? presetDrivenResultModeType
|
||||
: businessModeResultModeType;
|
||||
const detailReplacementModel = currentResultModeType === "detail_replacement"
|
||||
? modelsData?.detailReplacementModel
|
||||
: null;
|
||||
const isDetailReplacementModelLoading = currentResultModeType === "detail_replacement" && !modelsData;
|
||||
const lockedWorkflowModelId =
|
||||
detailReplacementModel?.id ||
|
||||
activeBusinessModeDefaultModelId ||
|
||||
selectedAgentPreset?.defaultModelId ||
|
||||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
|
||||
modelsData?.agentWorkflowDefaultModelId ||
|
||||
null;
|
||||
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
|
||||
const effectiveDefaultModelId = lockedWorkflowModelId || modelsData?.defaultModelId || null;
|
||||
const isSceneGenerationPreset = currentResultModeType === "scene_generation";
|
||||
const isDetailReplacementPreset =
|
||||
currentResultModeType === "detail_replacement" ||
|
||||
@ -3476,75 +3500,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
||||
const isReplacementPreset =
|
||||
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 =
|
||||
currentResultModeType === "detail_replacement" &&
|
||||
detailReplacementStrategy === "product_only";
|
||||
@ -4119,15 +4074,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
);
|
||||
const executableModelOptions = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const workflowModels = detailReplacementModel
|
||||
? [detailReplacementModel, ...configuredWorkflowModels]
|
||||
: configuredWorkflowModels;
|
||||
return [...selectableModels, ...workflowModels].filter((model) => {
|
||||
return [...selectableModels, ...configuredWorkflowModels].filter((model) => {
|
||||
if (seen.has(model.id)) return false;
|
||||
seen.add(model.id);
|
||||
return true;
|
||||
});
|
||||
}, [configuredWorkflowModels, detailReplacementModel, selectableModels]);
|
||||
}, [configuredWorkflowModels, selectableModels]);
|
||||
const executableModelIdSet = useMemo(
|
||||
() => new Set(executableModelOptions.map((model) => model.id)),
|
||||
[executableModelOptions]
|
||||
@ -4146,9 +4098,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
selectableModels[0],
|
||||
[executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId]
|
||||
);
|
||||
const lockedWorkflowModelLabel = detailReplacementModel?.providerName
|
||||
? `${detailReplacementModel.displayName} 「${detailReplacementModel.providerName}」`
|
||||
: lockedWorkflowModel?.displayName ||
|
||||
const lockedWorkflowModelLabel =
|
||||
lockedWorkflowModel?.displayName ||
|
||||
selectedAgentPreset?.defaultModel?.displayName ||
|
||||
(lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置");
|
||||
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings");
|
||||
@ -4185,7 +4136,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
? "agent-anchor-materials"
|
||||
: "agent-anchor-generate";
|
||||
|
||||
const [finishedSceneRequestId, setFinishedSceneRequestId] = useState<string | null>(null);
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
@ -4206,11 +4156,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
void refreshHistory();
|
||||
}
|
||||
},
|
||||
onFinish({ messages: finishedMessages }) {
|
||||
const lastUser = finishedMessages.filter((message) => message.role === "user").at(-1);
|
||||
setFinishedSceneRequestId(lastUser?.id || null);
|
||||
void refreshHistory();
|
||||
},
|
||||
onFinish() { void refreshHistory(); },
|
||||
experimental_throttle: 100,
|
||||
generateId: generateUUID,
|
||||
transport: new DefaultChatTransport({
|
||||
@ -4893,14 +4839,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
return recoverReplacementRun({ messages, ...pendingGeneration });
|
||||
}, [messages, pendingGeneration]);
|
||||
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(() => {
|
||||
if (!pendingGeneration) {
|
||||
return 0;
|
||||
@ -5058,17 +4996,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
Array.isArray(pendingGeneration.requestedCardIds)
|
||||
? pendingGeneration.requestedCardIds.length
|
||||
: null,
|
||||
status: detailBatchTask?.requestedCardIds.includes(card.id)
|
||||
? resultMeta?.url
|
||||
? resultMeta.status
|
||||
: detailBatchTask.status === "running"
|
||||
? detailBatchTask.currentCardId === card.id ? "generating" : "queued"
|
||||
: detailBatchTask.status === "pending"
|
||||
? "queued"
|
||||
: detailBatchTask.status === "failed"
|
||||
? "missing"
|
||||
: "waiting"
|
||||
: getReplacementCardStatus({
|
||||
status: getReplacementCardStatus({
|
||||
index,
|
||||
resultUrl,
|
||||
resultText,
|
||||
@ -5082,7 +5010,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}),
|
||||
[
|
||||
detailReplacementReferenceCards,
|
||||
detailBatchTask,
|
||||
isDetailResultModeGenerating,
|
||||
replacementRunRecovery.completedCardIds,
|
||||
latestReplacementResults,
|
||||
@ -5237,7 +5164,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const detailRemainingCount = useMemo(
|
||||
() =>
|
||||
detailResultCards.filter(
|
||||
(card) => !["completed", "passthrough"].includes(card.status)
|
||||
(card) => !["completed", "passthrough", "fallback"].includes(card.status)
|
||||
).length,
|
||||
[detailResultCards]
|
||||
);
|
||||
@ -5512,18 +5439,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [currentResultModeType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration?.requestMessageId || pendingGeneration.modeType !== "scene_generation" || transportIsGenerating) return;
|
||||
const result = getSceneRunResult(messages, pendingGeneration.requestMessageId);
|
||||
const persisted = getSceneRunResult(historyMessages, pendingGeneration.requestMessageId);
|
||||
if (!result || (finishedSceneRequestId !== pendingGeneration.requestMessageId && !persisted?.count)) return;
|
||||
setPendingGeneration(null);
|
||||
if (result.count < pendingGeneration.expectedCount) {
|
||||
toast.warning(`本次生成已结束,收到 ${result.count}/${pendingGeneration.expectedCount} 张图片。缺少的图片未继续生成,可调整张数后重新提交。`);
|
||||
}
|
||||
}, [pendingGeneration, transportIsGenerating, finishedSceneRequestId, messages, historyMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !replacementRunRecovery.done) return;
|
||||
if (!pendingGeneration || !replacementRunRecovery.done) return;
|
||||
// The backend has settled every requested card; an open SSE connection is no longer work.
|
||||
stop();
|
||||
transportGenerationStartedAtRef.current = null;
|
||||
@ -5531,7 +5447,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [pendingGeneration, replacementRunRecovery.done, stop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
||||
if (!pendingGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -5541,7 +5457,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || pendingGeneration.backendTaskId || !chatError) {
|
||||
if (!pendingGeneration || !chatError) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -5556,7 +5472,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [chatError, clearError, pendingGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || pendingGeneration.backendTaskId) {
|
||||
if (!pendingGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -6282,6 +6198,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const processedStatuses = new Set<ReplacementCardStatus>([
|
||||
"completed",
|
||||
"passthrough",
|
||||
"fallback",
|
||||
]);
|
||||
const remainingIds = resultCards
|
||||
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
|
||||
@ -6327,24 +6244,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
|
||||
return;
|
||||
}
|
||||
if (isDetailReplacementModelLoading) {
|
||||
if (modelsError) {
|
||||
toast.error("详情替换模型加载失败,正在重新读取");
|
||||
void refreshModels().catch(() => {});
|
||||
} else {
|
||||
toast.info("正在读取详情替换模型,请稍候");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
|
||||
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
|
||||
return;
|
||||
}
|
||||
const promptStatus = getWorkflowPromptStatus?.();
|
||||
if (promptStatus === "loading" || promptStatus === "error") {
|
||||
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
|
||||
return;
|
||||
}
|
||||
const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext;
|
||||
if (
|
||||
isDetailReplacementPreset &&
|
||||
@ -6401,92 +6304,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
|
||||
return;
|
||||
}
|
||||
if (replacementModeType === "detail_replacement") {
|
||||
if (detailBatchSubmittingRef.current) return;
|
||||
if (replacementExecutionCards.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
|
||||
toast.error(`本次选中了 ${replacementExecutionCards.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`);
|
||||
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 fetch("/api/agents/detail-replacement/batches", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
requestId,
|
||||
model: resolvedModelId,
|
||||
expectedModelId: resolvedModelId === "auto" ? undefined : 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) {
|
||||
if (payload?.code === "DETAIL_REPLACEMENT_MODEL_CHANGED") void refreshModels().catch(() => {});
|
||||
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 sanitizedAttachments = sceneMessageAttachments;
|
||||
if (
|
||||
@ -6636,8 +6453,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
detailResultCards,
|
||||
detailReplacementReferenceCards.length,
|
||||
detailReplacementStrategy,
|
||||
detailReplacementProductSourceImages,
|
||||
detailReplacementModelSourceImage,
|
||||
handleRunAiDetailGeneration,
|
||||
currentResultModeType,
|
||||
getReplacementExecutionCards,
|
||||
@ -6646,8 +6461,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
input,
|
||||
isDetailAiGenerationMethod,
|
||||
isDetailReplacementPreset,
|
||||
isDetailReplacementModelLoading,
|
||||
modelsError,
|
||||
isSelectedBusinessModeUnavailable,
|
||||
isSkuReplacementPreset,
|
||||
effectiveDefaultModelId,
|
||||
@ -6664,18 +6477,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
skuResultCards,
|
||||
userMessageCount,
|
||||
getLatestWorkflowContext,
|
||||
getWorkflowPromptStatus,
|
||||
isSceneGenerationPreset,
|
||||
workflowContext,
|
||||
sceneMessageAttachments,
|
||||
scenePrimaryReferenceCount,
|
||||
sceneSecondaryReferenceCount,
|
||||
selectedModelAttachment,
|
||||
selectedProductAttachments,
|
||||
onPersistProject,
|
||||
projectId,
|
||||
refreshDetailBatch,
|
||||
refreshModels,
|
||||
]);
|
||||
const handleStopGeneration = useCallback(() => {
|
||||
detailGenerationCancelRef.current = true;
|
||||
@ -6840,11 +6647,31 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
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) => {
|
||||
@ -7839,11 +7666,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数");
|
||||
return;
|
||||
}
|
||||
const promptStatus = getWorkflowPromptStatus?.();
|
||||
if (promptStatus === "loading" || promptStatus === "error") {
|
||||
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
|
||||
return;
|
||||
}
|
||||
|
||||
const productAttachments = detailGenerationProductSlotIndexes.map((index) =>
|
||||
detailGenerationSlots[index]
|
||||
@ -9448,11 +9270,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</Select>
|
||||
) : (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
|
||||
{isDetailReplacementModelLoading
|
||||
? modelsError ? "详情替换模型加载失败,点击批量替换可重试" : "正在读取详情替换模型"
|
||||
: isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
|
||||
{isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
|
||||
<span className="ml-2 font-medium text-white">
|
||||
{isDetailReplacementModelLoading ? null : isWorkflowModelLocked
|
||||
{isWorkflowModelLocked
|
||||
? lockedWorkflowModelLabel
|
||||
: currentModel?.displayName || "自动选择"}
|
||||
</span>
|
||||
@ -9519,7 +9339,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isAnyGenerationRunning && !pendingGeneration?.backendTaskId ? (
|
||||
{isAnyGenerationRunning ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@ -726,11 +725,30 @@ export function AgentGenerationWorkspaceNew({
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
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("已打开原图,可右键另存为");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Check, ImageIcon, Loader2, UserRound } from "lucide-react";
|
||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
|
||||
@ -230,11 +229,12 @@ export function AgentWorkflowBrandModelPicker({
|
||||
isDark ? "bg-white/[0.04]" : "bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<AgentPreviewImage
|
||||
<img
|
||||
src={image.imageUrl}
|
||||
alt={image.displayName || `${normalizedBrand} 模特图`}
|
||||
className="h-full w-full object-cover"
|
||||
previewWidth={360}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
|
||||
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
|
||||
|
||||
@ -112,8 +112,6 @@ const PREVIEW_DIMENSION_HANDLE_RADIUS = 3.2;
|
||||
const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2;
|
||||
const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2;
|
||||
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 NUMBER_REGEX = /\d+(?:\.\d+)?/g;
|
||||
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
||||
@ -570,54 +568,87 @@ function drawAnnotationToCanvas(params: {
|
||||
context: CanvasRenderingContext2D;
|
||||
annotation: DimensionAnnotation;
|
||||
imageSize: ImageSize;
|
||||
previewRect: PreviewRect;
|
||||
annotations: DimensionAnnotation[];
|
||||
autoDimensionValues?: AutoDimensionValues | null;
|
||||
}) {
|
||||
const { context, annotation } = params;
|
||||
const { context, annotation, imageSize, annotations, autoDimensionValues } =
|
||||
params;
|
||||
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 textOutlineWidth = annotation.glow
|
||||
? Math.max(1.5, fontSize * 0.18)
|
||||
: Math.max(0.85, fontSize * 0.08);
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = annotationColor;
|
||||
context.lineWidth = PREVIEW_DIMENSION_STROKE_WIDTH;
|
||||
context.lineWidth = strokeWidth;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.shadowColor = annotation.glow
|
||||
? colorWithAlpha(annotationColor, 0.42)
|
||||
? colorWithAlpha(annotationColor, 0.52)
|
||||
: "transparent";
|
||||
context.shadowBlur = annotation.glow ? 4 : 0;
|
||||
context.shadowBlur = annotation.glow ? Math.max(8, fontSize * 0.7) : 0;
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(geometry.start.x, geometry.start.y);
|
||||
context.lineTo(geometry.end.x, geometry.end.y);
|
||||
context.moveTo(annotation.x1, annotation.y1);
|
||||
context.lineTo(annotation.x2, annotation.y2);
|
||||
context.stroke();
|
||||
|
||||
context.lineWidth = PREVIEW_DIMENSION_CAP_STROKE_WIDTH;
|
||||
drawDimensionCap({
|
||||
context,
|
||||
point: geometry.start,
|
||||
size: PREVIEW_DIMENSION_TICK_LENGTH,
|
||||
vector: geometry.vector,
|
||||
point: { x: annotation.x1, y: annotation.y1 },
|
||||
size: capLength,
|
||||
vector,
|
||||
});
|
||||
drawDimensionCap({
|
||||
context,
|
||||
point: geometry.end,
|
||||
size: PREVIEW_DIMENSION_TICK_LENGTH,
|
||||
vector: geometry.vector,
|
||||
point: { x: annotation.x2, y: annotation.y2 },
|
||||
size: capLength,
|
||||
vector,
|
||||
});
|
||||
|
||||
context.translate(geometry.labelCenterX, geometry.labelCenterY);
|
||||
context.rotate((geometry.angleDegrees * Math.PI) / 180);
|
||||
context.font = `500 ${PREVIEW_DIMENSION_FONT_SIZE}px ${DIMENSION_FONT_FAMILY}`;
|
||||
context.translate(centerX, centerY);
|
||||
context.rotate(angleRadians);
|
||||
context.font = `500 ${fontSize}px "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif`;
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.lineWidth = getAnnotationTextOutlineWidth(annotation);
|
||||
context.lineWidth = textOutlineWidth;
|
||||
context.strokeStyle = textOutlineColor;
|
||||
context.strokeText(geometry.label, 0, 0.5);
|
||||
context.strokeText(label, 0, 0);
|
||||
context.fillStyle = annotationColor;
|
||||
context.fillText(geometry.label, 0, 0.5);
|
||||
context.fillText(label, 0, 0);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@ -1036,10 +1067,6 @@ export function AgentPsdTemplateExportPreview({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageSize || !previewRect) {
|
||||
throw new Error("请等待预览图片加载完成后再下载");
|
||||
}
|
||||
|
||||
const { image, dispose } = await loadImageFromCandidates([
|
||||
buildImageProxyUrl(imageUrl, { delivery: "proxy" }),
|
||||
imageUrl,
|
||||
@ -1055,25 +1082,18 @@ export function AgentPsdTemplateExportPreview({
|
||||
}
|
||||
|
||||
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) {
|
||||
drawAnnotationToCanvas({
|
||||
annotation,
|
||||
annotations,
|
||||
autoDimensionValues,
|
||||
context,
|
||||
imageSize,
|
||||
previewRect,
|
||||
imageSize: {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
context.restore();
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
@ -1105,8 +1125,6 @@ export function AgentPsdTemplateExportPreview({
|
||||
downloadName,
|
||||
enableDimensionAnnotations,
|
||||
imageUrl,
|
||||
imageSize,
|
||||
previewRect,
|
||||
]);
|
||||
|
||||
return (
|
||||
@ -1287,7 +1305,6 @@ export function AgentPsdTemplateExportPreview({
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill={annotationColor}
|
||||
fontFamily={DIMENSION_FONT_FAMILY}
|
||||
fontSize={PREVIEW_DIMENSION_FONT_SIZE}
|
||||
fontWeight={500}
|
||||
paintOrder="stroke"
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
|
||||
import { isPageDownloadActive, preparePageRefresh, subscribeToPageDownloadActivity } from "@/lib/client/page-refresh";
|
||||
|
||||
export function AppUpdateNotice() {
|
||||
const [availableVersion, setAvailableVersion] = useState("");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false);
|
||||
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) && !downloading}>
|
||||
<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";
|
||||
|
||||
import { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -21,8 +21,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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 {
|
||||
AgentWorkflowMaterialImage,
|
||||
AgentWorkflowMaterialQueryType,
|
||||
@ -165,8 +163,6 @@ export function CanvasAddImageDialog({
|
||||
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
|
||||
const [isSearching, setIsSearching] = 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 images = useMemo(() => normalizeImages(result), [result]);
|
||||
@ -186,13 +182,12 @@ export function CanvasAddImageDialog({
|
||||
|
||||
const handleDialogOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen && (isUploading || isAdding)) return;
|
||||
if (!nextOpen) {
|
||||
resetDialogState();
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
},
|
||||
[isAdding, isUploading, onOpenChange, resetDialogState]
|
||||
[onOpenChange, resetDialogState]
|
||||
);
|
||||
|
||||
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 (
|
||||
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
|
||||
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
||||
@ -322,38 +272,15 @@ export function CanvasAddImageDialog({
|
||||
添加图片
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。
|
||||
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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="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
|
||||
className="h-9 shrink-0 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}
|
||||
className="h-9 justify-start gap-2"
|
||||
onClick={() => {
|
||||
onCreateBlankImage();
|
||||
closeDialog();
|
||||
@ -428,13 +355,13 @@ export function CanvasAddImageDialog({
|
||||
</div>
|
||||
{!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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
@ -513,7 +440,6 @@ export function CanvasAddImageDialog({
|
||||
|
||||
<DialogFooter className="border-t px-5 py-3">
|
||||
<Button
|
||||
disabled={isUploading || isAdding}
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
variant="outline"
|
||||
@ -522,7 +448,7 @@ export function CanvasAddImageDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!selectedImages.length || isAdding || isUploading}
|
||||
disabled={!selectedImages.length || isAdding}
|
||||
onClick={() => void handleAddSelected()}
|
||||
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 {
|
||||
currentId: string;
|
||||
onClose: () => void;
|
||||
onNavigate?: (url: string) => void;
|
||||
workspaceType?: CanvasWorkspaceType;
|
||||
workspaceLabel?: string;
|
||||
createUrl?: string;
|
||||
@ -29,7 +28,6 @@ interface CanvasFileListPanelProps {
|
||||
export function CanvasFileListPanel({
|
||||
currentId,
|
||||
onClose,
|
||||
onNavigate,
|
||||
workspaceType = "canvas",
|
||||
workspaceLabel = "画布",
|
||||
createUrl = "/canvas?new=1",
|
||||
@ -90,10 +88,6 @@ export function CanvasFileListPanel({
|
||||
|
||||
// 创建新画布
|
||||
const handleCreateNew = async () => {
|
||||
if (onNavigate) {
|
||||
onNavigate(createUrl);
|
||||
return;
|
||||
}
|
||||
router.push(createUrl);
|
||||
};
|
||||
|
||||
@ -106,10 +100,6 @@ export function CanvasFileListPanel({
|
||||
}
|
||||
const targetUrl = buildProjectUrl(id);
|
||||
onClose(); // 关闭文件列表面板
|
||||
if (onNavigate) {
|
||||
onNavigate(targetUrl);
|
||||
return;
|
||||
}
|
||||
// 强制整页跳转,避免仅更新 URL 不刷新
|
||||
setTimeout(() => {
|
||||
window.location.assign(targetUrl);
|
||||
|
||||
@ -28,7 +28,6 @@ export function CanvasImageDownloadProgressDialog({
|
||||
: null;
|
||||
const loadedLabel = formatBytes(progress.loadedBytes);
|
||||
const totalLabel = formatBytes(progress.totalBytes);
|
||||
const converting = progress.phase === "converting";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@ -42,12 +41,12 @@ export function CanvasImageDownloadProgressDialog({
|
||||
{progress.phase === "saving" ? (
|
||||
<Download className="h-4 w-4" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 motion-safe:animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold">下载原图</div>
|
||||
<div className="mt-0.5 text-[12px] text-white/58" role="status">{progress.message}</div>
|
||||
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{onCancel ? (
|
||||
@ -64,19 +63,14 @@ export function CanvasImageDownloadProgressDialog({
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
|
||||
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"}</span>
|
||||
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
|
||||
<span>{percent !== null ? `${percent}%` : "..."}</span>
|
||||
</div>
|
||||
<div
|
||||
className="relative h-2 overflow-hidden rounded-full bg-white/10"
|
||||
role="progressbar"
|
||||
aria-label={converting ? "图片格式转换" : "图片下载"}
|
||||
aria-valuenow={percent ?? undefined}
|
||||
>
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
|
||||
percent === null && "w-1/3 motion-safe:animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
|
||||
percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
|
||||
)}
|
||||
style={percent !== null ? { width: `${percent}%` } : undefined}
|
||||
/>
|
||||
|
||||
@ -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";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
ReferenceIcon,
|
||||
type ReferenceIconName,
|
||||
} from "@/components/lovart/reference-icon";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -16,12 +12,13 @@ type CanvasToolbarIconProps = {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
active?: boolean;
|
||||
referenceName?: ReferenceIconName;
|
||||
referenceSize?: number;
|
||||
};
|
||||
|
||||
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) {
|
||||
@ -45,8 +42,6 @@ export function CanvasToolbarIcon({
|
||||
className,
|
||||
style,
|
||||
active = false,
|
||||
referenceName,
|
||||
referenceSize = 18,
|
||||
}: CanvasToolbarIconProps) {
|
||||
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
|
||||
|
||||
@ -80,9 +75,7 @@ export function CanvasToolbarIcon({
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"object-contain",
|
||||
active
|
||||
? "brightness-0 invert opacity-95"
|
||||
: "brightness-0 opacity-80 dark:invert",
|
||||
active ? "brightness-0 invert opacity-95" : "brightness-0 opacity-80 dark:invert",
|
||||
className
|
||||
)}
|
||||
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} />;
|
||||
}
|
||||
|
||||
@ -12,13 +12,8 @@ import { UiScreenNode } from "./nodes/UiScreenNode";
|
||||
import { VideoNode } from "./nodes/VideoNode";
|
||||
import { WorkflowNode } from "./nodes/WorkflowNode";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { toast } from "sonner";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import {
|
||||
@ -33,7 +28,6 @@ import {
|
||||
LayoutTemplate,
|
||||
LayoutGrid,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Minus,
|
||||
Pin,
|
||||
Plus,
|
||||
@ -396,11 +390,6 @@ export function CanvasWorkspace({
|
||||
} | null;
|
||||
const containerRef = 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 [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
||||
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
||||
@ -738,6 +727,10 @@ export function CanvasWorkspace({
|
||||
canvasToolbarConfig,
|
||||
"imageLayoutReplace"
|
||||
);
|
||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||
const selectedImageLocalEditEnabled = true;
|
||||
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
|
||||
const selectedImageLayoutReplaceEnabled = true;
|
||||
|
||||
// Store actions
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
@ -1113,61 +1106,42 @@ export function CanvasWorkspace({
|
||||
);
|
||||
|
||||
const fitAllImageNodesIntoViewport = useCallback(() => {
|
||||
const fit = getCanvasFitViewport(
|
||||
nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)),
|
||||
containerSize, occlusionRight
|
||||
const imageNodes = nodes.filter(
|
||||
(node) => node.type === "image" && Boolean((node.data as any)?.src)
|
||||
);
|
||||
if (fit) setViewport(fit);
|
||||
}, [containerSize, nodes, occlusionRight, setViewport]);
|
||||
if (imageNodes.length === 0) return;
|
||||
if (containerSize.width <= 0 || containerSize.height <= 0) return;
|
||||
|
||||
const fitAllCanvasNodesIntoViewport = useCallback(() => {
|
||||
const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight);
|
||||
if (fit) setViewport(fit);
|
||||
}, [containerSize, nodes, occlusionRight, setViewport]);
|
||||
const padding = 80;
|
||||
const minX = Math.min(...imageNodes.map((node) => node.position.x));
|
||||
const minY = Math.min(...imageNodes.map((node) => node.position.y));
|
||||
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) => {
|
||||
if (isWorkflowCanvas || hasBlockingDialogOpen() || canvasImageSelectionSession) return;
|
||||
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 contentWidth = Math.max(1, maxX - minX + padding * 2);
|
||||
const contentHeight = Math.max(1, maxY - minY + padding * 2);
|
||||
|
||||
const zoomCanvasFromContextMenu = useCallback((factor: number) => {
|
||||
const current = useCanvasStore.getState().viewport;
|
||||
const zoom = Math.max(0.1, Math.min(4, current.zoom * factor));
|
||||
const centerX = Math.max(1, containerSize.width - occlusionRight) / 2;
|
||||
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]);
|
||||
const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight));
|
||||
const fitZoomX = visibleWidth / contentWidth;
|
||||
const fitZoomY = containerSize.height / contentHeight;
|
||||
const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY)));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isWorkflowCanvas) return;
|
||||
const handleFitShortcut = (event: KeyboardEvent) => {
|
||||
if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return;
|
||||
if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest(
|
||||
'input, textarea, [contenteditable="true"], [role="textbox"]'
|
||||
))) return;
|
||||
const active = document.activeElement;
|
||||
if (active && active !== document.body && !containerRef.current?.contains(active)
|
||||
&& !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return;
|
||||
event.preventDefault();
|
||||
fitAllCanvasNodesIntoViewport();
|
||||
setBlankContextPosition(null);
|
||||
};
|
||||
window.addEventListener("keydown", handleFitShortcut);
|
||||
return () => window.removeEventListener("keydown", handleFitShortcut);
|
||||
}, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]);
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
const visibleCenterX = visibleWidth / 2;
|
||||
const nextViewportX = visibleCenterX - centerX * targetZoom;
|
||||
const nextViewportY = containerSize.height / 2 - centerY * targetZoom;
|
||||
|
||||
setViewport({
|
||||
x: nextViewportX,
|
||||
y: nextViewportY,
|
||||
zoom: targetZoom,
|
||||
});
|
||||
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
|
||||
|
||||
const getNodeInputAnchor = useCallback(
|
||||
(node: CanvasNode): Position => ({
|
||||
@ -1284,7 +1258,6 @@ export function CanvasWorkspace({
|
||||
if (canvasImageSelectionSession) return;
|
||||
if (hasBlockingDialogOpen()) return;
|
||||
setAgentSlotContextMenu(null);
|
||||
setBlankContextPosition(null);
|
||||
if (!isEventFromCanvasContainer(e.target)) return;
|
||||
if (suppressCanvasClickRef.current) {
|
||||
suppressCanvasClickRef.current = false;
|
||||
@ -1321,7 +1294,7 @@ export function CanvasWorkspace({
|
||||
!isPointOnNodeElement(e.target);
|
||||
|
||||
if (
|
||||
(tool === 'pan' && e.button === 0) ||
|
||||
tool === 'pan' ||
|
||||
e.button === 1 ||
|
||||
(e.button === 0 && e.altKey) ||
|
||||
shouldPanBlankWorkflowCanvas
|
||||
@ -1684,105 +1657,6 @@ export function CanvasWorkspace({
|
||||
}
|
||||
}, [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(() => {
|
||||
const handleWindowDrop = async (e: DragEvent) => {
|
||||
if (!isFileDrag(e.dataTransfer)) return;
|
||||
@ -1845,44 +1719,24 @@ export function CanvasWorkspace({
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]'));
|
||||
if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return;
|
||||
if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLElement &&
|
||||
activeElement !== document.body &&
|
||||
!container.contains(activeElement) && !fromContextMenu
|
||||
!container.contains(activeElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = extractImageFilesFromDataTransfer(e.clipboardData);
|
||||
const text = e.clipboardData?.getData("text/plain") || "";
|
||||
if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return;
|
||||
if (files.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
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(
|
||||
files,
|
||||
rect.left + rect.width / 2,
|
||||
@ -1892,7 +1746,7 @@ export function CanvasWorkspace({
|
||||
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]);
|
||||
}, [hasBlockingDialogOpen, processDroppedFiles]);
|
||||
|
||||
const handleDownloadSelectedImages = useCallback(async () => {
|
||||
if (selectedImageNodes.length === 0) {
|
||||
@ -1915,10 +1769,10 @@ export function CanvasWorkspace({
|
||||
const rawImageUrl = String(nodeData?.src || "").trim();
|
||||
if (!rawImageUrl) return null;
|
||||
let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl;
|
||||
if (browserOrigin && rawImageUrl.startsWith("//")) {
|
||||
imageUrl = `${window.location.protocol}${rawImageUrl}`;
|
||||
} else if (browserOrigin && rawImageUrl.startsWith("/")) {
|
||||
if (browserOrigin && rawImageUrl.startsWith("/")) {
|
||||
imageUrl = `${browserOrigin}${rawImageUrl}`;
|
||||
} else if (browserOrigin && rawImageUrl.startsWith("//")) {
|
||||
imageUrl = `${window.location.protocol}${rawImageUrl}`;
|
||||
}
|
||||
const name =
|
||||
String(nodeData?.title || "").trim() ||
|
||||
@ -1934,18 +1788,32 @@ export function CanvasWorkspace({
|
||||
}
|
||||
|
||||
setIsDownloadSubmitting(true);
|
||||
const toastId = toast.loading("正在准备所选图片...");
|
||||
try {
|
||||
const result = await downloadCanvasSelectionZip({ folderName, files,
|
||||
selectionKey: `${useCanvasStore.getState().projectId}:${selectedImageNodes.map(node => node.id).sort().join(",")}`,
|
||||
onProgress: task => toast.loading(`${task.stage}(${task.progress}%)`, { id: toastId }),
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
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 });
|
||||
else toast.success(`ZIP 已准备完成,正在下载 ${result.successCount} 张图片`, { id: toastId });
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "打包下载失败,请稍后重试", { id: toastId });
|
||||
form.appendChild(payloadInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
|
||||
toast.success("已开始打包下载,请留意浏览器下载栏");
|
||||
} catch (_error) {
|
||||
toast.error("下载失败,请稍后重试");
|
||||
} finally {
|
||||
setIsDownloadSubmitting(false);
|
||||
window.setTimeout(() => {
|
||||
setIsDownloadSubmitting(false);
|
||||
}, 800);
|
||||
}
|
||||
}, [isDownloadSubmitting, selectedImageNodes]);
|
||||
|
||||
@ -2401,47 +2269,12 @@ export function CanvasWorkspace({
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onDoubleClick={handleBlankCanvasDoubleClick}
|
||||
onContextMenu={handleBlankCanvasContextMenu}
|
||||
onWheel={handleWheel}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
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 ? (
|
||||
<>
|
||||
<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 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-4 z-30 flex items-center gap-2",
|
||||
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
|
||||
)}
|
||||
className="absolute bottom-16 left-4 z-30 flex items-center gap-2"
|
||||
data-canvas-overlay
|
||||
data-canvas-selection-actions="true"
|
||||
>
|
||||
{isWorkflowCanvas && selectedImageNodes.length === 1 ? (
|
||||
{selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
@ -2762,7 +2591,7 @@ export function CanvasWorkspace({
|
||||
{selectedImageLocalEditItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{isWorkflowCanvas &&
|
||||
{selectedImageLayoutReplaceEnabled &&
|
||||
selectedImageNodes.length >= 1 ? (
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
@ -2791,48 +2620,27 @@ export function CanvasWorkspace({
|
||||
{selectedImageLayoutReplaceItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size={isWorkflowCanvas ? "sm" : "icon"}
|
||||
className={cn(
|
||||
isWorkflowCanvas
|
||||
? "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}
|
||||
aria-label={
|
||||
isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
||||
}
|
||||
>
|
||||
{!isWorkflowCanvas && isDownloadSubmitting ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<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>
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
|
||||
isWorkflowCanvas &&
|
||||
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||
)}
|
||||
onClick={handleDownloadSelectedImages}
|
||||
disabled={isDownloadSubmitting}
|
||||
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-3.5 w-3.5"
|
||||
fallback={Download}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
||||
/>
|
||||
{isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
||||
</Button>
|
||||
</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,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 = {
|
||||
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) {
|
||||
const label = String(match[1] || "");
|
||||
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\b[^>]*\/?\s*>/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(
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,4 @@
|
||||
"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 {
|
||||
Box,
|
||||
@ -45,7 +42,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-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 { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
@ -59,10 +56,8 @@ import { CanvasLayoutReplaceBatchDialog } from "@/components/canvas/CanvasLayout
|
||||
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
|
||||
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
|
||||
import {
|
||||
areImageAdjustmentsEqual,
|
||||
buildImageAdjustmentFilterStyle,
|
||||
@ -178,7 +173,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import {
|
||||
downloadOriginalImage,
|
||||
downloadCanvasImageAsPng,
|
||||
getCanvasImageDownloadErrorMessage,
|
||||
type CanvasImageDownloadProgress,
|
||||
} from "@/lib/client/canvas-image-download";
|
||||
@ -189,7 +184,7 @@ import {
|
||||
normalizeDetailUiSubjectAnalysis,
|
||||
} from "@/lib/detail-ui-subject-analysis";
|
||||
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import {
|
||||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||||
getDefaultThreeDModelAliasId,
|
||||
@ -235,8 +230,6 @@ import {
|
||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||
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 type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
@ -1314,8 +1307,6 @@ export function ImageNode({
|
||||
}: ImageNodeProps) {
|
||||
const router = useRouter();
|
||||
const routeParams = useParams();
|
||||
const pathname = usePathname();
|
||||
const useReferenceImageToolbar = /^\/canvas\/[^/]+\/?$/.test(pathname);
|
||||
const canvasId = Array.isArray(routeParams?.id)
|
||||
? routeParams.id[0] || ""
|
||||
: typeof routeParams?.id === "string"
|
||||
@ -1541,11 +1532,9 @@ export function ImageNode({
|
||||
.map((action) => action.key)
|
||||
);
|
||||
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
|
||||
useReferenceImageToolbar
|
||||
? enabledSelectedImageToolbarActions.some((action) => action.key === key)
|
||||
: selectedImageInlineActionKeys.has(key);
|
||||
selectedImageInlineActionKeys.has(key);
|
||||
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
|
||||
!useReferenceImageToolbar && selectedImageMoreActionKeys.has(key);
|
||||
selectedImageMoreActionKeys.has(key);
|
||||
const hasHoverImageMorePrimaryActions =
|
||||
imageRemoveBackgroundEnabled ||
|
||||
imageRemoveWatermarkEnabled ||
|
||||
@ -1571,7 +1560,6 @@ export function ImageNode({
|
||||
const hasSelectedImageMorePrimaryActions =
|
||||
selectedImageMoreActionKeys.size > 0;
|
||||
const hasSelectedImageMoreMenu =
|
||||
useReferenceImageToolbar ||
|
||||
hasSelectedImageMorePrimaryActions ||
|
||||
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
|
||||
(imageNodeMoreEnabled && imageDeleteEnabled);
|
||||
@ -1714,7 +1702,6 @@ export function ImageNode({
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] =
|
||||
useState<CanvasImageDownloadProgress | null>(null);
|
||||
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
||||
@ -2084,7 +2071,7 @@ export function ImageNode({
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
const projectId = useCanvasStore((state) => state.projectId);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
||||
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
||||
const addPendingChatAttachment = useCanvasStore(
|
||||
(state) => state.addPendingChatAttachment
|
||||
);
|
||||
@ -2482,11 +2469,14 @@ export function ImageNode({
|
||||
[compareButtonVisualSize]
|
||||
);
|
||||
const shouldUseHighFidelityDisplayImage =
|
||||
isSelected ||
|
||||
isHovered ||
|
||||
isCompareMode ||
|
||||
boxCutoutMode ||
|
||||
localEditMode ||
|
||||
cropPanelOpen ||
|
||||
imageAdjustPanelOpen ||
|
||||
fullscreenPreviewOpen ||
|
||||
isThisNodePicking;
|
||||
const displayImageWidth = useMemo(() => {
|
||||
const devicePixelRatio =
|
||||
@ -2501,13 +2491,13 @@ export function ImageNode({
|
||||
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
|
||||
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
|
||||
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
|
||||
return canvasImagePreviewWidth(Math.max(
|
||||
return Math.max(
|
||||
minWidth,
|
||||
Math.min(
|
||||
maxWidth,
|
||||
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
|
||||
)
|
||||
), maxWidth);
|
||||
);
|
||||
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
||||
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
||||
const displayImageSrc = useMemo(
|
||||
@ -2519,6 +2509,15 @@ export function ImageNode({
|
||||
}),
|
||||
[data.src, displayImageQuality, displayImageWidth]
|
||||
);
|
||||
const fullscreenPreviewSrc = useMemo(() => {
|
||||
const originalUrl = resolveOriginalImageUrl(data.src || "");
|
||||
return buildImageProxyUrl(originalUrl || data.src || "", {
|
||||
delivery: "proxy",
|
||||
width: 4096,
|
||||
quality: 92,
|
||||
format: "webp",
|
||||
});
|
||||
}, [data.src]);
|
||||
const displayCompareSrc = useMemo(
|
||||
() =>
|
||||
buildImageProxyUrl(compareSource, {
|
||||
@ -4673,44 +4672,106 @@ export function ImageNode({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId || !runCanvasRemoteOperation) {
|
||||
toast.error("画布还在加载,请稍后再试");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingDetachedResultNode(false);
|
||||
setIsCreatingDetachedResultNode(true);
|
||||
setIsProcessing(true);
|
||||
const actionName = "框选抠图";
|
||||
|
||||
try {
|
||||
const command = createCanvasCapabilityClientCommand({
|
||||
capabilityId: "image.box_cutout",
|
||||
nodeIds: [node.id],
|
||||
projectId,
|
||||
source: "toolbar",
|
||||
args: {
|
||||
...boxCutoutSelection,
|
||||
const newNodeId = addNode({
|
||||
type: "image",
|
||||
position: {
|
||||
x: node.position.x + node.size.width + 20,
|
||||
y: node.position.y,
|
||||
},
|
||||
size: { width: node.size.width, height: node.size.height },
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: `${actionName}处理中...`,
|
||||
title: `${displayTitle}-${actionName}`,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
cropRect: boxCutoutSelection,
|
||||
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) {
|
||||
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 {
|
||||
setIsCreatingDetachedResultNode(false);
|
||||
setIsProcessing(false);
|
||||
resetBoxCutoutState();
|
||||
}
|
||||
}, [
|
||||
addNode,
|
||||
boxCutoutSelection,
|
||||
data.src,
|
||||
displayTitle,
|
||||
isProcessing,
|
||||
node.id,
|
||||
projectId,
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.height,
|
||||
node.size.width,
|
||||
resetBoxCutoutState,
|
||||
runCanvasRemoteOperation,
|
||||
updateNode,
|
||||
]);
|
||||
|
||||
const animateAddToChat = useCallback(async (previewUrl?: string) => {
|
||||
@ -6640,7 +6701,7 @@ export function ImageNode({
|
||||
const executeSharedToolbarCapability = async (
|
||||
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
||||
) => {
|
||||
if (!projectId || !runCanvasRemoteOperation) {
|
||||
if (!projectId) {
|
||||
toast.error("画布还在加载,请稍后再试");
|
||||
return;
|
||||
}
|
||||
@ -6659,7 +6720,21 @@ export function ImageNode({
|
||||
projectId,
|
||||
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}完成`);
|
||||
} catch (error) {
|
||||
const message =
|
||||
@ -8223,6 +8298,7 @@ export function ImageNode({
|
||||
}
|
||||
};
|
||||
|
||||
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
|
||||
const isDownloadingOriginalImage = Boolean(downloadProgress);
|
||||
const handleDownload = async () => {
|
||||
if (!data.src) return;
|
||||
@ -8236,16 +8312,24 @@ export function ImageNode({
|
||||
setDownloadProgress({
|
||||
phase: "preparing",
|
||||
message: "正在准备原图...",
|
||||
percent: 5,
|
||||
});
|
||||
|
||||
try {
|
||||
await downloadOriginalImage({
|
||||
url: resolveOriginalImageUrl(data.src) || data.src,
|
||||
await downloadCanvasImageAsPng({
|
||||
url: data.src,
|
||||
name: displayTitle,
|
||||
candidates: [
|
||||
displayImageSrc,
|
||||
fullscreenPreviewSrc,
|
||||
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
|
||||
delivery: "proxy",
|
||||
}),
|
||||
],
|
||||
signal: controller.signal,
|
||||
onProgress: setDownloadProgress,
|
||||
});
|
||||
toast.success("已开始下载");
|
||||
toast.success("原图下载成功");
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
toast.message("已取消下载");
|
||||
@ -8349,11 +8433,7 @@ export function ImageNode({
|
||||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||||
{data.src ? (
|
||||
<div className="relative h-full w-full">
|
||||
{isRetiredStorageUrl(data.src) ? (
|
||||
<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 ? (
|
||||
{!shouldRequestDisplayImage ? (
|
||||
<ImageNodeDeferredLoadState />
|
||||
) : isCompareMode && hasCompareSource ? (
|
||||
<div
|
||||
@ -8380,16 +8460,15 @@ export function ImageNode({
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
||||
<CanvasPreviewImage
|
||||
sourceKey={data.src}
|
||||
<img
|
||||
alt={displayTitle}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="eager"
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||
if (event.currentTarget.src !== data.src) {
|
||||
event.currentTarget.src = data.src;
|
||||
}
|
||||
}}
|
||||
onLoad={handleDisplayImageLoad}
|
||||
@ -8410,16 +8489,15 @@ export function ImageNode({
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
||||
<CanvasPreviewImage
|
||||
sourceKey={compareSource}
|
||||
<img
|
||||
alt={`${displayTitle}-原图`}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="eager"
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
||||
if (event.currentTarget.src !== compareSource) {
|
||||
event.currentTarget.src = compareSource;
|
||||
}
|
||||
}}
|
||||
src={displayCompareSrc}
|
||||
@ -8461,8 +8539,7 @@ export function ImageNode({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CanvasPreviewImage
|
||||
sourceKey={data.src}
|
||||
<img
|
||||
alt={displayTitle}
|
||||
className={cn(
|
||||
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
|
||||
@ -8474,7 +8551,7 @@ export function ImageNode({
|
||||
)}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="eager"
|
||||
loading="lazy"
|
||||
onClick={
|
||||
isThisNodePicking && !boxCutoutMode
|
||||
? handlePickerClick
|
||||
@ -8483,17 +8560,12 @@ export function ImageNode({
|
||||
onDoubleClick={(e) => {
|
||||
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
|
||||
e.stopPropagation();
|
||||
setFullscreenPreviewImage({
|
||||
src: e.currentTarget.currentSrc || e.currentTarget.src,
|
||||
width: e.currentTarget.naturalWidth,
|
||||
height: e.currentTarget.naturalHeight,
|
||||
});
|
||||
setFullscreenPreviewOpen(true);
|
||||
}
|
||||
}}
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||
if (event.currentTarget.src !== data.src) {
|
||||
event.currentTarget.src = data.src;
|
||||
}
|
||||
}}
|
||||
onLoad={handleDisplayImageLoad}
|
||||
@ -9404,8 +9476,6 @@ export function ImageNode({
|
||||
) : (
|
||||
<ImageNodeEmptyState
|
||||
detailProgressLabel={detailProgressLabel}
|
||||
failure={data.metadata?.generationFailure}
|
||||
requestId={data.metadata?.generationRequestId}
|
||||
fileInputRef={fileInputRef}
|
||||
generationProgress={generationProgress}
|
||||
onImageUpload={handleImageUpload}
|
||||
@ -9570,8 +9640,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageLayoutReplaceItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
@ -10124,12 +10192,8 @@ export function ImageNode({
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<ReferenceImageToolbar
|
||||
referencePresentation={useReferenceImageToolbar}
|
||||
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}
|
||||
<div
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
@ -10153,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"
|
||||
disabled={isProcessing || isAddingToChat}
|
||||
onClick={handleAddToChat}
|
||||
data-tool-key="addToChat"
|
||||
title={imageAddToChatItem.label}
|
||||
title={imageAddToChatItem.label}
|
||||
variant="ghost"
|
||||
>
|
||||
{isAddingToChat ? (
|
||||
@ -10165,8 +10228,6 @@ export function ImageNode({
|
||||
className="h-4 w-4 dark:invert-0"
|
||||
fallback={MessageSquarePlus}
|
||||
item={imageAddToChatItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
)}
|
||||
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
|
||||
@ -10181,7 +10242,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("pickObject") ? (
|
||||
<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"
|
||||
disabled={isProcessing || isPickerLoading || isThisNodePicking}
|
||||
onClick={handleStartPicking}
|
||||
@ -10192,8 +10252,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Crosshair}
|
||||
item={imagePickObjectItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-move-object" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imagePickObjectItem.label}
|
||||
</Button>
|
||||
@ -10201,7 +10259,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("enhance") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("enhance")}
|
||||
@ -10212,8 +10269,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
item={imageEnhanceItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-upscale" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageEnhanceItem.label}
|
||||
<ImageActionCreditBadge compact label={enhanceCostShortLabel} />
|
||||
@ -10222,7 +10277,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("removeBackground") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("rmbg")}
|
||||
@ -10233,8 +10287,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Scissors}
|
||||
item={imageRemoveBackgroundItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-remove-background" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageRemoveBackgroundItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10246,7 +10298,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("aiWashQuick") ? (
|
||||
<Button
|
||||
data-tool-key="aiWashQuick"
|
||||
className={cn(
|
||||
"group relative h-10 rounded-xl px-3 font-medium text-[13px] transition",
|
||||
aiWashQuickPanelOpen
|
||||
@ -10256,7 +10307,6 @@ export function ImageNode({
|
||||
disabled={isProcessing || isSubmittingAiWashQuick}
|
||||
onClick={() => handleAction("ai-wash")}
|
||||
title={aiWashQuickModeLabel}
|
||||
data-reference-active={aiWashQuickPanelOpen ? "true" : undefined}
|
||||
variant="ghost"
|
||||
>
|
||||
{isSubmittingAiWashQuick ? (
|
||||
@ -10267,8 +10317,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
)}
|
||||
{aiWashQuickModeLabel}
|
||||
@ -10281,7 +10329,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("textEdit") ? (
|
||||
<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"
|
||||
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
||||
onClick={() => handleAction("text-edit")}
|
||||
@ -10292,8 +10339,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Type}
|
||||
item={imageTextEditItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-edit-text" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageTextEditItem.label}
|
||||
</Button>
|
||||
@ -10301,7 +10346,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("boxCutout") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("box-cutout")}
|
||||
@ -10312,8 +10356,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Scan}
|
||||
item={imageBoxCutoutItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageBoxCutoutItem.label}
|
||||
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
|
||||
@ -10322,7 +10364,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("localEdit") ? (
|
||||
<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"
|
||||
disabled={isProcessing || isSubmittingLocalEdit}
|
||||
onClick={() => handleAction("local-edit")}
|
||||
@ -10333,8 +10374,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Wand2}
|
||||
item={imageLocalEditItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageLocalEditItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10346,7 +10385,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("layoutReplace") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("layout-replace")}
|
||||
@ -10357,8 +10395,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageLayoutReplaceItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10370,7 +10406,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("removeWatermark") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("watermark")}
|
||||
@ -10381,8 +10416,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Eraser}
|
||||
item={imageRemoveWatermarkItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageRemoveWatermarkItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10394,7 +10427,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("resize") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("resize")}
|
||||
@ -10412,7 +10444,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("multiAngle") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("angle")}
|
||||
@ -10423,8 +10454,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Box}
|
||||
item={imageMultiAngleItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageMultiAngleItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10436,7 +10465,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("generate3dModel") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("generate-3d-model")}
|
||||
@ -10458,7 +10486,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("generate3dWorld") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("generate-3d-world")}
|
||||
@ -10480,7 +10507,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("layerSplit") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("layer-split")}
|
||||
@ -10491,8 +10517,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Layers}
|
||||
item={imageLayerSplitItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageLayerSplitItem.label}
|
||||
</Button>
|
||||
@ -10500,7 +10524,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("extend") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("extend")}
|
||||
@ -10511,8 +10534,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Expand}
|
||||
item={imageExtendItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageExtendItem.label}
|
||||
<ImageActionCreditBadge compact label={extendCostShortLabel} />
|
||||
@ -10521,7 +10542,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("crop") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("crop")}
|
||||
@ -10532,8 +10552,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Crop}
|
||||
item={imageCropItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageCropItem.label}
|
||||
</Button>
|
||||
@ -10541,7 +10559,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("adjust") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("adjust")}
|
||||
@ -10552,8 +10569,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={SlidersHorizontal}
|
||||
item={imageAdjustItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageAdjustItem.label}
|
||||
</Button>
|
||||
@ -10561,7 +10576,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("flipRotate") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("flip-rotate")}
|
||||
@ -10572,8 +10586,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={FlipHorizontal2}
|
||||
item={imageFlipRotateItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageFlipRotateItem.label}
|
||||
</Button>
|
||||
@ -10581,7 +10593,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("promptRegenerate") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("prompt-regenerate")}
|
||||
@ -10603,7 +10614,6 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("note") ? (
|
||||
<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"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("note")}
|
||||
@ -10614,8 +10624,6 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={StickyNote}
|
||||
item={imageNoteItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageNoteItem.label}
|
||||
</Button>
|
||||
@ -10640,14 +10648,12 @@ export function ImageNode({
|
||||
className="h-4 w-4"
|
||||
fallback={MoreHorizontal}
|
||||
item={imageNodeMoreItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-more" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
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"
|
||||
>
|
||||
{isSelectedImageActionInMore("boxCutout") ? (
|
||||
@ -10656,8 +10662,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Scan}
|
||||
item={imageBoxCutoutItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageBoxCutoutItem.label}</span>
|
||||
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
|
||||
@ -10669,8 +10673,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Wand2}
|
||||
item={imageLocalEditItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageLocalEditItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
@ -10684,8 +10686,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageLayoutReplaceItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
@ -10697,8 +10697,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Eraser}
|
||||
item={imageRemoveWatermarkItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageRemoveWatermarkItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
@ -10722,8 +10720,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Box}
|
||||
item={imageMultiAngleItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageMultiAngleItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
@ -10765,8 +10761,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Layers}
|
||||
item={imageLayerSplitItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageLayerSplitItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10777,8 +10771,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Expand}
|
||||
item={imageExtendItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageExtendItem.label}</span>
|
||||
<ImageActionCreditBadge label={extendCostShortLabel} />
|
||||
@ -10790,8 +10782,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Crop}
|
||||
item={imageCropItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageCropItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10802,8 +10792,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={SlidersHorizontal}
|
||||
item={imageAdjustItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageAdjustItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10814,8 +10802,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={FlipHorizontal2}
|
||||
item={imageFlipRotateItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageFlipRotateItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10841,8 +10827,6 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={StickyNote}
|
||||
item={imageNoteItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageNoteItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10882,8 +10866,7 @@ export function ImageNode({
|
||||
void handleDownload();
|
||||
}}
|
||||
size="icon"
|
||||
data-tool-key="download"
|
||||
title={imageDownloadItem.label}
|
||||
title={imageDownloadItem.label}
|
||||
variant="ghost"
|
||||
>
|
||||
{isDownloadingOriginalImage ? (
|
||||
@ -10893,13 +10876,11 @@ export function ImageNode({
|
||||
className="h-4 w-4"
|
||||
fallback={Download}
|
||||
item={imageDownloadItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-download" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</ReferenceImageToolbar>
|
||||
</div>
|
||||
</TooltipProvider>,
|
||||
document.body
|
||||
)}
|
||||
@ -11895,7 +11876,6 @@ export function ImageNode({
|
||||
{/* 双击放大预览弹窗 */}
|
||||
{fullscreenPreviewOpen &&
|
||||
data.src &&
|
||||
fullscreenPreviewImage &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
|
||||
@ -11903,7 +11883,7 @@ export function ImageNode({
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<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)}
|
||||
>
|
||||
<svg
|
||||
@ -11921,7 +11901,7 @@ export function ImageNode({
|
||||
</svg>
|
||||
</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}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
@ -11940,11 +11920,13 @@ export function ImageNode({
|
||||
</button>
|
||||
|
||||
{/* 图片 */}
|
||||
<ProgressiveImagePreview
|
||||
<img
|
||||
alt={data.prompt || "图片预览"}
|
||||
className="max-h-[95vh] max-w-[95vw] object-contain"
|
||||
src={resolveOriginalImageUrl(data.src) || data.src}
|
||||
preview={fullscreenPreviewImage}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
src={fullscreenPreviewSrc}
|
||||
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 type React from "react";
|
||||
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
|
||||
import type { RequestFailureDetails } from "@/lib/request-error";
|
||||
|
||||
interface ImageNodeEmptyStateProps {
|
||||
detailProgressLabel?: string;
|
||||
fileInputRef: React.Ref<HTMLInputElement>;
|
||||
generationProgress?: number | null;
|
||||
failure?: RequestFailureDetails;
|
||||
requestId?: string;
|
||||
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
status?: string;
|
||||
@ -55,8 +52,6 @@ export function ImageNodeEmptyState({
|
||||
detailProgressLabel,
|
||||
fileInputRef,
|
||||
generationProgress,
|
||||
failure,
|
||||
requestId,
|
||||
onImageUpload,
|
||||
onSelectFile,
|
||||
status,
|
||||
@ -94,7 +89,7 @@ export function ImageNodeEmptyState({
|
||||
if (status === "failed") {
|
||||
const failedTitle = detailProgressLabel || "生成失败";
|
||||
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)]">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</span>
|
||||
@ -102,9 +97,8 @@ export function ImageNodeEmptyState({
|
||||
{failedTitle}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-[#b77480]">
|
||||
{failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")}
|
||||
{failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
|
||||
</span>
|
||||
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -15,10 +15,7 @@ import {
|
||||
splitOpenClawChartBlocks,
|
||||
} from "@/components/elements/openclaw-chart-card";
|
||||
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
|
||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
||||
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@ -29,7 +26,6 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown> & {
|
||||
collapsibleImageAnalysis?: boolean;
|
||||
isMarkdownStreaming?: boolean;
|
||||
enablePdfDownload?: boolean;
|
||||
pdfFileName?: string;
|
||||
pdfSiteLogo?: string | null;
|
||||
@ -366,6 +362,43 @@ function normalizeMarkdownContent(value: string) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function splitMarkdownImages(content: string) {
|
||||
const segments: Array<
|
||||
| { type: "text"; value: string }
|
||||
| { type: "image"; alt?: string; src: string }
|
||||
> = [];
|
||||
|
||||
const imageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = imageRegex.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
value: content.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
segments.push({
|
||||
type: "image",
|
||||
alt: match[1],
|
||||
src: match[2],
|
||||
});
|
||||
|
||||
lastIndex = imageRegex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
value: content.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function isLikelyImageUrl(value?: string | null) {
|
||||
return IMAGE_URL_REGEX.test(String(value || "").trim());
|
||||
}
|
||||
@ -442,8 +475,8 @@ function MarkdownTableImagePreview({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(src);
|
||||
if (event.currentTarget.src !== src) {
|
||||
event.currentTarget.src = src;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -473,8 +506,8 @@ function MarkdownTableImagePreview({
|
||||
decoding="async"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(originalSrc);
|
||||
if (event.currentTarget.src !== originalSrc) {
|
||||
event.currentTarget.src = originalSrc;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -764,7 +797,6 @@ function ResponseReportTableDownloadButton({
|
||||
export const Response = ({
|
||||
className,
|
||||
collapsibleImageAnalysis = false,
|
||||
isMarkdownStreaming = false,
|
||||
enablePdfDownload = false,
|
||||
pdfFileName,
|
||||
pdfSiteLogo,
|
||||
@ -1029,10 +1061,7 @@ export const Response = ({
|
||||
|
||||
const rawText =
|
||||
typeof props.children === "string"
|
||||
? normalizeMarkdownContent(stableStreamingMarkdown(
|
||||
getGatewayErrorPageMessage(props.children) || props.children,
|
||||
isMarkdownStreaming
|
||||
))
|
||||
? normalizeMarkdownContent(props.children)
|
||||
: null;
|
||||
const shouldShowPdfDownload =
|
||||
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
||||
@ -1062,7 +1091,7 @@ export const Response = ({
|
||||
);
|
||||
};
|
||||
|
||||
if (rawText !== null) {
|
||||
if (rawText) {
|
||||
const analysisSection = collapsibleImageAnalysis
|
||||
? extractImageAnalysisSection(rawText)
|
||||
: null;
|
||||
|
||||
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