Compare commits
No commits in common. "main" and "codex/scale-adjust-main-merge" have entirely different histories.
main
...
codex/scal
10
.env.example
10
.env.example
@ -9,13 +9,6 @@
|
|||||||
# 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
|
||||||
@ -120,9 +113,6 @@ ZOO_BASE_URL=https://api.zoo.dev
|
|||||||
# /vendor/pascal-editor/index.html bundle.
|
# /vendor/pascal-editor/index.html bundle.
|
||||||
NEXT_PUBLIC_PASCAL_EDITOR_URL=
|
NEXT_PUBLIC_PASCAL_EDITOR_URL=
|
||||||
PASCAL_EDITOR_URL=
|
PASCAL_EDITOR_URL=
|
||||||
# Shared only by the main app, Nginx, and the private Pascal service. Generate a
|
|
||||||
# long random value; never expose it through NEXT_PUBLIC_* variables.
|
|
||||||
PASCAL_PROXY_SECRET=
|
|
||||||
|
|
||||||
# RUXA / video callback.
|
# RUXA / video callback.
|
||||||
RUXA_CALLBACK_URL=
|
RUXA_CALLBACK_URL=
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -11,8 +11,6 @@ coverage
|
|||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
.next/
|
.next/
|
||||||
.next-build/
|
|
||||||
.next-previous/
|
|
||||||
out/
|
out/
|
||||||
build
|
build
|
||||||
|
|
||||||
@ -89,14 +87,8 @@ checkpoints/
|
|||||||
# 第三方参考源码(不提交到git)
|
# 第三方参考源码(不提交到git)
|
||||||
reference-sources/
|
reference-sources/
|
||||||
|
|
||||||
# Legacy external Pascal Editor checkout (the managed source is under services/).
|
# 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
|
||||||
|
|||||||
@ -144,10 +144,18 @@ log.error("Operation failed", error, { userId: "123" });
|
|||||||
#### 7. 修复 service 层错误吞没
|
#### 7. 修复 service 层错误吞没
|
||||||
**风险等级**: 🟡 中等风险
|
**风险等级**: 🟡 中等风险
|
||||||
**改动**:
|
**改动**:
|
||||||
|
- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误
|
||||||
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
|
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
|
||||||
|
|
||||||
**潜在问题**:
|
**潜在问题**:
|
||||||
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
|
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
|
||||||
|
- 如果有其他代码依赖 ai-cad service 的错误格式,可能受影响
|
||||||
|
|
||||||
|
**测试建议**:
|
||||||
|
```bash
|
||||||
|
# 触发一个 AI-CAD 错误(如无效的 workspace ID)
|
||||||
|
# 检查日志是否包含完整错误堆栈
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -117,14 +117,10 @@ const severityLabels: Record<Severity, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const severityClassNames: Record<Severity, string> = {
|
const severityClassNames: Record<Severity, string> = {
|
||||||
info:
|
info: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-300",
|
watch: "border-amber-200 bg-amber-50 text-amber-700",
|
||||||
watch:
|
high: "border-orange-200 bg-orange-50 text-orange-700",
|
||||||
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/45 dark:text-amber-300",
|
critical: "border-red-200 bg-red-50 text-red-700",
|
||||||
high:
|
|
||||||
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-300",
|
|
||||||
critical:
|
|
||||||
"border-red-200 bg-red-50 text-red-700 dark:border-red-800/70 dark:bg-red-950/45 dark:text-red-300",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined) {
|
function formatNumber(value: number | null | undefined) {
|
||||||
@ -149,10 +145,10 @@ function formatTime(value: string | null | undefined) {
|
|||||||
|
|
||||||
function getStatusClassName(statusCode: number | null) {
|
function getStatusClassName(statusCode: number | null) {
|
||||||
if (!statusCode) return "text-muted-foreground";
|
if (!statusCode) return "text-muted-foreground";
|
||||||
if (statusCode >= 500) return "text-red-700 dark:text-red-300";
|
if (statusCode >= 500) return "text-red-700";
|
||||||
if (statusCode >= 400) return "text-orange-700 dark:text-orange-300";
|
if (statusCode >= 400) return "text-orange-700";
|
||||||
if (statusCode >= 300) return "text-sky-700 dark:text-sky-300";
|
if (statusCode >= 300) return "text-sky-700";
|
||||||
return "text-emerald-700 dark:text-emerald-300";
|
return "text-emerald-700";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccessSeverityBadge({ severity }: { severity: Severity }) {
|
function AccessSeverityBadge({ severity }: { severity: Severity }) {
|
||||||
@ -264,8 +260,7 @@ export function AccessSecurityDashboard() {
|
|||||||
value: formatNumber(data?.overview.totalEvents),
|
value: formatNumber(data?.overview.totalEvents),
|
||||||
note: `${hours} 小时窗口`,
|
note: `${hours} 小时窗口`,
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
className:
|
className: "border-slate-200 bg-slate-50 text-slate-900",
|
||||||
"border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-700/70 dark:bg-slate-900/70 dark:text-slate-100",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "ips",
|
key: "ips",
|
||||||
@ -273,8 +268,7 @@ export function AccessSecurityDashboard() {
|
|||||||
value: formatNumber(data?.overview.uniqueIps),
|
value: formatNumber(data?.overview.uniqueIps),
|
||||||
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
|
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
|
||||||
icon: Globe2,
|
icon: Globe2,
|
||||||
className:
|
className: "border-sky-200 bg-sky-50 text-sky-900",
|
||||||
"border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800/70 dark:bg-sky-950/45 dark:text-sky-100",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "abnormal",
|
key: "abnormal",
|
||||||
@ -282,8 +276,7 @@ export function AccessSecurityDashboard() {
|
|||||||
value: formatNumber(data?.overview.abnormalEvents),
|
value: formatNumber(data?.overview.abnormalEvents),
|
||||||
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
|
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
|
||||||
icon: ShieldAlert,
|
icon: ShieldAlert,
|
||||||
className:
|
className: "border-orange-200 bg-orange-50 text-orange-900",
|
||||||
"border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-100",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "login",
|
key: "login",
|
||||||
@ -291,8 +284,7 @@ export function AccessSecurityDashboard() {
|
|||||||
value: formatNumber(data?.overview.loginSuccessEvents),
|
value: formatNumber(data?.overview.loginSuccessEvents),
|
||||||
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
|
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
|
||||||
icon: LogIn,
|
icon: LogIn,
|
||||||
className:
|
className: "border-emerald-200 bg-emerald-50 text-emerald-900",
|
||||||
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-100",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -403,7 +395,7 @@ export function AccessSecurityDashboard() {
|
|||||||
: 0;
|
: 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200 dark:bg-slate-800"
|
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200"
|
||||||
key={item.hour}
|
key={item.hour}
|
||||||
style={{ height: `${height}%` }}
|
style={{ height: `${height}%` }}
|
||||||
>
|
>
|
||||||
@ -463,26 +455,26 @@ export function AccessSecurityDashboard() {
|
|||||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{activeBlocklistEntries.map((entry) => (
|
{activeBlocklistEntries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3 dark:border-red-900/60 dark:bg-red-950/30"
|
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3"
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Ban className="h-4 w-4 text-red-600 dark:text-red-400" />
|
<Ban className="h-4 w-4 text-red-600" />
|
||||||
<span className="truncate font-mono font-semibold text-sm text-red-900 dark:text-red-200">
|
<span className="truncate font-mono font-semibold text-sm text-red-900">
|
||||||
{entry.ip}
|
{entry.ip}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs dark:text-red-300/75">
|
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs">
|
||||||
{entry.reason || "未填写原因"}
|
{entry.reason || "未填写原因"}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-red-700/70 text-xs dark:text-red-400/70">
|
<div className="mt-2 text-red-700/70 text-xs">
|
||||||
{formatTime(entry.updatedAt)}
|
{formatTime(entry.updatedAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-950/60"
|
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50"
|
||||||
onClick={() => updateBlockEntry(entry, false)}
|
onClick={() => updateBlockEntry(entry, false)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -536,7 +528,7 @@ export function AccessSecurityDashboard() {
|
|||||||
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
|
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
|
||||||
(reason) => (
|
(reason) => (
|
||||||
<Badge
|
<Badge
|
||||||
className="border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
className="border-zinc-200 bg-zinc-50 text-zinc-700"
|
||||||
key={reason}
|
key={reason}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@ -592,9 +584,9 @@ export function AccessSecurityDashboard() {
|
|||||||
{formatNumber(item.count)}
|
{formatNumber(item.count)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-zinc-900 dark:bg-zinc-200"
|
className="h-full rounded-full bg-zinc-900"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
|
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
|
||||||
}}
|
}}
|
||||||
@ -629,7 +621,7 @@ export function AccessSecurityDashboard() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(data?.rules || []).map((rule) => (
|
{(data?.rules || []).map((rule) => (
|
||||||
<Badge
|
<Badge
|
||||||
className="border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
className="border-zinc-200 bg-zinc-50 text-zinc-600"
|
||||||
key={rule}
|
key={rule}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@ -642,7 +634,7 @@ export function AccessSecurityDashboard() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto rounded-lg border">
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
<div className="min-w-[980px]">
|
<div className="min-w-[980px]">
|
||||||
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs dark:bg-zinc-900/70">
|
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs">
|
||||||
<div>时间</div>
|
<div>时间</div>
|
||||||
<div>IP</div>
|
<div>IP</div>
|
||||||
<div>状态</div>
|
<div>状态</div>
|
||||||
|
|||||||
@ -11,9 +11,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ADMIN_SETTINGS_NAV_ITEMS } from "../_lib/settings-navigation";
|
import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation";
|
||||||
import type { AdminSettingsNavItem } from "../_lib/types";
|
import type { AdminSettingsNavItem } from "../_lib/types";
|
||||||
|
|
||||||
|
const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items);
|
||||||
|
|
||||||
function SettingsQuickAction({
|
function SettingsQuickAction({
|
||||||
activeTab,
|
activeTab,
|
||||||
item,
|
item,
|
||||||
@ -71,13 +73,9 @@ export function AdminSettingsNavigation({
|
|||||||
onTabChange: (value: string) => void;
|
onTabChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const activeItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
const activeItem = ALL_SETTINGS_ITEMS.find((item) => item.value === activeTab);
|
||||||
(item) => item.value === activeTab
|
|
||||||
);
|
|
||||||
const handleSelectChange = (value: string) => {
|
const handleSelectChange = (value: string) => {
|
||||||
const nextItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
const nextItem = ALL_SETTINGS_ITEMS.find((item) => item.value === value);
|
||||||
(item) => item.value === value
|
|
||||||
);
|
|
||||||
if (nextItem?.href) {
|
if (nextItem?.href) {
|
||||||
router.push(nextItem.href);
|
router.push(nextItem.href);
|
||||||
return;
|
return;
|
||||||
@ -97,7 +95,7 @@ export function AdminSettingsNavigation({
|
|||||||
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
|
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
|
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||||
<SelectItem key={item.value} value={item.value}>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@ -121,7 +119,7 @@ export function AdminSettingsNavigation({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
|
<div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
|
||||||
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
|
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||||
<SettingsQuickAction
|
<SettingsQuickAction
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
item={item}
|
item={item}
|
||||||
|
|||||||
@ -75,8 +75,6 @@ export function LocalAiImagePipelineSections({
|
|||||||
aiWashConfig,
|
aiWashConfig,
|
||||||
setAiWashConfig,
|
setAiWashConfig,
|
||||||
aiWashModeIconUploading,
|
aiWashModeIconUploading,
|
||||||
canvasLocalEditConfig,
|
|
||||||
setCanvasLocalEditConfig,
|
|
||||||
extendConfig,
|
extendConfig,
|
||||||
setExtendConfig,
|
setExtendConfig,
|
||||||
watermarkRemoveConfig,
|
watermarkRemoveConfig,
|
||||||
@ -105,7 +103,6 @@ export function LocalAiImagePipelineSections({
|
|||||||
handleMoveEnhanceProPreset,
|
handleMoveEnhanceProPreset,
|
||||||
handleAiWashModeIconUpload,
|
handleAiWashModeIconUpload,
|
||||||
handleSaveAiWashConfig,
|
handleSaveAiWashConfig,
|
||||||
handleSaveCanvasLocalEditConfig,
|
|
||||||
handleSaveExtendConfig,
|
handleSaveExtendConfig,
|
||||||
handleSaveWatermarkRemoveConfig,
|
handleSaveWatermarkRemoveConfig,
|
||||||
handleSaveAngleEditConfig,
|
handleSaveAngleEditConfig,
|
||||||
@ -159,15 +156,12 @@ export function LocalAiImagePipelineSections({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label:
|
label:
|
||||||
rmbgConfig.provider === "vip98k"
|
rmbgConfig.apiKey ||
|
||||||
? "服务商 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
|
||||||
@ -182,26 +176,7 @@ 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-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<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"
|
||||||
@ -288,13 +263,6 @@ 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" && (
|
||||||
<>
|
<>
|
||||||
@ -500,7 +468,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
|
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
expanded={isLocalAiSectionExpanded("enhance")}
|
expanded={isLocalAiSectionExpanded("enhance")}
|
||||||
@ -510,21 +478,17 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionSummary
|
<LocalAiSectionSummary
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: enhanceConfig.provider === "vip98k"
|
label: enhanceConfig.useBackupApi
|
||||||
? "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",
|
||||||
@ -534,13 +498,6 @@ 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">
|
||||||
@ -789,8 +746,6 @@ 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" /> 保存配置
|
||||||
@ -1466,112 +1421,6 @@ 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={
|
||||||
<>
|
<>
|
||||||
@ -2366,7 +2215,7 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
|
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
expanded={isLocalAiSectionExpanded("extend")}
|
expanded={isLocalAiSectionExpanded("extend")}
|
||||||
@ -2376,14 +2225,8 @@ export function LocalAiImagePipelineSections({
|
|||||||
<LocalAiSectionSummary
|
<LocalAiSectionSummary
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label:
|
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
|
||||||
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
tone: extendConfig.apiKey ? "enabled" : "warning",
|
||||||
? "Key 已配置"
|
|
||||||
: "Key 待配置",
|
|
||||||
tone:
|
|
||||||
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
|
||||||
? "enabled"
|
|
||||||
: "warning",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
||||||
@ -2394,12 +2237,6 @@ 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
|
||||||
@ -2416,7 +2253,6 @@ 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>
|
||||||
|
|||||||
@ -916,8 +916,8 @@ export function LocalAiModel3dSections({
|
|||||||
<LocalAiSectionShell
|
<LocalAiSectionShell
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI
|
配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
|
||||||
提供商配置。
|
服务,保存后写入数据库 AI 提供商配置。
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
expanded={isLocalAiSectionExpanded("kittycad-cad")}
|
expanded={isLocalAiSectionExpanded("kittycad-cad")}
|
||||||
@ -1003,7 +1003,7 @@ export function LocalAiModel3dSections({
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label className="text-sm">启用 KittyCAD CAD</Label>
|
<Label className="text-sm">启用 KittyCAD CAD</Label>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
|
关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@ -12,7 +12,6 @@ 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,
|
||||||
|
|||||||
@ -114,7 +114,3 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ADMIN_SETTINGS_NAV_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap(
|
|
||||||
(group) => group.items
|
|
||||||
);
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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,
|
||||||
@ -89,10 +88,6 @@ 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"
|
||||||
@ -249,7 +244,7 @@ export type EnhanceProKeyRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type RmbgConfig = {
|
export type RmbgConfig = {
|
||||||
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
|
provider: "rmbg" | "photoroom" | "aoscdn";
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
photoroomApiKey: string;
|
photoroomApiKey: string;
|
||||||
@ -261,7 +256,6 @@ export type RmbgConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type EnhanceConfig = {
|
export type EnhanceConfig = {
|
||||||
provider: "vip98k" | "legacy";
|
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
type: string;
|
type: string;
|
||||||
@ -274,7 +268,6 @@ export type EnhanceConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExtendConfig = {
|
export type ExtendConfig = {
|
||||||
provider: "vip98k" | "dashscope";
|
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
xScale: string;
|
xScale: string;
|
||||||
yScale: string;
|
yScale: string;
|
||||||
@ -334,8 +327,6 @@ 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;
|
||||||
@ -378,7 +369,6 @@ 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
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Plus, Save, Trash2, Wand2 } from "lucide-react";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { ArrowLeft, Plus, Save, Trash2, Wand2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -25,7 +25,6 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { fetcher } from "@/lib/utils";
|
import { fetcher } from "@/lib/utils";
|
||||||
import { AdminSettingsNavigation } from "../_components/admin-settings-navigation";
|
|
||||||
import {
|
import {
|
||||||
STYLE_PRO_CONFIG_KEY,
|
STYLE_PRO_CONFIG_KEY,
|
||||||
defaultStyleProConfig,
|
defaultStyleProConfig,
|
||||||
@ -323,7 +322,6 @@ function CustomFieldEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StyleProSettingsClient() {
|
export function StyleProSettingsClient() {
|
||||||
const router = useRouter();
|
|
||||||
const { data, mutate } = useSWR<SettingsResponse>(
|
const { data, mutate } = useSWR<SettingsResponse>(
|
||||||
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
|
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
|
||||||
fetcher
|
fetcher
|
||||||
@ -563,15 +561,17 @@ export function StyleProSettingsClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-workspace w-full px-3 py-3 sm:px-4 xl:px-5">
|
<main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
|
||||||
<AdminSettingsNavigation
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
activeTab="style-pro"
|
|
||||||
onTabChange={(value) => router.push(`/settings?tab=${value}`)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="mt-6 flex w-full flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link href="/settings">
|
||||||
|
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
返回设置
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<h1 className="font-semibold text-xl">风格化 PRO 设置</h1>
|
<h1 className="font-semibold text-xl">风格化 PRO 设置</h1>
|
||||||
<p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5">
|
<p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5">
|
||||||
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
|
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
|
||||||
@ -592,7 +592,7 @@ export function StyleProSettingsClient() {
|
|||||||
{saving ? "保存中" : "保存配置"}
|
{saving ? "保存中" : "保存配置"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -1193,7 +1193,6 @@ export function StyleProSettingsClient() {
|
|||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,8 +34,6 @@ import {
|
|||||||
type VideoCanvasFunctionConfig,
|
type VideoCanvasFunctionConfig,
|
||||||
type VideoCanvasFunctionDefinition,
|
type VideoCanvasFunctionDefinition,
|
||||||
type VideoCanvasEffectPreset,
|
type VideoCanvasEffectPreset,
|
||||||
type VideoCanvasMotionPreset,
|
|
||||||
type VideoCanvasRolePreset,
|
|
||||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||||
defaultVideoCanvasFunctionConfig,
|
defaultVideoCanvasFunctionConfig,
|
||||||
normalizeVideoCanvasFunctionConfig,
|
normalizeVideoCanvasFunctionConfig,
|
||||||
@ -244,43 +242,6 @@ export function VideoCanvasSettingsClient() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMotionPreset = (
|
|
||||||
index: number,
|
|
||||||
updates: Partial<VideoCanvasMotionPreset>,
|
|
||||||
) => {
|
|
||||||
setConfig((current) => ({
|
|
||||||
...current,
|
|
||||||
motionPresets: current.motionPresets.map((preset, presetIndex) =>
|
|
||||||
presetIndex === index ? { ...preset, ...updates } : preset,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMotionPreset = () => {
|
|
||||||
const createdAt = Date.now();
|
|
||||||
setConfig((current) => ({
|
|
||||||
...current,
|
|
||||||
motionPresets: [
|
|
||||||
...current.motionPresets,
|
|
||||||
{
|
|
||||||
id: `motion-${createdAt}`,
|
|
||||||
name: "新运镜预设",
|
|
||||||
previewUrl: "/video-canvas/effects/multi-angle.webp",
|
|
||||||
prompt: "镜头平稳移动,保持主体和空间关系清晰。",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMotionPreset = (index: number) => {
|
|
||||||
setConfig((current) => ({
|
|
||||||
...current,
|
|
||||||
motionPresets: current.motionPresets.filter(
|
|
||||||
(_, presetIndex) => presetIndex !== index,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEffectPreset = () => {
|
const addEffectPreset = () => {
|
||||||
setConfig((current) => ({
|
setConfig((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@ -307,60 +268,6 @@ export function VideoCanvasSettingsClient() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRolePreset = (
|
|
||||||
index: number,
|
|
||||||
updates: Partial<VideoCanvasRolePreset>,
|
|
||||||
) => {
|
|
||||||
setConfig((current) => ({
|
|
||||||
...current,
|
|
||||||
rolePresets: current.rolePresets.map((preset, presetIndex) =>
|
|
||||||
presetIndex === index ? { ...preset, ...updates } : preset,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRolePreset = () => {
|
|
||||||
const createdAt = Date.now();
|
|
||||||
setConfig((current) => ({
|
|
||||||
...current,
|
|
||||||
rolePresets: [
|
|
||||||
...current.rolePresets,
|
|
||||||
{
|
|
||||||
id: `role-${createdAt}`,
|
|
||||||
assetId: `public-role:role-${createdAt}`,
|
|
||||||
name: "新角色预设",
|
|
||||||
accent: "#525252",
|
|
||||||
description: "管理员配置的公共角色素材。",
|
|
||||||
prompt: "保持角色身份、五官、发型、服装和体型在所有视频镜头中一致。",
|
|
||||||
tags: "角色",
|
|
||||||
compliant: true,
|
|
||||||
images: {
|
|
||||||
expression: "",
|
|
||||||
face: "",
|
|
||||||
full: "/video-canvas/effects/product-light-sweep.webp",
|
|
||||||
threeView: "",
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
age: "青年",
|
|
||||||
build: "匀称",
|
|
||||||
culture: "东亚",
|
|
||||||
era: "现代",
|
|
||||||
gender: "中性",
|
|
||||||
hairColor: "黑发",
|
|
||||||
species: "人类",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRolePreset = (index: number) => {
|
|
||||||
setConfig((current) => ({
|
|
||||||
...current,
|
|
||||||
rolePresets: current.rolePresets.filter((_, presetIndex) => presetIndex !== index),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@ -463,91 +370,6 @@ export function VideoCanvasSettingsClient() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<section className="space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold tracking-tight">视频运镜素材预设</h2>
|
|
||||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
|
||||||
视频节点“运镜广场”的名称、预览图和插入提示词都从这里读取。点击运镜卡会按竞品规则把提示词追加到用户输入框,允许组合和重复使用。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={addMotionPreset} size="sm" type="button" variant="outline">
|
|
||||||
<Plus className="mr-1.5 h-4 w-4" />
|
|
||||||
添加运镜
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{config.motionPresets.map((preset, index) => (
|
|
||||||
<details
|
|
||||||
className="group rounded-lg border border-border bg-card px-4 py-3"
|
|
||||||
key={`${preset.id}-${index}`}
|
|
||||||
>
|
|
||||||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
className="h-14 w-14 shrink-0 rounded-md object-cover"
|
|
||||||
src={preset.previewUrl}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-medium">{preset.name}</div>
|
|
||||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
|
||||||
{preset.prompt || "未设置插入提示词"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
aria-label={`删除运镜 ${preset.name}`}
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
removeMotionPreset(index);
|
|
||||||
}}
|
|
||||||
size="icon"
|
|
||||||
title={`删除运镜 ${preset.name}`}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`motion-name-${index}`}>运镜名称</Label>
|
|
||||||
<Input
|
|
||||||
id={`motion-name-${index}`}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateMotionPreset(index, { name: event.target.value })
|
|
||||||
}
|
|
||||||
value={preset.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`motion-preview-${index}`}>预览图地址</Label>
|
|
||||||
<Input
|
|
||||||
id={`motion-preview-${index}`}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateMotionPreset(index, { previewUrl: event.target.value })
|
|
||||||
}
|
|
||||||
value={preset.previewUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor={`motion-prompt-${index}`}>插入提示词</Label>
|
|
||||||
<Textarea
|
|
||||||
id={`motion-prompt-${index}`}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateMotionPreset(index, { prompt: event.target.value })
|
|
||||||
}
|
|
||||||
value={preset.prompt}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@ -667,150 +489,6 @@ export function VideoCanvasSettingsClient() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold tracking-tight">视频角色素材预设</h2>
|
|
||||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
|
||||||
视频节点“角色库”的公共角色、Seedance 合规资产 ID、四类角色图和隐藏生成提示词都从这里读取。角色引用通过画布连线保存,不会改写用户输入框。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={addRolePreset} size="sm" type="button" variant="outline">
|
|
||||||
<Plus className="mr-1.5 h-4 w-4" />
|
|
||||||
添加角色
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{config.rolePresets.map((preset, index) => (
|
|
||||||
<details
|
|
||||||
className="group rounded-lg border border-border bg-card px-4 py-3"
|
|
||||||
key={`${preset.id}-${index}`}
|
|
||||||
>
|
|
||||||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
className="h-14 w-11 shrink-0 rounded-md object-cover object-top"
|
|
||||||
src={preset.images.full}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-medium">{preset.name}</div>
|
|
||||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
|
||||||
{preset.assetId} · {preset.tags || "未设置标签"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
aria-label={`删除角色 ${preset.name}`}
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
removeRolePreset(index);
|
|
||||||
}}
|
|
||||||
size="icon"
|
|
||||||
title={`删除角色 ${preset.name}`}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`role-name-${index}`}>角色名称</Label>
|
|
||||||
<Input
|
|
||||||
id={`role-name-${index}`}
|
|
||||||
onChange={(event) => updateRolePreset(index, { name: event.target.value })}
|
|
||||||
value={preset.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`role-asset-${index}`}>合规资产 ID</Label>
|
|
||||||
<Input
|
|
||||||
id={`role-asset-${index}`}
|
|
||||||
onChange={(event) => updateRolePreset(index, { assetId: event.target.value })}
|
|
||||||
value={preset.assetId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor={`role-tags-${index}`}>展示标签</Label>
|
|
||||||
<Input
|
|
||||||
id={`role-tags-${index}`}
|
|
||||||
onChange={(event) => updateRolePreset(index, { tags: event.target.value })}
|
|
||||||
value={preset.tags}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor={`role-description-${index}`}>角色说明</Label>
|
|
||||||
<Textarea
|
|
||||||
id={`role-description-${index}`}
|
|
||||||
onChange={(event) => updateRolePreset(index, { description: event.target.value })}
|
|
||||||
value={preset.description}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor={`role-prompt-${index}`}>生成提示词 / 身份一致性规则</Label>
|
|
||||||
<Textarea
|
|
||||||
id={`role-prompt-${index}`}
|
|
||||||
onChange={(event) => updateRolePreset(index, { prompt: event.target.value })}
|
|
||||||
value={preset.prompt}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{([
|
|
||||||
["full", "角色立绘"],
|
|
||||||
["face", "脸部近景"],
|
|
||||||
["expression", "表情参考"],
|
|
||||||
["threeView", "三视图"],
|
|
||||||
] as const).map(([key, label]) => (
|
|
||||||
<div className="space-y-2" key={key}>
|
|
||||||
<Label htmlFor={`role-${key}-${index}`}>{label}地址</Label>
|
|
||||||
<Input
|
|
||||||
id={`role-${key}-${index}`}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateRolePreset(index, {
|
|
||||||
images: { ...preset.images, [key]: event.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
value={preset.images[key]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{([
|
|
||||||
["gender", "性别"],
|
|
||||||
["age", "年龄段"],
|
|
||||||
["species", "种族"],
|
|
||||||
["era", "时代"],
|
|
||||||
["culture", "文化区域"],
|
|
||||||
["build", "体型"],
|
|
||||||
["hairColor", "发色"],
|
|
||||||
] as const).map(([key, label]) => (
|
|
||||||
<div className="space-y-2" key={key}>
|
|
||||||
<Label htmlFor={`role-filter-${key}-${index}`}>{label}</Label>
|
|
||||||
<Input
|
|
||||||
id={`role-filter-${key}-${index}`}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateRolePreset(index, {
|
|
||||||
filters: { ...preset.filters, [key]: event.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
value={preset.filters[key]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="flex items-center gap-3 md:col-span-2">
|
|
||||||
<Switch
|
|
||||||
checked={preset.compliant}
|
|
||||||
id={`role-compliant-${index}`}
|
|
||||||
onCheckedChange={(checked) => updateRolePreset(index, { compliant: checked })}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={`role-compliant-${index}`}>可在 Seedance2.0 合规素材库中使用</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
|
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
|
||||||
|
|||||||
@ -1,44 +1,7 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
|
||||||
import path from "node:path";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
|
|
||||||
const ZOO_MODELING_APP_SRC =
|
|
||||||
"/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "HiCAD",
|
|
||||||
};
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export default async function AiCadPage() {
|
export default function AiCadPage() {
|
||||||
const session = await auth();
|
return <AiCadWorkbench />;
|
||||||
if (!session?.user?.id) {
|
|
||||||
redirect("/login?callbackUrl=%2Fai-cad");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isReady = existsSync(
|
|
||||||
path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isReady) {
|
|
||||||
redirect(ZOO_MODELING_APP_SRC);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex h-svh w-full items-center justify-center bg-[#10100e] p-6 text-[#f8f6ef]">
|
|
||||||
<section className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
|
|
||||||
<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>
|
|
||||||
生成官方前端,然后刷新本页。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,10 +15,6 @@ 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",
|
||||||
@ -247,10 +243,6 @@ 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,10 +524,6 @@ 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" ||
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import type {
|
|||||||
ResolvedTargetAssets,
|
ResolvedTargetAssets,
|
||||||
} from "@/lib/ai/context-memory/types";
|
} from "@/lib/ai/context-memory/types";
|
||||||
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
|
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
|
||||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.stream");
|
const chatApiLogger = createLogger("chat.stream");
|
||||||
@ -188,10 +187,6 @@ 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" ||
|
||||||
@ -426,19 +421,6 @@ export function writeCanvasViewOperationChunk(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeCanvasCapabilityCommandChunk(
|
|
||||||
writer: StreamWriter,
|
|
||||||
command: CanvasCapabilityClientCommand,
|
|
||||||
label: string
|
|
||||||
) {
|
|
||||||
writeStructuredDataChunk(
|
|
||||||
writer,
|
|
||||||
"data-canvasCapabilityCommand",
|
|
||||||
command,
|
|
||||||
label
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentTargetLockRefPreview = {
|
type AgentTargetLockRefPreview = {
|
||||||
url: string;
|
url: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
@ -90,10 +90,6 @@ import {
|
|||||||
hasCanvasAgentPanViewIntent,
|
hasCanvasAgentPanViewIntent,
|
||||||
} from "@/lib/canvas/agent-view-pan";
|
} from "@/lib/canvas/agent-view-pan";
|
||||||
import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback";
|
import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback";
|
||||||
import {
|
|
||||||
createCanvasCapabilityTools,
|
|
||||||
getCanvasCapabilityToolNames,
|
|
||||||
} from "@/lib/canvas/capabilities/ai-sdk-tools";
|
|
||||||
import {
|
import {
|
||||||
getCanvasAgentOperationErrorMessage,
|
getCanvasAgentOperationErrorMessage,
|
||||||
normalizeCanvasAgentOperationErrorCode,
|
normalizeCanvasAgentOperationErrorCode,
|
||||||
@ -396,7 +392,6 @@ import {
|
|||||||
writeAgentTargetLockChunk,
|
writeAgentTargetLockChunk,
|
||||||
writeAssistantTextChunk,
|
writeAssistantTextChunk,
|
||||||
writeCanvasMediaChunk,
|
writeCanvasMediaChunk,
|
||||||
writeCanvasCapabilityCommandChunk,
|
|
||||||
writeCanvasOperationChunk,
|
writeCanvasOperationChunk,
|
||||||
writeCanvasViewOperationChunk,
|
writeCanvasViewOperationChunk,
|
||||||
writeImageGenerationStatusChunk,
|
writeImageGenerationStatusChunk,
|
||||||
@ -5745,53 +5740,44 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
const assistantMessageId = generateUUID();
|
const assistantMessageId = generateUUID();
|
||||||
|
|
||||||
try {
|
await saveMessages({
|
||||||
await saveMessages({
|
messages: [
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: assistantMessageId,
|
|
||||||
chatId: id,
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "text", text: assistantText }],
|
|
||||||
attachments: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
videoStorageStatus: null,
|
|
||||||
temporaryVideoUrl: null,
|
|
||||||
permanentVideoUrl: null,
|
|
||||||
archivedAt: null,
|
|
||||||
videoMetadata: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await saveGeneratedImages(
|
|
||||||
assistantText,
|
|
||||||
session.user.id,
|
|
||||||
id,
|
|
||||||
assistantMessageId,
|
|
||||||
params.promptForPersistence || baseReplacementPrompt,
|
|
||||||
resolvedModelRef,
|
|
||||||
{
|
{
|
||||||
sourceAssetIds,
|
id: assistantMessageId,
|
||||||
sourceImageUrls: params.sourceImageUrls,
|
chatId: id,
|
||||||
relationType: "edited_from",
|
role: "assistant",
|
||||||
sourceType: "detail_replacement",
|
parts: [{ type: "text", text: assistantText }],
|
||||||
projectId: contextPayload?.projectId || null,
|
attachments: [],
|
||||||
userPrompt: requestTextContent,
|
createdAt: new Date(),
|
||||||
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
videoStorageStatus: null,
|
||||||
requestedImageCount: 1,
|
temporaryVideoUrl: null,
|
||||||
aspectRatio: resolvedAspectRatio,
|
permanentVideoUrl: null,
|
||||||
imageSize: persistedImageSize,
|
archivedAt: null,
|
||||||
|
videoMetadata: null,
|
||||||
},
|
},
|
||||||
);
|
],
|
||||||
} catch (persistError) {
|
});
|
||||||
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
|
|
||||||
chatId: id,
|
await saveGeneratedImages(
|
||||||
referenceId: params.reference.id,
|
assistantText,
|
||||||
referenceLabel: params.reference.label,
|
session.user.id,
|
||||||
error: persistError,
|
id,
|
||||||
});
|
assistantMessageId,
|
||||||
}
|
params.promptForPersistence || baseReplacementPrompt,
|
||||||
|
resolvedModelRef,
|
||||||
|
{
|
||||||
|
sourceAssetIds,
|
||||||
|
sourceImageUrls: params.sourceImageUrls,
|
||||||
|
relationType: "edited_from",
|
||||||
|
sourceType: "detail_replacement",
|
||||||
|
projectId: contextPayload?.projectId || null,
|
||||||
|
userPrompt: requestTextContent,
|
||||||
|
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
||||||
|
requestedImageCount: 1,
|
||||||
|
aspectRatio: resolvedAspectRatio,
|
||||||
|
imageSize: persistedImageSize,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
@ -5819,7 +5805,6 @@ 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([
|
||||||
@ -5840,29 +5825,9 @@ 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,
|
||||||
@ -5877,12 +5842,10 @@ 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,
|
||||||
@ -5992,12 +5955,7 @@ 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 {
|
||||||
generationStartedAt = Date.now();
|
|
||||||
const result = await generateCanvasImage({
|
const result = await generateCanvasImage({
|
||||||
prompt: replacementPrompt,
|
prompt: replacementPrompt,
|
||||||
negativePrompt: effectiveImageParams?.negativePrompt,
|
negativePrompt: effectiveImageParams?.negativePrompt,
|
||||||
@ -6037,7 +5995,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: 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) {
|
||||||
@ -6047,7 +6004,6 @@ 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,
|
||||||
@ -6061,11 +6017,7 @@ 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,
|
||||||
@ -6083,22 +6035,6 @@ 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,
|
||||||
@ -6110,16 +6046,9 @@ 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({
|
||||||
@ -6139,30 +6068,6 @@ 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();
|
||||||
@ -7811,17 +7716,6 @@ export async function POST(request: Request) {
|
|||||||
const directAgentExecution = directAgentToolContext?.execution || null;
|
const directAgentExecution = directAgentToolContext?.execution || null;
|
||||||
const directAgentToolTargetImageUrls = directAgentToolContext?.targetImageUrls || [];
|
const directAgentToolTargetImageUrls = directAgentToolContext?.targetImageUrls || [];
|
||||||
const directAgentToolTargetImageUrl = directAgentToolContext?.targetImageUrl || null;
|
const directAgentToolTargetImageUrl = directAgentToolContext?.targetImageUrl || null;
|
||||||
const selectedCanvasImageNodeIds = (
|
|
||||||
requestCanvasAgentContext?.selectedNodes || []
|
|
||||||
)
|
|
||||||
.filter((node) => node.type === "image")
|
|
||||||
.map((node) => node.nodeId)
|
|
||||||
.filter(Boolean);
|
|
||||||
const shouldUseCanvasCapabilityToolLoop = Boolean(
|
|
||||||
contextPayload?.source === "canvas" &&
|
|
||||||
contextPayload.projectId &&
|
|
||||||
selectedCanvasImageNodeIds.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldRunAgentConversation &&
|
shouldRunAgentConversation &&
|
||||||
@ -8371,7 +8265,6 @@ export async function POST(request: Request) {
|
|||||||
shouldRunAgentConversation &&
|
shouldRunAgentConversation &&
|
||||||
!smartAutoNeedsClarification &&
|
!smartAutoNeedsClarification &&
|
||||||
directAgentExecution &&
|
directAgentExecution &&
|
||||||
!shouldUseCanvasCapabilityToolLoop &&
|
|
||||||
isDirectAgentImageEditToolName(directAgentExecution.toolName)
|
isDirectAgentImageEditToolName(directAgentExecution.toolName)
|
||||||
) {
|
) {
|
||||||
const directAgentToolCapability = directAgentExecution.capability;
|
const directAgentToolCapability = directAgentExecution.capability;
|
||||||
@ -8818,14 +8711,6 @@ export async function POST(request: Request) {
|
|||||||
agentIdentityInstruction,
|
agentIdentityInstruction,
|
||||||
recentDialogueSystemContext,
|
recentDialogueSystemContext,
|
||||||
baseConversationSystemPrompt,
|
baseConversationSystemPrompt,
|
||||||
shouldUseCanvasCapabilityToolLoop
|
|
||||||
? [
|
|
||||||
"当前请求来自无限画布,且用户已明确选中图片节点。",
|
|
||||||
"涉及选中图片的识别、编辑、生成、3D、改名、批注或删除时,必须调用已提供的画布能力工具,不得只用文字声称完成。",
|
|
||||||
"可以连续调用多个工具;下一步要处理上一步结果时,把上一步返回的 createdNodeIds 作为 nodeIds。",
|
|
||||||
"只有工具返回成功后才能告诉用户已完成;若工具返回 interactive,说明已在画布打开对应交互界面并等待用户操作。",
|
|
||||||
].join("\n")
|
|
||||||
: "",
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
@ -9219,10 +9104,6 @@ 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,
|
||||||
@ -9614,10 +9495,6 @@ 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",
|
||||||
@ -9840,14 +9717,6 @@ 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"
|
||||||
@ -9951,10 +9820,6 @@ 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,
|
||||||
@ -9998,14 +9863,6 @@ 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"
|
||||||
@ -10392,10 +10249,6 @@ 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,
|
||||||
@ -10440,14 +10293,6 @@ 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"
|
||||||
@ -10661,7 +10506,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
|
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
|
||||||
if (
|
if (
|
||||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video", "vip98k"].includes((languageModel as any).provider) &&
|
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].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
|
||||||
@ -10670,8 +10515,7 @@ 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({
|
||||||
@ -11094,52 +10938,6 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.info("[Smart Auto Mode] 🚫 generateVideo tool disabled (manual video mode not selected)");
|
chatApiLogger.info("[Smart Auto Mode] 🚫 generateVideo tool disabled (manual video mode not selected)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
contextPayload?.source === "canvas" &&
|
|
||||||
contextPayload.projectId &&
|
|
||||||
selectedCanvasImageNodeIds.length > 0
|
|
||||||
) {
|
|
||||||
const canvasCapabilityTools = await createCanvasCapabilityTools({
|
|
||||||
projectId: contextPayload.projectId,
|
|
||||||
selectedNodeIds: selectedCanvasImageNodeIds,
|
|
||||||
userId: session.user.id,
|
|
||||||
requestHeaders: request.headers,
|
|
||||||
requestUrl: request.url,
|
|
||||||
defaultModelId: finalModelId,
|
|
||||||
destructiveConfirmed: isCanvasOperationConfirmText(requestTextContent),
|
|
||||||
callbacks: {
|
|
||||||
onClientCommand: (command, capability) => {
|
|
||||||
writeCanvasCapabilityCommandChunk(
|
|
||||||
dataStream as unknown as StreamWriter,
|
|
||||||
command,
|
|
||||||
`canvas-capability:${capability.id}:client-command`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onResult: (runtimeResult, capability) => {
|
|
||||||
if (!runtimeResult.operation) return;
|
|
||||||
writeCanvasOperationChunk(
|
|
||||||
dataStream as unknown as StreamWriter,
|
|
||||||
{
|
|
||||||
projectId: contextPayload.projectId!,
|
|
||||||
operationId: runtimeResult.task.requestId,
|
|
||||||
operation: capability.id,
|
|
||||||
status: "applied",
|
|
||||||
createdNodeIds: runtimeResult.operation.createdNodeIds,
|
|
||||||
createdNodeIdsByMediaItemId:
|
|
||||||
runtimeResult.operation.createdNodeIdsByMediaItemId,
|
|
||||||
updatedNodeIds: runtimeResult.operation.updatedNodeIds,
|
|
||||||
canvasRevision: runtimeResult.operation.canvasRevision,
|
|
||||||
message: `${capability.title}已完成`,
|
|
||||||
},
|
|
||||||
`canvas-capability:${capability.id}:operation`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.assign(tools, canvasCapabilityTools);
|
|
||||||
activeTools.push(...getCanvasCapabilityToolNames(canvasCapabilityTools));
|
|
||||||
}
|
|
||||||
|
|
||||||
chatApiLogger.info("[Chat API] Calling streamText with model:", (languageModel as any).modelId, (languageModel as any).provider); // 调试日志
|
chatApiLogger.info("[Chat API] Calling streamText with model:", (languageModel as any).modelId, (languageModel as any).provider); // 调试日志
|
||||||
chatApiLogger.info("[Chat API] Provider type:", (languageModel as any).provider); // 调试日志
|
chatApiLogger.info("[Chat API] Provider type:", (languageModel as any).provider); // 调试日志
|
||||||
|
|
||||||
@ -11324,10 +11122,6 @@ 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,
|
||||||
@ -11374,14 +11168,6 @@ 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"
|
||||||
@ -12139,13 +11925,9 @@ export async function POST(request: Request) {
|
|||||||
model: languageModel,
|
model: languageModel,
|
||||||
system: effectiveConversationSystemPrompt,
|
system: effectiveConversationSystemPrompt,
|
||||||
messages: convertedMessages,
|
messages: convertedMessages,
|
||||||
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),
|
stopWhen: stepCountIs(5),
|
||||||
experimental_activeTools:
|
experimental_activeTools:
|
||||||
(finalModelId === "chat-model-reasoning" &&
|
finalModelId === "chat-model-reasoning" || isVolcanoVideoModel ? [] : activeTools,
|
||||||
!shouldUseCanvasCapabilityToolLoop) ||
|
|
||||||
isVolcanoVideoModel
|
|
||||||
? []
|
|
||||||
: activeTools,
|
|
||||||
experimental_transform: shouldDisableSmoothStream
|
experimental_transform: shouldDisableSmoothStream
|
||||||
? undefined
|
? undefined
|
||||||
: smoothStream({ chunking: "word" }),
|
: smoothStream({ chunking: "word" }),
|
||||||
|
|||||||
@ -48,7 +48,6 @@ 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,7 +3,6 @@ import {
|
|||||||
CompleteMultipartUploadCommand,
|
CompleteMultipartUploadCommand,
|
||||||
CreateMultipartUploadCommand,
|
CreateMultipartUploadCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
ListPartsCommand,
|
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
UploadPartCommand,
|
UploadPartCommand,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
@ -22,22 +21,12 @@ 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 = OPENCLAW_CHAT_FILE_MAX_BYTES;
|
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||||
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 = 6 * 60 * 60;
|
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
|
||||||
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
||||||
|
|
||||||
function elapsedMs(startedAt: number) {
|
function elapsedMs(startedAt: number) {
|
||||||
@ -50,13 +39,6 @@ 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"),
|
||||||
@ -86,18 +68,6 @@ 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),
|
||||||
@ -107,7 +77,6 @@ const AbortSchema = z.object({
|
|||||||
|
|
||||||
const RequestSchema = z.discriminatedUnion("action", [
|
const RequestSchema = z.discriminatedUnion("action", [
|
||||||
InitiateSchema,
|
InitiateSchema,
|
||||||
ResumeSchema,
|
|
||||||
CompleteSchema,
|
CompleteSchema,
|
||||||
AbortSchema,
|
AbortSchema,
|
||||||
]);
|
]);
|
||||||
@ -135,49 +104,19 @@ 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)) {
|
||||||
const error = new Error("Invalid upload object key");
|
throw new Error("Invalid upload object key");
|
||||||
error.name = "InvalidUploadObjectKeyError";
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowedDirectUpload(
|
function isAllowedDirectUpload(contentType: string) {
|
||||||
contentType: string,
|
return allowedDirectUploadTypes.has(contentType);
|
||||||
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 <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
|
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
|
||||||
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
|
return 16 * 1024 * 1024;
|
||||||
return 64 * 1024 * 1024;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPartUrls(params: {
|
async function createPartUrls(params: {
|
||||||
@ -185,24 +124,24 @@ async function createPartUrls(params: {
|
|||||||
bucket: string;
|
bucket: string;
|
||||||
key: string;
|
key: string;
|
||||||
uploadId: string;
|
uploadId: string;
|
||||||
partNumbers: number[];
|
partCount: number;
|
||||||
}) {
|
}) {
|
||||||
return Promise.all(
|
const urls = [];
|
||||||
params.partNumbers.map(async (partNumber) => {
|
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) {
|
||||||
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,
|
||||||
});
|
});
|
||||||
return {
|
urls.push({
|
||||||
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) {
|
||||||
@ -265,7 +204,7 @@ export async function POST(request: Request) {
|
|||||||
payload.filename
|
payload.filename
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
if (!isAllowedDirectUpload(contentType)) {
|
||||||
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,
|
||||||
@ -274,19 +213,10 @@ 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(
|
||||||
@ -294,13 +224,7 @@ export async function POST(request: Request) {
|
|||||||
userUploadPrefix(session.user.id)
|
userUploadPrefix(session.user.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
const multipartThreshold =
|
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
|
||||||
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,
|
||||||
@ -360,7 +284,7 @@ export async function POST(request: Request) {
|
|||||||
bucket,
|
bucket,
|
||||||
key,
|
key,
|
||||||
uploadId,
|
uploadId,
|
||||||
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
|
partCount,
|
||||||
});
|
});
|
||||||
logger.info("direct_upload_multipart_prepared", {
|
logger.info("direct_upload_multipart_prepared", {
|
||||||
elapsedMs: elapsedMs(multipartStartedAt),
|
elapsedMs: elapsedMs(multipartStartedAt),
|
||||||
@ -384,98 +308,6 @@ 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();
|
||||||
@ -496,18 +328,12 @@ 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, payload.purpose, payload.filename)) {
|
if (!isAllowedDirectUpload(contentType)) {
|
||||||
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();
|
||||||
@ -553,10 +379,8 @@ 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 !== payload.size) {
|
if (actualSize !== null && actualSize <= 0) {
|
||||||
throw new Error(
|
throw new Error("Uploaded object is empty");
|
||||||
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = buildS3PublicUrl(target.config, key);
|
const url = buildS3PublicUrl(target.config, key);
|
||||||
@ -565,43 +389,15 @@ 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:
|
mediaType: "image",
|
||||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
sourceType: "upload",
|
||||||
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:
|
previewUrl: url,
|
||||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
thumbnailUrl: url,
|
||||||
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:
|
sourceTable: "direct_upload",
|
||||||
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,
|
||||||
@ -648,13 +444,7 @@ 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,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { 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 { 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([
|
||||||
@ -15,32 +14,12 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
|||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/png",
|
"image/png",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
"image/avif",
|
|
||||||
"image/tiff",
|
|
||||||
"image/x-tiff",
|
|
||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
"image/heic",
|
"image/heic",
|
||||||
"image/heif",
|
"image/heif",
|
||||||
]);
|
]);
|
||||||
const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
|
|
||||||
"video/mp4",
|
|
||||||
"video/quicktime",
|
|
||||||
"video/webm",
|
|
||||||
]);
|
|
||||||
const ALLOWED_OPENCLAW_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 SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
|
|
||||||
"image/avif",
|
|
||||||
"image/tiff",
|
|
||||||
"image/x-tiff",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function normalizeUploadContentType(contentType: string, filename: string) {
|
function normalizeUploadContentType(contentType: string, filename: string) {
|
||||||
const normalized = contentType.trim().toLowerCase();
|
const normalized = contentType.trim().toLowerCase();
|
||||||
@ -56,16 +35,8 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
|
|||||||
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
||||||
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
|
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
|
|
||||||
extension === "avif" ||
|
|
||||||
extension === "tif" ||
|
|
||||||
extension === "tiff"
|
|
||||||
) {
|
|
||||||
return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。";
|
return "File type should be JPEG, PNG, WebP or SVG";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHeicUpload(contentType: string, filename: string) {
|
function isHeicUpload(contentType: string, filename: string) {
|
||||||
@ -77,20 +48,10 @@ function isHeicUpload(contentType: string, filename: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSharpCompatibilityUpload(contentType: string, filename: string) {
|
|
||||||
const extension = filename.toLowerCase().split(".").pop() || "";
|
|
||||||
return (
|
|
||||||
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
|
|
||||||
extension === "avif" ||
|
|
||||||
extension === "tif" ||
|
|
||||||
extension === "tiff"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toJpegFilename(filename: string) {
|
function toJpegFilename(filename: string) {
|
||||||
const trimmed = filename.trim() || `upload-${Date.now()}`;
|
const trimmed = filename.trim() || `upload-${Date.now()}`;
|
||||||
if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
|
if (/\.(heic|heif)$/i.test(trimmed)) {
|
||||||
return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
|
return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
|
||||||
}
|
}
|
||||||
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
||||||
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
|
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
|
||||||
@ -110,21 +71,6 @@ async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
return Buffer.from(output);
|
return Buffer.from(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertSharpCompatibilityImageToJpeg(
|
|
||||||
buffer: Buffer
|
|
||||||
): Promise<Buffer> {
|
|
||||||
const sharp = (await import("sharp")).default;
|
|
||||||
return sharp(buffer, { animated: false, failOn: "none" })
|
|
||||||
.rotate()
|
|
||||||
.flatten({ background: "#ffffff" })
|
|
||||||
.jpeg({
|
|
||||||
quality: 92,
|
|
||||||
mozjpeg: true,
|
|
||||||
progressive: true,
|
|
||||||
})
|
|
||||||
.toBuffer();
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* 使用Sharp智能优化图片(保持原格式)
|
* 使用Sharp智能优化图片(保持原格式)
|
||||||
* 策略:避免双重压缩,只在需要时优化
|
* 策略:避免双重压缩,只在需要时优化
|
||||||
@ -251,12 +197,6 @@ 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 isAtlasCloudReferenceVideo =
|
|
||||||
uploadPurpose === "canvas-video-process" &&
|
|
||||||
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
|
|
||||||
const isOpenClawChatVideoUpload =
|
|
||||||
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
|
||||||
ALLOWED_OPENCLAW_VIDEO_TYPES.has(contentType);
|
|
||||||
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";
|
||||||
@ -287,11 +227,7 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
|
||||||
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
|
|
||||||
!isAtlasCloudReferenceVideo &&
|
|
||||||
!isOpenClawChatVideoUpload
|
|
||||||
) {
|
|
||||||
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,
|
||||||
@ -341,38 +277,6 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (isSharpCompatibilityUpload(contentType, filename)) {
|
|
||||||
try {
|
|
||||||
const convertedBuffer =
|
|
||||||
await convertSharpCompatibilityImageToJpeg(uploadBuffer);
|
|
||||||
logger.info("[Upload API] Compatibility image converted to JPEG", {
|
|
||||||
clientUploadId,
|
|
||||||
originalContentType: contentType,
|
|
||||||
originalSize: uploadBuffer.length,
|
|
||||||
convertedSize: convertedBuffer.length,
|
|
||||||
});
|
|
||||||
uploadBuffer = convertedBuffer;
|
|
||||||
filename = toJpegFilename(filename);
|
|
||||||
convertedFrom = contentType;
|
|
||||||
contentType = "image/jpeg";
|
|
||||||
} catch (conversionError) {
|
|
||||||
logger.warn("[Upload API] Compatibility image conversion failed", {
|
|
||||||
clientUploadId,
|
|
||||||
contentType,
|
|
||||||
size: uploadBuffer.length,
|
|
||||||
error:
|
|
||||||
conversionError instanceof Error
|
|
||||||
? conversionError.message
|
|
||||||
: String(conversionError),
|
|
||||||
});
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: createUnsupportedImageTypeMessage(contentType, filename),
|
|
||||||
clientUploadId,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
|
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
|
||||||
@ -457,32 +361,18 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const asset = await upsertMediaAsset({
|
const asset = await upsertMediaAsset({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
mediaType:
|
mediaType: "image",
|
||||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
sourceType: "upload",
|
||||||
? "video"
|
|
||||||
: "image",
|
|
||||||
sourceType: isOpenClawChatVideoUpload
|
|
||||||
? "openclaw_chat_video_upload"
|
|
||||||
: "upload",
|
|
||||||
status: "ready",
|
status: "ready",
|
||||||
originalUrl: data.url,
|
originalUrl: data.url,
|
||||||
previewUrl:
|
previewUrl: data.url,
|
||||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
thumbnailUrl: data.url,
|
||||||
? null
|
|
||||||
: data.url,
|
|
||||||
thumbnailUrl:
|
|
||||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
|
||||||
? null
|
|
||||||
: data.url,
|
|
||||||
mimeType: data.contentType || contentType || null,
|
mimeType: data.contentType || contentType || null,
|
||||||
displayName: filename,
|
displayName: filename,
|
||||||
sourceTable: isOpenClawChatVideoUpload
|
sourceTable: "upload",
|
||||||
? "openclaw_chat_video_upload"
|
|
||||||
: "upload",
|
|
||||||
metadata: {
|
metadata: {
|
||||||
pathname: data.pathname,
|
pathname: data.pathname,
|
||||||
size: optimizedBuffer.length,
|
size: optimizedBuffer.length,
|
||||||
purpose: uploadPurpose || null,
|
|
||||||
clientUploadId,
|
clientUploadId,
|
||||||
convertedFrom,
|
convertedFrom,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -33,11 +33,6 @@ 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(),
|
||||||
@ -50,7 +45,6 @@ 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,7 +9,6 @@ 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 = [
|
||||||
@ -33,10 +32,6 @@ 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",
|
||||||
@ -78,15 +73,11 @@ 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.vip98k.com";
|
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
|
||||||
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";
|
||||||
@ -246,14 +237,6 @@ 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"
|
||||||
@ -316,12 +299,6 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,182 +0,0 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
fetchAtlasCloudModelPresets,
|
|
||||||
resolveAtlasCloudDisplayName,
|
|
||||||
shouldInsertMissingAtlasCloudPreset,
|
|
||||||
} 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 fetchAtlasCloudModelPresets();
|
|
||||||
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 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 fetchAtlasCloudModelPresets();
|
|
||||||
const presets = requestedModelIds
|
|
||||||
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
|
|
||||||
: catalog.presets;
|
|
||||||
const addedModels: AiModel[] = [];
|
|
||||||
const updatedModels: AiModel[] = [];
|
|
||||||
const errors = [...catalog.errors];
|
|
||||||
const providerModels = await db.query.aiModel.findMany({
|
|
||||||
where: eq(aiModel.providerId, providerId),
|
|
||||||
});
|
|
||||||
const existingByModelId = new Map(
|
|
||||||
providerModels.map((model) => [model.modelId, model])
|
|
||||||
);
|
|
||||||
const existingMediaKinds = new Set(
|
|
||||||
providerModels.map((model) => {
|
|
||||||
const capabilities = model.capabilities as Record<string, unknown> | null;
|
|
||||||
return String(capabilities?.mediaType || "image");
|
|
||||||
})
|
|
||||||
);
|
|
||||||
let preservedDeletedCount = 0;
|
|
||||||
|
|
||||||
for (const preset of presets) {
|
|
||||||
try {
|
|
||||||
const existing = existingByModelId.get(preset.modelId);
|
|
||||||
const presetCapabilities = preset.capabilities as Record<string, unknown>;
|
|
||||||
const mediaKind = String(presetCapabilities.mediaType || "image");
|
|
||||||
if (
|
|
||||||
!existing &&
|
|
||||||
!shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
|
|
||||||
) {
|
|
||||||
preservedDeletedCount += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const values = {
|
|
||||||
displayName: resolveAtlasCloudDisplayName(
|
|
||||||
existing?.displayName,
|
|
||||||
preset.displayName,
|
|
||||||
preset.modelId
|
|
||||||
),
|
|
||||||
avatarUrl: preset.avatarUrl || null,
|
|
||||||
description: preset.description,
|
|
||||||
supportsVision: preset.supportsVision,
|
|
||||||
supportsImageGen: preset.supportsImageGen,
|
|
||||||
sortOrder: existing?.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,
|
|
||||||
isEnabled: true,
|
|
||||||
isHidden: false,
|
|
||||||
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,
|
|
||||||
skippedUnsupported: catalog.skipped.length,
|
|
||||||
preservedDeleted: preservedDeletedCount,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} 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 models",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -53,10 +53,6 @@ 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,
|
||||||
@ -130,12 +126,6 @@ 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({
|
||||||
@ -379,10 +369,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,213 +0,0 @@
|
|||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
app/api/ai-cad/modeling/command/route.ts
Normal file
122
app/api/ai-cad/modeling/command/route.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
getKittyCadModelingCommandError,
|
||||||
|
isKittyCadModelingCommandSuccessful,
|
||||||
|
sendKittyCadModelingCommand,
|
||||||
|
takeKittyCadModelingSnapshot,
|
||||||
|
} from "@/lib/kittycad/modeling-session";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
type ModelingCommandRequest = {
|
||||||
|
captureSnapshot?: boolean;
|
||||||
|
cmd?: Record<string, unknown>;
|
||||||
|
cmdId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
snapshotFormat?: "jpeg" | "png";
|
||||||
|
};
|
||||||
|
|
||||||
|
function readOptionalRecord(value: unknown) {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) as ModelingCommandRequest;
|
||||||
|
const sessionId = String(body.sessionId || "").trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少官方建模会话 ID" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = readOptionalRecord(body.cmd);
|
||||||
|
const cmdType = String(cmd?.type || "").trim();
|
||||||
|
if (!cmd || !cmdType) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少有效的官方建模命令" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmdId = String(body.cmdId || "").trim() || randomUUID();
|
||||||
|
const response = await sendKittyCadModelingCommand({
|
||||||
|
cmd: {
|
||||||
|
...cmd,
|
||||||
|
type: cmdType,
|
||||||
|
},
|
||||||
|
cmdId,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isKittyCadModelingCommandSuccessful(response)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: getKittyCadModelingCommandError(response),
|
||||||
|
payload: response.errors || null,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshotUrl: string | null = null;
|
||||||
|
let snapshotError: string | null = null;
|
||||||
|
|
||||||
|
const shouldCaptureSnapshot =
|
||||||
|
body.captureSnapshot === true && cmdType !== "take_snapshot";
|
||||||
|
|
||||||
|
if (cmdType === "take_snapshot") {
|
||||||
|
const contents = String(response.resp?.data?.contents || "").trim();
|
||||||
|
const snapshotFormat = body.snapshotFormat || "png";
|
||||||
|
snapshotUrl = contents
|
||||||
|
? `data:${snapshotFormat === "jpeg" ? "image/jpeg" : "image/png"};base64,${contents}`
|
||||||
|
: null;
|
||||||
|
} else if (shouldCaptureSnapshot) {
|
||||||
|
try {
|
||||||
|
const snapshot = await takeKittyCadModelingSnapshot({
|
||||||
|
format: body.snapshotFormat || "png",
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
snapshotUrl = snapshot.snapshotUrl;
|
||||||
|
} catch (error) {
|
||||||
|
snapshotError =
|
||||||
|
error instanceof Error ? error.message : "获取官方建模快照失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
raw: response,
|
||||||
|
requestId: response.request_id || cmdId,
|
||||||
|
responseData: response.resp?.data || null,
|
||||||
|
responseType: response.resp?.type || cmdType,
|
||||||
|
snapshotError,
|
||||||
|
snapshotUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "发送官方建模命令失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/ai-cad/modeling/session/[sessionId]/route.ts
Normal file
48
app/api/ai-cad/modeling/session/[sessionId]/route.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { closeKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
type RouteParams = {
|
||||||
|
params: Promise<{
|
||||||
|
sessionId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await params;
|
||||||
|
const normalizedSessionId = String(sessionId || "").trim();
|
||||||
|
if (!normalizedSessionId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少 sessionId" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await closeKittyCadModelingSession(normalizedSessionId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "关闭官方建模会话失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/api/ai-cad/modeling/session/route.ts
Normal file
33
app/api/ai-cad/modeling/session/route.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { createKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelingSession = await createKittyCadModelingSession();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: modelingSession,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "创建官方建模会话失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/api/ai-cad/provider/route.ts
Normal file
27
app/api/ai-cad/provider/route.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { findKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await findKittyCadProviderConfig();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
configured: Boolean(config),
|
||||||
|
source: config?.source || "none",
|
||||||
|
providerName: config?.providerName || null,
|
||||||
|
baseUrl: config?.baseUrl || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
391
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
391
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
type KittyCadTaskResponse = {
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
outputs?: Record<string, string> | null;
|
||||||
|
error?: string | null;
|
||||||
|
code?: string | null;
|
||||||
|
prompt?: string | null;
|
||||||
|
conversation_id?: string | null;
|
||||||
|
model_version?: string | null;
|
||||||
|
output_format?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArchivedFile = {
|
||||||
|
sourceHash: string;
|
||||||
|
archivedUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteParams = {
|
||||||
|
params: Promise<{ taskId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getArchiveCache() {
|
||||||
|
const globalState = globalThis as Record<string, unknown>;
|
||||||
|
if (!globalState.__kittyCadAssetArchiveCache) {
|
||||||
|
globalState.__kittyCadAssetArchiveCache = new Map<string, ArchivedFile>();
|
||||||
|
}
|
||||||
|
return globalState.__kittyCadAssetArchiveCache as Map<string, ArchivedFile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArchiveInflight() {
|
||||||
|
const globalState = globalThis as Record<string, unknown>;
|
||||||
|
if (!globalState.__kittyCadAssetArchiveInflight) {
|
||||||
|
globalState.__kittyCadAssetArchiveInflight = new Map<
|
||||||
|
string,
|
||||||
|
Promise<string | null>
|
||||||
|
>();
|
||||||
|
}
|
||||||
|
return globalState.__kittyCadAssetArchiveInflight as Map<
|
||||||
|
string,
|
||||||
|
Promise<string | null>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapKittyCadStatus(status: unknown) {
|
||||||
|
const normalized = String(status || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return "queued" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "queued" ||
|
||||||
|
normalized === "pending" ||
|
||||||
|
normalized === "created" ||
|
||||||
|
normalized === "submitted"
|
||||||
|
) {
|
||||||
|
return "queued" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "running" ||
|
||||||
|
normalized === "processing" ||
|
||||||
|
normalized === "in_progress" ||
|
||||||
|
normalized === "working" ||
|
||||||
|
normalized === "generating"
|
||||||
|
) {
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "completed" ||
|
||||||
|
normalized === "success" ||
|
||||||
|
normalized === "succeeded" ||
|
||||||
|
normalized === "done" ||
|
||||||
|
normalized === "finished"
|
||||||
|
) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "cancelled" || normalized === "canceled") {
|
||||||
|
return "cancelled" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "expired" || normalized === "timed_out") {
|
||||||
|
return "expired" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "banned" || normalized === "blocked") {
|
||||||
|
return "banned" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "failed" ||
|
||||||
|
normalized === "error" ||
|
||||||
|
normalized === "errored"
|
||||||
|
) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "unknown" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileExtension(filename: string) {
|
||||||
|
const normalized = String(filename || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
||||||
|
const segments = cleanName.split(".");
|
||||||
|
return segments.length > 1 ? segments.at(-1) || "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageExtension(extension: string) {
|
||||||
|
return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPreferredModelFile(files: Array<{ name: string; url: string }>) {
|
||||||
|
const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"];
|
||||||
|
for (const extension of preferredOrder) {
|
||||||
|
const matched = files.find(
|
||||||
|
(item) => getFileExtension(item.name) === extension
|
||||||
|
);
|
||||||
|
if (matched) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
|
||||||
|
return (
|
||||||
|
files.find((item) => isImageExtension(getFileExtension(item.name))) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveOutputIfNeeded(params: {
|
||||||
|
taskId: string;
|
||||||
|
filename: string;
|
||||||
|
base64: string;
|
||||||
|
}) {
|
||||||
|
const normalizedFilename = String(params.filename || "").trim();
|
||||||
|
const normalizedBase64 = String(params.base64 || "").trim();
|
||||||
|
if (!normalizedFilename || !normalizedBase64) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
||||||
|
const cache = getArchiveCache();
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached && cached.sourceHash === normalizedBase64) {
|
||||||
|
return cached.archivedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = getArchiveInflight();
|
||||||
|
const existingTask = inflight.get(cacheKey);
|
||||||
|
if (existingTask) {
|
||||||
|
return (await existingTask) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveTask = (async () => {
|
||||||
|
try {
|
||||||
|
const binary = Buffer.from(normalizedBase64, "base64");
|
||||||
|
if (binary.byteLength <= 0) {
|
||||||
|
throw new Error("KittyCAD 输出文件为空");
|
||||||
|
}
|
||||||
|
const arrayBuffer = Uint8Array.from(binary).buffer;
|
||||||
|
|
||||||
|
const safeFilename =
|
||||||
|
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin";
|
||||||
|
const uploaded = await uploadFile(
|
||||||
|
`kittycad-${params.taskId}-${safeFilename}`,
|
||||||
|
arrayBuffer
|
||||||
|
);
|
||||||
|
cache.set(cacheKey, {
|
||||||
|
sourceHash: normalizedBase64,
|
||||||
|
archivedUrl: uploaded.url,
|
||||||
|
});
|
||||||
|
return uploaded.url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[KittyCADArchive] failed", {
|
||||||
|
taskId: params.taskId,
|
||||||
|
filename: normalizedFilename,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
inflight.delete(cacheKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflight.set(cacheKey, archiveTask);
|
||||||
|
return await archiveTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveTextOutputIfNeeded(params: {
|
||||||
|
taskId: string;
|
||||||
|
filename: string;
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
const normalizedFilename = String(params.filename || "").trim();
|
||||||
|
const normalizedContent = String(params.content || "");
|
||||||
|
if (!normalizedFilename || !normalizedContent.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
||||||
|
const cache = getArchiveCache();
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached && cached.sourceHash === normalizedContent) {
|
||||||
|
return cached.archivedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = getArchiveInflight();
|
||||||
|
const existingTask = inflight.get(cacheKey);
|
||||||
|
if (existingTask) {
|
||||||
|
return (await existingTask) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveTask = (async () => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = Uint8Array.from(
|
||||||
|
new TextEncoder().encode(normalizedContent)
|
||||||
|
).buffer;
|
||||||
|
const safeFilename =
|
||||||
|
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl";
|
||||||
|
const uploaded = await uploadFile(
|
||||||
|
`kittycad-${params.taskId}-${safeFilename}`,
|
||||||
|
arrayBuffer
|
||||||
|
);
|
||||||
|
cache.set(cacheKey, {
|
||||||
|
sourceHash: normalizedContent,
|
||||||
|
archivedUrl: uploaded.url,
|
||||||
|
});
|
||||||
|
return uploaded.url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[KittyCADArchive:text] failed", {
|
||||||
|
taskId: params.taskId,
|
||||||
|
filename: normalizedFilename,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
inflight.delete(cacheKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflight.set(cacheKey, archiveTask);
|
||||||
|
return await archiveTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { taskId } = await params;
|
||||||
|
const normalizedTaskId = String(taskId || "").trim();
|
||||||
|
if (!normalizedTaskId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少 taskId" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
const result = await kittyCadRequest<KittyCadTaskResponse>(config, {
|
||||||
|
method: "GET",
|
||||||
|
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = mapKittyCadStatus(result.status);
|
||||||
|
if (status !== "success") {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "cad",
|
||||||
|
status,
|
||||||
|
progress: status === "running" ? 65 : null,
|
||||||
|
errorMessage: result.error || null,
|
||||||
|
conversationId: result.conversation_id || null,
|
||||||
|
modelVersion: result.model_version || null,
|
||||||
|
outputFormat: result.output_format || null,
|
||||||
|
output: result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputEntries = Object.entries(result.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
|
||||||
|
);
|
||||||
|
|
||||||
|
const archivedEntries = await Promise.all(
|
||||||
|
outputEntries.map(async ([name, base64]) => {
|
||||||
|
const archivedUrl = await archiveOutputIfNeeded({
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
filename: name,
|
||||||
|
base64,
|
||||||
|
});
|
||||||
|
return archivedUrl ? { name, url: archivedUrl } : null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const archivedFiles = archivedEntries.filter(
|
||||||
|
(item): item is { name: string; url: string } =>
|
||||||
|
Boolean(item?.name && item?.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasArchivedKclFile = archivedFiles.some(
|
||||||
|
(item) => getFileExtension(item.name) === "kcl"
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!hasArchivedKclFile &&
|
||||||
|
typeof result.code === "string" &&
|
||||||
|
result.code.trim()
|
||||||
|
) {
|
||||||
|
const archivedCodeUrl = await archiveTextOutputIfNeeded({
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
filename: "main.kcl",
|
||||||
|
content: result.code,
|
||||||
|
});
|
||||||
|
if (archivedCodeUrl) {
|
||||||
|
archivedFiles.push({
|
||||||
|
name: "main.kcl",
|
||||||
|
url: archivedCodeUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelFile = pickPreferredModelFile(archivedFiles);
|
||||||
|
const previewImageFile = pickPreviewImageFile(archivedFiles);
|
||||||
|
const downloadUrls = Object.fromEntries(
|
||||||
|
archivedFiles.map((item) => [item.name, item.url])
|
||||||
|
);
|
||||||
|
const codeFile =
|
||||||
|
archivedFiles.find((item) => getFileExtension(item.name) === "kcl") ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "cad",
|
||||||
|
status: "success",
|
||||||
|
progress: 100,
|
||||||
|
modelUrl:
|
||||||
|
modelFile &&
|
||||||
|
["glb", "gltf"].includes(getFileExtension(modelFile.name))
|
||||||
|
? modelFile.url
|
||||||
|
: null,
|
||||||
|
previewImageUrl: previewImageFile?.url || null,
|
||||||
|
errorMessage: result.error || null,
|
||||||
|
conversationId: result.conversation_id || null,
|
||||||
|
modelVersion: result.model_version || null,
|
||||||
|
outputFormat: result.output_format || null,
|
||||||
|
codeUrl: codeFile?.url || null,
|
||||||
|
downloadUrls,
|
||||||
|
output: result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof KittyCadApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
payload: error.payload,
|
||||||
|
},
|
||||||
|
{ status: error.httpStatus || 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
331
app/api/ai-cad/tasks/route.ts
Normal file
331
app/api/ai-cad/tasks/route.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
consumeUserPoints,
|
||||||
|
getPointsSystemConfig,
|
||||||
|
getUserById,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
type CreateAiCadTaskRequest = {
|
||||||
|
prompt?: string;
|
||||||
|
outputFormat?: string;
|
||||||
|
projectName?: string;
|
||||||
|
iterationSourceTaskId?: string;
|
||||||
|
sourceCode?: string;
|
||||||
|
sourceRanges?: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type KittyCadCreateResponse = {
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
conversation_id?: string | null;
|
||||||
|
model_version?: string | null;
|
||||||
|
output_format?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KittyCadSourceTaskResponse = {
|
||||||
|
code?: string | null;
|
||||||
|
outputs?: Record<string, string> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KittyCadIterationResponse = {
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
conversation_id?: string | null;
|
||||||
|
model_version?: string | null;
|
||||||
|
output_format?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFileExtension(filename: string) {
|
||||||
|
const normalized = String(filename || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
||||||
|
const segments = cleanName.split(".");
|
||||||
|
return segments.length > 1 ? segments.at(-1) || "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Text(value: unknown) {
|
||||||
|
const normalized = String(value || "").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(normalized, "base64").toString("utf8").trim();
|
||||||
|
return decoded || null;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSourceCodeFromTask(task: KittyCadSourceTaskResponse) {
|
||||||
|
const directCode = String(task.code || "").trim();
|
||||||
|
if (directCode) {
|
||||||
|
return directCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kclOutputEntry = Object.entries(task.outputs || {}).find(
|
||||||
|
(entry): entry is [string, string] =>
|
||||||
|
typeof entry[0] === "string" &&
|
||||||
|
entry[0].trim().length > 0 &&
|
||||||
|
getFileExtension(entry[0]) === "kcl" &&
|
||||||
|
typeof entry[1] === "string" &&
|
||||||
|
entry[1].trim().length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!kclOutputEntry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeBase64Text(kclOutputEntry[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapKittyCadStatus(status: unknown) {
|
||||||
|
const normalized = String(status || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return "queued" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "queued" ||
|
||||||
|
normalized === "pending" ||
|
||||||
|
normalized === "created" ||
|
||||||
|
normalized === "submitted"
|
||||||
|
) {
|
||||||
|
return "queued" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "running" ||
|
||||||
|
normalized === "processing" ||
|
||||||
|
normalized === "in_progress" ||
|
||||||
|
normalized === "working" ||
|
||||||
|
normalized === "generating"
|
||||||
|
) {
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "completed" ||
|
||||||
|
normalized === "success" ||
|
||||||
|
normalized === "succeeded" ||
|
||||||
|
normalized === "done" ||
|
||||||
|
normalized === "finished"
|
||||||
|
) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "cancelled" || normalized === "canceled") {
|
||||||
|
return "cancelled" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "expired" || normalized === "timed_out") {
|
||||||
|
return "expired" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "banned" || normalized === "blocked") {
|
||||||
|
return "banned" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "failed" ||
|
||||||
|
normalized === "error" ||
|
||||||
|
normalized === "errored"
|
||||||
|
) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "unknown" 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 body = (await request.json()) as CreateAiCadTaskRequest;
|
||||||
|
const prompt = String(body.prompt || "").trim();
|
||||||
|
|
||||||
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
|
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
|
||||||
|
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
pointsConfig.enabled &&
|
||||||
|
unitCost > 0 &&
|
||||||
|
pointsConfig.blockOnInsufficient !== false
|
||||||
|
) {
|
||||||
|
const userData = await getUserById({ id: session.user.id });
|
||||||
|
if (!userData?.isHighVip) {
|
||||||
|
const currentBalance = Math.max(
|
||||||
|
0,
|
||||||
|
Number(userData?.pointsBalance || 0)
|
||||||
|
);
|
||||||
|
if (currentBalance < unitCost) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "积分不足",
|
||||||
|
code: "insufficient_points",
|
||||||
|
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
|
||||||
|
balance: currentBalance,
|
||||||
|
expectedCost: unitCost,
|
||||||
|
unitCost,
|
||||||
|
},
|
||||||
|
{ status: 402 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputFormat =
|
||||||
|
String(body.outputFormat || "glb")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() || "glb";
|
||||||
|
const normalizedProjectName = String(body.projectName || "").trim();
|
||||||
|
const iterationSourceTaskId = String(
|
||||||
|
body.iterationSourceTaskId || ""
|
||||||
|
).trim();
|
||||||
|
const sourceCode = String(body.sourceCode || "");
|
||||||
|
const normalizedSourceCode = sourceCode.trim();
|
||||||
|
const sourceRanges = Array.isArray(body.sourceRanges)
|
||||||
|
? body.sourceRanges.filter(
|
||||||
|
(item): item is Record<string, unknown> =>
|
||||||
|
Boolean(item) && typeof item === "object" && !Array.isArray(item)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
const generationMode =
|
||||||
|
iterationSourceTaskId || normalizedSourceCode ? "iterate" : "create";
|
||||||
|
|
||||||
|
if (generationMode === "create" && !prompt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "请输入 CAD 描述" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterationPrompt =
|
||||||
|
prompt || "Apply the edited KCL source and rebuild the CAD model.";
|
||||||
|
const result =
|
||||||
|
generationMode === "iterate"
|
||||||
|
? await (async () => {
|
||||||
|
const originalSourceCode =
|
||||||
|
normalizedSourceCode ||
|
||||||
|
(await (async () => {
|
||||||
|
if (!iterationSourceTaskId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceTask =
|
||||||
|
await kittyCadRequest<KittyCadSourceTaskResponse>(config, {
|
||||||
|
method: "GET",
|
||||||
|
path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`,
|
||||||
|
});
|
||||||
|
return extractSourceCodeFromTask(sourceTask);
|
||||||
|
})());
|
||||||
|
|
||||||
|
if (!originalSourceCode) {
|
||||||
|
throw new KittyCadApiError({
|
||||||
|
message:
|
||||||
|
"当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。",
|
||||||
|
httpStatus: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return kittyCadRequest<KittyCadIterationResponse>(config, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/ml/text-to-cad/iteration",
|
||||||
|
json: {
|
||||||
|
original_source_code: originalSourceCode,
|
||||||
|
prompt: iterationPrompt,
|
||||||
|
source_ranges: sourceRanges,
|
||||||
|
...(normalizedProjectName
|
||||||
|
? { project_name: normalizedProjectName }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
: await kittyCadRequest<KittyCadCreateResponse>(config, {
|
||||||
|
method: "POST",
|
||||||
|
path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`,
|
||||||
|
json: {
|
||||||
|
prompt,
|
||||||
|
...(normalizedProjectName
|
||||||
|
? { project_name: normalizedProjectName }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskId = String(result.id || "").trim();
|
||||||
|
if (!taskId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "KittyCAD 返回缺少任务 ID",
|
||||||
|
raw: result,
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointsConfig.enabled && unitCost > 0) {
|
||||||
|
try {
|
||||||
|
await consumeUserPoints({
|
||||||
|
userId: session.user.id,
|
||||||
|
amount: unitCost,
|
||||||
|
reason: "model3d_generation",
|
||||||
|
modelId: "kittycad-cad",
|
||||||
|
note:
|
||||||
|
generationMode === "iterate"
|
||||||
|
? "AI-CAD 连续迭代消耗积分"
|
||||||
|
: "AI-CAD 生成消耗积分",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Points] Failed to consume AI-CAD points:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId,
|
||||||
|
status: mapKittyCadStatus(result.status),
|
||||||
|
conversationId: result.conversation_id || null,
|
||||||
|
modelVersion: result.model_version || null,
|
||||||
|
outputFormat:
|
||||||
|
generationMode === "iterate"
|
||||||
|
? result.output_format || null
|
||||||
|
: result.output_format || outputFormat,
|
||||||
|
generationMode,
|
||||||
|
sourceTaskId: iterationSourceTaskId || null,
|
||||||
|
raw: result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof KittyCadApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
payload: error.payload,
|
||||||
|
},
|
||||||
|
{ status: error.httpStatus || 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "创建 AI-CAD 任务失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
195
app/api/ai-cad/workspace-utils.ts
Normal file
195
app/api/ai-cad/workspace-utils.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import type { AiCadWorkspace } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
|
||||||
|
|
||||||
|
const FINAL_TASK_STATUS = new Set([
|
||||||
|
"success",
|
||||||
|
"failed",
|
||||||
|
"banned",
|
||||||
|
"expired",
|
||||||
|
"cancelled",
|
||||||
|
"unknown",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_VIEWER_SETTINGS = {
|
||||||
|
cameraPreset: "iso",
|
||||||
|
envPreset: "studio",
|
||||||
|
lightIntensity: 1.1,
|
||||||
|
autoRotate: true,
|
||||||
|
showGrid: true,
|
||||||
|
wireframe: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type WorkspaceVersionLike = {
|
||||||
|
status?: unknown;
|
||||||
|
prompt?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateAiCadWorkspaceRequest = {
|
||||||
|
name?: string;
|
||||||
|
prompt?: string;
|
||||||
|
generationProvider?: string;
|
||||||
|
modelVersion?: string;
|
||||||
|
viewerSettings?: unknown;
|
||||||
|
messages?: unknown;
|
||||||
|
versions?: unknown;
|
||||||
|
activeVersionId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.filter((item): item is WorkspaceVersionLike => isRecord(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeViewerSettings(
|
||||||
|
value: unknown
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return { ...DEFAULT_VIEWER_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cameraPreset:
|
||||||
|
value.cameraPreset === "top" ||
|
||||||
|
value.cameraPreset === "front" ||
|
||||||
|
value.cameraPreset === "right" ||
|
||||||
|
value.cameraPreset === "iso"
|
||||||
|
? value.cameraPreset
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.cameraPreset,
|
||||||
|
envPreset:
|
||||||
|
typeof value.envPreset === "string" && value.envPreset.trim()
|
||||||
|
? value.envPreset
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.envPreset,
|
||||||
|
lightIntensity:
|
||||||
|
typeof value.lightIntensity === "number" &&
|
||||||
|
Number.isFinite(value.lightIntensity)
|
||||||
|
? value.lightIntensity
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.lightIntensity,
|
||||||
|
autoRotate:
|
||||||
|
typeof value.autoRotate === "boolean"
|
||||||
|
? value.autoRotate
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.autoRotate,
|
||||||
|
showGrid:
|
||||||
|
typeof value.showGrid === "boolean"
|
||||||
|
? value.showGrid
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.showGrid,
|
||||||
|
wireframe:
|
||||||
|
typeof value.wireframe === "boolean"
|
||||||
|
? value.wireframe
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.wireframe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeJsonRecordArray(
|
||||||
|
value: unknown
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.filter((item): item is Record<string, unknown> =>
|
||||||
|
isRecord(item)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkspaceName(
|
||||||
|
value: unknown,
|
||||||
|
fallbackName = "AI-CAD 工作台"
|
||||||
|
) {
|
||||||
|
return typeof value === "string" && value.trim()
|
||||||
|
? value.trim()
|
||||||
|
: fallbackName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): {
|
||||||
|
name?: string;
|
||||||
|
prompt: string;
|
||||||
|
generationProvider: CadGenerationProvider;
|
||||||
|
modelVersion: string;
|
||||||
|
viewerSettings: Record<string, unknown>;
|
||||||
|
messages: Record<string, unknown>[];
|
||||||
|
versions: Record<string, unknown>[];
|
||||||
|
activeVersionId: string | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
...(typeof body.name === "string" && body.name.trim()
|
||||||
|
? { name: body.name.trim() }
|
||||||
|
: {}),
|
||||||
|
prompt: String(body.prompt || ""),
|
||||||
|
generationProvider:
|
||||||
|
body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad",
|
||||||
|
modelVersion:
|
||||||
|
typeof body.modelVersion === "string" && body.modelVersion.trim()
|
||||||
|
? body.modelVersion
|
||||||
|
: "Turbo-v1.0-20250506",
|
||||||
|
viewerSettings: normalizeViewerSettings(body.viewerSettings),
|
||||||
|
messages: normalizeJsonRecordArray(body.messages),
|
||||||
|
versions: normalizeJsonRecordArray(body.versions),
|
||||||
|
activeVersionId:
|
||||||
|
typeof body.activeVersionId === "string" && body.activeVersionId.trim()
|
||||||
|
? body.activeVersionId
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultWorkspaceName(index: number) {
|
||||||
|
return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceSummary(workspace: AiCadWorkspace) {
|
||||||
|
const versions = getWorkspaceVersions(workspace.versions);
|
||||||
|
const versionCount = versions.length;
|
||||||
|
const completedCount = versions.filter(
|
||||||
|
(version) => version.status === "success"
|
||||||
|
).length;
|
||||||
|
const hasPendingTasks = versions.some(
|
||||||
|
(version) =>
|
||||||
|
typeof version.status !== "string" ||
|
||||||
|
!FINAL_TASK_STATUS.has(version.status)
|
||||||
|
);
|
||||||
|
const latestPrompt =
|
||||||
|
typeof workspace.prompt === "string" && workspace.prompt.trim()
|
||||||
|
? workspace.prompt.trim()
|
||||||
|
: versions.find(
|
||||||
|
(version) =>
|
||||||
|
typeof version.prompt === "string" && version.prompt.trim()
|
||||||
|
)?.prompt || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: workspace.id,
|
||||||
|
name: workspace.name,
|
||||||
|
generationProvider: workspace.generationProvider,
|
||||||
|
versionCount,
|
||||||
|
completedCount,
|
||||||
|
hasPendingTasks,
|
||||||
|
latestPrompt:
|
||||||
|
typeof latestPrompt === "string" && latestPrompt.trim()
|
||||||
|
? latestPrompt.trim()
|
||||||
|
: null,
|
||||||
|
createdAt: workspace.createdAt?.toISOString() || null,
|
||||||
|
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceDocument(workspace: AiCadWorkspace) {
|
||||||
|
return {
|
||||||
|
id: workspace.id,
|
||||||
|
name: workspace.name,
|
||||||
|
prompt: workspace.prompt,
|
||||||
|
generationProvider: workspace.generationProvider,
|
||||||
|
modelVersion: workspace.modelVersion,
|
||||||
|
viewerSettings: workspace.viewerSettings,
|
||||||
|
messages: workspace.messages,
|
||||||
|
versions: workspace.versions,
|
||||||
|
activeVersionId: workspace.activeVersionId,
|
||||||
|
createdAt: workspace.createdAt?.toISOString() || null,
|
||||||
|
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
89
app/api/ai-cad/workspace/route.ts
Normal file
89
app/api/ai-cad/workspace/route.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
createWorkspaceDocument,
|
||||||
|
normalizeWorkspacePayload,
|
||||||
|
type UpdateAiCadWorkspaceRequest,
|
||||||
|
} from "@/app/api/ai-cad/workspace-utils";
|
||||||
|
import {
|
||||||
|
createAiCadWorkspace,
|
||||||
|
listAiCadWorkspacesByUserId,
|
||||||
|
updateAiCadWorkspace,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaces = await listAiCadWorkspacesByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const workspace = workspaces[0] ?? null;
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: createWorkspaceDocument(workspace),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
||||||
|
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const targetWorkspace =
|
||||||
|
existingWorkspaces[0] ??
|
||||||
|
(await createAiCadWorkspace({
|
||||||
|
userId: session.user.id,
|
||||||
|
name: "AI-CAD 工作台",
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!targetWorkspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "初始化 AI-CAD 工作台失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedWorkspace = await updateAiCadWorkspace({
|
||||||
|
workspaceId: targetWorkspace.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
...normalizeWorkspacePayload(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!savedWorkspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "保存 AI-CAD 工作台失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
114
app/api/ai-cad/workspaces/[workspaceId]/route.ts
Normal file
114
app/api/ai-cad/workspaces/[workspaceId]/route.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
createWorkspaceDocument,
|
||||||
|
createWorkspaceSummary,
|
||||||
|
normalizeWorkspacePayload,
|
||||||
|
type UpdateAiCadWorkspaceRequest,
|
||||||
|
} from "@/app/api/ai-cad/workspace-utils";
|
||||||
|
import {
|
||||||
|
deleteAiCadWorkspaceById,
|
||||||
|
getAiCadWorkspaceById,
|
||||||
|
updateAiCadWorkspace,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{
|
||||||
|
workspaceId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: Request, context: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspaceId } = await context.params;
|
||||||
|
const workspace = await getAiCadWorkspaceById({
|
||||||
|
userId: session.user.id,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: createWorkspaceDocument(workspace),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request, context: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspaceId } = await context.params;
|
||||||
|
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
||||||
|
const savedWorkspace = await updateAiCadWorkspace({
|
||||||
|
workspaceId,
|
||||||
|
userId: session.user.id,
|
||||||
|
...normalizeWorkspacePayload(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!savedWorkspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "AI-CAD 工作台不存在或保存失败" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: savedWorkspace.id,
|
||||||
|
name: savedWorkspace.name,
|
||||||
|
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||||
|
summary: createWorkspaceSummary(savedWorkspace),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, context: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspaceId } = await context.params;
|
||||||
|
const deletedWorkspace = await deleteAiCadWorkspaceById({
|
||||||
|
workspaceId,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deletedWorkspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: deletedWorkspace.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
69
app/api/ai-cad/workspaces/route.ts
Normal file
69
app/api/ai-cad/workspaces/route.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
buildDefaultWorkspaceName,
|
||||||
|
createWorkspaceSummary,
|
||||||
|
normalizeWorkspaceName,
|
||||||
|
} from "@/app/api/ai-cad/workspace-utils";
|
||||||
|
import {
|
||||||
|
createAiCadWorkspace,
|
||||||
|
listAiCadWorkspacesByUserId,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaces = await listAiCadWorkspacesByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: workspaces.map(createWorkspaceSummary),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => ({}))) as { name?: unknown };
|
||||||
|
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const workspace = await createAiCadWorkspace({
|
||||||
|
userId: session.user.id,
|
||||||
|
name: normalizeWorkspaceName(
|
||||||
|
body.name,
|
||||||
|
buildDefaultWorkspaceName(existingWorkspaces.length + 1)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "创建 AI-CAD 工作台失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: createWorkspaceSummary(workspace),
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,66 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
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,7 +13,6 @@ 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),
|
||||||
@ -53,16 +52,6 @@ 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,6 +32,18 @@ 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);
|
||||||
@ -59,7 +71,7 @@ export async function GET() {
|
|||||||
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
|
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...settings,
|
...settings,
|
||||||
tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
|
tags: applySkillTagOrder(settings.tags, userOrder),
|
||||||
userOrder,
|
userOrder,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -121,7 +133,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
userOrder: tagIds,
|
userOrder: tagIds,
|
||||||
tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
|
tags: applySkillTagOrder(settings.tags, tagIds),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CanvasSkillTags API] Save order error:", error);
|
console.error("[CanvasSkillTags API] Save order error:", error);
|
||||||
@ -140,12 +152,10 @@ 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,
|
||||||
};
|
};
|
||||||
@ -173,7 +183,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, enabled, title, categories, tags });
|
return NextResponse.json({ success: true, enabled, title, 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 });
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { ZodError } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
|
||||||
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
|
||||||
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
|
||||||
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
|
||||||
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
|
||||||
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const maxDuration = 600;
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
{ 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 requestId = new URL(request.url).searchParams.get("requestId");
|
|
||||||
if (!requestId) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
capabilities: getCanvasCapabilityManifest(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const task = await getCanvasCapabilityTask({
|
|
||||||
requestId,
|
|
||||||
userId: session.user.id,
|
|
||||||
projectId: id,
|
|
||||||
});
|
|
||||||
if (!task) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "能力任务不存在" },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json({ success: true, task });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
|
||||||
const result = await executeCanvasCapabilityRuntime({
|
|
||||||
command,
|
|
||||||
projectId: id,
|
|
||||||
requestHeaders: request.headers,
|
|
||||||
requestUrl: request.url,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ success: true, ...result });
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof CanvasCapabilityRuntimeError ||
|
|
||||||
error instanceof CanvasAgentMutationError
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: error.message, code: error.code },
|
|
||||||
{ status: error.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error instanceof ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "画布能力参数无效",
|
|
||||||
issues: error.issues,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -192,53 +192,23 @@ export async function PUT(
|
|||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const nextWorkspaceType = workspaceType || "canvas";
|
// 创建新画布(使用指定 ID)
|
||||||
const nextCanvasData = syncedCanvasData || {
|
|
||||||
nodes: [],
|
|
||||||
edges: [],
|
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
|
||||||
};
|
|
||||||
const nextTitle = 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(insertData)
|
.values({
|
||||||
.onConflictDoUpdate({
|
id, // 使用指定的 ID
|
||||||
target: canvasProject.id,
|
userId: session.user.id,
|
||||||
set: conflictUpdateData,
|
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
|
||||||
where: eq(canvasProject.userId, session.user.id),
|
description,
|
||||||
|
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) {
|
||||||
|
|||||||
@ -1,27 +1,11 @@
|
|||||||
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, getSystemSetting } from "@/lib/db/queries";
|
import { getChatById } 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(),
|
||||||
@ -31,13 +15,7 @@ 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(
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
{
|
|
||||||
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));
|
||||||
@ -50,43 +28,13 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelKey = buildClawdCancelKey({
|
const active = cancelClawdRequest(
|
||||||
userId: session.user.id,
|
buildClawdCancelKey({
|
||||||
chatId: parsed.data.id,
|
userId: session.user.id,
|
||||||
userMessageId: parsed.data.userMessageId,
|
chatId: parsed.data.id,
|
||||||
});
|
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleted = await deletePersistedClawdTask({
|
return NextResponse.json({ canceled: true, active });
|
||||||
chatId: parsed.data.id,
|
|
||||||
userMessageId: parsed.data.userMessageId,
|
|
||||||
}).catch(() => []);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
canceled: true,
|
|
||||||
active,
|
|
||||||
deleted: deleted.length > 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,141 +0,0 @@
|
|||||||
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,10 +1,6 @@
|
|||||||
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;
|
||||||
@ -13,13 +9,7 @@ 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(
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
{
|
|
||||||
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,59 +1,34 @@
|
|||||||
import { after, 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 {
|
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 DEFAULT_PROCESSING_HINT =
|
const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试";
|
||||||
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
|
||||||
|
|
||||||
export const maxDuration = 1800;
|
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
@ -61,63 +36,12 @@ 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 = Omit<
|
type ParsedClawdChatRequest = z.infer<typeof requestSchema> & {
|
||||||
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 对话";
|
||||||
@ -133,15 +57,17 @@ function getOpenClawUserName(user: {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return String(user.name || user.email || user.id).trim();
|
const name = String(user.name || "").trim();
|
||||||
|
if (name) return name;
|
||||||
|
|
||||||
|
const email = String(user.email || "").trim();
|
||||||
|
if (email) return email;
|
||||||
|
|
||||||
|
return user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMetadataString(
|
function getOpenClawDepartmentName(department?: string | null) {
|
||||||
metadata: Record<string, unknown> | undefined,
|
return String(department || "").trim();
|
||||||
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 {
|
||||||
@ -160,39 +86,25 @@ 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));
|
||||||
if (!parsed.success) return null;
|
return parsed.success ? { ...parsed.data, tableFiles: [] } : 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;
|
||||||
|
|
||||||
let metadata: Record<string, unknown> | undefined;
|
|
||||||
let attachments: unknown[] = [];
|
|
||||||
let sensitiveInput: unknown;
|
|
||||||
const metadataValue = formData.get("metadata");
|
const metadataValue = formData.get("metadata");
|
||||||
const attachmentsValue = formData.get("attachments");
|
let metadata: Record<string, unknown> | undefined;
|
||||||
const sensitiveInputValue = formData.get("sensitiveInput");
|
if (typeof metadataValue === "string" && metadataValue.trim()) {
|
||||||
try {
|
try {
|
||||||
if (typeof metadataValue === "string" && metadataValue.trim()) {
|
|
||||||
const parsedMetadata = JSON.parse(metadataValue);
|
const parsedMetadata = JSON.parse(metadataValue);
|
||||||
if (!parsedMetadata || typeof parsedMetadata !== "object") return null;
|
if (parsedMetadata && typeof parsedMetadata === "object") {
|
||||||
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({
|
||||||
@ -201,275 +113,68 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const tableFiles = formData
|
||||||
...parsed.data,
|
.getAll("tableFiles")
|
||||||
attachments: normalizedAttachments,
|
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0);
|
||||||
tableFiles: formData
|
|
||||||
.getAll("tableFiles")
|
return { ...parsed.data, tableFiles };
|
||||||
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readOpenClawConfig() {
|
async function readOpenClawConfig() {
|
||||||
const setting = await getSystemSetting({
|
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
|
||||||
key: OPENCLAW_CHAT_CONFIG_KEY,
|
() => null
|
||||||
}).catch(() => null);
|
);
|
||||||
return normalizeOpenClawChatConfig(setting?.value);
|
return normalizeOpenClawChatConfig(setting?.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAnswer(payload: unknown) {
|
function getOpenClawErrorCode(payload: unknown) {
|
||||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
if (!payload || typeof payload !== "object") return "";
|
||||||
return "";
|
const raw = payload as Record<string, unknown>;
|
||||||
}
|
const nestedError =
|
||||||
return String((payload as Record<string, unknown>).answer || "").trim();
|
raw.error && typeof raw.error === "object"
|
||||||
|
? (raw.error as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const code = nestedError?.code || raw.code;
|
||||||
|
return typeof code === "string" ? code.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRequestId(payload: unknown) {
|
function getMetadataString(
|
||||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
metadata: Record<string, unknown> | undefined,
|
||||||
return null;
|
key: string
|
||||||
}
|
) {
|
||||||
const value = (payload as Record<string, unknown>).request_id;
|
const value = metadata?.[key];
|
||||||
return value ? String(value) : null;
|
return typeof value === "string" ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
function createCanceledResponse() {
|
||||||
const controller = new AbortController();
|
return NextResponse.json({ canceled: true }, { status: 499 });
|
||||||
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(
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
{
|
|
||||||
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 (
|
if (!parsed.message && parsed.tableFiles.length === 0) {
|
||||||
!parsed.message &&
|
return NextResponse.json({ error: "请输入问题或上传表格" }, { status: 400 });
|
||||||
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 || !config.baseUrl || !config.token) {
|
if (!config.enabled) {
|
||||||
|
return NextResponse.json({ error: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 });
|
||||||
|
}
|
||||||
|
if (!config.baseUrl || !config.token) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: OPENCLAW_UPGRADE_RETRY_MESSAGE },
|
{ error: CLAW_UPGRADE_RETRY_MESSAGE },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -480,22 +185,13 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userMessageId = parsed.userMessageId || generateUUID();
|
const userMessageId = parsed.userMessageId || generateUUID();
|
||||||
const existingTask = await getPersistedClawdTask({
|
const cancelKey = buildClawdCancelKey({
|
||||||
|
userId: session.user.id,
|
||||||
chatId: parsed.id,
|
chatId: parsed.id,
|
||||||
userMessageId,
|
userMessageId,
|
||||||
}).catch(() => null);
|
});
|
||||||
if (existingTask) {
|
if (isClawdRequestCanceled(cancelKey)) {
|
||||||
return NextResponse.json(
|
return createCanceledResponse();
|
||||||
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<
|
||||||
@ -525,23 +221,10 @@ export async function POST(request: Request) {
|
|||||||
message: parsed.message,
|
message: parsed.message,
|
||||||
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
||||||
});
|
});
|
||||||
const spreadsheetAttachments: ClawdSpreadsheetAttachment[] = (
|
|
||||||
spreadsheetMarkdownResult?.files || []
|
if (isClawdRequestCanceled(cancelKey)) {
|
||||||
).map((summary, index) => {
|
return createCanceledResponse();
|
||||||
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({
|
||||||
@ -553,91 +236,220 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assistantMessageId = generateUUID();
|
const assistantMessageId = generateUUID();
|
||||||
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
|
const conversationId = createConversationId(session.user.id, parsed.id);
|
||||||
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
|
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
|
||||||
const processingHint =
|
const openClawUserName = getOpenClawUserName({
|
||||||
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"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
await persistPendingClawdTask({
|
|
||||||
chatId: parsed.id,
|
|
||||||
userMessageId,
|
|
||||||
assistantMessageId,
|
|
||||||
displayMessage,
|
|
||||||
attachments: messageAttachments,
|
|
||||||
spreadsheetAttachments,
|
|
||||||
skillTagId,
|
|
||||||
skillTagName,
|
|
||||||
processingHint,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 cancelKey = buildClawdCancelKey({
|
const openClawDepartmentName = getOpenClawDepartmentName(userProfile?.department);
|
||||||
userId: session.user.id,
|
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
|
||||||
chatId: parsed.id,
|
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
|
||||||
userMessageId,
|
const endpoint = `${config.baseUrl}/api/chat`;
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
let upstreamPayload: unknown = null;
|
||||||
await executeClawdTask({
|
let upstreamStatus = 502;
|
||||||
assistantMessageId,
|
|
||||||
cancelKey,
|
if (isClawdRequestCanceled(cancelKey)) {
|
||||||
chatId: parsed.id,
|
return createCanceledResponse();
|
||||||
config,
|
}
|
||||||
conversationId: createConversationId(session.user.id, parsed.id),
|
|
||||||
departmentName: String(userProfile?.department || "").trim(),
|
try {
|
||||||
metadata: parsed.metadata,
|
const controller = new AbortController();
|
||||||
sensitiveInput: parsed.sensitiveInput,
|
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
|
||||||
mediaAttachments: parsed.attachments,
|
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
||||||
skillTagId,
|
const handleRequestAbort = () => {
|
||||||
skillTagName,
|
cancelClawdRequest(cancelKey);
|
||||||
spreadsheetFiles: spreadsheetMarkdownResult?.files || [],
|
};
|
||||||
spreadsheetMarkdownTruncated:
|
request.signal.addEventListener("abort", handleRequestAbort, { once: true });
|
||||||
spreadsheetMarkdownResult?.truncated || false,
|
const response = await fetch(endpoint, {
|
||||||
upstreamMessage,
|
method: "POST",
|
||||||
userId: session.user.id,
|
headers: {
|
||||||
userMessageId,
|
Authorization: `Bearer ${config.token}`,
|
||||||
userName,
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const responseBody = buildTaskResponse({
|
upstreamStatus = response.status;
|
||||||
|
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,
|
||||||
userMessageId,
|
role: "user",
|
||||||
assistantMessageId,
|
parts: [{ type: "text", text: displayMessage }],
|
||||||
status: "queued",
|
attachments: [],
|
||||||
processingHint,
|
createdAt: userCreatedAt,
|
||||||
});
|
videoStorageStatus: null,
|
||||||
return NextResponse.json(responseBody, {
|
temporaryVideoUrl: null,
|
||||||
status: 202,
|
permanentVideoUrl: null,
|
||||||
headers: {
|
archivedAt: null,
|
||||||
"Cache-Control": "no-store, max-age=0",
|
videoMetadata: {
|
||||||
Location: responseBody.task.statusUrl,
|
composerMode: "clawd",
|
||||||
"Retry-After": "2",
|
source: "openclaw",
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,190 +0,0 @@
|
|||||||
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", "vip98k"].includes(item.provider.type),
|
["zhipu", "alibaba", "openai", "custom", "local"].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", "vip98k"].includes(item.provider.type),
|
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
||||||
);
|
);
|
||||||
const configuredDetailAgentConfig =
|
const configuredDetailAgentConfig =
|
||||||
currentSiteSettings?.canvasDetailAgentModelId &&
|
currentSiteSettings?.canvasDetailAgentModelId &&
|
||||||
|
|||||||
@ -9,25 +9,10 @@ import {
|
|||||||
getCanvasImageTaskForUser,
|
getCanvasImageTaskForUser,
|
||||||
} from "@/lib/canvas/image-task-queue";
|
} from "@/lib/canvas/image-task-queue";
|
||||||
import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
|
import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
|
||||||
import { hasScaleAdjustPositionMoved } from "@/lib/canvas/scale-adjust-geometry";
|
|
||||||
import {
|
|
||||||
buildScaleAdjustProductLockAssets,
|
|
||||||
type CanvasScaleAdjustIntentRegion,
|
|
||||||
} from "@/lib/canvas/scale-adjust-product-lock";
|
|
||||||
import {
|
|
||||||
buildSemanticScaleAdjustCleanupPrompt,
|
|
||||||
buildSemanticScaleAdjustCompositeRegions,
|
|
||||||
buildSemanticScaleAdjustExtractionPrompt,
|
|
||||||
} from "@/lib/canvas/scale-adjust-semantic";
|
|
||||||
import {
|
import {
|
||||||
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 { getImageToolCreditCost } from "@/lib/points/config";
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
@ -110,9 +95,6 @@ const MAX_LOCAL_EDIT_REGIONS = 8;
|
|||||||
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
|
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
|
||||||
const MIN_REGION_EDGE_PX = 24;
|
const MIN_REGION_EDGE_PX = 24;
|
||||||
const SCALE_ADJUST_OPERATION = "scale-adjust";
|
const SCALE_ADJUST_OPERATION = "scale-adjust";
|
||||||
const SCALE_ADJUST_REPAIR_MODEL_REF = "e03d3341-cecd-425e-b88b-9350cbbce18f";
|
|
||||||
const SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF =
|
|
||||||
"5198c20f-b4cc-402c-bc73-eeabacd086b4";
|
|
||||||
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
|
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
|
||||||
const MIN_SCALE_ADJUST_PERCENT = -80;
|
const MIN_SCALE_ADJUST_PERCENT = -80;
|
||||||
const MAX_SCALE_ADJUST_PERCENT = 200;
|
const MAX_SCALE_ADJUST_PERCENT = 200;
|
||||||
@ -536,6 +518,60 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSimpleScaleAdjustPrompt(
|
||||||
|
regions: NormalizedLocalEditRegion[],
|
||||||
|
scalePercent: ScaleAdjustPercent | null | undefined,
|
||||||
|
sourceSize: { width: number; height: number }
|
||||||
|
) {
|
||||||
|
const regionLines = regions
|
||||||
|
.map((region) => {
|
||||||
|
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
|
||||||
|
const targetPixelRect =
|
||||||
|
region.targetPixelRect ||
|
||||||
|
rectToPixels(targetRect, sourceSize.width, sourceSize.height);
|
||||||
|
const sourceRight = region.pixelRect.left + region.pixelRect.width;
|
||||||
|
const sourceBottom = region.pixelRect.top + region.pixelRect.height;
|
||||||
|
const targetRight = targetPixelRect.left + targetPixelRect.width;
|
||||||
|
const targetBottom = targetPixelRect.top + targetPixelRect.height;
|
||||||
|
const widthFactor =
|
||||||
|
region.rect.width > 0 ? targetRect.width / region.rect.width : 1;
|
||||||
|
const heightFactor =
|
||||||
|
region.rect.height > 0 ? targetRect.height / region.rect.height : 1;
|
||||||
|
const moved =
|
||||||
|
Math.hypot(
|
||||||
|
targetRect.x + targetRect.width / 2 - (region.rect.x + region.rect.width / 2),
|
||||||
|
targetRect.y + targetRect.height / 2 - (region.rect.y + region.rect.height / 2)
|
||||||
|
) > 0.01;
|
||||||
|
const action =
|
||||||
|
widthFactor < 0.92 || heightFactor < 0.92
|
||||||
|
? "把同一个主体等比缩小"
|
||||||
|
: widthFactor > 1.08 || heightFactor > 1.08
|
||||||
|
? "把同一个主体等比放大"
|
||||||
|
: "把同一个主体调整到目标位置和大小";
|
||||||
|
return [
|
||||||
|
`区域 ${region.index}:`,
|
||||||
|
`源框 A: left=${region.pixelRect.left}, top=${region.pixelRect.top}, right=${sourceRight}, bottom=${sourceBottom}, width=${region.pixelRect.width}, height=${region.pixelRect.height}.`,
|
||||||
|
`目标框 B: left=${targetPixelRect.left}, top=${targetPixelRect.top}, right=${targetRight}, bottom=${targetBottom}, width=${targetPixelRect.width}, height=${targetPixelRect.height}.`,
|
||||||
|
`目标比例约为宽 ${widthFactor.toFixed(2)}x、高 ${heightFactor.toFixed(2)}x。`,
|
||||||
|
`请识别 A 框内的主要商品/物体/人物主体,只${action},${moved ? "并移动到 B 框中心附近" : "保持在当前位置附近"}。`,
|
||||||
|
].join(" ");
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return [
|
||||||
|
"对输入图片做一次简单的图生图局部比例编辑。",
|
||||||
|
`最终输出保持原图完整画幅和像素尺寸:${sourceSize.width} x ${sourceSize.height} px。`,
|
||||||
|
"只编辑用户框选的主体本身,保持主体外观、材质、文字、图案、透视和宽高比,不要拉伸或压扁。",
|
||||||
|
"不要缩放整张图,不要裁剪整张图,不要重绘未选区域。",
|
||||||
|
"如果主体变小,原位置露出的区域按原图背景自然补齐;白底或纯色背景必须保持干净一致。",
|
||||||
|
"如果主体变大,让同一个主体自然占据更大的目标范围,并保持边缘、阴影和背景衔接自然。",
|
||||||
|
"A/B 坐标只是隐藏编辑指令,最终图片里绝不能出现框线、箭头、文字、标注、UI 或半透明色块。",
|
||||||
|
"",
|
||||||
|
"编辑要求:",
|
||||||
|
regionLines,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -624,23 +660,10 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [pointsConfig, siteSettingsForModel, localEditConfigSetting] = await Promise.all([
|
const [pointsConfig, siteSettingsForModel] = await Promise.all([
|
||||||
getPointsSystemConfig(),
|
getPointsSystemConfig(),
|
||||||
getSiteSettings().catch(() => null),
|
getSiteSettings().catch(() => null),
|
||||||
getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
const localEditConfig = normalizeCanvasLocalEditConfig(
|
|
||||||
localEditConfigSetting?.value
|
|
||||||
);
|
|
||||||
const siteDetailDefaultModelId =
|
|
||||||
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
|
|
||||||
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
|
|
||||||
: "";
|
|
||||||
const configuredLocalEditModelId = localEditConfig.modelId;
|
|
||||||
const configuredScaleAdjustModelId =
|
|
||||||
localEditConfig.scaleAdjustModelId ||
|
|
||||||
siteDetailDefaultModelId ||
|
|
||||||
SCALE_ADJUST_REPAIR_MODEL_REF;
|
|
||||||
const pointsCharge = await prepareFeaturePointsCharge({
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||||||
@ -668,164 +691,45 @@ 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 configuredDetailModel =
|
||||||
|
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
|
||||||
|
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
|
||||||
|
: "";
|
||||||
|
const configuredCanvasModel =
|
||||||
|
typeof siteSettingsForModel?.canvasDefaultModelId === "string"
|
||||||
|
? siteSettingsForModel.canvasDefaultModelId.trim()
|
||||||
|
: "";
|
||||||
|
const configuredAutoCanvasModel =
|
||||||
|
typeof siteSettingsForModel?.canvasAutoModeDefaultModelId === "string"
|
||||||
|
? siteSettingsForModel.canvasAutoModeDefaultModelId.trim()
|
||||||
|
: "";
|
||||||
|
const resolvedModel =
|
||||||
|
requestedModel ||
|
||||||
|
configuredDetailModel ||
|
||||||
|
configuredCanvasModel ||
|
||||||
|
configuredAutoCanvasModel;
|
||||||
const requestedImageSize =
|
const requestedImageSize =
|
||||||
typeof body.imageSize === "string" && body.imageSize.trim()
|
typeof body.imageSize === "string" && body.imageSize.trim()
|
||||||
? body.imageSize.trim()
|
? body.imageSize.trim()
|
||||||
: "2K";
|
: "2K";
|
||||||
const scaleAdjustIntentRegions: CanvasScaleAdjustIntentRegion[] = regions.map(
|
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
|
||||||
(region) => ({
|
region.referenceImages.map((reference) => reference.url)
|
||||||
index: region.index,
|
|
||||||
rect: region.rect,
|
|
||||||
targetRect: getScaleAdjustTargetRect(region, scalePercent),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
const hasPositionMove = scaleAdjustIntentRegions.some((region) =>
|
const prompt = buildSimpleScaleAdjustPrompt(regions, scalePercent, {
|
||||||
hasScaleAdjustPositionMoved(region.rect, region.targetRect)
|
width: sourceWidth,
|
||||||
);
|
height: sourceHeight,
|
||||||
const productLockAssets = await buildScaleAdjustProductLockAssets({
|
|
||||||
protectPeople: hasPositionMove,
|
|
||||||
regions: scaleAdjustIntentRegions,
|
|
||||||
sourceBuffer,
|
|
||||||
sourceHeight,
|
|
||||||
sourceImageUrl,
|
|
||||||
sourceWidth,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
});
|
||||||
const useSemanticScaleAdjust =
|
|
||||||
!hasPositionMove && productLockAssets.semanticFallbackSuggested;
|
|
||||||
const semanticCompositeRegions = useSemanticScaleAdjust
|
|
||||||
? buildSemanticScaleAdjustCompositeRegions(scaleAdjustIntentRegions)
|
|
||||||
: [];
|
|
||||||
const assetSuffix = `${Date.now()}-${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.slice(2, 8)}`;
|
|
||||||
const [
|
|
||||||
modelMaskUpload,
|
|
||||||
overlayUpload,
|
|
||||||
repairMaskUpload,
|
|
||||||
semanticSourceCropUploads,
|
|
||||||
sourceSubjectMaskUpload,
|
|
||||||
] = await Promise.all([
|
|
||||||
uploadFile(
|
|
||||||
`canvas-scale-adjust-mask-${assetSuffix}.png`,
|
|
||||||
toArrayBuffer(productLockAssets.modelMaskBuffer)
|
|
||||||
),
|
|
||||||
uploadFile(
|
|
||||||
`canvas-scale-adjust-overlay-${assetSuffix}.png`,
|
|
||||||
toArrayBuffer(productLockAssets.overlayBuffer)
|
|
||||||
),
|
|
||||||
uploadFile(
|
|
||||||
`canvas-scale-adjust-repair-mask-${assetSuffix}.png`,
|
|
||||||
toArrayBuffer(productLockAssets.repairMaskBuffer)
|
|
||||||
),
|
|
||||||
useSemanticScaleAdjust
|
|
||||||
? Promise.all(
|
|
||||||
productLockAssets.semanticSourceCropBuffers.map(
|
|
||||||
(buffer, index) =>
|
|
||||||
uploadFile(
|
|
||||||
`canvas-scale-adjust-semantic-source-${assetSuffix}-${index + 1}.png`,
|
|
||||||
toArrayBuffer(buffer)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
hasPositionMove || useSemanticScaleAdjust
|
|
||||||
? uploadFile(
|
|
||||||
`canvas-scale-adjust-subject-mask-${assetSuffix}.png`,
|
|
||||||
toArrayBuffer(productLockAssets.sourceSubjectMaskBuffer)
|
|
||||||
)
|
|
||||||
: Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
const backgroundPlateUpload = productLockAssets.backgroundPlateBuffer
|
|
||||||
? await uploadFile(
|
|
||||||
`canvas-scale-adjust-background-${assetSuffix}.png`,
|
|
||||||
toArrayBuffer(productLockAssets.backgroundPlateBuffer)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const prompt = useSemanticScaleAdjust
|
|
||||||
? buildSemanticScaleAdjustExtractionPrompt()
|
|
||||||
: hasPositionMove
|
|
||||||
? "只编辑白色掩膜区域,完整移除旧位置的产品及其原有阴影,并根据周围墙面、地面、家具和光照连续自然地重建被遮挡背景。黑色区域保持不变,不要保留产品的任何部件或残影。"
|
|
||||||
: "移除掩膜中的完整产品并自然还原背景。";
|
|
||||||
const providerImageParams = useSemanticScaleAdjust
|
|
||||||
? {
|
|
||||||
quality: "high",
|
|
||||||
}
|
|
||||||
: hasPositionMove
|
|
||||||
? {
|
|
||||||
maskImageUrl: modelMaskUpload.url,
|
|
||||||
quality: "medium",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
objectRemovalMaskImageUrl: modelMaskUpload.url,
|
|
||||||
objectRemovalMode: "premium",
|
|
||||||
};
|
|
||||||
const productLockComposite = {
|
|
||||||
...(backgroundPlateUpload
|
|
||||||
? { backgroundPlateImageUrl: backgroundPlateUpload.url }
|
|
||||||
: {}),
|
|
||||||
overlayImageUrl: overlayUpload.url,
|
|
||||||
...(hasPositionMove
|
|
||||||
? {
|
|
||||||
repairMaskImageUrl: repairMaskUpload.url,
|
|
||||||
sourceImageUrl,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(hasPositionMove && sourceSubjectMaskUpload
|
|
||||||
? {
|
|
||||||
regions: scaleAdjustIntentRegions.map((region) => ({
|
|
||||||
source: region.rect,
|
|
||||||
target: region.targetRect,
|
|
||||||
})),
|
|
||||||
shadowSourceImageUrl: sourceImageUrl,
|
|
||||||
sourceSubjectMaskImageUrl: sourceSubjectMaskUpload.url,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
sourceHeight,
|
|
||||||
sourceWidth,
|
|
||||||
};
|
|
||||||
const scaleAdjustVerification = {
|
|
||||||
allowOutsideChangedWhenChangedInside: false,
|
|
||||||
deterministicProductOverlayApplied: true,
|
|
||||||
oldFootprintMaskImageUrl: useSemanticScaleAdjust
|
|
||||||
? sourceSubjectMaskUpload?.url
|
|
||||||
: hasPositionMove
|
|
||||||
? sourceSubjectMaskUpload?.url
|
|
||||||
: repairMaskUpload.url,
|
|
||||||
regions: scaleAdjustIntentRegions.map((region) => region.rect),
|
|
||||||
rejectFailedCandidates: true,
|
|
||||||
rejectNoEffectiveChangeOnly: false,
|
|
||||||
rejectOldFootprintOnly: true,
|
|
||||||
sourceImageUrl,
|
|
||||||
targetRegions: scaleAdjustIntentRegions.map((region) => region.targetRect),
|
|
||||||
};
|
|
||||||
const imageRequest: CanvasGenerateImageRequest = {
|
const imageRequest: CanvasGenerateImageRequest = {
|
||||||
allowFailover: false,
|
aspectRatio: resolvedAspectRatio,
|
||||||
aspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
|
|
||||||
billingFeature: "repaint",
|
billingFeature: "repaint",
|
||||||
debugLabel: "canvas-scale-adjust-product-lock",
|
debugLabel: "canvas-scale-adjust-simple",
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
displayPrompt,
|
displayPrompt,
|
||||||
operation,
|
operation,
|
||||||
requestedModel: requestedModel || null,
|
requestedModel: requestedModel || null,
|
||||||
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
|
resolvedAspectRatio,
|
||||||
resolvedModel: configuredScaleAdjustModelId,
|
resolvedModel: resolvedModel || null,
|
||||||
scaleAdjustProductLockMaskEditApplied: true,
|
scaleAdjustPromptStrategy: "simple-image-edit-v1",
|
||||||
scaleAdjustPersonExclusionApplied:
|
|
||||||
productLockAssets.personExclusionApplied,
|
|
||||||
scaleAdjustPromptStrategy: useSemanticScaleAdjust
|
|
||||||
? "gpt-extract-product-lock-v2"
|
|
||||||
: hasPositionMove
|
|
||||||
? "gpt-mask-background-rebuild-product-lock-v1"
|
|
||||||
: "finegrain-erase-product-lock-v1",
|
|
||||||
scaleAdjustProviderMaskImageUrl: useSemanticScaleAdjust
|
|
||||||
? null
|
|
||||||
: modelMaskUpload.url,
|
|
||||||
scaleAdjustSemanticFallbackRegionIndexes:
|
|
||||||
productLockAssets.semanticFallbackRegionIndexes,
|
|
||||||
scaleAdjustSemanticFallbackSuggested:
|
|
||||||
productLockAssets.semanticFallbackSuggested,
|
|
||||||
scaleAdjustSequentialCleanupRetry: !useSemanticScaleAdjust,
|
|
||||||
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
|
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
|
||||||
.length,
|
.length,
|
||||||
scalePercent,
|
scalePercent,
|
||||||
@ -836,80 +740,12 @@ export async function POST(request: NextRequest) {
|
|||||||
displayPrompt,
|
displayPrompt,
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
imageSize: requestedImageSize,
|
imageSize: requestedImageSize,
|
||||||
model: configuredScaleAdjustModelId,
|
model: resolvedModel || undefined,
|
||||||
negativePrompt:
|
negativePrompt:
|
||||||
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
|
"不要画框线、箭头、坐标、标注、文字或 UI。不要缩放整张图、裁剪整张图、重绘未选区域。不要拉伸、压扁、复制或替换主体。",
|
||||||
prompt,
|
prompt,
|
||||||
promptExtend: false,
|
promptExtend: false,
|
||||||
providerImageParams,
|
referenceImages: [sourceImageUrl, ...manualRegionReferenceImageUrls],
|
||||||
referenceImages: [sourceImageUrl],
|
|
||||||
...(useSemanticScaleAdjust
|
|
||||||
? {
|
|
||||||
localEditComposite: {
|
|
||||||
expandRegions: false,
|
|
||||||
featherMaxPx: 28,
|
|
||||||
featherMinPx: 12,
|
|
||||||
featherRatio: 0.012,
|
|
||||||
maskMode: "region" as const,
|
|
||||||
regions: semanticCompositeRegions,
|
|
||||||
sourceImageUrl,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(useSemanticScaleAdjust &&
|
|
||||||
semanticSourceCropUploads.length === scaleAdjustIntentRegions.length
|
|
||||||
? {
|
|
||||||
semanticScaleAdjustPreparation: {
|
|
||||||
cleanupImageSize: "2K",
|
|
||||||
cleanupModel: SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF,
|
|
||||||
cleanupPrompt: buildSemanticScaleAdjustCleanupPrompt(),
|
|
||||||
extractionPrompt: buildSemanticScaleAdjustExtractionPrompt(),
|
|
||||||
regions: scaleAdjustIntentRegions.map((region, index) => ({
|
|
||||||
source: region.rect,
|
|
||||||
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
|
|
||||||
target: region.targetRect,
|
|
||||||
})),
|
|
||||||
relightModel: configuredScaleAdjustModelId,
|
|
||||||
sourceImageUrl,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
retryVariants: useSemanticScaleAdjust ? [] : [
|
|
||||||
{
|
|
||||||
debugMeta: {
|
|
||||||
scaleAdjustRetryReason: "old-footprint-remnant-detected",
|
|
||||||
scaleAdjustRetryStrategy: "sequential-finegrain-cleanup",
|
|
||||||
},
|
|
||||||
label: "finegrain-cleanup-pass",
|
|
||||||
prompt,
|
|
||||||
providerImageParams,
|
|
||||||
referenceImages: [sourceImageUrl],
|
|
||||||
scaleAdjustProductLockComposite: productLockComposite,
|
|
||||||
scaleAdjustVerification: {
|
|
||||||
...scaleAdjustVerification,
|
|
||||||
rejectNoEffectiveChangeOnly: true,
|
|
||||||
rejectOldFootprintOnly: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...(useSemanticScaleAdjust
|
|
||||||
? {}
|
|
||||||
: { scaleAdjustProductLockComposite: productLockComposite }),
|
|
||||||
...(hasPositionMove
|
|
||||||
? {
|
|
||||||
scaleAdjustModelRelight: {
|
|
||||||
model: configuredScaleAdjustModelId,
|
|
||||||
overlayImageUrl: overlayUpload.url,
|
|
||||||
quality: "medium" as const,
|
|
||||||
regions: scaleAdjustIntentRegions.map((region) => ({
|
|
||||||
target: region.targetRect,
|
|
||||||
})),
|
|
||||||
sourceHeight,
|
|
||||||
sourceWidth,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
scaleAdjustVerification,
|
|
||||||
ownerKey: session.user.id,
|
ownerKey: session.user.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -935,7 +771,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
annotatedReferenceUrl: overlayUpload.url,
|
annotatedReferenceUrl: sourceImageUrl,
|
||||||
aspectRatio: resolvedAspectRatio,
|
aspectRatio: resolvedAspectRatio,
|
||||||
displayPrompt,
|
displayPrompt,
|
||||||
prompt: displayPrompt,
|
prompt: displayPrompt,
|
||||||
@ -997,7 +833,6 @@ export async function POST(request: NextRequest) {
|
|||||||
displayPrompt,
|
displayPrompt,
|
||||||
operation,
|
operation,
|
||||||
requestedModel: requestedModel || null,
|
requestedModel: requestedModel || null,
|
||||||
resolvedModel: requestedModel || configuredLocalEditModelId || null,
|
|
||||||
resolvedAspectRatio,
|
resolvedAspectRatio,
|
||||||
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
|
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
|
||||||
regionReferenceImageCount: referenceImages.length,
|
regionReferenceImageCount: referenceImages.length,
|
||||||
@ -1009,7 +844,7 @@ export async function POST(request: NextRequest) {
|
|||||||
displayPrompt,
|
displayPrompt,
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
imageSize: requestedImageSize,
|
imageSize: requestedImageSize,
|
||||||
model: requestedModel || configuredLocalEditModelId || undefined,
|
model: requestedModel || undefined,
|
||||||
negativePrompt:
|
negativePrompt:
|
||||||
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
|
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
|
||||||
prompt,
|
prompt,
|
||||||
|
|||||||
@ -11,10 +11,6 @@ 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";
|
||||||
@ -55,10 +51,6 @@ 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",
|
||||||
@ -123,14 +115,6 @@ 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":
|
||||||
@ -483,7 +467,7 @@ async function generateTextEditImageWithFailover(params: {
|
|||||||
const runtimeModelId = resolveModelRuntimeId(selected.model, {
|
const runtimeModelId = resolveModelRuntimeId(selected.model, {
|
||||||
providerType: selected.provider.type,
|
providerType: selected.provider.type,
|
||||||
});
|
});
|
||||||
const generationOptions: any = {
|
const result = await provider.chat(runtimeModelId).doGenerate({
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
@ -496,18 +480,7 @@ 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();
|
||||||
|
|||||||
@ -440,6 +440,45 @@ function expandStoryScriptRowsToTarget(
|
|||||||
return expanded.slice(0, targetCount);
|
return expanded.slice(0, targetCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLocalStoryScriptRows(): NormalizedStoryScriptRow[] {
|
||||||
|
const rows = [
|
||||||
|
["01", "5s", "现代办公室", "深夜办公室里,办公电脑发出刺眼的蓝光。@现代沈昭昭伏案加班,面色苍白,表情极度疲惫,双手在键盘上飞速敲击。", "中景", "冷蓝灰低饱和,屏幕蓝光照亮脸部,压抑疲惫", "+", "急促沉闷的键盘敲击声,连续的手机提示音", "缓慢推镜头"],
|
||||||
|
["02", "5s", "现代办公室", "@现代沈昭昭抬头看向屏幕,眼神涣散,视线逐渐模糊,双眼缓缓闭上。画面瞬间陷入黑屏。", "特写", "光线逐渐变暗直至完全黑屏", "+", "心跳声骤停,刺耳的耳鸣声", "固定,焦点逐渐模糊"],
|
||||||
|
["03", "6s", "盛唐·金銮殿·日", "@金銮殿内,强烈的阳光从殿门射入。@沈昭昭猛然睁开眼睛,发现自己身着男装官服,正双手被押跪在大殿中央。", "全景", "极其明亮的暖金色调,反差强烈的庄严感", "+", "震天的威武鼓声,空旷的大殿回音", "快速摇镜头,从下往上展现大殿"],
|
||||||
|
["04", "5s", "盛唐·金銮殿·日", "镜头转向一侧,一名紫袍大臣满脸怒容,指着@沈昭昭厉声斥责,步步紧逼。", "中近景", "暖金主光带阴影,气氛紧张压抑", "大逆不道!假称诗才惊世,欺君罔上,当斩!", "脚步声重重踏在木地板上", "手持微晃跟拍"],
|
||||||
|
["05", "5s", "盛唐·金銮殿·日", "@沈昭昭跪在地上,满脸震惊与茫然,瞳孔微震,低声吐出几个字。", "特写", "暖金光影,充满不安感", "这是……唐朝?", "急促的呼吸声,环境静谧", "平滑微推"],
|
||||||
|
["06", "6s", "盛唐·金銮殿·日", "高高的龙椅之上,@皇帝目光沉沉地俯视下方,神情威严,极具压迫感地发话。", "中景", "金黄色主调,庄重威严不可侵犯", "既言才华盖世,当殿作诗。若不能--斩。", "衣物摩擦声,低沉浑厚的嗓音", "缓慢推镜头,轻微仰拍"],
|
||||||
|
["07", "5s", "盛唐·金銮殿·日", "随着话音落下,侍卫猛然拔出@侍卫长刀,冰冷的刀光瞬间映照在@沈昭昭惊恐的瞳孔中。", "大特写", "强烈的明暗对比,刀光如电", "+", "清脆刺耳的金属拔刀声,全场死寂", "极快推向眼睛"],
|
||||||
|
["08", "6s", "盛唐·金銮殿·日", "大殿死寂中,@沈昭昭闭上眼睛深呼吸,顶着寒芒缓缓站直身体,表情从慌乱彻底变为镇定。", "近景", "暖金光打在背上,驱散阴影,气场反转", "+", "绵长深沉的呼吸声", "伴随人物起身向上摇镜头"],
|
||||||
|
["09", "5s", "盛唐·金銮殿·日", "顺着她的视线看去,越过敞开的殿门,外面是壮丽的@长安城白昼,阳光万里,气象万千。", "大远景", "极度开阔明亮,充满生机与宏大感", "+", "微风声,隐约的市井钟声", "从室内越过门框向前推进"],
|
||||||
|
["10", "5s", "盛唐·金銮殿·日", "@沈昭昭目光变得极其锐利,嘴角勾起一抹自信的微笑,轻声低语宣告决心。", "特写", "暖光充盈,充满一触即发的力量感", "既来盛唐……便与盛唐争一争。", "心跳声重新响起,坚定有力", "静止,稳定构图"],
|
||||||
|
["11", "6s", "盛唐·金銮殿·日", "@沈昭昭猛然抬头,张开双臂,以极具爆发力的姿态向着殿外高声吟诵千古绝句。", "中景", "光芒万丈,极具震撼的视觉冲击力", "君不见黄河之水天上来--", "极具穿透力的朗诵声,音浪轰鸣", "极速拉开并转为轻微仰拍"],
|
||||||
|
["12", "5s", "盛唐·金銮殿·日", "诗句如惊雷般在殿内炸开,两旁的群臣面面相觑,脸上露出极度震惊与不可思议的表情。", "全景", "暖金光影,局势瞬间颠覆", "+", "倒吸凉气声,慌乱的窃窃私语", "快速平移横扫群臣"],
|
||||||
|
["13", "5s", "盛唐·金銮殿·日", "原本端坐的@皇帝被这惊世骇俗的诗句彻底震撼,双眼大睁,双手紧抓龙椅扶手,身体前倾缓缓站起。", "中近景", "皇帝周围的气压仿佛被点燃,极具戏剧张力", "+", "沉重的衣袍摩擦声,王座轻微吱呀声", "缓慢向上推,跟随起身动势"],
|
||||||
|
["14", "5s", "盛唐·金銮殿·日", "伴随着连绵不断的吟诵,殿外忽起大风吹入大殿,@沈昭昭青绿色的衣袖疯狂翻飞,宛如文曲星降世。", "全景", "风起云涌,光影随风摇曳,豪情万丈", "+", "呼啸的风声,激昂的背景音乐推向高潮", "环绕轨道跟拍"],
|
||||||
|
["15", "5s", "长安城夜景", "画面通过殿门的光影流转,平滑转场至@长安城夜景,万家灯火连绵不绝,孔明灯升空。", "大远景", "浪漫璀璨,时间流逝后的盛世繁华", "+", "余音绕梁的空灵回声,悠扬古风音乐", "向上缓慢摇升"],
|
||||||
|
["16", "5s", "盛唐·金銮殿·夜", "@皇帝站在大殿边缘,背负双手眺望远方夜色,脸上满是折服,低声给出至高定论。", "中景", "深沉平静,尽显惜才之心", "此诗,当传万世。", "夜风拂过,语气深沉悠远", "静止,人物居中侧面"],
|
||||||
|
["17", "5s", "长安城夜景", "@沈昭昭并肩看着殿外的万家灯火,目光中再无一丝迷茫,心中燃起万丈豪情,轻声立下宏愿。", "近景", "充满希望与野心,情绪达到最高点", "既然来了……那便写尽三万里长安。", "轻柔但坚定的吐字声", "侧后方缓缓推近面部侧颜"],
|
||||||
|
["18", "6s", "长安城夜景", "镜头快速向高空推远,整个@长安城夜景与无尽的盛唐宏大山河画卷徐徐铺陈开来,气吞山河。", "大远景", "大气磅礴,余韵无穷的史诗质感", "+", "激昂澎湃的古风管弦乐骤然爆发,随后戛然而止留白", "极速拉高、后退,展露全貌"],
|
||||||
|
];
|
||||||
|
|
||||||
|
return rows.map(([shot, duration, scene, sceneDescription, shotSize, lighting, dialogue, voiceAndSfx, cameraMovement], index) => ({
|
||||||
|
shot,
|
||||||
|
duration,
|
||||||
|
scene,
|
||||||
|
sceneDescription,
|
||||||
|
shotSize,
|
||||||
|
lighting,
|
||||||
|
dialogue,
|
||||||
|
backgroundMusic: index >= 10 ? "激昂古风管弦乐" : "低频史诗氛围",
|
||||||
|
voiceAndSfx,
|
||||||
|
cameraMovement,
|
||||||
|
imagePrompt: `${shotSize},${sceneDescription} ${lighting},构图清晰,角色与场景资产保持一致。[视觉风格:盛唐·国风二次元]`,
|
||||||
|
motionPrompt: `起始状态:${sceneDescription} 动作过程:${cameraMovement},主体动作自然连贯。结束状态:画面情绪与下一镜头衔接。音效:${voiceAndSfx}。[视觉风格:盛唐·国风二次元]`,
|
||||||
|
keyframeUrl: "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCharacters(value: unknown) {
|
function normalizeCharacters(value: unknown) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value
|
return value
|
||||||
@ -800,6 +839,30 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldUseLocalWorkflowE2E(request) && parsed.data.mode === "story-script") {
|
||||||
|
const analysisRows = buildLocalStoryScriptRows();
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
summary: "本地测试脚本:现代职场女性穿越盛唐,在金銮殿以诗才逆转死局并立下宏愿。",
|
||||||
|
analysisRows,
|
||||||
|
assetCandidates: {
|
||||||
|
style: "盛唐·国风二次元",
|
||||||
|
characters: ["现代沈昭昭", "沈昭昭", "皇帝"],
|
||||||
|
scenes: ["现代办公室", "盛唐·金銮殿", "长安城白昼", "长安城夜景"],
|
||||||
|
props: ["侍卫长刀"],
|
||||||
|
},
|
||||||
|
shotIdeas: analysisRows.map((row) => ({
|
||||||
|
title: `镜头 ${row.shot}`,
|
||||||
|
description: row.sceneDescription,
|
||||||
|
})),
|
||||||
|
recommendedNextSteps: ["准备角色/场景/道具资产", "合成分镜图提示词", "批量生成分镜", "批量生视频"],
|
||||||
|
model: "Local E2E Story Script",
|
||||||
|
raw: JSON.stringify({ analysisRows }, null, 2),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
|
|||||||
@ -7,16 +7,9 @@ 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 {
|
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||||
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,
|
||||||
@ -426,214 +419,6 @@ 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 }>;
|
||||||
}
|
}
|
||||||
@ -667,187 +452,6 @@ 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,23 +12,13 @@ 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 {
|
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||||
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 {
|
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
|
||||||
WorldLabsExternalTaskCreateData,
|
|
||||||
WorldLabsExternalTaskResponse,
|
|
||||||
WorldLabsModel,
|
|
||||||
WorldLabsOperation,
|
|
||||||
} from "@/lib/worldlabs/types";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 180;
|
export const maxDuration = 180;
|
||||||
@ -57,10 +47,8 @@ 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;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@ -70,89 +58,10 @@ 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);
|
||||||
|
|
||||||
@ -171,21 +80,15 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
|
|
||||||
if (mode === "image") {
|
if (mode === "image") {
|
||||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
if (!mediaAssetId) {
|
||||||
if (!mediaAssetId && !sourceUrl) {
|
throw new Error("图生世界缺少 mediaAssetId");
|
||||||
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: "image",
|
type: "image",
|
||||||
image_prompt: mediaAssetId
|
image_prompt: {
|
||||||
? {
|
source: "media_asset",
|
||||||
source: "media_asset",
|
media_asset_id: mediaAssetId,
|
||||||
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 }
|
||||||
@ -199,8 +102,7 @@ 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);
|
||||||
const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
|
if (!mediaAssetId) return null;
|
||||||
if (!mediaAssetId && !sourceUrl) return null;
|
|
||||||
const azimuth =
|
const azimuth =
|
||||||
item?.azimuth === null || item?.azimuth === undefined
|
item?.azimuth === null || item?.azimuth === undefined
|
||||||
? null
|
? null
|
||||||
@ -209,15 +111,10 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
...(azimuth === null ? {} : { azimuth }),
|
...(azimuth === null ? {} : { azimuth }),
|
||||||
content: mediaAssetId
|
content: {
|
||||||
? {
|
source: "media_asset",
|
||||||
source: "media_asset",
|
media_asset_id: mediaAssetId,
|
||||||
media_asset_id: mediaAssetId,
|
},
|
||||||
}
|
|
||||||
: {
|
|
||||||
source: "uri",
|
|
||||||
uri: sourceUrl,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@ -240,22 +137,16 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
if (!mediaAssetId) {
|
||||||
if (!mediaAssetId && !sourceUrl) {
|
throw new Error("视频生成世界缺少 mediaAssetId");
|
||||||
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "video",
|
type: "video",
|
||||||
video_prompt: mediaAssetId
|
video_prompt: {
|
||||||
? {
|
source: "media_asset",
|
||||||
source: "media_asset",
|
media_asset_id: mediaAssetId,
|
||||||
media_asset_id: mediaAssetId,
|
},
|
||||||
}
|
|
||||||
: {
|
|
||||||
source: "uri",
|
|
||||||
uri: sourceUrl,
|
|
||||||
},
|
|
||||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -296,29 +187,12 @@ 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(pointsConfig, predictedBillingModel);
|
const unitCost = get3DWorldCreditCost(
|
||||||
|
pointsConfig,
|
||||||
|
resolvedWorldModel || "marble-1.1",
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldChargePoints &&
|
shouldChargePoints &&
|
||||||
@ -346,51 +220,42 @@ 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 {
|
||||||
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
|
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||||
if (useExternalTaskApi) {
|
method: "POST",
|
||||||
const prompt = readOptionalTrimmedText(body.prompt);
|
path: "/marble/v1/worlds:generate",
|
||||||
if (!prompt) {
|
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||||
throw new Error("请输入世界描述");
|
json: {
|
||||||
}
|
display_name: displayName,
|
||||||
const externalResult = await worldLabsRequest<
|
model: resolvedWorldModel || "marble-1.1",
|
||||||
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
permission: {
|
||||||
>(candidate, {
|
public: body.makePublic === true,
|
||||||
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) {
|
||||||
@ -427,18 +292,12 @@ 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:${usedBillingModel}`,
|
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
|
||||||
note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
|
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("points consume failed", error, {
|
logger.error("points consume failed", error, {
|
||||||
@ -454,13 +313,10 @@ export async function POST(request: Request) {
|
|||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
taskIdLength: taskId.length,
|
taskIdLength: taskId.length,
|
||||||
mode,
|
mode,
|
||||||
model: usedExternalTaskApi
|
model: resolvedWorldModel || "marble-1.1",
|
||||||
? 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({
|
||||||
@ -471,8 +327,6 @@ 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,11 +1,7 @@
|
|||||||
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 { uploadFile } from "@/lib/storage/service";
|
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||||
import {
|
|
||||||
WorldLabsApiError,
|
|
||||||
worldLabsRequest,
|
|
||||||
} from "@/lib/worldlabs/client";
|
|
||||||
import {
|
import {
|
||||||
getWorldLabsProviderConfig,
|
getWorldLabsProviderConfig,
|
||||||
getWorldLabsProviderConfigs,
|
getWorldLabsProviderConfigs,
|
||||||
@ -116,81 +112,6 @@ 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 &&
|
||||||
@ -281,24 +202,24 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
||||||
const { uploadUrl, uploadMethod, requiredHeaders } =
|
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
|
||||||
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 normalizedUploadMethod = uploadMethod.toUpperCase();
|
const uploadMethod = String(
|
||||||
|
prepareUpload.upload_info?.upload_method || "PUT"
|
||||||
|
).toUpperCase();
|
||||||
const uploadResponse = await fetch(uploadUrl, {
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
|
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
|
||||||
headers: uploadHeaders,
|
headers: uploadHeaders,
|
||||||
body: file,
|
body: file,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@ -331,28 +252,6 @@ 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,7 +2,6 @@ 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";
|
||||||
|
|
||||||
@ -168,12 +167,9 @@ 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: persistedChatId,
|
chatId: event.chatId || null,
|
||||||
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,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
createMediaAssetRelation,
|
createMediaAssetRelation,
|
||||||
getAiProvidersByType,
|
|
||||||
getMediaAssetsByUserAndUrls,
|
getMediaAssetsByUserAndUrls,
|
||||||
getSystemSetting,
|
getSystemSetting,
|
||||||
upsertMediaAsset,
|
upsertMediaAsset,
|
||||||
@ -10,12 +9,9 @@ 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"
|
||||||
@ -321,155 +317,6 @@ 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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轮询获取任务结果(根据官方示例)
|
* 轮询获取任务结果(根据官方示例)
|
||||||
*/
|
*/
|
||||||
@ -689,42 +536,6 @@ 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();
|
||||||
@ -752,12 +563,6 @@ 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 轮询" },
|
||||||
@ -846,15 +651,12 @@ 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 = !useVip98k && config.useBackupApi === true;
|
const useBackupApi = config.useBackupApi === true;
|
||||||
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
|
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
|
||||||
|
|
||||||
if (useVip98k) {
|
if (useBackupApi) {
|
||||||
// VIP98K 密钥只从 AiProvider 读取。
|
|
||||||
} else if (useBackupApi) {
|
|
||||||
// 验证备用API配置
|
// 验证备用API配置
|
||||||
if (!config.backupApiUrl || !config.backupApiKey) {
|
if (!config.backupApiUrl || !config.backupApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -955,31 +757,6 @@ 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方式)...");
|
||||||
@ -1117,21 +894,6 @@ 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变清晰处理失败",
|
||||||
@ -1287,22 +1049,6 @@ export async function POST(request: NextRequest) {
|
|||||||
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) {
|
||||||
if (fetchError.name === "AbortError") {
|
if (fetchError.name === "AbortError") {
|
||||||
@ -1368,21 +1114,6 @@ 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,6 +21,103 @@ 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(),
|
||||||
@ -197,8 +294,10 @@ 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({
|
||||||
@ -245,7 +344,6 @@ 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,
|
||||||
@ -369,6 +467,21 @@ 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,
|
||||||
@ -376,5 +489,9 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
if (releaseExternalImageApiSlot) {
|
||||||
|
releaseExternalImageApiSlot();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,138 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getProviderDefaultBaseUrl } from "@/lib/ai/provider-defaults";
|
|
||||||
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
|
|
||||||
import { getMultimodalAiModelsWithProvider } from "@/lib/db/queries";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
import { isPascalInternalRequest } from "@/lib/pascal/internal-auth";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const maxDuration = 600;
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const logger = createLogger("pascal-model-gateway");
|
|
||||||
const OPENAI_COMPATIBLE_PROVIDER_TYPES = new Set([
|
|
||||||
"alibaba",
|
|
||||||
"custom",
|
|
||||||
"openai",
|
|
||||||
"zhipu",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function upstreamChatUrl(baseUrl: string) {
|
|
||||||
const normalized = baseUrl.replace(/\/+$/, "");
|
|
||||||
return normalized.endsWith("/chat/completions")
|
|
||||||
? normalized
|
|
||||||
: `${normalized}/chat/completions`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAiError(message: string, status: number) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: { message, type: "pascal_gateway_error" } },
|
|
||||||
{ status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getConfiguredMultimodalModel() {
|
|
||||||
const candidates = await getMultimodalAiModelsWithProvider();
|
|
||||||
return candidates.find(({ provider }) =>
|
|
||||||
OPENAI_COMPATIBLE_PROVIDER_TYPES.has(provider.type)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
if (!isPascalInternalRequest(request)) {
|
|
||||||
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const configured = await getConfiguredMultimodalModel();
|
|
||||||
if (!configured) {
|
|
||||||
return NextResponse.json({ configured: false, selection: "multimodal" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
configured: true,
|
|
||||||
compatible: OPENAI_COMPATIBLE_PROVIDER_TYPES.has(configured.provider.type),
|
|
||||||
model: configured.model.displayName || configured.model.modelId,
|
|
||||||
provider: configured.provider.name,
|
|
||||||
providerType: configured.provider.type,
|
|
||||||
selection: "multimodal",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
if (!isPascalInternalRequest(request)) {
|
|
||||||
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
const parsed = await request.json();
|
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
||||||
return openAiError("Invalid request body.", 400);
|
|
||||||
}
|
|
||||||
body = parsed as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return openAiError("Invalid JSON body.", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configured = await getConfiguredMultimodalModel();
|
|
||||||
if (!configured) {
|
|
||||||
return openAiError("No enabled multimodal model is configured.", 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { model, provider } = configured;
|
|
||||||
if (!OPENAI_COMPATIBLE_PROVIDER_TYPES.has(provider.type)) {
|
|
||||||
return openAiError(
|
|
||||||
`The configured multimodal provider type '${provider.type}' is not OpenAI-compatible.`,
|
|
||||||
503
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl =
|
|
||||||
provider.baseUrl?.trim() || getProviderDefaultBaseUrl(provider.type) || "";
|
|
||||||
const runtimeModelId = resolveModelRuntimeId(model, {
|
|
||||||
providerType: provider.type,
|
|
||||||
});
|
|
||||||
if (!baseUrl || !runtimeModelId || !provider.apiKey) {
|
|
||||||
return openAiError("The configured multimodal model is incomplete.", 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
const startedAt = Date.now();
|
|
||||||
try {
|
|
||||||
const upstream = await fetch(upstreamChatUrl(baseUrl), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${provider.apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ...body, model: runtimeModelId }),
|
|
||||||
cache: "no-store",
|
|
||||||
signal: request.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("upstream completed", {
|
|
||||||
elapsedMs: Date.now() - startedAt,
|
|
||||||
modelId: model.id,
|
|
||||||
providerId: provider.id,
|
|
||||||
status: upstream.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = new Headers({
|
|
||||||
"Cache-Control": "no-cache, no-transform",
|
|
||||||
"Content-Type":
|
|
||||||
upstream.headers.get("content-type") || "application/json; charset=utf-8",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
});
|
|
||||||
return new Response(upstream.body, { status: upstream.status, headers });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("upstream failed", {
|
|
||||||
elapsedMs: Date.now() - startedAt,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
modelId: model.id,
|
|
||||||
providerId: provider.id,
|
|
||||||
});
|
|
||||||
return openAiError(
|
|
||||||
"The configured multimodal model is temporarily unavailable.",
|
|
||||||
502
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { isPascalInternalRequest } from "@/lib/pascal/internal-auth";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
if (!isPascalInternalRequest(request)) {
|
|
||||||
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await auth();
|
|
||||||
const userId = session?.user?.id?.trim();
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(null, {
|
|
||||||
status: 204,
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
"X-Pascal-User-Id": userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { runImageArchiveJob } from "@/lib/jobs/image-archive-job";
|
|
||||||
import { getImageArchiveTaskStats } from "@/lib/storage/image-archive-db";
|
|
||||||
|
|
||||||
function verifyAuthToken(request: Request): boolean {
|
|
||||||
const authHeader = request.headers.get("authorization");
|
|
||||||
const token = authHeader?.replace("Bearer ", "");
|
|
||||||
const cronSecret = process.env.CRON_SECRET;
|
|
||||||
|
|
||||||
if (!cronSecret) {
|
|
||||||
console.error("[ImageArchiveAPI] CRON_SECRET is not configured");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return token === cronSecret;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
if (!verifyAuthToken(request)) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await runImageArchiveJob();
|
|
||||||
return NextResponse.json({
|
|
||||||
...result,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[ImageArchiveAPI] Job execution failed:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
try {
|
|
||||||
if (!verifyAuthToken(request)) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await getImageArchiveTaskStats();
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
stats,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[ImageArchiveAPI] Stats query failed:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { createCanvasCapabilityMcpServer } from "@/lib/canvas/capabilities/mcp-server";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const maxDuration = 600;
|
|
||||||
|
|
||||||
async function handleMcpRequest(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "未登录,无法访问画布 MCP 服务" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: projectId } = await params;
|
|
||||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
||||||
enableJsonResponse: true,
|
|
||||||
});
|
|
||||||
const server = await createCanvasCapabilityMcpServer({
|
|
||||||
projectId,
|
|
||||||
requestHeaders: request.headers,
|
|
||||||
requestUrl: request.url,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
await server.connect(transport);
|
|
||||||
return transport.handleRequest(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST = handleMcpRequest;
|
|
||||||
export const GET = handleMcpRequest;
|
|
||||||
export const DELETE = handleMcpRequest;
|
|
||||||
@ -32,15 +32,6 @@ 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: [] },
|
||||||
@ -286,7 +277,7 @@ export async function GET(request: NextRequest) {
|
|||||||
createdAt: msg.createdAt,
|
createdAt: msg.createdAt,
|
||||||
parts: parts,
|
parts: parts,
|
||||||
attachments: msg.attachments || [],
|
attachments: msg.attachments || [],
|
||||||
videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null,
|
videoMetadata: msg.videoMetadata || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,148 +0,0 @@
|
|||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -25,10 +25,6 @@ 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,
|
||||||
@ -1479,8 +1475,7 @@ 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(
|
||||||
@ -1532,52 +1527,6 @@ 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);
|
||||||
@ -1833,158 +1782,6 @@ 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用既梦/火山模型处理
|
* 使用既梦/火山模型处理
|
||||||
*/
|
*/
|
||||||
@ -2240,8 +2037,7 @@ 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();
|
||||||
|
|||||||
@ -579,10 +579,6 @@ 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: ["全部", "精选"],
|
||||||
@ -901,7 +897,6 @@ 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)
|
||||||
),
|
),
|
||||||
@ -944,11 +939,6 @@ 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 折优惠",
|
||||||
@ -1480,7 +1470,6 @@ 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",
|
||||||
@ -1662,7 +1651,6 @@ 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(),
|
||||||
@ -3123,7 +3111,6 @@ 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",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||||
import { getSystemSetting, getUserById } from "@/lib/db/queries";
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
import {
|
import {
|
||||||
findStyleProImageModelByRef,
|
findStyleProImageModelByRef,
|
||||||
getStyleProImageModelDisplayEntries,
|
getStyleProImageModelDisplayEntries,
|
||||||
@ -26,16 +26,10 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser = await getUserById({ id: session.user.id });
|
|
||||||
const allowHiddenModels =
|
|
||||||
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
|
|
||||||
|
|
||||||
const [displayEntries, entries, realisticModelEntries, setting] =
|
const [displayEntries, entries, realisticModelEntries, setting] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getStyleProImageModelDisplayEntries({
|
getStyleProImageModelDisplayEntries({ includeHidden: false }),
|
||||||
includeHidden: allowHiddenModels,
|
getStyleProImageModelEntries({ includeHidden: false }),
|
||||||
}),
|
|
||||||
getStyleProImageModelEntries({ includeHidden: allowHiddenModels }),
|
|
||||||
getStyleProImageModelEntries({ includeHidden: true }),
|
getStyleProImageModelEntries({ includeHidden: true }),
|
||||||
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
|
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,10 +12,6 @@ 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;
|
||||||
@ -119,10 +115,11 @@ function buildWatermarkTaskPendingResponse(taskId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistWatermarkResult(params: {
|
async function finalizeWatermarkTaskResult(params: {
|
||||||
processedImageUrl: string;
|
taskId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
|
apiKey: string;
|
||||||
sourceImageUrls?: string[];
|
sourceImageUrls?: string[];
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@ -130,8 +127,17 @@ async function persistWatermarkResult(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(params.processedImageUrl);
|
const imageResponse = await fetch(processedImageUrl);
|
||||||
if (!imageResponse.ok) {
|
if (!imageResponse.ok) {
|
||||||
throw new Error("下载处理后的图片失败");
|
throw new Error("下载处理后的图片失败");
|
||||||
}
|
}
|
||||||
@ -211,7 +217,7 @@ async function persistWatermarkResult(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (assetError) {
|
} catch (assetError) {
|
||||||
console.error("[WatermarkRemove] MediaAsset 注册失败:", assetError);
|
console.error("[WatermarkRemove] ⚠️ MediaAsset 注册失败:", assetError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,85 +232,14 @@ async function persistWatermarkResult(params: {
|
|||||||
console.error("[WatermarkRemove] 上传结果失败,回退原始URL:", uploadError);
|
console.error("[WatermarkRemove] 上传结果失败,回退原始URL:", uploadError);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
original_url: params.processedImageUrl,
|
original_url: processedImageUrl,
|
||||||
thumbnail_url: params.processedImageUrl,
|
thumbnail_url: 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();
|
||||||
@ -371,6 +306,13 @@ 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
|
||||||
@ -454,24 +396,6 @@ 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");
|
||||||
@ -491,24 +415,8 @@ 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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -540,7 +448,10 @@ 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,6 @@ import {
|
|||||||
createEmptyAgentGenerationProjectWorkspaceState,
|
createEmptyAgentGenerationProjectWorkspaceState,
|
||||||
createEmptyAgentWorkflowPersistedState,
|
createEmptyAgentWorkflowPersistedState,
|
||||||
normalizeAgentGenerationProjectPersistedState,
|
normalizeAgentGenerationProjectPersistedState,
|
||||||
normalizeAgentGenerationProjectReplacementResultState,
|
|
||||||
} from "@/lib/agents/generation-project-state";
|
} from "@/lib/agents/generation-project-state";
|
||||||
import {
|
import {
|
||||||
createEmptyAgentModeWorkspaceState,
|
createEmptyAgentModeWorkspaceState,
|
||||||
@ -192,12 +191,12 @@ import {
|
|||||||
getAgentWorkflowPageFeatureSettings,
|
getAgentWorkflowPageFeatureSettings,
|
||||||
sanitizeAgentWorkflowSelections,
|
sanitizeAgentWorkflowSelections,
|
||||||
} from "@/lib/agents/workflow-ui-config";
|
} from "@/lib/agents/workflow-ui-config";
|
||||||
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
loadCanvasChatPanelModule,
|
loadCanvasChatPanelModule,
|
||||||
preloadCanvasChatPanel,
|
preloadCanvasChatPanel,
|
||||||
} from "@/components/canvas/canvas-chat-panel-loader";
|
} from "@/components/canvas/canvas-chat-panel-loader";
|
||||||
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
|
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
buildVideoWorkflowCanvasNode,
|
buildVideoWorkflowCanvasNode,
|
||||||
getVideoWorkflowNodeDefinition,
|
getVideoWorkflowNodeDefinition,
|
||||||
@ -233,7 +232,6 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
|||||||
import type {
|
import type {
|
||||||
AgentGenerationProject,
|
AgentGenerationProject,
|
||||||
AgentGenerationProjectPersistedState,
|
AgentGenerationProjectPersistedState,
|
||||||
AgentGenerationProjectReplacementResultState,
|
|
||||||
AgentGenerationProjectResultCollections,
|
AgentGenerationProjectResultCollections,
|
||||||
AgentGenerationProjectWorkspaceAttachment,
|
AgentGenerationProjectWorkspaceAttachment,
|
||||||
AgentGenerationProjectWorkspaceState,
|
AgentGenerationProjectWorkspaceState,
|
||||||
@ -1142,7 +1140,7 @@ function normalizeImportedCanvasState(payload: unknown): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const FLOATING_PANEL_WIDTH = 400;
|
const FLOATING_PANEL_WIDTH = 380;
|
||||||
const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75;
|
const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75;
|
||||||
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
|
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
|
||||||
const FLOATING_PANEL_INSET = 16;
|
const FLOATING_PANEL_INSET = 16;
|
||||||
@ -1547,57 +1545,6 @@ 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 getReplacementResultUrlCount(
|
|
||||||
state: AgentGenerationProjectPersistedState | undefined | null
|
|
||||||
) {
|
|
||||||
const normalized = normalizeAgentGenerationProjectReplacementResultState(
|
|
||||||
state?.replacementResultState
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
Object.values(normalized.detail_replacement || {}).filter((record) =>
|
|
||||||
Boolean(record.url)
|
|
||||||
).length +
|
|
||||||
Object.values(normalized.sku_replacement || {}).filter((record) =>
|
|
||||||
Boolean(record.url)
|
|
||||||
).length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
@ -2034,7 +1981,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
);
|
);
|
||||||
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
||||||
useState<string | null>(null);
|
useState<string | null>(null);
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
const [isPanelOpen, setIsPanelOpen] = useState(() => requestedAgentMode);
|
||||||
const [floatingPanelWidth, setFloatingPanelWidth] =
|
const [floatingPanelWidth, setFloatingPanelWidth] =
|
||||||
useState(FLOATING_PANEL_WIDTH);
|
useState(FLOATING_PANEL_WIDTH);
|
||||||
const [
|
const [
|
||||||
@ -2043,8 +1990,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
] = useState(false);
|
] = useState(false);
|
||||||
const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
|
const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] =
|
const areSecondaryPanelsReady = true;
|
||||||
useState(false);
|
|
||||||
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
|
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
|
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
|
||||||
@ -2067,7 +2013,6 @@ 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);
|
||||||
@ -2091,16 +2036,6 @@ 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);
|
||||||
@ -2146,10 +2081,6 @@ 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
|
||||||
@ -2240,9 +2171,6 @@ 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());
|
||||||
@ -3364,17 +3292,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}, 700);
|
}, 700);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
setAreSecondaryPanelsReady(true);
|
|
||||||
}, 800);
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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
|
||||||
@ -3536,7 +3455,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
setAgentProjectThumbnail(null);
|
setAgentProjectThumbnail(null);
|
||||||
setAgentProjectResultCollections({});
|
setAgentProjectResultCollections({});
|
||||||
setAgentProjectDeletedResultCollections({});
|
setAgentProjectDeletedResultCollections({});
|
||||||
setAgentProjectReplacementResultState({});
|
|
||||||
setLoadedAgentProjectId(null);
|
setLoadedAgentProjectId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4028,15 +3946,8 @@ 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
|
||||||
@ -4221,9 +4132,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
setAgentProjectDeletedResultCollections(
|
setAgentProjectDeletedResultCollections(
|
||||||
persistedState.deletedResultCollections || {}
|
persistedState.deletedResultCollections || {}
|
||||||
);
|
);
|
||||||
setAgentProjectReplacementResultState(
|
|
||||||
persistedState.replacementResultState || {}
|
|
||||||
);
|
|
||||||
setLoadedAgentProjectId(params.projectId);
|
setLoadedAgentProjectId(params.projectId);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
@ -4398,9 +4306,7 @@ 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:
|
projectState: buildPersistedAgentProjectState(),
|
||||||
persistedAgentProjectStateRef.current ||
|
|
||||||
buildPersistedAgentProjectState(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@ -4466,27 +4372,9 @@ 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 serverReplacementResultCount = getReplacementResultUrlCount(
|
|
||||||
result.data.projectState
|
|
||||||
);
|
|
||||||
const localReplacementResultCount = getReplacementResultUrlCount(
|
|
||||||
localDraft?.projectState
|
|
||||||
);
|
|
||||||
const localDraftMissesServerReplacementResults =
|
|
||||||
serverReplacementResultCount > 0 && localReplacementResultCount === 0;
|
|
||||||
const localDraftReplacementResultsDifferFromServer =
|
|
||||||
serverReplacementResultCount > 0 &&
|
|
||||||
localReplacementResultCount > 0 &&
|
|
||||||
localReplacementResultCount !== serverReplacementResultCount;
|
|
||||||
const localDraftHasStaleReplacementCollections =
|
|
||||||
serverReplacementResultCount > 0 &&
|
|
||||||
hasReplacementResultCollectionMismatch(localDraft?.projectState);
|
|
||||||
const shouldUseLocalDraft =
|
const shouldUseLocalDraft =
|
||||||
Boolean(localDraft?.projectState) &&
|
Boolean(localDraft?.projectState) &&
|
||||||
Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
|
Number(localDraft?.savedAt || 0) > serverUpdatedAt;
|
||||||
!localDraftMissesServerReplacementResults &&
|
|
||||||
!localDraftReplacementResultsDifferFromServer &&
|
|
||||||
!localDraftHasStaleReplacementCollections;
|
|
||||||
|
|
||||||
if (shouldUseLocalDraft && localDraft?.projectState) {
|
if (shouldUseLocalDraft && localDraft?.projectState) {
|
||||||
hydrateAgentProjectSnapshot({
|
hydrateAgentProjectSnapshot({
|
||||||
@ -4588,7 +4476,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}, [
|
}, [
|
||||||
agentModeState,
|
agentModeState,
|
||||||
agentProjectId,
|
agentProjectId,
|
||||||
agentProjectReplacementResultState,
|
|
||||||
agentProjectResultCollections,
|
agentProjectResultCollections,
|
||||||
agentProjectThumbnail,
|
agentProjectThumbnail,
|
||||||
agentProjectTitle,
|
agentProjectTitle,
|
||||||
@ -4879,70 +4766,36 @@ 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);
|
||||||
const currentProjectState =
|
setAgentProjectResultCollections((current) => {
|
||||||
persistedAgentProjectStateRef.current ||
|
const nextCollections: AgentGenerationProjectResultCollections = {
|
||||||
createEmptyAgentGenerationProjectPersistedState();
|
...current,
|
||||||
const currentCollections = currentProjectState.resultCollections || {};
|
[modeType]: normalizedUrls,
|
||||||
const nextCollections: AgentGenerationProjectResultCollections = {
|
};
|
||||||
...currentCollections,
|
|
||||||
[modeType]: normalizedUrls,
|
if (areResultCollectionsEqual(current, nextCollections)) {
|
||||||
};
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
if (!areResultCollectionsEqual(currentCollections, nextCollections)) {
|
|
||||||
const nextProjectState: AgentGenerationProjectPersistedState = {
|
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||||
...currentProjectState,
|
...persistedAgentProjectStateRef.current,
|
||||||
resultCollections: nextCollections,
|
resultCollections: nextCollections,
|
||||||
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
|
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
|
||||||
};
|
};
|
||||||
persistedAgentProjectStateRef.current = nextProjectState;
|
persistedAgentProjectStateRef.current = nextProjectState;
|
||||||
writeAgentProjectDraftSnapshot(nextProjectState);
|
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||||
}
|
|
||||||
|
|
||||||
setAgentProjectResultCollections((current) =>
|
setAgentProjectThumbnail((currentThumbnail) => {
|
||||||
areResultCollectionsEqual(current, nextCollections)
|
const nextThumbnail =
|
||||||
? current
|
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
|
||||||
: nextCollections
|
return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
|
||||||
);
|
});
|
||||||
setAgentProjectThumbnail((currentThumbnail) => {
|
|
||||||
const nextThumbnail =
|
return nextCollections;
|
||||||
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();
|
||||||
@ -4976,33 +4829,6 @@ 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
|
||||||
@ -5011,11 +4837,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return current === nextThumbnail ? current : nextThumbnail;
|
return current === nextThumbnail ? current : nextThumbnail;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[agentProjectDeletedResultCollections, agentProjectResultCollections]
|
||||||
agentProjectDeletedResultCollections,
|
|
||||||
agentProjectResultCollections,
|
|
||||||
writeAgentProjectDraftSnapshot,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -5088,19 +4910,12 @@ 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));
|
||||||
@ -5127,70 +4942,6 @@ 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()) {
|
||||||
@ -6989,7 +6740,6 @@ export default function CanvasEditorLegacyPage({
|
|||||||
deletedResultCollections:
|
deletedResultCollections:
|
||||||
existingPersistedState.deletedResultCollections || {},
|
existingPersistedState.deletedResultCollections || {},
|
||||||
resultImageUrls: existingPersistedState.resultImageUrls || [],
|
resultImageUrls: existingPersistedState.resultImageUrls || [],
|
||||||
replacementResultState: existingPersistedState.replacementResultState || {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -10276,13 +10026,10 @@ 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
|
||||||
@ -11035,7 +10782,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 rounded-full px-3 font-medium text-sm",
|
"h-8 rounded-full px-2.5 text-[12px] font-medium",
|
||||||
isVideoMode
|
isVideoMode
|
||||||
? "border border-border/60 bg-background/94 text-foreground shadow-sm backdrop-blur-xl hover:bg-background supports-[backdrop-filter]:bg-background/82"
|
? "border border-border/60 bg-background/94 text-foreground shadow-sm backdrop-blur-xl hover:bg-background supports-[backdrop-filter]:bg-background/82"
|
||||||
: "border bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
|
: "border bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
|
||||||
@ -11043,7 +10790,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
onClick={() => setIsPanelOpen(true)}
|
onClick={() => setIsPanelOpen(true)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<MessageCircle className="mr-1.5 h-4 w-4" />
|
<MessageCircle className="mr-1 h-3.5 w-3.5" />
|
||||||
对话
|
对话
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -11056,7 +10803,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
"absolute z-10 ease-in-out",
|
"absolute z-10 ease-in-out",
|
||||||
isFloatingPanelResizing
|
isFloatingPanelResizing
|
||||||
? "transition-none"
|
? "transition-none"
|
||||||
: "transition-all duration-300",
|
: "transition-all duration-150",
|
||||||
isPanelOpen
|
isPanelOpen
|
||||||
? "translate-x-0 opacity-100"
|
? "translate-x-0 opacity-100"
|
||||||
: "pointer-events-none translate-x-[120%] opacity-0"
|
: "pointer-events-none translate-x-[120%] opacity-0"
|
||||||
@ -11095,7 +10842,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
||||||
{!areSecondaryPanelsReady ? (
|
{!isPanelOpen ? null : !areSecondaryPanelsReady ? (
|
||||||
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
||||||
) : panelMode === "chat" ? (
|
) : panelMode === "chat" ? (
|
||||||
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
|
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
|
||||||
@ -11110,7 +10857,6 @@ 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
|
||||||
}
|
}
|
||||||
@ -11691,60 +11437,60 @@ export default function CanvasEditorLegacyPage({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align={shouldDockFloatingToolbarToSide ? "start" : "center"}
|
align={shouldDockFloatingToolbarToSide ? "start" : "center"}
|
||||||
className="w-[420px] rounded-2xl border bg-background p-0 shadow-2xl"
|
className="w-[360px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border bg-background p-0 shadow-xl"
|
||||||
side={shouldDockFloatingToolbarToSide ? "right" : "top"}
|
side={shouldDockFloatingToolbarToSide ? "right" : "top"}
|
||||||
>
|
>
|
||||||
<div className="border-b px-4 py-3">
|
<div className="border-b px-3 py-2.5">
|
||||||
<div className="font-semibold text-foreground text-sm">
|
<div className="text-[13px] font-semibold text-foreground">
|
||||||
视频工作流节点库
|
视频工作流节点库
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-muted-foreground text-xs">
|
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
这里按参考 APP
|
这里按参考 APP
|
||||||
的节点目录整理,先把结构补齐,再逐步接入具体能力。
|
的节点目录整理,先把结构补齐,再逐步接入具体能力。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="max-h-[60vh]">
|
<ScrollArea className="max-h-[60vh]">
|
||||||
<div className="space-y-4 p-3">
|
<div className="space-y-3 p-2">
|
||||||
{VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => {
|
{VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => {
|
||||||
const sectionNodes = getVideoWorkflowSectionNodes(
|
const sectionNodes = getVideoWorkflowSectionNodes(
|
||||||
section.id
|
section.id
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2" key={section.id}>
|
<div className="space-y-1.5" key={section.id}>
|
||||||
<div className="px-2">
|
<div className="px-1.5">
|
||||||
<div className="font-semibold text-muted-foreground text-xs uppercase tracking-[0.16em]">
|
<div className="text-[10px] font-semibold uppercase text-muted-foreground">
|
||||||
{section.label}
|
{section.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-muted-foreground text-xs">
|
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
{section.description}
|
{section.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{sectionNodes.map((nodeDefinition) => (
|
{sectionNodes.map((nodeDefinition) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex items-start gap-3 rounded-xl px-3 py-3"
|
className="flex items-start gap-2 rounded-[var(--ui-radius-control)] px-2.5 py-2"
|
||||||
key={nodeDefinition.type}
|
key={nodeDefinition.type}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAddWorkflowNode(nodeDefinition.type)
|
handleAddWorkflowNode(nodeDefinition.type)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mt-0.5 h-2.5 w-2.5 rounded-full"
|
className="mt-1 h-2 w-2 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
nodeDefinition.accentColor,
|
nodeDefinition.accentColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-medium text-foreground text-sm">
|
<span className="text-[13px] font-medium text-foreground">
|
||||||
{nodeDefinition.label}
|
{nodeDefinition.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
{nodeDefinition.type}
|
{nodeDefinition.type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-muted-foreground text-xs leading-5">
|
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
{nodeDefinition.description}
|
{nodeDefinition.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1970,18 +1970,15 @@ export default function CanvasModel3DPage() {
|
|||||||
worldImageFile as File,
|
worldImageFile as File,
|
||||||
"image"
|
"image"
|
||||||
);
|
);
|
||||||
const uploadData = uploadResult.data || null;
|
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
||||||
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 ? { mediaAssetId } : { sourceUrl }),
|
mediaAssetId: uploadResult.data.mediaAssetId,
|
||||||
providerId: uploadData.providerId,
|
providerId: uploadResult.data.providerId,
|
||||||
prompt: normalizedPrompt || undefined,
|
prompt: normalizedPrompt || undefined,
|
||||||
displayName: worldDisplayName.trim() || undefined,
|
displayName: worldDisplayName.trim() || undefined,
|
||||||
model: worldModel,
|
model: worldModel,
|
||||||
@ -2003,18 +2000,15 @@ export default function CanvasModel3DPage() {
|
|||||||
worldVideoFile as File,
|
worldVideoFile as File,
|
||||||
"video"
|
"video"
|
||||||
);
|
);
|
||||||
const uploadData = uploadResult.data || null;
|
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
||||||
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 ? { mediaAssetId } : { sourceUrl }),
|
mediaAssetId: uploadResult.data.mediaAssetId,
|
||||||
providerId: uploadData.providerId,
|
providerId: uploadResult.data.providerId,
|
||||||
prompt: normalizedPrompt || undefined,
|
prompt: normalizedPrompt || undefined,
|
||||||
displayName: worldDisplayName.trim() || undefined,
|
displayName: worldDisplayName.trim() || undefined,
|
||||||
model: worldModel,
|
model: worldModel,
|
||||||
@ -2049,13 +2043,11 @@ export default function CanvasModel3DPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaAssets = uploads.map(({ slot, result }) => {
|
const mediaAssets = uploads.map(({ slot, result }) => {
|
||||||
const mediaAssetId = result.data?.mediaAssetId || null;
|
if (!result.success || !result.data?.mediaAssetId) {
|
||||||
const sourceUrl = result.data?.sourceUrl || null;
|
|
||||||
if (!result.success || (!mediaAssetId && !sourceUrl)) {
|
|
||||||
throw new Error(result.error || "上传多图世界图片失败");
|
throw new Error(result.error || "上传多图世界图片失败");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
mediaAssetId: result.data.mediaAssetId,
|
||||||
azimuth: slot.azimuth,
|
azimuth: slot.azimuth,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -2798,9 +2790,9 @@ export default function CanvasModel3DPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWorldFirstPerson(
|
if (workspaceMode !== "world" || worldViewportMode !== "splat") {
|
||||||
workspaceMode === "world" && worldViewportMode === "splat"
|
setWorldFirstPerson(false);
|
||||||
);
|
}
|
||||||
}, [workspaceMode, worldViewportMode]);
|
}, [workspaceMode, worldViewportMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -4008,7 +4000,6 @@ export default function CanvasModel3DPage() {
|
|||||||
previewImageUrl={currentPreviewImage}
|
previewImageUrl={currentPreviewImage}
|
||||||
resetViewToken={viewerResetToken}
|
resetViewToken={viewerResetToken}
|
||||||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||||||
showInfoOverlay={false}
|
|
||||||
showMovementControls={
|
showMovementControls={
|
||||||
worldViewportMode === "splat" || worldViewportMode === "mesh"
|
worldViewportMode === "splat" || worldViewportMode === "mesh"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
/* include utility classes in streamdown */
|
/* include utility classes in streamdown */
|
||||||
@source '../node_modules/streamdown/dist/index.js';
|
@source '../node_modules/streamdown/dist/index.js';
|
||||||
@source not '../services/pascal-editor';
|
|
||||||
|
|
||||||
/* custom variant for setting dark mode programmatically */
|
/* custom variant for setting dark mode programmatically */
|
||||||
@custom-variant dark (&:is(.dark, .dark *));
|
@custom-variant dark (&:is(.dark, .dark *));
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
"!lib/db/migrations/meta",
|
"!lib/db/migrations/meta",
|
||||||
"!public/vendor",
|
"!public/vendor",
|
||||||
"!public/three",
|
"!public/three",
|
||||||
"!services/pascal-editor",
|
|
||||||
"!vercel.json"
|
"!vercel.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -82,7 +82,6 @@ import {
|
|||||||
LOCAL_EDIT_MAX_REGIONS,
|
LOCAL_EDIT_MAX_REGIONS,
|
||||||
createLocalEditReferenceImageId,
|
createLocalEditReferenceImageId,
|
||||||
createLocalEditRegionId,
|
createLocalEditRegionId,
|
||||||
hasScaleAdjustTargetChanged,
|
|
||||||
reindexLocalEditRegions,
|
reindexLocalEditRegions,
|
||||||
type LocalEditReferenceImage,
|
type LocalEditReferenceImage,
|
||||||
type LocalEditRegion,
|
type LocalEditRegion,
|
||||||
@ -123,8 +122,6 @@ import type {
|
|||||||
AgentDetailBriefForm,
|
AgentDetailBriefForm,
|
||||||
AgentDetailGenerationProgressState,
|
AgentDetailGenerationProgressState,
|
||||||
AgentDetailReplacementStrategy,
|
AgentDetailReplacementStrategy,
|
||||||
AgentGenerationProjectReplacementResultState,
|
|
||||||
AgentGenerationReplacementResultRecord,
|
|
||||||
AgentGenerationProjectWorkspaceState,
|
AgentGenerationProjectWorkspaceState,
|
||||||
AgentGenerationProjectResultCollections,
|
AgentGenerationProjectResultCollections,
|
||||||
AgentGenerationResultModeType,
|
AgentGenerationResultModeType,
|
||||||
@ -149,7 +146,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
createEmptyAgentGenerationProjectWorkspaceState,
|
createEmptyAgentGenerationProjectWorkspaceState,
|
||||||
normalizeAgentGenerationProjectPersistedState,
|
normalizeAgentGenerationProjectPersistedState,
|
||||||
normalizeAgentGenerationProjectReplacementResultState,
|
|
||||||
} from "@/lib/agents/generation-project-state";
|
} from "@/lib/agents/generation-project-state";
|
||||||
|
|
||||||
export interface AgentGenerationWorkspaceDarkV2Props {
|
export interface AgentGenerationWorkspaceDarkV2Props {
|
||||||
@ -177,10 +173,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
|||||||
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
|
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
|
||||||
resultCollections?: AgentGenerationProjectResultCollections | null;
|
resultCollections?: AgentGenerationProjectResultCollections | null;
|
||||||
deletedResultCollections?: AgentGenerationProjectResultCollections | null;
|
deletedResultCollections?: AgentGenerationProjectResultCollections | null;
|
||||||
replacementResultState?: AgentGenerationProjectReplacementResultState | null;
|
|
||||||
onReplacementResultStateChange?: (
|
|
||||||
next: AgentGenerationProjectReplacementResultState
|
|
||||||
) => void;
|
|
||||||
onResultPreviewUrlsChange?: (
|
onResultPreviewUrlsChange?: (
|
||||||
modeType: AgentGenerationResultModeType,
|
modeType: AgentGenerationResultModeType,
|
||||||
urls: string[]
|
urls: string[]
|
||||||
@ -189,7 +181,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
|||||||
modeType: AgentGenerationResultModeType,
|
modeType: AgentGenerationResultModeType,
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
) => void;
|
) => void;
|
||||||
onPersistProject?: () => Promise<boolean> | boolean;
|
|
||||||
topNavRightSlot?: ReactNode;
|
topNavRightSlot?: ReactNode;
|
||||||
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
|
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
|
||||||
workflowStylePanel?: ReactNode;
|
workflowStylePanel?: ReactNode;
|
||||||
@ -256,8 +247,6 @@ type DetailResultLocalEditTarget = {
|
|||||||
|
|
||||||
type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
|
type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
|
||||||
|
|
||||||
type DetailResultLocalEditImageSize = "1K" | "2K";
|
|
||||||
|
|
||||||
type DetailResultLocalEditRect = {
|
type DetailResultLocalEditRect = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@ -280,7 +269,6 @@ type DetailResultLocalEditRegionInteraction = {
|
|||||||
|
|
||||||
type DetailResultLocalEditSubmitParams = {
|
type DetailResultLocalEditSubmitParams = {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
imageSize: DetailResultLocalEditImageSize;
|
|
||||||
mode: DetailResultLocalEditMode;
|
mode: DetailResultLocalEditMode;
|
||||||
title: string;
|
title: string;
|
||||||
regions: LocalEditRegion[];
|
regions: LocalEditRegion[];
|
||||||
@ -481,15 +469,6 @@ function normalizeResultCollectionUrls(urls?: string[] | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function areResultUrlListsEqual(left?: string[] | null, right?: string[] | null) {
|
|
||||||
const normalizedLeft = normalizeResultCollectionUrls(left);
|
|
||||||
const normalizedRight = normalizeResultCollectionUrls(right);
|
|
||||||
return (
|
|
||||||
normalizedLeft.length === normalizedRight.length &&
|
|
||||||
normalizedLeft.every((url, index) => normalizedRight[index] === url)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function excludeDeletedResultUrls(
|
function excludeDeletedResultUrls(
|
||||||
urls: string[] | undefined | null,
|
urls: string[] | undefined | null,
|
||||||
deletedUrls: string[] | undefined | null
|
deletedUrls: string[] | undefined | null
|
||||||
@ -915,7 +894,6 @@ function DetailResultLocalEditDialog({
|
|||||||
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
|
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
|
||||||
const [drawingTargetRegionId, setDrawingTargetRegionId] = useState<string | null>(null);
|
const [drawingTargetRegionId, setDrawingTargetRegionId] = useState<string | null>(null);
|
||||||
const [mode, setMode] = useState<DetailResultLocalEditMode>("local_edit");
|
const [mode, setMode] = useState<DetailResultLocalEditMode>("local_edit");
|
||||||
const [imageSize, setImageSize] = useState<DetailResultLocalEditImageSize>("1K");
|
|
||||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const editSurfaceRef = useRef<HTMLDivElement | null>(null);
|
const editSurfaceRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isScaleAdjustMode = mode === "scale_adjust";
|
const isScaleAdjustMode = mode === "scale_adjust";
|
||||||
@ -940,7 +918,6 @@ function DetailResultLocalEditDialog({
|
|||||||
setRegionInteraction(null);
|
setRegionInteraction(null);
|
||||||
setUploadingRegionId(null);
|
setUploadingRegionId(null);
|
||||||
setDrawingTargetRegionId(null);
|
setDrawingTargetRegionId(null);
|
||||||
setImageSize("1K");
|
|
||||||
dragStartRef.current = null;
|
dragStartRef.current = null;
|
||||||
}, [target?.cardId, target?.imageUrl, target?.mode]);
|
}, [target?.cardId, target?.imageUrl, target?.mode]);
|
||||||
|
|
||||||
@ -1234,11 +1211,11 @@ function DetailResultLocalEditDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const unchangedRegion = validRegions.find(
|
const unchangedRegion = validRegions.find(
|
||||||
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect)
|
(region) => deriveDetailScalePercentFromRects(region.rect, region.targetRect) === 0
|
||||||
);
|
);
|
||||||
if (unchangedRegion) {
|
if (unchangedRegion) {
|
||||||
setActiveRegionId(unchangedRegion.id);
|
setActiveRegionId(unchangedRegion.id);
|
||||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`);
|
toast.error(`区域 ${unchangedRegion.index} 的目标框大小变化不明显,请重新框选`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1251,12 +1228,11 @@ function DetailResultLocalEditDialog({
|
|||||||
}
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
imageUrl: target.imageUrl,
|
imageUrl: target.imageUrl,
|
||||||
imageSize,
|
|
||||||
mode,
|
mode,
|
||||||
title: target.title,
|
title: target.title,
|
||||||
regions: validRegions,
|
regions: validRegions,
|
||||||
});
|
});
|
||||||
}, [imageSize, isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
|
}, [isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
|
||||||
|
|
||||||
const completeDraftRegion = useCallback(
|
const completeDraftRegion = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@ -1841,36 +1817,6 @@ function DetailResultLocalEditDialog({
|
|||||||
? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
|
? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
|
||||||
: `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
|
: `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
|
||||||
</div>
|
</div>
|
||||||
{!activePreviewResult ? (
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3">
|
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
|
||||||
输出尺寸
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
{ label: "快速 1K", value: "1K" },
|
|
||||||
{ label: "高清 2K", value: "2K" },
|
|
||||||
] as const
|
|
||||||
).map((option) => (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"h-9 rounded-xl border px-3 text-sm font-semibold transition",
|
|
||||||
imageSize === option.value
|
|
||||||
? "border-white bg-white text-black"
|
|
||||||
: "border-white/10 bg-black/20 text-white/58 hover:bg-white/[0.08] hover:text-white"
|
|
||||||
)}
|
|
||||||
disabled={submitting}
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => setImageSize(option.value)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{!activePreviewResult && isScaleAdjustMode ? (
|
{!activePreviewResult && isScaleAdjustMode ? (
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3 text-sm text-white/58">
|
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3 text-sm text-white/58">
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
||||||
@ -2406,7 +2352,13 @@ type ReplacementModeType = Extract<
|
|||||||
"detail_replacement" | "sku_replacement"
|
"detail_replacement" | "sku_replacement"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface ReplacementResultRecord extends AgentGenerationReplacementResultRecord {}
|
interface ReplacementResultRecord {
|
||||||
|
url: string | null;
|
||||||
|
text: string;
|
||||||
|
status: Extract<ReplacementCardStatus, "completed" | "passthrough" | "fallback">;
|
||||||
|
updatedAt: number;
|
||||||
|
compareSourceUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ReplacementExecutionContext {
|
interface ReplacementExecutionContext {
|
||||||
modeType: ReplacementModeType;
|
modeType: ReplacementModeType;
|
||||||
@ -2453,26 +2405,13 @@ type ReplacementResultState = Record<
|
|||||||
const MANUAL_PASSTHROUGH_RESULT_TEXT =
|
const MANUAL_PASSTHROUGH_RESULT_TEXT =
|
||||||
"已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。";
|
"已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。";
|
||||||
|
|
||||||
function normalizeReplacementResultStateForWorkspace(
|
function createEmptyReplacementResultState(): ReplacementResultState {
|
||||||
value: AgentGenerationProjectReplacementResultState | null | undefined
|
|
||||||
): ReplacementResultState {
|
|
||||||
const normalized = normalizeAgentGenerationProjectReplacementResultState(value);
|
|
||||||
return {
|
return {
|
||||||
detail_replacement: { ...(normalized.detail_replacement || {}) },
|
detail_replacement: {},
|
||||||
sku_replacement: { ...(normalized.sku_replacement || {}) },
|
sku_replacement: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function areReplacementResultStatesEqual(
|
|
||||||
left: ReplacementResultState,
|
|
||||||
right: ReplacementResultState
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
|
|
||||||
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
|
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
|
||||||
const normalized = normalizeAssistantText(String(text || ""))
|
const normalized = normalizeAssistantText(String(text || ""))
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
@ -3120,12 +3059,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
workspaceState = null,
|
workspaceState = null,
|
||||||
resultCollections = null,
|
resultCollections = null,
|
||||||
deletedResultCollections = null,
|
deletedResultCollections = null,
|
||||||
replacementResultState: persistedReplacementResultState = null,
|
|
||||||
onWorkspaceStateChange,
|
onWorkspaceStateChange,
|
||||||
onReplacementResultStateChange,
|
|
||||||
onResultPreviewUrlsChange,
|
onResultPreviewUrlsChange,
|
||||||
onDeleteResultImage,
|
onDeleteResultImage,
|
||||||
onPersistProject,
|
|
||||||
topNavRightSlot,
|
topNavRightSlot,
|
||||||
showcaseConfig,
|
showcaseConfig,
|
||||||
workflowStylePanel,
|
workflowStylePanel,
|
||||||
@ -3277,7 +3213,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
Record<string, string>
|
Record<string, string>
|
||||||
>({});
|
>({});
|
||||||
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
|
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
|
||||||
() => normalizeReplacementResultStateForWorkspace(persistedReplacementResultState)
|
() => createEmptyReplacementResultState()
|
||||||
);
|
);
|
||||||
const [replacementExecutionContext, setReplacementExecutionContext] =
|
const [replacementExecutionContext, setReplacementExecutionContext] =
|
||||||
useState<ReplacementExecutionContext | null>(null);
|
useState<ReplacementExecutionContext | null>(null);
|
||||||
@ -3293,7 +3229,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
||||||
useState<Set<string>>(() => new Set());
|
useState<Set<string>>(() => new Set());
|
||||||
const processedResultHintTimerRef = useRef<Record<string, number>>({});
|
const processedResultHintTimerRef = useRef<Record<string, number>>({});
|
||||||
const transportGenerationStartedAtRef = useRef<number | null>(null);
|
|
||||||
const closePsdTemplateDialog = useCallback(() => {
|
const closePsdTemplateDialog = useCallback(() => {
|
||||||
setIsPsdTemplateDialogOpen(false);
|
setIsPsdTemplateDialogOpen(false);
|
||||||
setPsdTemplateDialogSourceUrl(null);
|
setPsdTemplateDialogSourceUrl(null);
|
||||||
@ -3327,22 +3262,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
},
|
},
|
||||||
[activeResultModeType, isPsdTemplateEnabled]
|
[activeResultModeType, isPsdTemplateEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nextState = normalizeReplacementResultStateForWorkspace(
|
|
||||||
persistedReplacementResultState
|
|
||||||
);
|
|
||||||
setReplacementResultState((current) =>
|
|
||||||
areReplacementResultStatesEqual(current, nextState) ? current : nextState
|
|
||||||
);
|
|
||||||
}, [persistedReplacementResultState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onReplacementResultStateChange?.(
|
|
||||||
normalizeAgentGenerationProjectReplacementResultState(replacementResultState)
|
|
||||||
);
|
|
||||||
}, [onReplacementResultStateChange, replacementResultState]);
|
|
||||||
|
|
||||||
const normalizedDeletedResultCollections = useMemo(
|
const normalizedDeletedResultCollections = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
scene_generation: normalizeResultCollectionUrls(
|
scene_generation: normalizeResultCollectionUrls(
|
||||||
@ -4118,15 +4037,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
? "agent-anchor-materials"
|
? "agent-anchor-materials"
|
||||||
: "agent-anchor-generate";
|
: "agent-anchor-generate";
|
||||||
|
|
||||||
const {
|
const { messages, setMessages, sendMessage, status, stop } = useChat<ChatMessage>({
|
||||||
messages,
|
|
||||||
setMessages,
|
|
||||||
sendMessage,
|
|
||||||
status,
|
|
||||||
stop,
|
|
||||||
error: chatError,
|
|
||||||
clearError,
|
|
||||||
} = useChat<ChatMessage>({
|
|
||||||
id: chatId,
|
id: chatId,
|
||||||
messages: historyMessages,
|
messages: historyMessages,
|
||||||
experimental_throttle: 100,
|
experimental_throttle: 100,
|
||||||
@ -4591,48 +4502,63 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
skuReplacementReferenceCards,
|
skuReplacementReferenceCards,
|
||||||
]);
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (latestReplacementResults.length === 0) {
|
setReplacementResultState((current) => {
|
||||||
|
const detailCardIds = new Set(detailReplacementReferenceCards.map((card) => card.id));
|
||||||
|
const skuCardIds = new Set(skuReplacementReferenceCards.map((card) => card.id));
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const nextDetail = Object.fromEntries(
|
||||||
|
Object.entries(current.detail_replacement).filter(([cardId]) => detailCardIds.has(cardId))
|
||||||
|
);
|
||||||
|
if (Object.keys(nextDetail).length !== Object.keys(current.detail_replacement).length) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSku = Object.fromEntries(
|
||||||
|
Object.entries(current.sku_replacement).filter(([cardId]) => skuCardIds.has(cardId))
|
||||||
|
);
|
||||||
|
if (Object.keys(nextSku).length !== Object.keys(current.sku_replacement).length) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detail_replacement: nextDetail,
|
||||||
|
sku_replacement: nextSku,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
(currentResultModeType !== "detail_replacement" && currentResultModeType !== "sku_replacement") ||
|
||||||
|
latestReplacementResults.length === 0
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const detailReferenceCardById = new Map(
|
const cardIds = currentReplacementExecutionCardIds;
|
||||||
detailReplacementReferenceCards.map((card) => [card.id, card])
|
if (cardIds.length === 0) {
|
||||||
);
|
return;
|
||||||
const skuReferenceCardById = new Map(
|
}
|
||||||
skuReplacementReferenceCards.map((card) => [card.id, card])
|
|
||||||
);
|
|
||||||
const fallbackModeType: ReplacementModeType | null =
|
|
||||||
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement"
|
|
||||||
? currentResultModeType
|
|
||||||
: null;
|
|
||||||
|
|
||||||
setReplacementResultState((current) => {
|
setReplacementResultState((current) => {
|
||||||
const nextState: ReplacementResultState = {
|
const nextModeState = { ...current[currentResultModeType] };
|
||||||
detail_replacement: { ...current.detail_replacement },
|
|
||||||
sku_replacement: { ...current.sku_replacement },
|
|
||||||
};
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
const applyResult = (
|
latestReplacementResults.forEach((result, index) => {
|
||||||
modeType: ReplacementModeType,
|
const cardId = result.replacementCardId || cardIds[index];
|
||||||
cardId: string | null | undefined,
|
|
||||||
result: {
|
|
||||||
url: string | null;
|
|
||||||
text: string;
|
|
||||||
replacementCardId: string | null;
|
|
||||||
},
|
|
||||||
referenceCard?: {
|
|
||||||
imageUrl?: string | null;
|
|
||||||
label?: string | null;
|
|
||||||
previewUrl?: string | null;
|
|
||||||
} | null
|
|
||||||
) => {
|
|
||||||
if (!cardId) {
|
if (!cardId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const inferredStatus = inferReplacementCardStatusFromText(result.text);
|
const inferredStatus = inferReplacementCardStatusFromText(result.text);
|
||||||
const nextModeState = nextState[modeType];
|
|
||||||
const previousRecord = nextModeState[cardId];
|
const previousRecord = nextModeState[cardId];
|
||||||
|
const referenceCard =
|
||||||
|
currentResultModeType === "detail_replacement"
|
||||||
|
? detailReplacementReferenceCards.find((card) => card.id === cardId)
|
||||||
|
: skuReplacementReferenceCards.find((card) => card.id === cardId);
|
||||||
const resultUrl = result.url || null;
|
const resultUrl = result.url || null;
|
||||||
const previousUrl =
|
const previousUrl =
|
||||||
previousRecord?.url && previousRecord.url !== resultUrl
|
previousRecord?.url && previousRecord.url !== resultUrl
|
||||||
@ -4652,59 +4578,27 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
resultUrl,
|
resultUrl,
|
||||||
previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null
|
previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null
|
||||||
),
|
),
|
||||||
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
|
|
||||||
referencePreviewUrl:
|
|
||||||
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
|
|
||||||
label: referenceCard?.label || previousRecord?.label || null,
|
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
previousRecord?.url === nextRecord.url &&
|
previousRecord?.url === nextRecord.url &&
|
||||||
previousRecord?.text === nextRecord.text &&
|
previousRecord?.text === nextRecord.text &&
|
||||||
previousRecord?.status === nextRecord.status &&
|
previousRecord?.status === nextRecord.status &&
|
||||||
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
|
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
|
||||||
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
|
|
||||||
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
|
|
||||||
previousRecord?.label === nextRecord.label
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nextModeState[cardId] = nextRecord;
|
nextModeState[cardId] = nextRecord;
|
||||||
changed = true;
|
changed = true;
|
||||||
};
|
|
||||||
|
|
||||||
latestReplacementResults.forEach((result, index) => {
|
|
||||||
const explicitCardId = result.replacementCardId || null;
|
|
||||||
if (explicitCardId) {
|
|
||||||
const detailReferenceCard = detailReferenceCardById.get(explicitCardId);
|
|
||||||
if (detailReferenceCard) {
|
|
||||||
applyResult("detail_replacement", explicitCardId, result, detailReferenceCard);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const skuReferenceCard = skuReferenceCardById.get(explicitCardId);
|
|
||||||
if (skuReferenceCard) {
|
|
||||||
applyResult("sku_replacement", explicitCardId, result, skuReferenceCard);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fallbackModeType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackCardId = currentReplacementExecutionCardIds[index];
|
|
||||||
const fallbackReferenceCard =
|
|
||||||
fallbackModeType === "detail_replacement"
|
|
||||||
? detailReferenceCardById.get(fallbackCardId)
|
|
||||||
: skuReferenceCardById.get(fallbackCardId);
|
|
||||||
applyResult(fallbackModeType, fallbackCardId, result, fallbackReferenceCard);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextState;
|
return {
|
||||||
|
...current,
|
||||||
|
[currentResultModeType]: nextModeState,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
currentReplacementExecutionCardIds,
|
currentReplacementExecutionCardIds,
|
||||||
@ -4714,8 +4608,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
skuReplacementReferenceCards,
|
skuReplacementReferenceCards,
|
||||||
]);
|
]);
|
||||||
const detailReplacementResultUrls = useMemo(
|
const detailReplacementResultUrls = useMemo(
|
||||||
() => {
|
() =>
|
||||||
const urlsFromCards = detailReplacementReferenceCards
|
detailReplacementReferenceCards
|
||||||
.map((card) => {
|
.map((card) => {
|
||||||
const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null;
|
const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null;
|
||||||
const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null;
|
const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null;
|
||||||
@ -4725,15 +4619,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
? nextUrl
|
? nextUrl
|
||||||
: null;
|
: null;
|
||||||
})
|
})
|
||||||
.filter((url): url is string => Boolean(url));
|
.filter((url): url is string => Boolean(url)),
|
||||||
const urlsFromRecords = Object.values(replacementResultState.detail_replacement)
|
|
||||||
.map((record) => record.url)
|
|
||||||
.filter((url): url is string => Boolean(url))
|
|
||||||
.filter(
|
|
||||||
(url) => !normalizedDeletedResultCollections.detail_replacement.includes(url)
|
|
||||||
);
|
|
||||||
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
|
|
||||||
},
|
|
||||||
[
|
[
|
||||||
detailReplacementReferenceCards,
|
detailReplacementReferenceCards,
|
||||||
normalizedDeletedResultCollections.detail_replacement,
|
normalizedDeletedResultCollections.detail_replacement,
|
||||||
@ -4741,21 +4627,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
const skuReplacementResultUrls = useMemo(
|
const skuReplacementResultUrls = useMemo(
|
||||||
() => {
|
() =>
|
||||||
const urlsFromCards = skuReplacementReferenceCards
|
skuReplacementReferenceCards
|
||||||
.map((card) => {
|
.map((card) => {
|
||||||
const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null;
|
const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null;
|
||||||
return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl)
|
return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl)
|
||||||
? nextUrl
|
? nextUrl
|
||||||
: null;
|
: null;
|
||||||
})
|
})
|
||||||
.filter((url): url is string => Boolean(url));
|
.filter((url): url is string => Boolean(url)),
|
||||||
const urlsFromRecords = Object.values(replacementResultState.sku_replacement)
|
|
||||||
.map((record) => record.url)
|
|
||||||
.filter((url): url is string => Boolean(url))
|
|
||||||
.filter((url) => !normalizedDeletedResultCollections.sku_replacement.includes(url));
|
|
||||||
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
|
|
||||||
},
|
|
||||||
[
|
[
|
||||||
normalizedDeletedResultCollections.sku_replacement,
|
normalizedDeletedResultCollections.sku_replacement,
|
||||||
replacementResultState.sku_replacement,
|
replacementResultState.sku_replacement,
|
||||||
@ -4846,20 +4726,24 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
resultCollections?.detail_generation,
|
resultCollections?.detail_generation,
|
||||||
normalizedDeletedResultCollections.detail_generation
|
normalizedDeletedResultCollections.detail_generation
|
||||||
),
|
),
|
||||||
detail_replacement:
|
detail_replacement: Array.from(
|
||||||
detailReplacementResultUrls.length > 0
|
new Set([
|
||||||
? detailReplacementResultUrls
|
...detailReplacementResultUrls,
|
||||||
: excludeDeletedResultUrls(
|
...excludeDeletedResultUrls(
|
||||||
resultCollections?.detail_replacement,
|
resultCollections?.detail_replacement,
|
||||||
normalizedDeletedResultCollections.detail_replacement
|
normalizedDeletedResultCollections.detail_replacement
|
||||||
),
|
),
|
||||||
sku_replacement:
|
])
|
||||||
skuReplacementResultUrls.length > 0
|
),
|
||||||
? skuReplacementResultUrls
|
sku_replacement: Array.from(
|
||||||
: excludeDeletedResultUrls(
|
new Set([
|
||||||
resultCollections?.sku_replacement,
|
...skuReplacementResultUrls,
|
||||||
normalizedDeletedResultCollections.sku_replacement
|
...excludeDeletedResultUrls(
|
||||||
),
|
resultCollections?.sku_replacement,
|
||||||
|
normalizedDeletedResultCollections.sku_replacement
|
||||||
|
),
|
||||||
|
])
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -5065,52 +4949,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
skuReplacementBatchDownloadUrls.length > 0
|
skuReplacementBatchDownloadUrls.length > 0
|
||||||
? skuReplacementBatchDownloadUrls
|
? skuReplacementBatchDownloadUrls
|
||||||
: mergedResultCollections.sku_replacement;
|
: mergedResultCollections.sku_replacement;
|
||||||
const syncReplacementResultCollectionsToParent = useCallback(
|
|
||||||
(targetModeType?: ReplacementModeType) => {
|
|
||||||
if (!onResultPreviewUrlsChange) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncMode = (modeType: ReplacementModeType, liveUrls: string[]) => {
|
|
||||||
if (targetModeType && targetModeType !== modeType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const normalizedLiveUrls = normalizeResultCollectionUrls(liveUrls);
|
|
||||||
if (normalizedLiveUrls.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedUrlSet = new Set(
|
|
||||||
normalizedDeletedResultCollections[modeType]
|
|
||||||
);
|
|
||||||
const nextUrls = Array.from(
|
|
||||||
new Set(normalizedLiveUrls.filter((url) => !deletedUrlSet.has(url)))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!areResultUrlListsEqual(resultCollections?.[modeType], nextUrls)) {
|
|
||||||
onResultPreviewUrlsChange(modeType, nextUrls);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onReplacementResultStateChange?.(
|
|
||||||
normalizeAgentGenerationProjectReplacementResultState(
|
|
||||||
replacementResultState
|
|
||||||
)
|
|
||||||
);
|
|
||||||
syncMode("detail_replacement", detailReplacementResultUrls);
|
|
||||||
syncMode("sku_replacement", skuReplacementResultUrls);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
detailReplacementResultUrls,
|
|
||||||
normalizedDeletedResultCollections,
|
|
||||||
onReplacementResultStateChange,
|
|
||||||
onResultPreviewUrlsChange,
|
|
||||||
replacementResultState,
|
|
||||||
resultCollections,
|
|
||||||
skuReplacementResultUrls,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const detailRemainingCount = useMemo(
|
const detailRemainingCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
detailResultCards.filter(
|
detailResultCards.filter(
|
||||||
@ -5398,115 +5236,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}
|
}
|
||||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingGeneration || !chatError) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
generationRequestSnapshotRef.current = {
|
|
||||||
hiddenPrompt: null,
|
|
||||||
replacementExecutionCardIds: null,
|
|
||||||
replacementRerunReferenceImages: null,
|
|
||||||
};
|
|
||||||
setPendingGeneration(null);
|
|
||||||
toast.error(chatError.message || "生成请求失败,请稍后重试");
|
|
||||||
clearError();
|
|
||||||
}, [chatError, clearError, pendingGeneration]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingGeneration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsedMs = Date.now() - pendingGeneration.timestamp;
|
|
||||||
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
|
|
||||||
const clearStalePendingGeneration = () => {
|
|
||||||
generationRequestSnapshotRef.current = {
|
|
||||||
hiddenPrompt: null,
|
|
||||||
replacementExecutionCardIds: null,
|
|
||||||
replacementRerunReferenceImages: null,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
stop();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
"[AgentGenerationWorkspaceDarkV2] Failed to stop stale generation transport:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
transportGenerationStartedAtRef.current = null;
|
|
||||||
setPendingGeneration((current) => {
|
|
||||||
if (
|
|
||||||
!current ||
|
|
||||||
current.chatId !== pendingGeneration.chatId ||
|
|
||||||
current.timestamp !== pendingGeneration.timestamp
|
|
||||||
) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
toast.warning("生成请求超过 30 分钟未回传,已结束处理中状态,可重新生成未回传结果。");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (remainingMs <= 0) {
|
|
||||||
clearStalePendingGeneration();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timerId = window.setTimeout(clearStalePendingGeneration, remainingMs);
|
|
||||||
return () => window.clearTimeout(timerId);
|
|
||||||
}, [pendingGeneration, stop]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!transportIsGenerating) {
|
|
||||||
transportGenerationStartedAtRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transportGenerationStartedAtRef.current === null) {
|
|
||||||
transportGenerationStartedAtRef.current =
|
|
||||||
pendingGeneration?.timestamp || Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const startedAt = transportGenerationStartedAtRef.current;
|
|
||||||
const elapsedMs = Date.now() - startedAt;
|
|
||||||
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
|
|
||||||
const abortStaleTransport = () => {
|
|
||||||
const currentStartedAt = transportGenerationStartedAtRef.current;
|
|
||||||
if (
|
|
||||||
currentStartedAt === null ||
|
|
||||||
Date.now() - currentStartedAt < AGENT_PENDING_GENERATION_EXPIRY_MS
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
generationRequestSnapshotRef.current = {
|
|
||||||
hiddenPrompt: null,
|
|
||||||
replacementExecutionCardIds: null,
|
|
||||||
replacementRerunReferenceImages: null,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
stop();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
"[AgentGenerationWorkspaceDarkV2] Failed to abort stale generation transport:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
transportGenerationStartedAtRef.current = null;
|
|
||||||
setPendingGeneration(null);
|
|
||||||
toast.warning("生成请求超过 30 分钟未回传,已中断卡住连接,可重新生成未回传结果。");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (remainingMs <= 0) {
|
|
||||||
abortStaleTransport();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timerId = window.setTimeout(abortStaleTransport, remainingMs);
|
|
||||||
return () => window.clearTimeout(timerId);
|
|
||||||
}, [pendingGeneration?.timestamp, stop, transportIsGenerating]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || transportIsGenerating) {
|
if (!pendingGeneration || transportIsGenerating) {
|
||||||
return;
|
return;
|
||||||
@ -5544,19 +5273,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onResultPreviewUrlsChange) {
|
onResultPreviewUrlsChange?.(
|
||||||
return;
|
currentResultModeType,
|
||||||
}
|
mergedResultCollections[currentResultModeType] || []
|
||||||
|
);
|
||||||
([
|
}, [currentResultModeType, mergedResultCollections, onResultPreviewUrlsChange]);
|
||||||
"scene_generation",
|
|
||||||
"detail_generation",
|
|
||||||
"detail_replacement",
|
|
||||||
"sku_replacement",
|
|
||||||
] as AgentGenerationResultModeType[]).forEach((modeType) => {
|
|
||||||
onResultPreviewUrlsChange(modeType, mergedResultCollections[modeType] || []);
|
|
||||||
});
|
|
||||||
}, [mergedResultCollections, onResultPreviewUrlsChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeResultModeUrls.length === 0) {
|
if (activeResultModeUrls.length === 0) {
|
||||||
@ -5698,7 +5419,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: string[];
|
imageUrls: string[];
|
||||||
zipName: string;
|
zipName: string;
|
||||||
scope: BatchDownloadScope;
|
scope: BatchDownloadScope;
|
||||||
modeType?: AgentGenerationResultModeType;
|
|
||||||
}) => {
|
}) => {
|
||||||
const normalizedUrls = (params.imageUrls || [])
|
const normalizedUrls = (params.imageUrls || [])
|
||||||
.map((item) => String(item || "").trim())
|
.map((item) => String(item || "").trim())
|
||||||
@ -5708,19 +5428,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
params.modeType === "detail_replacement" ||
|
|
||||||
params.modeType === "sku_replacement"
|
|
||||||
) {
|
|
||||||
syncReplacementResultCollectionsToParent(params.modeType);
|
|
||||||
}
|
|
||||||
const persisted = await Promise.resolve(onPersistProject?.() ?? true).catch(
|
|
||||||
() => false
|
|
||||||
);
|
|
||||||
if (!persisted) {
|
|
||||||
toast.info("结果已保留在当前页面,项目同步稍后会自动重试");
|
|
||||||
}
|
|
||||||
|
|
||||||
setBatchDownloadState(params.scope);
|
setBatchDownloadState(params.scope);
|
||||||
setBatchDownloadDialog({
|
setBatchDownloadDialog({
|
||||||
scope: params.scope,
|
scope: params.scope,
|
||||||
@ -5800,7 +5507,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}));
|
}));
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}, [onPersistProject, syncReplacementResultCollectionsToParent]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const taskId = batchDownloadDialog?.taskId;
|
const taskId = batchDownloadDialog?.taskId;
|
||||||
@ -5899,9 +5606,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = batchDownloadDialog.downloadUrl;
|
link.href = batchDownloadDialog.downloadUrl;
|
||||||
link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`;
|
link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`;
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
link.style.display = "none";
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
@ -5967,10 +5671,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
setReplacementResultState((current) => {
|
setReplacementResultState((current) => {
|
||||||
const currentModeState = current[params.modeType];
|
const currentModeState = current[params.modeType];
|
||||||
const previousRecord = currentModeState[params.cardId];
|
const previousRecord = currentModeState[params.cardId];
|
||||||
const referenceCard =
|
|
||||||
params.modeType === "detail_replacement"
|
|
||||||
? detailReplacementReferenceCards.find((card) => card.id === params.cardId)
|
|
||||||
: skuReplacementReferenceCards.find((card) => card.id === params.cardId);
|
|
||||||
const inferredStatus = inferReplacementCardStatusFromText(params.text);
|
const inferredStatus = inferReplacementCardStatusFromText(params.text);
|
||||||
const explicitCompareSourceUrl =
|
const explicitCompareSourceUrl =
|
||||||
params.compareSourceUrl === undefined
|
params.compareSourceUrl === undefined
|
||||||
@ -5991,20 +5691,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
explicitCompareSourceUrl === undefined
|
explicitCompareSourceUrl === undefined
|
||||||
? previousRecord?.compareSourceUrl || null
|
? previousRecord?.compareSourceUrl || null
|
||||||
: explicitCompareSourceUrl,
|
: explicitCompareSourceUrl,
|
||||||
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
|
|
||||||
referencePreviewUrl:
|
|
||||||
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
|
|
||||||
label: referenceCard?.label || previousRecord?.label || null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousRecord?.url === nextRecord.url &&
|
previousRecord?.url === nextRecord.url &&
|
||||||
previousRecord?.text === nextRecord.text &&
|
previousRecord?.text === nextRecord.text &&
|
||||||
previousRecord?.status === nextRecord.status &&
|
previousRecord?.status === nextRecord.status &&
|
||||||
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
|
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
|
||||||
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
|
|
||||||
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
|
|
||||||
previousRecord?.label === nextRecord.label
|
|
||||||
) {
|
) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
@ -6017,7 +5710,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
|
}, []);
|
||||||
const handleOpenReplacementTextEdit = useCallback((params: {
|
const handleOpenReplacementTextEdit = useCallback((params: {
|
||||||
modeType: ReplacementModeType;
|
modeType: ReplacementModeType;
|
||||||
cardId: string;
|
cardId: string;
|
||||||
@ -6366,15 +6059,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => {
|
sendMessage({ role: "user", parts });
|
||||||
generationRequestSnapshotRef.current = {
|
|
||||||
hiddenPrompt: null,
|
|
||||||
replacementExecutionCardIds: null,
|
|
||||||
replacementRerunReferenceImages: null,
|
|
||||||
};
|
|
||||||
setPendingGeneration(null);
|
|
||||||
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
|
|
||||||
});
|
|
||||||
if (selectedAgentPreset?.type !== "image") {
|
if (selectedAgentPreset?.type !== "image") {
|
||||||
setInput("");
|
setInput("");
|
||||||
}
|
}
|
||||||
@ -6961,19 +6646,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
: selectedModelId !== "auto"
|
: selectedModelId !== "auto"
|
||||||
? selectedModelId
|
? selectedModelId
|
||||||
: effectiveDefaultModelId || undefined;
|
: effectiveDefaultModelId || undefined;
|
||||||
const requestedImageSize: DetailResultLocalEditImageSize =
|
|
||||||
params.imageSize === "2K" ? "2K" : "1K";
|
|
||||||
|
|
||||||
setDetailLocalEditSubmittingId(cardId);
|
setDetailLocalEditSubmittingId(cardId);
|
||||||
setDetailLocalEditSubmittingMode(editMode);
|
setDetailLocalEditSubmittingMode(editMode);
|
||||||
toast.info(
|
toast.info(isScaleAdjust ? "正在提交比例调整..." : "正在提交局部编辑...");
|
||||||
`${isScaleAdjust ? "正在提交比例调整" : "正在提交局部编辑"}(${requestedImageSize})...`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const submitResult = await imageGenerationService.localEdit({
|
const submitResult = await imageGenerationService.localEdit({
|
||||||
aspectRatio: "auto",
|
aspectRatio: "auto",
|
||||||
imageSize: requestedImageSize,
|
imageSize: "2K",
|
||||||
model:
|
model:
|
||||||
resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto"
|
resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto"
|
||||||
? resolvedLocalEditModelId
|
? resolvedLocalEditModelId
|
||||||
@ -7191,23 +6872,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
delete next[normalizedUrl];
|
delete next[normalizedUrl];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
if (modeType === "detail_replacement" || modeType === "sku_replacement") {
|
|
||||||
setReplacementResultState((current) => {
|
|
||||||
const currentModeState = current[modeType];
|
|
||||||
const nextModeState = Object.fromEntries(
|
|
||||||
Object.entries(currentModeState).filter(
|
|
||||||
([, record]) => record.url !== normalizedUrl
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (Object.keys(nextModeState).length === Object.keys(currentModeState).length) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
[modeType]: nextModeState,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onDeleteResultImage(modeType, normalizedUrl);
|
onDeleteResultImage(modeType, normalizedUrl);
|
||||||
toast.success("结果图已删除");
|
toast.success("结果图已删除");
|
||||||
@ -9686,7 +9350,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: detailReplacementOrderedDownloadUrls,
|
imageUrls: detailReplacementOrderedDownloadUrls,
|
||||||
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
|
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
|
||||||
scope: "detail",
|
scope: "detail",
|
||||||
modeType: "detail_replacement",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={batchDownloadState === "detail"}
|
disabled={batchDownloadState === "detail"}
|
||||||
@ -9704,7 +9367,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: mergedResultCollections.detail_generation,
|
imageUrls: mergedResultCollections.detail_generation,
|
||||||
zipName: `${selectedAgentPreset?.name || "ai-detail"}-results`,
|
zipName: `${selectedAgentPreset?.name || "ai-detail"}-results`,
|
||||||
scope: "detail",
|
scope: "detail",
|
||||||
modeType: "detail_generation",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={batchDownloadState === "detail"}
|
disabled={batchDownloadState === "detail"}
|
||||||
@ -9722,7 +9384,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: skuReplacementOrderedDownloadUrls,
|
imageUrls: skuReplacementOrderedDownloadUrls,
|
||||||
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
|
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
|
||||||
scope: "sku",
|
scope: "sku",
|
||||||
modeType: "sku_replacement",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={batchDownloadState === "sku"}
|
disabled={batchDownloadState === "sku"}
|
||||||
@ -9739,7 +9400,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: activeResultModeUrls,
|
imageUrls: activeResultModeUrls,
|
||||||
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
|
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
|
||||||
scope: "scene",
|
scope: "scene",
|
||||||
modeType: "scene_generation",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={batchDownloadState === "scene"}
|
disabled={batchDownloadState === "scene"}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"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"
|
||||||
@ -141,9 +140,6 @@ 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) ||
|
||||||
@ -204,7 +200,7 @@ export function useAgentStylePerformanceMonitor({
|
|||||||
browser,
|
browser,
|
||||||
path: window.location.pathname,
|
path: window.location.pathname,
|
||||||
projectId: projectId || null,
|
projectId: projectId || null,
|
||||||
chatId: persistedChatId,
|
chatId: chatId || null,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -241,5 +237,5 @@ export function useAgentStylePerformanceMonitor({
|
|||||||
root.removeEventListener("keydown", markAction, true);
|
root.removeEventListener("keydown", markAction, true);
|
||||||
root.removeEventListener("scroll", markAction, true);
|
root.removeEventListener("scroll", markAction, true);
|
||||||
};
|
};
|
||||||
}, [persistedChatId, projectId, rootRef]);
|
}, [chatId, projectId, rootRef]);
|
||||||
}
|
}
|
||||||
|
|||||||
7017
components/ai-cad/ai-cad-workbench.tsx
Normal file
7017
components/ai-cad/ai-cad-workbench.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -610,9 +610,7 @@ 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
|
||||||
@ -759,10 +757,7 @@ export function AiWashWorkspace({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedPresetHasExecutableModel) {
|
if (!selectedPresetHasExecutableModel) {
|
||||||
toast.error(
|
toast.error("当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型");
|
||||||
selectedExecutionState?.resolvedModel?.disabledReason ||
|
|
||||||
"当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -823,15 +818,6 @@ 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",
|
||||||
@ -839,9 +825,7 @@ export function AiWashWorkspace({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt: executionState.resolvedPrompt,
|
prompt: executionState.resolvedPrompt,
|
||||||
referenceImage: requestReferenceImage,
|
referenceImage: requestReferenceImage,
|
||||||
selectedModelId:
|
selectedModelId: executionState.effectiveModelRef,
|
||||||
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
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,4 @@
|
|||||||
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
|
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
|
||||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
|
||||||
|
|
||||||
export type CanvasCapabilityCommandStreamPayload = CanvasCapabilityClientCommand;
|
|
||||||
|
|
||||||
export type CanvasMediaStreamPayload = {
|
export type CanvasMediaStreamPayload = {
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
|||||||
@ -245,267 +245,36 @@ export function extractAssistantImageUrls(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [];
|
|
||||||
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 tagPattern = /<(audio|video)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
||||||
let match: RegExpExecArray | null = null;
|
|
||||||
while ((match = tagPattern.exec(text)) !== null) {
|
|
||||||
candidates.push({
|
|
||||||
forceKind: match[1]?.toLowerCase() as ChatMediaKind,
|
|
||||||
index: match.index,
|
|
||||||
value: match[2] || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerPattern = /<(audio|video)\b[^>]*>([\s\S]*?)<\/\1>/gi;
|
|
||||||
while ((match = containerPattern.exec(text)) !== null) {
|
|
||||||
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 markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
|
|
||||||
while ((match = markdownLinkPattern.exec(text)) !== null) {
|
|
||||||
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] || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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[] {
|
export function extractVideoUrls(text: string): string[] {
|
||||||
return extractChatMediaUrls(text, "video");
|
if (!text) return [];
|
||||||
|
const urls = new Set<string>();
|
||||||
|
|
||||||
|
const videoTagPattern = /<video[^>]+src="([^"]+)"/gi;
|
||||||
|
let match: RegExpExecArray | null = null;
|
||||||
|
while ((match = videoTagPattern.exec(text)) !== null) {
|
||||||
|
if (match[1]) urls.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownVideoPattern = /\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi;
|
||||||
|
while ((match = markdownVideoPattern.exec(text)) !== null) {
|
||||||
|
if (match[1]) urls.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainVideoPattern = /(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/gi;
|
||||||
|
while ((match = plainVideoPattern.exec(text)) !== null) {
|
||||||
|
if (match[1]) urls.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
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]+)\)/gi, (match, label, url) => {
|
.replace(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/gi, "")
|
||||||
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
|
.replace(/https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?/gi, "")
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -548,7 +317,6 @@ 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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,12 +17,6 @@ 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([
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
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,10 +278,6 @@ 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;
|
||||||
@ -291,36 +287,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
material.needsUpdate = true;
|
material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneSceneWithIndependentMaterials(scene: Object3D) {
|
|
||||||
const clonedScene = scene.clone(true);
|
|
||||||
|
|
||||||
clonedScene.traverse((node) => {
|
|
||||||
if (!isMesh(node)) return;
|
|
||||||
|
|
||||||
const sourceMaterials = Array.isArray(node.material)
|
|
||||||
? node.material
|
|
||||||
: [node.material];
|
|
||||||
const clonedMaterials = sourceMaterials.map((material) => {
|
|
||||||
const clonedMaterial = material.clone();
|
|
||||||
configureMaterialTextures(clonedMaterial, node.geometry);
|
|
||||||
return clonedMaterial;
|
|
||||||
});
|
|
||||||
|
|
||||||
node.material = Array.isArray(node.material)
|
|
||||||
? clonedMaterials
|
|
||||||
: clonedMaterials[0] || node.material;
|
|
||||||
});
|
|
||||||
|
|
||||||
return clonedScene;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSceneGroundY(
|
function resolveSceneGroundY(
|
||||||
scene: Object3D,
|
scene: Object3D,
|
||||||
modelRotation?: [number, number, number]
|
modelRotation?: [number, number, number]
|
||||||
@ -377,10 +349,7 @@ function ModelScene({
|
|||||||
loader.setKTX2Loader(ktx2Loader);
|
loader.setKTX2Loader(ktx2Loader);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const scene = useMemo(
|
const scene = useMemo(() => model.scene.clone(), [model.scene]);
|
||||||
() => cloneSceneWithIndependentMaterials(model.scene),
|
|
||||||
[model.scene]
|
|
||||||
);
|
|
||||||
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
|
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
|
||||||
const modelRotationKey = modelRotation?.join("|") || "";
|
const modelRotationKey = modelRotation?.join("|") || "";
|
||||||
const groundY = useMemo(
|
const groundY = useMemo(
|
||||||
@ -405,20 +374,6 @@ function ModelScene({
|
|||||||
}
|
}
|
||||||
}, [layerVisibility, meta.meshRefs]);
|
}, [layerVisibility, meta.meshRefs]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
scene.traverse((node) => {
|
|
||||||
if (!isMesh(node)) return;
|
|
||||||
const materials = Array.isArray(node.material)
|
|
||||||
? node.material
|
|
||||||
: [node.material];
|
|
||||||
for (const material of materials) {
|
|
||||||
material.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [scene]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const { mesh } of meta.meshRefs) {
|
for (const { mesh } of meta.meshRefs) {
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
@ -428,6 +383,7 @@ function ModelScene({
|
|||||||
: [mesh.material];
|
: [mesh.material];
|
||||||
for (const material of materials) {
|
for (const material of materials) {
|
||||||
if (!material) continue;
|
if (!material) continue;
|
||||||
|
configureMaterialTextures(material, mesh.geometry);
|
||||||
if ("wireframe" in material) {
|
if ("wireframe" in material) {
|
||||||
(material as any).wireframe = settings.wireframe;
|
(material as any).wireframe = settings.wireframe;
|
||||||
}
|
}
|
||||||
@ -696,7 +652,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.08 : -0.08;
|
const gridY = Number.isFinite(groundY) ? groundY - 0.01 : -0.01;
|
||||||
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
|
||||||
|
|||||||
@ -97,7 +97,6 @@ import {
|
|||||||
createLocalEditRegionId,
|
createLocalEditRegionId,
|
||||||
getLocalEditRegionsFromMetadata,
|
getLocalEditRegionsFromMetadata,
|
||||||
getNormalizedRectOverlayStyle,
|
getNormalizedRectOverlayStyle,
|
||||||
hasScaleAdjustTargetChanged,
|
|
||||||
isHttpImageUrl,
|
isHttpImageUrl,
|
||||||
LOCAL_EDIT_COLORS,
|
LOCAL_EDIT_COLORS,
|
||||||
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
|
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
|
||||||
@ -220,9 +219,6 @@ import {
|
|||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
|
||||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
|
||||||
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
|
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
CanvasTextEditPayloadItem,
|
CanvasTextEditPayloadItem,
|
||||||
@ -1805,8 +1801,7 @@ export function ImageNode({
|
|||||||
: "",
|
: "",
|
||||||
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
||||||
);
|
);
|
||||||
const shouldShowAiWashQuickModelSelector =
|
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef;
|
||||||
!aiWashQuickConfig.hideModelSelector && !aiWashQuickAdminModelRef;
|
|
||||||
const effectiveAiWashQuickModelRef =
|
const effectiveAiWashQuickModelRef =
|
||||||
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
|
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
|
||||||
const selectedAiWashQuickModel = useMemo(
|
const selectedAiWashQuickModel = useMemo(
|
||||||
@ -1816,11 +1811,6 @@ export function ImageNode({
|
|||||||
null,
|
null,
|
||||||
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
|
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
|
||||||
);
|
);
|
||||||
const aiWashQuickHasExecutableModel = Boolean(
|
|
||||||
effectiveAiWashQuickModelRef &&
|
|
||||||
selectedAiWashQuickModel &&
|
|
||||||
selectedAiWashQuickModel.selectable !== false
|
|
||||||
);
|
|
||||||
const aiWashQuickPrompt = useMemo(
|
const aiWashQuickPrompt = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedAiWashQuickPreset
|
selectedAiWashQuickPreset
|
||||||
@ -2053,7 +2043,6 @@ export function ImageNode({
|
|||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
const projectId = useCanvasStore((state) => state.projectId);
|
const projectId = useCanvasStore((state) => state.projectId);
|
||||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||||
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
|
||||||
const addPendingChatAttachment = useCanvasStore(
|
const addPendingChatAttachment = useCanvasStore(
|
||||||
(state) => state.addPendingChatAttachment
|
(state) => state.addPendingChatAttachment
|
||||||
);
|
);
|
||||||
@ -2282,7 +2271,7 @@ export function ImageNode({
|
|||||||
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
|
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
|
||||||
});
|
});
|
||||||
const aiWashQuickCostShortLabel =
|
const aiWashQuickCostShortLabel =
|
||||||
pointsEnabled && !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel
|
pointsEnabled && selectedAiWashQuickModel
|
||||||
? formatImageNodeFeatureCostShortLabel({
|
? formatImageNodeFeatureCostShortLabel({
|
||||||
cost: selectedAiWashQuickModel.unitCost,
|
cost: selectedAiWashQuickModel.unitCost,
|
||||||
isHighVip,
|
isHighVip,
|
||||||
@ -4423,11 +4412,15 @@ export function ImageNode({
|
|||||||
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
|
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const unchangedRegion = validRegions.find(
|
const unchangedRegion = validRegions.find((region) => {
|
||||||
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect)
|
const regionScalePercent = deriveCanvasScalePercentFromRects(
|
||||||
);
|
region.rect,
|
||||||
|
region.targetRect
|
||||||
|
);
|
||||||
|
return Math.abs(regionScalePercent) < 1;
|
||||||
|
});
|
||||||
if (unchangedRegion) {
|
if (unchangedRegion) {
|
||||||
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小或位置`);
|
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -6478,11 +6471,8 @@ export function ImageNode({
|
|||||||
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
|
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!aiWashQuickHasExecutableModel || !selectedAiWashQuickModel) {
|
if (!effectiveAiWashQuickModelRef) {
|
||||||
toast.error(
|
toast.error("请先选择一个可用的生图模型");
|
||||||
selectedAiWashQuickModel?.disabledReason ||
|
|
||||||
"当前 AI洗图模型不可用,请在后台重新配置模型"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isProcessing || isSubmittingAiWashQuick) {
|
if (isProcessing || isSubmittingAiWashQuick) {
|
||||||
@ -6538,7 +6528,7 @@ export function ImageNode({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt: aiWashQuickPrompt,
|
prompt: aiWashQuickPrompt,
|
||||||
referenceImage: data.src,
|
referenceImage: data.src,
|
||||||
selectedModelId: selectedAiWashQuickModel.id || effectiveAiWashQuickModelRef,
|
selectedModelId: effectiveAiWashQuickModelRef,
|
||||||
imageSize: aiWashQuickConfig.imageSize,
|
imageSize: aiWashQuickConfig.imageSize,
|
||||||
promptExtend: aiWashQuickConfig.promptExtend,
|
promptExtend: aiWashQuickConfig.promptExtend,
|
||||||
title: selectedAiWashQuickPreset.title,
|
title: selectedAiWashQuickPreset.title,
|
||||||
@ -6635,7 +6625,6 @@ export function ImageNode({
|
|||||||
}, [
|
}, [
|
||||||
addNode,
|
addNode,
|
||||||
aiWashQuickConfig.enabled,
|
aiWashQuickConfig.enabled,
|
||||||
aiWashQuickConfig.hideModelSelector,
|
|
||||||
aiWashQuickConfig.imageSize,
|
aiWashQuickConfig.imageSize,
|
||||||
aiWashQuickConfig.promptExtend,
|
aiWashQuickConfig.promptExtend,
|
||||||
aiWashQuickLevels,
|
aiWashQuickLevels,
|
||||||
@ -6643,7 +6632,6 @@ export function ImageNode({
|
|||||||
aiWashQuickPrompt,
|
aiWashQuickPrompt,
|
||||||
data.src,
|
data.src,
|
||||||
displayTitle,
|
displayTitle,
|
||||||
aiWashQuickHasExecutableModel,
|
|
||||||
effectiveAiWashQuickModelRef,
|
effectiveAiWashQuickModelRef,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isSubmittingAiWashQuick,
|
isSubmittingAiWashQuick,
|
||||||
@ -6651,66 +6639,14 @@ export function ImageNode({
|
|||||||
resetBoxCutoutState,
|
resetBoxCutoutState,
|
||||||
resetLocalEditState,
|
resetLocalEditState,
|
||||||
selectNode,
|
selectNode,
|
||||||
selectedAiWashQuickModel?.disabledReason,
|
|
||||||
selectedAiWashQuickModel?.displayName,
|
selectedAiWashQuickModel?.displayName,
|
||||||
selectedAiWashQuickModel?.id,
|
|
||||||
selectedAiWashQuickModel?.modelId,
|
selectedAiWashQuickModel?.modelId,
|
||||||
selectedAiWashQuickPreset,
|
selectedAiWashQuickPreset,
|
||||||
updateNode,
|
updateNode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const executeSharedToolbarCapability = async (
|
// 处理图片编辑操作 - 集成我们现有的 API
|
||||||
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
const handleAction = async (action: string) => {
|
||||||
) => {
|
|
||||||
if (!projectId) {
|
|
||||||
toast.error("画布还在加载,请稍后再试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const capability = getCanvasCapabilityByClientAction(action);
|
|
||||||
if (!capability) {
|
|
||||||
toast.error("画布能力未注册");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreatingDetachedResultNode(false);
|
|
||||||
setIsProcessing(true);
|
|
||||||
try {
|
|
||||||
const command = createCanvasCapabilityClientCommand({
|
|
||||||
capabilityId: capability.id,
|
|
||||||
nodeIds: [node.id],
|
|
||||||
projectId,
|
|
||||||
source: "toolbar",
|
|
||||||
});
|
|
||||||
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(command),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok || payload?.success !== true) {
|
|
||||||
throw new Error(payload?.error || `${capability.title}失败`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectResult = await canvasService.getProject(projectId);
|
|
||||||
if (!projectResult.success || !projectResult.data?.canvasData) {
|
|
||||||
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
|
|
||||||
}
|
|
||||||
loadCanvas(projectResult.data.canvasData);
|
|
||||||
toast.success(`${capability.title}完成`);
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : `${capability.title}失败`;
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setIsCreatingDetachedResultNode(false);
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理图片编辑操作
|
|
||||||
const handleAction = async (
|
|
||||||
action: CanvasCapabilityClientAction | "scene-fusion" | "annotate"
|
|
||||||
) => {
|
|
||||||
if (!data.src) {
|
if (!data.src) {
|
||||||
toast.error("请先上传图片");
|
toast.error("请先上传图片");
|
||||||
return;
|
return;
|
||||||
@ -6813,7 +6749,34 @@ export function ImageNode({
|
|||||||
toast.info("图层拆分功能未开启");
|
toast.info("图层拆分功能未开启");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await executeSharedToolbarCapability("layer-split");
|
setIsCreatingDetachedResultNode(true);
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
const result = await imageGenerationService.splitImageLayers(data.src);
|
||||||
|
const layers = result.data?.layers || [];
|
||||||
|
|
||||||
|
if (!result.success || layers.length === 0) {
|
||||||
|
toast.error(result.error || "图层拆分失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedLayerCount = handleLayerSplitComplete(
|
||||||
|
layers.map((layer) => ({
|
||||||
|
originalUrl: layer.originalUrl,
|
||||||
|
title: layer.title,
|
||||||
|
layerIndex: layer.layerIndex,
|
||||||
|
totalLayers: layer.totalLayers,
|
||||||
|
bounds: layer.bounds ?? null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
toast.success(`图层拆分完成,已新增 ${addedLayerCount} 个图层结果`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("图层拆分失败:", error);
|
||||||
|
toast.error("图层拆分失败");
|
||||||
|
} finally {
|
||||||
|
setIsCreatingDetachedResultNode(false);
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "box-cutout") {
|
if (action === "box-cutout") {
|
||||||
@ -6875,13 +6838,102 @@ export function ImageNode({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const actionName = getActionName(action);
|
||||||
action === "rmbg" ||
|
|
||||||
action === "watermark" ||
|
setImageAdjustPanelOpen(false);
|
||||||
action === "enhance" ||
|
setCropPanelOpen(false);
|
||||||
action === "extend"
|
setCropInteraction(null);
|
||||||
) {
|
setFlipRotatePanelOpen(false);
|
||||||
await executeSharedToolbarCapability(action);
|
setAnglePanelOpen(false);
|
||||||
|
setIsCreatingDetachedResultNode(true);
|
||||||
|
setIsProcessing(true);
|
||||||
|
// 先创建一个"处理中"状态的节点,显示在原图右侧
|
||||||
|
const newNodeId = addNode({
|
||||||
|
type: "image",
|
||||||
|
position: {
|
||||||
|
x: node.position.x + node.size.width + 20,
|
||||||
|
y: node.position.y,
|
||||||
|
},
|
||||||
|
size: { width: node.size.width, height: node.size.height },
|
||||||
|
data: {
|
||||||
|
src: "", // 暂时没有图片
|
||||||
|
status: "generating",
|
||||||
|
prompt: `${actionName}处理中...`,
|
||||||
|
title: `${displayTitle}-${actionName}`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
},
|
||||||
|
connections: [node.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case "rmbg":
|
||||||
|
result = await imageGenerationService.removeBackground(data.src);
|
||||||
|
break;
|
||||||
|
case "watermark":
|
||||||
|
result = await imageGenerationService.removeWatermark(data.src);
|
||||||
|
break;
|
||||||
|
case "enhance":
|
||||||
|
result = await imageGenerationService.enhanceImage(data.src);
|
||||||
|
break;
|
||||||
|
case "extend":
|
||||||
|
result = await imageGenerationService.extendImage(data.src);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 删除创建的节点
|
||||||
|
if (newNodeId) deleteNode(newNodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.success && result.data?.url) {
|
||||||
|
// 更新节点为完成状态
|
||||||
|
if (newNodeId) {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: result.data.url,
|
||||||
|
status: "completed",
|
||||||
|
prompt: `${actionName}结果`,
|
||||||
|
title: `${displayTitle}-${actionName}`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success(`${actionName}处理完成`);
|
||||||
|
} else {
|
||||||
|
// 更新节点为失败状态
|
||||||
|
if (newNodeId) {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "failed",
|
||||||
|
prompt: `${actionName}失败`,
|
||||||
|
title: `${displayTitle}-${actionName}-失败`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.error(result?.error || "处理失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${action} 处理失败:`, error);
|
||||||
|
// 更新节点为失败状态
|
||||||
|
if (newNodeId) {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "failed",
|
||||||
|
prompt: `${actionName}失败`,
|
||||||
|
title: `${displayTitle}-${actionName}-失败`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.error("处理失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setIsCreatingDetachedResultNode(false);
|
||||||
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -6925,16 +6977,7 @@ export function ImageNode({
|
|||||||
const action = customEvent.detail?.action;
|
const action = customEvent.detail?.action;
|
||||||
|
|
||||||
if (!targetNodeId || !action || targetNodeId !== node.id) return;
|
if (!targetNodeId || !action || targetNodeId !== node.id) return;
|
||||||
if (action === "scene-fusion" || action === "annotate") {
|
void handleAction(action);
|
||||||
void handleAction(action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const capability = getCanvasCapabilityByClientAction(
|
|
||||||
action as CanvasCapabilityClientAction
|
|
||||||
);
|
|
||||||
if (capability) {
|
|
||||||
void handleAction(capability.clientAction);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
@ -8057,9 +8100,20 @@ export function ImageNode({
|
|||||||
setIsCreatingDetachedResultNode(true);
|
setIsCreatingDetachedResultNode(true);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
|
const uploadResult = await worldLabsGenerationService.uploadMediaFromUrl(
|
||||||
|
sourceImageUrl,
|
||||||
|
"image"
|
||||||
|
);
|
||||||
|
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
||||||
|
throw new Error(uploadResult.error || "上传世界参考图失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerId = uploadResult.data.providerId || null;
|
||||||
|
const providerName = uploadResult.data.providerName || null;
|
||||||
const createResult = await worldLabsGenerationService.createTask({
|
const createResult = await worldLabsGenerationService.createTask({
|
||||||
mode: "image",
|
mode: "image",
|
||||||
sourceUrl: sourceImageUrl,
|
mediaAssetId: uploadResult.data.mediaAssetId,
|
||||||
|
providerId,
|
||||||
prompt: promptText || undefined,
|
prompt: promptText || undefined,
|
||||||
displayName: displayTitle || promptText || "画布图生世界",
|
displayName: displayTitle || promptText || "画布图生世界",
|
||||||
model: modelVersion,
|
model: modelVersion,
|
||||||
@ -8072,6 +8126,8 @@ export function ImageNode({
|
|||||||
|
|
||||||
const createAssetPayload = {
|
const createAssetPayload = {
|
||||||
...(createResult.data.assetPayload || {}),
|
...(createResult.data.assetPayload || {}),
|
||||||
|
providerId,
|
||||||
|
providerName,
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
};
|
};
|
||||||
updateNode(newNodeId, {
|
updateNode(newNodeId, {
|
||||||
@ -8113,8 +8169,8 @@ export function ImageNode({
|
|||||||
void pollCanvasWorld3DTask({
|
void pollCanvasWorld3DTask({
|
||||||
nodeId: newNodeId,
|
nodeId: newNodeId,
|
||||||
taskId: createResult.data.taskId,
|
taskId: createResult.data.taskId,
|
||||||
providerId: createAssetPayload.providerId || null,
|
providerId,
|
||||||
providerName: createAssetPayload.providerName || null,
|
providerName,
|
||||||
promptText,
|
promptText,
|
||||||
modelVersion,
|
modelVersion,
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
@ -9911,7 +9967,7 @@ export function ImageNode({
|
|||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed z-[10000] overflow-hidden rounded-[14px] border border-slate-200 bg-white text-slate-950 shadow-[0_18px_44px_rgba(15,23,42,0.18)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
|
className="fixed z-[10000] overflow-hidden rounded-[var(--ui-radius-panel)] border border-slate-200 bg-white text-slate-950 shadow-[0_14px_36px_rgba(15,23,42,0.16)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
|
||||||
data-canvas-no-drag="true"
|
data-canvas-no-drag="true"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
@ -9925,21 +9981,21 @@ export function ImageNode({
|
|||||||
scrollbarWidth: "none",
|
scrollbarWidth: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3 dark:border-white/10">
|
<div className="flex items-center justify-between gap-2 border-b border-slate-100 px-3 py-2.5 dark:border-white/10">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-5 w-5"
|
className="h-4 w-4"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 truncate text-[17px] font-semibold tracking-[-0.02em]">
|
<div className="min-w-0 truncate text-[14px] font-semibold">
|
||||||
{aiWashQuickModeLabel}
|
{aiWashQuickModeLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--ui-radius-control)] text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
onClick={() => setAiWashQuickPanelOpen(false)}
|
onClick={() => setAiWashQuickPanelOpen(false)}
|
||||||
title="关闭"
|
title="关闭"
|
||||||
type="button"
|
type="button"
|
||||||
@ -9949,20 +10005,20 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aiWashConfigLoading ? (
|
{aiWashConfigLoading ? (
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3 text-[13px] text-slate-500 dark:border-white/10 dark:text-white/58">
|
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2.5 text-[12px] text-slate-500 dark:border-white/10 dark:text-white/58">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
正在加载 AI洗图 配置...
|
正在加载 AI洗图 配置...
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="divide-y divide-slate-100 dark:divide-white/10">
|
<div className="divide-y divide-slate-100 dark:divide-white/10">
|
||||||
<div className="px-4 py-3">
|
<div className="px-3 py-2.5">
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
<div className="flex items-center gap-2 text-[13px] font-semibold text-slate-950 dark:text-white">
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
目标
|
目标
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
<span className="rounded-[5px] border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
||||||
{getAiWashStatusLabel(aiWashQuickStatus)}
|
{getAiWashStatusLabel(aiWashQuickStatus)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -9973,7 +10029,7 @@ export function ImageNode({
|
|||||||
}}
|
}}
|
||||||
value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId}
|
value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
<SelectTrigger className="h-9 border-slate-200 bg-white px-2.5 text-[13px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||||
<SelectValue placeholder="选择渲染目标" />
|
<SelectValue placeholder="选择渲染目标" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||||
@ -9991,8 +10047,8 @@ export function ImageNode({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-3">
|
<div className="px-3 py-2.5">
|
||||||
<div className="mb-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
<div className="mb-1.5 text-[13px] font-semibold text-slate-950 dark:text-white">
|
||||||
参数
|
参数
|
||||||
</div>
|
</div>
|
||||||
{shouldShowAiWashQuickModelSelector ? (
|
{shouldShowAiWashQuickModelSelector ? (
|
||||||
@ -10004,7 +10060,7 @@ export function ImageNode({
|
|||||||
onValueChange={setAiWashQuickModelId}
|
onValueChange={setAiWashQuickModelId}
|
||||||
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
|
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
<SelectTrigger className="h-9 border-slate-200 bg-white px-2.5 text-[13px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||||
<SelectValue placeholder="选择生图模型" />
|
<SelectValue placeholder="选择生图模型" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||||
@ -10040,9 +10096,16 @@ export function ImageNode({
|
|||||||
: "没有可选模型"}
|
: "没有可选模型"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : selectedAiWashQuickModel ? (
|
||||||
|
<div className="mb-3 rounded-[var(--ui-radius-control)] border border-slate-200 bg-slate-50 px-2.5 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
|
||||||
|
{selectedAiWashQuickModel.displayName}
|
||||||
|
{selectedAiWashQuickModel.unitCost > 0
|
||||||
|
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="-mx-4 divide-y divide-slate-100 dark:divide-white/10">
|
<div className="-mx-3 divide-y divide-slate-100 dark:divide-white/10">
|
||||||
{aiWashQuickControls.map((control) => {
|
{aiWashQuickControls.map((control) => {
|
||||||
const selectedIndex =
|
const selectedIndex =
|
||||||
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
||||||
@ -10052,14 +10115,14 @@ export function ImageNode({
|
|||||||
null;
|
null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="px-4 py-3"
|
className="px-3 py-2.5"
|
||||||
key={control.id}
|
key={control.id}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
<div className="text-[14px] font-semibold">
|
<div className="text-[13px] font-semibold">
|
||||||
{control.label}
|
{control.label}
|
||||||
</div>
|
</div>
|
||||||
<span className="min-w-8 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[12px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
<span className="min-w-7 rounded-[5px] border border-slate-200 bg-white px-1.5 py-0.5 text-center text-[10px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
||||||
{activeLevel?.label || selectedIndex + 1}
|
{activeLevel?.label || selectedIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -10085,13 +10148,13 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-3">
|
<div className="px-3 py-2.5">
|
||||||
<Button
|
<Button
|
||||||
className="h-10 w-full rounded-[10px] bg-slate-950 text-[14px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
className="h-9 w-full bg-slate-950 text-[13px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
||||||
disabled={
|
disabled={
|
||||||
isSubmittingAiWashQuick ||
|
isSubmittingAiWashQuick ||
|
||||||
!selectedAiWashQuickPreset ||
|
!selectedAiWashQuickPreset ||
|
||||||
!aiWashQuickHasExecutableModel
|
!effectiveAiWashQuickModelRef
|
||||||
}
|
}
|
||||||
onClick={() => void handleSubmitAiWashQuick()}
|
onClick={() => void handleSubmitAiWashQuick()}
|
||||||
type="button"
|
type="button"
|
||||||
@ -10104,10 +10167,9 @@ export function ImageNode({
|
|||||||
{isSubmittingAiWashQuick
|
{isSubmittingAiWashQuick
|
||||||
? "生成中"
|
? "生成中"
|
||||||
: `${aiWashQuickModeLabel}${
|
: `${aiWashQuickModeLabel}${
|
||||||
!aiWashQuickConfig.hideModelSelector &&
|
|
||||||
selectedAiWashQuickModel?.unitCost
|
selectedAiWashQuickModel?.unitCost
|
||||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -11620,7 +11682,7 @@ export function ImageNode({
|
|||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed z-[9999] w-[360px] rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950"
|
className="fixed z-[9999] w-[340px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border border-slate-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-950"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
@ -11632,7 +11694,7 @@ export function ImageNode({
|
|||||||
<div className="flex items-center justify-between border-border/70 border-b px-4 py-3">
|
<div className="flex items-center justify-between border-border/70 border-b px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Type className="h-4 w-4 text-primary" />
|
<Type className="h-4 w-4 text-primary" />
|
||||||
<span className="font-medium text-sm">编辑文字</span>
|
<span className="text-[13px] font-medium">编辑文字</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
@ -11645,12 +11707,12 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[360px] space-y-3 overflow-y-auto px-4 py-3">
|
<div className="max-h-[360px] space-y-3 overflow-y-auto px-4 py-3">
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||||
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
|
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isDetectingText && (
|
{isDetectingText && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
正在识别图片文字...
|
正在识别图片文字...
|
||||||
</div>
|
</div>
|
||||||
@ -11672,7 +11734,7 @@ export function ImageNode({
|
|||||||
|
|
||||||
{textEditItems.map((item, index) => (
|
{textEditItems.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
className="space-y-2 rounded-lg border border-border/70 p-3"
|
className="space-y-2 rounded-[var(--ui-radius-control)] border border-border/70 p-2.5"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Box,
|
Box,
|
||||||
@ -43,7 +43,6 @@ import {
|
|||||||
type CanvasToolbarConfig,
|
type CanvasToolbarConfig,
|
||||||
} from "@/lib/canvas-toolbar-config";
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { canvasService, model3DGenerationService } from "@/services/canvas";
|
|
||||||
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
|
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
|
|
||||||
@ -62,15 +61,6 @@ const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = {
|
|||||||
wireframe: false,
|
wireframe: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODEL_3D_RECOVERY_INTERVAL_MS = 5000;
|
|
||||||
const MODEL_3D_RECOVERY_MAX_ATTEMPTS = 360;
|
|
||||||
|
|
||||||
function isTerminalModelFailureStatus(status: string) {
|
|
||||||
return ["failed", "error", "banned", "expired", "cancelled", "canceled"].includes(
|
|
||||||
status.trim().toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferModelFileName(modelUrl: string | null, nodeId: string) {
|
function inferModelFileName(modelUrl: string | null, nodeId: string) {
|
||||||
const raw = String(modelUrl || "").trim();
|
const raw = String(modelUrl || "").trim();
|
||||||
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
|
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
|
||||||
@ -103,12 +93,7 @@ export function Model3DNode({
|
|||||||
}: Model3DNodeProps) {
|
}: Model3DNodeProps) {
|
||||||
const data = node.data as Model3DNodeData;
|
const data = node.data as Model3DNodeData;
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const recoveryAttemptRef = useRef(0);
|
|
||||||
const recoveryTaskRef = useRef<string | null>(null);
|
|
||||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
|
||||||
const projectId = useCanvasStore((state) => state.projectId);
|
|
||||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
|
||||||
const model3dOpen3dItem = getCanvasToolbarItem(
|
const model3dOpen3dItem = getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"model3dOpen3d"
|
"model3dOpen3d"
|
||||||
@ -142,17 +127,6 @@ export function Model3DNode({
|
|||||||
"model3dDelete"
|
"model3dDelete"
|
||||||
);
|
);
|
||||||
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
|
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
|
||||||
const taskId = String(data.metadata?.taskId || "").trim();
|
|
||||||
const assetPayload = readWorldAssetPayload(data);
|
|
||||||
const providerId = String(
|
|
||||||
data.metadata?.providerId || assetPayload?.providerId || ""
|
|
||||||
).trim();
|
|
||||||
const providerName = String(
|
|
||||||
data.metadata?.providerName || assetPayload?.providerName || ""
|
|
||||||
).trim();
|
|
||||||
const sourceImageUrl = String(
|
|
||||||
data.metadata?.sourceImageUrl || data.previewImage || ""
|
|
||||||
).trim();
|
|
||||||
const { data: modelAliasConfigResponse } = useSWR<{
|
const { data: modelAliasConfigResponse } = useSWR<{
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
data?: PublicThreeDModelAliasConfig;
|
data?: PublicThreeDModelAliasConfig;
|
||||||
@ -225,224 +199,6 @@ export function Model3DNode({
|
|||||||
[placeholderViewportScale]
|
[placeholderViewportScale]
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistCanvasStateSnapshot = useCallback(() => {
|
|
||||||
if (!projectId || typeof window === "undefined") return;
|
|
||||||
window.setTimeout(() => {
|
|
||||||
void canvasService.saveCanvasState(projectId, getCanvasState());
|
|
||||||
}, 0);
|
|
||||||
}, [getCanvasState, projectId]);
|
|
||||||
|
|
||||||
const persistCanvas3DAsset = useCallback(
|
|
||||||
async (asset: Record<string, unknown>) => {
|
|
||||||
if (!projectId) return;
|
|
||||||
try {
|
|
||||||
await fetch(`/api/canvas/${projectId}/model3d-assets`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ asset }),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Model3DNode] 保存 3D 资产失败:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectId]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data.status !== "generating" || !taskId) return;
|
|
||||||
const provider = String(data.metadata?.provider || "tripo-3d").trim();
|
|
||||||
if (provider !== "tripo-3d") return;
|
|
||||||
|
|
||||||
if (recoveryTaskRef.current !== taskId) {
|
|
||||||
recoveryTaskRef.current = taskId;
|
|
||||||
recoveryAttemptRef.current = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
const prompt = String(data.prompt || "图生 3D 模型").trim();
|
|
||||||
const modelVersion = String(data.modelVersion || "tripo-standard").trim();
|
|
||||||
const previewImage = String(data.previewImage || sourceImageUrl).trim();
|
|
||||||
|
|
||||||
const currentData = () => {
|
|
||||||
const currentNode = getCanvasState().nodes.find((item) => item.id === node.id);
|
|
||||||
return (currentNode?.data || data) as Model3DNodeData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistTerminalAsset = async (params: {
|
|
||||||
error: string | null;
|
|
||||||
modelUrl: string | null;
|
|
||||||
previewImageUrl: string;
|
|
||||||
progress: number | null;
|
|
||||||
status: "failed" | "success";
|
|
||||||
taskAssetPayload?: Record<string, unknown> | null;
|
|
||||||
}) => {
|
|
||||||
await persistCanvas3DAsset({
|
|
||||||
taskId,
|
|
||||||
provider: "tripo-3d",
|
|
||||||
status: params.status,
|
|
||||||
mode: "image",
|
|
||||||
prompt,
|
|
||||||
modelVersion,
|
|
||||||
progress: params.progress,
|
|
||||||
modelUrl: params.modelUrl,
|
|
||||||
previewImageUrl: params.previewImageUrl,
|
|
||||||
error: params.error,
|
|
||||||
assetPayload: {
|
|
||||||
...(params.taskAssetPayload || {}),
|
|
||||||
providerId: providerId || null,
|
|
||||||
providerName: providerName || null,
|
|
||||||
sourceImageUrl,
|
|
||||||
},
|
|
||||||
createdAt: Date.now(),
|
|
||||||
});
|
|
||||||
persistCanvasStateSnapshot();
|
|
||||||
};
|
|
||||||
|
|
||||||
const markFailed = async (message: string, progress: number | null) => {
|
|
||||||
const latest = currentData();
|
|
||||||
updateNode(node.id, {
|
|
||||||
data: {
|
|
||||||
...latest,
|
|
||||||
src: "",
|
|
||||||
previewImage: previewImage || sourceImageUrl,
|
|
||||||
status: "failed",
|
|
||||||
metadata: {
|
|
||||||
...(latest.metadata || {}),
|
|
||||||
taskId,
|
|
||||||
provider: "tripo-3d",
|
|
||||||
mode: "image",
|
|
||||||
sourceImageUrl,
|
|
||||||
progress,
|
|
||||||
error: message,
|
|
||||||
},
|
|
||||||
} as Model3DNodeData,
|
|
||||||
});
|
|
||||||
await persistTerminalAsset({
|
|
||||||
error: message,
|
|
||||||
modelUrl: null,
|
|
||||||
previewImageUrl: previewImage || sourceImageUrl,
|
|
||||||
progress,
|
|
||||||
status: "failed",
|
|
||||||
});
|
|
||||||
toast.error(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleNextPoll = () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (recoveryAttemptRef.current >= MODEL_3D_RECOVERY_MAX_ATTEMPTS) {
|
|
||||||
void markFailed("3D 模型任务同步超时,请稍后重试", null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
void pollTask();
|
|
||||||
}, MODEL_3D_RECOVERY_INTERVAL_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function pollTask() {
|
|
||||||
if (cancelled) return;
|
|
||||||
recoveryAttemptRef.current += 1;
|
|
||||||
const result = await model3DGenerationService.getTask(taskId, {
|
|
||||||
providerId: providerId || null,
|
|
||||||
});
|
|
||||||
if (cancelled) return;
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
scheduleNextPoll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = String(result.data.status || "unknown").toLowerCase();
|
|
||||||
const progress = result.data.progress ?? null;
|
|
||||||
if (status === "success") {
|
|
||||||
const modelUrl = String(result.data.modelUrl || "").trim();
|
|
||||||
const nextPreviewImage = String(
|
|
||||||
result.data.previewImageUrl || previewImage || sourceImageUrl
|
|
||||||
).trim();
|
|
||||||
if (!modelUrl) {
|
|
||||||
scheduleNextPoll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const taskAssetPayload =
|
|
||||||
result.data.assetPayload && typeof result.data.assetPayload === "object"
|
|
||||||
? (result.data.assetPayload as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const latest = currentData();
|
|
||||||
updateNode(node.id, {
|
|
||||||
data: {
|
|
||||||
...latest,
|
|
||||||
src: modelUrl,
|
|
||||||
previewImage: nextPreviewImage,
|
|
||||||
format: inferModelFormat(modelUrl),
|
|
||||||
status: "completed",
|
|
||||||
metadata: {
|
|
||||||
...(latest.metadata || {}),
|
|
||||||
taskId,
|
|
||||||
provider: "tripo-3d",
|
|
||||||
mode: "image",
|
|
||||||
sourceImageUrl,
|
|
||||||
progress: 100,
|
|
||||||
output: result.data.output || null,
|
|
||||||
assetPayload: {
|
|
||||||
...taskAssetPayload,
|
|
||||||
providerId: providerId || taskAssetPayload.providerId || null,
|
|
||||||
providerName: providerName || taskAssetPayload.providerName || null,
|
|
||||||
sourceImageUrl,
|
|
||||||
},
|
|
||||||
recoveredAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
} as Model3DNodeData,
|
|
||||||
});
|
|
||||||
await persistTerminalAsset({
|
|
||||||
error: null,
|
|
||||||
modelUrl,
|
|
||||||
previewImageUrl: nextPreviewImage,
|
|
||||||
progress: 100,
|
|
||||||
status: "success",
|
|
||||||
taskAssetPayload,
|
|
||||||
});
|
|
||||||
toast.success("3D 模型已生成到画布");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTerminalModelFailureStatus(status)) {
|
|
||||||
await markFailed(result.data.errorMessage || "3D 模型生成失败", progress);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latest = currentData();
|
|
||||||
updateNode(node.id, {
|
|
||||||
data: {
|
|
||||||
...latest,
|
|
||||||
metadata: {
|
|
||||||
...(latest.metadata || {}),
|
|
||||||
progress,
|
|
||||||
},
|
|
||||||
} as Model3DNodeData,
|
|
||||||
});
|
|
||||||
scheduleNextPoll();
|
|
||||||
}
|
|
||||||
|
|
||||||
void pollTask();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
data.modelVersion,
|
|
||||||
data.previewImage,
|
|
||||||
data.prompt,
|
|
||||||
data.status,
|
|
||||||
getCanvasState,
|
|
||||||
node.id,
|
|
||||||
persistCanvas3DAsset,
|
|
||||||
persistCanvasStateSnapshot,
|
|
||||||
providerId,
|
|
||||||
providerName,
|
|
||||||
sourceImageUrl,
|
|
||||||
taskId,
|
|
||||||
updateNode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
if (!primaryOpenUrl) {
|
if (!primaryOpenUrl) {
|
||||||
toast.error("暂无可打开的地址");
|
toast.error("暂无可打开的地址");
|
||||||
|
|||||||
@ -61,17 +61,6 @@ 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>;
|
||||||
|
|||||||
@ -103,7 +103,6 @@ type World3DViewerProps = {
|
|||||||
resetViewToken?: number;
|
resetViewToken?: number;
|
||||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||||
showMovementControls?: boolean;
|
showMovementControls?: boolean;
|
||||||
showInfoOverlay?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||||
@ -118,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.45;
|
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.22;
|
||||||
|
|
||||||
const EMPTY_MOVEMENT_STATE: MovementState = {
|
const EMPTY_MOVEMENT_STATE: MovementState = {
|
||||||
forward: false,
|
forward: false,
|
||||||
@ -1495,7 +1494,6 @@ 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);
|
||||||
@ -1781,7 +1779,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]} />
|
||||||
{shouldShowGroundHelper ? (
|
{groundHelperOffset !== null ? (
|
||||||
<gridHelper
|
<gridHelper
|
||||||
args={[18, 18, "#334155", "#0f172a"]}
|
args={[18, 18, "#334155", "#0f172a"]}
|
||||||
position={[0, groundHelperOffset, 0]}
|
position={[0, groundHelperOffset, 0]}
|
||||||
@ -2282,7 +2280,6 @@ export function World3DViewer({
|
|||||||
resetViewToken,
|
resetViewToken,
|
||||||
onCaptureReady,
|
onCaptureReady,
|
||||||
showMovementControls = false,
|
showMovementControls = false,
|
||||||
showInfoOverlay = !minimalFrame,
|
|
||||||
}: World3DViewerProps) {
|
}: World3DViewerProps) {
|
||||||
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
||||||
|
|
||||||
@ -2389,7 +2386,7 @@ export function World3DViewer({
|
|||||||
minimalFrame={minimalFrame}
|
minimalFrame={minimalFrame}
|
||||||
resetViewToken={resetViewToken}
|
resetViewToken={resetViewToken}
|
||||||
semanticsMetadata={semanticsMetadata}
|
semanticsMetadata={semanticsMetadata}
|
||||||
showInfoOverlay={showInfoOverlay}
|
showInfoOverlay={!minimalFrame}
|
||||||
showMovementControls={showMovementControls}
|
showMovementControls={showMovementControls}
|
||||||
splatUrl={splatUrl}
|
splatUrl={splatUrl}
|
||||||
splatUrls={splatUrls}
|
splatUrls={splatUrls}
|
||||||
|
|||||||
@ -459,7 +459,7 @@ function MarkdownTableImagePreview({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group/table-image relative block h-16 w-16 overflow-hidden rounded-md border border-border/70 bg-muted/40"
|
className="group/table-image relative block h-24 w-24 overflow-hidden rounded-lg border border-border/70 bg-muted/40"
|
||||||
data-no-drag="true"
|
data-no-drag="true"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Info,
|
Info,
|
||||||
Layers3,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
@ -74,12 +73,6 @@ 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";
|
|
||||||
|
|
||||||
const ImageAnnotatorLazy = dynamic(
|
const ImageAnnotatorLazy = dynamic(
|
||||||
() => import("@/components/image-annotator").then((module) => module.ImageAnnotator),
|
() => import("@/components/image-annotator").then((module) => module.ImageAnnotator),
|
||||||
@ -331,7 +324,6 @@ const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
|
|||||||
imageEditor: Edit,
|
imageEditor: Edit,
|
||||||
aiCad: Box,
|
aiCad: Box,
|
||||||
model3d: Box,
|
model3d: Box,
|
||||||
scene: Layers3,
|
|
||||||
agent: Wand2,
|
agent: Wand2,
|
||||||
videoCanvas: Clapperboard,
|
videoCanvas: Clapperboard,
|
||||||
flowBoard: Workflow,
|
flowBoard: Workflow,
|
||||||
@ -453,10 +445,6 @@ 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"
|
||||||
@ -635,10 +623,6 @@ 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[];
|
||||||
@ -678,32 +662,6 @@ 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 =
|
||||||
@ -716,48 +674,6 @@ 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"
|
||||||
@ -1705,19 +1621,9 @@ 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-[80px]"
|
contentClassName="pl-[80px]"
|
||||||
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"}
|
||||||
@ -1970,7 +1876,6 @@ 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}
|
||||||
@ -1981,12 +1886,7 @@ export function LovartHome({
|
|||||||
tone={effectiveResolvedTheme === "dark" ? "dark" : "light"}
|
tone={effectiveResolvedTheme === "dark" ? "dark" : "light"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main
|
<main className="ml-[92px] mr-10 w-auto max-w-none pb-16 pt-[68px] max-lg:mx-5">
|
||||||
className={cn(
|
|
||||||
"ml-[92px] mr-10 w-auto max-w-none pb-16 max-lg:mx-5",
|
|
||||||
shouldShowHomeBanner ? "pt-[113px]" : "pt-[68px]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!isProjectsOnlyView && (
|
{!isProjectsOnlyView && (
|
||||||
<LovartHeroComposer
|
<LovartHeroComposer
|
||||||
composerPlaceholder={composerPlaceholder}
|
composerPlaceholder={composerPlaceholder}
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -48,7 +48,6 @@ 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;
|
||||||
@ -77,7 +76,6 @@ export function LovartSidebar({
|
|||||||
toolbox1Links,
|
toolbox1Links,
|
||||||
toolbox2Enabled,
|
toolbox2Enabled,
|
||||||
toolbox2Links,
|
toolbox2Links,
|
||||||
topOffset = 0,
|
|
||||||
tutorialEnabled,
|
tutorialEnabled,
|
||||||
tutorialVideos,
|
tutorialVideos,
|
||||||
videoCanvasEnabled,
|
videoCanvasEnabled,
|
||||||
@ -169,7 +167,6 @@ 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-3 pt-3 [-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-3 pt-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
@ -283,7 +280,6 @@ export function LovartSidebar({
|
|||||||
{tooltipButton("imageEditor", onImageEditor)}
|
{tooltipButton("imageEditor", onImageEditor)}
|
||||||
{tooltipButton("aiCad", () => onRoute("/ai-cad"))}
|
{tooltipButton("aiCad", () => onRoute("/ai-cad"))}
|
||||||
{tooltipButton("model3d", () => onRoute("/model3d"))}
|
{tooltipButton("model3d", () => onRoute("/model3d"))}
|
||||||
{tooltipButton("scene", () => onRoute("/pascal-editor"))}
|
|
||||||
{agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null}
|
{agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null}
|
||||||
{videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null}
|
{videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null}
|
||||||
{tooltipButton("flowBoard", () => onRoute("/flow-board"))}
|
{tooltipButton("flowBoard", () => onRoute("/flow-board"))}
|
||||||
|
|||||||
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