Compare commits
65 Commits
main
...
codex/prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4e3561510 | ||
|
|
92e0f49fbc | ||
|
|
a913f78f8b | ||
|
|
d65ebc253b | ||
|
|
4a6afb753d | ||
|
|
835b60f61c | ||
|
|
8a075045f0 | ||
|
|
f3f1fdafbd | ||
|
|
712d4350a0 | ||
|
|
e2f8a253a7 | ||
|
|
7a837c2180 | ||
|
|
94d6a9008c | ||
|
|
1b281a76dc | ||
|
|
2489ce54f2 | ||
|
|
86c196ce51 | ||
|
|
a0a144f506 | ||
|
|
cef15871ac | ||
|
|
9ae24394fe | ||
|
|
6ca7feb1a0 | ||
|
|
265b667628 | ||
|
|
18c4044c66 | ||
|
|
643a0128d3 | ||
|
|
219dc4a4ac | ||
|
|
d642470210 | ||
|
|
b3161d2ebc | ||
|
|
39e8107f23 | ||
|
|
4b53f4056a | ||
|
|
dd11942da7 | ||
|
|
8773b108e4 | ||
|
|
05de6d005c | ||
|
|
58f1cf1ae2 | ||
|
|
231be15459 | ||
|
|
9e6db98195 | ||
|
|
5e74c44430 | ||
|
|
c5c49b52a9 | ||
|
|
c988f2dee0 | ||
|
|
87bbcf0ccd | ||
|
|
9093b0370b | ||
|
|
288cd7bf79 | ||
|
|
8b4fd29fce | ||
|
|
3c6b7b7778 | ||
|
|
d5737b70c0 | ||
|
|
0a81f15f11 | ||
|
|
7c9fd8a796 | ||
|
|
55be81d3d8 | ||
|
|
000e5c75c9 | ||
|
|
f13b4e8909 | ||
|
|
78b0da908c | ||
|
|
f7fc3561f5 | ||
|
|
1b7d674bce | ||
|
|
79755bf9e8 | ||
|
|
ebc0680903 | ||
|
|
42a42e496d | ||
|
|
5e46ea698c | ||
|
|
d0a3708e6d | ||
|
|
e1044261d3 | ||
|
|
c8c6d9132a | ||
|
|
e4d527f0f2 | ||
|
|
bb62fa680f | ||
|
|
b3abaeef8d | ||
|
|
1ab1495e62 | ||
|
|
da5c0699dd | ||
|
|
5f15b99e7c | ||
|
|
56658a4d16 | ||
|
|
bc7b6f983f |
@ -9,6 +9,13 @@
|
|||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
AUTH_SECRET=replace-with-random-secret
|
AUTH_SECRET=replace-with-random-secret
|
||||||
|
|
||||||
|
# Stable pepper for user Access Key hashes. Generate independently from AUTH_SECRET.
|
||||||
|
ACCESS_KEY_PEPPER=replace-with-random-secret
|
||||||
|
|
||||||
|
# Authorizes trusted third-party services to provision user-scoped MCP keys.
|
||||||
|
# Must contain at least 32 characters. Never expose it to end users or browsers.
|
||||||
|
MCP_KEY_PROVISIONING_SECRET=replace-with-random-secret
|
||||||
|
|
||||||
# Public application URL. Required for production and self-hosted Auth.js.
|
# Public application URL. Required for production and self-hosted Auth.js.
|
||||||
AUTH_URL=https://your-domain.com
|
AUTH_URL=https://your-domain.com
|
||||||
NEXTAUTH_URL=https://your-domain.com
|
NEXTAUTH_URL=https://your-domain.com
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@ -87,8 +87,20 @@ checkpoints/
|
|||||||
# 第三方参考源码(不提交到git)
|
# 第三方参考源码(不提交到git)
|
||||||
reference-sources/
|
reference-sources/
|
||||||
|
|
||||||
|
# Development-only upload fallback.
|
||||||
|
public/uploads/local/
|
||||||
|
|
||||||
|
# Local isolated database and video-canvas test fixtures.
|
||||||
|
.local/
|
||||||
|
|
||||||
# Local self-hosted Pascal Editor checkout.
|
# Local self-hosted Pascal Editor checkout.
|
||||||
.pascal-editor/
|
.pascal-editor/
|
||||||
|
|
||||||
|
# Local self-hosted Zoo Design Studio checkout.
|
||||||
|
.zoo-modeling-app/
|
||||||
|
|
||||||
|
# Generated official Zoo Design Studio web bundle.
|
||||||
|
public/vendor/zoo-modeling-app/
|
||||||
|
|
||||||
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
||||||
!lib/db/migrations/*.sql
|
!lib/db/migrations/*.sql
|
||||||
|
|||||||
@ -75,6 +75,8 @@ export function LocalAiImagePipelineSections({
|
|||||||
aiWashConfig,
|
aiWashConfig,
|
||||||
setAiWashConfig,
|
setAiWashConfig,
|
||||||
aiWashModeIconUploading,
|
aiWashModeIconUploading,
|
||||||
|
canvasLocalEditConfig,
|
||||||
|
setCanvasLocalEditConfig,
|
||||||
extendConfig,
|
extendConfig,
|
||||||
setExtendConfig,
|
setExtendConfig,
|
||||||
watermarkRemoveConfig,
|
watermarkRemoveConfig,
|
||||||
@ -103,6 +105,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
handleMoveEnhanceProPreset,
|
handleMoveEnhanceProPreset,
|
||||||
handleAiWashModeIconUpload,
|
handleAiWashModeIconUpload,
|
||||||
handleSaveAiWashConfig,
|
handleSaveAiWashConfig,
|
||||||
|
handleSaveCanvasLocalEditConfig,
|
||||||
handleSaveExtendConfig,
|
handleSaveExtendConfig,
|
||||||
handleSaveWatermarkRemoveConfig,
|
handleSaveWatermarkRemoveConfig,
|
||||||
handleSaveAngleEditConfig,
|
handleSaveAngleEditConfig,
|
||||||
@ -156,12 +159,15 @@ export function LocalAiImagePipelineSections({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
rmbgConfig.apiKey ||
|
rmbgConfig.provider === "vip98k"
|
||||||
|
? "服务商 Key 已配置"
|
||||||
|
: rmbgConfig.apiKey ||
|
||||||
rmbgConfig.photoroomApiKey ||
|
rmbgConfig.photoroomApiKey ||
|
||||||
rmbgConfig.aosApiKey
|
rmbgConfig.aosApiKey
|
||||||
? "Key 已配置"
|
? "Key 已配置"
|
||||||
: "Key 待配置",
|
: "Key 待配置",
|
||||||
tone:
|
tone:
|
||||||
|
rmbgConfig.provider === "vip98k" ||
|
||||||
rmbgConfig.apiKey ||
|
rmbgConfig.apiKey ||
|
||||||
rmbgConfig.photoroomApiKey ||
|
rmbgConfig.photoroomApiKey ||
|
||||||
rmbgConfig.aosApiKey
|
rmbgConfig.aosApiKey
|
||||||
@ -176,7 +182,26 @@ export function LocalAiImagePipelineSections({
|
|||||||
{/* 服务提供商选择 */}
|
{/* 服务提供商选择 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>抠图服务提供商</Label>
|
<Label>抠图服务提供商</Label>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<div
|
||||||
|
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
||||||
|
rmbgConfig.provider === "vip98k"
|
||||||
|
? "border-primary bg-accent ring-1 ring-primary"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "vip98k" })}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
|
||||||
|
rmbgConfig.provider === "vip98k"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rmbgConfig.provider === "vip98k" && <Check className="h-3 w-3" />}
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-sm leading-none">VIP98K 统一服务</p>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
||||||
rmbgConfig.provider === "rmbg"
|
rmbgConfig.provider === "rmbg"
|
||||||
@ -263,6 +288,13 @@ export function LocalAiImagePipelineSections({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{rmbgConfig.provider === "vip98k" && (
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||||
|
使用 VIP98K 的 remove-background 模型。API Key 从“AI 模型设置”中的
|
||||||
|
VIP98K 服务商读取,不会在此重复保存。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* RMBG 配置 */}
|
{/* RMBG 配置 */}
|
||||||
{rmbgConfig.provider === "rmbg" && (
|
{rmbgConfig.provider === "rmbg" && (
|
||||||
<>
|
<>
|
||||||
@ -468,7 +500,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
|
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
expanded={isLocalAiSectionExpanded("enhance")}
|
expanded={isLocalAiSectionExpanded("enhance")}
|
||||||
@ -478,17 +510,21 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionSummary
|
<LocalAiSectionSummary
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: enhanceConfig.useBackupApi
|
label: enhanceConfig.provider === "vip98k"
|
||||||
|
? "VIP98K"
|
||||||
|
: enhanceConfig.useBackupApi
|
||||||
? "备用 Real-ESRGAN"
|
? "备用 Real-ESRGAN"
|
||||||
: "主 API",
|
: "主 API",
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
|
enhanceConfig.provider === "vip98k" ||
|
||||||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||||||
? "Key 已配置"
|
? "Key 已配置"
|
||||||
: "Key 待配置",
|
: "Key 待配置",
|
||||||
tone:
|
tone:
|
||||||
|
enhanceConfig.provider === "vip98k" ||
|
||||||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||||||
? "enabled"
|
? "enabled"
|
||||||
: "warning",
|
: "warning",
|
||||||
@ -498,6 +534,13 @@ export function LocalAiImagePipelineSections({
|
|||||||
}
|
}
|
||||||
title="变清晰API配置"
|
title="变清晰API配置"
|
||||||
>
|
>
|
||||||
|
{enhanceConfig.provider === "vip98k" ? (
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||||
|
使用 VIP98K 的 enhance 模型,默认 4 倍增强。API Key 从“AI 模型设置”中的
|
||||||
|
VIP98K 服务商读取。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* API切换开关 */}
|
{/* API切换开关 */}
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@ -746,6 +789,8 @@ export function LocalAiImagePipelineSections({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button onClick={handleSaveEnhanceConfig}>
|
<Button onClick={handleSaveEnhanceConfig}>
|
||||||
<Save className="mr-2 h-4 w-4" /> 保存配置
|
<Save className="mr-2 h-4 w-4" /> 保存配置
|
||||||
@ -1421,6 +1466,112 @@ export function LocalAiImagePipelineSections({
|
|||||||
</Button>
|
</Button>
|
||||||
</LocalAiSectionShell>
|
</LocalAiSectionShell>
|
||||||
|
|
||||||
|
<LocalAiSectionShell
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
|
||||||
|
id="local-ai-section-canvas-local-edit"
|
||||||
|
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
|
||||||
|
summary={
|
||||||
|
<LocalAiSectionSummary
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: canvasLocalEditConfig.modelId
|
||||||
|
? "普通局部编辑已指定"
|
||||||
|
: "普通局部编辑继承默认",
|
||||||
|
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: canvasLocalEditConfig.scaleAdjustModelId
|
||||||
|
? "比例调整已指定"
|
||||||
|
: "比例调整兼容旧默认",
|
||||||
|
tone: canvasLocalEditConfig.scaleAdjustModelId
|
||||||
|
? "enabled"
|
||||||
|
: "neutral",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title="局部编辑模型配置"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setCanvasLocalEditConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modelId: value === "__none__" ? "" : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
value={canvasLocalEditConfig.modelId || "__none__"}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="canvasLocalEditModel">
|
||||||
|
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
|
||||||
|
{availableImageModels.length > 0 ? (
|
||||||
|
availableImageModels.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.modelId}>
|
||||||
|
{formatAiWashModelOptionLabel(model)}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem disabled value="__empty__">
|
||||||
|
暂无可用生图模型,请先到 AI 模型里启用
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
适用于手动画框并填写提示词的局部编辑。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setCanvasLocalEditConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
scaleAdjustModelId: value === "__none__" ? "" : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="canvasScaleAdjustModel">
|
||||||
|
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
|
||||||
|
{availableImageModels.length > 0 ? (
|
||||||
|
availableImageModels.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.modelId}>
|
||||||
|
{formatAiWashModelOptionLabel(model)}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem disabled value="__empty__">
|
||||||
|
暂无可用生图模型,请先到 AI 模型里启用
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSaveCanvasLocalEditConfig}>
|
||||||
|
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
|
||||||
|
</Button>
|
||||||
|
</LocalAiSectionShell>
|
||||||
|
|
||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
@ -2215,7 +2366,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
|
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
expanded={isLocalAiSectionExpanded("extend")}
|
expanded={isLocalAiSectionExpanded("extend")}
|
||||||
@ -2225,8 +2376,14 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionSummary
|
<LocalAiSectionSummary
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
|
label:
|
||||||
tone: extendConfig.apiKey ? "enabled" : "warning",
|
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
||||||
|
? "Key 已配置"
|
||||||
|
: "Key 待配置",
|
||||||
|
tone:
|
||||||
|
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
||||||
|
? "enabled"
|
||||||
|
: "warning",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
||||||
@ -2237,6 +2394,12 @@ export function LocalAiImagePipelineSections({
|
|||||||
}
|
}
|
||||||
title="图像拓展API配置"
|
title="图像拓展API配置"
|
||||||
>
|
>
|
||||||
|
{extendConfig.provider === "vip98k" ? (
|
||||||
|
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||||
|
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
|
||||||
|
VIP98K 服务商读取,仅在这里维护扩展比例。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="extendApiKey">API密钥 *</Label>
|
<Label htmlFor="extendApiKey">API密钥 *</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -2253,6 +2416,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
|
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
|
|||||||
rmbg: false,
|
rmbg: false,
|
||||||
enhance: false,
|
enhance: false,
|
||||||
"enhance-pro": false,
|
"enhance-pro": false,
|
||||||
|
"canvas-local-edit": false,
|
||||||
"ai-wash": false,
|
"ai-wash": false,
|
||||||
extend: false,
|
extend: false,
|
||||||
"watermark-remove": false,
|
"watermark-remove": false,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
|
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
|
||||||
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
|
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
|
||||||
|
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
|
||||||
import type { AiWashConfig } from "@/lib/ai-wash/config";
|
import type { AiWashConfig } from "@/lib/ai-wash/config";
|
||||||
import type {
|
import type {
|
||||||
EnhanceProKreaConfig,
|
EnhanceProKreaConfig,
|
||||||
@ -88,6 +89,10 @@ export type AdminAiProvider = {
|
|||||||
| "fal-image"
|
| "fal-image"
|
||||||
| "snapgen-image"
|
| "snapgen-image"
|
||||||
| "openai-image"
|
| "openai-image"
|
||||||
|
| "bananaimage"
|
||||||
|
| "visionary"
|
||||||
|
| "atlascloud"
|
||||||
|
| "vip98k"
|
||||||
| "stepfun-image"
|
| "stepfun-image"
|
||||||
| "midjourney"
|
| "midjourney"
|
||||||
| "fal-video"
|
| "fal-video"
|
||||||
@ -244,7 +249,7 @@ export type EnhanceProKeyRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type RmbgConfig = {
|
export type RmbgConfig = {
|
||||||
provider: "rmbg" | "photoroom" | "aoscdn";
|
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
photoroomApiKey: string;
|
photoroomApiKey: string;
|
||||||
@ -256,6 +261,7 @@ export type RmbgConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type EnhanceConfig = {
|
export type EnhanceConfig = {
|
||||||
|
provider: "vip98k" | "legacy";
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
type: string;
|
type: string;
|
||||||
@ -268,6 +274,7 @@ export type EnhanceConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExtendConfig = {
|
export type ExtendConfig = {
|
||||||
|
provider: "vip98k" | "dashscope";
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
xScale: string;
|
xScale: string;
|
||||||
yScale: string;
|
yScale: string;
|
||||||
@ -327,6 +334,8 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
|||||||
aiWashConfig: AiWashConfig;
|
aiWashConfig: AiWashConfig;
|
||||||
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
|
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
|
||||||
aiWashModeIconUploading: boolean;
|
aiWashModeIconUploading: boolean;
|
||||||
|
canvasLocalEditConfig: CanvasLocalEditConfig;
|
||||||
|
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
|
||||||
extendConfig: ExtendConfig;
|
extendConfig: ExtendConfig;
|
||||||
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
|
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
|
||||||
watermarkRemoveConfig: WatermarkRemoveConfig;
|
watermarkRemoveConfig: WatermarkRemoveConfig;
|
||||||
@ -369,6 +378,7 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
|||||||
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
|
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
|
||||||
handleAiWashModeIconUpload: (file: File) => Promise<void>;
|
handleAiWashModeIconUpload: (file: File) => Promise<void>;
|
||||||
handleSaveAiWashConfig: AsyncAction;
|
handleSaveAiWashConfig: AsyncAction;
|
||||||
|
handleSaveCanvasLocalEditConfig: AsyncAction;
|
||||||
handleSaveExtendConfig: AsyncAction;
|
handleSaveExtendConfig: AsyncAction;
|
||||||
handleSaveWatermarkRemoveConfig: AsyncAction;
|
handleSaveWatermarkRemoveConfig: AsyncAction;
|
||||||
handleSaveAngleEditConfig: AsyncAction;
|
handleSaveAngleEditConfig: AsyncAction;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,32 @@
|
|||||||
import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { ZooDesignStudioFrame } from "@/components/ai-cad/zoo-design-studio-frame";
|
||||||
|
|
||||||
|
const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "HiCAD",
|
||||||
|
};
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export default function AiCadPage() {
|
export default async function AiCadPage() {
|
||||||
return <AiCadWorkbench />;
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect("/login?callbackUrl=%2Fai-cad");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isReady = existsSync(
|
||||||
|
path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ZooDesignStudioFrame
|
||||||
|
isReady={isReady}
|
||||||
|
src={ZOO_MODELING_APP_SRC}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,10 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
|
"visionary",
|
||||||
|
"atlascloud",
|
||||||
|
"vip98k",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"runway",
|
"runway",
|
||||||
"krea",
|
"krea",
|
||||||
@ -243,6 +247,10 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
|
|||||||
case "fal-image":
|
case "fal-image":
|
||||||
case "snapgen-image":
|
case "snapgen-image":
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
|
case "bananaimage":
|
||||||
|
case "visionary":
|
||||||
|
case "atlascloud":
|
||||||
|
case "vip98k":
|
||||||
case "stepfun-image":
|
case "stepfun-image":
|
||||||
case "midjourney":
|
case "midjourney":
|
||||||
case "runway":
|
case "runway":
|
||||||
|
|||||||
@ -524,6 +524,10 @@ export async function convertMessagesWithImages(
|
|||||||
normalizedProviderType === "fal-image" ||
|
normalizedProviderType === "fal-image" ||
|
||||||
normalizedProviderType === "snapgen-image" ||
|
normalizedProviderType === "snapgen-image" ||
|
||||||
normalizedProviderType === "openai-image" ||
|
normalizedProviderType === "openai-image" ||
|
||||||
|
normalizedProviderType === "bananaimage" ||
|
||||||
|
normalizedProviderType === "visionary" ||
|
||||||
|
normalizedProviderType === "atlascloud" ||
|
||||||
|
normalizedProviderType === "vip98k" ||
|
||||||
normalizedProviderType === "stepfun-image" ||
|
normalizedProviderType === "stepfun-image" ||
|
||||||
normalizedProviderType === "midjourney" ||
|
normalizedProviderType === "midjourney" ||
|
||||||
normalizedProviderType === "runway" ||
|
normalizedProviderType === "runway" ||
|
||||||
|
|||||||
@ -187,6 +187,10 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
|||||||
normalized === "fal-image" ||
|
normalized === "fal-image" ||
|
||||||
normalized === "snapgen-image" ||
|
normalized === "snapgen-image" ||
|
||||||
normalized === "openai-image" ||
|
normalized === "openai-image" ||
|
||||||
|
normalized === "bananaimage" ||
|
||||||
|
normalized === "visionary" ||
|
||||||
|
normalized === "atlascloud" ||
|
||||||
|
normalized === "vip98k" ||
|
||||||
normalized === "stepfun-image" ||
|
normalized === "stepfun-image" ||
|
||||||
normalized === "midjourney" ||
|
normalized === "midjourney" ||
|
||||||
normalized === "runway" ||
|
normalized === "runway" ||
|
||||||
@ -207,8 +211,10 @@ export function writeImageGenerationStatusChunk(
|
|||||||
modelName?: string | null;
|
modelName?: string | null;
|
||||||
imageCount?: number | null;
|
imageCount?: number | null;
|
||||||
queuePosition?: number | null;
|
queuePosition?: number | null;
|
||||||
|
sameModelPendingCount?: number | null;
|
||||||
sameModelRunningCount?: number | null;
|
sameModelRunningCount?: number | null;
|
||||||
concurrencyLimit?: number | null;
|
concurrencyLimit?: number | null;
|
||||||
|
dispatchIntervalMs?: number | null;
|
||||||
estimatedWaitMs?: number | null;
|
estimatedWaitMs?: number | null;
|
||||||
},
|
},
|
||||||
label: string
|
label: string
|
||||||
@ -229,6 +235,11 @@ export function writeImageGenerationStatusChunk(
|
|||||||
Number(params.queuePosition) >= 0
|
Number(params.queuePosition) >= 0
|
||||||
? Number(params.queuePosition)
|
? Number(params.queuePosition)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
sameModelPendingCount:
|
||||||
|
Number.isFinite(Number(params.sameModelPendingCount)) &&
|
||||||
|
Number(params.sameModelPendingCount) >= 0
|
||||||
|
? Number(params.sameModelPendingCount)
|
||||||
|
: undefined,
|
||||||
sameModelRunningCount:
|
sameModelRunningCount:
|
||||||
Number.isFinite(Number(params.sameModelRunningCount)) &&
|
Number.isFinite(Number(params.sameModelRunningCount)) &&
|
||||||
Number(params.sameModelRunningCount) >= 0
|
Number(params.sameModelRunningCount) >= 0
|
||||||
@ -239,6 +250,11 @@ export function writeImageGenerationStatusChunk(
|
|||||||
Number(params.concurrencyLimit) > 0
|
Number(params.concurrencyLimit) > 0
|
||||||
? Number(params.concurrencyLimit)
|
? Number(params.concurrencyLimit)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
dispatchIntervalMs:
|
||||||
|
Number.isFinite(Number(params.dispatchIntervalMs)) &&
|
||||||
|
Number(params.dispatchIntervalMs) >= 0
|
||||||
|
? Number(params.dispatchIntervalMs)
|
||||||
|
: undefined,
|
||||||
estimatedWaitMs:
|
estimatedWaitMs:
|
||||||
Number.isFinite(Number(params.estimatedWaitMs)) &&
|
Number.isFinite(Number(params.estimatedWaitMs)) &&
|
||||||
Number(params.estimatedWaitMs) >= 0
|
Number(params.estimatedWaitMs) >= 0
|
||||||
|
|||||||
@ -138,7 +138,11 @@ import {
|
|||||||
generatedImage,
|
generatedImage,
|
||||||
message as messageTable,
|
message as messageTable,
|
||||||
} from "@/lib/db/schema";
|
} from "@/lib/db/schema";
|
||||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
import {
|
||||||
|
generateCanvasImage,
|
||||||
|
type CanvasGenerateImageRequest,
|
||||||
|
} from "@/lib/canvas/generate-image";
|
||||||
|
import { generateCanvasImageWithPersistentQueue } from "@/lib/canvas/image-task-queue";
|
||||||
import {
|
import {
|
||||||
detectRequestedStoryboardGrid,
|
detectRequestedStoryboardGrid,
|
||||||
isGridConfirmationReply,
|
isGridConfirmationReply,
|
||||||
@ -5739,44 +5743,53 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
const assistantMessageId = generateUUID();
|
const assistantMessageId = generateUUID();
|
||||||
|
|
||||||
await saveMessages({
|
try {
|
||||||
messages: [
|
await saveMessages({
|
||||||
{
|
messages: [
|
||||||
id: assistantMessageId,
|
{
|
||||||
chatId: id,
|
id: assistantMessageId,
|
||||||
role: "assistant",
|
chatId: id,
|
||||||
parts: [{ type: "text", text: assistantText }],
|
role: "assistant",
|
||||||
attachments: [],
|
parts: [{ type: "text", text: assistantText }],
|
||||||
createdAt: new Date(),
|
attachments: [],
|
||||||
videoStorageStatus: null,
|
createdAt: new Date(),
|
||||||
temporaryVideoUrl: null,
|
videoStorageStatus: null,
|
||||||
permanentVideoUrl: null,
|
temporaryVideoUrl: null,
|
||||||
archivedAt: null,
|
permanentVideoUrl: null,
|
||||||
videoMetadata: null,
|
archivedAt: null,
|
||||||
},
|
videoMetadata: null,
|
||||||
],
|
},
|
||||||
});
|
],
|
||||||
|
});
|
||||||
|
|
||||||
await saveGeneratedImages(
|
await saveGeneratedImages(
|
||||||
assistantText,
|
assistantText,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
id,
|
id,
|
||||||
assistantMessageId,
|
assistantMessageId,
|
||||||
params.promptForPersistence || baseReplacementPrompt,
|
params.promptForPersistence || baseReplacementPrompt,
|
||||||
resolvedModelRef,
|
resolvedModelRef,
|
||||||
{
|
{
|
||||||
sourceAssetIds,
|
sourceAssetIds,
|
||||||
sourceImageUrls: params.sourceImageUrls,
|
sourceImageUrls: params.sourceImageUrls,
|
||||||
relationType: "edited_from",
|
relationType: "edited_from",
|
||||||
sourceType: "detail_replacement",
|
sourceType: "detail_replacement",
|
||||||
projectId: contextPayload?.projectId || null,
|
projectId: contextPayload?.projectId || null,
|
||||||
userPrompt: requestTextContent,
|
userPrompt: requestTextContent,
|
||||||
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
||||||
requestedImageCount: 1,
|
requestedImageCount: 1,
|
||||||
aspectRatio: resolvedAspectRatio,
|
aspectRatio: resolvedAspectRatio,
|
||||||
imageSize: persistedImageSize,
|
imageSize: persistedImageSize,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} catch (persistError) {
|
||||||
|
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
|
||||||
|
chatId: id,
|
||||||
|
referenceId: params.reference.id,
|
||||||
|
referenceLabel: params.reference.label,
|
||||||
|
error: persistError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
@ -5804,6 +5817,7 @@ export async function POST(request: Request) {
|
|||||||
Array.from(replacementReferences.entries()),
|
Array.from(replacementReferences.entries()),
|
||||||
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
|
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
|
||||||
async ([referenceIndex, reference]) => {
|
async ([referenceIndex, reference]) => {
|
||||||
|
const itemStartedAt = Date.now();
|
||||||
if (reference.passthrough === true) {
|
if (reference.passthrough === true) {
|
||||||
const referenceSourceImageUrls = Array.from(
|
const referenceSourceImageUrls = Array.from(
|
||||||
new Set([
|
new Set([
|
||||||
@ -5824,9 +5838,29 @@ export async function POST(request: Request) {
|
|||||||
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
|
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
|
||||||
});
|
});
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
|
chatApiLogger.info("[Chat API] Direct detail replacement item passthrough completed:", {
|
||||||
|
referenceIndex: referenceIndex + 1,
|
||||||
|
totalReferences: replacementReferences.length,
|
||||||
|
referenceLabel: reference.label,
|
||||||
|
elapsedMs: Date.now() - itemStartedAt,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackSourceImageUrls = Array.from(
|
||||||
|
new Set([
|
||||||
|
...productInputImageUrls,
|
||||||
|
...(modelInputImageUrl ? [modelInputImageUrl] : []),
|
||||||
|
...(reference.rerunReferenceImages || [])
|
||||||
|
.map((image) => String(image.url || "").trim())
|
||||||
|
.filter((url) => /^https?:\/\//i.test(url))
|
||||||
|
.slice(0, 6),
|
||||||
|
reference.imageUrl,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const planningStartedAt = Date.now();
|
||||||
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
|
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
|
||||||
modeType: "detail_replacement",
|
modeType: "detail_replacement",
|
||||||
replacementStrategy: detailReplacementStrategy,
|
replacementStrategy: detailReplacementStrategy,
|
||||||
@ -5841,10 +5875,12 @@ export async function POST(request: Request) {
|
|||||||
planningRuntime: replacementScenePlanningRuntime,
|
planningRuntime: replacementScenePlanningRuntime,
|
||||||
models: replacementVisionModels,
|
models: replacementVisionModels,
|
||||||
});
|
});
|
||||||
|
const planningElapsedMs = Date.now() - planningStartedAt;
|
||||||
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
|
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
|
||||||
referenceIndex: referenceIndex + 1,
|
referenceIndex: referenceIndex + 1,
|
||||||
totalReferences: replacementReferences.length,
|
totalReferences: replacementReferences.length,
|
||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
|
planningElapsedMs,
|
||||||
shouldReplace: replacementExecutionPlan.shouldReplace,
|
shouldReplace: replacementExecutionPlan.shouldReplace,
|
||||||
reason: replacementExecutionPlan.reason,
|
reason: replacementExecutionPlan.reason,
|
||||||
detectionSource: replacementExecutionPlan.detectionSource,
|
detectionSource: replacementExecutionPlan.detectionSource,
|
||||||
@ -5954,46 +5990,67 @@ export async function POST(request: Request) {
|
|||||||
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
|
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let generationStartedAt = 0;
|
||||||
|
let generationElapsedMs: number | null = null;
|
||||||
|
let normalizeStartedAt = 0;
|
||||||
|
let normalizeElapsedMs: number | null = null;
|
||||||
try {
|
try {
|
||||||
const result = await generateCanvasImage({
|
generationStartedAt = Date.now();
|
||||||
prompt: replacementPrompt,
|
const result = await generateCanvasImageWithPersistentQueue({
|
||||||
negativePrompt: effectiveImageParams?.negativePrompt,
|
userId: session.user.id,
|
||||||
model: resolvedModelRef,
|
request: {
|
||||||
ownerKey: session.user.id,
|
prompt: replacementPrompt,
|
||||||
aspectRatio: generationAspectRatio,
|
negativePrompt: effectiveImageParams?.negativePrompt,
|
||||||
imageSize: resolvedImageSize,
|
model: resolvedModelRef,
|
||||||
imageCount: 1,
|
ownerKey: session.user.id,
|
||||||
referenceImages: sourceImageUrls,
|
aspectRatio: generationAspectRatio,
|
||||||
debugLabel: "agent-detail-replacement",
|
imageSize: resolvedImageSize,
|
||||||
debugMeta: {
|
imageCount: 1,
|
||||||
referenceLabel: reference.label,
|
referenceImages: sourceImageUrls,
|
||||||
referenceWidth: referenceGeometry?.width,
|
debugLabel: "agent-detail-replacement",
|
||||||
referenceHeight: referenceGeometry?.height,
|
debugMeta: {
|
||||||
referenceAspectRatio: referenceGeometry?.aspectRatio,
|
operation: "agent-detail-replacement",
|
||||||
|
chatId: id,
|
||||||
|
messageId: message.id,
|
||||||
|
projectId: contextPayload?.projectId || null,
|
||||||
|
referenceId: reference.id,
|
||||||
|
referenceLabel: reference.label,
|
||||||
|
referenceWidth: referenceGeometry?.width,
|
||||||
|
referenceHeight: referenceGeometry?.height,
|
||||||
|
referenceAspectRatio: referenceGeometry?.aspectRatio,
|
||||||
|
},
|
||||||
|
onQueueStatusChange:
|
||||||
|
contextPayload?.source === "canvas"
|
||||||
|
? async (queueStatus) => {
|
||||||
|
writeImageGenerationStatusChunk(
|
||||||
|
writer,
|
||||||
|
{
|
||||||
|
phase:
|
||||||
|
queueStatus.status === "running"
|
||||||
|
? "processing"
|
||||||
|
: "queued",
|
||||||
|
modelName:
|
||||||
|
queueStatus.modelName ||
|
||||||
|
resolvedModelDisplayName ||
|
||||||
|
resolvedModelRef,
|
||||||
|
imageCount: 1,
|
||||||
|
queuePosition: queueStatus.queuePosition,
|
||||||
|
sameModelPendingCount:
|
||||||
|
queueStatus.sameModelPendingCount,
|
||||||
|
sameModelRunningCount:
|
||||||
|
queueStatus.sameModelRunningCount,
|
||||||
|
concurrencyLimit: queueStatus.concurrencyLimit,
|
||||||
|
dispatchIntervalMs:
|
||||||
|
queueStatus.dispatchIntervalMs,
|
||||||
|
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
||||||
|
},
|
||||||
|
`agent-mode:detail-replacement:queue:${reference.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
onQueueStatusChange:
|
|
||||||
contextPayload?.source === "canvas"
|
|
||||||
? async (queueStatus) => {
|
|
||||||
writeImageGenerationStatusChunk(
|
|
||||||
writer,
|
|
||||||
{
|
|
||||||
phase:
|
|
||||||
queueStatus.status === "running"
|
|
||||||
? "processing"
|
|
||||||
: "queued",
|
|
||||||
modelName:
|
|
||||||
queueStatus.modelName || resolvedModelDisplayName || resolvedModelRef,
|
|
||||||
imageCount: 1,
|
|
||||||
queuePosition: queueStatus.queuePosition,
|
|
||||||
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
|
||||||
concurrencyLimit: queueStatus.concurrencyLimit,
|
|
||||||
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
|
||||||
},
|
|
||||||
`agent-mode:detail-replacement:queue:${reference.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
generationElapsedMs = Date.now() - generationStartedAt;
|
||||||
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
||||||
const firstImage = result.images[0];
|
const firstImage = result.images[0];
|
||||||
if (!firstImage) {
|
if (!firstImage) {
|
||||||
@ -6003,6 +6060,7 @@ export async function POST(request: Request) {
|
|||||||
let finalImage = firstImage;
|
let finalImage = firstImage;
|
||||||
let finalImageSizeLabel: string = resolvedImageSize;
|
let finalImageSizeLabel: string = resolvedImageSize;
|
||||||
try {
|
try {
|
||||||
|
normalizeStartedAt = Date.now();
|
||||||
const normalizedImage = await normalizeReplacementResultImage({
|
const normalizedImage = await normalizeReplacementResultImage({
|
||||||
generatedImageUrl: firstImage.originalUrl,
|
generatedImageUrl: firstImage.originalUrl,
|
||||||
generatedThumbnailUrl: firstImage.thumbnailUrl,
|
generatedThumbnailUrl: firstImage.thumbnailUrl,
|
||||||
@ -6016,7 +6074,11 @@ export async function POST(request: Request) {
|
|||||||
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
|
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
|
||||||
};
|
};
|
||||||
finalImageSizeLabel = normalizedImage.imageSize;
|
finalImageSizeLabel = normalizedImage.imageSize;
|
||||||
|
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||||
} catch (normalizeError) {
|
} catch (normalizeError) {
|
||||||
|
if (normalizeStartedAt > 0 && normalizeElapsedMs === null) {
|
||||||
|
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||||
|
}
|
||||||
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
|
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
|
||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
referenceImageUrl: reference.imageUrl,
|
referenceImageUrl: reference.imageUrl,
|
||||||
@ -6034,6 +6096,22 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chatApiLogger.info("[Chat API] Direct detail replacement image generation completed:", {
|
||||||
|
referenceIndex: referenceIndex + 1,
|
||||||
|
totalReferences: replacementReferences.length,
|
||||||
|
referenceLabel: reference.label,
|
||||||
|
modelDisplayName: resolvedModelDisplayName,
|
||||||
|
resultImageSize: finalImageSizeLabel,
|
||||||
|
sourceImageCount: sourceImageUrls.length,
|
||||||
|
generationAspectRatio,
|
||||||
|
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||||
|
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||||
|
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||||
|
elapsedMs: Date.now() - itemStartedAt,
|
||||||
|
planningElapsedMs,
|
||||||
|
generationElapsedMs,
|
||||||
|
normalizeElapsedMs,
|
||||||
|
});
|
||||||
await emitReplacementResultMessage({
|
await emitReplacementResultMessage({
|
||||||
reference,
|
reference,
|
||||||
image: finalImage,
|
image: finalImage,
|
||||||
@ -6045,9 +6123,16 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
} catch (replacementItemError) {
|
} catch (replacementItemError) {
|
||||||
|
if (generationStartedAt > 0 && generationElapsedMs === null) {
|
||||||
|
generationElapsedMs = Date.now() - generationStartedAt;
|
||||||
|
}
|
||||||
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
|
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
|
||||||
referenceIndex,
|
referenceIndex,
|
||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
|
elapsedMs: Date.now() - itemStartedAt,
|
||||||
|
planningElapsedMs,
|
||||||
|
generationElapsedMs,
|
||||||
|
normalizeElapsedMs,
|
||||||
error: replacementItemError,
|
error: replacementItemError,
|
||||||
});
|
});
|
||||||
await emitReplacementResultMessage({
|
await emitReplacementResultMessage({
|
||||||
@ -6067,6 +6152,30 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
}
|
}
|
||||||
|
} catch (replacementPlanError) {
|
||||||
|
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
|
||||||
|
referenceIndex,
|
||||||
|
referenceLabel: reference.label,
|
||||||
|
elapsedMs: Date.now() - itemStartedAt,
|
||||||
|
error: replacementPlanError,
|
||||||
|
});
|
||||||
|
await emitReplacementResultMessage({
|
||||||
|
reference,
|
||||||
|
bodyText: `生成失败,当前先沿用参考图:${
|
||||||
|
replacementPlanError instanceof Error
|
||||||
|
? replacementPlanError.message
|
||||||
|
: "请稍后重试"
|
||||||
|
}`,
|
||||||
|
image: {
|
||||||
|
originalUrl: reference.imageUrl,
|
||||||
|
thumbnailUrl: reference.imageUrl,
|
||||||
|
},
|
||||||
|
modelDisplayName: null,
|
||||||
|
sourceImageUrls: fallbackSourceImageUrls,
|
||||||
|
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
||||||
|
});
|
||||||
|
successCount += 1;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
@ -6789,7 +6898,7 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await generateCanvasImage({
|
const generationRequest: CanvasGenerateImageRequest = {
|
||||||
prompt: replacementPrompt,
|
prompt: replacementPrompt,
|
||||||
negativePrompt:
|
negativePrompt:
|
||||||
typeof mergedImageParams.negativePrompt === "string"
|
typeof mergedImageParams.negativePrompt === "string"
|
||||||
@ -6803,6 +6912,11 @@ export async function POST(request: Request) {
|
|||||||
referenceImages: sourceImageUrls,
|
referenceImages: sourceImageUrls,
|
||||||
debugLabel: `agent-${replacementSourceType}`,
|
debugLabel: `agent-${replacementSourceType}`,
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
|
operation: `agent-${replacementSourceType}`,
|
||||||
|
chatId: id,
|
||||||
|
messageId: message.id,
|
||||||
|
projectId: contextPayload?.projectId || null,
|
||||||
|
referenceId: reference.id,
|
||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
referenceWidth: referenceGeometry?.width,
|
referenceWidth: referenceGeometry?.width,
|
||||||
referenceHeight: referenceGeometry?.height,
|
referenceHeight: referenceGeometry?.height,
|
||||||
@ -6824,15 +6938,25 @@ export async function POST(request: Request) {
|
|||||||
resolvedModelRef,
|
resolvedModelRef,
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
queuePosition: queueStatus.queuePosition,
|
queuePosition: queueStatus.queuePosition,
|
||||||
|
sameModelPendingCount:
|
||||||
|
queueStatus.sameModelPendingCount,
|
||||||
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
||||||
concurrencyLimit: queueStatus.concurrencyLimit,
|
concurrencyLimit: queueStatus.concurrencyLimit,
|
||||||
|
dispatchIntervalMs:
|
||||||
|
queueStatus.dispatchIntervalMs,
|
||||||
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
||||||
},
|
},
|
||||||
`agent-preset:replacement:queue:${reference.id}`,
|
`agent-preset:replacement:queue:${reference.id}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
};
|
||||||
|
const result = isDetailReplacementPreset
|
||||||
|
? await generateCanvasImageWithPersistentQueue({
|
||||||
|
userId: session.user.id,
|
||||||
|
request: generationRequest,
|
||||||
|
})
|
||||||
|
: await generateCanvasImage(generationRequest);
|
||||||
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
||||||
const firstImage = result.images[0];
|
const firstImage = result.images[0];
|
||||||
if (!firstImage) {
|
if (!firstImage) {
|
||||||
@ -7143,8 +7267,12 @@ export async function POST(request: Request) {
|
|||||||
queueStatus.modelName || selectedAgentPreset.name,
|
queueStatus.modelName || selectedAgentPreset.name,
|
||||||
imageCount: resolvedImageCount,
|
imageCount: resolvedImageCount,
|
||||||
queuePosition: queueStatus.queuePosition,
|
queuePosition: queueStatus.queuePosition,
|
||||||
|
sameModelPendingCount:
|
||||||
|
queueStatus.sameModelPendingCount,
|
||||||
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
||||||
concurrencyLimit: queueStatus.concurrencyLimit,
|
concurrencyLimit: queueStatus.concurrencyLimit,
|
||||||
|
dispatchIntervalMs:
|
||||||
|
queueStatus.dispatchIntervalMs,
|
||||||
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
||||||
},
|
},
|
||||||
"agent-preset:image:scene-generation:queue",
|
"agent-preset:image:scene-generation:queue",
|
||||||
@ -7370,8 +7498,12 @@ export async function POST(request: Request) {
|
|||||||
modelName: queueStatus.modelName || slotLabel,
|
modelName: queueStatus.modelName || slotLabel,
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
queuePosition: queueStatus.queuePosition,
|
queuePosition: queueStatus.queuePosition,
|
||||||
|
sameModelPendingCount:
|
||||||
|
queueStatus.sameModelPendingCount,
|
||||||
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
||||||
concurrencyLimit: queueStatus.concurrencyLimit,
|
concurrencyLimit: queueStatus.concurrencyLimit,
|
||||||
|
dispatchIntervalMs:
|
||||||
|
queueStatus.dispatchIntervalMs,
|
||||||
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
||||||
},
|
},
|
||||||
`agent-preset:image:slot-queue:${slot.slotIndex}`,
|
`agent-preset:image:slot-queue:${slot.slotIndex}`,
|
||||||
@ -9103,6 +9235,10 @@ export async function POST(request: Request) {
|
|||||||
"fal-image": 4,
|
"fal-image": 4,
|
||||||
"snapgen-image": 5,
|
"snapgen-image": 5,
|
||||||
"openai-image": 6,
|
"openai-image": 6,
|
||||||
|
bananaimage: 7,
|
||||||
|
visionary: 7,
|
||||||
|
atlascloud: 7,
|
||||||
|
vip98k: 7,
|
||||||
"stepfun-image": 7,
|
"stepfun-image": 7,
|
||||||
"grsai-gpt-image": 8,
|
"grsai-gpt-image": 8,
|
||||||
runway: 9,
|
runway: 9,
|
||||||
@ -9494,6 +9630,10 @@ export async function POST(request: Request) {
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
|
"visionary",
|
||||||
|
"atlascloud",
|
||||||
|
"vip98k",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"runway",
|
"runway",
|
||||||
@ -9716,6 +9856,14 @@ export async function POST(request: Request) {
|
|||||||
? "snapgen-image"
|
? "snapgen-image"
|
||||||
: (languageModel as any).provider === "openai-image"
|
: (languageModel as any).provider === "openai-image"
|
||||||
? "openai-image"
|
? "openai-image"
|
||||||
|
: (languageModel as any).provider === "bananaimage"
|
||||||
|
? "bananaimage"
|
||||||
|
: (languageModel as any).provider === "visionary"
|
||||||
|
? "visionary"
|
||||||
|
: (languageModel as any).provider === "atlascloud"
|
||||||
|
? "atlascloud"
|
||||||
|
: (languageModel as any).provider === "vip98k"
|
||||||
|
? "vip98k"
|
||||||
: (languageModel as any).provider === "stepfun-image"
|
: (languageModel as any).provider === "stepfun-image"
|
||||||
? "stepfun-image"
|
? "stepfun-image"
|
||||||
: (languageModel as any).provider === "midjourney"
|
: (languageModel as any).provider === "midjourney"
|
||||||
@ -9819,6 +9967,10 @@ export async function POST(request: Request) {
|
|||||||
"fal-image": 4,
|
"fal-image": 4,
|
||||||
"snapgen-image": 5,
|
"snapgen-image": 5,
|
||||||
"openai-image": 6,
|
"openai-image": 6,
|
||||||
|
bananaimage: 7,
|
||||||
|
visionary: 7,
|
||||||
|
atlascloud: 7,
|
||||||
|
vip98k: 7,
|
||||||
"stepfun-image": 7,
|
"stepfun-image": 7,
|
||||||
runway: 8,
|
runway: 8,
|
||||||
krea: 9,
|
krea: 9,
|
||||||
@ -9862,6 +10014,14 @@ export async function POST(request: Request) {
|
|||||||
? "snapgen-image"
|
? "snapgen-image"
|
||||||
: candidate.provider.type === "openai-image"
|
: candidate.provider.type === "openai-image"
|
||||||
? "openai-image"
|
? "openai-image"
|
||||||
|
: candidate.provider.type === "bananaimage"
|
||||||
|
? "bananaimage"
|
||||||
|
: candidate.provider.type === "visionary"
|
||||||
|
? "visionary"
|
||||||
|
: candidate.provider.type === "atlascloud"
|
||||||
|
? "atlascloud"
|
||||||
|
: candidate.provider.type === "vip98k"
|
||||||
|
? "vip98k"
|
||||||
: candidate.provider.type === "stepfun-image"
|
: candidate.provider.type === "stepfun-image"
|
||||||
? "stepfun-image"
|
? "stepfun-image"
|
||||||
: candidate.provider.type === "midjourney"
|
: candidate.provider.type === "midjourney"
|
||||||
@ -10248,6 +10408,10 @@ export async function POST(request: Request) {
|
|||||||
"fal-image": 3,
|
"fal-image": 3,
|
||||||
"snapgen-image": 4,
|
"snapgen-image": 4,
|
||||||
"openai-image": 5,
|
"openai-image": 5,
|
||||||
|
bananaimage: 6,
|
||||||
|
visionary: 6,
|
||||||
|
atlascloud: 6,
|
||||||
|
vip98k: 6,
|
||||||
"stepfun-image": 6,
|
"stepfun-image": 6,
|
||||||
runway: 7,
|
runway: 7,
|
||||||
krea: 8,
|
krea: 8,
|
||||||
@ -10292,6 +10456,14 @@ export async function POST(request: Request) {
|
|||||||
? "snapgen-image"
|
? "snapgen-image"
|
||||||
: candidate.provider.type === "openai-image"
|
: candidate.provider.type === "openai-image"
|
||||||
? "openai-image"
|
? "openai-image"
|
||||||
|
: candidate.provider.type === "bananaimage"
|
||||||
|
? "bananaimage"
|
||||||
|
: candidate.provider.type === "visionary"
|
||||||
|
? "visionary"
|
||||||
|
: candidate.provider.type === "atlascloud"
|
||||||
|
? "atlascloud"
|
||||||
|
: candidate.provider.type === "vip98k"
|
||||||
|
? "vip98k"
|
||||||
: candidate.provider.type === "stepfun-image"
|
: candidate.provider.type === "stepfun-image"
|
||||||
? "stepfun-image"
|
? "stepfun-image"
|
||||||
: candidate.provider.type === "midjourney"
|
: candidate.provider.type === "midjourney"
|
||||||
@ -10505,7 +10677,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
|
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
|
||||||
if (
|
if (
|
||||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].includes((languageModel as any).provider) &&
|
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video", "vip98k"].includes((languageModel as any).provider) &&
|
||||||
typeof (languageModel as any).doGenerate === "function"
|
typeof (languageModel as any).doGenerate === "function"
|
||||||
) {
|
) {
|
||||||
const currentVideoProvider = (languageModel as any).provider as
|
const currentVideoProvider = (languageModel as any).provider as
|
||||||
@ -10514,7 +10686,8 @@ export async function POST(request: Request) {
|
|||||||
| "ruxa-video"
|
| "ruxa-video"
|
||||||
| "runway"
|
| "runway"
|
||||||
| "krea"
|
| "krea"
|
||||||
| "bltcy-video";
|
| "bltcy-video"
|
||||||
|
| "vip98k";
|
||||||
chatApiLogger.info(`[Chat API] Using ${currentVideoProvider} video generation`);
|
chatApiLogger.info(`[Chat API] Using ${currentVideoProvider} video generation`);
|
||||||
|
|
||||||
const videoGenerationStream = createUIMessageStream({
|
const videoGenerationStream = createUIMessageStream({
|
||||||
@ -11121,6 +11294,10 @@ export async function POST(request: Request) {
|
|||||||
"fal-image": 4,
|
"fal-image": 4,
|
||||||
"snapgen-image": 5,
|
"snapgen-image": 5,
|
||||||
"openai-image": 6,
|
"openai-image": 6,
|
||||||
|
bananaimage: 7,
|
||||||
|
visionary: 7,
|
||||||
|
atlascloud: 7,
|
||||||
|
vip98k: 7,
|
||||||
"stepfun-image": 7,
|
"stepfun-image": 7,
|
||||||
runway: 8,
|
runway: 8,
|
||||||
krea: 9,
|
krea: 9,
|
||||||
@ -11167,6 +11344,14 @@ export async function POST(request: Request) {
|
|||||||
? "snapgen-image"
|
? "snapgen-image"
|
||||||
: candidate.provider.type === "openai-image"
|
: candidate.provider.type === "openai-image"
|
||||||
? "openai-image"
|
? "openai-image"
|
||||||
|
: candidate.provider.type === "bananaimage"
|
||||||
|
? "bananaimage"
|
||||||
|
: candidate.provider.type === "visionary"
|
||||||
|
? "visionary"
|
||||||
|
: candidate.provider.type === "atlascloud"
|
||||||
|
? "atlascloud"
|
||||||
|
: candidate.provider.type === "vip98k"
|
||||||
|
? "vip98k"
|
||||||
: candidate.provider.type === "stepfun-image"
|
: candidate.provider.type === "stepfun-image"
|
||||||
? "stepfun-image"
|
? "stepfun-image"
|
||||||
: candidate.provider.type === "midjourney"
|
: candidate.provider.type === "midjourney"
|
||||||
|
|||||||
@ -48,6 +48,7 @@ const imageParamsSchema = z.object({
|
|||||||
aspectRatio: z.string().optional(),
|
aspectRatio: z.string().optional(),
|
||||||
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
|
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
|
||||||
imageCount: z.number().min(1).max(10).optional(),
|
imageCount: z.number().min(1).max(10).optional(),
|
||||||
|
quality: z.enum(["auto", "low", "medium", "high"]).optional(),
|
||||||
// 阿里生图专用参数
|
// 阿里生图专用参数
|
||||||
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
|
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
|
||||||
negativePrompt: z.string().optional(), // 反向提示词
|
negativePrompt: z.string().optional(), // 反向提示词
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
CompleteMultipartUploadCommand,
|
CompleteMultipartUploadCommand,
|
||||||
CreateMultipartUploadCommand,
|
CreateMultipartUploadCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
|
ListPartsCommand,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
UploadPartCommand,
|
UploadPartCommand,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
@ -21,12 +22,22 @@ import {
|
|||||||
maybeEnqueueStorageMigrationTask,
|
maybeEnqueueStorageMigrationTask,
|
||||||
resolveS3UploadTarget,
|
resolveS3UploadTarget,
|
||||||
} from "@/lib/storage/service";
|
} from "@/lib/storage/service";
|
||||||
|
import {
|
||||||
|
getOpenClawChatFileContentType,
|
||||||
|
isOpenClawChatFolderFileAllowed,
|
||||||
|
OPENCLAW_CHAT_FILE_MAX_BYTES,
|
||||||
|
OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE,
|
||||||
|
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
|
||||||
|
OPENCLAW_CHAT_VIDEO_MAX_BYTES,
|
||||||
|
OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE,
|
||||||
|
} from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
|
||||||
const logger = createLogger("direct-upload-api");
|
const logger = createLogger("direct-upload-api");
|
||||||
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
|
||||||
|
const DIRECT_UPLOAD_MEDIA_MAX_BYTES = 500 * 1024 * 1024;
|
||||||
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
|
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
|
||||||
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
|
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
|
||||||
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
|
const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60;
|
||||||
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
||||||
|
|
||||||
function elapsedMs(startedAt: number) {
|
function elapsedMs(startedAt: number) {
|
||||||
@ -39,6 +50,13 @@ const allowedDirectUploadTypes = new Set([
|
|||||||
"image/png",
|
"image/png",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
]);
|
]);
|
||||||
|
const allowedOpenClawVideoUploadTypes = new Set([
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/webm",
|
||||||
|
"video/x-m4v",
|
||||||
|
"video/x-matroska",
|
||||||
|
]);
|
||||||
|
|
||||||
const InitiateSchema = z.object({
|
const InitiateSchema = z.object({
|
||||||
action: z.literal("initiate"),
|
action: z.literal("initiate"),
|
||||||
@ -68,6 +86,18 @@ const CompleteSchema = z.object({
|
|||||||
clientUploadId: z.string().max(128).optional(),
|
clientUploadId: z.string().max(128).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ResumeSchema = z.object({
|
||||||
|
action: z.literal("resume"),
|
||||||
|
key: z.string().min(1).max(512),
|
||||||
|
uploadId: z.string().min(1).max(512),
|
||||||
|
filename: z.string().min(1).max(256),
|
||||||
|
contentType: z.string().min(1).max(128),
|
||||||
|
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
|
||||||
|
partSize: z.number().int().min(5 * 1024 * 1024).max(128 * 1024 * 1024),
|
||||||
|
purpose: z.string().max(64).optional(),
|
||||||
|
clientUploadId: z.string().max(128).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const AbortSchema = z.object({
|
const AbortSchema = z.object({
|
||||||
action: z.literal("abort"),
|
action: z.literal("abort"),
|
||||||
key: z.string().min(1).max(512),
|
key: z.string().min(1).max(512),
|
||||||
@ -77,6 +107,7 @@ const AbortSchema = z.object({
|
|||||||
|
|
||||||
const RequestSchema = z.discriminatedUnion("action", [
|
const RequestSchema = z.discriminatedUnion("action", [
|
||||||
InitiateSchema,
|
InitiateSchema,
|
||||||
|
ResumeSchema,
|
||||||
CompleteSchema,
|
CompleteSchema,
|
||||||
AbortSchema,
|
AbortSchema,
|
||||||
]);
|
]);
|
||||||
@ -104,19 +135,49 @@ function assertUserOwnedKey(key: string, userId: string) {
|
|||||||
const normalized = String(key || "").replace(/^\/+/, "");
|
const normalized = String(key || "").replace(/^\/+/, "");
|
||||||
const prefix = `${userUploadPrefix(userId)}/`;
|
const prefix = `${userUploadPrefix(userId)}/`;
|
||||||
if (!normalized.startsWith(prefix)) {
|
if (!normalized.startsWith(prefix)) {
|
||||||
throw new Error("Invalid upload object key");
|
const error = new Error("Invalid upload object key");
|
||||||
|
error.name = "InvalidUploadObjectKeyError";
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedDirectUpload(contentType: string) {
|
function isAllowedDirectUpload(
|
||||||
return allowedDirectUploadTypes.has(contentType);
|
contentType: string,
|
||||||
|
purpose: string | undefined,
|
||||||
|
filename: string
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
allowedDirectUploadTypes.has(contentType) ||
|
||||||
|
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
||||||
|
allowedOpenClawVideoUploadTypes.has(contentType)) ||
|
||||||
|
(purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
|
||||||
|
Boolean(
|
||||||
|
getOpenClawChatFileContentType({ name: filename, type: contentType })
|
||||||
|
)) ||
|
||||||
|
(purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
|
||||||
|
isOpenClawChatFolderFileAllowed({ name: filename, type: contentType }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPurposeMaxBytes(purpose?: string) {
|
||||||
|
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
|
||||||
|
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||||
|
purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||||
|
) {
|
||||||
|
return DIRECT_UPLOAD_MAX_BYTES;
|
||||||
|
}
|
||||||
|
return DIRECT_UPLOAD_MEDIA_MAX_BYTES;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computePartSize(size: number) {
|
function computePartSize(size: number) {
|
||||||
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
|
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
|
||||||
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
|
if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
|
||||||
return 16 * 1024 * 1024;
|
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
|
||||||
|
return 64 * 1024 * 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPartUrls(params: {
|
async function createPartUrls(params: {
|
||||||
@ -124,24 +185,24 @@ async function createPartUrls(params: {
|
|||||||
bucket: string;
|
bucket: string;
|
||||||
key: string;
|
key: string;
|
||||||
uploadId: string;
|
uploadId: string;
|
||||||
partCount: number;
|
partNumbers: number[];
|
||||||
}) {
|
}) {
|
||||||
const urls = [];
|
return Promise.all(
|
||||||
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) {
|
params.partNumbers.map(async (partNumber) => {
|
||||||
const command = new UploadPartCommand({
|
const command = new UploadPartCommand({
|
||||||
Bucket: params.bucket,
|
Bucket: params.bucket,
|
||||||
Key: params.key,
|
Key: params.key,
|
||||||
UploadId: params.uploadId,
|
UploadId: params.uploadId,
|
||||||
PartNumber: partNumber,
|
PartNumber: partNumber,
|
||||||
});
|
});
|
||||||
urls.push({
|
return {
|
||||||
partNumber,
|
partNumber,
|
||||||
url: await getSignedUrl(params.client, command, {
|
url: await getSignedUrl(params.client, command, {
|
||||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
}
|
})
|
||||||
return urls;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@ -204,7 +265,7 @@ export async function POST(request: Request) {
|
|||||||
payload.filename
|
payload.filename
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isAllowedDirectUpload(contentType)) {
|
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||||
logger.warn("unsupported_direct_upload_type", {
|
logger.warn("unsupported_direct_upload_type", {
|
||||||
contentType,
|
contentType,
|
||||||
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
|
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
|
||||||
@ -213,10 +274,19 @@ export async function POST(request: Request) {
|
|||||||
clientUploadId: payload.clientUploadId || null,
|
clientUploadId: payload.clientUploadId || null,
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Direct upload only supports JPG, PNG and WebP images" },
|
{
|
||||||
|
error:
|
||||||
|
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||||
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `文件不能超过 ${getPurposeMaxBytes(payload.purpose) / 1024 / 1024}MB` },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const displayName = sanitizeDisplayName(payload.filename);
|
const displayName = sanitizeDisplayName(payload.filename);
|
||||||
const key = buildStorageObjectKey(
|
const key = buildStorageObjectKey(
|
||||||
@ -224,7 +294,13 @@ export async function POST(request: Request) {
|
|||||||
userUploadPrefix(session.user.id)
|
userUploadPrefix(session.user.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
|
const multipartThreshold =
|
||||||
|
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||||
|
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE ||
|
||||||
|
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||||
|
? DEFAULT_PART_SIZE_BYTES
|
||||||
|
: MULTIPART_THRESHOLD_BYTES;
|
||||||
|
if (payload.size < multipartThreshold) {
|
||||||
const presignStartedAt = Date.now();
|
const presignStartedAt = Date.now();
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@ -285,7 +361,7 @@ export async function POST(request: Request) {
|
|||||||
bucket,
|
bucket,
|
||||||
key,
|
key,
|
||||||
uploadId,
|
uploadId,
|
||||||
partCount,
|
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
|
||||||
});
|
});
|
||||||
logger.info("direct_upload_multipart_prepared", {
|
logger.info("direct_upload_multipart_prepared", {
|
||||||
elapsedMs: elapsedMs(multipartStartedAt),
|
elapsedMs: elapsedMs(multipartStartedAt),
|
||||||
@ -309,6 +385,98 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.action === "resume") {
|
||||||
|
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||||
|
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
||||||
|
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||||
|
return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||||
|
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
||||||
|
}
|
||||||
|
const expectedPartSize = computePartSize(payload.size);
|
||||||
|
if (payload.partSize !== expectedPartSize) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "续传分片参数已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const listed = await client.send(
|
||||||
|
new ListPartsCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
UploadId: payload.uploadId,
|
||||||
|
MaxParts: 1000,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const uploadedParts = (listed.Parts || []).flatMap((part) => {
|
||||||
|
if (!part.ETag || typeof part.PartNumber !== "number") return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
ETag: part.ETag,
|
||||||
|
PartNumber: part.PartNumber,
|
||||||
|
size: Number(part.Size || 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const uploadedPartNumbers = new Set(
|
||||||
|
uploadedParts.map((part) => part.PartNumber)
|
||||||
|
);
|
||||||
|
const partCount = Math.ceil(payload.size / expectedPartSize);
|
||||||
|
const missingPartNumbers = Array.from(
|
||||||
|
{ length: partCount },
|
||||||
|
(_, index) => index + 1
|
||||||
|
).filter((partNumber) => !uploadedPartNumbers.has(partNumber));
|
||||||
|
const parts = await createPartUrls({
|
||||||
|
client,
|
||||||
|
bucket,
|
||||||
|
key,
|
||||||
|
uploadId: payload.uploadId,
|
||||||
|
partNumbers: missingPartNumbers,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("direct_upload_multipart_resumed", {
|
||||||
|
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||||
|
size: payload.size,
|
||||||
|
uploadedPartCount: uploadedParts.length,
|
||||||
|
missingPartCount: missingPartNumbers.length,
|
||||||
|
clientUploadId: payload.clientUploadId || null,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
mode: "multipart",
|
||||||
|
key,
|
||||||
|
uploadId: payload.uploadId,
|
||||||
|
partSize: expectedPartSize,
|
||||||
|
partCount,
|
||||||
|
parts,
|
||||||
|
uploadedParts,
|
||||||
|
resumed: true,
|
||||||
|
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||||
|
clientUploadId: payload.clientUploadId || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const head = await client
|
||||||
|
.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
|
||||||
|
.catch(() => null);
|
||||||
|
if (head?.ContentLength === payload.size) {
|
||||||
|
return NextResponse.json({
|
||||||
|
mode: "completed",
|
||||||
|
key,
|
||||||
|
clientUploadId: payload.clientUploadId || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.warn("direct_upload_resume_session_missing", error, {
|
||||||
|
clientUploadId: payload.clientUploadId || null,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "续传会话已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.action === "abort") {
|
if (payload.action === "abort") {
|
||||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||||
const abortStartedAt = Date.now();
|
const abortStartedAt = Date.now();
|
||||||
@ -329,12 +497,18 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||||
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
||||||
if (!isAllowedDirectUpload(contentType)) {
|
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Direct upload only supports JPG, PNG and WebP images" },
|
{
|
||||||
|
error:
|
||||||
|
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||||
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||||
|
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.uploadId) {
|
if (payload.uploadId) {
|
||||||
const completeMultipartStartedAt = Date.now();
|
const completeMultipartStartedAt = Date.now();
|
||||||
@ -380,8 +554,10 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const actualSize =
|
const actualSize =
|
||||||
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
||||||
if (actualSize !== null && actualSize <= 0) {
|
if (actualSize !== null && actualSize !== payload.size) {
|
||||||
throw new Error("Uploaded object is empty");
|
throw new Error(
|
||||||
|
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = buildS3PublicUrl(target.config, key);
|
const url = buildS3PublicUrl(target.config, key);
|
||||||
@ -390,15 +566,43 @@ export async function POST(request: Request) {
|
|||||||
const assetStartedAt = Date.now();
|
const assetStartedAt = Date.now();
|
||||||
const asset = await upsertMediaAsset({
|
const asset = await upsertMediaAsset({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
mediaType: "image",
|
mediaType:
|
||||||
sourceType: "upload",
|
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||||
|
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||||
|
? "file"
|
||||||
|
: resolvedContentType.startsWith("video/")
|
||||||
|
? "video"
|
||||||
|
: "image",
|
||||||
|
sourceType:
|
||||||
|
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
|
||||||
|
? "openclaw_chat_video_upload"
|
||||||
|
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
|
||||||
|
? "openclaw_chat_file_upload"
|
||||||
|
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||||
|
? "openclaw_chat_folder_file_upload"
|
||||||
|
: "upload",
|
||||||
status: "ready",
|
status: "ready",
|
||||||
originalUrl: url,
|
originalUrl: url,
|
||||||
previewUrl: url,
|
previewUrl:
|
||||||
thumbnailUrl: url,
|
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||||
|
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||||
|
? null
|
||||||
|
: url,
|
||||||
|
thumbnailUrl:
|
||||||
|
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||||
|
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||||
|
? null
|
||||||
|
: url,
|
||||||
mimeType: resolvedContentType,
|
mimeType: resolvedContentType,
|
||||||
displayName: payload.filename,
|
displayName: payload.filename,
|
||||||
sourceTable: "direct_upload",
|
sourceTable:
|
||||||
|
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
|
||||||
|
? "openclaw_chat_video_upload"
|
||||||
|
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
|
||||||
|
? "openclaw_chat_file_upload"
|
||||||
|
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||||
|
? "openclaw_chat_folder_file_upload"
|
||||||
|
: "direct_upload",
|
||||||
metadata: {
|
metadata: {
|
||||||
pathname: key,
|
pathname: key,
|
||||||
size: actualSize ?? payload.size,
|
size: actualSize ?? payload.size,
|
||||||
@ -445,7 +649,13 @@ export async function POST(request: Request) {
|
|||||||
error: "Direct upload failed",
|
error: "Direct upload failed",
|
||||||
details: error instanceof Error ? error.message : String(error),
|
details: error instanceof Error ? error.message : String(error),
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{
|
||||||
|
status:
|
||||||
|
error instanceof Error &&
|
||||||
|
error.name === "InvalidUploadObjectKeyError"
|
||||||
|
? 403
|
||||||
|
: 500,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,24 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
|
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
|
||||||
import { upsertMediaAsset } from "@/lib/db/queries";
|
import { getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
|
||||||
import { isSiteAdminType } from "@/lib/middleware/admin";
|
import { isSiteAdminType } from "@/lib/middleware/admin";
|
||||||
import { getContentType, uploadFile } from "@/lib/storage/service";
|
import { getContentType, uploadFile } from "@/lib/storage/service";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import {
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||||
|
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||||
|
} from "@/lib/video-canvas/subjects";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE,
|
||||||
|
VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE,
|
||||||
|
} from "@/lib/video-canvas/uploaded-resource";
|
||||||
|
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
|
||||||
const logger = createLogger("upload-api");
|
const logger = createLogger("upload-api");
|
||||||
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
||||||
@ -18,11 +32,29 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
|||||||
"image/heic",
|
"image/heic",
|
||||||
"image/heif",
|
"image/heif",
|
||||||
]);
|
]);
|
||||||
|
const ALLOWED_UPLOAD_AUDIO_TYPES = new Set([
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp3",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/x-wav",
|
||||||
|
"audio/webm",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/aac",
|
||||||
|
]);
|
||||||
|
const ALLOWED_UPLOAD_VIDEO_TYPES = new Set([
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/webm",
|
||||||
|
"video/x-m4v",
|
||||||
|
"video/x-matroska",
|
||||||
|
]);
|
||||||
|
|
||||||
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
||||||
|
const GAUSSIAN_SPLAT_EXTENSION_RE = /\.(ply|splat|spz|ksplat)$/i;
|
||||||
|
|
||||||
function normalizeUploadContentType(contentType: string, filename: string) {
|
function normalizeUploadContentType(contentType: string, filename: string) {
|
||||||
const normalized = contentType.trim().toLowerCase();
|
const normalized = contentType.split(";", 1)[0].trim().toLowerCase();
|
||||||
if (normalized && normalized !== "application/octet-stream") {
|
if (normalized && normalized !== "application/octet-stream") {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@ -30,6 +62,15 @@ function normalizeUploadContentType(contentType: string, filename: string) {
|
|||||||
return getContentType(filename);
|
return getContentType(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAcceptedMimeTypes(accept: string) {
|
||||||
|
return new Set(
|
||||||
|
accept
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter((item) => item.includes("/") && !item.endsWith("/*")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createUnsupportedImageTypeMessage(contentType: string, filename: string) {
|
function createUnsupportedImageTypeMessage(contentType: string, filename: string) {
|
||||||
const extension = filename.toLowerCase().split(".").pop() || "unknown";
|
const extension = filename.toLowerCase().split(".").pop() || "unknown";
|
||||||
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
||||||
@ -154,23 +195,16 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use Blob instead of File since File is not available in Node.js environment
|
// Use Blob instead of File since File is not available in Node.js environment
|
||||||
const FileSchema = z.object({
|
function createFileSchema(maxBytes: number, maxSizeLabel: string) {
|
||||||
file: z
|
return z.object({
|
||||||
.instanceof(Blob)
|
file: z.instanceof(Blob).refine((value) => value.size <= maxBytes, {
|
||||||
.refine((file) => file.size <= 50 * 1024 * 1024, {
|
message: `File size should be less than ${maxSizeLabel}`,
|
||||||
message: "File size should be less than 50MB",
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
logger.debug("[Upload API] Received upload request");
|
logger.debug("[Upload API] Received upload request");
|
||||||
|
|
||||||
const session = await auth();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
logger.debug("[Upload API] Unauthorized - no session");
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.body === null) {
|
if (request.body === null) {
|
||||||
logger.debug("[Upload API] Request body is empty");
|
logger.debug("[Upload API] Request body is empty");
|
||||||
@ -197,12 +231,88 @@ export async function POST(request: Request) {
|
|||||||
let filename = originalFilename;
|
let filename = originalFilename;
|
||||||
let contentType = originalContentType;
|
let contentType = originalContentType;
|
||||||
const uploadPurpose = String(formData.get("purpose") || "");
|
const uploadPurpose = String(formData.get("purpose") || "");
|
||||||
|
const isDirectorGaussianUpload = uploadPurpose === "director-gaussian";
|
||||||
|
const isCanvasAudioUpload = uploadPurpose === "canvas-audio-process";
|
||||||
|
const isCanvasVideoUpload = uploadPurpose === "canvas-video-process";
|
||||||
|
const isOpenClawChatVideoUpload =
|
||||||
|
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE;
|
||||||
|
const isVideoCanvasAssetLibraryUpload =
|
||||||
|
uploadPurpose === "video-canvas-asset-library";
|
||||||
|
const isVideoCanvasResourceUpload =
|
||||||
|
uploadPurpose === VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE;
|
||||||
|
const isVideoCanvasSubjectUpload =
|
||||||
|
uploadPurpose === "video-canvas-subject";
|
||||||
|
const isVideoCanvasSubjectVoiceUpload =
|
||||||
|
uploadPurpose === "video-canvas-subject-voice";
|
||||||
|
const videoCanvasSetting =
|
||||||
|
isDirectorGaussianUpload ||
|
||||||
|
isVideoCanvasResourceUpload ||
|
||||||
|
isVideoCanvasSubjectUpload
|
||||||
|
? await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
|
||||||
|
() => undefined,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const videoCanvasConfig = videoCanvasSetting
|
||||||
|
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const gaussianConfig = videoCanvasConfig.directorDesk.gaussian;
|
||||||
|
const subjectLibraryConfig = videoCanvasConfig.subjectLibrary;
|
||||||
|
if (
|
||||||
|
isDirectorGaussianUpload &&
|
||||||
|
!GAUSSIAN_SPLAT_EXTENSION_RE.test(originalFilename)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `高斯泼溅仅支持 ${gaussianConfig.uploadAccept}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isVideoCanvasSubjectUpload) {
|
||||||
|
const acceptedTypes = new Set([
|
||||||
|
...getAcceptedMimeTypes(subjectLibraryConfig.imageAccept),
|
||||||
|
...getAcceptedMimeTypes(subjectLibraryConfig.videoAccept),
|
||||||
|
]);
|
||||||
|
if (!acceptedTypes.has(originalContentType)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "主体素材仅支持 JPG、PNG、WebP、MP4 或 MOV" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isVideoCanvasSubjectVoiceUpload &&
|
||||||
|
!ALLOWED_UPLOAD_AUDIO_TYPES.has(originalContentType) &&
|
||||||
|
!ALLOWED_UPLOAD_VIDEO_TYPES.has(originalContentType)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "音色素材仅支持 MP3、WAV、MP4 或 MOV" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const requestHostname = new URL(request.url).hostname.toLowerCase();
|
||||||
|
const isLoopbackRequest =
|
||||||
|
requestHostname === "localhost" ||
|
||||||
|
requestHostname === "127.0.0.1" ||
|
||||||
|
requestHostname === "::1";
|
||||||
|
const isLocalDevelopmentCanvasUpload =
|
||||||
|
(process.env.NODE_ENV !== "production" || isLoopbackRequest) &&
|
||||||
|
process.env.USE_REMOTE_STORAGE_IN_DEV !== "true" &&
|
||||||
|
(uploadPurpose === "canvas-upload" ||
|
||||||
|
isCanvasAudioUpload ||
|
||||||
|
isCanvasVideoUpload ||
|
||||||
|
isOpenClawChatVideoUpload ||
|
||||||
|
isDirectorGaussianUpload);
|
||||||
|
const session = isLocalDevelopmentCanvasUpload ? null : await auth();
|
||||||
|
|
||||||
|
if (!isLocalDevelopmentCanvasUpload && !session) {
|
||||||
|
logger.debug("[Upload API] Unauthorized - no session");
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
const clientUploadId =
|
const clientUploadId =
|
||||||
String(formData.get("clientUploadId") || "").trim() || null;
|
String(formData.get("clientUploadId") || "").trim() || null;
|
||||||
const isSvgUpload = contentType === "image/svg+xml";
|
const isSvgUpload = contentType === "image/svg+xml";
|
||||||
if (
|
if (
|
||||||
isSvgUpload &&
|
isSvgUpload &&
|
||||||
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
|
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session?.user.type))
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "SVG uploads are only allowed for administrator icon settings" },
|
{ error: "SVG uploads are only allowed for administrator icon settings" },
|
||||||
@ -216,7 +326,19 @@ export async function POST(request: Request) {
|
|||||||
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB"
|
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB"
|
||||||
});
|
});
|
||||||
|
|
||||||
const validatedFile = FileSchema.safeParse({ file });
|
const maxFileSizeMb = isDirectorGaussianUpload
|
||||||
|
? gaussianConfig.maxFileSizeMb
|
||||||
|
: isVideoCanvasResourceUpload
|
||||||
|
? videoCanvasConfig.nodeCreation.upload.maxFileSizeMb
|
||||||
|
: isVideoCanvasSubjectUpload
|
||||||
|
? originalContentType.startsWith("video/")
|
||||||
|
? subjectLibraryConfig.videoMaxFileSizeMb
|
||||||
|
: subjectLibraryConfig.imageMaxFileSizeMb
|
||||||
|
: 50;
|
||||||
|
const validatedFile = createFileSchema(
|
||||||
|
maxFileSizeMb * 1024 * 1024,
|
||||||
|
`${maxFileSizeMb}MB`,
|
||||||
|
).safeParse({ file });
|
||||||
|
|
||||||
if (!validatedFile.success) {
|
if (!validatedFile.success) {
|
||||||
const errorMessage = validatedFile.error.errors
|
const errorMessage = validatedFile.error.errors
|
||||||
@ -227,7 +349,36 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
|
if (
|
||||||
|
!isDirectorGaussianUpload &&
|
||||||
|
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
|
||||||
|
!(isCanvasAudioUpload && ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType)) &&
|
||||||
|
!(isCanvasVideoUpload && ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)) &&
|
||||||
|
!(
|
||||||
|
isOpenClawChatVideoUpload &&
|
||||||
|
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)
|
||||||
|
) &&
|
||||||
|
!(
|
||||||
|
isVideoCanvasAssetLibraryUpload &&
|
||||||
|
(ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
|
||||||
|
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
|
||||||
|
) &&
|
||||||
|
!(
|
||||||
|
isVideoCanvasResourceUpload &&
|
||||||
|
(ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) ||
|
||||||
|
ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
|
||||||
|
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
|
||||||
|
) &&
|
||||||
|
!(
|
||||||
|
isVideoCanvasSubjectUpload &&
|
||||||
|
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)
|
||||||
|
) &&
|
||||||
|
!(
|
||||||
|
isVideoCanvasSubjectVoiceUpload &&
|
||||||
|
(ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
|
||||||
|
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
|
||||||
|
)
|
||||||
|
) {
|
||||||
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
||||||
logger.warn("[Upload API] Unsupported upload content type", {
|
logger.warn("[Upload API] Unsupported upload content type", {
|
||||||
contentType,
|
contentType,
|
||||||
@ -248,7 +399,7 @@ export async function POST(request: Request) {
|
|||||||
let uploadBuffer: Buffer = Buffer.from(fileBuffer);
|
let uploadBuffer: Buffer = Buffer.from(fileBuffer);
|
||||||
let convertedFrom: string | null = null;
|
let convertedFrom: string | null = null;
|
||||||
|
|
||||||
if (isHeicUpload(contentType, filename)) {
|
if (!isDirectorGaussianUpload && isHeicUpload(contentType, filename)) {
|
||||||
try {
|
try {
|
||||||
const convertedBuffer = await convertHeicToJpeg(uploadBuffer);
|
const convertedBuffer = await convertHeicToJpeg(uploadBuffer);
|
||||||
logger.info("[Upload API] HEIC image converted to JPEG", {
|
logger.info("[Upload API] HEIC image converted to JPEG", {
|
||||||
@ -289,7 +440,7 @@ export async function POST(request: Request) {
|
|||||||
let assetId: string | null = null;
|
let assetId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const asset = await upsertMediaAsset({
|
const asset = await upsertMediaAsset({
|
||||||
userId: session.user.id,
|
userId: session?.user.id || "",
|
||||||
mediaType: "image",
|
mediaType: "image",
|
||||||
sourceType: "agent_workflow_preview_upload",
|
sourceType: "agent_workflow_preview_upload",
|
||||||
status: "ready",
|
status: "ready",
|
||||||
@ -335,7 +486,10 @@ export async function POST(request: Request) {
|
|||||||
// ✅ 优化原图(保持JPG/PNG格式)
|
// ✅ 优化原图(保持JPG/PNG格式)
|
||||||
// WebP格式不优化(用于预览图,保持原样)
|
// WebP格式不优化(用于预览图,保持原样)
|
||||||
const shouldOptimizeImage =
|
const shouldOptimizeImage =
|
||||||
|
!isCanvasAudioUpload &&
|
||||||
|
!isCanvasVideoUpload &&
|
||||||
uploadPurpose !== "canvas-upload" &&
|
uploadPurpose !== "canvas-upload" &&
|
||||||
|
!isDirectorGaussianUpload &&
|
||||||
(contentType === 'image/jpeg' || contentType === 'image/jpg' || contentType === 'image/png');
|
(contentType === 'image/jpeg' || contentType === 'image/jpg' || contentType === 'image/png');
|
||||||
|
|
||||||
if (shouldOptimizeImage) {
|
if (shouldOptimizeImage) {
|
||||||
@ -343,7 +497,13 @@ export async function POST(request: Request) {
|
|||||||
} else if (contentType === 'image/webp' || contentType === 'image/svg+xml') {
|
} else if (contentType === 'image/webp' || contentType === 'image/svg+xml') {
|
||||||
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
|
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
|
||||||
logger.debug(`[Upload] ${contentType}格式,跳过优化,直接上传`);
|
logger.debug(`[Upload] ${contentType}格式,跳过优化,直接上传`);
|
||||||
} else if (uploadPurpose === "canvas-upload") {
|
} else if (
|
||||||
|
uploadPurpose === "canvas-upload" ||
|
||||||
|
isCanvasAudioUpload ||
|
||||||
|
isCanvasVideoUpload ||
|
||||||
|
isOpenClawChatVideoUpload ||
|
||||||
|
isDirectorGaussianUpload
|
||||||
|
) {
|
||||||
logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop");
|
logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -358,33 +518,83 @@ export async function POST(request: Request) {
|
|||||||
const data = await uploadFile(filename, optimizedArrayBuffer);
|
const data = await uploadFile(filename, optimizedArrayBuffer);
|
||||||
|
|
||||||
let assetId: string | null = null;
|
let assetId: string | null = null;
|
||||||
try {
|
const isLocalDevelopmentUpload = data.pathname.startsWith("uploads/local/");
|
||||||
const asset = await upsertMediaAsset({
|
if (
|
||||||
userId: session.user.id,
|
!isLocalDevelopmentUpload ||
|
||||||
mediaType: "image",
|
isVideoCanvasResourceUpload ||
|
||||||
sourceType: "upload",
|
isVideoCanvasAssetLibraryUpload ||
|
||||||
status: "ready",
|
isVideoCanvasSubjectUpload ||
|
||||||
originalUrl: data.url,
|
isVideoCanvasSubjectVoiceUpload
|
||||||
previewUrl: data.url,
|
) {
|
||||||
thumbnailUrl: data.url,
|
try {
|
||||||
mimeType: data.contentType || contentType || null,
|
const uploadedMediaType = isDirectorGaussianUpload
|
||||||
displayName: filename,
|
? "model"
|
||||||
sourceTable: "upload",
|
: isCanvasAudioUpload || contentType.startsWith("audio/")
|
||||||
metadata: {
|
? "audio"
|
||||||
pathname: data.pathname,
|
: isCanvasVideoUpload || contentType.startsWith("video/")
|
||||||
size: optimizedBuffer.length,
|
? "video"
|
||||||
clientUploadId,
|
: "image";
|
||||||
convertedFrom,
|
const asset = await upsertMediaAsset({
|
||||||
},
|
userId: session?.user.id || "",
|
||||||
});
|
mediaType: uploadedMediaType,
|
||||||
assetId = asset?.id ?? null;
|
sourceType: isDirectorGaussianUpload
|
||||||
} catch (assetError) {
|
? "director_gaussian_upload"
|
||||||
logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
|
: isCanvasAudioUpload
|
||||||
|
? "canvas_audio_process"
|
||||||
|
: isCanvasVideoUpload
|
||||||
|
? "canvas_video_process"
|
||||||
|
: isOpenClawChatVideoUpload
|
||||||
|
? "openclaw_chat_video_upload"
|
||||||
|
: isVideoCanvasResourceUpload
|
||||||
|
? VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE
|
||||||
|
: isVideoCanvasAssetLibraryUpload
|
||||||
|
? "video_canvas_asset_library_upload"
|
||||||
|
: isVideoCanvasSubjectUpload
|
||||||
|
? VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE
|
||||||
|
: isVideoCanvasSubjectVoiceUpload
|
||||||
|
? VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE
|
||||||
|
: "upload",
|
||||||
|
status: "ready",
|
||||||
|
originalUrl: data.url,
|
||||||
|
previewUrl: isDirectorGaussianUpload ? null : data.url,
|
||||||
|
thumbnailUrl: isDirectorGaussianUpload ? null : data.url,
|
||||||
|
mimeType: contentType || data.contentType || null,
|
||||||
|
displayName: filename,
|
||||||
|
sourceTable: isDirectorGaussianUpload
|
||||||
|
? "director_gaussian_upload"
|
||||||
|
: isCanvasAudioUpload
|
||||||
|
? "canvas_audio_process"
|
||||||
|
: isCanvasVideoUpload
|
||||||
|
? "canvas_video_process"
|
||||||
|
: isOpenClawChatVideoUpload
|
||||||
|
? "openclaw_chat_video_upload"
|
||||||
|
: isVideoCanvasResourceUpload
|
||||||
|
? VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE
|
||||||
|
: isVideoCanvasSubjectUpload
|
||||||
|
? VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE
|
||||||
|
: isVideoCanvasSubjectVoiceUpload
|
||||||
|
? VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE
|
||||||
|
: "upload",
|
||||||
|
metadata: {
|
||||||
|
pathname: data.pathname,
|
||||||
|
size: optimizedBuffer.length,
|
||||||
|
clientUploadId,
|
||||||
|
convertedFrom,
|
||||||
|
purpose: uploadPurpose || null,
|
||||||
|
category:
|
||||||
|
String(formData.get("assetCategory") || "").trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assetId = asset?.id ?? null;
|
||||||
|
} catch (assetError) {
|
||||||
|
logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("[Upload API] Upload successful:", data.url);
|
logger.debug("[Upload API] Upload successful:", data.url);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...data,
|
...data,
|
||||||
|
contentType,
|
||||||
assetId,
|
assetId,
|
||||||
clientUploadId,
|
clientUploadId,
|
||||||
convertedFrom,
|
convertedFrom,
|
||||||
|
|||||||
@ -33,6 +33,11 @@ const imageGenConfigSchema = z.object({
|
|||||||
supportsNegativePrompt: z.boolean().optional(),
|
supportsNegativePrompt: z.boolean().optional(),
|
||||||
supportsWatermark: z.boolean().optional(),
|
supportsWatermark: z.boolean().optional(),
|
||||||
supportsSeed: z.boolean().optional(),
|
supportsSeed: z.boolean().optional(),
|
||||||
|
supportsQuality: z.boolean().optional(),
|
||||||
|
supportedAspectRatios: z.array(z.string().min(1).max(32)).max(32).optional(),
|
||||||
|
supportedImageSizes: z.array(z.string().min(1).max(32)).max(32).optional(),
|
||||||
|
maxReferenceImages: z.number().min(0).max(64).optional(),
|
||||||
|
maxVideoReferences: z.number().min(0).max(16).optional(),
|
||||||
ruxaTaskInputMode: z
|
ruxaTaskInputMode: z
|
||||||
.enum(["size-image", "resolution-based"])
|
.enum(["size-image", "resolution-based"])
|
||||||
.optional(),
|
.optional(),
|
||||||
@ -45,6 +50,7 @@ const imageGenConfigSchema = z.object({
|
|||||||
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
|
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
|
||||||
enableLoadingPlaceholder: z.boolean().optional(),
|
enableLoadingPlaceholder: z.boolean().optional(),
|
||||||
loadingColor: z.string().optional(),
|
loadingColor: z.string().optional(),
|
||||||
|
vip98k: z.record(z.string(), z.unknown()).optional(),
|
||||||
}).optional();
|
}).optional();
|
||||||
|
|
||||||
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
|
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
import { ChatSDKError } from "@/lib/errors";
|
||||||
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
|
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
|
||||||
|
import { VIP98K_BASE_URL } from "@/lib/ai/vip98k-models";
|
||||||
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
|
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
|
||||||
|
|
||||||
const providerTypes = [
|
const providerTypes = [
|
||||||
@ -32,6 +33,10 @@ const providerTypes = [
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
|
"visionary",
|
||||||
|
"atlascloud",
|
||||||
|
"vip98k",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"fal-video",
|
"fal-video",
|
||||||
@ -73,11 +78,15 @@ const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
|
|||||||
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
|
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
|
||||||
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
|
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
|
||||||
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
||||||
|
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
|
||||||
|
const VISIONARY_BASE_URL = "https://api.visionary.beer";
|
||||||
|
const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
|
||||||
|
const VIP98K_PROVIDER_BASE_URL = VIP98K_BASE_URL;
|
||||||
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
||||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||||
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||||
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
|
const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com";
|
||||||
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
|
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
|
||||||
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
||||||
const KREA_BASE_URL = "https://api.krea.ai";
|
const KREA_BASE_URL = "https://api.krea.ai";
|
||||||
@ -237,6 +246,14 @@ export async function POST(request: Request) {
|
|||||||
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
|
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
|
||||||
: data.type === "openai-image"
|
: data.type === "openai-image"
|
||||||
? data.baseUrl || OPENAI_IMAGE_BASE_URL
|
? data.baseUrl || OPENAI_IMAGE_BASE_URL
|
||||||
|
: data.type === "bananaimage"
|
||||||
|
? data.baseUrl || BANANAIMAGE_BASE_URL
|
||||||
|
: data.type === "visionary"
|
||||||
|
? data.baseUrl || VISIONARY_BASE_URL
|
||||||
|
: data.type === "atlascloud"
|
||||||
|
? data.baseUrl || ATLASCLOUD_BASE_URL
|
||||||
|
: data.type === "vip98k"
|
||||||
|
? data.baseUrl || VIP98K_PROVIDER_BASE_URL
|
||||||
: data.type === "stepfun-image"
|
: data.type === "stepfun-image"
|
||||||
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
||||||
: data.type === "midjourney"
|
: data.type === "midjourney"
|
||||||
@ -299,6 +316,12 @@ export async function PUT(request: Request) {
|
|||||||
...data,
|
...data,
|
||||||
apiKey: normalizedApiKey,
|
apiKey: normalizedApiKey,
|
||||||
type: normalizeProviderTypeForStorage(data.type),
|
type: normalizeProviderTypeForStorage(data.type),
|
||||||
|
baseUrl:
|
||||||
|
data.baseUrl !== undefined
|
||||||
|
? data.baseUrl
|
||||||
|
: data.type === "vip98k"
|
||||||
|
? VIP98K_PROVIDER_BASE_URL
|
||||||
|
: undefined,
|
||||||
updatedBy: session.user.id,
|
updatedBy: session.user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
155
app/api/admin/atlascloud-models/route.ts
Normal file
155
app/api/admin/atlascloud-models/route.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { fetchAtlasCloudImagePresets } from "@/lib/ai/atlascloud-models";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const result = await fetchAtlasCloudImagePresets();
|
||||||
|
return NextResponse.json({
|
||||||
|
presets: result.presets,
|
||||||
|
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||||
|
counts: {
|
||||||
|
total: result.catalogCount,
|
||||||
|
available: result.presets.length,
|
||||||
|
failed: result.errors.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Atlas Cloud Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to fetch Atlas Cloud image models",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const requestedModelIds = Array.isArray(body?.modelIds)
|
||||||
|
? new Set(
|
||||||
|
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "atlascloud") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be an Atlas Cloud provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await fetchAtlasCloudImagePresets();
|
||||||
|
const presets = requestedModelIds
|
||||||
|
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||||
|
: catalog.presets;
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
const errors = [...catalog.errors];
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
try {
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const values = {
|
||||||
|
displayName: preset.displayName,
|
||||||
|
avatarUrl: preset.avatarUrl || null,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities,
|
||||||
|
defaultParams: preset.defaultParams,
|
||||||
|
imageGenConfig: preset.imageGenConfig,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set(values)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (updated) updatedModels.push(updated);
|
||||||
|
} else {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
...values,
|
||||||
|
isDefault: false,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (created) addedModels.push(created);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[Atlas Cloud Models API] Failed to sync ${preset.modelId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error instanceof Error ? error.message : "Sync failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: {
|
||||||
|
requested: presets.length,
|
||||||
|
total: catalog.catalogCount,
|
||||||
|
available: catalog.presets.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Atlas Cloud Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to sync Atlas Cloud image models",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
app/api/admin/bananaimage-models/route.ts
Normal file
128
app/api/admin/bananaimage-models/route.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
return NextResponse.json({
|
||||||
|
presets: BANANAIMAGE_MODEL_PRESETS,
|
||||||
|
counts: { total: BANANAIMAGE_MODEL_PRESETS.length },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[BananaImage Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to fetch BananaImage model presets" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const modelIds = Array.isArray(body?.modelIds)
|
||||||
|
? body.modelIds
|
||||||
|
.map((item: unknown) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "bananaimage") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a BananaImage provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = modelIds
|
||||||
|
? BANANAIMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
|
||||||
|
: BANANAIMAGE_MODEL_PRESETS;
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
const errors: { modelId: string; error: string }[] = [];
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
try {
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const values = {
|
||||||
|
displayName: preset.displayName,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities || null,
|
||||||
|
defaultParams: preset.defaultParams || null,
|
||||||
|
imageGenConfig: preset.imageGenConfig || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set(values)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (updated) updatedModels.push(updated);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
...values,
|
||||||
|
isDefault: false,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (created) addedModels.push(created);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[BananaImage Models API] Failed to sync ${preset.modelId}:`, error);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error.message || "Sync failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: { requested: presets.length, total: BANANAIMAGE_MODEL_PRESETS.length },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[BananaImage Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to sync BananaImage models" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -51,6 +51,10 @@ import {
|
|||||||
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
|
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
|
||||||
normalizeCanvasImageTaskQueueConfig,
|
normalizeCanvasImageTaskQueueConfig,
|
||||||
} from "@/lib/canvas/image-task-config";
|
} from "@/lib/canvas/image-task-config";
|
||||||
|
import {
|
||||||
|
CANVAS_LOCAL_EDIT_CONFIG_KEY,
|
||||||
|
normalizeCanvasLocalEditConfig,
|
||||||
|
} from "@/lib/canvas/local-edit-config";
|
||||||
import {
|
import {
|
||||||
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
||||||
normalizeDingTalkRobotNotificationConfig,
|
normalizeDingTalkRobotNotificationConfig,
|
||||||
@ -124,6 +128,12 @@ export async function GET(request: Request) {
|
|||||||
setting: setting ? { ...setting, value } : { key, value },
|
setting: setting ? { ...setting, value } : { key, value },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
|
||||||
|
const value = normalizeCanvasLocalEditConfig(setting?.value);
|
||||||
|
return Response.json({
|
||||||
|
setting: setting ? { ...setting, value } : { key, value },
|
||||||
|
});
|
||||||
|
}
|
||||||
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
||||||
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
|
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
@ -359,6 +369,10 @@ export async function POST(request: Request) {
|
|||||||
nextValue = normalizeCanvasImageTaskQueueConfig(value);
|
nextValue = normalizeCanvasImageTaskQueueConfig(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
|
||||||
|
nextValue = normalizeCanvasLocalEditConfig(value);
|
||||||
|
}
|
||||||
|
|
||||||
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
||||||
nextValue = normalizeDingTalkRobotNotificationConfig(value);
|
nextValue = normalizeDingTalkRobotNotificationConfig(value);
|
||||||
}
|
}
|
||||||
|
|||||||
213
app/api/admin/vip98k-models/route.ts
Normal file
213
app/api/admin/vip98k-models/route.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
fetchVip98kModelPresets,
|
||||||
|
type Vip98kModelPreset,
|
||||||
|
} from "@/lib/ai/vip98k-models";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { aiModel, type AiModel } from "@/lib/db/schema";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
function summarizeByType(presets: Vip98kModelPreset[]) {
|
||||||
|
return presets.reduce<Record<string, number>>((acc, preset) => {
|
||||||
|
const type = String(preset.type || "model");
|
||||||
|
acc[type] = (acc[type] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const providerId = String(searchParams.get("providerId") || "").trim();
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider ID is required to fetch VIP98K catalog" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "vip98k") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a VIP98K provider" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = await fetchVip98kModelPresets({
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
presets,
|
||||||
|
counts: {
|
||||||
|
total: presets.length,
|
||||||
|
byType: summarizeByType(presets),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[VIP98K Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to fetch VIP98K models",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const requestedModelIds = Array.isArray(body?.modelIds)
|
||||||
|
? new Set(
|
||||||
|
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const preserveDeletedModels = body?.preserveDeletedModels !== false;
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "vip98k") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a VIP98K provider" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogPresets = await fetchVip98kModelPresets({
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
const existingModels = await db.query.aiModel.findMany({
|
||||||
|
where: eq(aiModel.providerId, providerId),
|
||||||
|
});
|
||||||
|
const existingModelIds = new Set(existingModels.map((model) => model.modelId));
|
||||||
|
const preserveExistingCatalog =
|
||||||
|
requestedModelIds === null &&
|
||||||
|
preserveDeletedModels &&
|
||||||
|
existingModels.length > 0;
|
||||||
|
const presets = requestedModelIds
|
||||||
|
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||||
|
: preserveExistingCatalog
|
||||||
|
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||||
|
: catalogPresets;
|
||||||
|
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
const disabledModels: AiModel[] = [];
|
||||||
|
const errors: Array<{ modelId: string; error: string }> = [];
|
||||||
|
|
||||||
|
if (requestedModelIds === null) {
|
||||||
|
const catalogModelIds = new Set(catalogPresets.map((preset) => preset.modelId));
|
||||||
|
for (const existing of existingModels) {
|
||||||
|
if (catalogModelIds.has(existing.modelId)) continue;
|
||||||
|
const [disabled] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set({ isEnabled: false, isHidden: true, updatedAt: new Date() })
|
||||||
|
.where(eq(aiModel.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
if (disabled) disabledModels.push(disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
try {
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const values = {
|
||||||
|
displayName: preset.displayName,
|
||||||
|
avatarUrl: null,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled:
|
||||||
|
requestedModelIds !== null ? true : (existing?.isEnabled ?? true),
|
||||||
|
isHidden: preset.isHidden,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities,
|
||||||
|
defaultParams: preset.defaultParams,
|
||||||
|
imageGenConfig: preset.imageGenConfig,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set(values)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (updated) updatedModels.push(updated);
|
||||||
|
} else {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
...values,
|
||||||
|
isDefault: false,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (created) addedModels.push(created);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[VIP98K Models API] Failed to sync ${preset.modelId}:`, error);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error instanceof Error ? error.message : "Sync failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
disabledCount: disabledModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
disabledModelIds: disabledModels.map((model) => model.modelId),
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: {
|
||||||
|
requested: presets.length,
|
||||||
|
total: catalogPresets.length,
|
||||||
|
byType: summarizeByType(catalogPresets),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[VIP98K Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to sync VIP98K models",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/api/admin/visionary-models/route.ts
Normal file
139
app/api/admin/visionary-models/route.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { VISIONARY_MODEL_PRESETS } from "@/lib/ai/visionary-models";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getAiProviderById } from "@/lib/db/queries";
|
||||||
|
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||||
|
import { requireAdmin } from "@/lib/middleware/admin";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
return NextResponse.json({
|
||||||
|
presets: VISIONARY_MODEL_PRESETS,
|
||||||
|
counts: { total: VISIONARY_MODEL_PRESETS.length },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[Visionary Models API] GET error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to fetch Visionary model presets" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const providerId = String(body?.providerId || "").trim();
|
||||||
|
const modelIds = Array.isArray(body?.modelIds)
|
||||||
|
? body.modelIds
|
||||||
|
.map((item: unknown) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getAiProviderById({ id: providerId });
|
||||||
|
if (!provider || provider.type !== "visionary") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider must be a Visionary provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = modelIds
|
||||||
|
? VISIONARY_MODEL_PRESETS.filter((preset) =>
|
||||||
|
modelIds.includes(preset.modelId)
|
||||||
|
)
|
||||||
|
: VISIONARY_MODEL_PRESETS;
|
||||||
|
const addedModels: AiModel[] = [];
|
||||||
|
const updatedModels: AiModel[] = [];
|
||||||
|
const errors: { modelId: string; error: string }[] = [];
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
try {
|
||||||
|
const existing = await db.query.aiModel.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const values = {
|
||||||
|
displayName: preset.displayName,
|
||||||
|
description: preset.description,
|
||||||
|
supportsVision: preset.supportsVision,
|
||||||
|
supportsImageGen: preset.supportsImageGen,
|
||||||
|
isEnabled: true,
|
||||||
|
isHidden: false,
|
||||||
|
sortOrder: preset.sortOrder,
|
||||||
|
capabilities: preset.capabilities || null,
|
||||||
|
defaultParams: preset.defaultParams || null,
|
||||||
|
imageGenConfig: preset.imageGenConfig || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(aiModel)
|
||||||
|
.set(values)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModel.providerId, providerId),
|
||||||
|
eq(aiModel.modelId, preset.modelId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (updated) updatedModels.push(updated);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(aiModel)
|
||||||
|
.values({
|
||||||
|
providerId,
|
||||||
|
modelId: preset.modelId,
|
||||||
|
...values,
|
||||||
|
isDefault: false,
|
||||||
|
isSubModel: false,
|
||||||
|
parentModelId: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (created) addedModels.push(created);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[Visionary Models API] Failed to sync ${preset.modelId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
errors.push({
|
||||||
|
modelId: preset.modelId,
|
||||||
|
error: error.message || "Sync failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
addedCount: addedModels.length,
|
||||||
|
updatedCount: updatedModels.length,
|
||||||
|
addedModels,
|
||||||
|
updatedModels,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
counts: {
|
||||||
|
requested: presets.length,
|
||||||
|
total: VISIONARY_MODEL_PRESETS.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[Visionary Models API] POST error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to sync Visionary models" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,7 +7,10 @@ import {
|
|||||||
extractAgentProjectStateFromCanvasData,
|
extractAgentProjectStateFromCanvasData,
|
||||||
isAgentProjectRow,
|
isAgentProjectRow,
|
||||||
} from "@/lib/agents/agent-project-compat";
|
} from "@/lib/agents/agent-project-compat";
|
||||||
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
|
import {
|
||||||
|
getAllResultUrlsFromAgentProjectState,
|
||||||
|
mergeAgentProjectStateForSave,
|
||||||
|
} from "@/lib/agents/agent-project-save-merge";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
@ -79,16 +82,28 @@ export async function PUT(
|
|||||||
return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 });
|
return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = body.title !== undefined ? String(body.title || "").trim() || "未命名生图项目" : undefined;
|
const title =
|
||||||
|
body.title !== undefined
|
||||||
|
? String(body.title || "").trim() || "未命名生图项目"
|
||||||
|
: existingProject.title;
|
||||||
const description =
|
const description =
|
||||||
body.description !== undefined ? String(body.description || "").trim() || null : undefined;
|
body.description !== undefined
|
||||||
const thumbnail =
|
? String(body.description || "").trim() || null
|
||||||
body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined;
|
: existingProject.description || null;
|
||||||
|
|
||||||
|
const existingProjectState = extractAgentProjectStateFromCanvasData(existingProject.canvasData);
|
||||||
const nextProjectState =
|
const nextProjectState =
|
||||||
body.projectState !== undefined
|
body.projectState !== undefined
|
||||||
? normalizeAgentGenerationProjectPersistedState(body.projectState)
|
? mergeAgentProjectStateForSave({
|
||||||
: extractAgentProjectStateFromCanvasData(existingProject.canvasData);
|
existingState: existingProjectState,
|
||||||
|
incomingState: body.projectState,
|
||||||
|
})
|
||||||
|
: existingProjectState;
|
||||||
|
const firstResultUrl = getAllResultUrlsFromAgentProjectState(nextProjectState)[0] || null;
|
||||||
|
const requestedThumbnail =
|
||||||
|
body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined;
|
||||||
|
const thumbnail =
|
||||||
|
requestedThumbnail || existingProject.thumbnail || firstResultUrl || null;
|
||||||
|
|
||||||
const [project] = await db
|
const [project] = await db
|
||||||
.update(canvasProject)
|
.update(canvasProject)
|
||||||
|
|||||||
@ -19,6 +19,10 @@ type KittyCadTaskResponse = {
|
|||||||
output_format?: string | null;
|
output_format?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type KittyCadAsyncOperationResponse = KittyCadTaskResponse & {
|
||||||
|
type?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type ArchivedFile = {
|
type ArchivedFile = {
|
||||||
sourceHash: string;
|
sourceHash: string;
|
||||||
archivedUrl: string;
|
archivedUrl: string;
|
||||||
@ -134,6 +138,78 @@ function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractOutputEntries(task: KittyCadTaskResponse) {
|
||||||
|
return Object.entries(task.outputs || {}).filter(
|
||||||
|
(entry): entry is [string, string] =>
|
||||||
|
typeof entry[0] === "string" &&
|
||||||
|
entry[0].trim().length > 0 &&
|
||||||
|
typeof entry[1] === "string" &&
|
||||||
|
entry[1].trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichTextToCadOutputs(params: {
|
||||||
|
config: Awaited<ReturnType<typeof getKittyCadProviderConfig>>;
|
||||||
|
task: KittyCadTaskResponse;
|
||||||
|
taskId: string;
|
||||||
|
}) {
|
||||||
|
if (extractOutputEntries(params.task).length > 0) {
|
||||||
|
return params.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const asyncOperation = await kittyCadRequest<KittyCadAsyncOperationResponse>(
|
||||||
|
params.config,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/async/operations/${encodeURIComponent(params.taskId)}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extractOutputEntries(asyncOperation).length === 0) {
|
||||||
|
return params.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...params.task,
|
||||||
|
...asyncOperation,
|
||||||
|
code: asyncOperation.code || params.task.code || null,
|
||||||
|
conversation_id:
|
||||||
|
asyncOperation.conversation_id || params.task.conversation_id || null,
|
||||||
|
model_version:
|
||||||
|
asyncOperation.model_version || params.task.model_version || null,
|
||||||
|
output_format:
|
||||||
|
asyncOperation.output_format || params.task.output_format || null,
|
||||||
|
outputs: asyncOperation.outputs,
|
||||||
|
prompt: asyncOperation.prompt || params.task.prompt || null,
|
||||||
|
} satisfies KittyCadTaskResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[KittyCADStatus] async output fallback failed", {
|
||||||
|
taskId: params.taskId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return params.task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClientSafeTaskOutput(task: KittyCadTaskResponse) {
|
||||||
|
const outputEntries = extractOutputEntries(task);
|
||||||
|
const outputSummary = Object.fromEntries(
|
||||||
|
outputEntries.map(([name, base64]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
base64Length: base64.length,
|
||||||
|
extension: getFileExtension(name) || null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
outputs: outputSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function archiveOutputIfNeeded(params: {
|
async function archiveOutputIfNeeded(params: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@ -269,7 +345,7 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = await getKittyCadProviderConfig();
|
const config = await getKittyCadProviderConfig();
|
||||||
const result = await kittyCadRequest<KittyCadTaskResponse>(config, {
|
let result = await kittyCadRequest<KittyCadTaskResponse>(config, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
|
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
|
||||||
});
|
});
|
||||||
@ -287,18 +363,18 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
|||||||
conversationId: result.conversation_id || null,
|
conversationId: result.conversation_id || null,
|
||||||
modelVersion: result.model_version || null,
|
modelVersion: result.model_version || null,
|
||||||
outputFormat: result.output_format || null,
|
outputFormat: result.output_format || null,
|
||||||
output: result,
|
output: createClientSafeTaskOutput(result),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputEntries = Object.entries(result.outputs || {}).filter(
|
result = await enrichTextToCadOutputs({
|
||||||
(entry): entry is [string, string] =>
|
config,
|
||||||
typeof entry[0] === "string" &&
|
task: result,
|
||||||
entry[0].trim().length > 0 &&
|
taskId: normalizedTaskId,
|
||||||
typeof entry[1] === "string" &&
|
});
|
||||||
entry[1].trim().length > 0
|
|
||||||
);
|
const outputEntries = extractOutputEntries(result);
|
||||||
|
|
||||||
const archivedEntries = await Promise.all(
|
const archivedEntries = await Promise.all(
|
||||||
outputEntries.map(async ([name, base64]) => {
|
outputEntries.map(async ([name, base64]) => {
|
||||||
@ -365,7 +441,7 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
|||||||
outputFormat: result.output_format || null,
|
outputFormat: result.output_format || null,
|
||||||
codeUrl: codeFile?.url || null,
|
codeUrl: codeFile?.url || null,
|
||||||
downloadUrls,
|
downloadUrls,
|
||||||
output: result,
|
output: createClientSafeTaskOutput(result),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
66
app/api/ai-cad/zoo-ws-authorize/route.ts
Normal file
66
app/api/ai-cad/zoo-ws-authorize/route.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const ALLOWED_WEBSOCKET_PATHS = new Set([
|
||||||
|
"/api/ai-cad/zoo-ws/ml/copilot",
|
||||||
|
"/api/ai-cad/zoo-ws/modeling/commands",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function hasValidInternalSecret(request: Request) {
|
||||||
|
const expected = String(process.env.AI_CAD_WS_PROXY_SECRET || "").trim();
|
||||||
|
const received = String(
|
||||||
|
request.headers.get("x-hicad-ws-proxy-secret") || ""
|
||||||
|
).trim();
|
||||||
|
if (!expected || !received) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
const receivedBuffer = Buffer.from(received);
|
||||||
|
return (
|
||||||
|
expectedBuffer.length === receivedBuffer.length &&
|
||||||
|
timingSafeEqual(expectedBuffer, receivedBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!hasValidInternalSecret(request)) {
|
||||||
|
return NextResponse.json({ error: "禁止访问" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalUri = String(
|
||||||
|
request.headers.get("x-hicad-original-uri") || ""
|
||||||
|
).split("?", 1)[0];
|
||||||
|
if (!ALLOWED_WEBSOCKET_PATHS.has(originalUri)) {
|
||||||
|
return NextResponse.json({ error: "不支持的 Zoo WebSocket" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"X-Zoo-Authorization": `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Zoo WebSocket 鉴权失败",
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
app/api/ai-cad/zoo/[...path]/route.ts
Normal file
140
app/api/ai-cad/zoo/[...path]/route.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const HOP_BY_HOP_HEADERS = new Set([
|
||||||
|
"connection",
|
||||||
|
"content-length",
|
||||||
|
"host",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BLOCKED_PATH_PREFIXES = [
|
||||||
|
"/admin",
|
||||||
|
"/internal",
|
||||||
|
"/logout",
|
||||||
|
"/oauth2",
|
||||||
|
"/org/api-tokens",
|
||||||
|
"/user/api-tokens",
|
||||||
|
"/users",
|
||||||
|
];
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ path: string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBlockedPath(pathname: string) {
|
||||||
|
return BLOCKED_PATH_PREFIXES.some(
|
||||||
|
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRequestHeaders(request: Request, apiKey: string) {
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
for (const [name, value] of request.headers.entries()) {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
if (
|
||||||
|
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
||||||
|
normalizedName === "authorization" ||
|
||||||
|
normalizedName === "cookie" ||
|
||||||
|
normalizedName === "origin" ||
|
||||||
|
normalizedName === "referer" ||
|
||||||
|
normalizedName.startsWith("x-forwarded-") ||
|
||||||
|
normalizedName === "x-hicad-ws-proxy-secret"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||||
|
headers.set("Accept", request.headers.get("accept") || "application/json");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyResponseHeaders(upstream: Response) {
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
for (const [name, value] of upstream.headers.entries()) {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
if (
|
||||||
|
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
||||||
|
normalizedName === "content-encoding" ||
|
||||||
|
normalizedName === "set-cookie"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headers.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set("Cache-Control", "private, no-store");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyZooRequest(request: Request, context: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = await context.params;
|
||||||
|
const pathname = `/${path.map(encodeURIComponent).join("/")}`;
|
||||||
|
if (isBlockedPath(pathname)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "该 Zoo API 不允许通过 HiCAD 代理访问" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
const incomingUrl = new URL(request.url);
|
||||||
|
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||||
|
const upstreamUrl = `${baseUrl}${pathname}${incomingUrl.search}`;
|
||||||
|
const method = request.method.toUpperCase();
|
||||||
|
const hasBody = method !== "GET" && method !== "HEAD";
|
||||||
|
const init: RequestInit & { duplex?: "half" } = {
|
||||||
|
body: hasBody ? request.body : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
duplex: hasBody ? "half" : undefined,
|
||||||
|
headers: copyRequestHeaders(request, config.apiKey),
|
||||||
|
method,
|
||||||
|
redirect: "manual",
|
||||||
|
signal: request.signal,
|
||||||
|
};
|
||||||
|
const upstream = await fetch(upstreamUrl, init);
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
headers: copyResponseHeaders(upstream),
|
||||||
|
status: upstream.status,
|
||||||
|
statusText: upstream.statusText,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Zoo API 代理请求失败",
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DELETE = proxyZooRequest;
|
||||||
|
export const GET = proxyZooRequest;
|
||||||
|
export const HEAD = proxyZooRequest;
|
||||||
|
export const OPTIONS = proxyZooRequest;
|
||||||
|
export const PATCH = proxyZooRequest;
|
||||||
|
export const POST = proxyZooRequest;
|
||||||
|
export const PUT = proxyZooRequest;
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
findStyleProImageModelByRef,
|
findStyleProImageModelByRef,
|
||||||
getStyleProImageModelEntries,
|
getStyleProImageModelEntries,
|
||||||
} from "@/lib/style-pro/model-options";
|
} from "@/lib/style-pro/model-options";
|
||||||
|
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
prompt: z.string().min(1).max(5000),
|
prompt: z.string().min(1).max(5000),
|
||||||
@ -52,6 +53,16 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const availability = getAiModelFrontendAvailability(selectedModel.model);
|
||||||
|
if (availability.selectable === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: availability.disabledReason || "所选 AI洗图 生图模型当前不可用",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const imageSize = body.imageSize?.trim() || "2K";
|
const imageSize = body.imageSize?.trim() || "2K";
|
||||||
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
|
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import {
|
|||||||
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
|
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
|
||||||
CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||||
CANVAS_SKILL_TAGS_SETTING_KEY,
|
CANVAS_SKILL_TAGS_SETTING_KEY,
|
||||||
type CanvasSkillTag,
|
|
||||||
normalizeCanvasSkillTagSettings,
|
normalizeCanvasSkillTagSettings,
|
||||||
} from "@/lib/canvas-skill-tags";
|
} from "@/lib/canvas-skill-tags";
|
||||||
|
import { applyCanvasSkillTagOrder } from "@/lib/canvas-skill-tag-order";
|
||||||
|
|
||||||
function getUserSkillTagOrderKey(userId: string) {
|
function getUserSkillTagOrderKey(userId: string) {
|
||||||
return `${CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX}${userId}`;
|
return `${CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX}${userId}`;
|
||||||
@ -32,18 +32,6 @@ function normalizeSkillTagOrder(value: unknown) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySkillTagOrder(tags: CanvasSkillTag[], order: string[]) {
|
|
||||||
if (tags.length < 2 || order.length === 0) return tags;
|
|
||||||
|
|
||||||
const tagById = new Map(tags.map((tag) => [tag.id, tag]));
|
|
||||||
const ordered = order
|
|
||||||
.map((id) => tagById.get(id))
|
|
||||||
.filter((tag): tag is CanvasSkillTag => Boolean(tag));
|
|
||||||
const orderedIds = new Set(ordered.map((tag) => tag.id));
|
|
||||||
const remaining = tags.filter((tag) => !orderedIds.has(tag.id));
|
|
||||||
return [...ordered, ...remaining];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth().catch(() => null);
|
const session = await auth().catch(() => null);
|
||||||
@ -71,7 +59,7 @@ export async function GET() {
|
|||||||
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
|
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...settings,
|
...settings,
|
||||||
tags: applySkillTagOrder(settings.tags, userOrder),
|
tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
|
||||||
userOrder,
|
userOrder,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -133,7 +121,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
userOrder: tagIds,
|
userOrder: tagIds,
|
||||||
tags: applySkillTagOrder(settings.tags, tagIds),
|
tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CanvasSkillTags API] Save order error:", error);
|
console.error("[CanvasSkillTags API] Save order error:", error);
|
||||||
@ -152,10 +140,12 @@ export async function PUT(request: NextRequest) {
|
|||||||
const normalized = normalizeCanvasSkillTagSettings(body);
|
const normalized = normalizeCanvasSkillTagSettings(body);
|
||||||
const enabled = normalized.enabled;
|
const enabled = normalized.enabled;
|
||||||
const title = normalized.title;
|
const title = normalized.title;
|
||||||
|
const categories = normalized.categories;
|
||||||
const tags = normalized.tags;
|
const tags = normalized.tags;
|
||||||
const nextValue = {
|
const nextValue = {
|
||||||
enabled,
|
enabled,
|
||||||
title,
|
title,
|
||||||
|
categories,
|
||||||
tags,
|
tags,
|
||||||
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||||
};
|
};
|
||||||
@ -183,7 +173,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, enabled, title, tags });
|
return NextResponse.json({ success: true, enabled, title, categories, tags });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CanvasSkillTags API] Update error:", error);
|
console.error("[CanvasSkillTags API] Update error:", error);
|
||||||
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
||||||
|
|||||||
53
app/api/canvas/[id]/revision/route.ts
Normal file
53
app/api/canvas/[id]/revision/route.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
const canvasIdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const parsedId = canvasIdSchema.safeParse(rawId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "画布 ID 无效" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parsedId.data;
|
||||||
|
const [project] = await db
|
||||||
|
.select({ updatedAt: canvasProject.updatedAt })
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.id, id),
|
||||||
|
eq(canvasProject.userId, session.user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "画布不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ success: true, data: { updatedAt: project.updatedAt.toISOString() } },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,11 +3,17 @@ import { auth } from '@/app/(auth)/auth';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { canvasProject } from '@/lib/db/schema';
|
import { canvasProject } from '@/lib/db/schema';
|
||||||
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
buildStaleCanvasSaveConflictPayload,
|
buildStaleCanvasSaveConflictPayload,
|
||||||
isStaleCanvasSaveBaseRevision,
|
isStaleCanvasSaveBaseRevision,
|
||||||
} from '@/lib/canvas/canvas-save-conflict';
|
} from '@/lib/canvas/canvas-save-conflict';
|
||||||
|
import { normalizeCanvasProjectTitle } from '@/lib/canvas/project-title';
|
||||||
|
import {
|
||||||
|
getExplicitVideoCanvasWorkspaceMeta,
|
||||||
|
getVideoCanvasWorkspaceProjects,
|
||||||
|
withVideoCanvasWorkspaceMeta,
|
||||||
|
} from '@/lib/canvas/video-workspace';
|
||||||
|
|
||||||
function resolveWorkspaceType(input: unknown) {
|
function resolveWorkspaceType(input: unknown) {
|
||||||
return input === "agent" || input === "video" ? input : "canvas";
|
return input === "agent" || input === "video" ? input : "canvas";
|
||||||
@ -98,18 +104,56 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const explicitWorkspaceMeta = getExplicitVideoCanvasWorkspaceMeta(
|
||||||
|
existingProject.canvasData
|
||||||
|
);
|
||||||
|
const canvasDataWithWorkspace =
|
||||||
|
canvasData !== undefined && explicitWorkspaceMeta
|
||||||
|
? withVideoCanvasWorkspaceMeta(canvasData, explicitWorkspaceMeta)
|
||||||
|
: canvasData;
|
||||||
const syncedCanvasData =
|
const syncedCanvasData =
|
||||||
canvasData !== undefined
|
canvasDataWithWorkspace !== undefined
|
||||||
? await syncCanvasNodesForProject({
|
? await syncCanvasNodesForProject({
|
||||||
projectId: id,
|
projectId: id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
canvasData,
|
canvasData: canvasDataWithWorkspace,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 更新现有画布
|
// 更新现有画布
|
||||||
const updateData: any = { updatedAt: new Date() };
|
const updateData: any = { updatedAt: new Date() };
|
||||||
if (title !== undefined) updateData.title = title;
|
if (title !== undefined) {
|
||||||
|
updateData.title = normalizeCanvasProjectTitle(
|
||||||
|
title,
|
||||||
|
existingProject.title
|
||||||
|
);
|
||||||
|
if (existingProject.workspaceType === "video") {
|
||||||
|
const videoProjects = await db
|
||||||
|
.select({
|
||||||
|
canvasData: canvasProject.canvasData,
|
||||||
|
id: canvasProject.id,
|
||||||
|
})
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.userId, session.user.id),
|
||||||
|
eq(canvasProject.workspaceType, "video")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const workspaceProjectIds = getVideoCanvasWorkspaceProjects(
|
||||||
|
videoProjects,
|
||||||
|
existingProject
|
||||||
|
)
|
||||||
|
.map((project) => project.id)
|
||||||
|
.filter((projectId) => projectId !== id);
|
||||||
|
if (workspaceProjectIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(canvasProject)
|
||||||
|
.set({ title: updateData.title, updatedAt: new Date() })
|
||||||
|
.where(inArray(canvasProject.id, workspaceProjectIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (description !== undefined) updateData.description = description;
|
if (description !== undefined) updateData.description = description;
|
||||||
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
|
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
|
||||||
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
|
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
|
||||||
@ -137,23 +181,56 @@ export async function PUT(
|
|||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 创建新画布(使用指定 ID)
|
const nextWorkspaceType = workspaceType || "canvas";
|
||||||
|
const nextCanvasData = syncedCanvasData || {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
};
|
||||||
|
const nextTitle = normalizeCanvasProjectTitle(
|
||||||
|
title,
|
||||||
|
getDefaultWorkspaceTitle(nextWorkspaceType)
|
||||||
|
);
|
||||||
|
const insertData: any = {
|
||||||
|
id, // 使用指定的 ID
|
||||||
|
userId: session.user.id,
|
||||||
|
title: nextTitle,
|
||||||
|
workspaceType: nextWorkspaceType,
|
||||||
|
canvasData: nextCanvasData,
|
||||||
|
};
|
||||||
|
const conflictUpdateData: any = {
|
||||||
|
title: nextTitle,
|
||||||
|
workspaceType: nextWorkspaceType,
|
||||||
|
canvasData: nextCanvasData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (description !== undefined) {
|
||||||
|
insertData.description = description;
|
||||||
|
conflictUpdateData.description = description;
|
||||||
|
}
|
||||||
|
if (thumbnail !== undefined) {
|
||||||
|
insertData.thumbnail = thumbnail;
|
||||||
|
conflictUpdateData.thumbnail = thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
|
||||||
const [newProject] = await db
|
const [newProject] = await db
|
||||||
.insert(canvasProject)
|
.insert(canvasProject)
|
||||||
.values({
|
.values(insertData)
|
||||||
id, // 使用指定的 ID
|
.onConflictDoUpdate({
|
||||||
userId: session.user.id,
|
target: canvasProject.id,
|
||||||
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
|
set: conflictUpdateData,
|
||||||
description,
|
where: eq(canvasProject.userId, session.user.id),
|
||||||
workspaceType: workspaceType || "canvas",
|
|
||||||
canvasData: syncedCanvasData || {
|
|
||||||
nodes: [],
|
|
||||||
edges: [],
|
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
if (!newProject) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '画布ID已被占用' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: newProject });
|
return NextResponse.json({ success: true, data: newProject });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
266
app/api/canvas/[id]/video-workspace/route.ts
Normal file
266
app/api/canvas/[id]/video-workspace/route.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
duplicateVideoCanvasData,
|
||||||
|
getExplicitVideoCanvasWorkspaceMeta,
|
||||||
|
getNextVideoCanvasName,
|
||||||
|
getVideoCanvasCopyName,
|
||||||
|
getVideoCanvasWorkspaceProjects,
|
||||||
|
normalizeVideoCanvasName,
|
||||||
|
resolveVideoCanvasWorkspaceMeta,
|
||||||
|
withVideoCanvasWorkspaceMeta,
|
||||||
|
} from "@/lib/canvas/video-workspace";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { syncCanvasNodesForProject } from "@/lib/db/queries";
|
||||||
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
type CanvasProjectRow = typeof canvasProject.$inferSelect;
|
||||||
|
|
||||||
|
function toCanvasItem(project: CanvasProjectRow, currentId: string) {
|
||||||
|
const meta = resolveVideoCanvasWorkspaceMeta(project);
|
||||||
|
return {
|
||||||
|
createdAt: project.createdAt.toISOString(),
|
||||||
|
id: project.id,
|
||||||
|
isCurrent: project.id === currentId,
|
||||||
|
name: meta.canvasName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWorkspaceContext(userId: string, currentId: string) {
|
||||||
|
const projects = await db
|
||||||
|
.select()
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.userId, userId),
|
||||||
|
eq(canvasProject.workspaceType, "video"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(canvasProject.createdAt));
|
||||||
|
const currentProject = projects.find((project) => project.id === currentId);
|
||||||
|
if (!currentProject) return null;
|
||||||
|
return {
|
||||||
|
currentProject,
|
||||||
|
projects: getVideoCanvasWorkspaceProjects(projects, currentProject),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureExplicitWorkspaceMetadata(projects: CanvasProjectRow[]) {
|
||||||
|
for (const project of projects) {
|
||||||
|
if (getExplicitVideoCanvasWorkspaceMeta(project.canvasData)) continue;
|
||||||
|
const canvasData = withVideoCanvasWorkspaceMeta(
|
||||||
|
project.canvasData,
|
||||||
|
resolveVideoCanvasWorkspaceMeta(project),
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(canvasProject)
|
||||||
|
.set({ canvasData })
|
||||||
|
.where(eq(canvasProject.id, project.id));
|
||||||
|
project.canvasData = canvasData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const context = await getWorkspaceContext(session.user.id, id);
|
||||||
|
if (!context) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "画布不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
canvases: context.projects.map((project) => toCanvasItem(project, id)),
|
||||||
|
currentCanvasId: id,
|
||||||
|
workspaceTitle: context.currentProject.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
if (body?.action !== "create" && body?.action !== "copy") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "不支持的画布操作" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const action = body.action;
|
||||||
|
const context = await getWorkspaceContext(session.user.id, id);
|
||||||
|
if (!context) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "画布不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await ensureExplicitWorkspaceMetadata(context.projects);
|
||||||
|
const currentMeta = resolveVideoCanvasWorkspaceMeta(context.currentProject);
|
||||||
|
const existingNames = context.projects.map(
|
||||||
|
(project) => resolveVideoCanvasWorkspaceMeta(project).canvasName,
|
||||||
|
);
|
||||||
|
const source =
|
||||||
|
action === "copy"
|
||||||
|
? context.projects.find((project) => project.id === body?.targetId)
|
||||||
|
: context.currentProject;
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "要复制的画布不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const canvasName =
|
||||||
|
action === "copy"
|
||||||
|
? getVideoCanvasCopyName(
|
||||||
|
resolveVideoCanvasWorkspaceMeta(source).canvasName,
|
||||||
|
existingNames,
|
||||||
|
)
|
||||||
|
: getNextVideoCanvasName(existingNames);
|
||||||
|
const meta = {
|
||||||
|
canvasName,
|
||||||
|
workspaceId: currentMeta.workspaceId,
|
||||||
|
};
|
||||||
|
const canvasData =
|
||||||
|
action === "copy"
|
||||||
|
? duplicateVideoCanvasData(source.canvasData, meta)
|
||||||
|
: withVideoCanvasWorkspaceMeta(null, meta);
|
||||||
|
const [insertedProject] = await db
|
||||||
|
.insert(canvasProject)
|
||||||
|
.values({
|
||||||
|
canvasData,
|
||||||
|
description: context.currentProject.description,
|
||||||
|
title: context.currentProject.title,
|
||||||
|
userId: session.user.id,
|
||||||
|
workspaceType: "video",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const syncedCanvasData = await syncCanvasNodesForProject({
|
||||||
|
canvasData,
|
||||||
|
projectId: insertedProject.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const [createdProject] = await db
|
||||||
|
.update(canvasProject)
|
||||||
|
.set({ canvasData: syncedCanvasData })
|
||||||
|
.where(eq(canvasProject.id, insertedProject.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: toCanvasItem(createdProject || insertedProject, id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const context = await getWorkspaceContext(session.user.id, id);
|
||||||
|
if (!context) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "画布不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const target = context.projects.find((project) => project.id === body?.targetId);
|
||||||
|
if (!target) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "画布不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentMeta = resolveVideoCanvasWorkspaceMeta(context.currentProject);
|
||||||
|
const targetMeta = resolveVideoCanvasWorkspaceMeta(target);
|
||||||
|
const canvasName = normalizeVideoCanvasName(body?.name, targetMeta.canvasName);
|
||||||
|
const [updatedProject] = await db
|
||||||
|
.update(canvasProject)
|
||||||
|
.set({
|
||||||
|
canvasData: withVideoCanvasWorkspaceMeta(target.canvasData, {
|
||||||
|
canvasName,
|
||||||
|
workspaceId: currentMeta.workspaceId,
|
||||||
|
}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.id, target.id),
|
||||||
|
eq(canvasProject.userId, session.user.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: toCanvasItem(updatedProject, id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const context = await getWorkspaceContext(session.user.id, id);
|
||||||
|
if (!context) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "画布不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const target = context.projects.find((project) => project.id === body?.targetId);
|
||||||
|
if (!target) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "画布不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (context.projects.length <= 1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "至少保留一个画布" },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.delete(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.id, target.id),
|
||||||
|
eq(canvasProject.userId, session.user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const remaining = context.projects.filter((project) => project.id !== target.id);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
nextCanvasId: target.id === id ? remaining[0]?.id || null : id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
16
app/api/canvas/audio-source/route.ts
Normal file
16
app/api/canvas/audio-source/route.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { proxyCanvasAudioSource } from "@/lib/video-canvas/audio-source-proxy";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const requestHostname = new URL(request.url).hostname.toLowerCase();
|
||||||
|
const isLoopbackRequest =
|
||||||
|
requestHostname === "localhost" ||
|
||||||
|
requestHostname === "127.0.0.1" ||
|
||||||
|
requestHostname === "::1";
|
||||||
|
const session = isLoopbackRequest ? null : await auth();
|
||||||
|
if (!isLoopbackRequest && !session?.user?.id) {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyCanvasAudioSource(request);
|
||||||
|
}
|
||||||
@ -1,11 +1,27 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getChatById } from "@/lib/db/queries";
|
import { getChatById, getSystemSetting } from "@/lib/db/queries";
|
||||||
import {
|
import {
|
||||||
buildClawdCancelKey,
|
buildClawdCancelKey,
|
||||||
cancelClawdRequest,
|
cancelClawdRequest,
|
||||||
} from "@/lib/openclaw-chat/cancel-registry";
|
} from "@/lib/openclaw-chat/cancel-registry";
|
||||||
|
import {
|
||||||
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
import {
|
||||||
|
normalizeOpenClawChatConfig,
|
||||||
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
} from "@/lib/openclaw-chat/config";
|
||||||
|
import {
|
||||||
|
deletePersistedClawdTask,
|
||||||
|
getPersistedClawdTask,
|
||||||
|
} from "@/lib/openclaw-chat/task-store";
|
||||||
|
import {
|
||||||
|
getPersistedUpstreamTaskState,
|
||||||
|
stopOpenClawDurableTask,
|
||||||
|
} from "@/lib/openclaw-chat/upstream-task-client";
|
||||||
|
|
||||||
const cancelSchema = z.object({
|
const cancelSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
@ -15,7 +31,13 @@ const cancelSchema = z.object({
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth().catch(() => null);
|
const session = await auth().catch(() => null);
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
|
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
|
||||||
@ -28,13 +50,43 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = cancelClawdRequest(
|
const cancelKey = buildClawdCancelKey({
|
||||||
buildClawdCancelKey({
|
userId: session.user.id,
|
||||||
userId: session.user.id,
|
chatId: parsed.data.id,
|
||||||
chatId: parsed.data.id,
|
userMessageId: parsed.data.userMessageId,
|
||||||
userMessageId: parsed.data.userMessageId,
|
});
|
||||||
})
|
const task = await getPersistedClawdTask({
|
||||||
);
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: parsed.data.userMessageId,
|
||||||
|
}).catch(() => null);
|
||||||
|
const active = cancelClawdRequest(cancelKey);
|
||||||
|
const upstreamTask = task
|
||||||
|
? getPersistedUpstreamTaskState(task.metadata)
|
||||||
|
: null;
|
||||||
|
if (upstreamTask) {
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const config = normalizeOpenClawChatConfig(setting?.value);
|
||||||
|
if (config.enabled && config.baseUrl && config.token) {
|
||||||
|
await stopOpenClawDurableTask({ config, upstreamTask }).catch((error) => {
|
||||||
|
console.warn("[ClawdChat Cancel] Upstream stop deferred:", {
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
taskId: upstreamTask.taskId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ canceled: true, active });
|
const deleted = await deletePersistedClawdTask({
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: parsed.data.userMessageId,
|
||||||
|
}).catch(() => []);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
canceled: true,
|
||||||
|
active,
|
||||||
|
deleted: deleted.length > 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
141
app/api/canvas/clawd-chat/folder-upload/route.ts
Normal file
141
app/api/canvas/clawd-chat/folder-upload/route.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import JSZip from "jszip";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getAllStorageConfigs } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
countOpenClawArchiveRootFolders,
|
||||||
|
sanitizeOpenClawArchiveEntryPath,
|
||||||
|
sanitizeOpenClawArchiveName,
|
||||||
|
} from "@/lib/openclaw-chat/folder-archive";
|
||||||
|
import {
|
||||||
|
OPENCLAW_CHAT_FOLDER_MAX_FILES,
|
||||||
|
} from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import {
|
||||||
|
toDbS3UploadConfig,
|
||||||
|
uploadFileWithS3Config,
|
||||||
|
} from "@/lib/storage/service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 600;
|
||||||
|
|
||||||
|
const logger = createLogger("openclaw-folder-upload");
|
||||||
|
const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
|
||||||
|
|
||||||
|
function isUploadedFile(value: FormDataEntryValue): value is File {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"arrayBuffer" in value &&
|
||||||
|
"name" in value &&
|
||||||
|
"size" in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(buffer: Buffer) {
|
||||||
|
return buffer.buffer.slice(
|
||||||
|
buffer.byteOffset,
|
||||||
|
buffer.byteOffset + buffer.byteLength
|
||||||
|
) as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const files = formData
|
||||||
|
.getAll("files")
|
||||||
|
.filter((entry): entry is File => isUploadedFile(entry));
|
||||||
|
const relativePaths = formData
|
||||||
|
.getAll("relativePaths")
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : ""));
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "所选文件夹中没有可上传文件" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `单次最多打包 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件` },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||||
|
if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivePaths = files.map((file, index) =>
|
||||||
|
sanitizeOpenClawArchiveEntryPath(
|
||||||
|
relativePaths[index] || file.name,
|
||||||
|
file.name,
|
||||||
|
index
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const zip = new JSZip();
|
||||||
|
for (const [index, file] of files.entries()) {
|
||||||
|
zip.file(archivePaths[index], await file.arrayBuffer(), {
|
||||||
|
date: new Date(file.lastModified || Date.now()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveBaseName = sanitizeOpenClawArchiveName(
|
||||||
|
String(formData.get("archiveName") || archivePaths[0]?.split("/")[0] || "")
|
||||||
|
);
|
||||||
|
const archiveName = `${archiveBaseName.replace(/\.zip$/i, "")}.zip`;
|
||||||
|
const archiveBuffer = await zip.generateAsync({
|
||||||
|
type: "nodebuffer",
|
||||||
|
compression: "DEFLATE",
|
||||||
|
compressionOptions: { level: 6 },
|
||||||
|
platform: "UNIX",
|
||||||
|
});
|
||||||
|
|
||||||
|
const storageConfigs = await getAllStorageConfigs();
|
||||||
|
const qiniuStorage = storageConfigs
|
||||||
|
.filter((config) => config.type === "qiniu-kodo" && config.isEnabled)
|
||||||
|
.sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
|
||||||
|
.find((config) => toDbS3UploadConfig(config));
|
||||||
|
const qiniuConfig = toDbS3UploadConfig(qiniuStorage);
|
||||||
|
if (!qiniuConfig) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "系统尚未配置可用的七牛云储存桶" },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploaded = await uploadFileWithS3Config(
|
||||||
|
archiveName,
|
||||||
|
toArrayBuffer(archiveBuffer),
|
||||||
|
qiniuConfig
|
||||||
|
);
|
||||||
|
if (!/^https?:\/\//i.test(uploaded.url)) {
|
||||||
|
throw new Error("七牛云未返回可访问的公网地址");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
url: uploaded.url,
|
||||||
|
name: archiveName,
|
||||||
|
contentType: "application/zip",
|
||||||
|
size: archiveBuffer.byteLength,
|
||||||
|
originalSize,
|
||||||
|
fileCount: files.length,
|
||||||
|
folderCount: countOpenClawArchiveRootFolders(archivePaths),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to package and upload Claw folders", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : "文件夹上传失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getClawdChatsByUserId } from "@/lib/db/queries";
|
import { getClawdChatsByUserId } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
@ -9,7 +13,13 @@ export async function GET(request: Request) {
|
|||||||
const session = await auth().catch(() => null);
|
const session = await auth().catch(() => null);
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|||||||
@ -1,34 +1,59 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { after, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import {
|
import {
|
||||||
getChatById,
|
getChatById,
|
||||||
getMessagesByChatId,
|
|
||||||
getSystemSetting,
|
getSystemSetting,
|
||||||
getUserById,
|
getUserById,
|
||||||
saveChat,
|
saveChat,
|
||||||
saveMessages,
|
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import {
|
|
||||||
normalizeOpenClawChatConfig,
|
|
||||||
OPENCLAW_CHAT_CONFIG_KEY,
|
|
||||||
} from "@/lib/openclaw-chat/config";
|
|
||||||
import {
|
import {
|
||||||
buildClawdCancelKey,
|
buildClawdCancelKey,
|
||||||
cancelClawdRequest,
|
|
||||||
clearClawdRequestCancellation,
|
clearClawdRequestCancellation,
|
||||||
isClawdRequestCanceled,
|
isClawdRequestCanceled,
|
||||||
registerActiveClawdRequest,
|
registerActiveClawdRequest,
|
||||||
unregisterActiveClawdRequest,
|
unregisterActiveClawdRequest,
|
||||||
} from "@/lib/openclaw-chat/cancel-registry";
|
} from "@/lib/openclaw-chat/cancel-registry";
|
||||||
|
import {
|
||||||
|
normalizeOpenClawChatConfig,
|
||||||
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
} from "@/lib/openclaw-chat/config";
|
||||||
|
import {
|
||||||
|
getOpenClawTaskFailureMessage,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
||||||
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
import {
|
||||||
|
normalizeOpenClawChatMediaAttachments,
|
||||||
|
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
|
||||||
|
type OpenClawChatMediaAttachment,
|
||||||
|
} from "@/lib/openclaw-chat/media-attachments";
|
||||||
import {
|
import {
|
||||||
buildOpenClawDisplayMessage,
|
buildOpenClawDisplayMessage,
|
||||||
buildOpenClawMessageWithSpreadsheetMarkdown,
|
buildOpenClawMessageWithSpreadsheetMarkdown,
|
||||||
buildOpenClawSpreadsheetMarkdown,
|
buildOpenClawSpreadsheetMarkdown,
|
||||||
} from "@/lib/openclaw-chat/spreadsheet-markdown";
|
} from "@/lib/openclaw-chat/spreadsheet-markdown";
|
||||||
|
import {
|
||||||
|
appendPersistedClawdTaskProgress,
|
||||||
|
getPersistedClawdTask,
|
||||||
|
markClawdTaskCanceled,
|
||||||
|
markClawdTaskCompleted,
|
||||||
|
markClawdTaskFailed,
|
||||||
|
markClawdTaskRunning,
|
||||||
|
persistPendingClawdTask,
|
||||||
|
} from "@/lib/openclaw-chat/task-store";
|
||||||
|
import {
|
||||||
|
OpenClawTaskDetachedError,
|
||||||
|
submitOpenClawDurableTask,
|
||||||
|
waitForOpenClawDurableTask,
|
||||||
|
} from "@/lib/openclaw-chat/upstream-task-client";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试";
|
const DEFAULT_PROCESSING_HINT =
|
||||||
|
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
||||||
|
|
||||||
|
export const maxDuration = 1800;
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
@ -36,12 +61,63 @@ const requestSchema = z.object({
|
|||||||
displayMessage: z.string().trim().max(8000).optional(),
|
displayMessage: z.string().trim().max(8000).optional(),
|
||||||
userMessageId: z.string().uuid().optional(),
|
userMessageId: z.string().uuid().optional(),
|
||||||
metadata: z.record(z.unknown()).optional(),
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
sensitiveInput: z
|
||||||
|
.discriminatedUnion("type", [
|
||||||
|
z.object({
|
||||||
|
type: z.literal("taobao_credentials"),
|
||||||
|
username: z.string().min(1).max(160),
|
||||||
|
password: z.string().min(1).max(256),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("taobao_verification"),
|
||||||
|
verification_code: z.string().min(4).max(12),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
attachments: z
|
||||||
|
.array(z.unknown())
|
||||||
|
.max(OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS)
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ParsedClawdChatRequest = z.infer<typeof requestSchema> & {
|
type ParsedClawdChatRequest = Omit<
|
||||||
|
z.infer<typeof requestSchema>,
|
||||||
|
"attachments"
|
||||||
|
> & {
|
||||||
|
attachments: OpenClawChatMediaAttachment[];
|
||||||
tableFiles: File[];
|
tableFiles: File[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClawdSpreadsheetAttachment = {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
sheetCount: number;
|
||||||
|
rowCount: number;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecuteClawdTaskParams = {
|
||||||
|
assistantMessageId: string;
|
||||||
|
cancelKey: string;
|
||||||
|
chatId: string;
|
||||||
|
config: { baseUrl: string; token: string; timeoutMs: number };
|
||||||
|
conversationId: string;
|
||||||
|
departmentName: string;
|
||||||
|
metadata: Record<string, unknown> | undefined;
|
||||||
|
sensitiveInput?: z.infer<typeof requestSchema>["sensitiveInput"];
|
||||||
|
mediaAttachments: OpenClawChatMediaAttachment[];
|
||||||
|
skillTagId: string;
|
||||||
|
skillTagName: string;
|
||||||
|
spreadsheetFiles: unknown[];
|
||||||
|
spreadsheetMarkdownTruncated: boolean;
|
||||||
|
upstreamMessage: string;
|
||||||
|
userId: string;
|
||||||
|
userMessageId: string;
|
||||||
|
userName: string;
|
||||||
|
};
|
||||||
|
|
||||||
function buildChatTitle(message: string) {
|
function buildChatTitle(message: string) {
|
||||||
const normalized = message.replace(/\s+/g, " ").trim();
|
const normalized = message.replace(/\s+/g, " ").trim();
|
||||||
if (!normalized) return "Claw 对话";
|
if (!normalized) return "Claw 对话";
|
||||||
@ -57,17 +133,15 @@ function getOpenClawUserName(user: {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const name = String(user.name || "").trim();
|
return String(user.name || user.email || user.id).trim();
|
||||||
if (name) return name;
|
|
||||||
|
|
||||||
const email = String(user.email || "").trim();
|
|
||||||
if (email) return email;
|
|
||||||
|
|
||||||
return user.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpenClawDepartmentName(department?: string | null) {
|
function getMetadataString(
|
||||||
return String(department || "").trim();
|
metadata: Record<string, unknown> | undefined,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
const value = metadata?.[key];
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRequestFile(value: FormDataEntryValue): value is File {
|
function isRequestFile(value: FormDataEntryValue): value is File {
|
||||||
@ -86,25 +160,39 @@ async function parseClawdChatRequest(
|
|||||||
const contentType = request.headers.get("content-type") || "";
|
const contentType = request.headers.get("content-type") || "";
|
||||||
if (!contentType.includes("multipart/form-data")) {
|
if (!contentType.includes("multipart/form-data")) {
|
||||||
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
||||||
return parsed.success ? { ...parsed.data, tableFiles: [] } : null;
|
if (!parsed.success) return null;
|
||||||
|
const attachments = normalizeOpenClawChatMediaAttachments(
|
||||||
|
parsed.data.attachments
|
||||||
|
);
|
||||||
|
if (attachments.length !== parsed.data.attachments.length) return null;
|
||||||
|
return { ...parsed.data, attachments, tableFiles: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData().catch(() => null);
|
const formData = await request.formData().catch(() => null);
|
||||||
if (!formData) return null;
|
if (!formData) return null;
|
||||||
|
|
||||||
const metadataValue = formData.get("metadata");
|
|
||||||
let metadata: Record<string, unknown> | undefined;
|
let metadata: Record<string, unknown> | undefined;
|
||||||
if (typeof metadataValue === "string" && metadataValue.trim()) {
|
let attachments: unknown[] = [];
|
||||||
try {
|
let sensitiveInput: unknown;
|
||||||
|
const metadataValue = formData.get("metadata");
|
||||||
|
const attachmentsValue = formData.get("attachments");
|
||||||
|
const sensitiveInputValue = formData.get("sensitiveInput");
|
||||||
|
try {
|
||||||
|
if (typeof metadataValue === "string" && metadataValue.trim()) {
|
||||||
const parsedMetadata = JSON.parse(metadataValue);
|
const parsedMetadata = JSON.parse(metadataValue);
|
||||||
if (parsedMetadata && typeof parsedMetadata === "object") {
|
if (!parsedMetadata || typeof parsedMetadata !== "object") return null;
|
||||||
metadata = parsedMetadata as Record<string, unknown>;
|
metadata = parsedMetadata as Record<string, unknown>;
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
if (typeof attachmentsValue === "string" && attachmentsValue.trim()) {
|
||||||
|
const parsedAttachments = JSON.parse(attachmentsValue);
|
||||||
|
if (!Array.isArray(parsedAttachments)) return null;
|
||||||
|
attachments = parsedAttachments;
|
||||||
|
}
|
||||||
|
if (typeof sensitiveInputValue === "string" && sensitiveInputValue.trim()) {
|
||||||
|
sensitiveInput = JSON.parse(sensitiveInputValue);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = requestSchema.safeParse({
|
const parsed = requestSchema.safeParse({
|
||||||
@ -113,68 +201,275 @@ async function parseClawdChatRequest(
|
|||||||
displayMessage: formData.get("displayMessage") || undefined,
|
displayMessage: formData.get("displayMessage") || undefined,
|
||||||
userMessageId: formData.get("userMessageId") || undefined,
|
userMessageId: formData.get("userMessageId") || undefined,
|
||||||
metadata,
|
metadata,
|
||||||
|
attachments,
|
||||||
|
sensitiveInput,
|
||||||
});
|
});
|
||||||
if (!parsed.success) return null;
|
if (!parsed.success) return null;
|
||||||
|
const normalizedAttachments = normalizeOpenClawChatMediaAttachments(
|
||||||
|
parsed.data.attachments
|
||||||
|
);
|
||||||
|
if (normalizedAttachments.length !== parsed.data.attachments.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const tableFiles = formData
|
return {
|
||||||
.getAll("tableFiles")
|
...parsed.data,
|
||||||
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0);
|
attachments: normalizedAttachments,
|
||||||
|
tableFiles: formData
|
||||||
return { ...parsed.data, tableFiles };
|
.getAll("tableFiles")
|
||||||
|
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readOpenClawConfig() {
|
async function readOpenClawConfig() {
|
||||||
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
|
const setting = await getSystemSetting({
|
||||||
() => null
|
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
);
|
}).catch(() => null);
|
||||||
return normalizeOpenClawChatConfig(setting?.value);
|
return normalizeOpenClawChatConfig(setting?.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpenClawErrorCode(payload: unknown) {
|
function getAnswer(payload: unknown) {
|
||||||
if (!payload || typeof payload !== "object") return "";
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
const raw = payload as Record<string, unknown>;
|
return "";
|
||||||
const nestedError =
|
}
|
||||||
raw.error && typeof raw.error === "object"
|
return String((payload as Record<string, unknown>).answer || "").trim();
|
||||||
? (raw.error as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const code = nestedError?.code || raw.code;
|
|
||||||
return typeof code === "string" ? code.trim() : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMetadataString(
|
function getRequestId(payload: unknown) {
|
||||||
metadata: Record<string, unknown> | undefined,
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
key: string
|
return null;
|
||||||
) {
|
}
|
||||||
const value = metadata?.[key];
|
const value = (payload as Record<string, unknown>).request_id;
|
||||||
return typeof value === "string" ? value.trim() : "";
|
return value ? String(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCanceledResponse() {
|
async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
||||||
return NextResponse.json({ canceled: true }, { status: 499 });
|
const controller = new AbortController();
|
||||||
|
registerActiveClawdRequest(
|
||||||
|
params.cancelKey,
|
||||||
|
controller,
|
||||||
|
Math.max(params.config.timeoutMs, 10 * 60 * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isClawdRequestCanceled(params.cancelKey)) return;
|
||||||
|
await markClawdTaskRunning({
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const upstreamBody: Record<string, unknown> = {
|
||||||
|
conversation_id: params.conversationId,
|
||||||
|
user_id: params.userId,
|
||||||
|
user_name: params.userName,
|
||||||
|
username: params.userName,
|
||||||
|
department: params.departmentName,
|
||||||
|
department_name: params.departmentName,
|
||||||
|
user_department: params.departmentName,
|
||||||
|
skill_tag_id: params.skillTagId || undefined,
|
||||||
|
skill_tag_name: params.skillTagName || undefined,
|
||||||
|
message: params.upstreamMessage,
|
||||||
|
attachments:
|
||||||
|
params.mediaAttachments.length > 0
|
||||||
|
? params.mediaAttachments
|
||||||
|
: undefined,
|
||||||
|
metadata: {
|
||||||
|
source: "ai_chatbot_canvas",
|
||||||
|
page: "/canvas",
|
||||||
|
tenant: "default",
|
||||||
|
chat_id: params.chatId,
|
||||||
|
user_id: params.userId,
|
||||||
|
user_name: params.userName,
|
||||||
|
username: params.userName,
|
||||||
|
department: params.departmentName,
|
||||||
|
department_name: params.departmentName,
|
||||||
|
user_department: params.departmentName,
|
||||||
|
skill_tag_id: params.skillTagId || undefined,
|
||||||
|
skill_tag_name: params.skillTagName || undefined,
|
||||||
|
media_attachments:
|
||||||
|
params.mediaAttachments.length > 0
|
||||||
|
? params.mediaAttachments
|
||||||
|
: undefined,
|
||||||
|
spreadsheet_files:
|
||||||
|
params.spreadsheetFiles.length > 0
|
||||||
|
? params.spreadsheetFiles
|
||||||
|
: undefined,
|
||||||
|
spreadsheet_markdown_truncated:
|
||||||
|
params.spreadsheetMarkdownTruncated || undefined,
|
||||||
|
...params.metadata,
|
||||||
|
},
|
||||||
|
stream_progress: false,
|
||||||
|
sensitive_input: params.sensitiveInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.sensitiveInput) {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() =>
|
||||||
|
controller.abort(
|
||||||
|
new DOMException("OpenClaw request timed out", "TimeoutError")
|
||||||
|
),
|
||||||
|
params.config.timeoutMs
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${params.config.baseUrl}/api/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${params.config.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(upstreamBody),
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const payload = (await response.json().catch(() => null)) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenClaw request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!payload) throw new Error("OpenClaw returned an invalid response");
|
||||||
|
const answer = getAnswer(payload);
|
||||||
|
if (!answer) throw new Error("OpenClaw returned an empty answer");
|
||||||
|
await markClawdTaskCompleted({
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
answer,
|
||||||
|
requestId: getRequestId(payload),
|
||||||
|
});
|
||||||
|
clearClawdRequestCancellation(params.cancelKey);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
};
|
||||||
|
const upstreamTask = await submitOpenClawDurableTask({
|
||||||
|
config: params.config,
|
||||||
|
body: upstreamBody,
|
||||||
|
idempotencyKey: `${params.userId}:${params.chatId}:${params.userMessageId}`,
|
||||||
|
target,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const outcome = await waitForOpenClawDurableTask({
|
||||||
|
config: params.config,
|
||||||
|
upstreamTask,
|
||||||
|
target,
|
||||||
|
deadlineAt: Date.now() + params.config.timeoutMs,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isClawdRequestCanceled(params.cancelKey)) return;
|
||||||
|
if (outcome.status === "completed") {
|
||||||
|
await markClawdTaskCompleted({
|
||||||
|
...target,
|
||||||
|
answer: outcome.answer,
|
||||||
|
requestId: outcome.requestId,
|
||||||
|
});
|
||||||
|
} else if (outcome.status === "canceled") {
|
||||||
|
await markClawdTaskCanceled({
|
||||||
|
...target,
|
||||||
|
message: outcome.error,
|
||||||
|
});
|
||||||
|
} else if (outcome.status === "failed") {
|
||||||
|
await markClawdTaskFailed({
|
||||||
|
...target,
|
||||||
|
error: outcome.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearClawdRequestCancellation(params.cancelKey);
|
||||||
|
} catch (error) {
|
||||||
|
if (isClawdRequestCanceled(params.cancelKey)) return;
|
||||||
|
if (error instanceof OpenClawTaskDetachedError) {
|
||||||
|
await appendPersistedClawdTaskProgress({
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
progress: {
|
||||||
|
step_code: "task.detached",
|
||||||
|
step_state: "waiting",
|
||||||
|
message: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
|
||||||
|
display: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
|
||||||
|
},
|
||||||
|
}).catch(() => null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[ClawdChat API] Durable task failed:", error);
|
||||||
|
await markClawdTaskFailed({
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
error: getOpenClawTaskFailureMessage(error),
|
||||||
|
}).catch(() => null);
|
||||||
|
} finally {
|
||||||
|
if (params.sensitiveInput?.type === "taobao_credentials") {
|
||||||
|
params.sensitiveInput.username = "";
|
||||||
|
params.sensitiveInput.password = "";
|
||||||
|
} else if (params.sensitiveInput?.type === "taobao_verification") {
|
||||||
|
params.sensitiveInput.verification_code = "";
|
||||||
|
}
|
||||||
|
params.sensitiveInput = undefined;
|
||||||
|
unregisterActiveClawdRequest(params.cancelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskResponse(params: {
|
||||||
|
chatId: string;
|
||||||
|
userMessageId: string;
|
||||||
|
assistantMessageId: string;
|
||||||
|
status: string;
|
||||||
|
processingHint: string;
|
||||||
|
}) {
|
||||||
|
const statusUrl = `/api/canvas/clawd-chat/status?id=${encodeURIComponent(
|
||||||
|
params.chatId
|
||||||
|
)}&userMessageId=${encodeURIComponent(params.userMessageId)}`;
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
task: {
|
||||||
|
chatId: params.chatId,
|
||||||
|
userMessageId: params.userMessageId,
|
||||||
|
assistantMessageId: params.assistantMessageId,
|
||||||
|
status: params.status,
|
||||||
|
processingHint: params.processingHint,
|
||||||
|
statusUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth().catch(() => null);
|
const session = await auth().catch(() => null);
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = await parseClawdChatRequest(request);
|
const parsed = await parseClawdChatRequest(request);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!parsed.message && parsed.tableFiles.length === 0) {
|
if (
|
||||||
return NextResponse.json({ error: "请输入问题或上传表格" }, { status: 400 });
|
!parsed.message &&
|
||||||
|
parsed.tableFiles.length === 0 &&
|
||||||
|
parsed.attachments.length === 0
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "请输入问题或上传附件" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await readOpenClawConfig();
|
const config = await readOpenClawConfig();
|
||||||
if (!config.enabled) {
|
if (!config.enabled || !config.baseUrl || !config.token) {
|
||||||
return NextResponse.json({ error: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 });
|
|
||||||
}
|
|
||||||
if (!config.baseUrl || !config.token) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: CLAW_UPGRADE_RETRY_MESSAGE },
|
{ error: OPENCLAW_UPGRADE_RETRY_MESSAGE },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -185,13 +480,22 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userMessageId = parsed.userMessageId || generateUUID();
|
const userMessageId = parsed.userMessageId || generateUUID();
|
||||||
const cancelKey = buildClawdCancelKey({
|
const existingTask = await getPersistedClawdTask({
|
||||||
userId: session.user.id,
|
|
||||||
chatId: parsed.id,
|
chatId: parsed.id,
|
||||||
userMessageId,
|
userMessageId,
|
||||||
});
|
}).catch(() => null);
|
||||||
if (isClawdRequestCanceled(cancelKey)) {
|
if (existingTask) {
|
||||||
return createCanceledResponse();
|
return NextResponse.json(
|
||||||
|
buildTaskResponse({
|
||||||
|
chatId: parsed.id,
|
||||||
|
userMessageId,
|
||||||
|
assistantMessageId: existingTask.metadata.assistantMessageId,
|
||||||
|
status: existingTask.metadata.status,
|
||||||
|
processingHint:
|
||||||
|
existingTask.metadata.processingHint || DEFAULT_PROCESSING_HINT,
|
||||||
|
}),
|
||||||
|
{ status: 202, headers: { "Retry-After": "2" } }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let spreadsheetMarkdownResult: Awaited<
|
let spreadsheetMarkdownResult: Awaited<
|
||||||
@ -221,10 +525,23 @@ export async function POST(request: Request) {
|
|||||||
message: parsed.message,
|
message: parsed.message,
|
||||||
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
||||||
});
|
});
|
||||||
|
const spreadsheetAttachments: ClawdSpreadsheetAttachment[] = (
|
||||||
if (isClawdRequestCanceled(cancelKey)) {
|
spreadsheetMarkdownResult?.files || []
|
||||||
return createCanceledResponse();
|
).map((summary, index) => {
|
||||||
}
|
const sourceFile = parsed.tableFiles[index];
|
||||||
|
return {
|
||||||
|
name: summary.fileName,
|
||||||
|
size: sourceFile?.size || 0,
|
||||||
|
contentType:
|
||||||
|
sourceFile?.type ||
|
||||||
|
(summary.fileName.toLowerCase().endsWith(".xlsx")
|
||||||
|
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
: "text/csv"),
|
||||||
|
sheetCount: summary.sheetCount,
|
||||||
|
rowCount: summary.rowCount,
|
||||||
|
truncated: summary.truncated,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (!existingChat) {
|
if (!existingChat) {
|
||||||
await saveChat({
|
await saveChat({
|
||||||
@ -236,220 +553,107 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assistantMessageId = generateUUID();
|
const assistantMessageId = generateUUID();
|
||||||
const conversationId = createConversationId(session.user.id, parsed.id);
|
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
|
||||||
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
|
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
|
||||||
const openClawUserName = getOpenClawUserName({
|
const processingHint =
|
||||||
|
getMetadataString(parsed.metadata, "processingHint") ||
|
||||||
|
DEFAULT_PROCESSING_HINT;
|
||||||
|
const messageAttachments = parsed.attachments.map((attachment) => ({
|
||||||
|
name:
|
||||||
|
attachment.name ||
|
||||||
|
(attachment.type === "video"
|
||||||
|
? "视频附件"
|
||||||
|
: attachment.type === "file"
|
||||||
|
? "文件附件"
|
||||||
|
: "图片附件"),
|
||||||
|
url: attachment.url,
|
||||||
|
contentType:
|
||||||
|
attachment.content_type ||
|
||||||
|
(attachment.type === "video"
|
||||||
|
? "video/mp4"
|
||||||
|
: attachment.type === "file"
|
||||||
|
? "application/octet-stream"
|
||||||
|
: "image/jpeg"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const persistedTask = await persistPendingClawdTask({
|
||||||
|
chatId: parsed.id,
|
||||||
|
userMessageId,
|
||||||
|
assistantMessageId,
|
||||||
|
displayMessage,
|
||||||
|
attachments: messageAttachments,
|
||||||
|
spreadsheetAttachments,
|
||||||
|
skillTagId,
|
||||||
|
skillTagName,
|
||||||
|
processingHint,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
persistedTask &&
|
||||||
|
persistedTask.assistantMessageId !== assistantMessageId
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
buildTaskResponse({
|
||||||
|
chatId: parsed.id,
|
||||||
|
userMessageId,
|
||||||
|
assistantMessageId: persistedTask.assistantMessageId,
|
||||||
|
status: persistedTask.status,
|
||||||
|
processingHint:
|
||||||
|
persistedTask.processingHint || DEFAULT_PROCESSING_HINT,
|
||||||
|
}),
|
||||||
|
{ status: 202, headers: { "Retry-After": "2" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProfile = await getUserById({ id: session.user.id }).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
const userName = getOpenClawUserName({
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
name: userProfile?.name ?? session.user.name,
|
name: userProfile?.name ?? session.user.name,
|
||||||
email: userProfile?.email ?? session.user.email,
|
email: userProfile?.email ?? session.user.email,
|
||||||
});
|
});
|
||||||
const openClawDepartmentName = getOpenClawDepartmentName(userProfile?.department);
|
const cancelKey = buildClawdCancelKey({
|
||||||
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
|
userId: session.user.id,
|
||||||
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
|
chatId: parsed.id,
|
||||||
const endpoint = `${config.baseUrl}/api/chat`;
|
userMessageId,
|
||||||
|
});
|
||||||
|
|
||||||
let upstreamPayload: unknown = null;
|
after(async () => {
|
||||||
let upstreamStatus = 502;
|
await executeClawdTask({
|
||||||
|
assistantMessageId,
|
||||||
if (isClawdRequestCanceled(cancelKey)) {
|
cancelKey,
|
||||||
return createCanceledResponse();
|
chatId: parsed.id,
|
||||||
}
|
config,
|
||||||
|
conversationId: createConversationId(session.user.id, parsed.id),
|
||||||
try {
|
departmentName: String(userProfile?.department || "").trim(),
|
||||||
const controller = new AbortController();
|
metadata: parsed.metadata,
|
||||||
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
|
sensitiveInput: parsed.sensitiveInput,
|
||||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
mediaAttachments: parsed.attachments,
|
||||||
const handleRequestAbort = () => {
|
skillTagId,
|
||||||
cancelClawdRequest(cancelKey);
|
skillTagName,
|
||||||
};
|
spreadsheetFiles: spreadsheetMarkdownResult?.files || [],
|
||||||
request.signal.addEventListener("abort", handleRequestAbort, { once: true });
|
spreadsheetMarkdownTruncated:
|
||||||
const response = await fetch(endpoint, {
|
spreadsheetMarkdownResult?.truncated || false,
|
||||||
method: "POST",
|
upstreamMessage,
|
||||||
headers: {
|
userId: session.user.id,
|
||||||
Authorization: `Bearer ${config.token}`,
|
userMessageId,
|
||||||
"Content-Type": "application/json",
|
userName,
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
conversation_id: conversationId,
|
|
||||||
user_id: session.user.id,
|
|
||||||
user_name: openClawUserName,
|
|
||||||
username: openClawUserName,
|
|
||||||
department: openClawDepartmentName,
|
|
||||||
department_name: openClawDepartmentName,
|
|
||||||
user_department: openClawDepartmentName,
|
|
||||||
skill_tag_id: skillTagId || undefined,
|
|
||||||
skill_tag_name: skillTagName || undefined,
|
|
||||||
message: upstreamMessage,
|
|
||||||
metadata: {
|
|
||||||
source: "ai_chatbot_canvas",
|
|
||||||
page: "/canvas",
|
|
||||||
tenant: "default",
|
|
||||||
chat_id: parsed.id,
|
|
||||||
user_id: session.user.id,
|
|
||||||
user_name: openClawUserName,
|
|
||||||
username: openClawUserName,
|
|
||||||
department: openClawDepartmentName,
|
|
||||||
department_name: openClawDepartmentName,
|
|
||||||
user_department: openClawDepartmentName,
|
|
||||||
skill_tag_id: skillTagId || undefined,
|
|
||||||
skill_tag_name: skillTagName || undefined,
|
|
||||||
spreadsheet_files: spreadsheetMarkdownResult?.files || undefined,
|
|
||||||
spreadsheet_markdown_truncated:
|
|
||||||
spreadsheetMarkdownResult?.truncated || undefined,
|
|
||||||
...parsed.metadata,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
|
||||||
}).finally(() => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
request.signal.removeEventListener("abort", handleRequestAbort);
|
|
||||||
unregisterActiveClawdRequest(cancelKey);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
upstreamStatus = response.status;
|
const responseBody = buildTaskResponse({
|
||||||
upstreamPayload = await response.json().catch(() => null);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorCode = getOpenClawErrorCode(upstreamPayload);
|
|
||||||
console.error("[ClawdChat API] OpenClaw upstream error:", {
|
|
||||||
status: response.status,
|
|
||||||
code: errorCode || undefined,
|
|
||||||
payload: upstreamPayload,
|
|
||||||
});
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: CLAW_UPGRADE_RETRY_MESSAGE,
|
|
||||||
code: errorCode || undefined,
|
|
||||||
},
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
unregisterActiveClawdRequest(cancelKey);
|
|
||||||
const errorName =
|
|
||||||
error && typeof error === "object" && "name" in error
|
|
||||||
? String((error as { name?: unknown }).name || "")
|
|
||||||
: "";
|
|
||||||
const isAbortError = errorName === "AbortError";
|
|
||||||
if (isClawdRequestCanceled(cancelKey)) {
|
|
||||||
console.info("[ClawdChat API] OpenClaw request canceled:", {
|
|
||||||
chatId: parsed.id,
|
|
||||||
userMessageId,
|
|
||||||
});
|
|
||||||
return createCanceledResponse();
|
|
||||||
}
|
|
||||||
console.error("[ClawdChat API] OpenClaw request failed:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: CLAW_UPGRADE_RETRY_MESSAGE,
|
|
||||||
},
|
|
||||||
{ status: isAbortError ? 504 : upstreamStatus }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const answer =
|
|
||||||
upstreamPayload && typeof upstreamPayload === "object"
|
|
||||||
? String((upstreamPayload as Record<string, unknown>).answer || "").trim()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (!answer) {
|
|
||||||
console.error("[ClawdChat API] OpenClaw empty answer:", upstreamPayload);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: CLAW_UPGRADE_RETRY_MESSAGE },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isClawdRequestCanceled(cancelKey)) {
|
|
||||||
return createCanceledResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
|
|
||||||
() => []
|
|
||||||
);
|
|
||||||
const latestMessageTimestamp = existingMessages.reduce((latest, item) => {
|
|
||||||
const timestamp =
|
|
||||||
item.createdAt instanceof Date
|
|
||||||
? item.createdAt.getTime()
|
|
||||||
: Date.parse(String(item.createdAt || ""));
|
|
||||||
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
|
|
||||||
}, 0);
|
|
||||||
const userCreatedAt = new Date(
|
|
||||||
Math.max(Date.now(), latestMessageTimestamp + 1)
|
|
||||||
);
|
|
||||||
const assistantCreatedAt = new Date(userCreatedAt.getTime() + 1);
|
|
||||||
|
|
||||||
const userMessage = {
|
|
||||||
id: userMessageId,
|
|
||||||
chatId: parsed.id,
|
chatId: parsed.id,
|
||||||
role: "user",
|
userMessageId,
|
||||||
parts: [{ type: "text", text: displayMessage }],
|
assistantMessageId,
|
||||||
attachments: [],
|
status: "queued",
|
||||||
createdAt: userCreatedAt,
|
processingHint,
|
||||||
videoStorageStatus: null,
|
});
|
||||||
temporaryVideoUrl: null,
|
return NextResponse.json(responseBody, {
|
||||||
permanentVideoUrl: null,
|
status: 202,
|
||||||
archivedAt: null,
|
headers: {
|
||||||
videoMetadata: {
|
"Cache-Control": "no-store, max-age=0",
|
||||||
composerMode: "clawd",
|
Location: responseBody.task.statusUrl,
|
||||||
source: "openclaw",
|
"Retry-After": "2",
|
||||||
skillTagId: skillTagId || null,
|
|
||||||
skillTagName: skillTagName || null,
|
|
||||||
},
|
},
|
||||||
};
|
|
||||||
const assistantMessage = {
|
|
||||||
id: assistantMessageId,
|
|
||||||
chatId: parsed.id,
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "text", text: answer }],
|
|
||||||
attachments: [],
|
|
||||||
createdAt: assistantCreatedAt,
|
|
||||||
videoStorageStatus: null,
|
|
||||||
temporaryVideoUrl: null,
|
|
||||||
permanentVideoUrl: null,
|
|
||||||
archivedAt: null,
|
|
||||||
videoMetadata: {
|
|
||||||
composerMode: "clawd",
|
|
||||||
source: "openclaw",
|
|
||||||
requestId:
|
|
||||||
upstreamPayload && typeof upstreamPayload === "object"
|
|
||||||
? (upstreamPayload as Record<string, unknown>).request_id || null
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await saveMessages({ messages: [userMessage, assistantMessage] });
|
|
||||||
clearClawdRequestCancellation(cancelKey);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
conversationId,
|
|
||||||
requestId:
|
|
||||||
upstreamPayload && typeof upstreamPayload === "object"
|
|
||||||
? (upstreamPayload as Record<string, unknown>).request_id || null
|
|
||||||
: null,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: userMessage.id,
|
|
||||||
role: "user",
|
|
||||||
parts: userMessage.parts,
|
|
||||||
createdAt: userMessage.createdAt.toISOString(),
|
|
||||||
metadata: {
|
|
||||||
createdAt: userMessage.createdAt.toISOString(),
|
|
||||||
composerMode: "clawd",
|
|
||||||
},
|
|
||||||
videoMetadata: userMessage.videoMetadata,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: assistantMessage.id,
|
|
||||||
role: "assistant",
|
|
||||||
parts: assistantMessage.parts,
|
|
||||||
createdAt: assistantMessage.createdAt.toISOString(),
|
|
||||||
metadata: {
|
|
||||||
createdAt: assistantMessage.createdAt.toISOString(),
|
|
||||||
composerMode: "clawd",
|
|
||||||
},
|
|
||||||
videoMetadata: assistantMessage.videoMetadata,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
190
app/api/canvas/clawd-chat/status/route.ts
Normal file
190
app/api/canvas/clawd-chat/status/route.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getChatById, getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
buildClawdCancelKey,
|
||||||
|
hasActiveClawdRequest,
|
||||||
|
registerActiveClawdRequest,
|
||||||
|
unregisterActiveClawdRequest,
|
||||||
|
} from "@/lib/openclaw-chat/cancel-registry";
|
||||||
|
import {
|
||||||
|
normalizeOpenClawChatConfig,
|
||||||
|
OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
} from "@/lib/openclaw-chat/config";
|
||||||
|
import {
|
||||||
|
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
import {
|
||||||
|
getPersistedClawdTask,
|
||||||
|
markClawdTaskCanceled,
|
||||||
|
markClawdTaskCompleted,
|
||||||
|
markClawdTaskFailed,
|
||||||
|
} from "@/lib/openclaw-chat/task-store";
|
||||||
|
import { isPendingClawdTaskStatus } from "@/lib/openclaw-chat/task-state";
|
||||||
|
import {
|
||||||
|
getPersistedUpstreamTaskState,
|
||||||
|
refreshOpenClawDurableTask,
|
||||||
|
} from "@/lib/openclaw-chat/upstream-task-client";
|
||||||
|
|
||||||
|
const statusSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
userMessageId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
async function reconcileDetachedOpenClawTask(params: {
|
||||||
|
chatId: string;
|
||||||
|
userId: string;
|
||||||
|
task: NonNullable<Awaited<ReturnType<typeof getPersistedClawdTask>>>;
|
||||||
|
}) {
|
||||||
|
if (!isPendingClawdTaskStatus(params.task.metadata.status)) return;
|
||||||
|
const upstreamTask = getPersistedUpstreamTaskState(params.task.metadata);
|
||||||
|
if (!upstreamTask) return;
|
||||||
|
const cancelKey = buildClawdCancelKey({
|
||||||
|
userId: params.userId,
|
||||||
|
chatId: params.chatId,
|
||||||
|
userMessageId: params.task.metadata.userMessageId,
|
||||||
|
});
|
||||||
|
if (hasActiveClawdRequest(cancelKey)) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
registerActiveClawdRequest(cancelKey, controller, 30_000);
|
||||||
|
const target = {
|
||||||
|
chatId: params.chatId,
|
||||||
|
assistantMessageId: params.task.metadata.assistantMessageId,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const config = normalizeOpenClawChatConfig(setting?.value);
|
||||||
|
if (!config.enabled || !config.baseUrl || !config.token) return;
|
||||||
|
const outcome = await refreshOpenClawDurableTask({
|
||||||
|
config,
|
||||||
|
upstreamTask,
|
||||||
|
target,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (outcome.status === "completed") {
|
||||||
|
await markClawdTaskCompleted({
|
||||||
|
...target,
|
||||||
|
answer: outcome.answer,
|
||||||
|
requestId: outcome.requestId,
|
||||||
|
});
|
||||||
|
} else if (outcome.status === "failed") {
|
||||||
|
await markClawdTaskFailed({ ...target, error: outcome.error });
|
||||||
|
} else if (outcome.status === "canceled") {
|
||||||
|
await markClawdTaskCanceled({ ...target, message: outcome.error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
console.warn("[ClawdChat Status] Upstream reconciliation deferred:", {
|
||||||
|
chatId: params.chatId,
|
||||||
|
taskId: upstreamTask.taskId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
unregisterActiveClawdRequest(cancelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth().catch(() => null);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||||
|
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const parsed = statusSchema.safeParse({
|
||||||
|
id: url.searchParams.get("id"),
|
||||||
|
userMessageId: url.searchParams.get("userMessageId") || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await getChatById({ id: parsed.data.id }).catch(() => null);
|
||||||
|
if (!chat) {
|
||||||
|
return NextResponse.json({ task: null }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (chat.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = await getPersistedClawdTask({
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: parsed.data.userMessageId,
|
||||||
|
});
|
||||||
|
if (!task) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ task: null },
|
||||||
|
{ headers: { "Cache-Control": "no-store, max-age=0" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconcileDetachedOpenClawTask({
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
task =
|
||||||
|
(await getPersistedClawdTask({
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: parsed.data.userMessageId,
|
||||||
|
})) || task;
|
||||||
|
|
||||||
|
const userTextPart = Array.isArray(task.userMessage?.parts)
|
||||||
|
? task.userMessage.parts.find(
|
||||||
|
(part) =>
|
||||||
|
part &&
|
||||||
|
typeof part === "object" &&
|
||||||
|
!Array.isArray(part) &&
|
||||||
|
(part as { type?: unknown }).type === "text"
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const userText =
|
||||||
|
userTextPart && typeof userTextPart === "object"
|
||||||
|
? String((userTextPart as { text?: unknown }).text || "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
task: {
|
||||||
|
chatId: parsed.data.id,
|
||||||
|
userMessageId: task.metadata.userMessageId,
|
||||||
|
assistantMessageId: task.metadata.assistantMessageId,
|
||||||
|
status: task.metadata.status,
|
||||||
|
processingHint: task.metadata.processingHint,
|
||||||
|
progressEvents: task.metadata.progressEvents,
|
||||||
|
skillTagId: task.metadata.skillTagId || null,
|
||||||
|
skillTagName: task.metadata.skillTagName || null,
|
||||||
|
requestId: task.metadata.requestId || null,
|
||||||
|
error: task.metadata.error || null,
|
||||||
|
createdAt: task.metadata.createdAt,
|
||||||
|
updatedAt: task.metadata.updatedAt,
|
||||||
|
userMessage: {
|
||||||
|
text: userText,
|
||||||
|
attachments: task.userMessage?.attachments || [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store, max-age=0",
|
||||||
|
"Retry-After": "2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1956,7 +1956,7 @@ export async function POST(request: Request) {
|
|||||||
(item) =>
|
(item) =>
|
||||||
!item.model.isSubModel &&
|
!item.model.isSubModel &&
|
||||||
item.model.isEnabled &&
|
item.model.isEnabled &&
|
||||||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
|
||||||
);
|
);
|
||||||
const configuredDetailAgentConfig =
|
const configuredDetailAgentConfig =
|
||||||
currentSiteSettings?.canvasDetailAgentModelId &&
|
currentSiteSettings?.canvasDetailAgentModelId &&
|
||||||
|
|||||||
@ -204,7 +204,7 @@ export async function POST(request: Request) {
|
|||||||
(item) =>
|
(item) =>
|
||||||
!item.model.isSubModel &&
|
!item.model.isSubModel &&
|
||||||
item.model.isEnabled &&
|
item.model.isEnabled &&
|
||||||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
|
||||||
);
|
);
|
||||||
const configuredDetailAgentConfig =
|
const configuredDetailAgentConfig =
|
||||||
currentSiteSettings?.canvasDetailAgentModelId &&
|
currentSiteSettings?.canvasDetailAgentModelId &&
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
createCanvasImageTask,
|
createCanvasImageTask,
|
||||||
enqueueCanvasImageTask,
|
enqueueCanvasImageTask,
|
||||||
getCanvasImageTaskForUser,
|
getCanvasImageTaskForUser,
|
||||||
|
getCanvasDetailReplacementTasksForUserContext,
|
||||||
} from "@/lib/canvas/image-task-queue";
|
} from "@/lib/canvas/image-task-queue";
|
||||||
import {
|
import {
|
||||||
generateCanvasImage,
|
generateCanvasImage,
|
||||||
@ -30,8 +31,16 @@ function normalizeBillingFeature(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
|
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
|
||||||
|
const requestedQuality =
|
||||||
|
body.providerImageParams &&
|
||||||
|
typeof body.providerImageParams === "object" &&
|
||||||
|
!Array.isArray(body.providerImageParams)
|
||||||
|
? String(body.providerImageParams.quality || "").trim().toLowerCase()
|
||||||
|
: "";
|
||||||
return {
|
return {
|
||||||
prompt: String(body.prompt || "").trim(),
|
prompt: String(body.prompt || "").trim(),
|
||||||
|
displayPrompt:
|
||||||
|
typeof body.displayPrompt === "string" ? body.displayPrompt : undefined,
|
||||||
negativePrompt:
|
negativePrompt:
|
||||||
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
|
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
|
||||||
model: typeof body.model === "string" ? body.model : undefined,
|
model: typeof body.model === "string" ? body.model : undefined,
|
||||||
@ -49,11 +58,24 @@ function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImage
|
|||||||
: 1,
|
: 1,
|
||||||
promptExtend:
|
promptExtend:
|
||||||
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
|
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
|
||||||
|
providerImageParams: new Set(["low", "medium", "high"]).has(
|
||||||
|
requestedQuality,
|
||||||
|
)
|
||||||
|
? { quality: requestedQuality }
|
||||||
|
: undefined,
|
||||||
referenceImage:
|
referenceImage:
|
||||||
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
|
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
|
||||||
referenceImages: Array.isArray(body.referenceImages)
|
referenceImages: Array.isArray(body.referenceImages)
|
||||||
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
|
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
|
||||||
: [],
|
: [],
|
||||||
|
debugLabel:
|
||||||
|
typeof body.debugLabel === "string" ? body.debugLabel : undefined,
|
||||||
|
debugMeta:
|
||||||
|
body.debugMeta &&
|
||||||
|
typeof body.debugMeta === "object" &&
|
||||||
|
!Array.isArray(body.debugMeta)
|
||||||
|
? body.debugMeta
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,6 +88,19 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const taskId = String(searchParams.get("taskId") || "").trim();
|
const taskId = String(searchParams.get("taskId") || "").trim();
|
||||||
|
const chatId = String(searchParams.get("chatId") || "").trim();
|
||||||
|
const projectId = String(searchParams.get("projectId") || "").trim();
|
||||||
|
if (!taskId && chatId) {
|
||||||
|
const tasks = await getCanvasDetailReplacementTasksForUserContext({
|
||||||
|
userId: session.user.id,
|
||||||
|
chatId,
|
||||||
|
projectId: projectId || null,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
tasks,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "缺少 taskId 参数" },
|
{ success: false, error: "缺少 taskId 参数" },
|
||||||
|
|||||||
@ -1,23 +1,61 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
|
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
|
||||||
|
import {
|
||||||
|
resolveVideoReferenceSelectionRule,
|
||||||
|
selectVideoReferenceImages,
|
||||||
|
} from "@/lib/canvas/video-reference-selection";
|
||||||
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { mediaAsset } from "@/lib/db/schema";
|
||||||
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
|
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import { normalizeVideoCanvasGenerationRequestCount } from "@/lib/video-canvas/video-generation-parameters";
|
||||||
|
import {
|
||||||
|
buildVideoCanvasSubjectProviderReferences,
|
||||||
|
composeVideoCanvasSubjectPrompt,
|
||||||
|
normalizeVideoCanvasAttachedSubjects,
|
||||||
|
supportsVideoCanvasSubjectUsage,
|
||||||
|
toVideoCanvasAttachedSubject,
|
||||||
|
VIDEO_CANVAS_SUBJECT_SOURCE_TYPE,
|
||||||
|
type VideoCanvasAttachedSubject,
|
||||||
|
} from "@/lib/video-canvas/subjects";
|
||||||
|
import {
|
||||||
|
composeVideoCanvasSmartStoryboardPrompt,
|
||||||
|
normalizeVideoCanvasSmartStoryboard,
|
||||||
|
supportsVideoCanvasSmartStoryboard,
|
||||||
|
validateVideoCanvasSmartStoryboard,
|
||||||
|
type VideoCanvasSmartStoryboard,
|
||||||
|
} from "@/lib/video-canvas/smart-storyboard";
|
||||||
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
|
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
|
||||||
|
|
||||||
export const maxDuration = 300;
|
export const maxDuration = 300;
|
||||||
|
|
||||||
interface GenerateVideoRequest {
|
interface GenerateVideoRequest {
|
||||||
|
autoValidateMaterial?: boolean;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
mode?: VideoGenerationMode;
|
mode?: VideoGenerationMode;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
audioUrls?: string[];
|
||||||
resolution?: VideoResolution;
|
resolution?: VideoResolution;
|
||||||
ratio?: VideoRatio;
|
ratio?: VideoRatio;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
generateAudio?: boolean;
|
||||||
|
count?: number;
|
||||||
watermark?: boolean;
|
watermark?: boolean;
|
||||||
cameraFixed?: boolean;
|
cameraFixed?: boolean;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
returnLastFrame?: boolean;
|
returnLastFrame?: boolean;
|
||||||
|
modelLabel?: string;
|
||||||
preferredModelRef?: string;
|
preferredModelRef?: string;
|
||||||
|
searchEnabled?: boolean;
|
||||||
|
smartStoryboard?: VideoCanvasSmartStoryboard;
|
||||||
|
subjects?: VideoCanvasAttachedSubject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMode(images: string[]): VideoGenerationMode {
|
function inferMode(images: string[]): VideoGenerationMode {
|
||||||
@ -39,18 +77,142 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as GenerateVideoRequest;
|
const body = (await request.json().catch(() => ({}))) as GenerateVideoRequest;
|
||||||
const prompt = String(body.prompt || "").trim();
|
const prompt = String(body.prompt || "").trim();
|
||||||
if (!prompt) {
|
const count = normalizeVideoCanvasGenerationRequestCount(body.count, 1);
|
||||||
|
if (count === null) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "生成数量仅支持 1、2 或 4 个" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const generateAudio = body.generateAudio !== false;
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const config = setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const autoValidateMaterial =
|
||||||
|
typeof body.autoValidateMaterial === "boolean"
|
||||||
|
? body.autoValidateMaterial
|
||||||
|
: config.videoAdvancedSettings.defaultAutoValidateMaterial;
|
||||||
|
const searchEnabled =
|
||||||
|
typeof body.searchEnabled === "boolean"
|
||||||
|
? body.searchEnabled
|
||||||
|
: config.videoAdvancedSettings.defaultSearchEnabled;
|
||||||
|
const subjectUsageEnabled = supportsVideoCanvasSubjectUsage(
|
||||||
|
config.subjectLibrary,
|
||||||
|
`${body.modelLabel || ""} ${body.preferredModelRef || ""}`,
|
||||||
|
);
|
||||||
|
const requestedSubjects = subjectUsageEnabled
|
||||||
|
? normalizeVideoCanvasAttachedSubjects(
|
||||||
|
body.subjects,
|
||||||
|
config.subjectLibrary.videoUsage.maxSubjects,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const requestedSubjectIds = requestedSubjects
|
||||||
|
.map((subject) => subject.id)
|
||||||
|
.filter((id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id));
|
||||||
|
const ownedSubjectRows = requestedSubjectIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
description: mediaAsset.summary,
|
||||||
|
displayName: mediaAsset.displayName,
|
||||||
|
id: mediaAsset.id,
|
||||||
|
metadata: mediaAsset.metadata,
|
||||||
|
originalUrl: mediaAsset.originalUrl,
|
||||||
|
previewUrl: mediaAsset.previewUrl,
|
||||||
|
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||||
|
})
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(mediaAsset.id, requestedSubjectIds),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.mediaType, "subject"),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const ownedSubjectsById = new Map(
|
||||||
|
ownedSubjectRows.map((subject) => [subject.id, subject]),
|
||||||
|
);
|
||||||
|
const subjects = requestedSubjects.flatMap((requestedSubject) => {
|
||||||
|
const ownedSubject = ownedSubjectsById.get(requestedSubject.id);
|
||||||
|
const normalized = ownedSubject
|
||||||
|
? toVideoCanvasAttachedSubject(ownedSubject)
|
||||||
|
: null;
|
||||||
|
return normalized ? [normalized] : [];
|
||||||
|
});
|
||||||
|
const subjectReferences = buildVideoCanvasSubjectProviderReferences(subjects);
|
||||||
|
const smartStoryboardSupported = supportsVideoCanvasSmartStoryboard(
|
||||||
|
config.smartStoryboard,
|
||||||
|
`${body.modelLabel || ""} ${body.preferredModelRef || ""}`,
|
||||||
|
);
|
||||||
|
const smartStoryboard = normalizeVideoCanvasSmartStoryboard(
|
||||||
|
body.smartStoryboard,
|
||||||
|
{
|
||||||
|
config: config.smartStoryboard,
|
||||||
|
fallbackPrompt: prompt,
|
||||||
|
fallbackTotalDurationSeconds: body.duration,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const storyboardValidation = validateVideoCanvasSmartStoryboard(
|
||||||
|
smartStoryboard,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
smartStoryboardSupported &&
|
||||||
|
smartStoryboard.enabled &&
|
||||||
|
!storyboardValidation.valid
|
||||||
|
) {
|
||||||
|
const message =
|
||||||
|
storyboardValidation.emptyShotIndexes.length > 0
|
||||||
|
? "智能分镜存在未填写的镜头描述"
|
||||||
|
: storyboardValidation.status === "over"
|
||||||
|
? "智能分镜总时长超出"
|
||||||
|
: "智能分镜总时长不足";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: message },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const promptWithStoryboard = smartStoryboardSupported
|
||||||
|
? composeVideoCanvasSmartStoryboardPrompt(
|
||||||
|
prompt,
|
||||||
|
smartStoryboard,
|
||||||
|
config.smartStoryboard,
|
||||||
|
)
|
||||||
|
: prompt;
|
||||||
|
const generationPrompt = composeVideoCanvasSubjectPrompt(
|
||||||
|
promptWithStoryboard,
|
||||||
|
subjects,
|
||||||
|
config.subjectLibrary.videoUsage.promptTemplate,
|
||||||
|
);
|
||||||
|
if (!generationPrompt) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "请输入视频提示词" },
|
{ success: false, error: "请输入视频提示词" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const referenceRule = resolveVideoReferenceSelectionRule(
|
||||||
const inputImages = Array.isArray(body.images)
|
config.videoReferenceSelection,
|
||||||
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
|
`${body.modelLabel || ""} ${body.preferredModelRef || ""}`,
|
||||||
|
);
|
||||||
|
const rawImages = Array.isArray(body.images)
|
||||||
|
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 20)
|
||||||
: [];
|
: [];
|
||||||
|
const inputImages = body.mode
|
||||||
|
? body.mode === "text-to-video" || body.mode === "audio-to-video"
|
||||||
|
? rawImages.slice(0, 4)
|
||||||
|
: selectVideoReferenceImages(rawImages, body.mode, referenceRule.maxImages)
|
||||||
|
: rawImages.slice(0, referenceRule.maxImages);
|
||||||
const { kept: reachableImages, dropped: unreachableImages } =
|
const { kept: reachableImages, dropped: unreachableImages } =
|
||||||
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
|
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
|
||||||
|
const inputAudioUrls = Array.isArray(body.audioUrls)
|
||||||
|
? body.audioUrls.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 3)
|
||||||
|
: [];
|
||||||
|
const { kept: reachableAudioUrls, dropped: unreachableAudioUrls } =
|
||||||
|
await filterReachableRemoteUrls(inputAudioUrls, { timeoutMs: 3000 });
|
||||||
|
|
||||||
if (inputImages.length > 0 && reachableImages.length === 0) {
|
if (inputImages.length > 0 && reachableImages.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -59,17 +221,35 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = body.mode || inferMode(reachableImages);
|
const mode = body.mode || (reachableAudioUrls.length > 0 ? "audio-to-video" : inferMode(reachableImages));
|
||||||
|
const selectedImages =
|
||||||
|
mode === "text-to-video" || mode === "audio-to-video"
|
||||||
|
? reachableImages
|
||||||
|
: selectVideoReferenceImages(
|
||||||
|
reachableImages,
|
||||||
|
mode,
|
||||||
|
referenceRule.maxImages,
|
||||||
|
);
|
||||||
|
if (mode === "audio-to-video" && reachableAudioUrls.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "音频生视频至少需要 1 个可访问的音频素材" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
const result = await executeDirectVideoGeneration({
|
const result = await executeDirectVideoGeneration({
|
||||||
prompt,
|
prompt: generationPrompt,
|
||||||
mode,
|
mode,
|
||||||
images: reachableImages,
|
images: selectedImages,
|
||||||
|
audioUrls: reachableAudioUrls,
|
||||||
resolution: body.resolution,
|
resolution: body.resolution,
|
||||||
ratio: body.ratio,
|
ratio: body.ratio,
|
||||||
duration:
|
duration:
|
||||||
typeof body.duration === "number" && Number.isFinite(body.duration)
|
smartStoryboardSupported && smartStoryboard.enabled
|
||||||
? body.duration
|
? smartStoryboard.totalDurationSeconds
|
||||||
: undefined,
|
: typeof body.duration === "number" && Number.isFinite(body.duration)
|
||||||
|
? body.duration
|
||||||
|
: undefined,
|
||||||
|
generateAudio,
|
||||||
watermark: body.watermark,
|
watermark: body.watermark,
|
||||||
cameraFixed: body.cameraFixed,
|
cameraFixed: body.cameraFixed,
|
||||||
seed:
|
seed:
|
||||||
@ -95,8 +275,20 @@ export async function POST(request: Request) {
|
|||||||
modelId: result.modelId,
|
modelId: result.modelId,
|
||||||
modelRef: result.modelRef,
|
modelRef: result.modelRef,
|
||||||
mode,
|
mode,
|
||||||
images: reachableImages,
|
images: selectedImages,
|
||||||
|
audioUrls: reachableAudioUrls,
|
||||||
|
autoValidateMaterial,
|
||||||
|
count,
|
||||||
|
generateAudio,
|
||||||
droppedImages: unreachableImages,
|
droppedImages: unreachableImages,
|
||||||
|
droppedAudioUrls: unreachableAudioUrls,
|
||||||
|
searchEnabled,
|
||||||
|
subjects,
|
||||||
|
subjectReferences,
|
||||||
|
smartStoryboard:
|
||||||
|
smartStoryboardSupported && smartStoryboard.enabled
|
||||||
|
? smartStoryboard
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
116
app/api/canvas/layout-replace/route.ts
Normal file
116
app/api/canvas/layout-replace/route.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
createCanvasImageTask,
|
||||||
|
enqueueCanvasImageTask,
|
||||||
|
} from "@/lib/canvas/image-task-queue";
|
||||||
|
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||||
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
|
import type { CanvasLayoutReplaceTaskRequest } from "@/lib/canvas/layout-replace-task";
|
||||||
|
|
||||||
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
|
function normalizeHttpUrl(value: unknown) {
|
||||||
|
const url = String(value || "").trim();
|
||||||
|
return /^https?:\/\//i.test(url) ? url : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const sourceImageUrl = normalizeHttpUrl(body?.sourceImageUrl);
|
||||||
|
const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl);
|
||||||
|
if (!sourceImageUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少当前产品图片" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!referenceImageUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少排版参考图" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
|
userId: session.user.id,
|
||||||
|
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||||||
|
reason: "image_repaint",
|
||||||
|
modelId: "canvas-layout-replace",
|
||||||
|
note: "排版替换消耗积分",
|
||||||
|
pointsConfig,
|
||||||
|
});
|
||||||
|
if (!pointsCharge.ok) {
|
||||||
|
return NextResponse.json(pointsCharge.payload, {
|
||||||
|
status: pointsCharge.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskRequest: CanvasLayoutReplaceTaskRequest = {
|
||||||
|
taskKind: "canvas-layout-replace",
|
||||||
|
sourceImageUrl,
|
||||||
|
referenceImageUrl,
|
||||||
|
supplementalPrompt:
|
||||||
|
typeof body?.supplementalPrompt === "string"
|
||||||
|
? body.supplementalPrompt
|
||||||
|
: undefined,
|
||||||
|
model: typeof body?.model === "string" ? body.model : undefined,
|
||||||
|
imageSize:
|
||||||
|
typeof body?.imageSize === "string" && body.imageSize.trim()
|
||||||
|
? body.imageSize.trim()
|
||||||
|
: "2K",
|
||||||
|
sourceNodeId:
|
||||||
|
typeof body?.sourceNodeId === "string" ? body.sourceNodeId : undefined,
|
||||||
|
referenceLabel:
|
||||||
|
typeof body?.referenceLabel === "string" ? body.referenceLabel : undefined,
|
||||||
|
debugMeta:
|
||||||
|
body?.debugMeta &&
|
||||||
|
typeof body.debugMeta === "object" &&
|
||||||
|
!Array.isArray(body.debugMeta)
|
||||||
|
? body.debugMeta
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const task = await createCanvasImageTask({
|
||||||
|
userId: session.user.id,
|
||||||
|
request: taskRequest as unknown as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
await enqueueCanvasImageTask(task.id);
|
||||||
|
|
||||||
|
if (pointsCharge.chargeable) {
|
||||||
|
try {
|
||||||
|
await pointsCharge.consume({
|
||||||
|
note: `排版替换消耗积分(${task.id})`,
|
||||||
|
});
|
||||||
|
} catch (pointsError) {
|
||||||
|
console.error("[Points] Failed to consume canvas layout replace points:", pointsError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
taskId: task.id,
|
||||||
|
status: task.status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Canvas Layout Replace] Error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "排版替换提交失败",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,12 @@ import {
|
|||||||
getEnabledAiModelsWithProvider,
|
getEnabledAiModelsWithProvider,
|
||||||
getPointsSystemConfig,
|
getPointsSystemConfig,
|
||||||
getSiteSettings,
|
getSiteSettings,
|
||||||
|
getSystemSetting,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
CANVAS_LOCAL_EDIT_CONFIG_KEY,
|
||||||
|
normalizeCanvasLocalEditConfig,
|
||||||
|
} from "@/lib/canvas/local-edit-config";
|
||||||
import {
|
import {
|
||||||
modelLacksReferenceImageInputSupport,
|
modelLacksReferenceImageInputSupport,
|
||||||
} from "@/lib/ai/reference-image-capability";
|
} from "@/lib/ai/reference-image-capability";
|
||||||
@ -318,6 +323,7 @@ function isScaleAdjustMaskCapableModel(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (entry.provider.type === "openai-image") return true;
|
if (entry.provider.type === "openai-image") return true;
|
||||||
|
if (entry.provider.type === "vip98k") return true;
|
||||||
if (entry.provider.type === "fal-image") {
|
if (entry.provider.type === "fal-image") {
|
||||||
return isFalGptImage2EditModelId(entry.model.modelId || "");
|
return isFalGptImage2EditModelId(entry.model.modelId || "");
|
||||||
}
|
}
|
||||||
@ -366,6 +372,7 @@ function getScaleAdjustProviderPriority(providerType: string) {
|
|||||||
case "alibaba-image":
|
case "alibaba-image":
|
||||||
case "bfl-image":
|
case "bfl-image":
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
|
case "vip98k":
|
||||||
case "stepfun-image":
|
case "stepfun-image":
|
||||||
case "volcano-vision":
|
case "volcano-vision":
|
||||||
return 60;
|
return 60;
|
||||||
@ -4466,10 +4473,17 @@ export async function POST(request: NextRequest) {
|
|||||||
const shouldUseScaleAdjustDetailModel =
|
const shouldUseScaleAdjustDetailModel =
|
||||||
operation === SCALE_ADJUST_OPERATION &&
|
operation === SCALE_ADJUST_OPERATION &&
|
||||||
(hasScaleAdjustTargetRects || scalePercent !== null);
|
(hasScaleAdjustTargetRects || scalePercent !== null);
|
||||||
const [pointsConfig, siteSettingsForModel, scaleAdjustModelCandidates] =
|
const [
|
||||||
await Promise.all([
|
pointsConfig,
|
||||||
|
siteSettingsForModel,
|
||||||
|
localEditConfigSetting,
|
||||||
|
scaleAdjustModelCandidates,
|
||||||
|
] = await Promise.all([
|
||||||
getPointsSystemConfig(),
|
getPointsSystemConfig(),
|
||||||
getSiteSettings().catch(() => null),
|
getSiteSettings().catch(() => null),
|
||||||
|
getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(
|
||||||
|
() => null
|
||||||
|
),
|
||||||
shouldUseScaleAdjustDetailModel
|
shouldUseScaleAdjustDetailModel
|
||||||
? getEnabledAiModelsWithProvider(false, true).catch((error) => {
|
? getEnabledAiModelsWithProvider(false, true).catch((error) => {
|
||||||
console.warn(
|
console.warn(
|
||||||
@ -4483,10 +4497,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const pointsCharge = await prepareFeaturePointsCharge({
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||||||
reason: operation === SCALE_ADJUST_OPERATION ? "image_scale_adjust" : "image_repaint",
|
reason:
|
||||||
|
operation === SCALE_ADJUST_OPERATION
|
||||||
|
? "image_scale_adjust"
|
||||||
|
: "image_repaint",
|
||||||
modelId:
|
modelId:
|
||||||
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
|
operation === SCALE_ADJUST_OPERATION
|
||||||
note: operation === SCALE_ADJUST_OPERATION ? "比例调整消耗积分" : "局部编辑消耗积分",
|
? "canvas-scale-adjust"
|
||||||
|
: "canvas-local-edit",
|
||||||
|
note:
|
||||||
|
operation === SCALE_ADJUST_OPERATION
|
||||||
|
? "比例调整消耗积分"
|
||||||
|
: "局部编辑消耗积分",
|
||||||
pointsConfig,
|
pointsConfig,
|
||||||
});
|
});
|
||||||
if (!pointsCharge.ok) {
|
if (!pointsCharge.ok) {
|
||||||
@ -4518,13 +4540,19 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof body.model === "string" && body.model.trim()
|
typeof body.model === "string" && body.model.trim()
|
||||||
? body.model.trim()
|
? body.model.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const localEditConfig = normalizeCanvasLocalEditConfig(
|
||||||
|
localEditConfigSetting?.value
|
||||||
|
);
|
||||||
const configuredDetailModel =
|
const configuredDetailModel =
|
||||||
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
|
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
|
||||||
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
|
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
|
||||||
: "";
|
: "";
|
||||||
|
const configuredLocalEditModel = localEditConfig.modelId;
|
||||||
|
const configuredScaleAdjustModel =
|
||||||
|
localEditConfig.scaleAdjustModelId || configuredDetailModel;
|
||||||
const detailFixedModel = isScaleAdjustOperation
|
const detailFixedModel = isScaleAdjustOperation
|
||||||
? resolveScaleAdjustPreferredModelId({
|
? resolveScaleAdjustPreferredModelId({
|
||||||
configuredModelId: configuredDetailModel,
|
configuredModelId: configuredScaleAdjustModel,
|
||||||
honorRequestedModelBeforeGpt: hasScaleAdjustEnlarge,
|
honorRequestedModelBeforeGpt: hasScaleAdjustEnlarge,
|
||||||
preferFalNanoBananaEdit: preferScaleAdjustFalNanoBananaEdit,
|
preferFalNanoBananaEdit: preferScaleAdjustFalNanoBananaEdit,
|
||||||
preferMaskCapableGpt: preferScaleAdjustMaskCapableModel,
|
preferMaskCapableGpt: preferScaleAdjustMaskCapableModel,
|
||||||
@ -4536,15 +4564,17 @@ export async function POST(request: NextRequest) {
|
|||||||
if (
|
if (
|
||||||
isScaleAdjustOperation &&
|
isScaleAdjustOperation &&
|
||||||
detailFixedModel &&
|
detailFixedModel &&
|
||||||
detailFixedModel !== configuredDetailModel
|
detailFixedModel !== configuredScaleAdjustModel
|
||||||
) {
|
) {
|
||||||
console.info("[Canvas Local Edit] Override scale adjust model to detail line:", {
|
console.info("[Canvas Local Edit] Override scale adjust model to detail line:", {
|
||||||
configuredDetailModel: configuredDetailModel || null,
|
configuredDetailModel: configuredDetailModel || null,
|
||||||
|
configuredLocalEditScaleModel: localEditConfig.scaleAdjustModelId || null,
|
||||||
requestedModel: requestedModel || null,
|
requestedModel: requestedModel || null,
|
||||||
resolvedModel: detailFixedModel,
|
resolvedModel: detailFixedModel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const resolvedModel = detailFixedModel || requestedModel;
|
const resolvedModel =
|
||||||
|
detailFixedModel || requestedModel || configuredLocalEditModel;
|
||||||
const resolvedScaleAdjustModelEntry = isScaleAdjustOperation
|
const resolvedScaleAdjustModelEntry = isScaleAdjustOperation
|
||||||
? findModelSelectionEntry(scaleAdjustModelCandidates, resolvedModel || "")
|
? findModelSelectionEntry(scaleAdjustModelCandidates, resolvedModel || "")
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
|
|||||||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||||||
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||||
|
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||||
|
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||||
|
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||||
|
import { createVip98k } from "@/lib/ai/providers/vip98k";
|
||||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||||
import { createRunway } from "@/lib/ai/providers/runway";
|
import { createRunway } from "@/lib/ai/providers/runway";
|
||||||
@ -51,6 +55,10 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
|||||||
"fal-image",
|
"fal-image",
|
||||||
"snapgen-image",
|
"snapgen-image",
|
||||||
"openai-image",
|
"openai-image",
|
||||||
|
"bananaimage",
|
||||||
|
"visionary",
|
||||||
|
"atlascloud",
|
||||||
|
"vip98k",
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"runway",
|
"runway",
|
||||||
@ -115,6 +123,14 @@ function createTextEditProvider(
|
|||||||
return createSnapgenImage({ baseURL, apiKey });
|
return createSnapgenImage({ baseURL, apiKey });
|
||||||
case "openai-image":
|
case "openai-image":
|
||||||
return createOpenAIImage({ baseURL, apiKey });
|
return createOpenAIImage({ baseURL, apiKey });
|
||||||
|
case "bananaimage":
|
||||||
|
return createBananaImage({ baseURL, apiKey });
|
||||||
|
case "visionary":
|
||||||
|
return createVisionary({ baseURL, apiKey });
|
||||||
|
case "atlascloud":
|
||||||
|
return createAtlasCloud({ baseURL, apiKey });
|
||||||
|
case "vip98k":
|
||||||
|
return createVip98k({ baseURL, apiKey });
|
||||||
case "stepfun-image":
|
case "stepfun-image":
|
||||||
return createStepfunImage({ baseURL, apiKey });
|
return createStepfunImage({ baseURL, apiKey });
|
||||||
case "midjourney":
|
case "midjourney":
|
||||||
@ -467,7 +483,7 @@ async function generateTextEditImageWithFailover(params: {
|
|||||||
const runtimeModelId = resolveModelRuntimeId(selected.model, {
|
const runtimeModelId = resolveModelRuntimeId(selected.model, {
|
||||||
providerType: selected.provider.type,
|
providerType: selected.provider.type,
|
||||||
});
|
});
|
||||||
const result = await provider.chat(runtimeModelId).doGenerate({
|
const generationOptions: any = {
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
@ -480,7 +496,18 @@ async function generateTextEditImageWithFailover(params: {
|
|||||||
imageParams: {
|
imageParams: {
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
|
||||||
|
generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
|
||||||
|
}
|
||||||
|
const modelInstance =
|
||||||
|
selected.provider.type === "vip98k"
|
||||||
|
? (provider as any).chat(runtimeModelId, {
|
||||||
|
capabilities: selected.model.capabilities || null,
|
||||||
|
imageGenConfig: selected.model.imageGenConfig || null,
|
||||||
|
})
|
||||||
|
: provider.chat(runtimeModelId);
|
||||||
|
const result = await modelInstance.doGenerate(generationOptions);
|
||||||
|
|
||||||
const resultText = String(result?.text || "").trim();
|
const resultText = String(result?.text || "").trim();
|
||||||
const finishReason = String(result?.finishReason || "").toLowerCase();
|
const finishReason = String(result?.finishReason || "").toLowerCase();
|
||||||
|
|||||||
16
app/api/canvas/video-source/route.ts
Normal file
16
app/api/canvas/video-source/route.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { proxyCanvasVideoSource } from "@/lib/video-canvas/audio-source-proxy";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const requestHostname = new URL(request.url).hostname.toLowerCase();
|
||||||
|
const isLoopbackRequest =
|
||||||
|
requestHostname === "localhost" ||
|
||||||
|
requestHostname === "127.0.0.1" ||
|
||||||
|
requestHostname === "::1";
|
||||||
|
const session = isLoopbackRequest ? null : await auth();
|
||||||
|
if (!isLoopbackRequest && !session?.user?.id) {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyCanvasVideoSource(request);
|
||||||
|
}
|
||||||
@ -9,12 +9,26 @@ import sharp from "sharp";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||||
|
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
|
||||||
|
import {
|
||||||
|
isImageGenerationModelDescriptor,
|
||||||
|
isVideoModelDescriptor,
|
||||||
|
} from "@/lib/ai/model-classification";
|
||||||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||||
import { getAiModelAgentCodingModelId, isAiModelMultimodal } from "@/lib/db/ai-model-flags";
|
import { getAiModelAgentCodingModelId, isAiModelMultimodal } from "@/lib/db/ai-model-flags";
|
||||||
import {
|
import {
|
||||||
getAgentAiModelWithProvider,
|
getAgentAiModelWithProvider,
|
||||||
getEnabledAiModelsWithProvider,
|
getEnabledAiModelsWithProvider,
|
||||||
|
getSystemSetting,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import { normalizeVideoAnalysisTimeline } from "@/lib/video-canvas/video-analysis";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 180;
|
export const maxDuration = 180;
|
||||||
@ -23,6 +37,11 @@ const execFileAsync = promisify(execFile);
|
|||||||
const VIDEO_ANALYSIS_FRAME_COUNT = 4;
|
const VIDEO_ANALYSIS_FRAME_COUNT = 4;
|
||||||
const VIDEO_ANALYSIS_MAX_SIDE = 960;
|
const VIDEO_ANALYSIS_MAX_SIDE = 960;
|
||||||
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
|
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
|
||||||
|
const logger = createLogger("canvas-workflow-analyze");
|
||||||
|
|
||||||
|
type ModelCandidate = Awaited<
|
||||||
|
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||||
|
>[number];
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
|
mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
|
||||||
@ -30,6 +49,7 @@ const requestSchema = z.object({
|
|||||||
imageUrls: z.array(z.string().trim()).optional(),
|
imageUrls: z.array(z.string().trim()).optional(),
|
||||||
videoUrls: z.array(z.string().trim()).optional(),
|
videoUrls: z.array(z.string().trim()).optional(),
|
||||||
nodeTitle: z.string().trim().optional(),
|
nodeTitle: z.string().trim().optional(),
|
||||||
|
durationSeconds: z.number().positive().max(3600).optional(),
|
||||||
preferredModelRef: z.string().trim().optional(),
|
preferredModelRef: z.string().trim().optional(),
|
||||||
model: z.string().trim().optional(),
|
model: z.string().trim().optional(),
|
||||||
});
|
});
|
||||||
@ -56,6 +76,24 @@ function supportsVision(model: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnalysisModelCandidate(candidate: ModelCandidate) {
|
||||||
|
const descriptor = {
|
||||||
|
capabilities: candidate.model.capabilities,
|
||||||
|
modelId: candidate.model.modelId,
|
||||||
|
providerType: candidate.provider.type,
|
||||||
|
supportsImageGen: candidate.model.supportsImageGen,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
candidate.model.isEnabled &&
|
||||||
|
candidate.provider.isEnabled &&
|
||||||
|
!candidate.model.isSubModel &&
|
||||||
|
candidate.model.healthStatus !== "auto_disabled" &&
|
||||||
|
!isImageGenerationModelDescriptor(descriptor) &&
|
||||||
|
!isVideoModelDescriptor(descriptor)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function extractJsonBlock<T = Record<string, unknown>>(text: string): T | null {
|
function extractJsonBlock<T = Record<string, unknown>>(text: string): T | null {
|
||||||
const matched = String(text || "").match(/\{[\s\S]*\}/);
|
const matched = String(text || "").match(/\{[\s\S]*\}/);
|
||||||
if (!matched) return null;
|
if (!matched) return null;
|
||||||
@ -107,9 +145,10 @@ function normalizeShotIdeas(value: unknown) {
|
|||||||
function normalizeVideoAnalysisRows(
|
function normalizeVideoAnalysisRows(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
framePreviews: string[],
|
framePreviews: string[],
|
||||||
|
totalDurationSeconds?: number | null,
|
||||||
) {
|
) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value
|
const rows = value
|
||||||
.map((item, index) => {
|
.map((item, index) => {
|
||||||
const raw =
|
const raw =
|
||||||
item && typeof item === "object" && !Array.isArray(item)
|
item && typeof item === "object" && !Array.isArray(item)
|
||||||
@ -210,6 +249,8 @@ function normalizeVideoAnalysisRows(
|
|||||||
} => Boolean(item),
|
} => Boolean(item),
|
||||||
)
|
)
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
|
||||||
|
return normalizeVideoAnalysisTimeline(rows, totalDurationSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStoryShot(value: unknown, index: number) {
|
function normalizeStoryShot(value: unknown, index: number) {
|
||||||
@ -489,6 +530,7 @@ function buildVideoAnalysisPrompt(params: {
|
|||||||
frameCount: number;
|
frameCount: number;
|
||||||
imageCount: number;
|
imageCount: number;
|
||||||
videoCount: number;
|
videoCount: number;
|
||||||
|
durationSeconds?: number | null;
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
"你是视频画布工作流里的视频分析节点,需要为后续分镜和生成节点提供结构化输出。",
|
"你是视频画布工作流里的视频分析节点,需要为后续分镜和生成节点提供结构化输出。",
|
||||||
@ -496,6 +538,9 @@ function buildVideoAnalysisPrompt(params: {
|
|||||||
`上游参考图数量:${params.imageCount}`,
|
`上游参考图数量:${params.imageCount}`,
|
||||||
`上游视频数量:${params.videoCount}`,
|
`上游视频数量:${params.videoCount}`,
|
||||||
`已提供按时间顺序抽取的视频关键帧数量:${params.frameCount}`,
|
`已提供按时间顺序抽取的视频关键帧数量:${params.frameCount}`,
|
||||||
|
params.durationSeconds
|
||||||
|
? `视频总时长:${Number(params.durationSeconds.toFixed(2))} 秒`
|
||||||
|
: "视频总时长未知,请按关键帧顺序稳健估算。",
|
||||||
params.prompt ? `上游脚本/说明:\n${params.prompt}` : "上游没有额外脚本,请重点依据参考画面分析。",
|
params.prompt ? `上游脚本/说明:\n${params.prompt}` : "上游没有额外脚本,请重点依据参考画面分析。",
|
||||||
"",
|
"",
|
||||||
"请只输出 JSON,不要输出 markdown,不要输出解释。",
|
"请只输出 JSON,不要输出 markdown,不要输出解释。",
|
||||||
@ -530,6 +575,9 @@ function buildVideoAnalysisPrompt(params: {
|
|||||||
"recommendedNextSteps": ["2-4条建议的下游动作"]
|
"recommendedNextSteps": ["2-4条建议的下游动作"]
|
||||||
}`,
|
}`,
|
||||||
"analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。",
|
"analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。",
|
||||||
|
params.durationSeconds
|
||||||
|
? `所有镜头必须从 0 秒连续覆盖到 ${Number(params.durationSeconds.toFixed(2))} 秒,不能重叠、留空或超出视频总时长。`
|
||||||
|
: "镜头时间不能重叠,开始时间、结束时间和时长必须相互一致。",
|
||||||
"每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。",
|
"每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。",
|
||||||
"imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
|
"imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
|
||||||
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
|
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
|
||||||
@ -594,8 +642,9 @@ async function downscaleVideoFrameForVision(inputBuffer: Buffer) {
|
|||||||
|
|
||||||
async function extractVideoFrameDataUrls(videoUrl: string, frameCount = VIDEO_ANALYSIS_FRAME_COUNT) {
|
async function extractVideoFrameDataUrls(videoUrl: string, frameCount = VIDEO_ANALYSIS_FRAME_COUNT) {
|
||||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-analysis-"));
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-analysis-"));
|
||||||
|
let durationSeconds: number | null = null;
|
||||||
try {
|
try {
|
||||||
const durationSeconds = await getVideoDurationSeconds(videoUrl);
|
durationSeconds = await getVideoDurationSeconds(videoUrl);
|
||||||
const timestamps = buildVideoFrameTimestamps(durationSeconds, frameCount);
|
const timestamps = buildVideoFrameTimestamps(durationSeconds, frameCount);
|
||||||
const frames = await Promise.all(
|
const frames = await Promise.all(
|
||||||
timestamps.map(async (timestamp, index) => {
|
timestamps.map(async (timestamp, index) => {
|
||||||
@ -627,13 +676,13 @@ async function extractVideoFrameDataUrls(videoUrl: string, frameCount = VIDEO_AN
|
|||||||
return `data:image/jpeg;base64,${processed.toString("base64")}`;
|
return `data:image/jpeg;base64,${processed.toString("base64")}`;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return frames.filter(Boolean);
|
return { durationSeconds, frames: frames.filter(Boolean) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Canvas Workflow Analyze] extract video frames failed:", {
|
console.warn("[Canvas Workflow Analyze] extract video frames failed:", {
|
||||||
message: error instanceof Error ? error.message : String(error),
|
message: error instanceof Error ? error.message : String(error),
|
||||||
videoUrl,
|
videoUrl,
|
||||||
});
|
});
|
||||||
return [];
|
return { durationSeconds, frames: [] as string[] };
|
||||||
} finally {
|
} finally {
|
||||||
await rm(tempDir, { force: true, recursive: true }).catch(() => undefined);
|
await rm(tempDir, { force: true, recursive: true }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
@ -780,10 +829,13 @@ export async function POST(request: Request) {
|
|||||||
const prompt = normalizeText(parsed.data.prompt);
|
const prompt = normalizeText(parsed.data.prompt);
|
||||||
const imageUrls = sanitizeUrls(parsed.data.imageUrls, 4);
|
const imageUrls = sanitizeUrls(parsed.data.imageUrls, 4);
|
||||||
const videoUrls = sanitizeUrls(parsed.data.videoUrls, 4);
|
const videoUrls = sanitizeUrls(parsed.data.videoUrls, 4);
|
||||||
const extractedVideoFrames =
|
const extractedVideo =
|
||||||
parsed.data.mode === "video-analysis" && videoUrls.length > 0
|
parsed.data.mode === "video-analysis" && videoUrls.length > 0
|
||||||
? await extractVideoFrameDataUrls(videoUrls[0], VIDEO_ANALYSIS_FRAME_COUNT)
|
? await extractVideoFrameDataUrls(videoUrls[0], VIDEO_ANALYSIS_FRAME_COUNT)
|
||||||
: [];
|
: { durationSeconds: null, frames: [] as string[] };
|
||||||
|
const extractedVideoFrames = extractedVideo.frames;
|
||||||
|
const videoDurationSeconds =
|
||||||
|
extractedVideo.durationSeconds || parsed.data.durationSeconds || null;
|
||||||
const preferredModelRef = normalizeText(parsed.data.preferredModelRef || parsed.data.model);
|
const preferredModelRef = normalizeText(parsed.data.preferredModelRef || parsed.data.model);
|
||||||
if (!prompt && imageUrls.length === 0 && videoUrls.length === 0) {
|
if (!prompt && imageUrls.length === 0 && videoUrls.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -797,60 +849,76 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const videoCanvasConfig = setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const functionDefinition = videoCanvasConfig.functions[parsed.data.mode];
|
||||||
|
if (!videoCanvasConfig.enabled || functionDefinition?.enabled === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: `${functionDefinition?.label || "当前功能"}已停用` },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
|
forceEnabled:
|
||||||
|
parsed.data.mode === "video-analysis" && (functionDefinition?.cost || 0) > 0,
|
||||||
|
userId: session.user.id,
|
||||||
|
unitCost: parsed.data.mode === "video-analysis" ? functionDefinition?.cost || 0 : 0,
|
||||||
|
reason: parsed.data.mode,
|
||||||
|
note: functionDefinition?.label || "视频画布工作流分析",
|
||||||
|
});
|
||||||
|
if (!pointsCharge.ok) {
|
||||||
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
|
}
|
||||||
|
|
||||||
const [allModels, agentModel] = await Promise.all([
|
const [allModels, agentModel] = await Promise.all([
|
||||||
getEnabledAiModelsWithProvider(false),
|
getEnabledAiModelsWithProvider(false),
|
||||||
getAgentAiModelWithProvider(),
|
getAgentAiModelWithProvider(),
|
||||||
]);
|
]);
|
||||||
const compatibleModels = allModels.filter(
|
const compatibleModels = allModels.filter(isAnalysisModelCandidate);
|
||||||
(item) => !item.model.isSubModel && item.model.isEnabled
|
const hasVisionInputs = imageUrls.length > 0 || extractedVideoFrames.length > 0;
|
||||||
);
|
const requestCandidates = hasVisionInputs
|
||||||
|
? compatibleModels.filter((item) => supportsVision(item.model))
|
||||||
|
: compatibleModels;
|
||||||
|
|
||||||
const preferredModel =
|
const preferredModel =
|
||||||
preferredModelRef
|
preferredModelRef
|
||||||
? compatibleModels.find(
|
? requestCandidates.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.model.id === preferredModelRef ||
|
item.model.id === preferredModelRef ||
|
||||||
item.model.modelId === preferredModelRef,
|
item.model.modelId === preferredModelRef,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
const compatibleAgentModel =
|
||||||
|
agentModel &&
|
||||||
|
isAnalysisModelCandidate(agentModel) &&
|
||||||
|
(!hasVisionInputs || supportsVision(agentModel.model))
|
||||||
|
? agentModel
|
||||||
|
: null;
|
||||||
|
const orderedCandidates = [
|
||||||
|
preferredModel,
|
||||||
|
compatibleAgentModel,
|
||||||
|
...requestCandidates,
|
||||||
|
].filter(
|
||||||
|
(candidate, index, items): candidate is ModelCandidate =>
|
||||||
|
Boolean(candidate) &&
|
||||||
|
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
|
||||||
|
);
|
||||||
|
|
||||||
const hasVisionInputs = imageUrls.length > 0 || extractedVideoFrames.length > 0;
|
if (orderedCandidates.length === 0) {
|
||||||
|
|
||||||
let selectedModel =
|
|
||||||
preferredModel && (!hasVisionInputs || supportsVision(preferredModel.model))
|
|
||||||
? preferredModel
|
|
||||||
: hasVisionInputs &&
|
|
||||||
agentModel &&
|
|
||||||
supportsVision(agentModel.model)
|
|
||||||
? agentModel
|
|
||||||
: hasVisionInputs
|
|
||||||
? compatibleModels.find((item) => supportsVision(item.model))
|
|
||||||
: agentModel || compatibleModels[0];
|
|
||||||
|
|
||||||
if (!selectedModel) {
|
|
||||||
selectedModel = compatibleModels[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedModel) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "当前站点没有可用的分析模型" },
|
{ success: false, error: "当前站点没有可用的分析模型" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const useVision = hasVisionInputs && supportsVision(selectedModel.model);
|
|
||||||
const codingModelId = getAiModelAgentCodingModelId(selectedModel.model);
|
|
||||||
const model = await getLanguageModelById(selectedModel.model.id, codingModelId);
|
|
||||||
if (!model) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "分析模型初始化失败" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const promptText =
|
const promptText =
|
||||||
parsed.data.mode === "video-analysis"
|
parsed.data.mode === "video-analysis"
|
||||||
? buildVideoAnalysisPrompt({
|
? buildVideoAnalysisPrompt({
|
||||||
|
durationSeconds: videoDurationSeconds,
|
||||||
frameCount: extractedVideoFrames.length,
|
frameCount: extractedVideoFrames.length,
|
||||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||||
prompt,
|
prompt,
|
||||||
@ -877,7 +945,7 @@ export async function POST(request: Request) {
|
|||||||
videoCount: videoUrls.length,
|
videoCount: videoUrls.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const inlinedImageReferences = useVision
|
const inlinedImageReferences = hasVisionInputs
|
||||||
? (
|
? (
|
||||||
await inlineVisionImages(imageUrls, {
|
await inlineVisionImages(imageUrls, {
|
||||||
maxImages: 4,
|
maxImages: 4,
|
||||||
@ -888,26 +956,79 @@ export async function POST(request: Request) {
|
|||||||
})
|
})
|
||||||
).inlineDataUrls
|
).inlineDataUrls
|
||||||
: [];
|
: [];
|
||||||
const referenceImages = useVision
|
const referenceImages = [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6);
|
||||||
? [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6)
|
let generatedResult: {
|
||||||
: [];
|
candidate: ModelCandidate;
|
||||||
|
parsedJson: Record<string, unknown>;
|
||||||
|
text: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
const { text } = await generateText({
|
for (const candidate of orderedCandidates.slice(0, 3)) {
|
||||||
model,
|
try {
|
||||||
messages: [
|
const model = await getLanguageModelById(
|
||||||
{
|
candidate.model.id,
|
||||||
role: "user",
|
getAiModelAgentCodingModelId(candidate.model),
|
||||||
content: [
|
);
|
||||||
{ type: "text", text: promptText },
|
if (!model) continue;
|
||||||
...referenceImages.map((image) => ({ type: "image" as const, image })),
|
const { text } = await generateText({
|
||||||
|
abortSignal: AbortSignal.timeout(55_000),
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: promptText },
|
||||||
|
...referenceImages.map((image) => ({ type: "image" as const, image })),
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
temperature: getCompatibleTextGenerationTemperature(candidate, 0.2),
|
||||||
],
|
maxTokens:
|
||||||
temperature: 0.2,
|
parsed.data.mode === "story-script"
|
||||||
maxTokens: parsed.data.mode === "story-script" ? 4200 : parsed.data.mode === "video-analysis" ? 2200 : 1600,
|
? 4200
|
||||||
} as any);
|
: parsed.data.mode === "video-analysis"
|
||||||
|
? 2200
|
||||||
|
: 1600,
|
||||||
|
} as any);
|
||||||
|
const parsedJson = extractJsonBlock<Record<string, unknown>>(text);
|
||||||
|
if (!parsedJson) throw new Error("分析模型没有返回有效 JSON");
|
||||||
|
if (
|
||||||
|
parsed.data.mode === "video-analysis" &&
|
||||||
|
normalizeVideoAnalysisRows(
|
||||||
|
parsedJson.analysisRows,
|
||||||
|
extractedVideoFrames,
|
||||||
|
videoDurationSeconds,
|
||||||
|
).length === 0
|
||||||
|
) {
|
||||||
|
throw new Error("分析模型没有返回镜头数据");
|
||||||
|
}
|
||||||
|
generatedResult = { candidate, parsedJson, text };
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("workflow analysis model candidate failed", {
|
||||||
|
error,
|
||||||
|
modelId: candidate.model.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
|
if (!generatedResult) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "分析模型暂时不可用,请稍后重试" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { candidate: selectedModel, parsedJson, text } = generatedResult;
|
||||||
|
const chargeResult = await pointsCharge.consume({
|
||||||
|
modelId: selectedModel.model.modelId,
|
||||||
|
});
|
||||||
|
if (pointsCharge.chargeable && chargeResult?.ok === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "积分扣除失败,请刷新余额后重试" },
|
||||||
|
{ status: 402 },
|
||||||
|
);
|
||||||
|
}
|
||||||
const summary =
|
const summary =
|
||||||
normalizeText(parsedJson.summary) ||
|
normalizeText(parsedJson.summary) ||
|
||||||
normalizeText(text).slice(0, 160);
|
normalizeText(text).slice(0, 160);
|
||||||
@ -916,6 +1037,7 @@ export async function POST(request: Request) {
|
|||||||
const analysisRows = normalizeVideoAnalysisRows(
|
const analysisRows = normalizeVideoAnalysisRows(
|
||||||
parsedJson.analysisRows,
|
parsedJson.analysisRows,
|
||||||
extractedVideoFrames,
|
extractedVideoFrames,
|
||||||
|
videoDurationSeconds,
|
||||||
);
|
);
|
||||||
const shotIdeas =
|
const shotIdeas =
|
||||||
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
|
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
|
||||||
|
|||||||
@ -7,9 +7,16 @@ import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
|
|||||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
import {
|
||||||
|
isWorldLabsExternalTaskApi,
|
||||||
|
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
WorldLabsApiError,
|
||||||
|
worldLabsRequest,
|
||||||
|
} from "@/lib/worldlabs/client";
|
||||||
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
|
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
|
||||||
import type {
|
import type {
|
||||||
|
WorldLabsExternalTaskResponse,
|
||||||
|
WorldLabsExternalTaskResultData,
|
||||||
WorldLabsOperation,
|
WorldLabsOperation,
|
||||||
WorldLabsWorld,
|
WorldLabsWorld,
|
||||||
WorldLabsWorldAssets,
|
WorldLabsWorldAssets,
|
||||||
@ -419,6 +426,214 @@ function buildAssetPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
|
||||||
|
const root = getObjectValue(payload);
|
||||||
|
const data = getObjectValue(root?.data);
|
||||||
|
return (data || root || {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
|
||||||
|
const normalized = String(status || "").trim().toLowerCase();
|
||||||
|
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringRecord(value: unknown) {
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
const entries = Object.entries(record).filter(
|
||||||
|
(entry): entry is [string, string] =>
|
||||||
|
Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0
|
||||||
|
);
|
||||||
|
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedString(
|
||||||
|
value: unknown,
|
||||||
|
keys: string[],
|
||||||
|
depth = 0
|
||||||
|
): string | null {
|
||||||
|
if (depth > 8) return null;
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const directValue = getStringValue(record[key]);
|
||||||
|
if (directValue) return directValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findNestedString(child, keys, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedObject(
|
||||||
|
value: unknown,
|
||||||
|
keys: string[],
|
||||||
|
depth = 0
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (depth > 8) return null;
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const directValue = getObjectValue(record[key]);
|
||||||
|
if (directValue) return directValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findNestedObject(child, keys, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedStringRecord(
|
||||||
|
value: unknown,
|
||||||
|
keys: string[],
|
||||||
|
depth = 0
|
||||||
|
): Record<string, string> | null {
|
||||||
|
if (depth > 8) return null;
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const directValue = readStringRecord(record[key]);
|
||||||
|
if (directValue) return directValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findNestedStringRecord(child, keys, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExternalTaskErrorMessage(
|
||||||
|
data: WorldLabsExternalTaskResultData,
|
||||||
|
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
||||||
|
) {
|
||||||
|
const dataError = getObjectValue(data.error);
|
||||||
|
const payloadError = getObjectValue(payload.error);
|
||||||
|
return (
|
||||||
|
getStringValue(
|
||||||
|
data.errorMessage,
|
||||||
|
data.message,
|
||||||
|
dataError?.message,
|
||||||
|
payload.message,
|
||||||
|
payloadError?.message,
|
||||||
|
payload.error
|
||||||
|
) || "World Labs 外部任务失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExternalWorldAssetPayload(data: WorldLabsExternalTaskResultData) {
|
||||||
|
const root = getObjectValue(data) || {};
|
||||||
|
const resultRoot =
|
||||||
|
getObjectValue(root.result) ||
|
||||||
|
getObjectValue(root.output) ||
|
||||||
|
getObjectValue(root.world) ||
|
||||||
|
root;
|
||||||
|
const assets = findNestedObject(resultRoot, ["assets"]);
|
||||||
|
const worldAssets = assets as Partial<WorldLabsWorldAssets> | null;
|
||||||
|
const splats = getObjectValue(worldAssets?.splats);
|
||||||
|
|
||||||
|
return {
|
||||||
|
worldId: getStringValue(
|
||||||
|
findNestedString(resultRoot, ["worldId", "world_id"]),
|
||||||
|
findNestedString(root, ["worldId", "world_id"])
|
||||||
|
),
|
||||||
|
worldUrl: getStringValue(
|
||||||
|
findNestedString(resultRoot, [
|
||||||
|
"worldUrl",
|
||||||
|
"world_marble_url",
|
||||||
|
"sourceWorldUrl",
|
||||||
|
"viewerUrl",
|
||||||
|
"marbleUrl",
|
||||||
|
]),
|
||||||
|
findNestedString(root, [
|
||||||
|
"worldUrl",
|
||||||
|
"world_marble_url",
|
||||||
|
"sourceWorldUrl",
|
||||||
|
"viewerUrl",
|
||||||
|
"marbleUrl",
|
||||||
|
])
|
||||||
|
),
|
||||||
|
displayName: getStringValue(
|
||||||
|
findNestedString(resultRoot, ["displayName", "display_name"]),
|
||||||
|
findNestedString(root, ["displayName", "display_name"])
|
||||||
|
),
|
||||||
|
model:
|
||||||
|
getStringValue(
|
||||||
|
findNestedString(resultRoot, ["model", "modelId", "model_id"]),
|
||||||
|
findNestedString(root, ["model", "modelId", "model_id"])
|
||||||
|
) || WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
caption: getStringValue(
|
||||||
|
worldAssets?.caption,
|
||||||
|
findNestedString(resultRoot, ["caption"]),
|
||||||
|
findNestedString(root, ["caption"])
|
||||||
|
),
|
||||||
|
thumbnailUrl: getStringValue(
|
||||||
|
worldAssets?.thumbnail_url,
|
||||||
|
findNestedString(resultRoot, [
|
||||||
|
"thumbnailUrl",
|
||||||
|
"thumbnail_url",
|
||||||
|
"previewImageUrl",
|
||||||
|
"preview_image_url",
|
||||||
|
]),
|
||||||
|
findNestedString(root, [
|
||||||
|
"thumbnailUrl",
|
||||||
|
"thumbnail_url",
|
||||||
|
"previewImageUrl",
|
||||||
|
"preview_image_url",
|
||||||
|
])
|
||||||
|
),
|
||||||
|
panoUrl: getStringValue(
|
||||||
|
worldAssets?.imagery?.pano_url,
|
||||||
|
findNestedString(resultRoot, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"]),
|
||||||
|
findNestedString(root, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"])
|
||||||
|
),
|
||||||
|
meshUrl: getStringValue(
|
||||||
|
worldAssets?.mesh?.collider_mesh_url,
|
||||||
|
findNestedString(resultRoot, [
|
||||||
|
"meshUrl",
|
||||||
|
"mesh_url",
|
||||||
|
"modelUrl",
|
||||||
|
"model_url",
|
||||||
|
"glbUrl",
|
||||||
|
"glb_url",
|
||||||
|
"colliderMeshUrl",
|
||||||
|
"collider_mesh_url",
|
||||||
|
]),
|
||||||
|
findNestedString(root, [
|
||||||
|
"meshUrl",
|
||||||
|
"mesh_url",
|
||||||
|
"modelUrl",
|
||||||
|
"model_url",
|
||||||
|
"glbUrl",
|
||||||
|
"glb_url",
|
||||||
|
"colliderMeshUrl",
|
||||||
|
"collider_mesh_url",
|
||||||
|
])
|
||||||
|
),
|
||||||
|
spzUrls:
|
||||||
|
readStringRecord(splats?.spz_urls) ||
|
||||||
|
findNestedStringRecord(resultRoot, ["spzUrls", "spz_urls"]) ||
|
||||||
|
findNestedStringRecord(root, ["spzUrls", "spz_urls"]),
|
||||||
|
semanticsMetadata:
|
||||||
|
getObjectValue(splats?.semantics_metadata) ||
|
||||||
|
findNestedObject(resultRoot, ["semanticsMetadata", "semantics_metadata"]) ||
|
||||||
|
findNestedObject(root, ["semanticsMetadata", "semantics_metadata"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{ taskId: string }>;
|
params: Promise<{ taskId: string }>;
|
||||||
}
|
}
|
||||||
@ -452,6 +667,187 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
providerForLog = config.providerId || null;
|
providerForLog = config.providerId || null;
|
||||||
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
|
const persistedAssetPayload =
|
||||||
|
getObjectValue(persistedWorldAsset?.assetPayload) || {};
|
||||||
|
const shouldUseExternalTaskResultApi =
|
||||||
|
isWorldLabsExternalTaskApi(config.baseUrl) &&
|
||||||
|
persistedAssetPayload.externalTaskApi !== false &&
|
||||||
|
persistedAssetPayload.marbleApi !== true;
|
||||||
|
if (shouldUseExternalTaskResultApi) {
|
||||||
|
const externalPayload = await worldLabsRequest<
|
||||||
|
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
||||||
|
>(config, {
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
|
||||||
|
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
||||||
|
});
|
||||||
|
const externalData =
|
||||||
|
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
|
||||||
|
const externalStatusText = getStringValue(externalData.status) || "running";
|
||||||
|
const externalStatus = normalizeExternalTaskStatus(
|
||||||
|
externalStatusText,
|
||||||
|
externalData.done === true
|
||||||
|
);
|
||||||
|
const externalAssetPayloadBase = {
|
||||||
|
...persistedAssetPayload,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
providerName: config.providerName || null,
|
||||||
|
externalTaskApi: true,
|
||||||
|
externalTaskStatus: externalStatusText,
|
||||||
|
requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (externalStatus === "running") {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "world",
|
||||||
|
status: "running",
|
||||||
|
progress: null,
|
||||||
|
output: externalData,
|
||||||
|
assetPayload: externalAssetPayloadBase,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalStatus === "failed") {
|
||||||
|
const errorMessage = extractExternalTaskErrorMessage(
|
||||||
|
externalData,
|
||||||
|
externalPayload
|
||||||
|
);
|
||||||
|
await updatePersistedWorldAsset({
|
||||||
|
id: persistedWorldAsset?.id,
|
||||||
|
status: "failed",
|
||||||
|
progress: null,
|
||||||
|
error: errorMessage,
|
||||||
|
assetPayload: externalAssetPayloadBase,
|
||||||
|
});
|
||||||
|
logger.warn("external task failed status", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
taskIdLength: normalizedTaskId.length,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "world",
|
||||||
|
status: "failed",
|
||||||
|
progress: null,
|
||||||
|
errorMessage,
|
||||||
|
output: externalData,
|
||||||
|
assetPayload: externalAssetPayloadBase,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
|
||||||
|
const rawMeshUrl = extractedAssetPayload.meshUrl;
|
||||||
|
const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
|
||||||
|
const rawPanoUrl = extractedAssetPayload.panoUrl;
|
||||||
|
const stableWorldUrl: string | null = null;
|
||||||
|
const stableModelUrl = await archiveRemoteAssetIfNeeded({
|
||||||
|
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
|
||||||
|
sourceUrl: rawMeshUrl,
|
||||||
|
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "glb",
|
||||||
|
validateAs: "glb",
|
||||||
|
});
|
||||||
|
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
|
||||||
|
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
|
||||||
|
sourceUrl: rawThumbnailUrl,
|
||||||
|
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
});
|
||||||
|
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
|
||||||
|
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
|
||||||
|
sourceUrl: rawPanoUrl,
|
||||||
|
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
});
|
||||||
|
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
|
||||||
|
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
|
||||||
|
sourceMap: extractedAssetPayload.spzUrls,
|
||||||
|
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "spz",
|
||||||
|
validateAs: "spz",
|
||||||
|
});
|
||||||
|
const assetPayload = {
|
||||||
|
...externalAssetPayloadBase,
|
||||||
|
worldId: extractedAssetPayload.worldId,
|
||||||
|
worldUrl: stableWorldUrl,
|
||||||
|
displayName: extractedAssetPayload.displayName,
|
||||||
|
model: resolveThreeDModelAliasId(
|
||||||
|
extractedAssetPayload.model,
|
||||||
|
modelAliasConfig.worldModels
|
||||||
|
),
|
||||||
|
caption: extractedAssetPayload.caption,
|
||||||
|
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
|
||||||
|
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
|
||||||
|
meshUrl: stableModelUrl,
|
||||||
|
spzUrls: stableSpzUrls,
|
||||||
|
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
|
||||||
|
storageArchivedAt: new Date().toISOString(),
|
||||||
|
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
|
||||||
|
sourceMeshUrl:
|
||||||
|
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
||||||
|
? rawMeshUrl
|
||||||
|
: null,
|
||||||
|
sourceThumbnailUrl:
|
||||||
|
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
|
||||||
|
? rawThumbnailUrl
|
||||||
|
: null,
|
||||||
|
sourcePanoUrl:
|
||||||
|
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
|
||||||
|
? rawPanoUrl
|
||||||
|
: null,
|
||||||
|
sourceSpzUrls:
|
||||||
|
extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls
|
||||||
|
? extractedAssetPayload.spzUrls
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updatePersistedWorldAsset({
|
||||||
|
id: persistedWorldAsset?.id,
|
||||||
|
status: "success",
|
||||||
|
progress: 100,
|
||||||
|
modelUrl: stableModelUrl,
|
||||||
|
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||||||
|
error: null,
|
||||||
|
assetPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("external task success status", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
taskIdLength: normalizedTaskId.length,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
hasMesh: Boolean(stableModelUrl),
|
||||||
|
hasPano: Boolean(stablePanoUrl),
|
||||||
|
hasThumbnail: Boolean(stableThumbnailUrl),
|
||||||
|
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "world",
|
||||||
|
status: "success",
|
||||||
|
progress: 100,
|
||||||
|
output: externalData,
|
||||||
|
modelUrl: stableModelUrl,
|
||||||
|
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||||||
|
worldId: assetPayload.worldId,
|
||||||
|
worldUrl: stableWorldUrl,
|
||||||
|
panoUrl: stablePanoUrl,
|
||||||
|
caption: assetPayload.caption,
|
||||||
|
assetPayload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
||||||
|
|||||||
@ -12,13 +12,23 @@ import {
|
|||||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { get3DWorldCreditCost } from "@/lib/points/config";
|
import { get3DWorldCreditCost } from "@/lib/points/config";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
import {
|
||||||
|
isWorldLabsExternalTaskApi,
|
||||||
|
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
WorldLabsApiError,
|
||||||
|
worldLabsRequest,
|
||||||
|
} from "@/lib/worldlabs/client";
|
||||||
import {
|
import {
|
||||||
getWorldLabsProviderConfig,
|
getWorldLabsProviderConfig,
|
||||||
getWorldLabsProviderConfigs,
|
getWorldLabsProviderConfigs,
|
||||||
reportWorldLabsProviderRuntimeFailure,
|
reportWorldLabsProviderRuntimeFailure,
|
||||||
} from "@/lib/worldlabs/server";
|
} from "@/lib/worldlabs/server";
|
||||||
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
|
import type {
|
||||||
|
WorldLabsExternalTaskCreateData,
|
||||||
|
WorldLabsExternalTaskResponse,
|
||||||
|
WorldLabsModel,
|
||||||
|
WorldLabsOperation,
|
||||||
|
} from "@/lib/worldlabs/types";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 180;
|
export const maxDuration = 180;
|
||||||
@ -47,8 +57,10 @@ type CreateWorldTaskRequest = {
|
|||||||
makePublic?: boolean;
|
makePublic?: boolean;
|
||||||
reconstructImages?: boolean;
|
reconstructImages?: boolean;
|
||||||
mediaAssetId?: string;
|
mediaAssetId?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
mediaAssets?: Array<{
|
mediaAssets?: Array<{
|
||||||
mediaAssetId?: string;
|
mediaAssetId?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
azimuth?: number | null;
|
azimuth?: number | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@ -58,10 +70,89 @@ function readOptionalTrimmedText(input: unknown) {
|
|||||||
return value || null;
|
return value || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalHttpUrl(input: unknown) {
|
||||||
|
const value = readOptionalTrimmedText(input);
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||||||
|
? parsed.toString()
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObjectValue(value: unknown) {
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
|
||||||
|
const root = getObjectValue(payload);
|
||||||
|
const data = getObjectValue(root?.data);
|
||||||
|
return (data || root || {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
|
||||||
|
const normalized = String(status || "").trim().toLowerCase();
|
||||||
|
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalTaskCreateOperation(
|
||||||
|
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||||||
|
): WorldLabsOperation {
|
||||||
|
const data = unwrapExternalTaskData<WorldLabsExternalTaskCreateData>(payload);
|
||||||
|
const taskId = readOptionalTrimmedText(data.taskId || data.task_id || data.id);
|
||||||
|
const status = readOptionalTrimmedText(data.status) || "accepted";
|
||||||
|
const normalizedStatus = normalizeExternalTaskStatus(status, data.done === true);
|
||||||
|
return {
|
||||||
|
operation_id: taskId || undefined,
|
||||||
|
done: normalizedStatus === "success",
|
||||||
|
error:
|
||||||
|
normalizedStatus === "failed"
|
||||||
|
? {
|
||||||
|
message:
|
||||||
|
readOptionalTrimmedText(
|
||||||
|
(payload as Record<string, unknown>)?.message ||
|
||||||
|
(payload as Record<string, unknown>)?.error
|
||||||
|
) || "World Labs 外部任务创建失败",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
metadata: {
|
||||||
|
externalTaskApi: true,
|
||||||
|
status,
|
||||||
|
resultPath: readOptionalTrimmedText(data.resultPath || data.result_path),
|
||||||
|
requestId: readOptionalTrimmedText(payload.requestId || payload.request_id),
|
||||||
|
},
|
||||||
|
response: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function shouldCharge3DPoints(mode: WorldTaskMode) {
|
function shouldCharge3DPoints(mode: WorldTaskMode) {
|
||||||
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
|
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseExternalTaskCreateApi(
|
||||||
|
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||||||
|
mode: WorldTaskMode
|
||||||
|
) {
|
||||||
|
return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOfficialMarbleModel(model?: string | null) {
|
||||||
|
const normalized = readOptionalTrimmedText(model);
|
||||||
|
if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
|
||||||
|
return "marble-1.1";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||||
const textPrompt = readOptionalTrimmedText(body.prompt);
|
const textPrompt = readOptionalTrimmedText(body.prompt);
|
||||||
|
|
||||||
@ -80,15 +171,21 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
|
|
||||||
if (mode === "image") {
|
if (mode === "image") {
|
||||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||||
if (!mediaAssetId) {
|
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||||
throw new Error("图生世界缺少 mediaAssetId");
|
if (!mediaAssetId && !sourceUrl) {
|
||||||
|
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: "image",
|
type: "image",
|
||||||
image_prompt: {
|
image_prompt: mediaAssetId
|
||||||
source: "media_asset",
|
? {
|
||||||
media_asset_id: mediaAssetId,
|
source: "media_asset",
|
||||||
},
|
media_asset_id: mediaAssetId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: "uri",
|
||||||
|
uri: sourceUrl,
|
||||||
|
},
|
||||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||||
...(body.disableRecaption !== undefined
|
...(body.disableRecaption !== undefined
|
||||||
? { disable_recaption: body.disableRecaption }
|
? { disable_recaption: body.disableRecaption }
|
||||||
@ -102,7 +199,8 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
const multiImagePrompt = mediaAssets
|
const multiImagePrompt = mediaAssets
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
|
||||||
if (!mediaAssetId) return null;
|
const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
|
||||||
|
if (!mediaAssetId && !sourceUrl) return null;
|
||||||
const azimuth =
|
const azimuth =
|
||||||
item?.azimuth === null || item?.azimuth === undefined
|
item?.azimuth === null || item?.azimuth === undefined
|
||||||
? null
|
? null
|
||||||
@ -111,10 +209,15 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
...(azimuth === null ? {} : { azimuth }),
|
...(azimuth === null ? {} : { azimuth }),
|
||||||
content: {
|
content: mediaAssetId
|
||||||
source: "media_asset",
|
? {
|
||||||
media_asset_id: mediaAssetId,
|
source: "media_asset",
|
||||||
},
|
media_asset_id: mediaAssetId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: "uri",
|
||||||
|
uri: sourceUrl,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@ -137,16 +240,22 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||||
if (!mediaAssetId) {
|
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||||
throw new Error("视频生成世界缺少 mediaAssetId");
|
if (!mediaAssetId && !sourceUrl) {
|
||||||
|
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "video",
|
type: "video",
|
||||||
video_prompt: {
|
video_prompt: mediaAssetId
|
||||||
source: "media_asset",
|
? {
|
||||||
media_asset_id: mediaAssetId,
|
source: "media_asset",
|
||||||
},
|
media_asset_id: mediaAssetId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: "uri",
|
||||||
|
uri: sourceUrl,
|
||||||
|
},
|
||||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -187,12 +296,29 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
modelForLog = resolvedWorldModel || "marble-1.1";
|
modelForLog = resolvedWorldModel || "marble-1.1";
|
||||||
|
|
||||||
|
const preferredProviderId = readOptionalTrimmedText(body.providerId);
|
||||||
|
const displayName =
|
||||||
|
readOptionalTrimmedText(body.displayName) ||
|
||||||
|
readOptionalTrimmedText(body.prompt) ||
|
||||||
|
`World ${new Date().toLocaleString("zh-CN")}`;
|
||||||
|
const worldPrompt = buildWorldPrompt(body, mode);
|
||||||
|
|
||||||
|
const candidates = preferredProviderId
|
||||||
|
? [await getWorldLabsProviderConfig(preferredProviderId)]
|
||||||
|
: await getWorldLabsProviderConfigs();
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error("未找到可用的 World Labs Key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const officialMarbleModel = resolveOfficialMarbleModel(resolvedWorldModel);
|
||||||
|
const predictedBillingModel = candidates.some((candidate) =>
|
||||||
|
shouldUseExternalTaskCreateApi(candidate, mode)
|
||||||
|
)
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: officialMarbleModel;
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
const shouldChargePoints = shouldCharge3DPoints(mode);
|
const shouldChargePoints = shouldCharge3DPoints(mode);
|
||||||
const unitCost = get3DWorldCreditCost(
|
const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
|
||||||
pointsConfig,
|
|
||||||
resolvedWorldModel || "marble-1.1",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldChargePoints &&
|
shouldChargePoints &&
|
||||||
@ -220,42 +346,51 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredProviderId = readOptionalTrimmedText(body.providerId);
|
|
||||||
const displayName =
|
|
||||||
readOptionalTrimmedText(body.displayName) ||
|
|
||||||
readOptionalTrimmedText(body.prompt) ||
|
|
||||||
`World ${new Date().toLocaleString("zh-CN")}`;
|
|
||||||
const worldPrompt = buildWorldPrompt(body, mode);
|
|
||||||
|
|
||||||
const candidates = preferredProviderId
|
|
||||||
? [await getWorldLabsProviderConfig(preferredProviderId)]
|
|
||||||
: await getWorldLabsProviderConfigs();
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
throw new Error("未找到可用的 World Labs Key");
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: WorldLabsOperation | null = null;
|
let result: WorldLabsOperation | null = null;
|
||||||
let usedProvider = null as Awaited<
|
let usedProvider = null as Awaited<
|
||||||
ReturnType<typeof getWorldLabsProviderConfig>
|
ReturnType<typeof getWorldLabsProviderConfig>
|
||||||
> | null;
|
> | null;
|
||||||
|
let usedExternalTaskApi = false;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
|
||||||
method: "POST",
|
if (useExternalTaskApi) {
|
||||||
path: "/marble/v1/worlds:generate",
|
const prompt = readOptionalTrimmedText(body.prompt);
|
||||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
if (!prompt) {
|
||||||
json: {
|
throw new Error("请输入世界描述");
|
||||||
display_name: displayName,
|
}
|
||||||
model: resolvedWorldModel || "marble-1.1",
|
const externalResult = await worldLabsRequest<
|
||||||
permission: {
|
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||||||
public: body.makePublic === true,
|
>(candidate, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/external/task/generate",
|
||||||
|
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||||
|
json: {
|
||||||
|
model: WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
displayName,
|
||||||
|
prompt,
|
||||||
},
|
},
|
||||||
world_prompt: worldPrompt,
|
});
|
||||||
},
|
result = normalizeExternalTaskCreateOperation(externalResult);
|
||||||
});
|
} else {
|
||||||
|
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/marble/v1/worlds:generate",
|
||||||
|
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||||
|
json: {
|
||||||
|
display_name: displayName,
|
||||||
|
model: officialMarbleModel,
|
||||||
|
permission: {
|
||||||
|
public: body.makePublic === true,
|
||||||
|
},
|
||||||
|
world_prompt: worldPrompt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
usedProvider = candidate;
|
usedProvider = candidate;
|
||||||
|
usedExternalTaskApi = useExternalTaskApi;
|
||||||
providerForLog = candidate.providerId || null;
|
providerForLog = candidate.providerId || null;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -292,12 +427,18 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
||||||
try {
|
try {
|
||||||
|
const usedBillingModel = usedExternalTaskApi
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: officialMarbleModel;
|
||||||
|
const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: worldModelDisplayName;
|
||||||
await consumeUserPoints({
|
await consumeUserPoints({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
amount: unitCost,
|
amount: unitCost,
|
||||||
reason: "world3d_generation",
|
reason: "world3d_generation",
|
||||||
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
|
modelId: `worldlabs-3d:${usedBillingModel}`,
|
||||||
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
|
note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("points consume failed", error, {
|
logger.error("points consume failed", error, {
|
||||||
@ -313,10 +454,13 @@ export async function POST(request: Request) {
|
|||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
taskIdLength: taskId.length,
|
taskIdLength: taskId.length,
|
||||||
mode,
|
mode,
|
||||||
model: resolvedWorldModel || "marble-1.1",
|
model: usedExternalTaskApi
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: officialMarbleModel,
|
||||||
providerId: usedProvider.providerId || null,
|
providerId: usedProvider.providerId || null,
|
||||||
status: result.done ? "success" : "running",
|
status: result.done ? "success" : "running",
|
||||||
unitCost,
|
unitCost,
|
||||||
|
externalTaskApi: usedExternalTaskApi,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@ -327,6 +471,8 @@ export async function POST(request: Request) {
|
|||||||
assetPayload: {
|
assetPayload: {
|
||||||
providerId: usedProvider.providerId || null,
|
providerId: usedProvider.providerId || null,
|
||||||
providerName: usedProvider.providerName || null,
|
providerName: usedProvider.providerName || null,
|
||||||
|
externalTaskApi: usedExternalTaskApi,
|
||||||
|
marbleApi: !usedExternalTaskApi,
|
||||||
},
|
},
|
||||||
raw: result,
|
raw: result,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
import {
|
||||||
|
WorldLabsApiError,
|
||||||
|
worldLabsRequest,
|
||||||
|
} from "@/lib/worldlabs/client";
|
||||||
import {
|
import {
|
||||||
getWorldLabsProviderConfig,
|
getWorldLabsProviderConfig,
|
||||||
getWorldLabsProviderConfigs,
|
getWorldLabsProviderConfigs,
|
||||||
@ -112,6 +116,81 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
|
|||||||
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
|
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalString(value: unknown) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUploadInfoFromCurlExample(curlExample: unknown) {
|
||||||
|
const curl = String(curlExample || "");
|
||||||
|
const uploadUrl =
|
||||||
|
[...curl.matchAll(/https?:\/\/[^\s'"\\]+/g)]
|
||||||
|
.map((match) => match[0])
|
||||||
|
.find(Boolean) || null;
|
||||||
|
const requiredHeaders: Record<string, string> = {};
|
||||||
|
const headerPattern = /(?:-H|--header)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
|
||||||
|
let headerMatch: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((headerMatch = headerPattern.exec(curl))) {
|
||||||
|
const header = String(
|
||||||
|
headerMatch[1] || headerMatch[2] || headerMatch[3] || ""
|
||||||
|
).trim();
|
||||||
|
const colonIndex = header.indexOf(":");
|
||||||
|
if (colonIndex <= 0) continue;
|
||||||
|
const name = header.slice(0, colonIndex).trim();
|
||||||
|
const value = header.slice(colonIndex + 1).trim();
|
||||||
|
if (name && value) {
|
||||||
|
requiredHeaders[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploadUrl, requiredHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePrepareUploadDetails(data: WorldLabsPrepareUploadResponse) {
|
||||||
|
const uploadInfo = data.upload_info || {};
|
||||||
|
const uploadInfoRecord = uploadInfo as Record<string, unknown>;
|
||||||
|
const curlDetails = extractUploadInfoFromCurlExample(uploadInfo.curl_example);
|
||||||
|
const explicitHeaders =
|
||||||
|
uploadInfo.required_headers && typeof uploadInfo.required_headers === "object"
|
||||||
|
? uploadInfo.required_headers
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadUrl:
|
||||||
|
readOptionalString(uploadInfo.upload_url) ||
|
||||||
|
readOptionalString(uploadInfoRecord.uploadUrl) ||
|
||||||
|
readOptionalString(uploadInfoRecord.url) ||
|
||||||
|
curlDetails.uploadUrl,
|
||||||
|
uploadMethod:
|
||||||
|
readOptionalString(uploadInfo.upload_method) ||
|
||||||
|
readOptionalString(uploadInfoRecord.method) ||
|
||||||
|
"PUT",
|
||||||
|
requiredHeaders: {
|
||||||
|
...curlDetails.requiredHeaders,
|
||||||
|
...explicitHeaders,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSignedUploadFailure(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
|
return (
|
||||||
|
message.includes("SignatureDoesNotMatch") ||
|
||||||
|
message.includes("Access denied") ||
|
||||||
|
message.includes("Google secret key") ||
|
||||||
|
message.includes("签名")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadWorldLabsInputToStorage(file: File, extension: string) {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const safeExtension = extension && extension !== "bin" ? extension : "bin";
|
||||||
|
const filename = `worldlabs-input-${Date.now()}-${crypto.randomUUID()}.${safeExtension}`;
|
||||||
|
const uploaded = await uploadFile(filename, buffer);
|
||||||
|
return uploaded.url;
|
||||||
|
}
|
||||||
|
|
||||||
function isCredentialOrBillingProviderError(error: unknown) {
|
function isCredentialOrBillingProviderError(error: unknown) {
|
||||||
return (
|
return (
|
||||||
error instanceof WorldLabsApiError &&
|
error instanceof WorldLabsApiError &&
|
||||||
@ -202,24 +281,24 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
||||||
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
|
const { uploadUrl, uploadMethod, requiredHeaders } =
|
||||||
|
resolvePrepareUploadDetails(prepareUpload);
|
||||||
if (!mediaAssetId || !uploadUrl) {
|
if (!mediaAssetId || !uploadUrl) {
|
||||||
throw new Error("World Labs 返回的上传信息不完整");
|
throw new Error("World Labs 返回的上传信息不完整");
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadHeaders = new Headers();
|
const uploadHeaders = new Headers();
|
||||||
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
|
|
||||||
const requiredHeaders = prepareUpload.upload_info?.required_headers || {};
|
|
||||||
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
|
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
|
||||||
if (!headerName || headerValue === undefined || headerValue === null) continue;
|
if (!headerName || headerValue === undefined || headerValue === null) continue;
|
||||||
uploadHeaders.set(headerName, String(headerValue));
|
uploadHeaders.set(headerName, String(headerValue));
|
||||||
}
|
}
|
||||||
|
if (!uploadHeaders.has("Content-Type") && !prepareUpload.upload_info?.curl_example) {
|
||||||
|
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
const uploadMethod = String(
|
const normalizedUploadMethod = uploadMethod.toUpperCase();
|
||||||
prepareUpload.upload_info?.upload_method || "PUT"
|
|
||||||
).toUpperCase();
|
|
||||||
const uploadResponse = await fetch(uploadUrl, {
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
|
method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
|
||||||
headers: uploadHeaders,
|
headers: uploadHeaders,
|
||||||
body: file,
|
body: file,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@ -252,6 +331,28 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
if (isSignedUploadFailure(error)) {
|
||||||
|
try {
|
||||||
|
const sourceUrl = await uploadWorldLabsInputToStorage(file, extension);
|
||||||
|
logger.warn("worldlabs signed upload failed; using storage source url", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
kind: isImage ? "image" : "video",
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sourceUrl,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
providerName: config.providerName || null,
|
||||||
|
uploadFallback: "storage-source-url",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fallbackError) {
|
||||||
|
lastError = fallbackError;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isCredentialOrBillingProviderError(error)) {
|
if (isCredentialOrBillingProviderError(error)) {
|
||||||
await reportWorldLabsProviderRuntimeFailure(
|
await reportWorldLabsProviderRuntimeFailure(
|
||||||
config.providerId,
|
config.providerId,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { clientPerformanceEvent } from "@/lib/db/schema";
|
import { clientPerformanceEvent } from "@/lib/db/schema";
|
||||||
|
|
||||||
@ -167,9 +168,12 @@ export async function POST(request: Request) {
|
|||||||
console.warn("[ClientPerformance] prune failed", error);
|
console.warn("[ClientPerformance] prune failed", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const persistedChatId =
|
||||||
|
event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
|
||||||
|
|
||||||
await db.insert(clientPerformanceEvent).values({
|
await db.insert(clientPerformanceEvent).values({
|
||||||
userId: session?.user?.id || null,
|
userId: session?.user?.id || null,
|
||||||
chatId: event.chatId || null,
|
chatId: persistedChatId,
|
||||||
projectId: clampText(event.projectId, 64),
|
projectId: clampText(event.projectId, 64),
|
||||||
eventType: clampText(event.eventType, 64) || "unknown",
|
eventType: clampText(event.eventType, 64) || "unknown",
|
||||||
section: event.section || "unknown",
|
section: event.section || "unknown",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
createMediaAssetRelation,
|
createMediaAssetRelation,
|
||||||
|
getAiProvidersByType,
|
||||||
getMediaAssetsByUserAndUrls,
|
getMediaAssetsByUserAndUrls,
|
||||||
getSystemSetting,
|
getSystemSetting,
|
||||||
upsertMediaAsset,
|
upsertMediaAsset,
|
||||||
@ -9,9 +10,12 @@ import { auth } from "@/app/(auth)/auth";
|
|||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage } from "@/lib/db/schema";
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
|
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
|
||||||
|
import { createFalClient } from "@fal-ai/client";
|
||||||
|
|
||||||
// 变清晰API配置类型
|
// 变清晰API配置类型
|
||||||
type EnhanceApiConfig = {
|
type EnhanceApiConfig = {
|
||||||
|
provider?: "vip98k" | "aoscdn";
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
type?: string; // "clean" | "face"
|
type?: string; // "clean" | "face"
|
||||||
@ -317,6 +321,155 @@ async function processWithRealESRGANBase64(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function processWithFalEsrgan(
|
||||||
|
imageBase64: string,
|
||||||
|
requestedScaleFactor: number,
|
||||||
|
): Promise<{ imageBase64: string; processingTime: number }> {
|
||||||
|
const providers = await getAiProvidersByType({
|
||||||
|
type: "fal-image",
|
||||||
|
enabledOnly: true,
|
||||||
|
});
|
||||||
|
const provider = providers.find((item) => item.apiKey?.trim());
|
||||||
|
if (!provider?.apiKey) {
|
||||||
|
throw new Error("未配置可用的 FAL 专用高清服务");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.max(2, Math.min(8, requestedScaleFactor || 4));
|
||||||
|
const model = scale <= 2 ? "RealESRGAN_x2plus" : "RealESRGAN_x4plus";
|
||||||
|
const credentials = provider.apiKey
|
||||||
|
.trim()
|
||||||
|
.replace(/^(?:Key|Bearer)\s+/i, "");
|
||||||
|
const fal = createFalClient({ credentials });
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const result: any = await fal.subscribe("fal-ai/esrgan", {
|
||||||
|
input: {
|
||||||
|
image_url: `data:image/jpeg;base64,${imageBase64}`,
|
||||||
|
scale,
|
||||||
|
model,
|
||||||
|
output_format: "png",
|
||||||
|
},
|
||||||
|
logs: true,
|
||||||
|
timeout: 180_000,
|
||||||
|
});
|
||||||
|
const outputUrl = result?.data?.image?.url || result?.image?.url;
|
||||||
|
if (!outputUrl) {
|
||||||
|
throw new Error("FAL ESRGAN 专用高清服务未返回图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputResponse = await fetch(outputUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(120_000),
|
||||||
|
});
|
||||||
|
if (!outputResponse.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`FAL ESRGAN 高清结果下载失败(HTTP ${outputResponse.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageBase64: Buffer.from(await outputResponse.arrayBuffer()).toString(
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
processingTime: (Date.now() - startedAt) / 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithVip98kEnhance(params: {
|
||||||
|
userId: string;
|
||||||
|
imageBase64: string;
|
||||||
|
prompt: string;
|
||||||
|
chatId: string | null;
|
||||||
|
messageId: string | null;
|
||||||
|
sourceImageUrl: string | null;
|
||||||
|
scaleFactor: number;
|
||||||
|
}) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const result = await executeConfiguredVip98kTask({
|
||||||
|
modelId: "enhance",
|
||||||
|
kind: "image-tool",
|
||||||
|
prompt: `${params.prompt || "Enhance image clarity"}. Upscale ${params.scaleFactor}x while preserving composition, color and texture.`,
|
||||||
|
imageUrls: [`data:image/jpeg;base64,${params.imageBase64}`],
|
||||||
|
imageParams: { scaleFactor: params.scaleFactor },
|
||||||
|
});
|
||||||
|
const outputUrl = result.outputUrls[0];
|
||||||
|
if (!outputUrl) throw new Error("VIP98K 变清晰未返回图片");
|
||||||
|
|
||||||
|
const response = await fetch(outputUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(120_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`VIP98K 变清晰结果下载失败(HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
const outputBuffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
const outputBase64 = outputBuffer.toString("base64");
|
||||||
|
const uploaded = await uploadBase64ToS3(outputBase64, "image/png");
|
||||||
|
const [savedImage] = await db
|
||||||
|
.insert(generatedImage)
|
||||||
|
.values({
|
||||||
|
userId: params.userId,
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
thumbnailUrl: uploaded.thumbnail,
|
||||||
|
originalUrl: uploaded.original,
|
||||||
|
imageSize: `${params.scaleFactor}x放大`,
|
||||||
|
prompt: params.prompt,
|
||||||
|
modelId: "enhance",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceAssets = params.sourceImageUrl
|
||||||
|
? await getMediaAssetsByUserAndUrls({
|
||||||
|
userId: params.userId,
|
||||||
|
urls: [params.sourceImageUrl],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const media = await upsertMediaAsset({
|
||||||
|
userId: params.userId,
|
||||||
|
chatId: params.chatId,
|
||||||
|
mediaType: "image",
|
||||||
|
sourceType: "edited",
|
||||||
|
status: "ready",
|
||||||
|
originalUrl: uploaded.original,
|
||||||
|
previewUrl: uploaded.original,
|
||||||
|
thumbnailUrl: uploaded.thumbnail,
|
||||||
|
displayName: params.prompt.slice(0, 80),
|
||||||
|
prompt: params.prompt,
|
||||||
|
modelId: "enhance",
|
||||||
|
sourceMessageId: params.messageId,
|
||||||
|
sourceTable: "GeneratedImage",
|
||||||
|
sourceRowId: savedImage?.id || null,
|
||||||
|
primaryParentAssetId: sourceAssets[0]?.id ?? null,
|
||||||
|
metadata: { provider: "vip98k", scaleFactor: params.scaleFactor },
|
||||||
|
});
|
||||||
|
if (media) {
|
||||||
|
for (const sourceAsset of sourceAssets) {
|
||||||
|
await createMediaAssetRelation({
|
||||||
|
userId: params.userId,
|
||||||
|
chatId: params.chatId,
|
||||||
|
fromAssetId: sourceAsset.id,
|
||||||
|
toAssetId: media.id,
|
||||||
|
relationType: "edited_from",
|
||||||
|
sourceMessageId: params.messageId,
|
||||||
|
metadata: { prompt: params.prompt, provider: "vip98k" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Enhance-VIP98K] MediaAsset registration failed", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
image_base64: outputBase64,
|
||||||
|
thumbnail_url: uploaded.thumbnail,
|
||||||
|
original_url: uploaded.original,
|
||||||
|
processing_time: (Date.now() - startedAt) / 1000,
|
||||||
|
api_type: "vip98k",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轮询获取任务结果(根据官方示例)
|
* 轮询获取任务结果(根据官方示例)
|
||||||
*/
|
*/
|
||||||
@ -536,6 +689,42 @@ async function finalizeEnhanceTaskResult(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function executeFalEsrganFallback(params: {
|
||||||
|
userId: string;
|
||||||
|
imageBase64: string;
|
||||||
|
prompt: string;
|
||||||
|
chatId: string | null;
|
||||||
|
messageId: string | null;
|
||||||
|
sourceImageUrl: string | null;
|
||||||
|
requestedScaleFactor?: number;
|
||||||
|
reason: string;
|
||||||
|
}) {
|
||||||
|
const scaleFactor = params.requestedScaleFactor || 4;
|
||||||
|
console.warn("[Enhance-FAL-ESRGAN] 启用专用高清备用链路", {
|
||||||
|
reason: params.reason.slice(0, 500),
|
||||||
|
scaleFactor,
|
||||||
|
});
|
||||||
|
const result = await processWithFalEsrgan(params.imageBase64, scaleFactor);
|
||||||
|
const payload = await finalizeEnhanceTaskResult({
|
||||||
|
userId: params.userId,
|
||||||
|
taskResult: {
|
||||||
|
status: 200,
|
||||||
|
data: { image_base64: result.imageBase64 },
|
||||||
|
},
|
||||||
|
prompt: params.prompt || "图片变清晰处理",
|
||||||
|
chatId: params.chatId,
|
||||||
|
messageId: params.messageId,
|
||||||
|
sourceImageUrls: params.sourceImageUrl
|
||||||
|
? [params.sourceImageUrl]
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
processing_time: result.processingTime,
|
||||||
|
api_type: "fal-esrgan",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -563,6 +752,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = enhanceConfig.value as EnhanceApiConfig;
|
const config = enhanceConfig.value as EnhanceApiConfig;
|
||||||
|
if (config.provider === "vip98k") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "VIP98K 变清晰任务在 POST 请求内完成,无需 taskId 轮询" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
if (config.useBackupApi) {
|
if (config.useBackupApi) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "当前启用了Real-ESRGAN备用模式,不支持 taskId 轮询" },
|
{ error: "当前启用了Real-ESRGAN备用模式,不支持 taskId 轮询" },
|
||||||
@ -651,12 +846,15 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = enhanceConfig.value as EnhanceApiConfig;
|
const config = enhanceConfig.value as EnhanceApiConfig;
|
||||||
|
const useVip98k = config.provider === "vip98k";
|
||||||
|
|
||||||
// 检查是否使用备用API
|
// 检查是否使用备用API
|
||||||
const useBackupApi = config.useBackupApi === true;
|
const useBackupApi = !useVip98k && config.useBackupApi === true;
|
||||||
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
|
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
|
||||||
|
|
||||||
if (useBackupApi) {
|
if (useVip98k) {
|
||||||
|
// VIP98K 密钥只从 AiProvider 读取。
|
||||||
|
} else if (useBackupApi) {
|
||||||
// 验证备用API配置
|
// 验证备用API配置
|
||||||
if (!config.backupApiUrl || !config.backupApiKey) {
|
if (!config.backupApiUrl || !config.backupApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -757,6 +955,31 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useVip98k) {
|
||||||
|
try {
|
||||||
|
const scaleFactor = requestedScaleFactor || 4;
|
||||||
|
const payload = await processWithVip98kEnhance({
|
||||||
|
userId,
|
||||||
|
imageBase64,
|
||||||
|
prompt: prompt || "图片变清晰处理",
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sourceImageUrl,
|
||||||
|
scaleFactor,
|
||||||
|
});
|
||||||
|
return NextResponse.json(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Enhance-VIP98K] Processing failed", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "VIP98K 变清晰处理失败",
|
||||||
|
detail: error instanceof Error ? error.message : "未知错误",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 备用API处理路径 (Real-ESRGAN) ==========
|
// ========== 备用API处理路径 (Real-ESRGAN) ==========
|
||||||
if (useBackupApi) {
|
if (useBackupApi) {
|
||||||
console.log("[Enhance API] 使用Real-ESRGAN备用API处理(URL方式)...");
|
console.log("[Enhance API] 使用Real-ESRGAN备用API处理(URL方式)...");
|
||||||
@ -894,6 +1117,21 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Enhance-RealESRGAN] ❌ 处理失败:", error);
|
console.error("[Enhance-RealESRGAN] ❌ 处理失败:", error);
|
||||||
|
try {
|
||||||
|
const fallbackPayload = await executeFalEsrganFallback({
|
||||||
|
userId,
|
||||||
|
imageBase64,
|
||||||
|
prompt,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sourceImageUrl,
|
||||||
|
requestedScaleFactor,
|
||||||
|
reason: error instanceof Error ? error.message : "RealESRGAN 备用服务失败",
|
||||||
|
});
|
||||||
|
return NextResponse.json(fallbackPayload);
|
||||||
|
} catch (falError) {
|
||||||
|
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
|
||||||
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: "Real-ESRGAN变清晰处理失败",
|
error: "Real-ESRGAN变清晰处理失败",
|
||||||
@ -1048,6 +1286,22 @@ export async function POST(request: NextRequest) {
|
|||||||
enhanceUrl,
|
enhanceUrl,
|
||||||
processingTime,
|
processingTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fallbackPayload = await executeFalEsrganFallback({
|
||||||
|
userId,
|
||||||
|
imageBase64,
|
||||||
|
prompt,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sourceImageUrl,
|
||||||
|
requestedScaleFactor,
|
||||||
|
reason: fetchError instanceof Error ? fetchError.message : "AOS 高清任务创建失败",
|
||||||
|
});
|
||||||
|
return NextResponse.json(fallbackPayload);
|
||||||
|
} catch (falError) {
|
||||||
|
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否是网络连接错误
|
// 检查是否是网络连接错误
|
||||||
if (fetchError instanceof Error) {
|
if (fetchError instanceof Error) {
|
||||||
@ -1114,6 +1368,21 @@ export async function POST(request: NextRequest) {
|
|||||||
return buildEnhanceTaskPendingResponse(taskId);
|
return buildEnhanceTaskPendingResponse(taskId);
|
||||||
}
|
}
|
||||||
const detail = pollError instanceof Error ? pollError.message : "轮询失败";
|
const detail = pollError instanceof Error ? pollError.message : "轮询失败";
|
||||||
|
try {
|
||||||
|
const fallbackPayload = await executeFalEsrganFallback({
|
||||||
|
userId,
|
||||||
|
imageBase64,
|
||||||
|
prompt,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
sourceImageUrl,
|
||||||
|
requestedScaleFactor,
|
||||||
|
reason: detail,
|
||||||
|
});
|
||||||
|
return NextResponse.json(fallbackPayload);
|
||||||
|
} catch (falError) {
|
||||||
|
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
|
||||||
|
}
|
||||||
const friendlyMessage = getAosFriendlyErrorMessage(detail);
|
const friendlyMessage = getAosFriendlyErrorMessage(detail);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "变清晰处理失败", detail: friendlyMessage || detail },
|
{ error: "变清晰处理失败", detail: friendlyMessage || detail },
|
||||||
|
|||||||
119
app/api/external/image/generate/route.ts
vendored
119
app/api/external/image/generate/route.ts
vendored
@ -21,103 +21,6 @@ import {
|
|||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 1200;
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__";
|
|
||||||
const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2;
|
|
||||||
const DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE = 8;
|
|
||||||
const DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS = 15_000;
|
|
||||||
|
|
||||||
class ExternalImageApiBusyError extends Error {
|
|
||||||
readonly retryAfterSeconds: number;
|
|
||||||
|
|
||||||
constructor(message: string, retryAfterSeconds = 15) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ExternalImageApiBusyError";
|
|
||||||
this.retryAfterSeconds = retryAfterSeconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExternalImageApiWaiter = {
|
|
||||||
resolve: (release: () => void) => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
timer: ReturnType<typeof setTimeout>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExternalImageApiLimiterState = {
|
|
||||||
running: number;
|
|
||||||
queue: ExternalImageApiWaiter[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function parsePositiveInteger(value: unknown, fallback: number) {
|
|
||||||
const parsed = Number.parseInt(String(value || ""), 10);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExternalImageApiLimiterState(): ExternalImageApiLimiterState {
|
|
||||||
const globalState = globalThis as typeof globalThis & {
|
|
||||||
[EXTERNAL_IMAGE_API_LIMITER_KEY]?: ExternalImageApiLimiterState;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]) {
|
|
||||||
globalState[EXTERNAL_IMAGE_API_LIMITER_KEY] = {
|
|
||||||
running: 0,
|
|
||||||
queue: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function acquireExternalImageApiSlot(): Promise<() => void> {
|
|
||||||
const state = getExternalImageApiLimiterState();
|
|
||||||
const maxConcurrent = parsePositiveInteger(
|
|
||||||
process.env.EXTERNAL_IMAGE_API_MAX_CONCURRENT,
|
|
||||||
DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT,
|
|
||||||
);
|
|
||||||
const maxQueue = parsePositiveInteger(
|
|
||||||
process.env.EXTERNAL_IMAGE_API_MAX_QUEUE,
|
|
||||||
DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE,
|
|
||||||
);
|
|
||||||
const queueTimeoutMs = parsePositiveInteger(
|
|
||||||
process.env.EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
|
|
||||||
DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
const release = () => {
|
|
||||||
state.running = Math.max(0, state.running - 1);
|
|
||||||
const next = state.queue.shift();
|
|
||||||
if (!next) return;
|
|
||||||
clearTimeout(next.timer);
|
|
||||||
state.running += 1;
|
|
||||||
next.resolve(release);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (state.running < maxConcurrent) {
|
|
||||||
state.running += 1;
|
|
||||||
return Promise.resolve(release);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.queue.length >= maxQueue) {
|
|
||||||
return Promise.reject(
|
|
||||||
new ExternalImageApiBusyError("图片生成服务繁忙,请稍后重试"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const waiter: ExternalImageApiWaiter = {
|
|
||||||
resolve,
|
|
||||||
reject,
|
|
||||||
timer: setTimeout(() => {
|
|
||||||
const index = state.queue.indexOf(waiter);
|
|
||||||
if (index >= 0) {
|
|
||||||
state.queue.splice(index, 1);
|
|
||||||
}
|
|
||||||
reject(new ExternalImageApiBusyError("图片生成排队超时,请稍后重试"));
|
|
||||||
}, queueTimeoutMs),
|
|
||||||
};
|
|
||||||
state.queue.push(waiter);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
||||||
negativePrompt: z.string().trim().optional(),
|
negativePrompt: z.string().trim().optional(),
|
||||||
@ -294,10 +197,8 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let logId: string | null = null;
|
let logId: string | null = null;
|
||||||
let releaseExternalImageApiSlot: (() => void) | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
|
|
||||||
const payload = await parseRequestPayload(request);
|
const payload = await parseRequestPayload(request);
|
||||||
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
|
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
|
||||||
const candidateModels = await buildExternalImageApiModelCandidates({
|
const candidateModels = await buildExternalImageApiModelCandidates({
|
||||||
@ -344,6 +245,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const generationResult = await generateImagesWithFailover({
|
const generationResult = await generateImagesWithFailover({
|
||||||
candidates: candidateModels,
|
candidates: candidateModels,
|
||||||
|
ownerKey: `external-image-api:${authResult.accessKey.id}`,
|
||||||
prompt: payload.prompt,
|
prompt: payload.prompt,
|
||||||
negativePrompt: payload.negativePrompt || null,
|
negativePrompt: payload.negativePrompt || null,
|
||||||
imageCount: payload.imageCount || 1,
|
imageCount: payload.imageCount || 1,
|
||||||
@ -467,21 +369,6 @@ export async function POST(request: Request) {
|
|||||||
.where(eq(externalImageApiRequestLog.id, logId));
|
.where(eq(externalImageApiRequestLog.id, logId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ExternalImageApiBusyError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
"Retry-After": String(error.retryAfterSeconds),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@ -489,9 +376,5 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
if (releaseExternalImageApiSlot) {
|
|
||||||
releaseExternalImageApiSlot();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,15 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
|
|||||||
const falTaskLastPolledAt = new Map<string, number>();
|
const falTaskLastPolledAt = new Map<string, number>();
|
||||||
const falTaskSyncInFlight = new Set<string>();
|
const falTaskSyncInFlight = new Set<string>();
|
||||||
|
|
||||||
|
function toPublicVideoMetadata(value: DBMessage["videoMetadata"]) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||||
|
const metadata = value as Record<string, unknown>;
|
||||||
|
if (metadata.taskKind !== "clawd_chat") return value;
|
||||||
|
const publicMetadata = { ...metadata };
|
||||||
|
delete publicMetadata.upstreamTaskKey;
|
||||||
|
return publicMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
function createEmptyMessagesResponse() {
|
function createEmptyMessagesResponse() {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ messages: [] },
|
{ messages: [] },
|
||||||
@ -277,7 +286,7 @@ export async function GET(request: NextRequest) {
|
|||||||
createdAt: msg.createdAt,
|
createdAt: msg.createdAt,
|
||||||
parts: parts,
|
parts: parts,
|
||||||
attachments: msg.attachments || [],
|
attachments: msg.attachments || [],
|
||||||
videoMetadata: msg.videoMetadata || null,
|
videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
148
app/api/openapi/v1/access-keys/provision/route.ts
Normal file
148
app/api/openapi/v1/access-keys/provision/route.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { and, eq, isNotNull } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
createUserAccessKey,
|
||||||
|
listUserAccessKeys,
|
||||||
|
} from "@/lib/access-keys/service";
|
||||||
|
import {
|
||||||
|
MIN_PROVISIONING_SECRET_LENGTH,
|
||||||
|
parseProvisioningBearerSecret,
|
||||||
|
verifyProvisioningSecret,
|
||||||
|
} from "@/lib/access-keys/provisioning-secret";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { user } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
const provisionAccessKeySchema = z.object({
|
||||||
|
dingTalkUsername: z.string().trim().min(1).max(100),
|
||||||
|
dingTalkUserId: z.string().trim().min(1).max(128).optional(),
|
||||||
|
keyName: z.string().trim().min(1).max(100).optional(),
|
||||||
|
scopes: z
|
||||||
|
.array(z.enum(["canvas:read", "canvas:write"]))
|
||||||
|
.min(1)
|
||||||
|
.optional(),
|
||||||
|
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonError(error: string, code: string, status: number) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error, code },
|
||||||
|
{ status, headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const expectedSecret = String(
|
||||||
|
process.env.MCP_KEY_PROVISIONING_SECRET || ""
|
||||||
|
).trim();
|
||||||
|
if (expectedSecret.length < MIN_PROVISIONING_SECRET_LENGTH) {
|
||||||
|
return jsonError(
|
||||||
|
"Access Key provisioning is not configured",
|
||||||
|
"PROVISIONING_NOT_CONFIGURED",
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedSecret = parseProvisioningBearerSecret(
|
||||||
|
request.headers.get("authorization")
|
||||||
|
);
|
||||||
|
if (!verifyProvisioningSecret(providedSecret, expectedSecret)) {
|
||||||
|
return jsonError(
|
||||||
|
"Missing or invalid provisioning credential",
|
||||||
|
"PROVISIONING_UNAUTHORIZED",
|
||||||
|
401
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = provisionAccessKeySchema.safeParse(
|
||||||
|
await request.json().catch(() => null)
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return jsonError(
|
||||||
|
"Invalid provisioning request",
|
||||||
|
"PROVISIONING_REQUEST_INVALID",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsedBody.data;
|
||||||
|
const conditions = [
|
||||||
|
eq(user.name, body.dingTalkUsername),
|
||||||
|
eq(user.isActive, true),
|
||||||
|
isNotNull(user.dingtalkUserId),
|
||||||
|
];
|
||||||
|
if (body.dingTalkUserId) {
|
||||||
|
conditions.push(eq(user.dingtalkUserId, body.dingTalkUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await db
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
dingtalkUserId: user.dingtalkUserId,
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(2);
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return jsonError(
|
||||||
|
"No active DingTalk user matched the supplied identity",
|
||||||
|
"DINGTALK_USER_NOT_FOUND",
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
return jsonError(
|
||||||
|
"DingTalk username is not unique; provide dingTalkUserId",
|
||||||
|
"DINGTALK_USERNAME_AMBIGUOUS",
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedUser = matches[0];
|
||||||
|
const existingKeys = await listUserAccessKeys(matchedUser.id);
|
||||||
|
const activeKeyCount = existingKeys.filter(
|
||||||
|
(item) =>
|
||||||
|
!item.revokedAt &&
|
||||||
|
(!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
|
||||||
|
).length;
|
||||||
|
if (activeKeyCount >= 10) {
|
||||||
|
return jsonError(
|
||||||
|
"The user already has 10 active Access Keys",
|
||||||
|
"ACCESS_KEY_LIMIT_REACHED",
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessKey = await createUserAccessKey({
|
||||||
|
userId: matchedUser.id,
|
||||||
|
name: body.keyName || "第三方 OpenClaw",
|
||||||
|
scopes: body.scopes,
|
||||||
|
expiresInDays: body.expiresInDays,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: matchedUser,
|
||||||
|
accessKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
Pragma: "no-cache",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return jsonError(
|
||||||
|
"Failed to provision Access Key",
|
||||||
|
"ACCESS_KEY_PROVISION_FAILED",
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/openapi/v1/canvases/[id]/operations/route.ts
Normal file
48
app/api/openapi/v1/canvases/[id]/operations/route.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
|
||||||
|
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
||||||
|
import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
openApiErrorResponse,
|
||||||
|
requireOpenApiPrincipal,
|
||||||
|
} from "@/lib/access-keys/openapi-auth";
|
||||||
|
|
||||||
|
const canvasIdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const principal = await requireOpenApiPrincipal(request, "canvas:write");
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const parsedId = canvasIdSchema.safeParse(rawId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Invalid canvas ID",
|
||||||
|
code: "INVALID_CANVAS_ID",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parsedId.data;
|
||||||
|
const response = await handleCanvasAgentOperationRouteRequest({
|
||||||
|
userId: principal.userId,
|
||||||
|
projectId: id,
|
||||||
|
readBody: () => request.json(),
|
||||||
|
features: await getCanvasAgentFeatureFlagsFromSystemSettings(),
|
||||||
|
applyOperation: applyCanvasOperationPlan,
|
||||||
|
logger: console,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return openApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/api/openapi/v1/canvases/[id]/route.ts
Normal file
57
app/api/openapi/v1/canvases/[id]/route.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
openApiErrorResponse,
|
||||||
|
requireOpenApiPrincipal,
|
||||||
|
} from "@/lib/access-keys/openapi-auth";
|
||||||
|
|
||||||
|
const canvasIdSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const principal = await requireOpenApiPrincipal(request, "canvas:read");
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const parsedId = canvasIdSchema.safeParse(rawId);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Invalid canvas ID",
|
||||||
|
code: "INVALID_CANVAS_ID",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parsedId.data;
|
||||||
|
const [project] = await db
|
||||||
|
.select()
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.id, id),
|
||||||
|
eq(canvasProject.userId, principal.userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "Canvas not found", code: "CANVAS_NOT_FOUND" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ success: true, data: project },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return openApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/api/openapi/v1/canvases/route.ts
Normal file
52
app/api/openapi/v1/canvases/route.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { canvasProject } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
openApiErrorResponse,
|
||||||
|
requireOpenApiPrincipal,
|
||||||
|
} from "@/lib/access-keys/openapi-auth";
|
||||||
|
|
||||||
|
function normalizeWorkspaceType(value: string | null) {
|
||||||
|
return value === "agent" || value === "video" ? value : "canvas";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const principal = await requireOpenApiPrincipal(request, "canvas:read");
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const workspaceType = normalizeWorkspaceType(
|
||||||
|
url.searchParams.get("workspaceType")
|
||||||
|
);
|
||||||
|
const requestedLimit = Number(url.searchParams.get("limit") || 24);
|
||||||
|
const limit = Number.isFinite(requestedLimit)
|
||||||
|
? Math.min(50, Math.max(1, Math.round(requestedLimit)))
|
||||||
|
: 24;
|
||||||
|
|
||||||
|
const projects = await db
|
||||||
|
.select({
|
||||||
|
id: canvasProject.id,
|
||||||
|
title: canvasProject.title,
|
||||||
|
description: canvasProject.description,
|
||||||
|
thumbnail: canvasProject.thumbnail,
|
||||||
|
workspaceType: canvasProject.workspaceType,
|
||||||
|
createdAt: canvasProject.createdAt,
|
||||||
|
updatedAt: canvasProject.updatedAt,
|
||||||
|
})
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.userId, principal.userId),
|
||||||
|
eq(canvasProject.workspaceType, workspaceType)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(canvasProject.updatedAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ success: true, data: projects },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return openApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/openapi/v1/me/route.ts
Normal file
41
app/api/openapi/v1/me/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { user } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
openApiErrorResponse,
|
||||||
|
requireOpenApiPrincipal,
|
||||||
|
} from "@/lib/access-keys/openapi-auth";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const principal = await requireOpenApiPrincipal(request, "canvas:read");
|
||||||
|
const [currentUser] = await db
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
department: user.department,
|
||||||
|
role: user.role,
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, principal.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: currentUser,
|
||||||
|
accessKey: {
|
||||||
|
id: principal.accessKeyId,
|
||||||
|
name: principal.accessKeyName,
|
||||||
|
scopes: principal.scopes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return openApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import sharp from "sharp";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||||
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import {
|
||||||
import sharp from "sharp";
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import { readTrustedLocalImage } from "@/lib/video-canvas/local-image-input";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
@ -21,10 +27,12 @@ interface PickObjectRequest {
|
|||||||
imageData: string; // 带红色标记的图片 base64
|
imageData: string; // 带红色标记的图片 base64
|
||||||
clickX: number; // 点击位置 X (0-1)
|
clickX: number; // 点击位置 X (0-1)
|
||||||
clickY: number; // 点击位置 Y (0-1)
|
clickY: number; // 点击位置 Y (0-1)
|
||||||
|
mode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickObjectResult {
|
interface PickObjectResult {
|
||||||
name: string;
|
name: string;
|
||||||
|
labels: string[];
|
||||||
position: string;
|
position: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
boundingBox: {
|
boundingBox: {
|
||||||
@ -72,7 +80,7 @@ function ensureImageSizeAllowed(byteLength: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downscaleImageForVision(buffer: Buffer) {
|
async function prepareImageForVision(buffer: Buffer, clickX: number, clickY: number) {
|
||||||
const source = sharp(buffer, { failOn: "none" });
|
const source = sharp(buffer, { failOn: "none" });
|
||||||
const meta = await source.metadata();
|
const meta = await source.metadata();
|
||||||
const hasAlpha = Boolean(meta.hasAlpha);
|
const hasAlpha = Boolean(meta.hasAlpha);
|
||||||
@ -88,41 +96,50 @@ async function downscaleImageForVision(buffer: Buffer) {
|
|||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasAlpha) {
|
const resizedBuffer = hasAlpha
|
||||||
return {
|
? await resized.png({ compressionLevel: 9 }).toBuffer()
|
||||||
buffer: await resized.png({ compressionLevel: 9 }).toBuffer(),
|
: await resized.jpeg({ quality: 84, mozjpeg: true }).toBuffer();
|
||||||
ext: "png",
|
const resizedMeta = await sharp(resizedBuffer).metadata();
|
||||||
width,
|
const resizedWidth = resizedMeta.width || MAX_VISION_SIDE;
|
||||||
height,
|
const resizedHeight = resizedMeta.height || MAX_VISION_SIDE;
|
||||||
};
|
const markerRadius = Math.max(10, Math.round(Math.min(resizedWidth, resizedHeight) * 0.035));
|
||||||
}
|
const markerX = Math.round(Math.max(0, Math.min(1, clickX)) * resizedWidth);
|
||||||
|
const markerY = Math.round(Math.max(0, Math.min(1, clickY)) * resizedHeight);
|
||||||
return {
|
const markerSvg = Buffer.from(
|
||||||
buffer: await resized.jpeg({ quality: 84, mozjpeg: true }).toBuffer(),
|
`<svg width="${resizedWidth}" height="${resizedHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||||
ext: "jpg",
|
<circle cx="${markerX}" cy="${markerY}" r="${markerRadius}" fill="none" stroke="#ff2d2d" stroke-width="${Math.max(4, Math.round(markerRadius * 0.28))}" />
|
||||||
width,
|
<line x1="${markerX - markerRadius}" y1="${markerY}" x2="${markerX + markerRadius}" y2="${markerY}" stroke="#ff2d2d" stroke-width="3" />
|
||||||
height,
|
<line x1="${markerX}" y1="${markerY - markerRadius}" x2="${markerX}" y2="${markerY + markerRadius}" stroke="#ff2d2d" stroke-width="3" />
|
||||||
};
|
</svg>`,
|
||||||
}
|
);
|
||||||
|
const outputBuffer = await sharp(resizedBuffer)
|
||||||
async function uploadVisionAnalysisImage(buffer: Buffer, filenamePrefix: string) {
|
.composite([{ input: markerSvg, left: 0, top: 0 }])
|
||||||
const { buffer: processedBuffer, ext, width, height } = await downscaleImageForVision(buffer);
|
.png({ compressionLevel: 9 })
|
||||||
const filename = `${filenamePrefix}-${Date.now()}.${ext}`;
|
.toBuffer();
|
||||||
const uploaded = await uploadFile(filename, processedBuffer as any);
|
|
||||||
logger.info("vision image prepared", {
|
logger.info("vision image prepared", {
|
||||||
originalWidth: width,
|
originalWidth: width,
|
||||||
originalHeight: height,
|
originalHeight: height,
|
||||||
maxSide: MAX_VISION_SIDE,
|
maxSide: MAX_VISION_SIDE,
|
||||||
outputExt: ext,
|
outputExt: "png",
|
||||||
outputBytes: processedBuffer.length,
|
outputBytes: outputBuffer.length,
|
||||||
});
|
});
|
||||||
return uploaded.url;
|
return `data:image/png;base64,${outputBuffer.toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function normalizeImageInput(imageData: string): Promise<string> {
|
async function normalizeImageInput(
|
||||||
|
imageData: string,
|
||||||
|
clickX: number,
|
||||||
|
clickY: number,
|
||||||
|
): Promise<string> {
|
||||||
const raw = String(imageData || "").trim();
|
const raw = String(imageData || "").trim();
|
||||||
if (!raw) throw new Error("缺少图片数据");
|
if (!raw) throw new Error("缺少图片数据");
|
||||||
|
|
||||||
|
const localImage = await readTrustedLocalImage(raw);
|
||||||
|
if (localImage) {
|
||||||
|
ensureImageSizeAllowed(localImage.byteLength);
|
||||||
|
return prepareImageForVision(localImage, clickX, clickY);
|
||||||
|
}
|
||||||
|
|
||||||
if (/^https?:\/\//i.test(raw)) {
|
if (/^https?:\/\//i.test(raw)) {
|
||||||
const url = new URL(raw);
|
const url = new URL(raw);
|
||||||
if (!["http:", "https:"].includes(url.protocol) || isBlockedImageHost(url.hostname)) {
|
if (!["http:", "https:"].includes(url.protocol) || isBlockedImageHost(url.hostname)) {
|
||||||
@ -145,7 +162,7 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
ensureImageSizeAllowed(arrayBuffer.byteLength);
|
ensureImageSizeAllowed(arrayBuffer.byteLength);
|
||||||
return uploadVisionAnalysisImage(Buffer.from(arrayBuffer), "pick-object-url");
|
return prepareImageForVision(Buffer.from(arrayBuffer), clickX, clickY);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
@ -160,7 +177,7 @@ async function normalizeImageInput(imageData: string): Promise<string> {
|
|||||||
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
|
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
|
||||||
const buffer = Buffer.from(base64Body, "base64");
|
const buffer = Buffer.from(base64Body, "base64");
|
||||||
ensureImageSizeAllowed(buffer.byteLength);
|
ensureImageSizeAllowed(buffer.byteLength);
|
||||||
return uploadVisionAnalysisImage(buffer, "pick-object-base64");
|
return prepareImageForVision(buffer, clickX, clickY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@ -204,12 +221,19 @@ export async function POST(request: Request) {
|
|||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedImage = await normalizeImageInput(imageData);
|
const normalizedImage = await normalizeImageInput(imageData, clickX, clickY);
|
||||||
|
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
const config = setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const definition = config.functions["element-mark"];
|
||||||
|
|
||||||
logger.info("vision model selected", { model: "qwen3-vl" });
|
logger.info("vision model selected", { model: definition?.model || "qwen3-vl-plus" });
|
||||||
|
|
||||||
// 构建 prompt - AI 通过红色标记来识别用户选择的物品
|
// 构建 prompt - AI 通过红色标记来识别用户选择的物品
|
||||||
const prompt = `
|
const fallbackPrompt = `
|
||||||
图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。
|
图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。
|
||||||
|
|
||||||
【重要】
|
【重要】
|
||||||
@ -232,12 +256,17 @@ export async function POST(request: Request) {
|
|||||||
【position 格式】固定4字,从以下选择:
|
【position 格式】固定4字,从以下选择:
|
||||||
${POSITION_OPTIONS.join('、')}
|
${POSITION_OPTIONS.join('、')}
|
||||||
`.trim();
|
`.trim();
|
||||||
|
const prompt = String(definition?.prompt || fallbackPrompt).replace(
|
||||||
|
"{{positionOptions}}",
|
||||||
|
POSITION_OPTIONS.join("、"),
|
||||||
|
);
|
||||||
|
|
||||||
logger.info("vision model call started");
|
logger.info("vision model call started");
|
||||||
|
|
||||||
// 调用视觉模型,发送带红色标记的图片
|
// 调用视觉模型,发送带红色标记的图片
|
||||||
const { text: responseText, model } = await callQwen3Vl({
|
const { text: responseText, model } = await callQwen3Vl({
|
||||||
imageUrl: normalizedImage,
|
imageUrl: normalizedImage,
|
||||||
|
modelIds: definition?.model ? [definition.model] : undefined,
|
||||||
prompt,
|
prompt,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
maxTokens: 300,
|
maxTokens: 300,
|
||||||
@ -292,6 +321,13 @@ ${POSITION_OPTIONS.join('、')}
|
|||||||
|
|
||||||
result = {
|
result = {
|
||||||
name,
|
name,
|
||||||
|
labels: Array.from(
|
||||||
|
new Set<string>(
|
||||||
|
(Array.isArray(parsed.labels) ? parsed.labels : [name])
|
||||||
|
.map((label: unknown) => String(label || "").trim().slice(0, 8))
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
).slice(0, 4),
|
||||||
position,
|
position,
|
||||||
confidence,
|
confidence,
|
||||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
|
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
|
||||||
@ -312,6 +348,7 @@ ${POSITION_OPTIONS.join('、')}
|
|||||||
|
|
||||||
result = {
|
result = {
|
||||||
name: '物品',
|
name: '物品',
|
||||||
|
labels: ['物品'],
|
||||||
position,
|
position,
|
||||||
confidence: 0.5,
|
confidence: 0.5,
|
||||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },
|
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },
|
||||||
|
|||||||
@ -25,6 +25,10 @@ import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
|||||||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||||||
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||||
|
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||||
|
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||||
|
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||||
|
import { createVip98k } from "@/lib/ai/providers/vip98k";
|
||||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||||
import {
|
import {
|
||||||
canProcessSceneFusionProviderType,
|
canProcessSceneFusionProviderType,
|
||||||
@ -1475,7 +1479,8 @@ async function processSceneWithProvider(
|
|||||||
modelId: string,
|
modelId: string,
|
||||||
runtimeModelId: string,
|
runtimeModelId: string,
|
||||||
aspectRatio?: string,
|
aspectRatio?: string,
|
||||||
imageSize?: string
|
imageSize?: string,
|
||||||
|
imageGenConfig?: Record<string, any> | null
|
||||||
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||||
// 构建智能提示词
|
// 构建智能提示词
|
||||||
const prompt = buildPrompt(
|
const prompt = buildPrompt(
|
||||||
@ -1527,6 +1532,52 @@ async function processSceneWithProvider(
|
|||||||
case "openai-image":
|
case "openai-image":
|
||||||
return processWithOpenAIImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
return processWithOpenAIImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||||
|
|
||||||
|
case "bananaimage":
|
||||||
|
return processWithBananaImage(
|
||||||
|
productImageUrl,
|
||||||
|
sceneImageUrl,
|
||||||
|
prompt,
|
||||||
|
provider,
|
||||||
|
runtimeModelId || modelId,
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
case "visionary":
|
||||||
|
return processWithVisionary(
|
||||||
|
productImageUrl,
|
||||||
|
sceneImageUrl,
|
||||||
|
prompt,
|
||||||
|
provider,
|
||||||
|
runtimeModelId || modelId,
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
case "atlascloud":
|
||||||
|
return processWithAtlasCloud(
|
||||||
|
productImageUrl,
|
||||||
|
sceneImageUrl,
|
||||||
|
prompt,
|
||||||
|
provider,
|
||||||
|
runtimeModelId || modelId,
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
imageGenConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
case "vip98k":
|
||||||
|
return processWithVip98k(
|
||||||
|
productImageUrl,
|
||||||
|
sceneImageUrl,
|
||||||
|
prompt,
|
||||||
|
provider,
|
||||||
|
runtimeModelId || modelId,
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
imageGenConfig,
|
||||||
|
);
|
||||||
|
|
||||||
case "gemini":
|
case "gemini":
|
||||||
case "gemini-image":
|
case "gemini-image":
|
||||||
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||||
@ -1782,6 +1833,158 @@ async function processWithOpenAIImage(
|
|||||||
return { thumbnail, original, prompt };
|
return { thumbnail, original, prompt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function processWithBananaImage(
|
||||||
|
productImageUrl: string,
|
||||||
|
sceneImageUrl: string,
|
||||||
|
prompt: string,
|
||||||
|
provider: { baseUrl: string; apiKey: string },
|
||||||
|
modelId: string,
|
||||||
|
aspectRatio?: string,
|
||||||
|
imageSize?: string
|
||||||
|
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||||
|
logger.info("provider_processing", { providerType: "bananaimage", modelId });
|
||||||
|
|
||||||
|
const bananaImageProvider = createBananaImage({
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
const result = await bananaImageProvider.chat(modelId).doGenerate({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||||
|
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||||
|
{ type: "text", text: prompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
imageParams: {
|
||||||
|
aspectRatio: aspectRatio || "auto",
|
||||||
|
imageSize: imageSize || "2K",
|
||||||
|
imageCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||||
|
return { thumbnail, original, prompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithVisionary(
|
||||||
|
productImageUrl: string,
|
||||||
|
sceneImageUrl: string,
|
||||||
|
prompt: string,
|
||||||
|
provider: { baseUrl: string; apiKey: string },
|
||||||
|
modelId: string,
|
||||||
|
aspectRatio?: string,
|
||||||
|
imageSize?: string
|
||||||
|
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||||
|
logger.info("provider_processing", { providerType: "visionary", modelId });
|
||||||
|
|
||||||
|
const visionaryProvider = createVisionary({
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
const result = await visionaryProvider.chat(modelId).doGenerate({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||||
|
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||||
|
{ type: "text", text: prompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
imageParams: {
|
||||||
|
aspectRatio: aspectRatio || "auto",
|
||||||
|
imageSize: imageSize || "2K",
|
||||||
|
imageCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||||
|
return { thumbnail, original, prompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithAtlasCloud(
|
||||||
|
productImageUrl: string,
|
||||||
|
sceneImageUrl: string,
|
||||||
|
prompt: string,
|
||||||
|
provider: { baseUrl: string; apiKey: string },
|
||||||
|
modelId: string,
|
||||||
|
aspectRatio?: string,
|
||||||
|
imageSize?: string,
|
||||||
|
imageGenConfig?: Record<string, any> | null
|
||||||
|
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||||
|
logger.info("provider_processing", { providerType: "atlascloud", modelId });
|
||||||
|
|
||||||
|
const atlasCloudProvider = createAtlasCloud({
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
const result = await atlasCloudProvider.chat(modelId).doGenerate({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||||
|
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||||
|
{ type: "text", text: prompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
imageParams: {
|
||||||
|
aspectRatio: aspectRatio || "auto",
|
||||||
|
imageSize: imageSize || "2K",
|
||||||
|
imageCount: 1,
|
||||||
|
},
|
||||||
|
imageGenConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||||
|
return { thumbnail, original, prompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWithVip98k(
|
||||||
|
productImageUrl: string,
|
||||||
|
sceneImageUrl: string,
|
||||||
|
prompt: string,
|
||||||
|
provider: { baseUrl: string; apiKey: string },
|
||||||
|
modelId: string,
|
||||||
|
aspectRatio?: string,
|
||||||
|
imageSize?: string,
|
||||||
|
imageGenConfig?: Record<string, any> | null
|
||||||
|
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||||
|
logger.info("provider_processing", { providerType: "vip98k", modelId });
|
||||||
|
|
||||||
|
const vip98kProvider = createVip98k({
|
||||||
|
baseURL: provider.baseUrl,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
});
|
||||||
|
const result = await vip98kProvider.chat(modelId, imageGenConfig || null).doGenerate({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||||
|
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||||
|
{ type: "text", text: prompt },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
imageParams: {
|
||||||
|
aspectRatio: aspectRatio || "auto",
|
||||||
|
imageSize: imageSize || "2K",
|
||||||
|
imageCount: 1,
|
||||||
|
},
|
||||||
|
imageGenConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||||
|
return { thumbnail, original, prompt };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用既梦/火山模型处理
|
* 使用既梦/火山模型处理
|
||||||
*/
|
*/
|
||||||
@ -1931,6 +2134,7 @@ async function processSceneWithDiversity(
|
|||||||
sameModelPendingCount: number;
|
sameModelPendingCount: number;
|
||||||
sameModelRunningCount: number;
|
sameModelRunningCount: number;
|
||||||
concurrencyLimit: number;
|
concurrencyLimit: number;
|
||||||
|
dispatchIntervalMs: number;
|
||||||
estimatedWaitMs: number;
|
estimatedWaitMs: number;
|
||||||
source: "model_queue";
|
source: "model_queue";
|
||||||
}) => void | Promise<void>;
|
}) => void | Promise<void>;
|
||||||
@ -2014,12 +2218,13 @@ async function processSceneWithDiversity(
|
|||||||
onQueued: params.onQueueStatus,
|
onQueued: params.onQueueStatus,
|
||||||
});
|
});
|
||||||
let result: Awaited<ReturnType<typeof processSceneWithProvider>>;
|
let result: Awaited<ReturnType<typeof processSceneWithProvider>>;
|
||||||
|
const runtimeModelId = resolveModelRuntimeId(candidate.model, {
|
||||||
|
providerType: candidate.provider.type,
|
||||||
|
});
|
||||||
|
await params.onProcessingStart?.();
|
||||||
|
let execution: ReturnType<typeof processSceneWithProvider> | null = null;
|
||||||
try {
|
try {
|
||||||
const runtimeModelId = resolveModelRuntimeId(candidate.model, {
|
execution = processSceneWithProvider(
|
||||||
providerType: candidate.provider.type,
|
|
||||||
});
|
|
||||||
await params.onProcessingStart?.();
|
|
||||||
result = await processSceneWithProvider(
|
|
||||||
params.productImageUrl,
|
params.productImageUrl,
|
||||||
params.sceneImageUrl,
|
params.sceneImageUrl,
|
||||||
params.productName,
|
params.productName,
|
||||||
@ -2037,11 +2242,16 @@ async function processSceneWithDiversity(
|
|||||||
candidate.model.modelId,
|
candidate.model.modelId,
|
||||||
runtimeModelId,
|
runtimeModelId,
|
||||||
normalizedOutputParams.aspectRatio,
|
normalizedOutputParams.aspectRatio,
|
||||||
normalizedOutputParams.imageSize
|
normalizedOutputParams.imageSize,
|
||||||
|
(candidate.model.imageGenConfig as Record<string, any> | null) || null
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await modelSlot.release();
|
await modelSlot.release();
|
||||||
}
|
}
|
||||||
|
if (!execution) {
|
||||||
|
throw new Error("场景裂变请求未能提交到模型服务");
|
||||||
|
}
|
||||||
|
result = await execution;
|
||||||
|
|
||||||
const echoCheck = await detectEchoedProductResult({
|
const echoCheck = await detectEchoedProductResult({
|
||||||
requestId: params.requestId,
|
requestId: params.requestId,
|
||||||
|
|||||||
@ -579,6 +579,10 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
|||||||
favicon: null,
|
favicon: null,
|
||||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
lovartPromoActionText: "立即升级",
|
lovartPromoActionText: "立即升级",
|
||||||
|
bannerEnabled: true,
|
||||||
|
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||||
|
bannerLink: null,
|
||||||
|
bannerActionText: "立即查看",
|
||||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||||
surpriseCategories: ["全部", "精选"],
|
surpriseCategories: ["全部", "精选"],
|
||||||
@ -897,6 +901,7 @@ async function getScopedPublicSiteSettings(
|
|||||||
bannerText:
|
bannerText:
|
||||||
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
|
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
|
||||||
bannerLink: typedSettings?.bannerLink || null,
|
bannerLink: typedSettings?.bannerLink || null,
|
||||||
|
bannerActionText: typedSettings?.bannerActionText || "立即查看",
|
||||||
lovartComposerPrefix: normalizeLovartComposerPrefix(
|
lovartComposerPrefix: normalizeLovartComposerPrefix(
|
||||||
valueByKey.get(LOVART_COMPOSER_PREFIX_KEY)
|
valueByKey.get(LOVART_COMPOSER_PREFIX_KEY)
|
||||||
),
|
),
|
||||||
@ -939,6 +944,11 @@ async function getScopedPublicSiteSettings(
|
|||||||
|
|
||||||
return createScopedSiteSettingsResponse(scope, {
|
return createScopedSiteSettingsResponse(scope, {
|
||||||
...createBrandingSettings(typedSettings),
|
...createBrandingSettings(typedSettings),
|
||||||
|
bannerEnabled: typedSettings?.bannerEnabled ?? true,
|
||||||
|
bannerText:
|
||||||
|
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
|
||||||
|
bannerLink: typedSettings?.bannerLink || null,
|
||||||
|
bannerActionText: typedSettings?.bannerActionText || "立即查看",
|
||||||
lovartPromoText:
|
lovartPromoText:
|
||||||
typedSettings?.lovartPromoText ||
|
typedSettings?.lovartPromoText ||
|
||||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||||
@ -1444,6 +1454,7 @@ export async function GET(request: NextRequest) {
|
|||||||
bannerEnabled: true,
|
bannerEnabled: true,
|
||||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||||
bannerLink: null,
|
bannerLink: null,
|
||||||
|
bannerActionText: "立即查看",
|
||||||
toolbox1Name: "工具箱",
|
toolbox1Name: "工具箱",
|
||||||
toolbox1Enabled: true,
|
toolbox1Enabled: true,
|
||||||
toolbox2Name: "工具箱2",
|
toolbox2Name: "工具箱2",
|
||||||
@ -1625,6 +1636,7 @@ const updateSchema = z.object({
|
|||||||
bannerEnabled: z.boolean().optional(),
|
bannerEnabled: z.boolean().optional(),
|
||||||
bannerText: z.string().optional(),
|
bannerText: z.string().optional(),
|
||||||
bannerLink: z.string().nullable().optional(),
|
bannerLink: z.string().nullable().optional(),
|
||||||
|
bannerActionText: z.string().max(32).optional(),
|
||||||
toolbox1Name: z.string().max(64).optional(),
|
toolbox1Name: z.string().max(64).optional(),
|
||||||
toolbox1Enabled: z.boolean().optional(),
|
toolbox1Enabled: z.boolean().optional(),
|
||||||
toolbox2Name: z.string().max(64).optional(),
|
toolbox2Name: z.string().max(64).optional(),
|
||||||
@ -3085,6 +3097,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
|
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
|
||||||
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
|
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
|
||||||
bannerLink: siteSettingsPayload.bannerLink,
|
bannerLink: siteSettingsPayload.bannerLink,
|
||||||
|
bannerActionText: siteSettingsPayload.bannerActionText ?? "立即查看",
|
||||||
toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱",
|
toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱",
|
||||||
toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true,
|
toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true,
|
||||||
toolbox2Name: siteSettingsPayload.toolbox2Name ?? "工具箱2",
|
toolbox2Name: siteSettingsPayload.toolbox2Name ?? "工具箱2",
|
||||||
|
|||||||
40
app/api/user/access-keys/[id]/route.ts
Normal file
40
app/api/user/access-keys/[id]/route.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { revokeUserAccessKey } from "@/lib/access-keys/service";
|
||||||
|
|
||||||
|
const idSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id || session.user.type === "guest") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "请先登录正式账号" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedId = idSchema.safeParse((await params).id);
|
||||||
|
if (!parsedId.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Access Key ID 无效" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revoked = await revokeUserAccessKey({
|
||||||
|
userId: session.user.id,
|
||||||
|
accessKeyId: parsedId.data,
|
||||||
|
});
|
||||||
|
if (!revoked) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Access Key 不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
76
app/api/user/access-keys/route.ts
Normal file
76
app/api/user/access-keys/route.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
createUserAccessKey,
|
||||||
|
listUserAccessKeys,
|
||||||
|
} from "@/lib/access-keys/service";
|
||||||
|
|
||||||
|
const createAccessKeySchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(100).optional(),
|
||||||
|
scopes: z
|
||||||
|
.array(z.enum(["canvas:read", "canvas:write"]))
|
||||||
|
.min(1)
|
||||||
|
.optional(),
|
||||||
|
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id || session.user.type === "guest") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "请先登录正式账号" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = await listUserAccessKeys(session.user.id);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, data: keys },
|
||||||
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id || session.user.type === "guest") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "请先登录正式账号" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingKeys = await listUserAccessKeys(session.user.id);
|
||||||
|
const activeKeyCount = existingKeys.filter(
|
||||||
|
(item) =>
|
||||||
|
!item.revokedAt &&
|
||||||
|
(!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
|
||||||
|
).length;
|
||||||
|
if (activeKeyCount >= 10) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "最多保留 10 个有效 Access Key" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = createAccessKeySchema.parse(
|
||||||
|
await request.json().catch(() => ({}))
|
||||||
|
);
|
||||||
|
const created = await createUserAccessKey({
|
||||||
|
userId: session.user.id,
|
||||||
|
name: body?.name,
|
||||||
|
scopes: body?.scopes,
|
||||||
|
expiresInDays: body?.expiresInDays,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true, data: created }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "创建 Access Key 失败",
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { model3dAsset, paymentOrder, systemSettings } from "@/lib/db/schema";
|
|||||||
import {
|
import {
|
||||||
getPointsLedgerByUserId,
|
getPointsLedgerByUserId,
|
||||||
getPointsSystemConfig,
|
getPointsSystemConfig,
|
||||||
|
getSystemSetting,
|
||||||
getUserById,
|
getUserById,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { resolveThreeDModelDisplayName } from "@/lib/model3d/model-alias-config";
|
import { resolveThreeDModelDisplayName } from "@/lib/model3d/model-alias-config";
|
||||||
@ -18,6 +19,12 @@ import {
|
|||||||
normalizeMembershipUserOverrides,
|
normalizeMembershipUserOverrides,
|
||||||
resolveMembershipPlanIdForUser,
|
resolveMembershipPlanIdForUser,
|
||||||
} from "@/lib/membership/config";
|
} from "@/lib/membership/config";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
hasEnabledFixedVideoCanvasPointCosts,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
|
||||||
const MEMBERSHIP_CONFIG_KEY = "membership_config";
|
const MEMBERSHIP_CONFIG_KEY = "membership_config";
|
||||||
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
|
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
|
||||||
@ -129,6 +136,7 @@ export async function GET() {
|
|||||||
paymentOrders,
|
paymentOrders,
|
||||||
configSetting,
|
configSetting,
|
||||||
overridesSetting,
|
overridesSetting,
|
||||||
|
videoCanvasSetting,
|
||||||
] =
|
] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getUserById({ id: session.user.id }),
|
getUserById({ id: session.user.id }),
|
||||||
@ -157,6 +165,7 @@ export async function GET() {
|
|||||||
.where(eq(systemSettings.key, MEMBERSHIP_USER_OVERRIDES_KEY))
|
.where(eq(systemSettings.key, MEMBERSHIP_USER_OVERRIDES_KEY))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]),
|
||||||
|
getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const threeDModelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
const threeDModelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
@ -274,6 +283,11 @@ export async function GET() {
|
|||||||
].sort((a, b) => {
|
].sort((a, b) => {
|
||||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||||
});
|
});
|
||||||
|
const videoCanvasConfig = videoCanvasSetting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const fixedVideoCanvasPointsEnabled =
|
||||||
|
hasEnabledFixedVideoCanvasPointCosts(videoCanvasConfig);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
profile: {
|
profile: {
|
||||||
@ -281,7 +295,7 @@ export async function GET() {
|
|||||||
email: dbUser?.email || session.user.email || "",
|
email: dbUser?.email || session.user.email || "",
|
||||||
currentPlanName: currentPlan.name,
|
currentPlanName: currentPlan.name,
|
||||||
currentPlanId,
|
currentPlanId,
|
||||||
availablePoints: pointsConfig.enabled
|
availablePoints: pointsConfig.enabled || fixedVideoCanvasPointsEnabled
|
||||||
? Math.max(0, Number(dbUser?.pointsBalance || 0))
|
? Math.max(0, Number(dbUser?.pointsBalance || 0))
|
||||||
: currentPlan.monthlyCredits,
|
: currentPlan.monthlyCredits,
|
||||||
monthlyCredits: currentPlan.monthlyCredits,
|
monthlyCredits: currentPlan.monthlyCredits,
|
||||||
|
|||||||
@ -6,9 +6,16 @@ import { systemSettings } from "@/lib/db/schema";
|
|||||||
import {
|
import {
|
||||||
checkUserImageQuota,
|
checkUserImageQuota,
|
||||||
getPointsSystemConfig,
|
getPointsSystemConfig,
|
||||||
|
getSystemSetting,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserMonthlyImageCount,
|
getUserMonthlyImageCount,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
hasEnabledFixedVideoCanvasPointCosts,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
import {
|
import {
|
||||||
defaultMembershipConfig,
|
defaultMembershipConfig,
|
||||||
defaultMembershipUserOverrides,
|
defaultMembershipUserOverrides,
|
||||||
@ -29,7 +36,15 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [configSetting, overridesSetting, quota, totalImages, pointsConfig, dbUser] = await Promise.all([
|
const [
|
||||||
|
configSetting,
|
||||||
|
overridesSetting,
|
||||||
|
quota,
|
||||||
|
totalImages,
|
||||||
|
pointsConfig,
|
||||||
|
dbUser,
|
||||||
|
videoCanvasSetting,
|
||||||
|
] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(systemSettings)
|
.from(systemSettings)
|
||||||
@ -46,6 +61,7 @@ export async function GET() {
|
|||||||
getUserMonthlyImageCount(session.user.id),
|
getUserMonthlyImageCount(session.user.id),
|
||||||
getPointsSystemConfig(),
|
getPointsSystemConfig(),
|
||||||
getUserById({ id: session.user.id }),
|
getUserById({ id: session.user.id }),
|
||||||
|
getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const config = configSetting?.value
|
const config = configSetting?.value
|
||||||
@ -63,7 +79,15 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
const currentPlan = getMembershipPlanById(config, currentPlanId);
|
const currentPlan = getMembershipPlanById(config, currentPlanId);
|
||||||
|
|
||||||
const currentCredits = pointsConfig.enabled
|
const videoCanvasConfig = videoCanvasSetting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const fixedVideoCanvasPointsEnabled =
|
||||||
|
hasEnabledFixedVideoCanvasPointCosts(videoCanvasConfig);
|
||||||
|
const effectivePointsConfig = fixedVideoCanvasPointsEnabled
|
||||||
|
? { ...pointsConfig, enabled: true }
|
||||||
|
: pointsConfig;
|
||||||
|
const currentCredits = effectivePointsConfig.enabled
|
||||||
? Math.max(0, Number(dbUser?.pointsBalance || 0))
|
? Math.max(0, Number(dbUser?.pointsBalance || 0))
|
||||||
: quota.remaining !== null
|
: quota.remaining !== null
|
||||||
? Math.max(0, quota.remaining)
|
? Math.max(0, quota.remaining)
|
||||||
@ -71,7 +95,7 @@ export async function GET() {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
config,
|
config,
|
||||||
pointsSystemConfig: pointsConfig,
|
pointsSystemConfig: effectivePointsConfig,
|
||||||
isHighVip: Boolean(dbUser?.isHighVip),
|
isHighVip: Boolean(dbUser?.isHighVip),
|
||||||
currentPlanId,
|
currentPlanId,
|
||||||
currentPlan,
|
currentPlan,
|
||||||
|
|||||||
267
app/api/video-canvas/assets/route.ts
Normal file
267
app/api/video-canvas/assets/route.ts
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import { and, desc, eq, ilike, inArray, ne, sql } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { mediaAsset } from "@/lib/db/schema";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||||
|
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||||
|
} from "@/lib/video-canvas/subjects";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const logger = createLogger("video-canvas-assets");
|
||||||
|
const SUPPORTED_MEDIA_TYPES = new Set(["image", "video", "audio", "model"]);
|
||||||
|
const ASSET_CATEGORIES = new Set(["其它", "人物", "场景", "物品", "风格", "音效"]);
|
||||||
|
|
||||||
|
function normalizeAssetIds(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
value
|
||||||
|
.map((item) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 50),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const query = url.searchParams.get("q")?.trim().slice(0, 120) || "";
|
||||||
|
const requestedType = url.searchParams.get("mediaType")?.trim() || "";
|
||||||
|
const mediaType = SUPPORTED_MEDIA_TYPES.has(requestedType)
|
||||||
|
? requestedType
|
||||||
|
: "";
|
||||||
|
const filters = [
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
ne(mediaAsset.mediaType, "subject"),
|
||||||
|
ne(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE),
|
||||||
|
ne(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||||
|
];
|
||||||
|
if (mediaType) filters.push(eq(mediaAsset.mediaType, mediaType));
|
||||||
|
if (query) filters.push(ilike(mediaAsset.displayName, `%${query}%`));
|
||||||
|
|
||||||
|
const assets = await db
|
||||||
|
.select({
|
||||||
|
createdAt: mediaAsset.createdAt,
|
||||||
|
displayName: mediaAsset.displayName,
|
||||||
|
durationMs: mediaAsset.durationMs,
|
||||||
|
id: mediaAsset.id,
|
||||||
|
mediaType: mediaAsset.mediaType,
|
||||||
|
metadata: mediaAsset.metadata,
|
||||||
|
mimeType: mediaAsset.mimeType,
|
||||||
|
originalUrl: mediaAsset.originalUrl,
|
||||||
|
previewUrl: mediaAsset.previewUrl,
|
||||||
|
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||||
|
})
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(and(...filters))
|
||||||
|
.orderBy(desc(mediaAsset.updatedAt))
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, assets });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("asset list failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "资产加载失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as {
|
||||||
|
action?: string;
|
||||||
|
name?: string;
|
||||||
|
} | null;
|
||||||
|
if (body?.action !== "create-folder") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "不支持的操作" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = String(body.name || "").trim().slice(0, 20);
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "请输入文件夹名称" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderUrl = `asset-folder://${crypto.randomUUID()}`;
|
||||||
|
const [folder] = await db
|
||||||
|
.insert(mediaAsset)
|
||||||
|
.values({
|
||||||
|
displayName: name,
|
||||||
|
mediaType: "folder",
|
||||||
|
metadata: { isFolder: true },
|
||||||
|
normalizedUrl: folderUrl,
|
||||||
|
originalUrl: folderUrl,
|
||||||
|
sourceTable: "video_canvas_asset_library",
|
||||||
|
sourceType: "video_canvas_asset_folder",
|
||||||
|
status: "ready",
|
||||||
|
userId: session.user.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, asset: folder });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("asset folder creation failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "文件夹创建失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as {
|
||||||
|
action?: string;
|
||||||
|
assetIds?: unknown;
|
||||||
|
category?: string;
|
||||||
|
folderId?: string | null;
|
||||||
|
} | null;
|
||||||
|
const assetIds = normalizeAssetIds(body?.assetIds);
|
||||||
|
if (!assetIds.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "请选择资产" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadataPatch: Record<string, unknown>;
|
||||||
|
if (body?.action === "update-category") {
|
||||||
|
const category = String(body.category || "").trim();
|
||||||
|
if (!ASSET_CATEGORIES.has(category)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "资产标签无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
metadataPatch = { category };
|
||||||
|
} else if (body?.action === "move-to-folder") {
|
||||||
|
const folderId = String(body.folderId || "").trim() || null;
|
||||||
|
if (folderId) {
|
||||||
|
const [folder] = await db
|
||||||
|
.select({ id: mediaAsset.id })
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.id, folderId),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.mediaType, "folder"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!folder) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "文件夹不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadataPatch = { folderId };
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "不支持的操作" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await db
|
||||||
|
.update(mediaAsset)
|
||||||
|
.set({
|
||||||
|
metadata: sql`coalesce(${mediaAsset.metadata}, '{}'::jsonb) || ${JSON.stringify(metadataPatch)}::jsonb`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
inArray(mediaAsset.id, assetIds),
|
||||||
|
ne(mediaAsset.mediaType, "folder"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: mediaAsset.id });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, updated: updated.length });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("asset batch update failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "批量更新失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as {
|
||||||
|
assetIds?: unknown;
|
||||||
|
} | null;
|
||||||
|
const assetIds = normalizeAssetIds(body?.assetIds);
|
||||||
|
if (!assetIds.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "请选择资产" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await db
|
||||||
|
.delete(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
inArray(mediaAsset.id, assetIds),
|
||||||
|
ne(mediaAsset.mediaType, "folder"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: mediaAsset.id });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, removed: removed.length });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("asset batch delete failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "批量删除失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
309
app/api/video-canvas/director-ai-import/route.ts
Normal file
309
app/api/video-canvas/director-ai-import/route.ts
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
import { generateText } from "ai";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||||
|
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||||
|
import {
|
||||||
|
getAiModelAgentCodingModelId,
|
||||||
|
isAiModelMultimodal,
|
||||||
|
} from "@/lib/db/ai-model-flags";
|
||||||
|
import {
|
||||||
|
getAgentAiModelWithProvider,
|
||||||
|
getEnabledAiModelsWithProvider,
|
||||||
|
getSystemSetting,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 90;
|
||||||
|
|
||||||
|
const logger = createLogger("video-canvas-director-ai-import");
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
imageUrl: z.string().trim().min(1).max(8_000_000),
|
||||||
|
mode: z.enum(["insert", "overwrite"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ModelCandidate = Awaited<
|
||||||
|
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
const BODY_TYPES = new Set([
|
||||||
|
"mannequin",
|
||||||
|
"female",
|
||||||
|
"broad",
|
||||||
|
"muscular",
|
||||||
|
"slim",
|
||||||
|
"teen",
|
||||||
|
"child",
|
||||||
|
"chibi",
|
||||||
|
]);
|
||||||
|
const GEOMETRY_TYPES = new Set([
|
||||||
|
"box",
|
||||||
|
"sphere",
|
||||||
|
"cylinder",
|
||||||
|
"torus",
|
||||||
|
"cone",
|
||||||
|
"pyramid",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function supportsVision(candidate: ModelCandidate) {
|
||||||
|
return (
|
||||||
|
candidate.model.supportsVision === true ||
|
||||||
|
isAiModelMultimodal({
|
||||||
|
capabilities: candidate.model.capabilities,
|
||||||
|
defaultParams: candidate.model.defaultParams,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelRef(value: unknown) {
|
||||||
|
const candidate = String(value || "").trim();
|
||||||
|
return candidate && candidate !== "auto" ? candidate : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJson(value: unknown) {
|
||||||
|
const match = String(value || "").match(/\{[\s\S]*\}/);
|
||||||
|
if (!match) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match[0]);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(value: unknown, fallback: number, min: number, max: number) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed)
|
||||||
|
? Number(Math.min(max, Math.max(min, parsed)).toFixed(4))
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVector(
|
||||||
|
value: unknown,
|
||||||
|
fallback: [number, number, number],
|
||||||
|
min = -20,
|
||||||
|
max = 20,
|
||||||
|
): [number, number, number] {
|
||||||
|
if (!Array.isArray(value) || value.length < 3) return fallback;
|
||||||
|
return [
|
||||||
|
clampNumber(value[0], fallback[0], min, max),
|
||||||
|
clampNumber(value[1], fallback[1], min, max),
|
||||||
|
clampNumber(value[2], fallback[2], min, max),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeColor(value: unknown, fallback: string) {
|
||||||
|
const candidate = String(value || "").trim();
|
||||||
|
return /^#[0-9a-f]{6}$/i.test(candidate) ? candidate : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(value: unknown, fallback: string) {
|
||||||
|
return String(value || fallback).trim().slice(0, 80) || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResult(value: Record<string, unknown>) {
|
||||||
|
const rawCharacters = Array.isArray(value.characters) ? value.characters : [];
|
||||||
|
const rawProps = Array.isArray(value.props) ? value.props : [];
|
||||||
|
const rawCameras = Array.isArray(value.cameras) ? value.cameras : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: String(value.summary || "").trim().slice(0, 600),
|
||||||
|
characters: rawCharacters.slice(0, 8).flatMap((item, index) => {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const bodyType = String(record.bodyType || "mannequin");
|
||||||
|
return [{
|
||||||
|
bodyType: BODY_TYPES.has(bodyType) ? bodyType : "mannequin",
|
||||||
|
color: normalizeColor(record.color, ["#7AA7FF", "#E56C5B", "#6CDB7A"][index % 3]!),
|
||||||
|
name: normalizeName(record.name, `角色${String.fromCharCode(65 + index)}`),
|
||||||
|
position: normalizeVector(record.position, [0, 0, index * 1.2]),
|
||||||
|
}];
|
||||||
|
}),
|
||||||
|
props: rawProps.slice(0, 12).flatMap((item, index) => {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const geometryType = String(record.geometryType || "box");
|
||||||
|
return [{
|
||||||
|
color: normalizeColor(record.color, "#d7e7ff"),
|
||||||
|
geometryType: GEOMETRY_TYPES.has(geometryType) ? geometryType : "box",
|
||||||
|
name: normalizeName(record.name, `道具${index + 1}`),
|
||||||
|
position: normalizeVector(record.position, [0, 0, 0]),
|
||||||
|
scale: normalizeVector(record.scale, [1, 1, 1], 0.05, 20),
|
||||||
|
}];
|
||||||
|
}),
|
||||||
|
cameras: rawCameras.slice(0, 4).flatMap((item, index) => {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
return [{
|
||||||
|
fov: clampNumber(record.fov, 50, 10, 120),
|
||||||
|
name: normalizeName(record.name, `机位${index + 1}`),
|
||||||
|
position: normalizeVector(record.position, [0, 2.2, 8 + index * 1.5], -50, 50),
|
||||||
|
target: normalizeVector(record.target, [0, 1.2, 0], -50, 50),
|
||||||
|
}];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = requestSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let imageUrl = parsed.data.imageUrl;
|
||||||
|
if (!imageUrl.startsWith("data:image/")) {
|
||||||
|
try {
|
||||||
|
const resolved = new URL(imageUrl, request.url);
|
||||||
|
imageUrl = resolved.protocol === "http:" || resolved.protocol === "https:"
|
||||||
|
? resolved.toString()
|
||||||
|
: "";
|
||||||
|
} catch {
|
||||||
|
imageUrl = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!imageUrl) {
|
||||||
|
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null);
|
||||||
|
const config = setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const aiImportConfig = config.directorDesk.aiImport;
|
||||||
|
const definition = config.functions[aiImportConfig.functionKey];
|
||||||
|
if (!config.enabled || !aiImportConfig.enabled || !definition?.enabled) {
|
||||||
|
return NextResponse.json({ success: false, error: "AI 识图导入已停用" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
|
userId: session.user.id,
|
||||||
|
unitCost: aiImportConfig.cost,
|
||||||
|
reason: "director-ai-import",
|
||||||
|
note: "3D导演台 AI 识图导入",
|
||||||
|
});
|
||||||
|
if (!pointsCharge.ok) {
|
||||||
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [models, agentModel] = await Promise.all([
|
||||||
|
getEnabledAiModelsWithProvider(false),
|
||||||
|
getAgentAiModelWithProvider(),
|
||||||
|
]);
|
||||||
|
const compatible = models.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.model.isEnabled &&
|
||||||
|
candidate.provider.isEnabled &&
|
||||||
|
!candidate.model.isSubModel &&
|
||||||
|
candidate.model.healthStatus !== "auto_disabled" &&
|
||||||
|
supportsVision(candidate),
|
||||||
|
);
|
||||||
|
const preferredRef =
|
||||||
|
normalizeModelRef(definition.model) || normalizeModelRef(config.defaultMultimodalModel);
|
||||||
|
const preferred = preferredRef
|
||||||
|
? compatible.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.model.id === preferredRef ||
|
||||||
|
candidate.model.modelId === preferredRef,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const ordered = [
|
||||||
|
preferred,
|
||||||
|
agentModel && supportsVision(agentModel) ? agentModel : null,
|
||||||
|
...compatible,
|
||||||
|
].filter(
|
||||||
|
(candidate, index, items): candidate is ModelCandidate =>
|
||||||
|
Boolean(candidate) &&
|
||||||
|
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
|
||||||
|
);
|
||||||
|
if (ordered.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, error: "当前站点没有可用的多模态模型" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceImages = imageUrl.startsWith("data:image/")
|
||||||
|
? [imageUrl]
|
||||||
|
: (
|
||||||
|
await inlineVisionImages([imageUrl], {
|
||||||
|
maxImages: 1,
|
||||||
|
maxSide: 1280,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
retries: 1,
|
||||||
|
loggerPrefix: "[Director AI Import]",
|
||||||
|
})
|
||||||
|
).inlineDataUrls;
|
||||||
|
if (referenceImages.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, error: "识图素材读取失败" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of ordered.slice(0, 3)) {
|
||||||
|
try {
|
||||||
|
const model = await getLanguageModelById(
|
||||||
|
candidate.model.id,
|
||||||
|
getAiModelAgentCodingModelId(candidate.model),
|
||||||
|
);
|
||||||
|
if (!model) continue;
|
||||||
|
const { text } = await generateText({
|
||||||
|
abortSignal: AbortSignal.timeout(45_000),
|
||||||
|
maxTokens: 2600,
|
||||||
|
messages: [{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `${definition.prompt}\n\n导入模式:${parsed.data.mode === "overwrite" ? "覆盖当前导演台" : "插入当前导演台"}\n只输出 JSON,不要 Markdown。`,
|
||||||
|
},
|
||||||
|
{ type: "image", image: referenceImages[0]! },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
model,
|
||||||
|
temperature: 0.15,
|
||||||
|
} as any);
|
||||||
|
const json = extractJson(text);
|
||||||
|
if (!json) continue;
|
||||||
|
const result = normalizeResult(json);
|
||||||
|
await pointsCharge.consume({ modelId: candidate.model.modelId });
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...result,
|
||||||
|
model: candidate.model.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("director AI import model failed", {
|
||||||
|
error,
|
||||||
|
modelId: candidate.model.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "识图模型暂时不可用,请稍后重试" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("director AI import failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: error instanceof Error ? error.message : "识图导入失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/api/video-canvas/director-captures/route.ts
Normal file
113
app/api/video-canvas/director-captures/route.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { unlink } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { del } from "@vercel/blob";
|
||||||
|
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 { canvasProject, mediaAsset } from "@/lib/db/schema";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import {
|
||||||
|
deleteS3ObjectByPath,
|
||||||
|
resolveS3UploadTarget,
|
||||||
|
} from "@/lib/storage/service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const logger = createLogger("video-canvas-director-captures");
|
||||||
|
const deleteSchema = z.object({
|
||||||
|
assetId: z.string().uuid().optional(),
|
||||||
|
canvasId: z.string().uuid(),
|
||||||
|
imageUrl: z.string().trim().max(4096).optional(),
|
||||||
|
pathname: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(1024)
|
||||||
|
.transform((value) => value.replace(/^\/+/, ""))
|
||||||
|
.refine((value) => !value.includes("..") && !value.includes("\\"), "文件路径无效")
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteStoredCapture(pathname: string, imageUrl: string) {
|
||||||
|
if (pathname.startsWith("uploads/local/")) {
|
||||||
|
const publicRoot = path.resolve(process.cwd(), "public");
|
||||||
|
const absolutePath = path.resolve(publicRoot, pathname);
|
||||||
|
if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) {
|
||||||
|
throw new Error("文件路径越界");
|
||||||
|
}
|
||||||
|
await unlink(absolutePath).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
imageUrl.includes(".public.blob.vercel-storage.com/") &&
|
||||||
|
process.env.BLOB_READ_WRITE_TOKEN
|
||||||
|
) {
|
||||||
|
await del(imageUrl, { token: process.env.BLOB_READ_WRITE_TOKEN });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await resolveS3UploadTarget();
|
||||||
|
if (!target) throw new Error("没有可用的对象存储配置");
|
||||||
|
await deleteS3ObjectByPath(pathname, target.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = deleteSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [project] = await db
|
||||||
|
.select({ id: canvasProject.id })
|
||||||
|
.from(canvasProject)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(canvasProject.id, parsed.data.canvasId),
|
||||||
|
eq(canvasProject.userId, session.user.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.pathname) {
|
||||||
|
await deleteStoredCapture(
|
||||||
|
parsed.data.pathname,
|
||||||
|
parsed.data.imageUrl || "",
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn("capture file cleanup failed", { error });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parsed.data.assetId) {
|
||||||
|
await db
|
||||||
|
.delete(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.id, parsed.data.assetId),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("capture cleanup failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "截图清理失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/api/video-canvas/effects/search/route.ts
Normal file
56
app/api/video-canvas/effects/search/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import { rankVideoCanvasEffectPresets } from "@/lib/video-canvas/video-effects";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
query: z.string().trim().max(100).default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = requestSchema.safeParse(await request.json().catch(() => ({})));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: parsed.error.issues[0]?.message || "搜索参数无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const config = setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "视频画布功能已停用" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
presets: rankVideoCanvasEffectPresets(
|
||||||
|
config.effectPresets,
|
||||||
|
parsed.data.query,
|
||||||
|
),
|
||||||
|
query: parsed.data.query,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
153
app/api/video-canvas/history-assets/route.ts
Normal file
153
app/api/video-canvas/history-assets/route.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { and, desc, eq, inArray, ne, sql } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import { mediaAsset } from "@/lib/db/schema";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY,
|
||||||
|
VIDEO_CANVAS_HISTORY_MEDIA_TYPES,
|
||||||
|
normalizeVideoCanvasHistoryAssetIds,
|
||||||
|
} from "@/lib/video-canvas/history-assets";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||||
|
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||||
|
} from "@/lib/video-canvas/subjects";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const logger = createLogger("video-canvas-history-assets");
|
||||||
|
|
||||||
|
async function getHistoryConfig() {
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
return setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value).historyAssets
|
||||||
|
: defaultVideoCanvasFunctionConfig.historyAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [config, assets] = await Promise.all([
|
||||||
|
getHistoryConfig(),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
createdAt: mediaAsset.createdAt,
|
||||||
|
displayName: mediaAsset.displayName,
|
||||||
|
durationMs: mediaAsset.durationMs,
|
||||||
|
height: mediaAsset.height,
|
||||||
|
id: mediaAsset.id,
|
||||||
|
mediaType: mediaAsset.mediaType,
|
||||||
|
metadata: mediaAsset.metadata,
|
||||||
|
mimeType: mediaAsset.mimeType,
|
||||||
|
modelId: mediaAsset.modelId,
|
||||||
|
originalUrl: mediaAsset.originalUrl,
|
||||||
|
previewUrl: mediaAsset.previewUrl,
|
||||||
|
prompt: mediaAsset.prompt,
|
||||||
|
sourceNodeId: mediaAsset.sourceNodeId,
|
||||||
|
sourceType: mediaAsset.sourceType,
|
||||||
|
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||||
|
width: mediaAsset.width,
|
||||||
|
})
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
inArray(mediaAsset.mediaType, [
|
||||||
|
...VIDEO_CANVAS_HISTORY_MEDIA_TYPES,
|
||||||
|
]),
|
||||||
|
ne(
|
||||||
|
mediaAsset.sourceType,
|
||||||
|
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||||
|
),
|
||||||
|
ne(
|
||||||
|
mediaAsset.sourceType,
|
||||||
|
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||||
|
),
|
||||||
|
sql`coalesce(${mediaAsset.metadata}->>${VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY}, '') = ''`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(mediaAsset.createdAt))
|
||||||
|
.limit(500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
assets: config.enabled ? assets.slice(0, config.maxItems) : [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("history asset list failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "历史资产加载失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as {
|
||||||
|
assetIds?: unknown;
|
||||||
|
} | null;
|
||||||
|
const assetIds = normalizeVideoCanvasHistoryAssetIds(body?.assetIds);
|
||||||
|
if (assetIds.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "请选择历史资产" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenAt = new Date().toISOString();
|
||||||
|
const hidden = await db
|
||||||
|
.update(mediaAsset)
|
||||||
|
.set({
|
||||||
|
metadata: sql`coalesce(${mediaAsset.metadata}, '{}'::jsonb) || ${JSON.stringify({
|
||||||
|
[VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY]: hiddenAt,
|
||||||
|
})}::jsonb`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
inArray(mediaAsset.id, assetIds),
|
||||||
|
inArray(mediaAsset.mediaType, [...VIDEO_CANVAS_HISTORY_MEDIA_TYPES]),
|
||||||
|
sql`coalesce(${mediaAsset.metadata}->>${VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY}, '') = ''`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: mediaAsset.id });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, removed: hidden.length });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("history asset delete failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "历史记录删除失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
app/api/video-canvas/prompt-tools/route.ts
Normal file
226
app/api/video-canvas/prompt-tools/route.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import { generateText } from "ai";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||||
|
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
|
||||||
|
import {
|
||||||
|
isImageGenerationModelDescriptor,
|
||||||
|
isVideoModelDescriptor,
|
||||||
|
} from "@/lib/ai/model-classification";
|
||||||
|
import { getAiModelAgentCodingModelId } from "@/lib/db/ai-model-flags";
|
||||||
|
import {
|
||||||
|
getAgentAiModelWithProvider,
|
||||||
|
getEnabledAiModelsWithProvider,
|
||||||
|
getSystemSetting,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import {
|
||||||
|
buildVideoPromptStructureInstructions,
|
||||||
|
injectVideoPromptStructure,
|
||||||
|
resolveVideoPromptStructure,
|
||||||
|
} from "@/lib/video-canvas/prompt-structure";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const logger = createLogger("video-canvas-prompt-tools");
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
marks: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1).max(100),
|
||||||
|
label: z.string().trim().min(1).max(100),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(12)
|
||||||
|
.default([]),
|
||||||
|
mode: z.enum(["optimize", "translate"]),
|
||||||
|
prompt: z.string().trim().min(1, "请输入提示词").max(50_000, "提示词过长"),
|
||||||
|
references: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1).max(100),
|
||||||
|
index: z.number().int().min(1).max(10),
|
||||||
|
label: z.string().trim().min(1).max(100),
|
||||||
|
mediaType: z.enum(["audio", "image", "video"]),
|
||||||
|
offset: z.number().int().min(0).max(50_000),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.max(20)
|
||||||
|
.default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ModelCandidate = Awaited<
|
||||||
|
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
function isTextModelCandidate(candidate: ModelCandidate) {
|
||||||
|
const descriptor = {
|
||||||
|
capabilities: candidate.model.capabilities,
|
||||||
|
modelId: candidate.model.modelId,
|
||||||
|
providerType: candidate.provider.type,
|
||||||
|
supportsImageGen: candidate.model.supportsImageGen,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
candidate.model.isEnabled &&
|
||||||
|
candidate.provider.isEnabled &&
|
||||||
|
!candidate.model.isSubModel &&
|
||||||
|
candidate.model.healthStatus !== "auto_disabled" &&
|
||||||
|
!isImageGenerationModelDescriptor(descriptor) &&
|
||||||
|
!isVideoModelDescriptor(descriptor)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelRef(value: unknown) {
|
||||||
|
const normalized = String(value || "").trim();
|
||||||
|
return normalized && normalized !== "auto" ? normalized : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeneratedText(value: unknown) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^```(?:text|markdown|md)?\s*/i, "")
|
||||||
|
.replace(/\s*```$/i, "")
|
||||||
|
.replace(/^(?:优化后的提示词|译文|translation)\s*[::]\s*/i, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = requestSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
const config = setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const functionKey =
|
||||||
|
parsed.data.mode === "optimize"
|
||||||
|
? "video-prompt-optimize"
|
||||||
|
: "video-prompt-translate";
|
||||||
|
const definition = config.functions[functionKey];
|
||||||
|
|
||||||
|
if (!definition?.enabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: `${definition?.label || "当前功能"}已停用` },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [allModels, agentModel] = await Promise.all([
|
||||||
|
getEnabledAiModelsWithProvider(false),
|
||||||
|
getAgentAiModelWithProvider(),
|
||||||
|
]);
|
||||||
|
const candidates = allModels.filter(isTextModelCandidate);
|
||||||
|
const preferredModelRef =
|
||||||
|
normalizeModelRef(definition.model) || normalizeModelRef(config.defaultMultimodalModel);
|
||||||
|
const preferredModel = preferredModelRef
|
||||||
|
? candidates.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.model.id === preferredModelRef ||
|
||||||
|
candidate.model.modelId === preferredModelRef,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const orderedCandidates = [
|
||||||
|
preferredModel,
|
||||||
|
agentModel && isTextModelCandidate(agentModel) ? agentModel : null,
|
||||||
|
...candidates,
|
||||||
|
].filter(
|
||||||
|
(candidate, index, items): candidate is ModelCandidate =>
|
||||||
|
Boolean(candidate) &&
|
||||||
|
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orderedCandidates.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "当前站点没有可用的提示词处理模型" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of orderedCandidates.slice(0, 3)) {
|
||||||
|
try {
|
||||||
|
const codingModelId = getAiModelAgentCodingModelId(candidate.model);
|
||||||
|
const model = await getLanguageModelById(candidate.model.id, codingModelId);
|
||||||
|
if (!model) continue;
|
||||||
|
const structuredPrompt = injectVideoPromptStructure(
|
||||||
|
parsed.data.prompt,
|
||||||
|
parsed.data.references,
|
||||||
|
parsed.data.marks,
|
||||||
|
);
|
||||||
|
const structureInstructions = buildVideoPromptStructureInstructions(
|
||||||
|
parsed.data.references,
|
||||||
|
parsed.data.marks,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { text } = await generateText({
|
||||||
|
abortSignal: AbortSignal.timeout(18_000),
|
||||||
|
maxTokens: parsed.data.mode === "optimize" ? 2200 : 1600,
|
||||||
|
model,
|
||||||
|
prompt:
|
||||||
|
parsed.data.mode === "optimize"
|
||||||
|
? `原始视频提示词:\n${structuredPrompt}\n\n请按照系统规则输出优化后的提示词正文。`
|
||||||
|
: `待翻译的视频提示词:\n${structuredPrompt}\n\n请按照系统规则只输出译文。`,
|
||||||
|
system: [definition.prompt, structureInstructions].filter(Boolean).join("\n\n"),
|
||||||
|
temperature: getCompatibleTextGenerationTemperature(
|
||||||
|
candidate,
|
||||||
|
parsed.data.mode === "optimize" ? 0.65 : 0.25,
|
||||||
|
),
|
||||||
|
} as any);
|
||||||
|
const generatedText = normalizeGeneratedText(text);
|
||||||
|
if (!generatedText) continue;
|
||||||
|
const result = resolveVideoPromptStructure(
|
||||||
|
generatedText,
|
||||||
|
parsed.data.references,
|
||||||
|
parsed.data.marks,
|
||||||
|
{ flatten: parsed.data.mode === "optimize" },
|
||||||
|
);
|
||||||
|
if (!result.text) continue;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
mentionOffsets: result.mentionOffsets,
|
||||||
|
model: candidate.model.displayName,
|
||||||
|
preservedMarkIds: result.preservedMarkIds,
|
||||||
|
text: result.text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("prompt model candidate failed", {
|
||||||
|
error,
|
||||||
|
modelId: candidate.model.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "提示词处理模型暂时不可用,请稍后重试" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("prompt tool failed", { error });
|
||||||
|
const message = error instanceof Error ? error.message : "提示词处理失败";
|
||||||
|
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
226
app/api/video-canvas/subjects/complete-views/route.ts
Normal file
226
app/api/video-canvas/subjects/complete-views/route.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getPointsSystemConfig, getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import { mediaAsset } from "@/lib/db/schema";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import { VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE } from "@/lib/video-canvas/subjects";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
|
const logger = createLogger("video-canvas-subject-view-completion");
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
primaryAssetId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SUBJECT_COMPLETION_VIEW_DIRECTIONS = [
|
||||||
|
"正面近景,突出面部与头部特征",
|
||||||
|
"正面全身,完整展示服装与体态",
|
||||||
|
"左前方四十五度全身视角",
|
||||||
|
"右前方四十五度全身视角",
|
||||||
|
"左侧面全身视角",
|
||||||
|
"右侧面全身视角",
|
||||||
|
"背面全身视角",
|
||||||
|
"背面回首的全身视角",
|
||||||
|
"轻微俯视的正面全身视角",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = requestSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const config = setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const completionConfig = config.subjectLibrary.smartCompletion;
|
||||||
|
if (!config.subjectLibrary.enabled || !completionConfig.enabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "智能补全已停用" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [primaryAsset] = await db
|
||||||
|
.select({
|
||||||
|
id: mediaAsset.id,
|
||||||
|
originalUrl: mediaAsset.originalUrl,
|
||||||
|
})
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.id, parsed.data.primaryAssetId),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.mediaType, "image"),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!primaryAsset) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主参考图不存在或已失效" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
|
modelId: "video-canvas-subject-view-completion",
|
||||||
|
note: "主体其他视角智能补全消耗积分",
|
||||||
|
pointsConfig,
|
||||||
|
reason: "video_canvas_subject_view_completion",
|
||||||
|
unitCost: completionConfig.cost,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
if (!pointsCharge.ok) {
|
||||||
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceImage = new URL(primaryAsset.originalUrl, request.url).toString();
|
||||||
|
const selectedModel =
|
||||||
|
completionConfig.model === "auto"
|
||||||
|
? config.defaultImageModel
|
||||||
|
: completionConfig.model;
|
||||||
|
const generatedCandidates = (
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: completionConfig.candidateCount }, async (_, index) => {
|
||||||
|
const viewDirection =
|
||||||
|
SUBJECT_COMPLETION_VIEW_DIRECTIONS[
|
||||||
|
index % SUBJECT_COMPLETION_VIEW_DIRECTIONS.length
|
||||||
|
];
|
||||||
|
const candidatePrompt = `${completionConfig.prompt}\n本次只生成一张独立候选图。候选方向:${viewDirection}。不要生成拼图、九宫格、文字或边框。`;
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const result = await generateCanvasImage({
|
||||||
|
aspectRatio: completionConfig.aspectRatio,
|
||||||
|
debugLabel: "video-canvas-subject-view-completion",
|
||||||
|
debugMeta: {
|
||||||
|
candidateIndex: index,
|
||||||
|
candidateViewDirection: viewDirection,
|
||||||
|
primaryAssetId: primaryAsset.id,
|
||||||
|
subjectViewCompletion: true,
|
||||||
|
},
|
||||||
|
imageCount: 1,
|
||||||
|
imageSize: completionConfig.imageSize,
|
||||||
|
model: selectedModel,
|
||||||
|
ownerKey: `${session.user.id}:subject-view-${index}`,
|
||||||
|
prompt: candidatePrompt,
|
||||||
|
promptExtend: false,
|
||||||
|
referenceImage,
|
||||||
|
});
|
||||||
|
const image = result.images[0];
|
||||||
|
if (!image) throw new Error("模型未返回图片");
|
||||||
|
return {
|
||||||
|
image,
|
||||||
|
model: result.model,
|
||||||
|
modelRef: result.modelRef,
|
||||||
|
prompt: result.prompt,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("subject completion candidate failed", {
|
||||||
|
attempt,
|
||||||
|
candidateIndex: index,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate));
|
||||||
|
if (generatedCandidates.length !== completionConfig.candidateCount) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `智能补全未能生成全部 ${completionConfig.candidateCount} 张候选图,请重试`,
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedRows = await db
|
||||||
|
.insert(mediaAsset)
|
||||||
|
.values(
|
||||||
|
generatedCandidates.map((candidate, index) => ({
|
||||||
|
displayName: `主体补充视角 ${index + 1}`,
|
||||||
|
mediaType: "image",
|
||||||
|
metadata: {
|
||||||
|
candidateIndex: index,
|
||||||
|
generatedBy: "subject-smart-completion",
|
||||||
|
primaryAssetId: primaryAsset.id,
|
||||||
|
},
|
||||||
|
mimeType: "image/png",
|
||||||
|
modelId: candidate.modelRef || candidate.model,
|
||||||
|
normalizedUrl: candidate.image.originalUrl,
|
||||||
|
originalUrl: candidate.image.originalUrl,
|
||||||
|
previewUrl: candidate.image.thumbnailUrl || candidate.image.originalUrl,
|
||||||
|
prompt: candidate.prompt,
|
||||||
|
rootAssetId: primaryAsset.id,
|
||||||
|
sourceTable: "video_canvas_subject",
|
||||||
|
sourceType: VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||||
|
status: "ready",
|
||||||
|
thumbnailUrl: candidate.image.thumbnailUrl || candidate.image.originalUrl,
|
||||||
|
userId: session.user.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.returning({
|
||||||
|
assetId: mediaAsset.id,
|
||||||
|
name: mediaAsset.displayName,
|
||||||
|
url: mediaAsset.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryGeneratedCandidate = generatedCandidates[0];
|
||||||
|
await pointsCharge.consume({
|
||||||
|
modelId: primaryGeneratedCandidate.modelRef || primaryGeneratedCandidate.model,
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.warn("subject completion points consumption failed", { error });
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
candidates: generatedRows.map((item) => ({
|
||||||
|
assetId: item.assetId,
|
||||||
|
mediaType: "image" as const,
|
||||||
|
name: item.name || "补充视角",
|
||||||
|
url: item.url,
|
||||||
|
})),
|
||||||
|
cost: pointsCharge.unitCost,
|
||||||
|
maxSelected: completionConfig.maxSelected,
|
||||||
|
model: primaryGeneratedCandidate.model,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("subject view completion failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "智能补全失败",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
274
app/api/video-canvas/subjects/generate-description/route.ts
Normal file
274
app/api/video-canvas/subjects/generate-description/route.ts
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import { generateText } from "ai";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||||
|
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
|
||||||
|
import {
|
||||||
|
isImageGenerationModelDescriptor,
|
||||||
|
isVideoModelDescriptor,
|
||||||
|
} from "@/lib/ai/model-classification";
|
||||||
|
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
getAiModelAgentCodingModelId,
|
||||||
|
isAiModelMultimodal,
|
||||||
|
} from "@/lib/db/ai-model-flags";
|
||||||
|
import {
|
||||||
|
getAgentAiModelWithProvider,
|
||||||
|
getEnabledAiModelsWithProvider,
|
||||||
|
getPointsSystemConfig,
|
||||||
|
getSystemSetting,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { mediaAsset } from "@/lib/db/schema";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import {
|
||||||
|
normalizeGeneratedSubjectDescription,
|
||||||
|
resolveVideoCanvasSubjectCategory,
|
||||||
|
} from "@/lib/video-canvas/subjects";
|
||||||
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const logger = createLogger("video-canvas-subject-description");
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
assetIds: z.array(z.string().uuid()).min(1).max(4),
|
||||||
|
category: z.string().trim().max(20).default("无标签"),
|
||||||
|
frameDataUrl: z
|
||||||
|
.string()
|
||||||
|
.max(12_000_000)
|
||||||
|
.refine(
|
||||||
|
(value) => !value || /^data:image\/(?:jpeg|png|webp);base64,/i.test(value),
|
||||||
|
"视频预览帧无效",
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ModelCandidate = Awaited<
|
||||||
|
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
function isDescriptionModelCandidate(candidate: ModelCandidate) {
|
||||||
|
const descriptor = {
|
||||||
|
capabilities: candidate.model.capabilities,
|
||||||
|
modelId: candidate.model.modelId,
|
||||||
|
providerType: candidate.provider.type,
|
||||||
|
supportsImageGen: candidate.model.supportsImageGen,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
candidate.model.isEnabled &&
|
||||||
|
candidate.provider.isEnabled &&
|
||||||
|
!candidate.model.isSubModel &&
|
||||||
|
candidate.model.healthStatus !== "auto_disabled" &&
|
||||||
|
!isImageGenerationModelDescriptor(descriptor) &&
|
||||||
|
!isVideoModelDescriptor(descriptor) &&
|
||||||
|
(Boolean(candidate.model.supportsVision) ||
|
||||||
|
isAiModelMultimodal({
|
||||||
|
capabilities: candidate.model.capabilities,
|
||||||
|
defaultParams: candidate.model.defaultParams,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelRef(value: unknown) {
|
||||||
|
const normalized = String(value || "").trim();
|
||||||
|
return normalized && normalized !== "auto" ? normalized : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = requestSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
const config = setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||||
|
: defaultVideoCanvasFunctionConfig;
|
||||||
|
const subjectConfig = config.subjectLibrary;
|
||||||
|
if (!subjectConfig.enabled || !subjectConfig.smartDescription.enabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "智能生成已停用" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = await db
|
||||||
|
.select({
|
||||||
|
id: mediaAsset.id,
|
||||||
|
mediaType: mediaAsset.mediaType,
|
||||||
|
originalUrl: mediaAsset.originalUrl,
|
||||||
|
})
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
inArray(mediaAsset.id, parsed.data.assetIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
|
||||||
|
const orderedAssets = parsed.data.assetIds.flatMap((id) => {
|
||||||
|
const asset = assetById.get(id);
|
||||||
|
return asset ? [asset] : [];
|
||||||
|
});
|
||||||
|
if (orderedAssets.length !== parsed.data.assetIds.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体素材不存在或已失效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrls = orderedAssets
|
||||||
|
.filter((asset) => asset.mediaType === "image")
|
||||||
|
.map((asset) => new URL(asset.originalUrl, request.url).toString());
|
||||||
|
const inlinedImages = imageUrls.length
|
||||||
|
? (
|
||||||
|
await inlineVisionImages(imageUrls, {
|
||||||
|
loggerPrefix: "[Video Canvas Subject]",
|
||||||
|
maxImages: subjectConfig.imageMaxCount,
|
||||||
|
maxSide: 1024,
|
||||||
|
retries: 1,
|
||||||
|
timeoutMs: 12_000,
|
||||||
|
})
|
||||||
|
).inlineDataUrls
|
||||||
|
: [];
|
||||||
|
const references = [
|
||||||
|
...inlinedImages,
|
||||||
|
...(parsed.data.frameDataUrl ? [parsed.data.frameDataUrl] : []),
|
||||||
|
].slice(0, subjectConfig.imageMaxCount);
|
||||||
|
if (references.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未能读取主体画面" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [allModels, agentModel] = await Promise.all([
|
||||||
|
getEnabledAiModelsWithProvider(false),
|
||||||
|
getAgentAiModelWithProvider(),
|
||||||
|
]);
|
||||||
|
const candidates = allModels.filter(isDescriptionModelCandidate);
|
||||||
|
const preferredRef = normalizeModelRef(config.defaultMultimodalModel);
|
||||||
|
const preferredModel = preferredRef
|
||||||
|
? candidates.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.model.id === preferredRef ||
|
||||||
|
candidate.model.modelId === preferredRef,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const orderedCandidates = [
|
||||||
|
preferredModel,
|
||||||
|
agentModel && isDescriptionModelCandidate(agentModel) ? agentModel : null,
|
||||||
|
...candidates,
|
||||||
|
].filter(
|
||||||
|
(candidate, index, items): candidate is ModelCandidate =>
|
||||||
|
Boolean(candidate) &&
|
||||||
|
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
|
||||||
|
);
|
||||||
|
if (orderedCandidates.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "当前站点没有可用的多模态模型" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = resolveVideoCanvasSubjectCategory(
|
||||||
|
parsed.data.category,
|
||||||
|
subjectConfig,
|
||||||
|
);
|
||||||
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
|
modelId: "video-canvas-subject-description",
|
||||||
|
note: "主体描述智能生成消耗积分",
|
||||||
|
pointsConfig,
|
||||||
|
reason: "video_canvas_subject_description",
|
||||||
|
unitCost: subjectConfig.smartDescription.cost,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
if (!pointsCharge.ok) {
|
||||||
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
|
}
|
||||||
|
for (const candidate of orderedCandidates.slice(0, 3)) {
|
||||||
|
try {
|
||||||
|
const model = await getLanguageModelById(
|
||||||
|
candidate.model.id,
|
||||||
|
getAiModelAgentCodingModelId(candidate.model),
|
||||||
|
);
|
||||||
|
if (!model) continue;
|
||||||
|
const { text } = await generateText({
|
||||||
|
abortSignal: AbortSignal.timeout(35_000),
|
||||||
|
maxTokens: subjectConfig.smartDescription.maxTokens,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `主体标签:${category}\n参考画面数量:${references.length}\n请根据系统规则生成主体描述。`,
|
||||||
|
},
|
||||||
|
...references.map((image) => ({ type: "image" as const, image })),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model,
|
||||||
|
system: subjectConfig.smartDescription.prompt,
|
||||||
|
temperature: getCompatibleTextGenerationTemperature(
|
||||||
|
candidate,
|
||||||
|
subjectConfig.smartDescription.temperature,
|
||||||
|
),
|
||||||
|
} as any);
|
||||||
|
const description = normalizeGeneratedSubjectDescription(
|
||||||
|
text,
|
||||||
|
subjectConfig.descriptionMaxLength,
|
||||||
|
);
|
||||||
|
if (!description) continue;
|
||||||
|
await pointsCharge.consume({ modelId: candidate.model.id }).catch((error) => {
|
||||||
|
logger.warn("subject description points consumption failed", { error });
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
cost: pointsCharge.unitCost,
|
||||||
|
description,
|
||||||
|
model: candidate.model.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("subject description model failed", {
|
||||||
|
error,
|
||||||
|
modelId: candidate.model.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "智能描述模型暂时不可用,请稍后重试" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("subject description generation failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体描述生成失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
569
app/api/video-canvas/subjects/route.ts
Normal file
569
app/api/video-canvas/subjects/route.ts
Normal file
@ -0,0 +1,569 @@
|
|||||||
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import { mediaAsset } from "@/lib/db/schema";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
defaultVideoCanvasFunctionConfig,
|
||||||
|
normalizeVideoCanvasFunctionConfig,
|
||||||
|
} from "@/lib/video-canvas/config";
|
||||||
|
import {
|
||||||
|
VIDEO_CANVAS_SUBJECT_SOURCE_TYPE,
|
||||||
|
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||||
|
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||||
|
normalizeVideoCanvasSubjectText,
|
||||||
|
resolveVideoCanvasSubjectCategory,
|
||||||
|
validateVideoCanvasSubjectDraft,
|
||||||
|
} from "@/lib/video-canvas/subjects";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const logger = createLogger("video-canvas-subjects");
|
||||||
|
|
||||||
|
const createSubjectSchema = z.object({
|
||||||
|
assetIds: z.array(z.string().uuid()).min(1).max(10),
|
||||||
|
category: z.string().trim().max(20),
|
||||||
|
description: z.string().trim().min(1).max(1000),
|
||||||
|
durationSeconds: z.number().finite().positive().max(3600).nullish(),
|
||||||
|
mediaMode: z.enum(["images", "video"]),
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
voiceId: z.string().trim().min(1).max(160).default("none"),
|
||||||
|
});
|
||||||
|
const updateSubjectSchema = createSubjectSchema.extend({
|
||||||
|
subjectId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getSubjectLibraryConfig() {
|
||||||
|
const setting = await getSystemSetting({
|
||||||
|
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
|
}).catch(() => null);
|
||||||
|
return setting?.value
|
||||||
|
? normalizeVideoCanvasFunctionConfig(setting.value).subjectLibrary
|
||||||
|
: defaultVideoCanvasFunctionConfig.subjectLibrary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMetadataAssetIds(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||||
|
const assetIds = (value as Record<string, unknown>).assetIds;
|
||||||
|
return Array.isArray(assetIds)
|
||||||
|
? assetIds.map((item) => String(item || "")).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getSubjectLibraryConfig();
|
||||||
|
const requestedCategory = new URL(request.url).searchParams.get("category");
|
||||||
|
const category = requestedCategory
|
||||||
|
? resolveVideoCanvasSubjectCategory(requestedCategory, config)
|
||||||
|
: "";
|
||||||
|
const subjects = await db
|
||||||
|
.select({
|
||||||
|
createdAt: mediaAsset.createdAt,
|
||||||
|
description: mediaAsset.summary,
|
||||||
|
displayName: mediaAsset.displayName,
|
||||||
|
durationMs: mediaAsset.durationMs,
|
||||||
|
id: mediaAsset.id,
|
||||||
|
metadata: mediaAsset.metadata,
|
||||||
|
originalUrl: mediaAsset.originalUrl,
|
||||||
|
previewUrl: mediaAsset.previewUrl,
|
||||||
|
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||||
|
})
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.mediaType, "subject"),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(mediaAsset.updatedAt))
|
||||||
|
.limit(100);
|
||||||
|
const filteredSubjects = category
|
||||||
|
? subjects.filter((subject) => {
|
||||||
|
const metadata = subject.metadata;
|
||||||
|
return (
|
||||||
|
metadata &&
|
||||||
|
typeof metadata === "object" &&
|
||||||
|
!Array.isArray(metadata) &&
|
||||||
|
(metadata as Record<string, unknown>).category === category
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: subjects;
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, subjects: filteredSubjects });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("subject list failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体加载失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = createSubjectSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getSubjectLibraryConfig();
|
||||||
|
const category = resolveVideoCanvasSubjectCategory(parsed.data.category, config);
|
||||||
|
const validationError = validateVideoCanvasSubjectDraft(
|
||||||
|
{
|
||||||
|
assetCount: parsed.data.assetIds.length,
|
||||||
|
category,
|
||||||
|
description: parsed.data.description,
|
||||||
|
durationSeconds: parsed.data.durationSeconds,
|
||||||
|
mediaMode: parsed.data.mediaMode,
|
||||||
|
name: parsed.data.name,
|
||||||
|
voiceId: parsed.data.voiceId,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
if (validationError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: validationError },
|
||||||
|
{ status: config.enabled ? 400 : 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedAssets = await db
|
||||||
|
.select({
|
||||||
|
id: mediaAsset.id,
|
||||||
|
mediaType: mediaAsset.mediaType,
|
||||||
|
mimeType: mediaAsset.mimeType,
|
||||||
|
originalUrl: mediaAsset.originalUrl,
|
||||||
|
previewUrl: mediaAsset.previewUrl,
|
||||||
|
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||||
|
})
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
inArray(mediaAsset.id, parsed.data.assetIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const assetById = new Map(uploadedAssets.map((asset) => [asset.id, asset]));
|
||||||
|
const orderedAssets = parsed.data.assetIds.flatMap((id) => {
|
||||||
|
const asset = assetById.get(id);
|
||||||
|
return asset ? [asset] : [];
|
||||||
|
});
|
||||||
|
if (orderedAssets.length !== parsed.data.assetIds.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体素材不存在或已失效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expectedMediaType = parsed.data.mediaMode === "images" ? "image" : "video";
|
||||||
|
if (orderedAssets.some((asset) => asset.mediaType !== expectedMediaType)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体素材类型不一致" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let customVoiceAssetId: string | null = null;
|
||||||
|
if (parsed.data.voiceId.startsWith("custom:")) {
|
||||||
|
customVoiceAssetId = parsed.data.voiceId.slice("custom:".length);
|
||||||
|
if (!z.string().uuid().safeParse(customVoiceAssetId).success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "自定义音色无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [voiceAsset] = await db
|
||||||
|
.select({ id: mediaAsset.id })
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.id, customVoiceAssetId),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!voiceAsset) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "自定义音色不存在或已失效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryAsset = orderedAssets[0];
|
||||||
|
const description = normalizeVideoCanvasSubjectText(
|
||||||
|
parsed.data.description,
|
||||||
|
config.descriptionMaxLength,
|
||||||
|
);
|
||||||
|
const name = normalizeVideoCanvasSubjectText(
|
||||||
|
parsed.data.name,
|
||||||
|
config.nameMaxLength,
|
||||||
|
);
|
||||||
|
const voice = config.voices.find((item) => item.id === parsed.data.voiceId);
|
||||||
|
const durationMs =
|
||||||
|
parsed.data.mediaMode === "video" && parsed.data.durationSeconds
|
||||||
|
? Math.round(parsed.data.durationSeconds * 1000)
|
||||||
|
: null;
|
||||||
|
const subjectId = randomUUID();
|
||||||
|
const [subject] = await db
|
||||||
|
.insert(mediaAsset)
|
||||||
|
.values({
|
||||||
|
displayName: name,
|
||||||
|
durationMs,
|
||||||
|
id: subjectId,
|
||||||
|
mediaType: "subject",
|
||||||
|
metadata: {
|
||||||
|
assetIds: parsed.data.assetIds,
|
||||||
|
category,
|
||||||
|
customVoiceAssetId,
|
||||||
|
durationSeconds: parsed.data.durationSeconds || null,
|
||||||
|
mediaMode: parsed.data.mediaMode,
|
||||||
|
mediaUrls: orderedAssets.map((asset) => asset.originalUrl),
|
||||||
|
voiceBadge: voice?.badge || (customVoiceAssetId ? "自定义" : ""),
|
||||||
|
voiceId: parsed.data.voiceId,
|
||||||
|
voiceName: voice?.name || (customVoiceAssetId ? "自定义音色" : "无配音"),
|
||||||
|
},
|
||||||
|
mimeType: "application/x-video-canvas-subject",
|
||||||
|
normalizedUrl: `video-canvas-subject://${subjectId}`,
|
||||||
|
originalUrl: primaryAsset.originalUrl,
|
||||||
|
previewUrl: primaryAsset.previewUrl || primaryAsset.originalUrl,
|
||||||
|
sourceTable: "video_canvas_subject",
|
||||||
|
sourceType: VIDEO_CANVAS_SUBJECT_SOURCE_TYPE,
|
||||||
|
status: "ready",
|
||||||
|
summary: { description },
|
||||||
|
thumbnailUrl:
|
||||||
|
primaryAsset.thumbnailUrl || primaryAsset.previewUrl || primaryAsset.originalUrl,
|
||||||
|
userId: session.user.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, subject });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("subject creation failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体创建失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const parsed = updateSubjectSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingSubject] = await db
|
||||||
|
.select({ id: mediaAsset.id, metadata: mediaAsset.metadata })
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.id, parsed.data.subjectId),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!existingSubject) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getSubjectLibraryConfig();
|
||||||
|
const category = resolveVideoCanvasSubjectCategory(parsed.data.category, config);
|
||||||
|
const validationError = validateVideoCanvasSubjectDraft(
|
||||||
|
{
|
||||||
|
assetCount: parsed.data.assetIds.length,
|
||||||
|
category,
|
||||||
|
description: parsed.data.description,
|
||||||
|
durationSeconds: parsed.data.durationSeconds,
|
||||||
|
mediaMode: parsed.data.mediaMode,
|
||||||
|
name: parsed.data.name,
|
||||||
|
voiceId: parsed.data.voiceId,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
if (validationError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: validationError },
|
||||||
|
{ status: config.enabled ? 400 : 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedAssets = await db
|
||||||
|
.select({
|
||||||
|
id: mediaAsset.id,
|
||||||
|
mediaType: mediaAsset.mediaType,
|
||||||
|
originalUrl: mediaAsset.originalUrl,
|
||||||
|
previewUrl: mediaAsset.previewUrl,
|
||||||
|
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||||
|
})
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
inArray(mediaAsset.id, parsed.data.assetIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const assetById = new Map(ownedAssets.map((asset) => [asset.id, asset]));
|
||||||
|
const orderedAssets = parsed.data.assetIds.flatMap((id) => {
|
||||||
|
const asset = assetById.get(id);
|
||||||
|
return asset ? [asset] : [];
|
||||||
|
});
|
||||||
|
const expectedMediaType = parsed.data.mediaMode === "images" ? "image" : "video";
|
||||||
|
if (
|
||||||
|
orderedAssets.length !== parsed.data.assetIds.length ||
|
||||||
|
orderedAssets.some((asset) => asset.mediaType !== expectedMediaType)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体素材不存在、已失效或类型不一致" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let customVoiceAssetId: string | null = null;
|
||||||
|
if (parsed.data.voiceId.startsWith("custom:")) {
|
||||||
|
customVoiceAssetId = parsed.data.voiceId.slice("custom:".length);
|
||||||
|
if (!z.string().uuid().safeParse(customVoiceAssetId).success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "自定义音色无效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [voiceAsset] = await db
|
||||||
|
.select({ id: mediaAsset.id })
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.id, customVoiceAssetId),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||||
|
eq(mediaAsset.status, "ready"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!voiceAsset) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "自定义音色不存在或已失效" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryAsset = orderedAssets[0];
|
||||||
|
const voice = config.voices.find((item) => item.id === parsed.data.voiceId);
|
||||||
|
const previousMetadata =
|
||||||
|
existingSubject.metadata &&
|
||||||
|
typeof existingSubject.metadata === "object" &&
|
||||||
|
!Array.isArray(existingSubject.metadata)
|
||||||
|
? (existingSubject.metadata as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const previousAssetIds = readMetadataAssetIds(existingSubject.metadata);
|
||||||
|
const previousCustomVoiceId = String(previousMetadata.customVoiceAssetId || "").trim();
|
||||||
|
const [subject] = await db
|
||||||
|
.update(mediaAsset)
|
||||||
|
.set({
|
||||||
|
displayName: normalizeVideoCanvasSubjectText(
|
||||||
|
parsed.data.name,
|
||||||
|
config.nameMaxLength,
|
||||||
|
),
|
||||||
|
durationMs:
|
||||||
|
parsed.data.mediaMode === "video" && parsed.data.durationSeconds
|
||||||
|
? Math.round(parsed.data.durationSeconds * 1000)
|
||||||
|
: null,
|
||||||
|
metadata: {
|
||||||
|
assetIds: parsed.data.assetIds,
|
||||||
|
category,
|
||||||
|
customVoiceAssetId,
|
||||||
|
durationSeconds: parsed.data.durationSeconds || null,
|
||||||
|
mediaMode: parsed.data.mediaMode,
|
||||||
|
mediaUrls: orderedAssets.map((asset) => asset.originalUrl),
|
||||||
|
voiceBadge: voice?.badge || (customVoiceAssetId ? "自定义" : ""),
|
||||||
|
voiceId: parsed.data.voiceId,
|
||||||
|
voiceName: voice?.name || (customVoiceAssetId ? "自定义音色" : "无配音"),
|
||||||
|
},
|
||||||
|
originalUrl: primaryAsset.originalUrl,
|
||||||
|
previewUrl: primaryAsset.previewUrl || primaryAsset.originalUrl,
|
||||||
|
summary: {
|
||||||
|
description: normalizeVideoCanvasSubjectText(
|
||||||
|
parsed.data.description,
|
||||||
|
config.descriptionMaxLength,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
thumbnailUrl:
|
||||||
|
primaryAsset.thumbnailUrl || primaryAsset.previewUrl || primaryAsset.originalUrl,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(mediaAsset.id, existingSubject.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const removedAssetIds = previousAssetIds.filter(
|
||||||
|
(assetId) => !parsed.data.assetIds.includes(assetId),
|
||||||
|
);
|
||||||
|
if (removedAssetIds.length) {
|
||||||
|
await db.delete(mediaAsset).where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE),
|
||||||
|
inArray(mediaAsset.id, removedAssetIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
previousCustomVoiceId &&
|
||||||
|
previousCustomVoiceId !== customVoiceAssetId &&
|
||||||
|
z.string().uuid().safeParse(previousCustomVoiceId).success
|
||||||
|
) {
|
||||||
|
await db.delete(mediaAsset).where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.id, previousCustomVoiceId),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, subject });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("subject update failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体保存失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const body = (await request.json().catch(() => null)) as {
|
||||||
|
subjectId?: string;
|
||||||
|
uploadAssetIds?: unknown;
|
||||||
|
voiceAssetIds?: unknown;
|
||||||
|
} | null;
|
||||||
|
const uploadAssetIds = Array.isArray(body?.uploadAssetIds)
|
||||||
|
? body.uploadAssetIds.map((item) => String(item || "")).filter(Boolean).slice(0, 10)
|
||||||
|
: [];
|
||||||
|
const voiceAssetIds = Array.isArray(body?.voiceAssetIds)
|
||||||
|
? body.voiceAssetIds.map((item) => String(item || "")).filter(Boolean).slice(0, 10)
|
||||||
|
: [];
|
||||||
|
const subjectId = String(body?.subjectId || "").trim();
|
||||||
|
|
||||||
|
if (subjectId) {
|
||||||
|
const [subject] = await db
|
||||||
|
.select({ id: mediaAsset.id, metadata: mediaAsset.metadata })
|
||||||
|
.from(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.id, subjectId),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!subject) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体不存在" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const referencedAssetIds = readMetadataAssetIds(subject.metadata);
|
||||||
|
const subjectMetadata =
|
||||||
|
subject.metadata &&
|
||||||
|
typeof subject.metadata === "object" &&
|
||||||
|
!Array.isArray(subject.metadata)
|
||||||
|
? (subject.metadata as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const customVoiceAssetId = String(
|
||||||
|
subjectMetadata.customVoiceAssetId || "",
|
||||||
|
).trim();
|
||||||
|
await db.delete(mediaAsset).where(eq(mediaAsset.id, subject.id));
|
||||||
|
if (referencedAssetIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE),
|
||||||
|
inArray(mediaAsset.id, referencedAssetIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (z.string().uuid().safeParse(customVoiceAssetId).success) {
|
||||||
|
await db
|
||||||
|
.delete(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.id, customVoiceAssetId),
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: true, removed: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupGroups = [
|
||||||
|
{ ids: uploadAssetIds, sourceType: VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE },
|
||||||
|
{ ids: voiceAssetIds, sourceType: VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE },
|
||||||
|
];
|
||||||
|
let removed = 0;
|
||||||
|
for (const group of cleanupGroups) {
|
||||||
|
if (group.ids.length === 0) continue;
|
||||||
|
const deleted = await db
|
||||||
|
.delete(mediaAsset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(mediaAsset.userId, session.user.id),
|
||||||
|
eq(mediaAsset.sourceType, group.sourceType),
|
||||||
|
inArray(mediaAsset.id, group.ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: mediaAsset.id });
|
||||||
|
removed += deleted.length;
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: true, removed });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("subject deletion failed", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "主体删除失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,10 @@ import {
|
|||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
|
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||||
|
import { FAL_NANO_BANANA_2_EDIT_MODEL_ID } from "@/lib/ai/fal-image-models";
|
||||||
|
|
||||||
|
export const maxDuration = 600;
|
||||||
|
|
||||||
// PicWish API 需要公网可访问的 HTTP/HTTPS URL
|
// PicWish API 需要公网可访问的 HTTP/HTTPS URL
|
||||||
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
|
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
|
||||||
@ -115,11 +119,10 @@ function buildWatermarkTaskPendingResponse(taskId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finalizeWatermarkTaskResult(params: {
|
async function persistWatermarkResult(params: {
|
||||||
taskId: string;
|
processedImageUrl: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
apiKey: string;
|
|
||||||
sourceImageUrls?: string[];
|
sourceImageUrls?: string[];
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@ -127,17 +130,8 @@ async function finalizeWatermarkTaskResult(params: {
|
|||||||
thumbnail_url: string;
|
thumbnail_url: string;
|
||||||
processing_time?: number;
|
processing_time?: number;
|
||||||
}> {
|
}> {
|
||||||
const pollResult = await pollForResult(params.taskId, params.apiKey, WATERMARK_SYNC_WAIT_TIMEOUT_MS);
|
|
||||||
if (pollResult.pending) {
|
|
||||||
throw new Error("WATERMARK_TASK_PENDING");
|
|
||||||
}
|
|
||||||
if (!pollResult.success || !pollResult.imageUrl) {
|
|
||||||
throw new Error(pollResult.error || "去水印处理失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
const processedImageUrl = pollResult.imageUrl;
|
|
||||||
try {
|
try {
|
||||||
const imageResponse = await fetch(processedImageUrl);
|
const imageResponse = await fetch(params.processedImageUrl);
|
||||||
if (!imageResponse.ok) {
|
if (!imageResponse.ok) {
|
||||||
throw new Error("下载处理后的图片失败");
|
throw new Error("下载处理后的图片失败");
|
||||||
}
|
}
|
||||||
@ -217,7 +211,7 @@ async function finalizeWatermarkTaskResult(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (assetError) {
|
} catch (assetError) {
|
||||||
console.error("[WatermarkRemove] ⚠️ MediaAsset 注册失败:", assetError);
|
console.error("[WatermarkRemove] MediaAsset 注册失败:", assetError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,14 +226,85 @@ async function finalizeWatermarkTaskResult(params: {
|
|||||||
console.error("[WatermarkRemove] 上传结果失败,回退原始URL:", uploadError);
|
console.error("[WatermarkRemove] 上传结果失败,回退原始URL:", uploadError);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
original_url: processedImageUrl,
|
original_url: params.processedImageUrl,
|
||||||
thumbnail_url: processedImageUrl,
|
thumbnail_url: params.processedImageUrl,
|
||||||
processing_time:
|
processing_time:
|
||||||
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
|
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseFalWatermarkFallback(status: number, detail: string) {
|
||||||
|
return (
|
||||||
|
status === 401 ||
|
||||||
|
status === 402 ||
|
||||||
|
status === 403 ||
|
||||||
|
status === 429 ||
|
||||||
|
status >= 500 ||
|
||||||
|
/quota|credit|balance|exceed|expired|invalid|unauthor/i.test(detail)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeWatermarkWithFal(params: {
|
||||||
|
imageUrl: string;
|
||||||
|
userId: string;
|
||||||
|
startedAt: number;
|
||||||
|
sourceImageUrls?: string[];
|
||||||
|
}) {
|
||||||
|
console.warn("[WatermarkRemove] AOS unavailable; using FAL Nano Banana 2 edit fallback");
|
||||||
|
const result = await generateCanvasImage({
|
||||||
|
prompt: [
|
||||||
|
"仅移除图片中后期叠加的水印、平台角标和覆盖式文字标记。",
|
||||||
|
"必须完整保留原图主体、商品、背景、构图、尺寸、颜色、光影和清晰度。",
|
||||||
|
"用周围真实纹理自然补全被遮挡区域,不得新增文字、标志或其他物体。",
|
||||||
|
].join(""),
|
||||||
|
model: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
|
||||||
|
aspectRatio: "auto",
|
||||||
|
imageSize: "2K",
|
||||||
|
imageCount: 1,
|
||||||
|
referenceImages: [params.imageUrl],
|
||||||
|
allowFailover: false,
|
||||||
|
debugLabel: "watermark-remove-fal-fallback",
|
||||||
|
});
|
||||||
|
const processedImageUrl = result.images[0]?.originalUrl;
|
||||||
|
if (!processedImageUrl) {
|
||||||
|
throw new Error("FAL 去水印未返回可用图片");
|
||||||
|
}
|
||||||
|
return persistWatermarkResult({
|
||||||
|
processedImageUrl,
|
||||||
|
userId: params.userId,
|
||||||
|
startedAt: params.startedAt,
|
||||||
|
sourceImageUrls: params.sourceImageUrls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeWatermarkTaskResult(params: {
|
||||||
|
taskId: string;
|
||||||
|
userId: string;
|
||||||
|
startedAt?: number;
|
||||||
|
apiKey: string;
|
||||||
|
sourceImageUrls?: string[];
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
original_url: string;
|
||||||
|
thumbnail_url: string;
|
||||||
|
processing_time?: number;
|
||||||
|
}> {
|
||||||
|
const pollResult = await pollForResult(params.taskId, params.apiKey, WATERMARK_SYNC_WAIT_TIMEOUT_MS);
|
||||||
|
if (pollResult.pending) {
|
||||||
|
throw new Error("WATERMARK_TASK_PENDING");
|
||||||
|
}
|
||||||
|
if (!pollResult.success || !pollResult.imageUrl) {
|
||||||
|
throw new Error(pollResult.error || "去水印处理失败");
|
||||||
|
}
|
||||||
|
return persistWatermarkResult({
|
||||||
|
processedImageUrl: pollResult.imageUrl,
|
||||||
|
userId: params.userId,
|
||||||
|
startedAt: params.startedAt,
|
||||||
|
sourceImageUrls: params.sourceImageUrls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -306,13 +371,6 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 获取API配置
|
// 获取API配置
|
||||||
const config = await getWatermarkRemoveConfig();
|
const config = await getWatermarkRemoveConfig();
|
||||||
if (!config?.apiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "去水印服务未配置,请联系管理员在系统设置中配置API密钥" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[WatermarkRemove] 使用原图URL:", image_url.substring(0, 100) + "...");
|
console.log("[WatermarkRemove] 使用原图URL:", image_url.substring(0, 100) + "...");
|
||||||
|
|
||||||
// 处理图片URL,确保获取公网可访问的URL
|
// 处理图片URL,确保获取公网可访问的URL
|
||||||
@ -396,6 +454,24 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceImageUrls =
|
||||||
|
typeof image_url === "string" && /^https?:\/\//i.test(image_url)
|
||||||
|
? [image_url]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!config?.apiKey) {
|
||||||
|
const fallbackResult = await removeWatermarkWithFal({
|
||||||
|
imageUrl: publicImageUrl,
|
||||||
|
userId: session.user.id,
|
||||||
|
startedAt: startTime,
|
||||||
|
sourceImageUrls,
|
||||||
|
});
|
||||||
|
if (pointsCharge.chargeable) {
|
||||||
|
await pointsCharge.consume({ note: "FAL 去水印备用链路消耗积分" });
|
||||||
|
}
|
||||||
|
return NextResponse.json(fallbackResult);
|
||||||
|
}
|
||||||
|
|
||||||
// 创建去水印任务
|
// 创建去水印任务
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("sync", "0");
|
formData.append("sync", "0");
|
||||||
@ -415,8 +491,24 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!createResponse.ok) {
|
if (!createResponse.ok) {
|
||||||
const errorText = await createResponse.text();
|
const errorText = await createResponse.text();
|
||||||
console.error("[WatermarkRemove] 创建任务失败:", errorText);
|
console.error("[WatermarkRemove] 创建任务失败:", errorText);
|
||||||
|
if (shouldUseFalWatermarkFallback(createResponse.status, errorText)) {
|
||||||
|
try {
|
||||||
|
const fallbackResult = await removeWatermarkWithFal({
|
||||||
|
imageUrl: publicImageUrl,
|
||||||
|
userId: session.user.id,
|
||||||
|
startedAt: startTime,
|
||||||
|
sourceImageUrls,
|
||||||
|
});
|
||||||
|
if (pointsCharge.chargeable) {
|
||||||
|
await pointsCharge.consume({ note: "FAL 去水印备用链路消耗积分" });
|
||||||
|
}
|
||||||
|
return NextResponse.json(fallbackResult);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error("[WatermarkRemove] FAL 备用链路失败:", fallbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "创建去水印任务失败" },
|
{ error: "去水印服务暂时不可用,请稍后重试" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -448,10 +540,7 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
startedAt: startTime,
|
startedAt: startTime,
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
sourceImageUrls:
|
sourceImageUrls,
|
||||||
typeof image_url === "string" && /^https?:\/\//i.test(image_url)
|
|
||||||
? [image_url]
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -141,7 +141,9 @@ import {
|
|||||||
createEmptyAgentGenerationProjectWorkspaceState,
|
createEmptyAgentGenerationProjectWorkspaceState,
|
||||||
createEmptyAgentWorkflowPersistedState,
|
createEmptyAgentWorkflowPersistedState,
|
||||||
normalizeAgentGenerationProjectPersistedState,
|
normalizeAgentGenerationProjectPersistedState,
|
||||||
|
normalizeAgentGenerationProjectReplacementResultState,
|
||||||
} from "@/lib/agents/generation-project-state";
|
} from "@/lib/agents/generation-project-state";
|
||||||
|
import { doesAgentProjectStateCoverReplacementResults } from "@/lib/agents/agent-project-save-merge";
|
||||||
import {
|
import {
|
||||||
createEmptyAgentModeWorkspaceState,
|
createEmptyAgentModeWorkspaceState,
|
||||||
normalizeAgentModeWorkspaceState,
|
normalizeAgentModeWorkspaceState,
|
||||||
@ -227,6 +229,7 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
|||||||
import type {
|
import type {
|
||||||
AgentGenerationProject,
|
AgentGenerationProject,
|
||||||
AgentGenerationProjectPersistedState,
|
AgentGenerationProjectPersistedState,
|
||||||
|
AgentGenerationProjectReplacementResultState,
|
||||||
AgentGenerationProjectResultCollections,
|
AgentGenerationProjectResultCollections,
|
||||||
AgentGenerationProjectWorkspaceAttachment,
|
AgentGenerationProjectWorkspaceAttachment,
|
||||||
AgentGenerationProjectWorkspaceState,
|
AgentGenerationProjectWorkspaceState,
|
||||||
@ -1540,6 +1543,41 @@ function normalizeResultCollectionUrls(urls: string[] | undefined | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areReplacementResultStatesEqual(
|
||||||
|
left: AgentGenerationProjectReplacementResultState | undefined | null,
|
||||||
|
right: AgentGenerationProjectReplacementResultState | undefined | null
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
|
||||||
|
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReplacementResultCollectionMismatch(
|
||||||
|
state: AgentGenerationProjectPersistedState | undefined | null
|
||||||
|
) {
|
||||||
|
const normalized = normalizeAgentGenerationProjectReplacementResultState(
|
||||||
|
state?.replacementResultState
|
||||||
|
);
|
||||||
|
const resultCollections = state?.resultCollections || {};
|
||||||
|
const countModeRecords = (
|
||||||
|
modeType: "detail_replacement" | "sku_replacement"
|
||||||
|
) =>
|
||||||
|
Object.values(normalized[modeType] || {}).filter((record) =>
|
||||||
|
Boolean(record.url)
|
||||||
|
).length;
|
||||||
|
const countModeCollection = (
|
||||||
|
modeType: "detail_replacement" | "sku_replacement"
|
||||||
|
) => normalizeResultCollectionUrls(resultCollections[modeType]).length;
|
||||||
|
|
||||||
|
return (["detail_replacement", "sku_replacement"] as const).some(
|
||||||
|
(modeType) => {
|
||||||
|
const recordCount = countModeRecords(modeType);
|
||||||
|
return recordCount > 0 && countModeCollection(modeType) !== recordCount;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:";
|
const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:";
|
||||||
|
|
||||||
function getAgentProjectDraftStorageKey(projectId: string) {
|
function getAgentProjectDraftStorageKey(projectId: string) {
|
||||||
@ -2009,6 +2047,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
useState(false);
|
useState(false);
|
||||||
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
||||||
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
|
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
|
||||||
|
const chatPanelCanvasIdRef = useRef<string | null>(null);
|
||||||
const [viewportWidth, setViewportWidth] = useState(0);
|
const [viewportWidth, setViewportWidth] = useState(0);
|
||||||
const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] =
|
const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] =
|
||||||
useState(0);
|
useState(0);
|
||||||
@ -2032,6 +2071,16 @@ export default function CanvasEditorLegacyPage({
|
|||||||
Partial<Record<AgentWorkflowStepKey, string>>
|
Partial<Record<AgentWorkflowStepKey, string>>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!id || isVideoMode || chatPanelCanvasIdRef.current === id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatPanelCanvasIdRef.current = id;
|
||||||
|
setPanelMode("chat");
|
||||||
|
setIsPanelOpen(true);
|
||||||
|
}, [id, isVideoMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideoMode) {
|
if (!isVideoMode) {
|
||||||
setVideoCanvasInsertMenu(null);
|
setVideoCanvasInsertMenu(null);
|
||||||
@ -2077,6 +2126,10 @@ export default function CanvasEditorLegacyPage({
|
|||||||
agentProjectDeletedResultCollections,
|
agentProjectDeletedResultCollections,
|
||||||
setAgentProjectDeletedResultCollections,
|
setAgentProjectDeletedResultCollections,
|
||||||
] = useState<AgentGenerationProjectResultCollections>({});
|
] = useState<AgentGenerationProjectResultCollections>({});
|
||||||
|
const [
|
||||||
|
agentProjectReplacementResultState,
|
||||||
|
setAgentProjectReplacementResultState,
|
||||||
|
] = useState<AgentGenerationProjectReplacementResultState>({});
|
||||||
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
|
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
|
||||||
const [loadedAgentProjectId, setLoadedAgentProjectId] = useState<
|
const [loadedAgentProjectId, setLoadedAgentProjectId] = useState<
|
||||||
string | null
|
string | null
|
||||||
@ -2167,6 +2220,9 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const [aspectRatio, setAspectRatio] = useState("auto");
|
const [aspectRatio, setAspectRatio] = useState("auto");
|
||||||
const [imageCount, setImageCount] = useState(1);
|
const [imageCount, setImageCount] = useState(1);
|
||||||
const previousNodeIdsRef = useRef<Set<string>>(new Set());
|
const previousNodeIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const lastLocalCanvasMutationAtRef = useRef(0);
|
||||||
|
const externalCanvasConflictRef = useRef(false);
|
||||||
|
const externalCanvasConflictNotifiedRef = useRef(false);
|
||||||
const previousBrandForModelRef = useRef("");
|
const previousBrandForModelRef = useRef("");
|
||||||
const autoSelectedBrandModelBrandRef = useRef("");
|
const autoSelectedBrandModelBrandRef = useRef("");
|
||||||
const autoTitleAttemptedRef = useRef<Set<string>>(new Set());
|
const autoTitleAttemptedRef = useRef<Set<string>>(new Set());
|
||||||
@ -3288,6 +3344,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const hydrateCanvasProject = useCallback(
|
const hydrateCanvasProject = useCallback(
|
||||||
(project: CanvasInitialProject) => {
|
(project: CanvasInitialProject) => {
|
||||||
|
externalCanvasConflictRef.current = false;
|
||||||
|
externalCanvasConflictNotifiedRef.current = false;
|
||||||
useCanvasStore.getState().setProject(id, project.title);
|
useCanvasStore.getState().setProject(id, project.title);
|
||||||
const projectUpdatedAt = String(project.updatedAt || "");
|
const projectUpdatedAt = String(project.updatedAt || "");
|
||||||
useCanvasStore
|
useCanvasStore
|
||||||
@ -3449,6 +3507,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
setAgentProjectThumbnail(null);
|
setAgentProjectThumbnail(null);
|
||||||
setAgentProjectResultCollections({});
|
setAgentProjectResultCollections({});
|
||||||
setAgentProjectDeletedResultCollections({});
|
setAgentProjectDeletedResultCollections({});
|
||||||
|
setAgentProjectReplacementResultState({});
|
||||||
setLoadedAgentProjectId(null);
|
setLoadedAgentProjectId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3940,8 +3999,15 @@ export default function CanvasEditorLegacyPage({
|
|||||||
...resultCollections.sku_replacement,
|
...resultCollections.sku_replacement,
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
|
replacementResultState: normalizeAgentGenerationProjectReplacementResultState(
|
||||||
|
agentProjectReplacementResultState
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}, [agentProjectDeletedResultCollections, agentProjectResultCollections]);
|
}, [
|
||||||
|
agentProjectDeletedResultCollections,
|
||||||
|
agentProjectReplacementResultState,
|
||||||
|
agentProjectResultCollections,
|
||||||
|
]);
|
||||||
|
|
||||||
const buildPersistedAgentProjectState = useCallback(() => {
|
const buildPersistedAgentProjectState = useCallback(() => {
|
||||||
const persistedMaterials = appearanceRecognitionEnabled
|
const persistedMaterials = appearanceRecognitionEnabled
|
||||||
@ -4126,6 +4192,9 @@ export default function CanvasEditorLegacyPage({
|
|||||||
setAgentProjectDeletedResultCollections(
|
setAgentProjectDeletedResultCollections(
|
||||||
persistedState.deletedResultCollections || {}
|
persistedState.deletedResultCollections || {}
|
||||||
);
|
);
|
||||||
|
setAgentProjectReplacementResultState(
|
||||||
|
persistedState.replacementResultState || {}
|
||||||
|
);
|
||||||
setLoadedAgentProjectId(params.projectId);
|
setLoadedAgentProjectId(params.projectId);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
@ -4300,7 +4369,9 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const result = await agentProjectService.updateProject(agentProjectId, {
|
const result = await agentProjectService.updateProject(agentProjectId, {
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
thumbnail: agentProjectThumbnail,
|
thumbnail: agentProjectThumbnail,
|
||||||
projectState: buildPersistedAgentProjectState(),
|
projectState:
|
||||||
|
persistedAgentProjectStateRef.current ||
|
||||||
|
buildPersistedAgentProjectState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@ -4366,9 +4437,19 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const serverUpdatedAt =
|
const serverUpdatedAt =
|
||||||
Number(new Date(result.data.updatedAt).getTime()) || 0;
|
Number(new Date(result.data.updatedAt).getTime()) || 0;
|
||||||
|
const localDraftMissesServerReplacementResults =
|
||||||
|
!doesAgentProjectStateCoverReplacementResults({
|
||||||
|
candidateState: localDraft?.projectState,
|
||||||
|
authoritativeState: result.data.projectState,
|
||||||
|
});
|
||||||
|
const localDraftHasStaleReplacementCollections =
|
||||||
|
!localDraftMissesServerReplacementResults &&
|
||||||
|
hasReplacementResultCollectionMismatch(localDraft?.projectState);
|
||||||
const shouldUseLocalDraft =
|
const shouldUseLocalDraft =
|
||||||
Boolean(localDraft?.projectState) &&
|
Boolean(localDraft?.projectState) &&
|
||||||
Number(localDraft?.savedAt || 0) > serverUpdatedAt;
|
Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
|
||||||
|
!localDraftMissesServerReplacementResults &&
|
||||||
|
!localDraftHasStaleReplacementCollections;
|
||||||
|
|
||||||
if (shouldUseLocalDraft && localDraft?.projectState) {
|
if (shouldUseLocalDraft && localDraft?.projectState) {
|
||||||
hydrateAgentProjectSnapshot({
|
hydrateAgentProjectSnapshot({
|
||||||
@ -4470,6 +4551,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}, [
|
}, [
|
||||||
agentModeState,
|
agentModeState,
|
||||||
agentProjectId,
|
agentProjectId,
|
||||||
|
agentProjectReplacementResultState,
|
||||||
agentProjectResultCollections,
|
agentProjectResultCollections,
|
||||||
agentProjectThumbnail,
|
agentProjectThumbnail,
|
||||||
agentProjectTitle,
|
agentProjectTitle,
|
||||||
@ -4760,36 +4842,70 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const handleAgentResultPreviewUrlsChange = useCallback(
|
const handleAgentResultPreviewUrlsChange = useCallback(
|
||||||
(modeType: AgentGenerationResultModeType, urls: string[]) => {
|
(modeType: AgentGenerationResultModeType, urls: string[]) => {
|
||||||
const normalizedUrls = normalizeResultCollectionUrls(urls);
|
const normalizedUrls = normalizeResultCollectionUrls(urls);
|
||||||
setAgentProjectResultCollections((current) => {
|
const currentProjectState =
|
||||||
const nextCollections: AgentGenerationProjectResultCollections = {
|
persistedAgentProjectStateRef.current ||
|
||||||
...current,
|
createEmptyAgentGenerationProjectPersistedState();
|
||||||
[modeType]: normalizedUrls,
|
const currentCollections = currentProjectState.resultCollections || {};
|
||||||
};
|
const nextCollections: AgentGenerationProjectResultCollections = {
|
||||||
|
...currentCollections,
|
||||||
if (areResultCollectionsEqual(current, nextCollections)) {
|
[modeType]: normalizedUrls,
|
||||||
return current;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!areResultCollectionsEqual(currentCollections, nextCollections)) {
|
||||||
const nextProjectState: AgentGenerationProjectPersistedState = {
|
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||||
...persistedAgentProjectStateRef.current,
|
...currentProjectState,
|
||||||
resultCollections: nextCollections,
|
resultCollections: nextCollections,
|
||||||
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
|
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
|
||||||
};
|
};
|
||||||
persistedAgentProjectStateRef.current = nextProjectState;
|
persistedAgentProjectStateRef.current = nextProjectState;
|
||||||
writeAgentProjectDraftSnapshot(nextProjectState);
|
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||||
|
}
|
||||||
|
|
||||||
setAgentProjectThumbnail((currentThumbnail) => {
|
setAgentProjectResultCollections((current) =>
|
||||||
const nextThumbnail =
|
areResultCollectionsEqual(current, nextCollections)
|
||||||
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
|
? current
|
||||||
return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
|
: nextCollections
|
||||||
});
|
);
|
||||||
|
setAgentProjectThumbnail((currentThumbnail) => {
|
||||||
return nextCollections;
|
const nextThumbnail =
|
||||||
|
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
|
||||||
|
return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[writeAgentProjectDraftSnapshot]
|
[writeAgentProjectDraftSnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleAgentReplacementResultStateChange = useCallback(
|
||||||
|
(nextState: AgentGenerationProjectReplacementResultState) => {
|
||||||
|
const normalizedState =
|
||||||
|
normalizeAgentGenerationProjectReplacementResultState(nextState);
|
||||||
|
|
||||||
|
const currentProjectState =
|
||||||
|
persistedAgentProjectStateRef.current ||
|
||||||
|
createEmptyAgentGenerationProjectPersistedState();
|
||||||
|
if (
|
||||||
|
!areReplacementResultStatesEqual(
|
||||||
|
currentProjectState.replacementResultState,
|
||||||
|
normalizedState
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||||
|
...currentProjectState,
|
||||||
|
replacementResultState: normalizedState,
|
||||||
|
};
|
||||||
|
persistedAgentProjectStateRef.current = nextProjectState;
|
||||||
|
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAgentProjectReplacementResultState((current) =>
|
||||||
|
areReplacementResultStatesEqual(current, normalizedState)
|
||||||
|
? current
|
||||||
|
: normalizedState
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[writeAgentProjectDraftSnapshot]
|
||||||
|
);
|
||||||
|
|
||||||
const handleAgentResultImageDelete = useCallback(
|
const handleAgentResultImageDelete = useCallback(
|
||||||
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
||||||
const normalizedUrl = String(imageUrl || "").trim();
|
const normalizedUrl = String(imageUrl || "").trim();
|
||||||
@ -4823,6 +4939,33 @@ export default function CanvasEditorLegacyPage({
|
|||||||
? current
|
? current
|
||||||
: nextDeletedCollections
|
: nextDeletedCollections
|
||||||
);
|
);
|
||||||
|
if (modeType === "detail_replacement" || modeType === "sku_replacement") {
|
||||||
|
setAgentProjectReplacementResultState((current) => {
|
||||||
|
const normalizedState =
|
||||||
|
normalizeAgentGenerationProjectReplacementResultState(current);
|
||||||
|
const currentModeState = normalizedState[modeType] || {};
|
||||||
|
const nextModeState = Object.fromEntries(
|
||||||
|
Object.entries(currentModeState).filter(
|
||||||
|
([, record]) => record.url !== normalizedUrl
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const nextState: AgentGenerationProjectReplacementResultState = {
|
||||||
|
...normalizedState,
|
||||||
|
[modeType]: nextModeState,
|
||||||
|
};
|
||||||
|
if (areReplacementResultStatesEqual(normalizedState, nextState)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||||
|
...persistedAgentProjectStateRef.current,
|
||||||
|
replacementResultState: nextState,
|
||||||
|
};
|
||||||
|
persistedAgentProjectStateRef.current = nextProjectState;
|
||||||
|
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
}
|
||||||
setAgentProjectThumbnail((current) => {
|
setAgentProjectThumbnail((current) => {
|
||||||
const nextThumbnail =
|
const nextThumbnail =
|
||||||
current === normalizedUrl
|
current === normalizedUrl
|
||||||
@ -4831,7 +4974,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return current === nextThumbnail ? current : nextThumbnail;
|
return current === nextThumbnail ? current : nextThumbnail;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[agentProjectDeletedResultCollections, agentProjectResultCollections]
|
[
|
||||||
|
agentProjectDeletedResultCollections,
|
||||||
|
agentProjectResultCollections,
|
||||||
|
writeAgentProjectDraftSnapshot,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -4904,12 +5051,19 @@ export default function CanvasEditorLegacyPage({
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||||
if (result.status === 409) {
|
if (result.status === 409) {
|
||||||
|
externalCanvasConflictRef.current = true;
|
||||||
console.warn("[Canvas] stale save revision handled without user prompt", {
|
console.warn("[Canvas] stale save revision handled without user prompt", {
|
||||||
reason,
|
reason,
|
||||||
canvasUpdatedAt: result.canvasUpdatedAt,
|
canvasUpdatedAt: result.canvasUpdatedAt,
|
||||||
});
|
});
|
||||||
|
if (!externalCanvasConflictNotifiedRef.current) {
|
||||||
|
externalCanvasConflictNotifiedRef.current = true;
|
||||||
|
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (result.data?.updatedAt) {
|
} else if (result.data?.updatedAt) {
|
||||||
|
externalCanvasConflictRef.current = false;
|
||||||
|
externalCanvasConflictNotifiedRef.current = false;
|
||||||
useCanvasStore
|
useCanvasStore
|
||||||
.getState()
|
.getState()
|
||||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||||
@ -4936,6 +5090,70 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return () => clearTimeout(saveTimer);
|
return () => clearTimeout(saveTimer);
|
||||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || isAgentOperationRefreshActive()) return;
|
||||||
|
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||||
|
}, [isLoading, isAgentOperationRefreshActive, nodes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || isAgentMode) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const refreshIfExternallyUpdated = async () => {
|
||||||
|
if (
|
||||||
|
cancelled ||
|
||||||
|
document.visibilityState !== "visible" ||
|
||||||
|
isSaving ||
|
||||||
|
externalCanvasConflictRef.current ||
|
||||||
|
Date.now() - lastLocalCanvasMutationAtRef.current < 5000
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const revisionPayload = await revisionResponse.json().catch(() => ({}));
|
||||||
|
const remoteRevision = String(
|
||||||
|
revisionPayload?.data?.updatedAt || ""
|
||||||
|
).trim();
|
||||||
|
const localRevision = String(
|
||||||
|
useCanvasStore.getState().canvasRevision || ""
|
||||||
|
).trim();
|
||||||
|
if (
|
||||||
|
cancelled ||
|
||||||
|
!revisionResponse.ok ||
|
||||||
|
!remoteRevision ||
|
||||||
|
remoteRevision === localRevision
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await canvasService.getProject(id);
|
||||||
|
if (cancelled || !result.success || !result.data) return;
|
||||||
|
useCanvasStore.getState().markAgentOperationRefresh();
|
||||||
|
hydrateCanvasProject(result.data);
|
||||||
|
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||||
|
toast.success("已同步 OpenClaw 的画布操作");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Canvas] external operation refresh failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void refreshIfExternallyUpdated();
|
||||||
|
}, 4000);
|
||||||
|
const handleFocus = () => void refreshIfExternallyUpdated();
|
||||||
|
window.addEventListener("focus", handleFocus);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
window.removeEventListener("focus", handleFocus);
|
||||||
|
};
|
||||||
|
}, [hydrateCanvasProject, id, isAgentMode, isLoading, isSaving]);
|
||||||
|
|
||||||
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
|
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) {
|
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) {
|
||||||
@ -6734,6 +6952,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
deletedResultCollections:
|
deletedResultCollections:
|
||||||
existingPersistedState.deletedResultCollections || {},
|
existingPersistedState.deletedResultCollections || {},
|
||||||
resultImageUrls: existingPersistedState.resultImageUrls || [],
|
resultImageUrls: existingPersistedState.resultImageUrls || [],
|
||||||
|
replacementResultState: existingPersistedState.replacementResultState || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -10020,10 +10239,13 @@ export default function CanvasEditorLegacyPage({
|
|||||||
mode: null,
|
mode: null,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onPersistProject={() => persistAgentProject("manual")}
|
||||||
|
onReplacementResultStateChange={handleAgentReplacementResultStateChange}
|
||||||
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
|
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
|
||||||
onWorkspaceStateChange={setAgentWorkspaceState}
|
onWorkspaceStateChange={setAgentWorkspaceState}
|
||||||
projectId={agentProjectId}
|
projectId={agentProjectId}
|
||||||
key={agentProjectId}
|
key={agentProjectId}
|
||||||
|
replacementResultState={agentProjectReplacementResultState}
|
||||||
resultCollections={agentProjectResultCollections}
|
resultCollections={agentProjectResultCollections}
|
||||||
selectedAgentPresetId={
|
selectedAgentPresetId={
|
||||||
isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null
|
isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null
|
||||||
@ -10851,6 +11073,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
chatScope="canvas"
|
chatScope="canvas"
|
||||||
onClose={() => setIsPanelOpen(false)}
|
onClose={() => setIsPanelOpen(false)}
|
||||||
onOpenFileList={() => setPanelMode("fileList")}
|
onOpenFileList={() => setPanelMode("fileList")}
|
||||||
|
onRequireLogin={openLoginDialog}
|
||||||
selectedAgentPresetId={
|
selectedAgentPresetId={
|
||||||
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
|
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,10 @@ export function CanvasPageClientRouter({
|
|||||||
const id = Array.isArray(routeId) ? routeId[0] || initialId : typeof routeId === "string" ? routeId : initialId;
|
const id = Array.isArray(routeId) ? routeId[0] || initialId : typeof routeId === "string" ? routeId : initialId;
|
||||||
const mode = searchParams.get("mode") || initialMode;
|
const mode = searchParams.get("mode") || initialMode;
|
||||||
const requestedWorkspaceType =
|
const requestedWorkspaceType =
|
||||||
searchParams.get("workspaceType") || initialWorkspaceType;
|
searchParams.get("workspaceType") ||
|
||||||
|
initialWorkspaceType ||
|
||||||
|
initialProject?.workspaceType ||
|
||||||
|
"";
|
||||||
const requestedAgentMode = mode === "agent";
|
const requestedAgentMode = mode === "agent";
|
||||||
const isVideoMode = !requestedAgentMode && (mode === "video" || requestedWorkspaceType === "video");
|
const isVideoMode = !requestedAgentMode && (mode === "video" || requestedWorkspaceType === "video");
|
||||||
const legacyParams = useMemo(() => Promise.resolve({ id }), [id]);
|
const legacyParams = useMemo(() => Promise.resolve({ id }), [id]);
|
||||||
|
|||||||
@ -1970,15 +1970,18 @@ export default function CanvasModel3DPage() {
|
|||||||
worldImageFile as File,
|
worldImageFile as File,
|
||||||
"image"
|
"image"
|
||||||
);
|
);
|
||||||
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
const uploadData = uploadResult.data || null;
|
||||||
|
const mediaAssetId = uploadData?.mediaAssetId || null;
|
||||||
|
const sourceUrl = uploadData?.sourceUrl || null;
|
||||||
|
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
|
||||||
throw new Error(uploadResult.error || "上传世界参考图失败");
|
throw new Error(uploadResult.error || "上传世界参考图失败");
|
||||||
}
|
}
|
||||||
await submitTask({
|
await submitTask({
|
||||||
provider: "worldlabs-3d",
|
provider: "worldlabs-3d",
|
||||||
payload: {
|
payload: {
|
||||||
mode: "image",
|
mode: "image",
|
||||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||||
providerId: uploadResult.data.providerId,
|
providerId: uploadData.providerId,
|
||||||
prompt: normalizedPrompt || undefined,
|
prompt: normalizedPrompt || undefined,
|
||||||
displayName: worldDisplayName.trim() || undefined,
|
displayName: worldDisplayName.trim() || undefined,
|
||||||
model: worldModel,
|
model: worldModel,
|
||||||
@ -2000,15 +2003,18 @@ export default function CanvasModel3DPage() {
|
|||||||
worldVideoFile as File,
|
worldVideoFile as File,
|
||||||
"video"
|
"video"
|
||||||
);
|
);
|
||||||
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
const uploadData = uploadResult.data || null;
|
||||||
|
const mediaAssetId = uploadData?.mediaAssetId || null;
|
||||||
|
const sourceUrl = uploadData?.sourceUrl || null;
|
||||||
|
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
|
||||||
throw new Error(uploadResult.error || "上传世界视频失败");
|
throw new Error(uploadResult.error || "上传世界视频失败");
|
||||||
}
|
}
|
||||||
await submitTask({
|
await submitTask({
|
||||||
provider: "worldlabs-3d",
|
provider: "worldlabs-3d",
|
||||||
payload: {
|
payload: {
|
||||||
mode: "video",
|
mode: "video",
|
||||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||||
providerId: uploadResult.data.providerId,
|
providerId: uploadData.providerId,
|
||||||
prompt: normalizedPrompt || undefined,
|
prompt: normalizedPrompt || undefined,
|
||||||
displayName: worldDisplayName.trim() || undefined,
|
displayName: worldDisplayName.trim() || undefined,
|
||||||
model: worldModel,
|
model: worldModel,
|
||||||
@ -2043,11 +2049,13 @@ export default function CanvasModel3DPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaAssets = uploads.map(({ slot, result }) => {
|
const mediaAssets = uploads.map(({ slot, result }) => {
|
||||||
if (!result.success || !result.data?.mediaAssetId) {
|
const mediaAssetId = result.data?.mediaAssetId || null;
|
||||||
|
const sourceUrl = result.data?.sourceUrl || null;
|
||||||
|
if (!result.success || (!mediaAssetId && !sourceUrl)) {
|
||||||
throw new Error(result.error || "上传多图世界图片失败");
|
throw new Error(result.error || "上传多图世界图片失败");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mediaAssetId: result.data.mediaAssetId,
|
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||||
azimuth: slot.azimuth,
|
azimuth: slot.azimuth,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, type RefObject } from "react";
|
import { useEffect, type RefObject } from "react";
|
||||||
|
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||||
|
|
||||||
export type AgentStyleSection =
|
export type AgentStyleSection =
|
||||||
| "brand"
|
| "brand"
|
||||||
@ -140,6 +141,9 @@ export function useAgentStylePerformanceMonitor({
|
|||||||
chatId,
|
chatId,
|
||||||
projectId,
|
projectId,
|
||||||
}: UseAgentStylePerformanceMonitorParams) {
|
}: UseAgentStylePerformanceMonitorParams) {
|
||||||
|
const persistedChatId =
|
||||||
|
chatId && !isAgentModeScopedChatId(chatId) ? chatId : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) ||
|
!getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) ||
|
||||||
@ -200,7 +204,7 @@ export function useAgentStylePerformanceMonitor({
|
|||||||
browser,
|
browser,
|
||||||
path: window.location.pathname,
|
path: window.location.pathname,
|
||||||
projectId: projectId || null,
|
projectId: projectId || null,
|
||||||
chatId: chatId || null,
|
chatId: persistedChatId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -237,5 +241,5 @@ export function useAgentStylePerformanceMonitor({
|
|||||||
root.removeEventListener("keydown", markAction, true);
|
root.removeEventListener("keydown", markAction, true);
|
||||||
root.removeEventListener("scroll", markAction, true);
|
root.removeEventListener("scroll", markAction, true);
|
||||||
};
|
};
|
||||||
}, [chatId, projectId, rootRef]);
|
}, [persistedChatId, projectId, rootRef]);
|
||||||
}
|
}
|
||||||
|
|||||||
47
components/ai-cad/zoo-design-studio-frame.tsx
Normal file
47
components/ai-cad/zoo-design-studio-frame.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
type ZooDesignStudioFrameProps = {
|
||||||
|
isReady: boolean;
|
||||||
|
src: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ZooDesignStudioFrame({ isReady, src }: ZooDesignStudioFrameProps) {
|
||||||
|
if (!isReady) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-svh w-full items-center justify-center overflow-hidden bg-[#10100e] p-6 text-[#f8f6ef]">
|
||||||
|
<div className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="font-semibold text-lg">
|
||||||
|
HiCAD 官方前端尚未构建
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
|
||||||
|
请运行
|
||||||
|
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
|
||||||
|
pnpm zoo-modeling-app:vendor
|
||||||
|
</code>
|
||||||
|
生成官方 Zoo Design Studio 前端。生成后刷新本页即可使用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-svh w-full overflow-hidden bg-[#10100e]">
|
||||||
|
<iframe
|
||||||
|
allow="clipboard-read; clipboard-write; fullscreen; gamepad; pointer-lock; web-share; xr-spatial-tracking"
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||||
|
src={src}
|
||||||
|
title="HiCAD"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -610,7 +610,9 @@ export function AiWashWorkspace({
|
|||||||
[presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId]
|
[presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId]
|
||||||
);
|
);
|
||||||
const selectedPresetHasExecutableModel = Boolean(
|
const selectedPresetHasExecutableModel = Boolean(
|
||||||
selectedExecutionState?.effectiveModelRef
|
selectedExecutionState?.effectiveModelRef &&
|
||||||
|
selectedExecutionState?.resolvedModel &&
|
||||||
|
selectedExecutionState.resolvedModel.selectable !== false
|
||||||
);
|
);
|
||||||
const shouldShowUserModelSelector = Boolean(
|
const shouldShowUserModelSelector = Boolean(
|
||||||
!config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel
|
!config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel
|
||||||
@ -757,7 +759,10 @@ export function AiWashWorkspace({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedPresetHasExecutableModel) {
|
if (!selectedPresetHasExecutableModel) {
|
||||||
toast.error("当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型");
|
toast.error(
|
||||||
|
selectedExecutionState?.resolvedModel?.disabledReason ||
|
||||||
|
"当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -818,6 +823,15 @@ export function AiWashWorkspace({
|
|||||||
if (!executionState.effectiveModelRef) {
|
if (!executionState.effectiveModelRef) {
|
||||||
throw new Error(`${preset.title} 未配置可用模型`);
|
throw new Error(`${preset.title} 未配置可用模型`);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!executionState.resolvedModel ||
|
||||||
|
executionState.resolvedModel.selectable === false
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
executionState.resolvedModel?.disabledReason ||
|
||||||
|
`${preset.title} 配置的模型不可用`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const submitResponse = await fetch("/api/ai-wash/generate", {
|
const submitResponse = await fetch("/api/ai-wash/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -825,7 +839,9 @@ export function AiWashWorkspace({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt: executionState.resolvedPrompt,
|
prompt: executionState.resolvedPrompt,
|
||||||
referenceImage: requestReferenceImage,
|
referenceImage: requestReferenceImage,
|
||||||
selectedModelId: executionState.effectiveModelRef,
|
selectedModelId:
|
||||||
|
executionState.resolvedModel.id ||
|
||||||
|
executionState.effectiveModelRef,
|
||||||
imageSize: config.imageSize,
|
imageSize: config.imageSize,
|
||||||
promptExtend: config.promptExtend,
|
promptExtend: config.promptExtend,
|
||||||
title: preset.title,
|
title: preset.title,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -22,11 +22,13 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
Download,
|
Download,
|
||||||
|
FileImage,
|
||||||
Flag,
|
Flag,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Heart,
|
Heart,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
|
LayoutTemplate,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Minus,
|
Minus,
|
||||||
@ -553,8 +555,19 @@ export function CanvasWorkspace({
|
|||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageLocalEdit"
|
"imageLocalEdit"
|
||||||
);
|
);
|
||||||
|
const selectedImageLayoutReplaceItem = getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"imageLayoutReplace"
|
||||||
|
);
|
||||||
|
const selectedImagePsdTemplateReplaceItem = getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"imagePsdTemplateReplace"
|
||||||
|
);
|
||||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||||
const selectedImageLocalEditEnabled = true;
|
const selectedImageLocalEditEnabled = true;
|
||||||
|
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
|
||||||
|
const selectedImageLayoutReplaceEnabled = true;
|
||||||
|
const selectedImagePsdTemplateReplaceEnabled = true;
|
||||||
|
|
||||||
// Store actions
|
// Store actions
|
||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
@ -2367,6 +2380,53 @@ export function CanvasWorkspace({
|
|||||||
{selectedImageLocalEditItem.label}
|
{selectedImageLocalEditItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{selectedImagePsdTemplateReplaceEnabled && selectedImageNodes.length === 1 ? (
|
||||||
|
<Button
|
||||||
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 gap-1.5 border border-border/60 px-3 text-xs shadow-sm",
|
||||||
|
isWorkflowCanvas &&
|
||||||
|
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
requestImageNodeAction(selectedImageNodes[0].id, "psd-template")
|
||||||
|
}
|
||||||
|
title={selectedImagePsdTemplateReplaceItem.label}
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fallback={FileImage}
|
||||||
|
item={selectedImagePsdTemplateReplaceItem}
|
||||||
|
/>
|
||||||
|
{selectedImagePsdTemplateReplaceItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{selectedImageLayoutReplaceEnabled && selectedImageNodes.length === 1 ? (
|
||||||
|
<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={() =>
|
||||||
|
requestImageNodeAction(
|
||||||
|
selectedImageNodes[0].id,
|
||||||
|
"layout-replace"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={selectedImageLayoutReplaceItem.label}
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fallback={LayoutTemplate}
|
||||||
|
item={selectedImageLayoutReplaceItem}
|
||||||
|
/>
|
||||||
|
{selectedImageLayoutReplaceItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
947
components/canvas/VideoCanvasStoryboardCompositionEditor.tsx
Normal file
947
components/canvas/VideoCanvasStoryboardCompositionEditor.tsx
Normal file
@ -0,0 +1,947 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Gauge,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
PanelsTopLeft,
|
||||||
|
Maximize2,
|
||||||
|
Minus,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Redo2,
|
||||||
|
Scissors,
|
||||||
|
TextCursorInput,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
Video,
|
||||||
|
X,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
type DragEvent,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
createVideoCanvasCompositionClip,
|
||||||
|
getVideoCanvasCompositionClipDuration,
|
||||||
|
getVideoCanvasCompositionClipStart,
|
||||||
|
getVideoCanvasCompositionDuration,
|
||||||
|
splitVideoCanvasCompositionClip,
|
||||||
|
type VideoCanvasStoryboardCompositionProject,
|
||||||
|
type VideoCanvasStoryboardCompositionTransition,
|
||||||
|
} from "@/lib/video-canvas/storyboard-composition";
|
||||||
|
import type {
|
||||||
|
VideoCanvasStoryboardCompositionItem,
|
||||||
|
VideoCanvasStoryboardItem,
|
||||||
|
} from "@/lib/video-canvas/storyboard-overview";
|
||||||
|
|
||||||
|
type CompositionEditorProps = {
|
||||||
|
composition: VideoCanvasStoryboardCompositionItem;
|
||||||
|
compositionItems: VideoCanvasStoryboardCompositionItem[];
|
||||||
|
exporting: boolean;
|
||||||
|
onChange: (project: VideoCanvasStoryboardCompositionProject) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onExport: (destination: "canvas" | "local") => void;
|
||||||
|
onSwitchComposition: (compositionId: string) => void;
|
||||||
|
project: VideoCanvasStoryboardCompositionProject;
|
||||||
|
videoItems: VideoCanvasStoryboardItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSITION_OPTIONS: Array<{
|
||||||
|
label: string;
|
||||||
|
value: VideoCanvasStoryboardCompositionTransition;
|
||||||
|
}> = [
|
||||||
|
{ label: "淡入淡出", value: "fade" },
|
||||||
|
{ label: "黑场过渡", value: "fade-black" },
|
||||||
|
{ label: "白场过渡", value: "fade-white" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [0.5, 1, 1.5, 2];
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const safeSeconds = Math.max(0, Math.floor(seconds));
|
||||||
|
const minutes = Math.floor(safeSeconds / 60);
|
||||||
|
const remainder = safeSeconds % 60;
|
||||||
|
return `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VideoCanvasStoryboardCompositionEditor({
|
||||||
|
composition,
|
||||||
|
compositionItems,
|
||||||
|
exporting,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
onExport,
|
||||||
|
onSwitchComposition,
|
||||||
|
project,
|
||||||
|
videoItems,
|
||||||
|
}: CompositionEditorProps) {
|
||||||
|
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const compositionMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
|
||||||
|
const [selectedSubtitleId, setSelectedSubtitleId] = useState<string | null>(null);
|
||||||
|
const [playheadSeconds, setPlayheadSeconds] = useState(0);
|
||||||
|
const [undoStack, setUndoStack] = useState<VideoCanvasStoryboardCompositionProject[]>([]);
|
||||||
|
const [redoStack, setRedoStack] = useState<VideoCanvasStoryboardCompositionProject[]>([]);
|
||||||
|
const [subtitlePanelOpen, setSubtitlePanelOpen] = useState(false);
|
||||||
|
const [subtitleTab, setSubtitleTab] = useState<"subtitle" | "text">("subtitle");
|
||||||
|
const [transitionPanelOpen, setTransitionPanelOpen] = useState(false);
|
||||||
|
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
|
||||||
|
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||||
|
const [compositionMenuOpen, setCompositionMenuOpen] = useState(false);
|
||||||
|
const [previewCollapsed, setPreviewCollapsed] = useState(false);
|
||||||
|
const [previewFullscreen, setPreviewFullscreen] = useState(false);
|
||||||
|
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||||
|
const [subtitleTrackVisible, setSubtitleTrackVisible] = useState(true);
|
||||||
|
const [timelineZoom, setTimelineZoom] = useState(20);
|
||||||
|
const totalDuration = getVideoCanvasCompositionDuration(project);
|
||||||
|
const timelineDuration = Math.max(totalDuration, 10);
|
||||||
|
const selectedClip = project.clips.find((clip) => clip.id === selectedClipId) || null;
|
||||||
|
const selectedSubtitle =
|
||||||
|
project.subtitles.find((subtitle) => subtitle.id === selectedSubtitleId) || null;
|
||||||
|
const previewClip = selectedClip || project.clips[0] || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedClipId(null);
|
||||||
|
setSelectedSubtitleId(null);
|
||||||
|
setPlayheadSeconds(0);
|
||||||
|
setUndoStack([]);
|
||||||
|
setRedoStack([]);
|
||||||
|
setSubtitlePanelOpen(false);
|
||||||
|
setTransitionPanelOpen(false);
|
||||||
|
setSpeedMenuOpen(false);
|
||||||
|
setExportMenuOpen(false);
|
||||||
|
setCompositionMenuOpen(false);
|
||||||
|
setPreviewCollapsed(false);
|
||||||
|
}, [composition.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedClipId && !selectedClip) setSelectedClipId(null);
|
||||||
|
}, [selectedClip, selectedClipId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSubtitleId && !selectedSubtitle) setSelectedSubtitleId(null);
|
||||||
|
}, [selectedSubtitle, selectedSubtitleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPlayheadSeconds((current) => Math.min(current, totalDuration));
|
||||||
|
}, [totalDuration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
if (previewFullscreen) {
|
||||||
|
setPreviewFullscreen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (exportMenuOpen || speedMenuOpen) {
|
||||||
|
setExportMenuOpen(false);
|
||||||
|
setSpeedMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
|
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||||
|
}, [exportMenuOpen, previewFullscreen, speedMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!compositionMenuOpen) return;
|
||||||
|
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||||
|
if (
|
||||||
|
event.target instanceof Node &&
|
||||||
|
!compositionMenuRef.current?.contains(event.target)
|
||||||
|
) {
|
||||||
|
setCompositionMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||||
|
}, [compositionMenuOpen]);
|
||||||
|
|
||||||
|
const applyProject = useCallback(
|
||||||
|
(nextProject: VideoCanvasStoryboardCompositionProject) => {
|
||||||
|
if (nextProject === project) return;
|
||||||
|
setUndoStack((current) => [...current.slice(-49), structuredClone(project)]);
|
||||||
|
setRedoStack([]);
|
||||||
|
onChange(nextProject);
|
||||||
|
},
|
||||||
|
[onChange, project],
|
||||||
|
);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
const previous = undoStack.at(-1);
|
||||||
|
if (!previous) return;
|
||||||
|
setUndoStack((current) => current.slice(0, -1));
|
||||||
|
setRedoStack((current) => [...current.slice(-49), structuredClone(project)]);
|
||||||
|
onChange(previous);
|
||||||
|
}, [onChange, project, undoStack]);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
const next = redoStack.at(-1);
|
||||||
|
if (!next) return;
|
||||||
|
setRedoStack((current) => current.slice(0, -1));
|
||||||
|
setUndoStack((current) => [...current.slice(-49), structuredClone(project)]);
|
||||||
|
onChange(next);
|
||||||
|
}, [onChange, project, redoStack]);
|
||||||
|
|
||||||
|
const addVideoItem = useCallback(
|
||||||
|
(item: VideoCanvasStoryboardItem) => {
|
||||||
|
if (!item.mediaUrl) return;
|
||||||
|
const clip = createVideoCanvasCompositionClip({
|
||||||
|
durationSeconds: item.durationSeconds || 5,
|
||||||
|
existingIds: project.clips.map((candidate) => candidate.id),
|
||||||
|
mediaUrl: item.mediaUrl,
|
||||||
|
nodeId: item.id,
|
||||||
|
title: item.title,
|
||||||
|
});
|
||||||
|
applyProject({ ...project, clips: [...project.clips, clip] });
|
||||||
|
setSelectedClipId(clip.id);
|
||||||
|
setPlayheadSeconds(totalDuration);
|
||||||
|
},
|
||||||
|
[applyProject, project, totalDuration],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSelectedClip = useCallback(
|
||||||
|
(update: (clip: NonNullable<typeof selectedClip>) => NonNullable<typeof selectedClip>) => {
|
||||||
|
if (!selectedClip) return;
|
||||||
|
applyProject({
|
||||||
|
...project,
|
||||||
|
clips: project.clips.map((clip) =>
|
||||||
|
clip.id === selectedClip.id ? update(clip) : clip,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyProject, project, selectedClip],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeSelectedClip = useCallback(() => {
|
||||||
|
if (!selectedClip) return;
|
||||||
|
applyProject({
|
||||||
|
...project,
|
||||||
|
clips: project.clips.filter((clip) => clip.id !== selectedClip.id),
|
||||||
|
});
|
||||||
|
setSelectedClipId(null);
|
||||||
|
}, [applyProject, project, selectedClip]);
|
||||||
|
|
||||||
|
const trimSelectedClip = useCallback(
|
||||||
|
(side: "left" | "right") => {
|
||||||
|
if (!selectedClip) return;
|
||||||
|
const clipStart = getVideoCanvasCompositionClipStart(project, selectedClip.id);
|
||||||
|
if (clipStart === null) return;
|
||||||
|
const sourcePoint =
|
||||||
|
selectedClip.inPointSeconds +
|
||||||
|
Math.max(0, playheadSeconds - clipStart) * selectedClip.speed;
|
||||||
|
if (side === "left") {
|
||||||
|
if (sourcePoint >= selectedClip.outPointSeconds - 0.1) return;
|
||||||
|
updateSelectedClip((clip) => ({ ...clip, inPointSeconds: sourcePoint }));
|
||||||
|
} else {
|
||||||
|
if (sourcePoint <= selectedClip.inPointSeconds + 0.1) return;
|
||||||
|
updateSelectedClip((clip) => ({ ...clip, outPointSeconds: sourcePoint }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[playheadSeconds, project, selectedClip, updateSelectedClip],
|
||||||
|
);
|
||||||
|
|
||||||
|
const splitSelectedClip = useCallback(() => {
|
||||||
|
if (!selectedClip) return;
|
||||||
|
const next = splitVideoCanvasCompositionClip(
|
||||||
|
project,
|
||||||
|
selectedClip.id,
|
||||||
|
playheadSeconds,
|
||||||
|
);
|
||||||
|
if (next !== project) applyProject(next);
|
||||||
|
}, [applyProject, playheadSeconds, project, selectedClip]);
|
||||||
|
|
||||||
|
const addSubtitle = useCallback(() => {
|
||||||
|
const id = `subtitle-${Date.now()}`;
|
||||||
|
const startSeconds = Math.min(playheadSeconds, totalDuration);
|
||||||
|
const subtitle = {
|
||||||
|
endSeconds: Math.min(Math.max(totalDuration, 2), startSeconds + 2),
|
||||||
|
id,
|
||||||
|
startSeconds,
|
||||||
|
text: "新建字幕",
|
||||||
|
};
|
||||||
|
applyProject({ ...project, subtitles: [...project.subtitles, subtitle] });
|
||||||
|
setSelectedSubtitleId(id);
|
||||||
|
setSubtitleTab("text");
|
||||||
|
}, [applyProject, playheadSeconds, project, totalDuration]);
|
||||||
|
|
||||||
|
const seekTimeline = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
if (!rect.width) return;
|
||||||
|
let next = ((event.clientX - rect.left) / rect.width) * timelineDuration;
|
||||||
|
if (snapEnabled) next = Math.round(next * 2) / 2;
|
||||||
|
setPlayheadSeconds(Math.min(totalDuration, Math.max(0, next)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const nodeId = event.dataTransfer.getData("application/x-video-canvas-node");
|
||||||
|
const item = videoItems.find((candidate) => candidate.id === nodeId);
|
||||||
|
if (item) addVideoItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlayback = async () => {
|
||||||
|
const video = previewVideoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.paused) await video.play().catch(() => undefined);
|
||||||
|
else video.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPreview = (fullscreen = false) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-h-0 items-center justify-center overflow-hidden bg-[#1b1b1b]",
|
||||||
|
fullscreen && "fixed inset-0 z-[300] bg-black",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{previewClip ? (
|
||||||
|
<video
|
||||||
|
className={cn(
|
||||||
|
"max-h-full max-w-full object-contain",
|
||||||
|
fullscreen ? "h-full w-full" : "h-full",
|
||||||
|
)}
|
||||||
|
key={`${composition.id}-${previewClip.id}`}
|
||||||
|
onTimeUpdate={(event) => {
|
||||||
|
const clipStart = getVideoCanvasCompositionClipStart(project, previewClip.id) || 0;
|
||||||
|
const relative = Math.max(
|
||||||
|
0,
|
||||||
|
event.currentTarget.currentTime - previewClip.inPointSeconds,
|
||||||
|
);
|
||||||
|
setPlayheadSeconds(
|
||||||
|
Math.min(totalDuration, clipStart + relative / previewClip.speed),
|
||||||
|
);
|
||||||
|
if (event.currentTarget.currentTime >= previewClip.outPointSeconds) {
|
||||||
|
event.currentTarget.pause();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
ref={previewVideoRef}
|
||||||
|
src={previewClip.mediaUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Video className="h-7 w-7 text-white/10" />
|
||||||
|
)}
|
||||||
|
{fullscreen ? (
|
||||||
|
<button
|
||||||
|
aria-label="退出全屏预览"
|
||||||
|
className="absolute right-5 top-5 flex size-9 items-center justify-center rounded-[6px] bg-black/60 text-white/72 hover:bg-black/80 hover:text-white"
|
||||||
|
onClick={() => setPreviewFullscreen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-0 min-w-[760px] flex-1 grid-cols-[minmax(248px,0.5fr)_minmax(520px,1fr)] gap-3 pb-4">
|
||||||
|
<section className="relative flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#242424]">
|
||||||
|
<header className="flex h-[52px] shrink-0 items-center justify-between px-4">
|
||||||
|
<div className="flex items-center gap-2 text-[14px] font-medium text-white/78">
|
||||||
|
<span>视频</span>
|
||||||
|
<ChevronDown className="h-3 w-3 text-white/42" />
|
||||||
|
</div>
|
||||||
|
<button className="flex items-center gap-1 text-[12px] text-white/42" type="button">
|
||||||
|
全部
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-20">
|
||||||
|
{videoItems.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{videoItems.map((item) => (
|
||||||
|
<article
|
||||||
|
className="group relative border-b border-white/7 pb-3"
|
||||||
|
draggable={Boolean(item.mediaUrl)}
|
||||||
|
key={item.id}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
"application/x-video-canvas-node",
|
||||||
|
item.id,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[12px] text-white/48">{item.title}</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<div className="relative aspect-[2/3] w-[106px] overflow-hidden rounded-[7px] bg-[#191919]">
|
||||||
|
{item.mediaUrl ? (
|
||||||
|
<video
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
src={item.mediaUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Video className="h-5 w-5 text-white/15" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label={`添加 ${item.title} 到时间线`}
|
||||||
|
className="absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-full bg-black/65 text-white opacity-0 transition-opacity hover:bg-black/85 focus-visible:opacity-100 group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-25"
|
||||||
|
disabled={!item.mediaUrl}
|
||||||
|
onClick={() => addVideoItem(item)}
|
||||||
|
title="添加到时间线"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-1 text-[11px] text-white/32">
|
||||||
|
<div>{item.modelName || "视频素材"}</div>
|
||||||
|
{item.durationSeconds ? (
|
||||||
|
<div className="mt-2">{item.durationSeconds}秒</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-8 truncate">{item.subtitle || "参考素材"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-36 items-center justify-center text-[12px] text-white/28">
|
||||||
|
暂无视频素材
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-5 right-4" ref={compositionMenuRef}>
|
||||||
|
<button
|
||||||
|
aria-expanded={compositionMenuOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className="flex size-[50px] flex-col items-center justify-center gap-0.5 rounded-full bg-[#2b2b2b] text-[11px] text-white/72 shadow-[0_10px_30px_rgba(0,0,0,0.36)] hover:bg-[#343434] hover:text-white"
|
||||||
|
onClick={() => setCompositionMenuOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
<span>剪辑</span>
|
||||||
|
</button>
|
||||||
|
{compositionMenuOpen ? (
|
||||||
|
<div
|
||||||
|
aria-label="剪辑"
|
||||||
|
className="absolute bottom-[58px] right-0 w-[208px] overflow-hidden rounded-[8px] border border-white/8 bg-[#242424] py-1 shadow-[0_16px_40px_rgba(0,0,0,0.46)]"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
{compositionItems.map((item) => (
|
||||||
|
<button
|
||||||
|
className="flex h-9 w-full items-center gap-2 px-3 text-left text-[13px] text-white/76 hover:bg-white/7 hover:text-white"
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setCompositionMenuOpen(false);
|
||||||
|
onSwitchComposition(item.id);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Video className="h-4 w-4 text-white/42" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{item.title}</span>
|
||||||
|
{item.id === composition.id ? <Check className="h-4 w-4" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#242424]">
|
||||||
|
{!previewCollapsed ? (
|
||||||
|
<>
|
||||||
|
<header className="flex h-[48px] shrink-0 items-center justify-between px-3">
|
||||||
|
<span className="text-[14px] font-medium text-white/78">视频合成</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
aria-expanded={exportMenuOpen}
|
||||||
|
className="flex h-8 items-center gap-2 rounded-[8px] bg-white px-3 text-[13px] font-medium text-black hover:bg-white/88"
|
||||||
|
disabled={exporting}
|
||||||
|
onClick={() => setExportMenuOpen((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
导出
|
||||||
|
{!exporting ? <ChevronDown className="h-3.5 w-3.5" /> : null}
|
||||||
|
</button>
|
||||||
|
{exportMenuOpen ? (
|
||||||
|
<div
|
||||||
|
aria-label="导出"
|
||||||
|
className="absolute right-0 top-10 z-40 w-36 rounded-[8px] border border-white/8 bg-[#202020] p-1 shadow-[0_12px_30px_rgba(0,0,0,0.42)]"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 items-center px-3 text-[12px] text-white/34">
|
||||||
|
导出位置
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex h-9 w-full items-center gap-2 rounded-[6px] px-3 text-left text-[12px] text-white/68 hover:bg-white/7 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
disabled={!project.clips.length || exporting}
|
||||||
|
onClick={() => {
|
||||||
|
setExportMenuOpen(false);
|
||||||
|
onExport("local");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
导出到本地
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex h-9 w-full items-center gap-2 rounded-[6px] px-3 text-left text-[12px] text-white/68 hover:bg-white/7 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
disabled={!project.clips.length || exporting}
|
||||||
|
onClick={() => {
|
||||||
|
setExportMenuOpen(false);
|
||||||
|
onExport("canvas");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<PanelsTopLeft className="h-4 w-4" />
|
||||||
|
导出到画布
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="退出剪辑"
|
||||||
|
className="flex size-8 items-center justify-center rounded-[6px] text-white/50 hover:bg-white/8 hover:text-white"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid h-[230px] shrink-0 gap-2 px-2 pb-2",
|
||||||
|
transitionPanelOpen
|
||||||
|
? "grid-cols-[minmax(300px,1.2fr)_minmax(220px,0.9fr)_296px]"
|
||||||
|
: subtitlePanelOpen
|
||||||
|
? "grid-cols-[minmax(0,1fr)_296px]"
|
||||||
|
: "grid-cols-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transitionPanelOpen ? (
|
||||||
|
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
|
||||||
|
<div className="flex h-10 items-center justify-between border-b border-white/7 px-3 text-[13px] text-white/76">
|
||||||
|
<span>转场库</span>
|
||||||
|
<button
|
||||||
|
aria-label="关闭转场库"
|
||||||
|
className="text-white/42 hover:text-white"
|
||||||
|
onClick={() => setTransitionPanelOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="mb-3 text-[12px] text-white/34">基础转场</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{TRANSITION_OPTIONS.map((option, index) => (
|
||||||
|
<button
|
||||||
|
aria-label={`${option.label} ${option.label}`}
|
||||||
|
className={cn(
|
||||||
|
"text-left text-[11px] text-white/54 hover:text-white",
|
||||||
|
!selectedClip && "cursor-not-allowed opacity-35",
|
||||||
|
)}
|
||||||
|
disabled={!selectedClip}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() =>
|
||||||
|
updateSelectedClip((clip) => ({
|
||||||
|
...clip,
|
||||||
|
transition: option.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 block aspect-[1.45] rounded-[6px] border border-white/7",
|
||||||
|
index === 0 && "bg-[linear-gradient(90deg,#665d51,#202020)]",
|
||||||
|
index === 1 && "bg-[linear-gradient(90deg,#342d28,#050505)]",
|
||||||
|
index === 2 && "bg-[linear-gradient(90deg,#5c554d,#dedede)]",
|
||||||
|
selectedClip?.transition === option.value &&
|
||||||
|
"border-white/70 ring-1 ring-white/40",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="block truncate">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
{renderPreview()}
|
||||||
|
{transitionPanelOpen ? (
|
||||||
|
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
|
||||||
|
<div className="flex h-10 items-center justify-between border-b border-white/7 px-3 text-[13px] text-white/76">
|
||||||
|
<span>转场</span>
|
||||||
|
<button
|
||||||
|
aria-label="关闭转场"
|
||||||
|
className="text-white/42 hover:text-white"
|
||||||
|
onClick={() => setTransitionPanelOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 text-[12px]">
|
||||||
|
<div className="text-white/32">名称</div>
|
||||||
|
<div className="mt-1 text-white/68">
|
||||||
|
{TRANSITION_OPTIONS.find((item) => item.value === selectedClip?.transition)?.label || "未选择转场"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-white/32">时长</div>
|
||||||
|
<div className="mt-1 text-white/68">{selectedClip?.transition ? "0.5 秒" : "-"}</div>
|
||||||
|
<button
|
||||||
|
className="mt-5 flex h-8 w-full items-center justify-center gap-2 rounded-[6px] bg-white/6 text-white/56 hover:bg-white/9 disabled:opacity-30"
|
||||||
|
disabled={!selectedClip?.transition}
|
||||||
|
onClick={() => updateSelectedClip((clip) => ({ ...clip, transition: null }))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
) : subtitlePanelOpen ? (
|
||||||
|
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
|
||||||
|
<div className="flex h-10 items-center gap-4 border-b border-white/7 px-3 text-[13px]">
|
||||||
|
<button
|
||||||
|
className={subtitleTab === "subtitle" ? "text-white" : "text-white/40"}
|
||||||
|
onClick={() => setSubtitleTab("subtitle")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
字幕
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={subtitleTab === "text" ? "text-white" : "text-white/40"}
|
||||||
|
onClick={() => setSubtitleTab("text")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
文本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{subtitleTab === "subtitle" ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<input
|
||||||
|
aria-label="搜索字幕文本"
|
||||||
|
className="h-8 w-full rounded-[6px] bg-white/8 px-3 text-[12px] text-white outline-none placeholder:text-white/30"
|
||||||
|
placeholder="搜索字幕文本"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="mt-2 flex h-8 w-full items-center justify-center gap-1 rounded-[6px] bg-white/9 text-[12px] text-white/78 hover:bg-white/12"
|
||||||
|
onClick={addSubtitle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新建字幕
|
||||||
|
</button>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{project.subtitles.length ? project.subtitles.map((subtitle) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"w-full truncate rounded-[5px] px-2 py-1.5 text-left text-[12px] text-white/58 hover:bg-white/6",
|
||||||
|
subtitle.id === selectedSubtitleId && "bg-white/8 text-white/86",
|
||||||
|
)}
|
||||||
|
key={subtitle.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubtitleId(subtitle.id);
|
||||||
|
setSubtitleTab("text");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{subtitle.text}
|
||||||
|
</button>
|
||||||
|
)) : (
|
||||||
|
<div className="py-3 text-[12px] text-white/28">暂无字幕</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedSubtitle ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<textarea
|
||||||
|
aria-label="字幕文本"
|
||||||
|
className="h-24 w-full resize-none rounded-[6px] bg-white/7 p-3 text-[12px] leading-5 text-white outline-none"
|
||||||
|
onChange={(event) =>
|
||||||
|
applyProject({
|
||||||
|
...project,
|
||||||
|
subtitles: project.subtitles.map((subtitle) =>
|
||||||
|
subtitle.id === selectedSubtitle.id
|
||||||
|
? { ...subtitle, text: event.target.value }
|
||||||
|
: subtitle,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
value={selectedSubtitle.text}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 text-[12px] text-white/34">请先选择一条字幕</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex h-12 shrink-0 items-center gap-1 border-y border-white/7 px-3">
|
||||||
|
<EditorIconButton disabled={!undoStack.length} label="撤销" onClick={undo}>
|
||||||
|
<Undo2 className="h-4 w-4" />
|
||||||
|
</EditorIconButton>
|
||||||
|
<EditorIconButton disabled={!redoStack.length} label="重做" onClick={redo}>
|
||||||
|
<Redo2 className="h-4 w-4" />
|
||||||
|
</EditorIconButton>
|
||||||
|
<EditorIconButton disabled={!selectedClip} label="分割" onClick={splitSelectedClip}>
|
||||||
|
<Scissors className="h-4 w-4" />
|
||||||
|
</EditorIconButton>
|
||||||
|
<EditorIconButton disabled={!selectedClip} label="向左裁剪" onClick={() => trimSelectedClip("left")}>
|
||||||
|
<span className="text-[16px]">⌈</span>
|
||||||
|
</EditorIconButton>
|
||||||
|
<EditorIconButton disabled={!selectedClip} label="向右裁剪" onClick={() => trimSelectedClip("right")}>
|
||||||
|
<span className="text-[16px]">⌉</span>
|
||||||
|
</EditorIconButton>
|
||||||
|
<EditorIconButton
|
||||||
|
active={subtitlePanelOpen}
|
||||||
|
label="字幕轨道"
|
||||||
|
onClick={() => {
|
||||||
|
setSubtitlePanelOpen((current) => !current);
|
||||||
|
setTransitionPanelOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextCursorInput className="h-4 w-4" />
|
||||||
|
</EditorIconButton>
|
||||||
|
<EditorIconButton
|
||||||
|
active={transitionPanelOpen}
|
||||||
|
label="转场"
|
||||||
|
onClick={() => {
|
||||||
|
setTransitionPanelOpen((current) => !current);
|
||||||
|
setSubtitlePanelOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
</EditorIconButton>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3 text-[12px] tabular-nums text-white/58">
|
||||||
|
<span>{formatTime(playheadSeconds)}</span>
|
||||||
|
<button
|
||||||
|
aria-label={previewVideoRef.current?.paused === false ? "暂停" : "播放"}
|
||||||
|
className="flex size-8 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/16"
|
||||||
|
onClick={() => void togglePlayback()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<span>{formatTime(totalDuration)}</span>
|
||||||
|
<EditorIconButton label="全屏预览" onClick={() => setPreviewFullscreen(true)}>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</EditorIconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative ml-auto flex items-center gap-1">
|
||||||
|
<EditorIconButton
|
||||||
|
disabled={!selectedClip}
|
||||||
|
label="变速"
|
||||||
|
onClick={() => setSpeedMenuOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
<Gauge className="h-4 w-4" />
|
||||||
|
</EditorIconButton>
|
||||||
|
{speedMenuOpen ? (
|
||||||
|
<div
|
||||||
|
aria-label="变速"
|
||||||
|
className="absolute bottom-10 right-0 z-40 w-28 rounded-[8px] border border-white/8 bg-[#202020] p-1 shadow-xl"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
{SPEED_OPTIONS.map((speed) => (
|
||||||
|
<button
|
||||||
|
className="flex h-8 w-full items-center justify-between rounded-[5px] px-2 text-[12px] text-white/68 hover:bg-white/7"
|
||||||
|
key={speed}
|
||||||
|
onClick={() => {
|
||||||
|
updateSelectedClip((clip) => ({ ...clip, speed }));
|
||||||
|
setSpeedMenuOpen(false);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{speed}x
|
||||||
|
{selectedClip?.speed === speed ? <Check className="h-3.5 w-3.5" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<EditorIconButton active={snapEnabled} label="吸附" onClick={() => setSnapEnabled((current) => !current)}>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
</EditorIconButton>
|
||||||
|
<ZoomOut className="h-4 w-4 text-white/48" />
|
||||||
|
<input
|
||||||
|
aria-label="时间线缩放"
|
||||||
|
className="h-1 w-20 accent-white"
|
||||||
|
max={100}
|
||||||
|
min={0}
|
||||||
|
onChange={(event) => setTimelineZoom(Number(event.target.value))}
|
||||||
|
type="range"
|
||||||
|
value={timelineZoom}
|
||||||
|
/>
|
||||||
|
<ZoomIn className="h-4 w-4 text-white/48" />
|
||||||
|
<EditorIconButton
|
||||||
|
label={previewCollapsed ? "展开预览(占满时间轴上方)" : "收起预览"}
|
||||||
|
onClick={() => setPreviewCollapsed((current) => !current)}
|
||||||
|
>
|
||||||
|
{previewCollapsed ? <Plus className="h-4 w-4" /> : <Minus className="h-4 w-4" />}
|
||||||
|
</EditorIconButton>
|
||||||
|
{previewCollapsed ? (
|
||||||
|
<EditorIconButton label="退出剪辑" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</EditorIconButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative min-h-[180px] flex-1 overflow-auto bg-[#1c1c1c]"
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-label="时间线"
|
||||||
|
className="relative min-h-full"
|
||||||
|
onPointerDown={seekTimeline}
|
||||||
|
style={{ minWidth: `${Math.max(100, timelineZoom + 80)}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute left-[88px] right-4 top-8 h-px bg-white/7" />
|
||||||
|
<div className="absolute left-0 top-[50px] flex h-14 w-[72px] items-center justify-center text-[20px] text-white/50">
|
||||||
|
<Video className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-[88px] right-4 top-[50px] flex h-14 gap-px bg-white/[0.025]">
|
||||||
|
{project.clips.map((clip) => (
|
||||||
|
<button
|
||||||
|
aria-label={`${clip.title} 时间线片段`}
|
||||||
|
className={cn(
|
||||||
|
"relative min-w-[52px] overflow-hidden border border-white/10 bg-[#303030] text-left",
|
||||||
|
clip.id === selectedClipId && "border-[#7aa7ff] ring-1 ring-[#7aa7ff]",
|
||||||
|
)}
|
||||||
|
key={clip.id}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedClipId(clip.id);
|
||||||
|
setPlayheadSeconds(getVideoCanvasCompositionClipStart(project, clip.id) || 0);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(4, (getVideoCanvasCompositionClipDuration(clip) / timelineDuration) * 100)}%`,
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
className="absolute inset-0 h-full w-full object-cover opacity-55"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
src={clip.mediaUrl}
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-x-1 bottom-1 truncate text-[10px] text-white/86">
|
||||||
|
{clip.title}
|
||||||
|
</span>
|
||||||
|
{clip.transition ? (
|
||||||
|
<span className="absolute left-0 top-0 h-full w-3 bg-white/25" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subtitlePanelOpen ? (
|
||||||
|
<>
|
||||||
|
<div className="absolute left-0 top-[118px] flex h-10 w-[72px] items-center justify-between px-3 text-white/50">
|
||||||
|
<TextCursorInput className="h-5 w-5" />
|
||||||
|
<button
|
||||||
|
aria-label={subtitleTrackVisible ? "关闭字幕" : "显示字幕"}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSubtitleTrackVisible((current) => !current);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{subtitleTrackVisible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-[88px] right-4 top-[118px] h-10 bg-white/[0.025]">
|
||||||
|
{subtitleTrackVisible ? project.subtitles.map((subtitle) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1 h-8 truncate border border-white/10 bg-[#505050] px-2 text-left text-[10px] text-white/80",
|
||||||
|
subtitle.id === selectedSubtitleId && "border-[#7aa7ff]",
|
||||||
|
)}
|
||||||
|
key={subtitle.id}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setSelectedSubtitleId(subtitle.id);
|
||||||
|
setSubtitleTab("text");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
left: `${(subtitle.startSeconds / timelineDuration) * 100}%`,
|
||||||
|
width: `${Math.max(3, ((subtitle.endSeconds - subtitle.startSeconds) / timelineDuration) * 100)}%`,
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{subtitle.text}
|
||||||
|
</button>
|
||||||
|
)) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute bottom-0 top-0 z-20 w-px bg-white/90"
|
||||||
|
style={{
|
||||||
|
left: `calc(88px + (100% - 104px) * ${timelineDuration ? playheadSeconds / timelineDuration : 0})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{previewFullscreen ? renderPreview(true) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorIconButton({
|
||||||
|
active = false,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-[6px] text-white/46 hover:bg-white/7 hover:text-white disabled:cursor-not-allowed disabled:opacity-20",
|
||||||
|
active && "bg-white/9 text-white/88",
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
title={label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -155,8 +155,10 @@ export type ImageGenerationStatusStreamPayload = {
|
|||||||
modelName?: string;
|
modelName?: string;
|
||||||
imageCount?: number;
|
imageCount?: number;
|
||||||
queuePosition?: number;
|
queuePosition?: number;
|
||||||
|
sameModelPendingCount?: number;
|
||||||
sameModelRunningCount?: number;
|
sameModelRunningCount?: number;
|
||||||
concurrencyLimit?: number;
|
concurrencyLimit?: number;
|
||||||
|
dispatchIntervalMs?: number;
|
||||||
estimatedWaitMs?: number;
|
estimatedWaitMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -250,36 +250,270 @@ export function extractAssistantImageUrls(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractVideoUrls(text: string): string[] {
|
function isMarkdownTableRow(line: string): boolean {
|
||||||
|
const trimmed = String(line || "").trim();
|
||||||
|
if (!trimmed || !trimmed.includes("|")) return false;
|
||||||
|
|
||||||
|
const cells = trimmed
|
||||||
|
.replace(/^\|/, "")
|
||||||
|
.replace(/\|$/, "")
|
||||||
|
.split("|");
|
||||||
|
return cells.length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkdownTableSeparator(line: string): boolean {
|
||||||
|
if (!isMarkdownTableRow(line)) return false;
|
||||||
|
|
||||||
|
const cells = String(line || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^\|/, "")
|
||||||
|
.replace(/\|$/, "")
|
||||||
|
.split("|")
|
||||||
|
.map((cell) => cell.trim().replace(/\s+/g, ""));
|
||||||
|
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMarkdownTableImageUrlKeys(text: string): Set<string> {
|
||||||
|
const urlKeys = new Set<string>();
|
||||||
|
const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
|
||||||
|
let inCodeFence = false;
|
||||||
|
|
||||||
|
const addUrl = (url: string) => {
|
||||||
|
const key = normalizeImageIdentityUrl(url);
|
||||||
|
if (key) urlKeys.add(key);
|
||||||
|
};
|
||||||
|
const addUrlsFromTableText = (tableText: string) => {
|
||||||
|
extractAssistantImageUrls(tableText).forEach(addUrl);
|
||||||
|
extractPlainImageUrls(tableText).forEach(addUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < lines.length - 1; index += 1) {
|
||||||
|
const line = lines[index] || "";
|
||||||
|
if (/^\s*```/.test(line)) {
|
||||||
|
inCodeFence = !inCodeFence;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
inCodeFence ||
|
||||||
|
!isMarkdownTableRow(line) ||
|
||||||
|
!isMarkdownTableSeparator(lines[index + 1] || "")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let endIndex = index + 2;
|
||||||
|
while (
|
||||||
|
endIndex < lines.length &&
|
||||||
|
isMarkdownTableRow(lines[endIndex] || "")
|
||||||
|
) {
|
||||||
|
endIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
addUrlsFromTableText(lines.slice(index, endIndex).join("\n"));
|
||||||
|
index = endIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claw may emit pipe tables without a separator row while streaming. Treat a
|
||||||
|
// contiguous block of at least two pipe rows as a table for media deduping.
|
||||||
|
for (let index = 0; index < lines.length; ) {
|
||||||
|
if (!isMarkdownTableRow(lines[index] || "")) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let endIndex = index + 1;
|
||||||
|
while (
|
||||||
|
endIndex < lines.length &&
|
||||||
|
isMarkdownTableRow(lines[endIndex] || "")
|
||||||
|
) {
|
||||||
|
endIndex += 1;
|
||||||
|
}
|
||||||
|
if (endIndex - index >= 2) {
|
||||||
|
addUrlsFromTableText(lines.slice(index, endIndex).join("\n"));
|
||||||
|
}
|
||||||
|
index = endIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlTablePattern = /<table\b[^>]*>[\s\S]*?<\/table>/gi;
|
||||||
|
let htmlTableMatch: RegExpExecArray | null = null;
|
||||||
|
while ((htmlTableMatch = htmlTablePattern.exec(String(text || ""))) !== null) {
|
||||||
|
addUrlsFromTableText(htmlTableMatch[0] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterStandaloneAssistantImageUrls(
|
||||||
|
text: string,
|
||||||
|
imageUrls: string[]
|
||||||
|
): string[] {
|
||||||
|
const tableImageUrlKeys = getMarkdownTableImageUrlKeys(text);
|
||||||
|
if (tableImageUrlKeys.size === 0) return imageUrls;
|
||||||
|
|
||||||
|
return imageUrls.filter((url) => {
|
||||||
|
const key = normalizeImageIdentityUrl(url);
|
||||||
|
return !key || !tableImageUrlKeys.has(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUDIO_MEDIA_EXTENSIONS = new Set([
|
||||||
|
"aac",
|
||||||
|
"flac",
|
||||||
|
"m4a",
|
||||||
|
"mp3",
|
||||||
|
"oga",
|
||||||
|
"ogg",
|
||||||
|
"opus",
|
||||||
|
"wav",
|
||||||
|
]);
|
||||||
|
const VIDEO_MEDIA_EXTENSIONS = new Set([
|
||||||
|
"m3u8",
|
||||||
|
"m4v",
|
||||||
|
"mov",
|
||||||
|
"mp4",
|
||||||
|
"ogv",
|
||||||
|
"webm",
|
||||||
|
]);
|
||||||
|
|
||||||
|
type ChatMediaKind = "audio" | "video";
|
||||||
|
|
||||||
|
function sanitizeExtractedMediaUrl(value: string): string {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/[),.;!?,。;!?]+$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChatMediaUrlExtension(value: string): string {
|
||||||
|
const raw = sanitizeExtractedMediaUrl(value);
|
||||||
|
if (!raw) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
|
||||||
|
const pathnameExtension = parsed.pathname.match(/\.([a-z0-9]+)$/i)?.[1];
|
||||||
|
if (pathnameExtension) return pathnameExtension.toLowerCase();
|
||||||
|
|
||||||
|
for (const key of ["format", "ext", "type", "filename", "file", "name"]) {
|
||||||
|
const candidate = parsed.searchParams.get(key) || "";
|
||||||
|
const queryExtension = candidate.match(/(?:^|\.)([a-z0-9]+)$/i)?.[1];
|
||||||
|
if (queryExtension) return queryExtension.toLowerCase();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChatMediaUrl(value: string, kind: ChatMediaKind): boolean {
|
||||||
|
const extension = getChatMediaUrlExtension(value);
|
||||||
|
return kind === "audio"
|
||||||
|
? AUDIO_MEDIA_EXTENSIONS.has(extension)
|
||||||
|
: VIDEO_MEDIA_EXTENSIONS.has(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
const urls = new Set<string>();
|
const urls = new Map<string, string>();
|
||||||
|
const candidates: Array<{
|
||||||
|
forceKind?: ChatMediaKind;
|
||||||
|
index: number;
|
||||||
|
value: string;
|
||||||
|
}> = [];
|
||||||
|
const addUrl = (value: string, forceKind?: ChatMediaKind) => {
|
||||||
|
const url = sanitizeExtractedMediaUrl(value);
|
||||||
|
if (!url || (forceKind !== kind && !isChatMediaUrl(url, kind))) return;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!urls.has(url)) urls.set(url, url);
|
||||||
|
};
|
||||||
|
|
||||||
const videoTagPattern = /<video[^>]+src="([^"]+)"/gi;
|
const tagPattern = /<(audio|video)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||||
let match: RegExpExecArray | null = null;
|
let match: RegExpExecArray | null = null;
|
||||||
while ((match = videoTagPattern.exec(text)) !== null) {
|
while ((match = tagPattern.exec(text)) !== null) {
|
||||||
if (match[1]) urls.add(match[1]);
|
candidates.push({
|
||||||
|
forceKind: match[1]?.toLowerCase() as ChatMediaKind,
|
||||||
|
index: match.index,
|
||||||
|
value: match[2] || "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownVideoPattern = /\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi;
|
const containerPattern = /<(audio|video)\b[^>]*>([\s\S]*?)<\/\1>/gi;
|
||||||
while ((match = markdownVideoPattern.exec(text)) !== null) {
|
while ((match = containerPattern.exec(text)) !== null) {
|
||||||
if (match[1]) urls.add(match[1]);
|
const containerKind = match[1]?.toLowerCase() as ChatMediaKind;
|
||||||
|
const sourcePattern = /<source\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||||
|
let sourceMatch: RegExpExecArray | null = null;
|
||||||
|
while ((sourceMatch = sourcePattern.exec(match[2] || "")) !== null) {
|
||||||
|
candidates.push({
|
||||||
|
forceKind: containerKind,
|
||||||
|
index: match.index + sourceMatch.index,
|
||||||
|
value: sourceMatch[1] || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const plainVideoPattern = /(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/gi;
|
const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
|
||||||
while ((match = plainVideoPattern.exec(text)) !== null) {
|
while ((match = markdownLinkPattern.exec(text)) !== null) {
|
||||||
if (match[1]) urls.add(match[1]);
|
const label = String(match[1] || "");
|
||||||
|
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
|
||||||
|
? "audio"
|
||||||
|
: /视频|影片|播放|video/i.test(label)
|
||||||
|
? "video"
|
||||||
|
: undefined;
|
||||||
|
candidates.push({
|
||||||
|
forceKind: hintedKind,
|
||||||
|
index: match.index,
|
||||||
|
value: match[2] || "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(urls);
|
const plainUrlPattern = /https?:\/\/[^\s"'<>\[\]]+/gi;
|
||||||
|
while ((match = plainUrlPattern.exec(text)) !== null) {
|
||||||
|
candidates.push({ index: match.index, value: match[0] || "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.sort((left, right) => left.index - right.index)
|
||||||
|
.forEach((candidate) => addUrl(candidate.value, candidate.forceKind));
|
||||||
|
|
||||||
|
return Array.from(urls.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAudioUrls(text: string): string[] {
|
||||||
|
return extractChatMediaUrls(text, "audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractVideoUrls(text: string): string[] {
|
||||||
|
return extractChatMediaUrls(text, "video");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripMediaMarkup(text: string): string {
|
export function stripMediaMarkup(text: string): string {
|
||||||
return String(text || "")
|
return String(text || "")
|
||||||
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "")
|
.replace(/!\[(?:thumb)?\|\|\|[^|]+\|\|\|[^\]]*\]\([^)]+\)/g, "")
|
||||||
.replace(/<video[\s\S]*?<\/video>/gi, "")
|
.replace(/<video[\s\S]*?<\/video>/gi, "")
|
||||||
|
.replace(/<video\b[^>]*\/?\s*>/gi, "")
|
||||||
|
.replace(/<audio[\s\S]*?<\/audio>/gi, "")
|
||||||
|
.replace(/<audio\b[^>]*\/?\s*>/gi, "")
|
||||||
.replace(/<a[\s\S]*?<\/a>/gi, "")
|
.replace(/<a[\s\S]*?<\/a>/gi, "")
|
||||||
.replace(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi, "")
|
.replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
|
||||||
.replace(/https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?/gi, "")
|
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
|
||||||
|
String(label || "")
|
||||||
|
);
|
||||||
|
return hintedMedia || isChatMediaUrl(url, "audio") || isChatMediaUrl(url, "video")
|
||||||
|
? ""
|
||||||
|
: match;
|
||||||
|
})
|
||||||
|
.replace(/https?:\/\/[^\s"'<>\[\]]+/gi, (match) => {
|
||||||
|
const url = sanitizeExtractedMediaUrl(match);
|
||||||
|
if (!isChatMediaUrl(url, "audio") && !isChatMediaUrl(url, "video")) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return match.slice(url.length);
|
||||||
|
})
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,6 +556,7 @@ export function messageHasAssistantMedia(
|
|||||||
const text = String(part.text || "");
|
const text = String(part.text || "");
|
||||||
return (
|
return (
|
||||||
extractAssistantImageUrls(text, referenceUrlKeys).length > 0 ||
|
extractAssistantImageUrls(text, referenceUrlKeys).length > 0 ||
|
||||||
|
extractAudioUrls(text).length > 0 ||
|
||||||
extractVideoUrls(text).length > 0
|
extractVideoUrls(text).length > 0
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||||
|
import { isOpenClawChatFileAttachment } from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
|
||||||
export const SESSION_TITLE_MAX_LEN = 18;
|
export const SESSION_TITLE_MAX_LEN = 18;
|
||||||
export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
|
export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
|
||||||
@ -17,6 +18,12 @@ const ALLOWED_CHAT_MEDIA_TYPES = new Set([
|
|||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/bmp",
|
"image/bmp",
|
||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/webm",
|
||||||
|
"video/x-matroska",
|
||||||
|
"application/zip",
|
||||||
|
"application/x-zip-compressed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const GENERIC_CANVAS_PROMPT_SET = new Set([
|
const GENERIC_CANVAS_PROMPT_SET = new Set([
|
||||||
@ -28,6 +35,7 @@ const GENERIC_CANVAS_PROMPT_SET = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export interface PickedObjectAttachment extends Attachment {
|
export interface PickedObjectAttachment extends Attachment {
|
||||||
|
composerTagId?: string;
|
||||||
pickedObject?: {
|
pickedObject?: {
|
||||||
objectName: string;
|
objectName: string;
|
||||||
objectPosition: string;
|
objectPosition: string;
|
||||||
@ -35,6 +43,10 @@ export interface PickedObjectAttachment extends Attachment {
|
|||||||
clickY: number;
|
clickY: number;
|
||||||
boundingBox: { x: number; y: number; width: number; height: number };
|
boundingBox: { x: number; y: number; width: number; height: number };
|
||||||
};
|
};
|
||||||
|
storyboardReference?: {
|
||||||
|
nodeId: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toRawCanvasChatId(value: string): string {
|
export function toRawCanvasChatId(value: string): string {
|
||||||
@ -74,6 +86,9 @@ export function normalizeChatMediaType(value?: string | null): string {
|
|||||||
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
|
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
if (isOpenClawChatFileAttachment("", normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
return "image/jpeg";
|
return "image/jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
60
components/canvas/chat-panel/clawd-request-state.ts
Normal file
60
components/canvas/chat-panel/clawd-request-state.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
export type ClawdRequestState<TProgress> = {
|
||||||
|
userMessageId: string;
|
||||||
|
hint: string | null;
|
||||||
|
progressEvents: TProgress[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClawdRequestStateByChatId<TProgress> = Record<
|
||||||
|
string,
|
||||||
|
ClawdRequestState<TProgress>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function startClawdRequestState<TProgress>(
|
||||||
|
previousState: ClawdRequestStateByChatId<TProgress>,
|
||||||
|
chatSessionId: string,
|
||||||
|
userMessageId: string,
|
||||||
|
hint: string | null
|
||||||
|
): ClawdRequestStateByChatId<TProgress> {
|
||||||
|
return {
|
||||||
|
...previousState,
|
||||||
|
[chatSessionId]: {
|
||||||
|
userMessageId,
|
||||||
|
hint,
|
||||||
|
progressEvents: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateClawdRequestState<TProgress>(
|
||||||
|
previousState: ClawdRequestStateByChatId<TProgress>,
|
||||||
|
chatSessionId: string,
|
||||||
|
userMessageId: string,
|
||||||
|
updater: (
|
||||||
|
currentState: ClawdRequestState<TProgress>
|
||||||
|
) => ClawdRequestState<TProgress>
|
||||||
|
): ClawdRequestStateByChatId<TProgress> {
|
||||||
|
const currentState = previousState[chatSessionId];
|
||||||
|
if (!currentState || currentState.userMessageId !== userMessageId) {
|
||||||
|
return previousState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previousState,
|
||||||
|
[chatSessionId]: updater(currentState),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearClawdRequestState<TProgress>(
|
||||||
|
previousState: ClawdRequestStateByChatId<TProgress>,
|
||||||
|
chatSessionId: string,
|
||||||
|
userMessageId: string
|
||||||
|
): ClawdRequestStateByChatId<TProgress> {
|
||||||
|
const currentState = previousState[chatSessionId];
|
||||||
|
if (!currentState || currentState.userMessageId !== userMessageId) {
|
||||||
|
return previousState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = { ...previousState };
|
||||||
|
delete nextState[chatSessionId];
|
||||||
|
return nextState;
|
||||||
|
}
|
||||||
@ -278,6 +278,10 @@ function configureMaterialTextures(
|
|||||||
geometry.setAttribute("uv2", geometry.attributes.uv);
|
geometry.setAttribute("uv2", geometry.attributes.uv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (geometry?.attributes.color && "vertexColors" in materialRecord) {
|
||||||
|
materialRecord.vertexColors = true;
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of MATERIAL_TEXTURE_KEYS) {
|
for (const key of MATERIAL_TEXTURE_KEYS) {
|
||||||
const texture = materialRecord[key];
|
const texture = materialRecord[key];
|
||||||
if (!texture?.isTexture) continue;
|
if (!texture?.isTexture) continue;
|
||||||
@ -287,6 +291,7 @@ function configureMaterialTextures(
|
|||||||
? THREE.SRGBColorSpace
|
? THREE.SRGBColorSpace
|
||||||
: THREE.NoColorSpace;
|
: THREE.NoColorSpace;
|
||||||
}
|
}
|
||||||
|
texture.anisotropy = Math.max(Number(texture.anisotropy || 1), 8);
|
||||||
texture.needsUpdate = true;
|
texture.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -652,7 +657,7 @@ export function Model3DViewer({
|
|||||||
const resolvedModelUrl = useMemo(() => {
|
const resolvedModelUrl = useMemo(() => {
|
||||||
return buildThreeAssetUrl(modelUrl);
|
return buildThreeAssetUrl(modelUrl);
|
||||||
}, [modelUrl]);
|
}, [modelUrl]);
|
||||||
const gridY = Number.isFinite(groundY) ? groundY - 0.01 : -0.01;
|
const gridY = Number.isFinite(groundY) ? groundY - 0.08 : -0.08;
|
||||||
const handleGroundYChange = React.useCallback((nextGroundY: number) => {
|
const handleGroundYChange = React.useCallback((nextGroundY: number) => {
|
||||||
setGroundY((previousGroundY) =>
|
setGroundY((previousGroundY) =>
|
||||||
Math.abs(previousGroundY - nextGroundY) < 0.001
|
Math.abs(previousGroundY - nextGroundY) < 0.001
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -61,6 +61,17 @@ export const createLocalEditRegionId = () =>
|
|||||||
export const createLocalEditReferenceImageId = () =>
|
export const createLocalEditReferenceImageId = () =>
|
||||||
`local-edit-ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
`local-edit-ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
export const hasScaleAdjustTargetChanged = (
|
||||||
|
sourceRect: NormalizedCropRect,
|
||||||
|
targetRect: NormalizedCropRect | null | undefined
|
||||||
|
) => {
|
||||||
|
if (!targetRect) return false;
|
||||||
|
const minimumNormalizedChange = 0.001;
|
||||||
|
return (["x", "y", "width", "height"] as const).some(
|
||||||
|
(key) => Math.abs(targetRect[key] - sourceRect[key]) >= minimumNormalizedChange
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeLocalEditRect = (value: unknown): NormalizedCropRect | null => {
|
export const normalizeLocalEditRect = (value: unknown): NormalizedCropRect | null => {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const rect = value as Partial<NormalizedCropRect>;
|
const rect = value as Partial<NormalizedCropRect>;
|
||||||
|
|||||||
@ -117,7 +117,7 @@ const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
|||||||
const WORLDLABS_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0];
|
const WORLDLABS_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0];
|
||||||
const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1);
|
const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1);
|
||||||
const WORLDLABS_DISPLAY_GROUND_BIAS_RATIO = 0.08;
|
const WORLDLABS_DISPLAY_GROUND_BIAS_RATIO = 0.08;
|
||||||
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.22;
|
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.45;
|
||||||
|
|
||||||
const EMPTY_MOVEMENT_STATE: MovementState = {
|
const EMPTY_MOVEMENT_STATE: MovementState = {
|
||||||
forward: false,
|
forward: false,
|
||||||
@ -1494,6 +1494,7 @@ function WorldSplatScene({
|
|||||||
semanticsMetadata?.ground_plane_offset
|
semanticsMetadata?.ground_plane_offset
|
||||||
);
|
);
|
||||||
const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset);
|
const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset);
|
||||||
|
const shouldShowGroundHelper = groundHelperOffset !== null && !firstPerson;
|
||||||
const handleOrbitControlsRef = React.useCallback((controls: any | null) => {
|
const handleOrbitControlsRef = React.useCallback((controls: any | null) => {
|
||||||
orbitControlsRef.current = controls;
|
orbitControlsRef.current = controls;
|
||||||
setControlsMountVersion((version) => version + 1);
|
setControlsMountVersion((version) => version + 1);
|
||||||
@ -1779,7 +1780,7 @@ function WorldSplatScene({
|
|||||||
<>
|
<>
|
||||||
<ambientLight intensity={1.6} />
|
<ambientLight intensity={1.6} />
|
||||||
<directionalLight intensity={1.25} position={[6, 10, 4]} />
|
<directionalLight intensity={1.25} position={[6, 10, 4]} />
|
||||||
{groundHelperOffset !== null ? (
|
{shouldShowGroundHelper ? (
|
||||||
<gridHelper
|
<gridHelper
|
||||||
args={[18, 18, "#334155", "#0f172a"]}
|
args={[18, 18, "#334155", "#0f172a"]}
|
||||||
position={[0, groundHelperOffset, 0]}
|
position={[0, groundHelperOffset, 0]}
|
||||||
|
|||||||
@ -459,7 +459,7 @@ function MarkdownTableImagePreview({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group/table-image relative block h-24 w-24 overflow-hidden rounded-lg border border-border/70 bg-muted/40"
|
className="group/table-image relative block h-16 w-16 overflow-hidden rounded-md border border-border/70 bg-muted/40"
|
||||||
data-no-drag="true"
|
data-no-drag="true"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -696,7 +696,9 @@ function DownloadableMarkdownTable({
|
|||||||
<table
|
<table
|
||||||
ref={tableRef}
|
ref={tableRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-full table-auto border-collapse text-sm",
|
"w-max min-w-full table-auto border-collapse text-sm leading-6",
|
||||||
|
"[&_tr]:border-b [&_tr]:border-border/70 [&_tr:last-child]:border-b-0",
|
||||||
|
"[&_tbody_tr:nth-child(even)]:bg-muted/15 [&_tbody_tr:hover]:bg-muted/30",
|
||||||
"[&_td]:min-w-[10rem] [&_td]:max-w-[24rem] [&_td]:align-top [&_td]:whitespace-normal [&_td]:break-words [&_td]:[overflow-wrap:anywhere] [&_td]:[word-break:break-word]",
|
"[&_td]:min-w-[10rem] [&_td]:max-w-[24rem] [&_td]:align-top [&_td]:whitespace-normal [&_td]:break-words [&_td]:[overflow-wrap:anywhere] [&_td]:[word-break:break-word]",
|
||||||
"[&_td:first-child]:min-w-[12rem] [&_td:first-child]:max-w-[18rem] [&_th:first-child]:min-w-[12rem] [&_th:first-child]:max-w-[18rem]",
|
"[&_td:first-child]:min-w-[12rem] [&_td:first-child]:max-w-[18rem] [&_th:first-child]:min-w-[12rem] [&_th:first-child]:max-w-[18rem]",
|
||||||
"[&_th]:min-w-[10rem] [&_th]:max-w-[22rem] [&_th]:whitespace-normal [&_th]:break-words [&_th]:[overflow-wrap:anywhere] [&_th]:[word-break:break-word]",
|
"[&_th]:min-w-[10rem] [&_th]:max-w-[22rem] [&_th]:whitespace-normal [&_th]:break-words [&_th]:[overflow-wrap:anywhere] [&_th]:[word-break:break-word]",
|
||||||
@ -723,7 +725,8 @@ function MarkdownTableCell({
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[10rem] max-w-[24rem] align-top !whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
|
"min-w-[10rem] max-w-[24rem] border-r border-border/60 px-4 py-2.5 text-left align-top leading-6 last:border-r-0",
|
||||||
|
"!whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -741,7 +744,8 @@ function MarkdownTableHeaderCell({
|
|||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[10rem] max-w-[22rem] !whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
|
"min-w-[10rem] max-w-[22rem] border-r border-border/70 bg-muted/70 px-4 py-2.5 text-left align-middle font-semibold leading-5 last:border-r-0",
|
||||||
|
"!whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -70,6 +70,12 @@ import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured-
|
|||||||
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
|
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
|
||||||
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
|
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
|
||||||
import { LovartSidebar } from "@/components/home/lovart/lovart-sidebar";
|
import { LovartSidebar } from "@/components/home/lovart/lovart-sidebar";
|
||||||
|
import {
|
||||||
|
LOVART_PROMO_BANNER_HEIGHT,
|
||||||
|
LovartPromoBanner,
|
||||||
|
} from "@/components/home/lovart/lovart-promo-banner";
|
||||||
|
|
||||||
|
const HOME_PROMO_DISMISS_STORAGE_KEY = "lovart-home-promo-dismissed-v1";
|
||||||
|
|
||||||
type ImageAnnotatorComponent = typeof import("@/components/image-annotator").ImageAnnotator;
|
type ImageAnnotatorComponent = typeof import("@/components/image-annotator").ImageAnnotator;
|
||||||
|
|
||||||
@ -431,6 +437,10 @@ export function LovartHome({
|
|||||||
const [activeCategory, setActiveCategory] = useState("全部");
|
const [activeCategory, setActiveCategory] = useState("全部");
|
||||||
const [isProjectsOnlyView, setIsProjectsOnlyView] = useState(false);
|
const [isProjectsOnlyView, setIsProjectsOnlyView] = useState(false);
|
||||||
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||||
|
const [dismissedHomeBannerIdentity, setDismissedHomeBannerIdentity] =
|
||||||
|
useState<string | null>(null);
|
||||||
|
const [hasResolvedHomeBannerDismissal, setHasResolvedHomeBannerDismissal] =
|
||||||
|
useState(false);
|
||||||
const [accountCenterOpen, setAccountCenterOpen] = useState(false);
|
const [accountCenterOpen, setAccountCenterOpen] = useState(false);
|
||||||
const [accountCenterTab, setAccountCenterTab] = useState<"account" | "usage" | "billing">(
|
const [accountCenterTab, setAccountCenterTab] = useState<"account" | "usage" | "billing">(
|
||||||
"account"
|
"account"
|
||||||
@ -604,6 +614,10 @@ export function LovartHome({
|
|||||||
siteLogoDark?: string | null;
|
siteLogoDark?: string | null;
|
||||||
lovartPromoText?: string | null;
|
lovartPromoText?: string | null;
|
||||||
lovartPromoActionText?: string | null;
|
lovartPromoActionText?: string | null;
|
||||||
|
bannerEnabled?: boolean;
|
||||||
|
bannerText?: string | null;
|
||||||
|
bannerLink?: string | null;
|
||||||
|
bannerActionText?: string | null;
|
||||||
lovartHeroTitle?: string | null;
|
lovartHeroTitle?: string | null;
|
||||||
lovartHeroSubtitle?: string | null;
|
lovartHeroSubtitle?: string | null;
|
||||||
surpriseCategories?: string[];
|
surpriseCategories?: string[];
|
||||||
@ -643,6 +657,32 @@ export function LovartHome({
|
|||||||
siteSettingsData?.lovartPromoText ?? initialHeroPromoText;
|
siteSettingsData?.lovartPromoText ?? initialHeroPromoText;
|
||||||
const heroPromoActionText =
|
const heroPromoActionText =
|
||||||
siteSettingsData?.lovartPromoActionText ?? initialHeroPromoActionText;
|
siteSettingsData?.lovartPromoActionText ?? initialHeroPromoActionText;
|
||||||
|
const homeBannerText = String(siteSettingsData?.bannerText || "").trim();
|
||||||
|
const homeBannerActionText = String(
|
||||||
|
siteSettingsData?.bannerActionText || "立即查看"
|
||||||
|
).trim();
|
||||||
|
const rawHomeBannerLink = String(siteSettingsData?.bannerLink || "").trim();
|
||||||
|
const homeBannerLink =
|
||||||
|
/^(https?:\/\/|\/(?!\/))/i.test(rawHomeBannerLink)
|
||||||
|
? rawHomeBannerLink
|
||||||
|
: "";
|
||||||
|
const homeBannerIdentity = useMemo(
|
||||||
|
() =>
|
||||||
|
homeBannerText
|
||||||
|
? JSON.stringify({
|
||||||
|
actionText: homeBannerActionText,
|
||||||
|
link: homeBannerLink,
|
||||||
|
text: homeBannerText,
|
||||||
|
})
|
||||||
|
: "",
|
||||||
|
[homeBannerActionText, homeBannerLink, homeBannerText]
|
||||||
|
);
|
||||||
|
const shouldShowHomeBanner = Boolean(
|
||||||
|
siteSettingsData?.bannerEnabled === true &&
|
||||||
|
homeBannerIdentity &&
|
||||||
|
hasResolvedHomeBannerDismissal &&
|
||||||
|
dismissedHomeBannerIdentity !== homeBannerIdentity
|
||||||
|
);
|
||||||
const heroTitleText =
|
const heroTitleText =
|
||||||
siteSettingsData?.lovartHeroTitle ?? initialHeroTitleText;
|
siteSettingsData?.lovartHeroTitle ?? initialHeroTitleText;
|
||||||
const heroSubtitleText =
|
const heroSubtitleText =
|
||||||
@ -654,6 +694,48 @@ export function LovartHome({
|
|||||||
? siteLogoDark || siteLogo || ""
|
? siteLogoDark || siteLogo || ""
|
||||||
: siteLogo || siteLogoDark || "";
|
: siteLogo || siteLogoDark || "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!homeBannerIdentity) {
|
||||||
|
setDismissedHomeBannerIdentity(null);
|
||||||
|
setHasResolvedHomeBannerDismissal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedIdentity = window.localStorage.getItem(
|
||||||
|
HOME_PROMO_DISMISS_STORAGE_KEY
|
||||||
|
);
|
||||||
|
setDismissedHomeBannerIdentity(
|
||||||
|
storedIdentity === homeBannerIdentity ? homeBannerIdentity : null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setDismissedHomeBannerIdentity(null);
|
||||||
|
}
|
||||||
|
setHasResolvedHomeBannerDismissal(true);
|
||||||
|
}, [homeBannerIdentity]);
|
||||||
|
|
||||||
|
const dismissHomeBanner = useCallback(() => {
|
||||||
|
if (!homeBannerIdentity) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
HOME_PROMO_DISMISS_STORAGE_KEY,
|
||||||
|
homeBannerIdentity
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// The banner still closes for this page when storage is unavailable.
|
||||||
|
}
|
||||||
|
setDismissedHomeBannerIdentity(homeBannerIdentity);
|
||||||
|
}, [homeBannerIdentity]);
|
||||||
|
|
||||||
|
const openHomeBannerLink = useCallback(() => {
|
||||||
|
if (!homeBannerLink) return;
|
||||||
|
if (homeBannerLink.startsWith("/")) {
|
||||||
|
router.push(homeBannerLink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(homeBannerLink, "_blank", "noopener,noreferrer");
|
||||||
|
}, [homeBannerLink, router]);
|
||||||
|
|
||||||
const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>(
|
const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>(
|
||||||
isSecondaryDataReady && siteSettingsData?.tutorialEnabled
|
isSecondaryDataReady && siteSettingsData?.tutorialEnabled
|
||||||
? "/api/tutorial-videos"
|
? "/api/tutorial-videos"
|
||||||
@ -1522,9 +1604,19 @@ export function LovartHome({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
|
<div className="min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
|
||||||
|
{shouldShowHomeBanner ? (
|
||||||
|
<LovartPromoBanner
|
||||||
|
actionText={homeBannerActionText}
|
||||||
|
link={homeBannerLink}
|
||||||
|
onAction={openHomeBannerLink}
|
||||||
|
onDismiss={dismissHomeBanner}
|
||||||
|
text={homeBannerText}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<BrandTopBar
|
<BrandTopBar
|
||||||
contentClassName="pl-[98px]"
|
contentClassName="pl-[98px]"
|
||||||
logo={displayLogo}
|
logo={displayLogo}
|
||||||
|
offsetTop={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
|
||||||
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
showBrand={false}
|
showBrand={false}
|
||||||
siteName={siteName || "Lovart"}
|
siteName={siteName || "Lovart"}
|
||||||
@ -1754,6 +1846,7 @@ export function LovartHome({
|
|||||||
agentEnabled={siteSettingsData?.agentEnabled !== false}
|
agentEnabled={siteSettingsData?.agentEnabled !== false}
|
||||||
getItem={getLovartSidebarItem}
|
getItem={getLovartSidebarItem}
|
||||||
isEnabled={isLovartSidebarItemEnabled}
|
isEnabled={isLovartSidebarItemEnabled}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
isProjectsOnlyView={isProjectsOnlyView}
|
isProjectsOnlyView={isProjectsOnlyView}
|
||||||
onAllProjects={openAllProjectsInPage}
|
onAllProjects={openAllProjectsInPage}
|
||||||
onGallery={() => router.push("/gallery")}
|
onGallery={() => router.push("/gallery")}
|
||||||
@ -1764,6 +1857,7 @@ export function LovartHome({
|
|||||||
onImageEditor={handleOpenImageEditor}
|
onImageEditor={handleOpenImageEditor}
|
||||||
onNavLink={openNavLink}
|
onNavLink={openNavLink}
|
||||||
onNewCanvas={openNewCanvas}
|
onNewCanvas={openNewCanvas}
|
||||||
|
onRequireLogin={openLoginDialog}
|
||||||
onRoute={openNavLink}
|
onRoute={openNavLink}
|
||||||
onTutorial={handleOpenTutorial}
|
onTutorial={handleOpenTutorial}
|
||||||
productPlanningEnabled={siteSettingsData?.productPlanningEnabled !== false}
|
productPlanningEnabled={siteSettingsData?.productPlanningEnabled !== false}
|
||||||
@ -1777,6 +1871,7 @@ export function LovartHome({
|
|||||||
}
|
}
|
||||||
siteLogo={displayLogo}
|
siteLogo={displayLogo}
|
||||||
siteName={siteName || "Lovart"}
|
siteName={siteName || "Lovart"}
|
||||||
|
topOffset={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
|
||||||
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
|
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
|
||||||
toolbox1Links={toolbox1Links}
|
toolbox1Links={toolbox1Links}
|
||||||
toolbox2Enabled={siteSettingsData?.toolbox2Enabled === true}
|
toolbox2Enabled={siteSettingsData?.toolbox2Enabled === true}
|
||||||
@ -1787,7 +1882,12 @@ export function LovartHome({
|
|||||||
tone={resolvedTheme === "dark" ? "dark" : "light"}
|
tone={resolvedTheme === "dark" ? "dark" : "light"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="ml-[118px] mr-[56px] w-auto max-w-none pb-20 pt-[84px] max-lg:mx-6">
|
<main
|
||||||
|
className={cn(
|
||||||
|
"ml-[118px] mr-[56px] w-auto max-w-none pb-20 max-lg:mx-6",
|
||||||
|
shouldShowHomeBanner ? "pt-[129px]" : "pt-[84px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{!isProjectsOnlyView && (
|
{!isProjectsOnlyView && (
|
||||||
<LovartHeroComposer
|
<LovartHeroComposer
|
||||||
composerPlaceholder={composerPlaceholder}
|
composerPlaceholder={composerPlaceholder}
|
||||||
|
|||||||
51
components/home/lovart/lovart-promo-banner.tsx
Normal file
51
components/home/lovart/lovart-promo-banner.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { ArrowRight, Sparkles, X } from "lucide-react";
|
||||||
|
|
||||||
|
type LovartPromoBannerProps = {
|
||||||
|
actionText?: string | null;
|
||||||
|
link?: string | null;
|
||||||
|
onAction: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOVART_PROMO_BANNER_HEIGHT = 45;
|
||||||
|
|
||||||
|
export function LovartPromoBanner({
|
||||||
|
actionText,
|
||||||
|
link,
|
||||||
|
onAction,
|
||||||
|
onDismiss,
|
||||||
|
text,
|
||||||
|
}: LovartPromoBannerProps) {
|
||||||
|
const showAction = Boolean(link?.trim() && actionText?.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-x-0 top-0 z-[120] h-[45px] bg-[#ddff58] text-[#11130d]">
|
||||||
|
<div className="mx-auto flex h-full max-w-[1440px] items-center justify-center gap-3 px-11 sm:px-12">
|
||||||
|
<Sparkles className="hidden h-4 w-4 shrink-0 sm:block" />
|
||||||
|
<p className="min-w-0 truncate text-center text-[13px] font-normal sm:text-[15px]">
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{showAction ? (
|
||||||
|
<button
|
||||||
|
className="inline-flex h-8 max-w-[42%] shrink-0 items-center gap-1 px-1 text-[13px] font-medium underline decoration-1 underline-offset-4 transition-opacity hover:opacity-65 sm:max-w-none sm:text-[15px]"
|
||||||
|
onClick={onAction}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="truncate">{actionText}</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
aria-label="关闭首页广告"
|
||||||
|
className="absolute right-2 flex h-8 w-8 items-center justify-center text-black/65 transition hover:text-black sm:right-4"
|
||||||
|
onClick={onDismiss}
|
||||||
|
title="关闭"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-[18px] w-[18px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
|
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
|
||||||
|
import { runAuthenticatedSidebarAction } from "@/lib/home/sidebar-auth";
|
||||||
import type { NavLink, TutorialVideo } from "./types";
|
import type { NavLink, TutorialVideo } from "./types";
|
||||||
|
|
||||||
type SidebarItem = {
|
type SidebarItem = {
|
||||||
@ -23,6 +24,7 @@ type LovartSidebarProps = {
|
|||||||
agentEnabled: boolean;
|
agentEnabled: boolean;
|
||||||
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
|
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
|
||||||
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
|
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
isProjectsOnlyView: boolean;
|
isProjectsOnlyView: boolean;
|
||||||
onAllProjects: () => void;
|
onAllProjects: () => void;
|
||||||
onGallery: () => void;
|
onGallery: () => void;
|
||||||
@ -30,6 +32,7 @@ type LovartSidebarProps = {
|
|||||||
onImageEditor: () => void;
|
onImageEditor: () => void;
|
||||||
onNavLink: (url: string) => void;
|
onNavLink: (url: string) => void;
|
||||||
onNewCanvas: () => void;
|
onNewCanvas: () => void;
|
||||||
|
onRequireLogin: () => void;
|
||||||
onRoute: (url: string) => void;
|
onRoute: (url: string) => void;
|
||||||
onTutorial: (video: TutorialVideo) => void;
|
onTutorial: (video: TutorialVideo) => void;
|
||||||
productPlanningEnabled: boolean;
|
productPlanningEnabled: boolean;
|
||||||
@ -48,6 +51,7 @@ type LovartSidebarProps = {
|
|||||||
toolbox1Links: NavLink[];
|
toolbox1Links: NavLink[];
|
||||||
toolbox2Enabled: boolean;
|
toolbox2Enabled: boolean;
|
||||||
toolbox2Links: NavLink[];
|
toolbox2Links: NavLink[];
|
||||||
|
topOffset?: number;
|
||||||
tutorialEnabled: boolean;
|
tutorialEnabled: boolean;
|
||||||
tutorialVideos: TutorialVideo[];
|
tutorialVideos: TutorialVideo[];
|
||||||
videoCanvasEnabled: boolean;
|
videoCanvasEnabled: boolean;
|
||||||
@ -58,6 +62,7 @@ export function LovartSidebar({
|
|||||||
agentEnabled,
|
agentEnabled,
|
||||||
getItem,
|
getItem,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
|
isAuthenticated,
|
||||||
isProjectsOnlyView,
|
isProjectsOnlyView,
|
||||||
onAllProjects,
|
onAllProjects,
|
||||||
onGallery,
|
onGallery,
|
||||||
@ -65,6 +70,7 @@ export function LovartSidebar({
|
|||||||
onImageEditor,
|
onImageEditor,
|
||||||
onNavLink,
|
onNavLink,
|
||||||
onNewCanvas,
|
onNewCanvas,
|
||||||
|
onRequireLogin,
|
||||||
onRoute,
|
onRoute,
|
||||||
onTutorial,
|
onTutorial,
|
||||||
productPlanningEnabled,
|
productPlanningEnabled,
|
||||||
@ -76,6 +82,7 @@ export function LovartSidebar({
|
|||||||
toolbox1Links,
|
toolbox1Links,
|
||||||
toolbox2Enabled,
|
toolbox2Enabled,
|
||||||
toolbox2Links,
|
toolbox2Links,
|
||||||
|
topOffset = 0,
|
||||||
tutorialEnabled,
|
tutorialEnabled,
|
||||||
tutorialVideos,
|
tutorialVideos,
|
||||||
videoCanvasEnabled,
|
videoCanvasEnabled,
|
||||||
@ -93,6 +100,13 @@ export function LovartSidebar({
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
const protectAction = (action: () => void) => () => {
|
||||||
|
runAuthenticatedSidebarAction({
|
||||||
|
action,
|
||||||
|
isAuthenticated,
|
||||||
|
onRequireLogin,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const tooltipButton = (
|
const tooltipButton = (
|
||||||
key: LovartHomeSidebarItemKey,
|
key: LovartHomeSidebarItemKey,
|
||||||
@ -105,7 +119,7 @@ export function LovartSidebar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={sidebarButtonClass(options?.active)}
|
className={sidebarButtonClass(options?.active)}
|
||||||
onClick={onClick}
|
onClick={protectAction(onClick)}
|
||||||
title={getItem(key).label}
|
title={getItem(key).label}
|
||||||
>
|
>
|
||||||
{renderIcon(key, {
|
{renderIcon(key, {
|
||||||
@ -129,6 +143,7 @@ export function LovartSidebar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={sidebarButtonClass()}
|
className={sidebarButtonClass()}
|
||||||
|
onClick={protectAction(() => undefined)}
|
||||||
title={getItem(key).label}
|
title={getItem(key).label}
|
||||||
>
|
>
|
||||||
{renderIcon(key, { sizeClassName: "h-[18px] w-[18px]" })}
|
{renderIcon(key, { sizeClassName: "h-[18px] w-[18px]" })}
|
||||||
@ -148,7 +163,7 @@ export function LovartSidebar({
|
|||||||
key={link.id}
|
key={link.id}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04] dark:text-white/80 dark:hover:bg-white/8"
|
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04] dark:text-white/80 dark:hover:bg-white/8"
|
||||||
onClick={() => onNavLink(link.url)}
|
onClick={protectAction(() => onNavLink(link.url))}
|
||||||
>
|
>
|
||||||
<span className="line-clamp-1">{link.name}</span>
|
<span className="line-clamp-1">{link.name}</span>
|
||||||
<ArrowRight className="h-3.5 w-3.5 text-black/30 dark:text-white/35" />
|
<ArrowRight className="h-3.5 w-3.5 text-black/30 dark:text-white/35" />
|
||||||
@ -167,6 +182,7 @@ export function LovartSidebar({
|
|||||||
? "border-white/10 bg-black"
|
? "border-white/10 bg-black"
|
||||||
: "border-[#e9e9e9] bg-white",
|
: "border-[#e9e9e9] bg-white",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
|
style={{ top: topOffset }}
|
||||||
>
|
>
|
||||||
<TooltipProvider delayDuration={120}>
|
<TooltipProvider delayDuration={120}>
|
||||||
<div className="flex h-full w-full flex-col items-center overflow-y-auto overflow-x-hidden px-2 pb-4 pt-[15px] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<div className="flex h-full w-full flex-col items-center overflow-y-auto overflow-x-hidden px-2 pb-4 pt-[15px] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
@ -175,7 +191,7 @@ export function LovartSidebar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-opacity hover:opacity-80"
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-opacity hover:opacity-80"
|
||||||
onClick={onHome}
|
onClick={protectAction(onHome)}
|
||||||
title={siteName}
|
title={siteName}
|
||||||
>
|
>
|
||||||
{siteLogo ? (
|
{siteLogo ? (
|
||||||
@ -200,7 +216,7 @@ export function LovartSidebar({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNewCanvas}
|
onClick={protectAction(onNewCanvas)}
|
||||||
className={sidebarButtonClass()}
|
className={sidebarButtonClass()}
|
||||||
title={getItem("newProject").label}
|
title={getItem("newProject").label}
|
||||||
>
|
>
|
||||||
@ -223,6 +239,7 @@ export function LovartSidebar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={sidebarButtonClass()}
|
className={sidebarButtonClass()}
|
||||||
|
onClick={protectAction(() => undefined)}
|
||||||
title={getItem("tutorial").label}
|
title={getItem("tutorial").label}
|
||||||
>
|
>
|
||||||
{renderIcon("tutorial", { sizeClassName: "h-[18px] w-[18px]" })}
|
{renderIcon("tutorial", { sizeClassName: "h-[18px] w-[18px]" })}
|
||||||
@ -243,7 +260,7 @@ export function LovartSidebar({
|
|||||||
key={video.id}
|
key={video.id}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2.5 rounded-2xl p-2.5 text-left transition hover:bg-black/[0.04] dark:hover:bg-white/8"
|
className="flex w-full items-center gap-2.5 rounded-2xl p-2.5 text-left transition hover:bg-black/[0.04] dark:hover:bg-white/8"
|
||||||
onClick={() => onTutorial(video)}
|
onClick={protectAction(() => onTutorial(video))}
|
||||||
>
|
>
|
||||||
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04] dark:bg-white/8">
|
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04] dark:bg-white/8">
|
||||||
{video.coverUrl ? (
|
{video.coverUrl ? (
|
||||||
|
|||||||
@ -15,8 +15,10 @@ interface ImageGenerationLoadingProps {
|
|||||||
phase?: "idle" | "queued" | "processing" | "finalizing" | "failed";
|
phase?: "idle" | "queued" | "processing" | "finalizing" | "failed";
|
||||||
elapsedSeconds?: number;
|
elapsedSeconds?: number;
|
||||||
queuePosition?: number;
|
queuePosition?: number;
|
||||||
|
sameModelPendingCount?: number;
|
||||||
sameModelRunningCount?: number;
|
sameModelRunningCount?: number;
|
||||||
concurrencyLimit?: number;
|
concurrencyLimit?: number;
|
||||||
|
dispatchIntervalMs?: number;
|
||||||
estimatedWaitMs?: number;
|
estimatedWaitMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,6 +28,10 @@ const PROVIDER_MODEL_LABEL: Record<string, string> = {
|
|||||||
"fal-image": "FAL 图片",
|
"fal-image": "FAL 图片",
|
||||||
"snapgen-image": "SnapGen 图片",
|
"snapgen-image": "SnapGen 图片",
|
||||||
"openai-image": "OpenAI 图片",
|
"openai-image": "OpenAI 图片",
|
||||||
|
bananaimage: "BananaImage 图片",
|
||||||
|
visionary: "Visionary 图片",
|
||||||
|
atlascloud: "Atlas Cloud 图片",
|
||||||
|
vip98k: "VIP98K 图片",
|
||||||
"stepfun-image": "StepFun 图片",
|
"stepfun-image": "StepFun 图片",
|
||||||
midjourney: "Midjourney",
|
midjourney: "Midjourney",
|
||||||
"volcano-vision": "即梦 4.5",
|
"volcano-vision": "即梦 4.5",
|
||||||
@ -64,6 +70,7 @@ const LONG_PROCESSING_HINTS = [
|
|||||||
"正在准备把结果显示到画布。",
|
"正在准备把结果显示到画布。",
|
||||||
];
|
];
|
||||||
const LONG_WAIT_SECONDS = 5 * 60;
|
const LONG_WAIT_SECONDS = 5 * 60;
|
||||||
|
const LONG_QUEUE_WAIT_SECONDS = 10 * 60;
|
||||||
const LONG_WAIT_HINT =
|
const LONG_WAIT_HINT =
|
||||||
"服务繁忙,模型可能正在排队或自动尝试备用通道,请耐心等候,结果返回后会自动同步到画布。";
|
"服务繁忙,模型可能正在排队或自动尝试备用通道,请耐心等候,结果返回后会自动同步到画布。";
|
||||||
|
|
||||||
@ -102,8 +109,9 @@ export const ImageGenerationLoading = memo(
|
|||||||
phase = "processing",
|
phase = "processing",
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
queuePosition,
|
queuePosition,
|
||||||
|
sameModelPendingCount,
|
||||||
sameModelRunningCount,
|
sameModelRunningCount,
|
||||||
concurrencyLimit,
|
dispatchIntervalMs,
|
||||||
estimatedWaitMs,
|
estimatedWaitMs,
|
||||||
}: ImageGenerationLoadingProps) => {
|
}: ImageGenerationLoadingProps) => {
|
||||||
const shouldTrackElapsed =
|
const shouldTrackElapsed =
|
||||||
@ -134,6 +142,8 @@ export const ImageGenerationLoading = memo(
|
|||||||
const isLongWait =
|
const isLongWait =
|
||||||
resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
|
resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
|
||||||
(phase === "processing" || phase === "finalizing");
|
(phase === "processing" || phase === "finalizing");
|
||||||
|
const isLongQueueWait =
|
||||||
|
phase === "queued" && resolvedElapsedSeconds >= LONG_QUEUE_WAIT_SECONDS;
|
||||||
const estimateRangeLabel = isLongWait
|
const estimateRangeLabel = isLongWait
|
||||||
? "单次最长约10分钟"
|
? "单次最长约10分钟"
|
||||||
: providerType === "grsai-gpt-image"
|
: providerType === "grsai-gpt-image"
|
||||||
@ -152,13 +162,28 @@ export const ImageGenerationLoading = memo(
|
|||||||
: null;
|
: null;
|
||||||
const queueDetailLine =
|
const queueDetailLine =
|
||||||
phase === "queued"
|
phase === "queued"
|
||||||
? `当前模型运行 ${Math.max(
|
? `队列共 ${Math.max(
|
||||||
|
0,
|
||||||
|
Number(sameModelPendingCount || 0)
|
||||||
|
)} 个待提交,已提交 ${Math.max(
|
||||||
0,
|
0,
|
||||||
Number(sameModelRunningCount || 0)
|
Number(sameModelRunningCount || 0)
|
||||||
)}/${Math.max(1, Number(concurrencyLimit || 0) || 1)},${formatImageQueueWaitLabel(
|
)} 个生成任务,${
|
||||||
estimatedWaitMs
|
Number(dispatchIntervalMs || 0) > 0
|
||||||
|
? `每 ${Math.max(0.1, Number(dispatchIntervalMs) / 1000).toFixed(
|
||||||
|
Number(dispatchIntervalMs) % 1000 === 0 ? 0 : 1
|
||||||
|
)} 秒提交 1 个,`
|
||||||
|
: ""
|
||||||
|
}${formatImageQueueWaitLabel(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Number(estimatedWaitMs || 0) - resolvedElapsedSeconds * 1000
|
||||||
|
)
|
||||||
)}`
|
)}`
|
||||||
: null;
|
: null;
|
||||||
|
const queueTimeoutHint = isLongQueueWait
|
||||||
|
? "排队时间较长,队列位置仍会实时更新;进入执行后单次最长约10分钟,超时会自动提示。"
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -194,6 +219,9 @@ export const ImageGenerationLoading = memo(
|
|||||||
</div>
|
</div>
|
||||||
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
|
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
|
||||||
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
|
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
|
||||||
|
{queueTimeoutHint ? (
|
||||||
|
<div className="text-[13px] text-amber-500">{queueTimeoutHint}</div>
|
||||||
|
) : null}
|
||||||
{(phase !== "processing" || resolvedElapsedSeconds > 0) && (
|
{(phase !== "processing" || resolvedElapsedSeconds > 0) && (
|
||||||
<div className="text-[13px]">
|
<div className="text-[13px]">
|
||||||
{phaseHint} {elapsedLabel}
|
{phaseHint} {elapsedLabel}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export interface ImageGenerationParams {
|
|||||||
aspectRatio: string;
|
aspectRatio: string;
|
||||||
imageSize: string;
|
imageSize: string;
|
||||||
imageCount?: number; // 生成图片数量
|
imageCount?: number; // 生成图片数量
|
||||||
|
quality?: "auto" | "low" | "medium" | "high";
|
||||||
// 🆕 阿里生图专用参数
|
// 🆕 阿里生图专用参数
|
||||||
size?: string; // 自定义分辨率,如 "1024*1024"
|
size?: string; // 自定义分辨率,如 "1024*1024"
|
||||||
negativePrompt?: string; // 反向提示词
|
negativePrompt?: string; // 反向提示词
|
||||||
@ -47,6 +48,7 @@ interface ImageParamsBarProps {
|
|||||||
supportsNegativePrompt?: boolean;
|
supportsNegativePrompt?: boolean;
|
||||||
supportsWatermark?: boolean;
|
supportsWatermark?: boolean;
|
||||||
supportsSeed?: boolean;
|
supportsSeed?: boolean;
|
||||||
|
supportsQuality?: boolean;
|
||||||
supportedAspectRatios?: string[];
|
supportedAspectRatios?: string[];
|
||||||
supportedImageSizes?: string[];
|
supportedImageSizes?: string[];
|
||||||
};
|
};
|
||||||
@ -144,6 +146,7 @@ export function ImageParamsBar({
|
|||||||
"aspectRatio",
|
"aspectRatio",
|
||||||
"imageSize",
|
"imageSize",
|
||||||
"imageCount",
|
"imageCount",
|
||||||
|
"quality",
|
||||||
"size",
|
"size",
|
||||||
"negativePrompt",
|
"negativePrompt",
|
||||||
"promptExtend",
|
"promptExtend",
|
||||||
@ -171,6 +174,10 @@ export function ImageParamsBar({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
providerType === "openai-image" ||
|
providerType === "openai-image" ||
|
||||||
|
providerType === "bananaimage" ||
|
||||||
|
providerType === "visionary" ||
|
||||||
|
providerType === "atlascloud" ||
|
||||||
|
providerType === "vip98k" ||
|
||||||
providerType === "stepfun-image" ||
|
providerType === "stepfun-image" ||
|
||||||
providerType === "midjourney"
|
providerType === "midjourney"
|
||||||
) {
|
) {
|
||||||
@ -280,6 +287,7 @@ export function ImageParamsBar({
|
|||||||
a.aspectRatio === b.aspectRatio &&
|
a.aspectRatio === b.aspectRatio &&
|
||||||
a.imageSize === b.imageSize &&
|
a.imageSize === b.imageSize &&
|
||||||
(a.imageCount || 1) === (b.imageCount || 1) &&
|
(a.imageCount || 1) === (b.imageCount || 1) &&
|
||||||
|
(a.quality || "medium") === (b.quality || "medium") &&
|
||||||
a.size === b.size &&
|
a.size === b.size &&
|
||||||
a.negativePrompt === b.negativePrompt &&
|
a.negativePrompt === b.negativePrompt &&
|
||||||
a.promptExtend === b.promptExtend &&
|
a.promptExtend === b.promptExtend &&
|
||||||
@ -294,6 +302,7 @@ export function ImageParamsBar({
|
|||||||
aspectRatio: inlineTheme === "minimal" ? "1:1" : "auto",
|
aspectRatio: inlineTheme === "minimal" ? "1:1" : "auto",
|
||||||
imageSize: "2K",
|
imageSize: "2K",
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
|
quality: "medium",
|
||||||
promptExtend: true,
|
promptExtend: true,
|
||||||
watermark: false,
|
watermark: false,
|
||||||
...source,
|
...source,
|
||||||
@ -307,7 +316,14 @@ export function ImageParamsBar({
|
|||||||
...buildBaseParams(initialParams),
|
...buildBaseParams(initialParams),
|
||||||
});
|
});
|
||||||
const [minimalParamsOpen, setMinimalParamsOpen] = useState(false);
|
const [minimalParamsOpen, setMinimalParamsOpen] = useState(false);
|
||||||
const [qualityLabel, setQualityLabel] = useState<"自动" | "高" | "中" | "低">("中");
|
const qualityOptions = [
|
||||||
|
{ label: "自动", value: "auto" },
|
||||||
|
{ label: "高", value: "high" },
|
||||||
|
{ label: "中", value: "medium" },
|
||||||
|
{ label: "低", value: "low" },
|
||||||
|
] as const;
|
||||||
|
const qualityLabel =
|
||||||
|
qualityOptions.find((option) => option.value === params.quality)?.label || "中";
|
||||||
|
|
||||||
const imageCountOptions = getImageCountOptions();
|
const imageCountOptions = getImageCountOptions();
|
||||||
const showImageCountSelector = !hideImageCount && imageCountOptions.length > 1;
|
const showImageCountSelector = !hideImageCount && imageCountOptions.length > 1;
|
||||||
@ -498,19 +514,19 @@ export function ImageParamsBar({
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-[14px] font-medium leading-none">质量</div>
|
<div className="mb-2 text-[14px] font-medium leading-none">质量</div>
|
||||||
<div className="grid h-9 grid-cols-4 rounded-full bg-[#f4f4f5] p-0.5 dark:bg-white/8">
|
<div className="grid h-9 grid-cols-4 rounded-full bg-[#f4f4f5] p-0.5 dark:bg-white/8">
|
||||||
{(["自动", "高", "中", "低"] as const).map((quality) => (
|
{qualityOptions.map((quality) => (
|
||||||
<button
|
<button
|
||||||
key={quality}
|
key={quality.value}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full text-[13px] font-medium transition-colors",
|
"rounded-full text-[13px] font-medium transition-colors",
|
||||||
qualityLabel === quality
|
params.quality === quality.value
|
||||||
? "border border-[#c8cdd5] bg-white shadow-[0_1px_4px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-white/16"
|
? "border border-[#c8cdd5] bg-white shadow-[0_1px_4px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-white/16"
|
||||||
: "text-[#3d434d] hover:bg-white/55 dark:text-zinc-300 dark:hover:bg-white/10"
|
: "text-[#3d434d] hover:bg-white/55 dark:text-zinc-300 dark:hover:bg-white/10"
|
||||||
)}
|
)}
|
||||||
onClick={() => setQualityLabel(quality)}
|
onClick={() => updateParam("quality", quality.value)}
|
||||||
>
|
>
|
||||||
{quality}
|
{quality.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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