Compare commits
65 Commits
main
...
codex/prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4e3561510 | ||
|
|
92e0f49fbc | ||
|
|
a913f78f8b | ||
|
|
d65ebc253b | ||
|
|
4a6afb753d | ||
|
|
835b60f61c | ||
|
|
8a075045f0 | ||
|
|
f3f1fdafbd | ||
|
|
712d4350a0 | ||
|
|
e2f8a253a7 | ||
|
|
7a837c2180 | ||
|
|
94d6a9008c | ||
|
|
1b281a76dc | ||
|
|
2489ce54f2 | ||
|
|
86c196ce51 | ||
|
|
a0a144f506 | ||
|
|
cef15871ac | ||
|
|
9ae24394fe | ||
|
|
6ca7feb1a0 | ||
|
|
265b667628 | ||
|
|
18c4044c66 | ||
|
|
643a0128d3 | ||
|
|
219dc4a4ac | ||
|
|
d642470210 | ||
|
|
b3161d2ebc | ||
|
|
39e8107f23 | ||
|
|
4b53f4056a | ||
|
|
dd11942da7 | ||
|
|
8773b108e4 | ||
|
|
05de6d005c | ||
|
|
58f1cf1ae2 | ||
|
|
231be15459 | ||
|
|
9e6db98195 | ||
|
|
5e74c44430 | ||
|
|
c5c49b52a9 | ||
|
|
c988f2dee0 | ||
|
|
87bbcf0ccd | ||
|
|
9093b0370b | ||
|
|
288cd7bf79 | ||
|
|
8b4fd29fce | ||
|
|
3c6b7b7778 | ||
|
|
d5737b70c0 | ||
|
|
0a81f15f11 | ||
|
|
7c9fd8a796 | ||
|
|
55be81d3d8 | ||
|
|
000e5c75c9 | ||
|
|
f13b4e8909 | ||
|
|
78b0da908c | ||
|
|
f7fc3561f5 | ||
|
|
1b7d674bce | ||
|
|
79755bf9e8 | ||
|
|
ebc0680903 | ||
|
|
42a42e496d | ||
|
|
5e46ea698c | ||
|
|
d0a3708e6d | ||
|
|
e1044261d3 | ||
|
|
c8c6d9132a | ||
|
|
e4d527f0f2 | ||
|
|
bb62fa680f | ||
|
|
b3abaeef8d | ||
|
|
1ab1495e62 | ||
|
|
da5c0699dd | ||
|
|
5f15b99e7c | ||
|
|
56658a4d16 | ||
|
|
bc7b6f983f |
@ -120,9 +120,6 @@ ZOO_BASE_URL=https://api.zoo.dev
|
||||
# /vendor/pascal-editor/index.html bundle.
|
||||
NEXT_PUBLIC_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_CALLBACK_URL=
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -11,8 +11,6 @@ coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
.next-build/
|
||||
.next-previous/
|
||||
out/
|
||||
build
|
||||
|
||||
@ -89,7 +87,13 @@ checkpoints/
|
||||
# 第三方参考源码(不提交到git)
|
||||
reference-sources/
|
||||
|
||||
# Legacy external Pascal Editor checkout (the managed source is under services/).
|
||||
# Development-only upload fallback.
|
||||
public/uploads/local/
|
||||
|
||||
# Local isolated database and video-canvas test fixtures.
|
||||
.local/
|
||||
|
||||
# Local self-hosted Pascal Editor checkout.
|
||||
.pascal-editor/
|
||||
|
||||
# Local self-hosted Zoo Design Studio checkout.
|
||||
|
||||
@ -144,10 +144,18 @@ log.error("Operation failed", error, { userId: "123" });
|
||||
#### 7. 修复 service 层错误吞没
|
||||
**风险等级**: 🟡 中等风险
|
||||
**改动**:
|
||||
- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误
|
||||
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
|
||||
|
||||
**潜在问题**:
|
||||
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
|
||||
- 如果有其他代码依赖 ai-cad service 的错误格式,可能受影响
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 触发一个 AI-CAD 错误(如无效的 workspace ID)
|
||||
# 检查日志是否包含完整错误堆栈
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,706 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
Clock3,
|
||||
Globe2,
|
||||
LogIn,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Unlock,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
|
||||
type Severity = "info" | "watch" | "high" | "critical";
|
||||
|
||||
type AccessLogEvent = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
ip: string;
|
||||
userEmail: string | null;
|
||||
userRole: string | null;
|
||||
method: string | null;
|
||||
path: string | null;
|
||||
routeGroup: string | null;
|
||||
statusCode: number | null;
|
||||
outcome: string;
|
||||
severity: Severity;
|
||||
reasons: string[] | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AccessLogTopIp = {
|
||||
ip: string;
|
||||
total: number;
|
||||
uniqueUsers: number;
|
||||
failureCount: number;
|
||||
rateLimitedCount: number;
|
||||
authFailureCount: number;
|
||||
loginSuccessCount: number;
|
||||
suspiciousCount: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
severity: Severity;
|
||||
failureRate: number;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
type AccessLogResponse = {
|
||||
overview: {
|
||||
totalEvents: number;
|
||||
uniqueIps: number;
|
||||
abnormalEvents: number;
|
||||
abnormalIps: number;
|
||||
rateLimitedEvents: number;
|
||||
manualBlockedEvents: number;
|
||||
loginSuccessEvents: number;
|
||||
unauthorizedEvents: number;
|
||||
};
|
||||
topIps: AccessLogTopIp[];
|
||||
hourlyStats: Array<{
|
||||
hour: string;
|
||||
count: number;
|
||||
abnormalCount: number;
|
||||
}>;
|
||||
pathStats: Array<{
|
||||
path: string | null;
|
||||
routeGroup: string | null;
|
||||
count: number;
|
||||
abnormalCount: number;
|
||||
}>;
|
||||
recentEvents: AccessLogEvent[];
|
||||
rules: string[];
|
||||
};
|
||||
|
||||
type AccessBlocklistEntry = {
|
||||
id: string;
|
||||
ip: string;
|
||||
reason: string | null;
|
||||
isActive: boolean;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdByEmail: string | null;
|
||||
};
|
||||
|
||||
const severityLabels: Record<Severity, string> = {
|
||||
info: "正常",
|
||||
watch: "关注",
|
||||
high: "异常",
|
||||
critical: "高危",
|
||||
};
|
||||
|
||||
const severityClassNames: Record<Severity, string> = {
|
||||
info:
|
||||
"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 dark:border-amber-800/70 dark:bg-amber-950/45 dark:text-amber-300",
|
||||
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) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null | undefined) {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getStatusClassName(statusCode: number | null) {
|
||||
if (!statusCode) return "text-muted-foreground";
|
||||
if (statusCode >= 500) return "text-red-700 dark:text-red-300";
|
||||
if (statusCode >= 400) return "text-orange-700 dark:text-orange-300";
|
||||
if (statusCode >= 300) return "text-sky-700 dark:text-sky-300";
|
||||
return "text-emerald-700 dark:text-emerald-300";
|
||||
}
|
||||
|
||||
function AccessSeverityBadge({ severity }: { severity: Severity }) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn("shrink-0 border px-2 py-0.5", severityClassNames[severity])}
|
||||
variant="outline"
|
||||
>
|
||||
{severityLabels[severity]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessSecurityDashboard() {
|
||||
const [hours, setHours] = useState("24");
|
||||
const [severity, setSeverity] = useState("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [blockIp, setBlockIp] = useState("");
|
||||
const [blockReason, setBlockReason] = useState("");
|
||||
const [isSavingBlock, setIsSavingBlock] = useState(false);
|
||||
|
||||
const accessLogUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
hours,
|
||||
severity,
|
||||
limit: "160",
|
||||
});
|
||||
if (query.trim()) params.set("q", query.trim());
|
||||
return `/api/admin/access-logs?${params.toString()}`;
|
||||
}, [hours, query, severity]);
|
||||
|
||||
const { data, isLoading, mutate } = useSWR<AccessLogResponse>(
|
||||
accessLogUrl,
|
||||
fetcher,
|
||||
{ refreshInterval: 30_000 }
|
||||
);
|
||||
const { data: blocklistData, mutate: mutateBlocklist } = useSWR<{
|
||||
entries: AccessBlocklistEntry[];
|
||||
}>("/api/admin/access-blocklist", fetcher);
|
||||
|
||||
const activeBlocklistEntries = useMemo(
|
||||
() => (blocklistData?.entries || []).filter((entry) => entry.isActive),
|
||||
[blocklistData?.entries]
|
||||
);
|
||||
const blockedRules = useMemo(
|
||||
() => new Set(activeBlocklistEntries.map((entry) => entry.ip.toLowerCase())),
|
||||
[activeBlocklistEntries]
|
||||
);
|
||||
|
||||
const saveBlockEntry = async (targetIp?: string, targetReason?: string) => {
|
||||
const ip = String(targetIp || blockIp).trim();
|
||||
const reason = String(targetReason ?? blockReason).trim();
|
||||
if (!ip) {
|
||||
toast.error("请输入要拉黑的 IP");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingBlock(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/access-blocklist", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ip, reason }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || "拉黑失败");
|
||||
}
|
||||
|
||||
toast.success(`${ip} 已加入手动黑名单`);
|
||||
setBlockIp("");
|
||||
setBlockReason("");
|
||||
await Promise.all([mutateBlocklist(), mutate()]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "拉黑失败");
|
||||
} finally {
|
||||
setIsSavingBlock(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBlockEntry = async (entry: AccessBlocklistEntry, isActive: boolean) => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/access-blocklist", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: entry.id,
|
||||
isActive,
|
||||
reason: entry.reason || "",
|
||||
expiresAt: entry.expiresAt || null,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || "更新失败");
|
||||
}
|
||||
|
||||
toast.success(isActive ? `${entry.ip} 已重新拉黑` : `${entry.ip} 已解除拉黑`);
|
||||
await Promise.all([mutateBlocklist(), mutate()]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
const overviewCards = [
|
||||
{
|
||||
key: "events",
|
||||
label: "访问记录",
|
||||
value: formatNumber(data?.overview.totalEvents),
|
||||
note: `${hours} 小时窗口`,
|
||||
icon: Activity,
|
||||
className:
|
||||
"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",
|
||||
label: "独立 IP",
|
||||
value: formatNumber(data?.overview.uniqueIps),
|
||||
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
|
||||
icon: Globe2,
|
||||
className:
|
||||
"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",
|
||||
label: "异常事件",
|
||||
value: formatNumber(data?.overview.abnormalEvents),
|
||||
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
|
||||
icon: ShieldAlert,
|
||||
className:
|
||||
"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",
|
||||
label: "成功登录",
|
||||
value: formatNumber(data?.overview.loginSuccessEvents),
|
||||
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
|
||||
icon: LogIn,
|
||||
className:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-100",
|
||||
},
|
||||
];
|
||||
|
||||
const maxHourlyCount = Math.max(
|
||||
1,
|
||||
...(data?.hourlyStats || []).map((item) => item.count)
|
||||
);
|
||||
const maxPathCount = Math.max(
|
||||
1,
|
||||
...(data?.pathStats || []).map((item) => item.count)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">访问安全分析</CardTitle>
|
||||
<CardDescription>
|
||||
IP 访问记录、登录来源、异常标注与请求趋势
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative sm:w-[260px]">
|
||||
<Search className="-translate-y-1/2 absolute left-3 top-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-9 pl-9"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索 IP、路径、用户"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Select onValueChange={setHours} value={hours}>
|
||||
<SelectTrigger className="h-9 sm:w-[132px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">近 1 小时</SelectItem>
|
||||
<SelectItem value="6">近 6 小时</SelectItem>
|
||||
<SelectItem value="24">近 24 小时</SelectItem>
|
||||
<SelectItem value="72">近 3 天</SelectItem>
|
||||
<SelectItem value="168">近 7 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={setSeverity} value={severity}>
|
||||
<SelectTrigger className="h-9 sm:w-[132px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部级别</SelectItem>
|
||||
<SelectItem value="abnormal">仅异常</SelectItem>
|
||||
<SelectItem value="watch">关注</SelectItem>
|
||||
<SelectItem value="high">异常</SelectItem>
|
||||
<SelectItem value="critical">高危</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="h-9 gap-2"
|
||||
onClick={() => mutate()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{overviewCards.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-lg border p-4", item.className)}
|
||||
key={item.key}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-current/70">{item.label}</div>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="mt-3 font-semibold text-3xl tracking-tight">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="mt-1 text-current/65 text-xs">{item.note}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 font-medium text-sm">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
小时趋势
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
橙色表示异常事件占比
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[180px] items-end gap-1">
|
||||
{(data?.hourlyStats || []).map((item) => {
|
||||
const height = Math.max(4, (item.count / maxHourlyCount) * 100);
|
||||
const abnormalHeight =
|
||||
item.count > 0
|
||||
? Math.max(0, (item.abnormalCount / item.count) * height)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200 dark:bg-slate-800"
|
||||
key={item.hour}
|
||||
style={{ height: `${height}%` }}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-orange-400"
|
||||
style={{ height: `${abnormalHeight}%` }}
|
||||
/>
|
||||
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-white text-xs shadow-lg group-hover:block">
|
||||
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "}
|
||||
{formatNumber(item.abnormalCount)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!data?.hourlyStats || data.hourlyStats.length === 0) && (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
|
||||
暂无访问记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">手动 IP 黑名单</CardTitle>
|
||||
<CardDescription>
|
||||
管理员手动加入后,命中 IP 会被阻断并写入访问日志
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(240px,1fr)_auto]">
|
||||
<Input
|
||||
className="h-9"
|
||||
onChange={(event) => setBlockIp(event.target.value)}
|
||||
placeholder="IP 或 IPv4 CIDR"
|
||||
value={blockIp}
|
||||
/>
|
||||
<Input
|
||||
className="h-9"
|
||||
onChange={(event) => setBlockReason(event.target.value)}
|
||||
placeholder="拉黑原因"
|
||||
value={blockReason}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 gap-2"
|
||||
disabled={isSavingBlock}
|
||||
onClick={() => saveBlockEntry()}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
加入黑名单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{activeBlocklistEntries.map((entry) => (
|
||||
<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"
|
||||
key={entry.id}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
<span className="truncate font-mono font-semibold text-sm text-red-900 dark:text-red-200">
|
||||
{entry.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs dark:text-red-300/75">
|
||||
{entry.reason || "未填写原因"}
|
||||
</div>
|
||||
<div className="mt-2 text-red-700/70 text-xs dark:text-red-400/70">
|
||||
{formatTime(entry.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
onClick={() => updateBlockEntry(entry, false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
解除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeBlocklistEntries.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed py-8 text-center text-muted-foreground text-sm md:col-span-2 xl:col-span-3">
|
||||
暂无手动拉黑 IP
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">异常 IP 标注</CardTitle>
|
||||
<CardDescription>
|
||||
按限流、失败率、认证异常、敏感路径和请求量综合排序
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(data?.topIps || []).map((item) => (
|
||||
<div
|
||||
className="rounded-lg border bg-background px-4 py-3"
|
||||
key={item.ip}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono font-semibold text-sm">
|
||||
{item.ip}
|
||||
</span>
|
||||
<AccessSeverityBadge severity={item.severity} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-muted-foreground text-xs">
|
||||
<span>{formatNumber(item.total)} 次访问</span>
|
||||
<span>{formatNumber(item.uniqueUsers)} 个用户</span>
|
||||
<span>失败率 {formatPercent(item.failureRate)}</span>
|
||||
<span>最近 {formatTime(item.lastSeen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 md:justify-end">
|
||||
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
|
||||
(reason) => (
|
||||
<Badge
|
||||
className="border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||||
key={reason}
|
||||
variant="outline"
|
||||
>
|
||||
{reason}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
disabled={blockedRules.has(item.ip.toLowerCase()) || isSavingBlock}
|
||||
onClick={() =>
|
||||
saveBlockEntry(
|
||||
item.ip,
|
||||
item.reasons.join("、") || "管理员从异常 IP 排行手动拉黑"
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
{blockedRules.has(item.ip.toLowerCase()) ? "已拉黑" : "拉黑"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!data?.topIps || data.topIps.length === 0) && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
||||
暂无 IP 聚合数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">访问路径</CardTitle>
|
||||
<CardDescription>按请求数量排序</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{(data?.pathStats || []).map((item) => {
|
||||
const abnormalRate =
|
||||
item.count > 0 ? item.abnormalCount / item.count : 0;
|
||||
return (
|
||||
<div key={`${item.routeGroup || "page"}-${item.path || "/"}`}>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{item.path || "/"}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{formatNumber(item.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-zinc-900 dark:bg-zinc-200"
|
||||
style={{
|
||||
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="-mt-2 h-full rounded-full bg-orange-400"
|
||||
style={{
|
||||
width: `${Math.round(abnormalRate * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!data?.pathStats || data.pathStats.length === 0) && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
||||
暂无路径统计
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">访问日志列表</CardTitle>
|
||||
<CardDescription>最近 {formatNumber(data?.recentEvents.length)} 条</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(data?.rules || []).map((rule) => (
|
||||
<Badge
|
||||
className="border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||||
key={rule}
|
||||
variant="outline"
|
||||
>
|
||||
{rule}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<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>时间</div>
|
||||
<div>IP</div>
|
||||
<div>状态</div>
|
||||
<div>路径</div>
|
||||
<div>用户</div>
|
||||
<div>标注</div>
|
||||
</div>
|
||||
{(data?.recentEvents || []).map((event) => (
|
||||
<div
|
||||
className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] items-center border-b px-4 py-3 text-sm last:border-b-0"
|
||||
key={event.id}
|
||||
>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatTime(event.createdAt)}
|
||||
</div>
|
||||
<div className="font-mono text-xs">{event.ip}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-semibold text-xs",
|
||||
getStatusClassName(event.statusCode)
|
||||
)}
|
||||
>
|
||||
{event.statusCode || "--"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{event.method || "GET"} {event.path || "/"}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-muted-foreground text-xs">
|
||||
{event.reasons?.join("、") || event.outcome}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs">
|
||||
{event.userEmail || "匿名"}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
|
||||
<UserCheck className="h-3 w-3" />
|
||||
{event.userRole || "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{event.severity === "high" || event.severity === "critical" ? (
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
) : null}
|
||||
<AccessSeverityBadge severity={event.severity} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!data?.recentEvents || data.recentEvents.length === 0) && (
|
||||
<div className="py-10 text-center text-muted-foreground text-sm">
|
||||
暂无访问日志
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,9 +11,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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";
|
||||
|
||||
const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items);
|
||||
|
||||
function SettingsQuickAction({
|
||||
activeTab,
|
||||
item,
|
||||
@ -31,8 +33,8 @@ function SettingsQuickAction({
|
||||
? "border-foreground/10 bg-foreground text-background shadow-sm hover:!bg-foreground/90 hover:!text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:!bg-white/90 dark:hover:!text-black"
|
||||
: "border-transparent bg-transparent text-muted-foreground hover:!border-border/80 hover:!bg-zinc-100 hover:!text-foreground dark:hover:!bg-zinc-900/50";
|
||||
const layoutClassName = stretched
|
||||
? "h-8 w-full min-w-0 justify-center gap-1.5 rounded-md border px-2.5 text-xs"
|
||||
: "h-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs";
|
||||
? "h-10 w-full min-w-0 justify-center gap-2 rounded-2xl border px-4"
|
||||
: "h-9 shrink-0 gap-2 rounded-full border px-3.5";
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
@ -43,7 +45,7 @@ function SettingsQuickAction({
|
||||
variant="ghost"
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
@ -57,7 +59,7 @@ function SettingsQuickAction({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
||||
</Button>
|
||||
);
|
||||
@ -71,13 +73,9 @@ export function AdminSettingsNavigation({
|
||||
onTabChange: (value: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const activeItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
||||
(item) => item.value === activeTab
|
||||
);
|
||||
const activeItem = ALL_SETTINGS_ITEMS.find((item) => item.value === activeTab);
|
||||
const handleSelectChange = (value: string) => {
|
||||
const nextItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
||||
(item) => item.value === value
|
||||
);
|
||||
const nextItem = ALL_SETTINGS_ITEMS.find((item) => item.value === value);
|
||||
if (nextItem?.href) {
|
||||
router.push(nextItem.href);
|
||||
return;
|
||||
@ -86,18 +84,15 @@ export function AdminSettingsNavigation({
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="系统设置导航"
|
||||
className="sticky top-2 z-30 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85"
|
||||
>
|
||||
<div className="md:hidden">
|
||||
<div className="rounded-2xl border bg-background px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-2 md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select onValueChange={handleSelectChange} value={activeTab}>
|
||||
<SelectTrigger className="h-8 flex-1 border-border/70 bg-background shadow-none">
|
||||
<SelectTrigger className="h-9 flex-1 border-border/70 bg-background shadow-none">
|
||||
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
|
||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
@ -107,7 +102,7 @@ export function AdminSettingsNavigation({
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="h-8 rounded-md border-border/70 px-2.5 text-xs"
|
||||
className="h-9 rounded-full border-border/70 px-3 text-[13px]"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
@ -118,22 +113,35 @@ export function AdminSettingsNavigation({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||
<SettingsQuickAction
|
||||
activeTab={activeTab}
|
||||
item={item}
|
||||
key={item.value}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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) => (
|
||||
<SettingsQuickAction
|
||||
activeTab={activeTab}
|
||||
item={item}
|
||||
key={item.value}
|
||||
onTabChange={onTabChange}
|
||||
stretched
|
||||
/>
|
||||
))}
|
||||
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
|
||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||
<div className="min-w-[118px] flex-1" key={item.value}>
|
||||
<SettingsQuickAction
|
||||
activeTab={activeTab}
|
||||
item={item}
|
||||
onTabChange={onTabChange}
|
||||
stretched
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="h-8 min-w-0 rounded-md border-border/70 px-2.5 text-xs"
|
||||
className="h-9 shrink-0 rounded-full border-border/70 px-3.5 text-[13px]"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
@ -143,6 +151,6 @@ export function AdminSettingsNavigation({
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,22 +26,22 @@ export function LocalAiCommandCenter({
|
||||
onSetAllExpanded: (expanded: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<div className="border-b bg-muted/20 px-4 py-3">
|
||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-[10px] text-muted-foreground">
|
||||
<div className="overflow-hidden rounded-xl border bg-background">
|
||||
<div className="border-b bg-muted/20 px-5 py-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-semibold text-muted-foreground text-xs">
|
||||
SYSTEM INTEGRATIONS
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg">
|
||||
<h2 className="font-semibold text-2xl tracking-normal">
|
||||
高级能力与外部服务
|
||||
</h2>
|
||||
<p className="max-w-3xl text-muted-foreground text-xs leading-5">
|
||||
<p className="max-w-3xl text-muted-foreground text-sm leading-6">
|
||||
把商品策划、图像处理、3D/CAD、实时画布和开放 API
|
||||
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SettingStatusBadge tone="neutral">
|
||||
已展开 {openSectionCount} 个模块
|
||||
</SettingStatusBadge>
|
||||
@ -52,7 +52,7 @@ export function LocalAiCommandCenter({
|
||||
模型 {modelCount}
|
||||
</SettingStatusBadge>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[11px]"
|
||||
className="h-7 rounded-md px-2 text-xs"
|
||||
onClick={() => onSetAllExpanded(true)}
|
||||
size="sm"
|
||||
type="button"
|
||||
@ -61,7 +61,7 @@ export function LocalAiCommandCenter({
|
||||
全部展开
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[11px]"
|
||||
className="h-7 rounded-md px-2 text-xs"
|
||||
onClick={() => onSetAllExpanded(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
@ -77,21 +77,21 @@ export function LocalAiCommandCenter({
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className="group bg-background p-3 text-left transition-colors hover:bg-muted/30"
|
||||
className="group bg-background p-4 text-left transition-colors hover:bg-muted/30"
|
||||
key={item.key}
|
||||
onClick={() => onFocusSection(item.key)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-muted/30">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-muted/30">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-xs">
|
||||
<div className="truncate font-semibold text-sm">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
<div className="mt-0.5 truncate text-muted-foreground text-xs">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
@ -104,20 +104,20 @@ export function LocalAiCommandCenter({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t bg-muted/10 px-4 py-3">
|
||||
<div className="grid gap-3 lg:grid-cols-4">
|
||||
<div className="border-t bg-muted/10 px-5 py-4">
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
|
||||
<div className="space-y-1.5" key={group}>
|
||||
<div className="font-semibold text-[11px] text-muted-foreground">
|
||||
<div className="space-y-2" key={group}>
|
||||
<div className="font-semibold text-muted-foreground text-xs">
|
||||
{group}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickLinks
|
||||
.filter((item) => item.group === group)
|
||||
.map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center gap-1 rounded-md border px-2 text-[11px] transition-colors hover:bg-background",
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs transition-colors hover:bg-background",
|
||||
isSectionExpanded(item.key)
|
||||
? "border-foreground/20 bg-background text-foreground"
|
||||
: "border-border bg-transparent text-muted-foreground"
|
||||
|
||||
@ -24,13 +24,13 @@ export function LocalAiGroupHeading({
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<div className="font-semibold text-[10px] text-muted-foreground uppercase">
|
||||
<div className="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div className="font-semibold text-[11px] text-muted-foreground uppercase tracking-[0.16em]">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
|
||||
<h3 className="font-semibold text-sm text-foreground">{title}</h3>
|
||||
<p className="max-w-3xl text-muted-foreground text-xs">{description}</p>
|
||||
<h3 className="font-semibold text-base text-foreground">{title}</h3>
|
||||
<p className="max-w-3xl text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -45,7 +45,7 @@ export function LocalAiSectionSummary({
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{items.map((item) => (
|
||||
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
|
||||
{item.label}
|
||||
@ -65,16 +65,16 @@ export function LocalAiSectionToggleButton({
|
||||
return (
|
||||
<Button
|
||||
aria-expanded={expanded}
|
||||
className="h-7 shrink-0 gap-1 rounded-md px-2 text-[11px]"
|
||||
className="h-8 shrink-0 gap-1.5 rounded-md px-2.5 text-xs"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{expanded ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{expanded ? "收起配置" : "展开配置"}
|
||||
</Button>
|
||||
@ -100,9 +100,9 @@ export function LocalAiSectionShell({
|
||||
}) {
|
||||
return (
|
||||
<Card id={id}>
|
||||
<CardHeader className="flex flex-col gap-3 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
{summary}
|
||||
</div>
|
||||
@ -110,7 +110,7 @@ export function LocalAiSectionShell({
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"local-ai-config-card-content space-y-4 pt-4",
|
||||
"local-ai-config-card-content space-y-6 pt-6",
|
||||
!expanded && "hidden"
|
||||
)}
|
||||
>
|
||||
|
||||
@ -916,8 +916,8 @@ export function LocalAiModel3dSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI
|
||||
提供商配置。
|
||||
配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
|
||||
服务,保存后写入数据库 AI 提供商配置。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("kittycad-cad")}
|
||||
@ -1003,7 +1003,7 @@ export function LocalAiModel3dSections({
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">启用 KittyCAD CAD</Label>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
|
||||
关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -590,7 +590,7 @@ export function LocalAiPlanningSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
为 AI详情生成 单独配置 Kimi Code 编程套餐
|
||||
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.6
|
||||
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
|
||||
</>
|
||||
}
|
||||
@ -615,7 +615,7 @@ export function LocalAiPlanningSections({
|
||||
]}
|
||||
/>
|
||||
}
|
||||
title={<>Kimi Code 编程套餐配置</>}
|
||||
title={<>Moonshot 官方 Kimi K2.6 配置</>}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotProviderName">提供商名称</Label>
|
||||
@ -627,13 +627,13 @@ export function LocalAiPlanningSections({
|
||||
providerName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Kimi Code 编程套餐"
|
||||
placeholder="Moonshot AI (Official)"
|
||||
value={moonshotDetailAgentConfig.providerName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotBaseUrl">编程套餐专用 API 地址</Label>
|
||||
<Label htmlFor="moonshotBaseUrl">官方 API 地址</Label>
|
||||
<Input
|
||||
id="moonshotBaseUrl"
|
||||
onChange={(e) =>
|
||||
@ -646,7 +646,7 @@ export function LocalAiPlanningSections({
|
||||
value={moonshotDetailAgentConfig.baseUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
根据 Kimi Code 文档,编程套餐 OpenAI 兼容接口地址为 `
|
||||
根据 Moonshot 官方文档,OpenAI 兼容接口地址为 `
|
||||
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
|
||||
</div>
|
||||
</div>
|
||||
@ -667,7 +667,7 @@ export function LocalAiPlanningSections({
|
||||
placeholder={
|
||||
moonshotDetailAgentConfig.providerId
|
||||
? "留空则不修改当前 Key"
|
||||
: "请输入 Kimi Code 控制台 API Key"
|
||||
: "请输入 Moonshot 官方 API Key"
|
||||
}
|
||||
type="password"
|
||||
value={moonshotDetailAgentConfig.apiKey}
|
||||
@ -675,7 +675,7 @@ export function LocalAiPlanningSections({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotModelId">Kimi Code API 模型 ID *</Label>
|
||||
<Label htmlFor="moonshotModelId">Kimi K2.6 API 模型 ID *</Label>
|
||||
<Input
|
||||
id="moonshotModelId"
|
||||
onChange={(e) =>
|
||||
@ -688,8 +688,8 @@ export function LocalAiPlanningSections({
|
||||
value={moonshotDetailAgentConfig.modelId}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Kimi Code 编程套餐专用接口固定使用
|
||||
`kimi-for-coding`;保存后系统会自动创建/更新对应模型记录。
|
||||
已按你提供的 Moonshot 官方文档默认填写
|
||||
`kimi-k2.6`;保存后系统会自动创建/更新对应模型记录。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -745,7 +745,7 @@ export function LocalAiPlanningSections({
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存 Kimi Code 编程套餐配置
|
||||
保存 Moonshot Kimi K2.6 配置
|
||||
</Button>
|
||||
</div>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
@ -15,7 +15,7 @@ export function SettingStatusBadge({
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
"h-5 rounded-md border px-1.5 font-medium text-[10px]",
|
||||
"h-6 rounded-md border px-2 font-medium text-[11px]",
|
||||
tone === "enabled" &&
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
MessageSquare,
|
||||
Scissors,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
@ -38,12 +37,6 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
|
||||
description: "用量、趋势与运营指标",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
value: "access-security",
|
||||
label: "访问安全",
|
||||
description: "访问 IP、异常标注与登录来源",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -114,7 +107,3 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ADMIN_SETTINGS_NAV_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap(
|
||||
(group) => group.items
|
||||
);
|
||||
|
||||
@ -12,8 +12,6 @@ import {
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
Crop,
|
||||
@ -38,7 +36,7 @@ import {
|
||||
ImagePlus,
|
||||
Key,
|
||||
Layers,
|
||||
Layers3,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
Maximize2,
|
||||
@ -161,8 +159,6 @@ import {
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
|
||||
isDetailAgentLocalMoonshotBaseUrl,
|
||||
normalizeDetailAgentLocalMoonshotBaseUrl,
|
||||
normalizeDetailAgentLocalMoonshotConfig,
|
||||
} from "@/lib/ai/detail-agent-model-config";
|
||||
import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models";
|
||||
@ -321,7 +317,6 @@ import {
|
||||
} from "@/lib/worldlabs/key-pool-config-shared";
|
||||
import type { AgentWorkflowUiConfig } from "@/types/agent";
|
||||
import { AdminSettingsNavigation } from "./_components/admin-settings-navigation";
|
||||
import { AccessSecurityDashboard } from "./_components/access-security-dashboard";
|
||||
import { LocalAiCommandCenter } from "./_components/local-ai-command-center";
|
||||
import { LocalAiGroupHeading } from "./_components/local-ai-section-shell";
|
||||
import { LocalAiImagePipelineSections } from "./_components/local-ai-sections/image-pipeline-sections";
|
||||
@ -332,7 +327,6 @@ import {
|
||||
DEFAULT_LOCAL_AI_EXPANDED_SECTIONS,
|
||||
LOCAL_AI_EXPANDED_STORAGE_KEY,
|
||||
} from "./_lib/local-ai-sections";
|
||||
import { ADMIN_SETTINGS_NAV_ITEMS } from "./_lib/settings-navigation";
|
||||
import type {
|
||||
LocalAiOverviewItem,
|
||||
LocalAiPlanningSectionsProps,
|
||||
@ -1061,6 +1055,8 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
|
||||
imageRemoveBackground: Scissors,
|
||||
imageBoxCutout: Scan,
|
||||
imageLocalEdit: Wand2,
|
||||
imageLayoutReplace: LayoutTemplate,
|
||||
imagePsdTemplateReplace: ImageIcon,
|
||||
imageRemoveWatermark: Eraser,
|
||||
imageResize: Maximize2,
|
||||
imageTextEdit: Type,
|
||||
@ -1158,7 +1154,6 @@ const LOVART_HOME_SIDEBAR_FALLBACK_ICONS: Record<
|
||||
imageEditor: Edit,
|
||||
aiCad: Box,
|
||||
model3d: Box,
|
||||
scene: Layers3,
|
||||
agent: Wand2,
|
||||
videoCanvas: Video,
|
||||
flowBoard: GitBranch,
|
||||
@ -1610,7 +1605,7 @@ const PROVIDER_TYPES = [
|
||||
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
||||
{ value: "bananaimage", label: "BananaImage 生图" },
|
||||
{ value: "visionary", label: "Visionary 生图" },
|
||||
{ value: "atlascloud", label: "Atlas Cloud 图片 / 视频 / 3D 模型" },
|
||||
{ value: "atlascloud", label: "Atlas Cloud 图片 / 3D 模型" },
|
||||
{ value: "vip98k", label: "VIP98K 混合模型服务" },
|
||||
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
|
||||
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
||||
@ -1744,31 +1739,15 @@ function isBltcyBflBaseUrl(value?: string | null) {
|
||||
}
|
||||
|
||||
function isMoonshotOfficialBaseUrl(value?: string | null) {
|
||||
return isDetailAgentLocalMoonshotBaseUrl(value);
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.replace(TRAILING_SLASHES_REGEX, "");
|
||||
return normalized === DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
|
||||
}
|
||||
|
||||
export function AdminSettingsClient() {
|
||||
const [activeTab, setActiveTab] = useState("users");
|
||||
|
||||
useEffect(() => {
|
||||
const requestedTab = new URLSearchParams(window.location.search).get("tab");
|
||||
if (
|
||||
requestedTab &&
|
||||
ADMIN_SETTINGS_NAV_ITEMS.some(
|
||||
(item) => item.value === requestedTab && !item.href
|
||||
)
|
||||
) {
|
||||
setActiveTab(requestedTab);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActiveTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", value);
|
||||
window.history.replaceState(null, "", url);
|
||||
};
|
||||
|
||||
// 用户管理状态
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [userPage, setUserPage] = useState(1);
|
||||
@ -2762,7 +2741,7 @@ export function AdminSettingsClient() {
|
||||
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
|
||||
providerId: "",
|
||||
modelEntryId: "",
|
||||
providerName: "Kimi Code 编程套餐",
|
||||
providerName: "Moonshot AI (Official)",
|
||||
baseUrl: DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
||||
apiKey: "",
|
||||
modelId: DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||
@ -6614,7 +6593,7 @@ export function AdminSettingsClient() {
|
||||
} catch (modelError) {
|
||||
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
|
||||
toast.warning(
|
||||
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 模型"按钮'
|
||||
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 图片 / 3D 模型"按钮'
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7300,9 +7279,8 @@ export function AdminSettingsClient() {
|
||||
const handleSaveMoonshotDetailAgentConfig = async () => {
|
||||
try {
|
||||
const trimmedBaseUrl =
|
||||
normalizeDetailAgentLocalMoonshotBaseUrl(
|
||||
moonshotDetailAgentConfig.baseUrl
|
||||
);
|
||||
moonshotDetailAgentConfig.baseUrl.trim() ||
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
|
||||
const trimmedModelId =
|
||||
moonshotDetailAgentConfig.modelId.trim() ||
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID;
|
||||
@ -7311,17 +7289,17 @@ export function AdminSettingsClient() {
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME;
|
||||
const trimmedProviderName =
|
||||
moonshotDetailAgentConfig.providerName.trim() ||
|
||||
"Kimi Code 编程套餐";
|
||||
"Moonshot AI (Official)";
|
||||
|
||||
if (!isMoonshotOfficialBaseUrl(trimmedBaseUrl)) {
|
||||
toast.error(
|
||||
`请使用 Kimi Code 编程套餐专用 API 地址:${DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
|
||||
"请使用 Moonshot 官方 API 地址:https://api.moonshot.cn/v1"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedModelId) {
|
||||
toast.error("请填写 Kimi Code API 模型 ID");
|
||||
toast.error("请填写官方 Kimi K2.6 的 API 模型 ID");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -7329,7 +7307,7 @@ export function AdminSettingsClient() {
|
||||
!moonshotDetailAgentConfig.providerId &&
|
||||
!moonshotDetailAgentConfig.apiKey.trim()
|
||||
) {
|
||||
toast.error("请填写 Kimi Code 控制台 API Key");
|
||||
toast.error("请填写 Moonshot 官方 API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -7381,7 +7359,7 @@ export function AdminSettingsClient() {
|
||||
|
||||
mutateMoonshotDetailAgentSetting();
|
||||
mutateSiteSettings();
|
||||
toast.success("Kimi Code 编程套餐已绑定为 AI详情 Agent 专用模型");
|
||||
toast.success("Moonshot 官方 Kimi K2.6 已绑定为 AI详情 Agent 专用模型");
|
||||
} catch (error) {
|
||||
console.error("[Moonshot Detail Agent Config] Error:", error);
|
||||
toast.error(
|
||||
@ -7686,7 +7664,7 @@ export function AdminSettingsClient() {
|
||||
if (
|
||||
!options?.skipConfirm &&
|
||||
!confirm(
|
||||
"将从 Atlas Cloud 官方目录同步图片、视频与 3D 模型,并读取每个模型的参数 Schema。已删除的模型不会被恢复。同步数量较多,可能需要几十秒。\n\n继续吗?"
|
||||
"将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@ -7705,7 +7683,7 @@ export function AdminSettingsClient() {
|
||||
const totalSynced =
|
||||
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
|
||||
toast.success(
|
||||
`已同步 ${totalSynced} 个 Atlas Cloud 图片 / 视频 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
|
||||
`已同步 ${totalSynced} 个 Atlas Cloud 图片 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
|
||||
);
|
||||
mutateModels();
|
||||
if (data.errors?.length) {
|
||||
@ -7713,7 +7691,7 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Quick Add Atlas Cloud Models] Error:", error);
|
||||
toast.error(error.message || "同步 Atlas Cloud 模型失败");
|
||||
toast.error(error.message || "同步 Atlas Cloud 图片 / 3D 模型失败");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@ -9056,11 +9034,11 @@ export function AdminSettingsClient() {
|
||||
}, [expandedLocalAiSections]);
|
||||
|
||||
return (
|
||||
<div className="settings-workspace w-full px-3 py-3 sm:px-4 xl:px-5">
|
||||
<Tabs onValueChange={handleActiveTabChange} value={activeTab}>
|
||||
<div className="w-full px-4 py-6 sm:px-6 xl:px-8 2xl:px-10">
|
||||
<Tabs onValueChange={setActiveTab} value={activeTab}>
|
||||
<AdminSettingsNavigation
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleActiveTabChange}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* 用户管理 */}
|
||||
@ -9080,20 +9058,18 @@ export function AdminSettingsClient() {
|
||||
type="text"
|
||||
value={userSearch}
|
||||
/>
|
||||
<div className="grid w-full grid-cols-2 gap-1.5 sm:flex sm:w-auto sm:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="min-w-0 px-2"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setShowBatchLimitDialog(true)}
|
||||
size="sm"
|
||||
title="批量设置限额"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="sm:hidden">批量限额</span>
|
||||
<span className="hidden sm:inline">批量设置限额</span>
|
||||
批量设置限额
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-0 px-2"
|
||||
className="flex items-center gap-2"
|
||||
onClick={async () => {
|
||||
try {
|
||||
toast.info("正在从钉钉同步用户信息,可能需要几分钟...");
|
||||
@ -9118,12 +9094,10 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
title="同步钉钉用户"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span className="sm:hidden">同步用户</span>
|
||||
<span className="hidden sm:inline">同步钉钉用户</span>
|
||||
同步钉钉用户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -9132,35 +9106,40 @@ export function AdminSettingsClient() {
|
||||
<div className="mb-4 space-y-4">
|
||||
{usersData?.users?.map((user) => (
|
||||
<div
|
||||
className="flex flex-col items-stretch gap-3 rounded-lg border p-3 xl:flex-row xl:items-center xl:justify-between"
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
key={user.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">
|
||||
{user.name || user.email}
|
||||
</div>
|
||||
<div className="mt-1 grid gap-x-4 gap-y-0.5 text-muted-foreground text-xs sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="truncate">邮箱: {user.email}</div>
|
||||
<div>部门: {user.department || "未设置"}</div>
|
||||
<div>
|
||||
角色: {ROLE_LABELS[user.role] ?? user.role} ·{" "}
|
||||
{user.isActive ? "已激活" : "未激活"}
|
||||
</div>
|
||||
<div>标签: {user.isHighVip ? "高级VIP" : "普通"}</div>
|
||||
<div>
|
||||
月度限额:{" "}
|
||||
{user.monthlyImageLimit
|
||||
? `${user.monthlyImageLimit} 张/月`
|
||||
: "无限制"}
|
||||
</div>
|
||||
<div>
|
||||
积分: {Number(user.pointsBalance || 0).toLocaleString()}
|
||||
{" · 赠送 "}
|
||||
{Number(user.pointsGiftedTotal || 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
邮箱: {user.email}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
部门: {user.department || "未设置"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
角色: {ROLE_LABELS[user.role] ?? user.role} | 状态:{" "}
|
||||
{user.isActive ? "已激活" : "未激活"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
标签: {user.isHighVip ? "高级VIP" : "普通"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
月度出图限额:{" "}
|
||||
{user.monthlyImageLimit
|
||||
? `${user.monthlyImageLimit} 张/月`
|
||||
: "无限制"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
剩余积分:{" "}
|
||||
{Number(user.pointsBalance || 0).toLocaleString()} |
|
||||
累计赠送:{" "}
|
||||
{Number(user.pointsGiftedTotal || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-1.5 xl:w-auto xl:justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 配额设置 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
@ -9307,16 +9286,15 @@ export function AdminSettingsClient() {
|
||||
|
||||
{/* 分页控件 */}
|
||||
{usersData && usersData.totalPages > 1 && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
显示 {(userPage - 1) * userLimit + 1} -{" "}
|
||||
{Math.min(userPage * userLimit, usersData.total)} 条,共{" "}
|
||||
{usersData.total} 条
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 sm:flex sm:w-auto sm:gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 首页按钮 */}
|
||||
<Button
|
||||
className="hidden sm:inline-flex"
|
||||
disabled={userPage === 1}
|
||||
onClick={() => setUserPage(1)}
|
||||
size="sm"
|
||||
@ -9328,24 +9306,19 @@ export function AdminSettingsClient() {
|
||||
|
||||
{/* 上一页按钮 */}
|
||||
<Button
|
||||
aria-label="上一页"
|
||||
className="h-8 w-8 px-0 sm:w-auto sm:px-2.5"
|
||||
disabled={userPage === 1}
|
||||
onClick={() => setUserPage((p) => Math.max(1, p - 1))}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">上一页</span>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
{/* 页码显示和跳转 */}
|
||||
<div className="flex min-w-0 items-center justify-center gap-1">
|
||||
<span className="hidden whitespace-nowrap text-xs sm:inline">
|
||||
第
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-sm">第</span>
|
||||
<Input
|
||||
className="h-8 w-10 px-1 text-center sm:w-14"
|
||||
className="h-8 w-16 text-center"
|
||||
max={usersData.totalPages}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
@ -9370,15 +9343,13 @@ export function AdminSettingsClient() {
|
||||
type="number"
|
||||
value={userPage}
|
||||
/>
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
/{usersData.totalPages}页
|
||||
<span className="whitespace-nowrap text-sm">
|
||||
/ {usersData.totalPages} 页
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 下一页按钮 */}
|
||||
<Button
|
||||
aria-label="下一页"
|
||||
className="h-8 w-8 px-0 sm:w-auto sm:px-2.5"
|
||||
disabled={userPage === usersData.totalPages}
|
||||
onClick={() =>
|
||||
setUserPage((p) =>
|
||||
@ -9388,13 +9359,11 @@ export function AdminSettingsClient() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<span className="hidden sm:inline">下一页</span>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
下一页
|
||||
</Button>
|
||||
|
||||
{/* 末页按钮 */}
|
||||
<Button
|
||||
className="hidden sm:inline-flex"
|
||||
disabled={userPage === usersData.totalPages}
|
||||
onClick={() => setUserPage(usersData.totalPages)}
|
||||
size="sm"
|
||||
@ -10391,11 +10360,6 @@ export function AdminSettingsClient() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 访问安全 */}
|
||||
<TabsContent className="mt-6" value="access-security">
|
||||
<AccessSecurityDashboard />
|
||||
</TabsContent>
|
||||
|
||||
{/* 网站设置 */}
|
||||
<TabsContent className="mt-6" value="site">
|
||||
<Card>
|
||||
@ -13862,7 +13826,7 @@ export function AdminSettingsClient() {
|
||||
用于 AI详情生成 的分镜规划与排版复审。未设置时沿用
|
||||
Agent 对话默认模型;仅影响
|
||||
AI详情生成,不影响普通无限画布对话框的 Agent
|
||||
对话。建议选择 Kimi Code 编程套餐模型。
|
||||
对话。建议选择官方 Kimi K2.6 多模态模型。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16086,8 +16050,8 @@ export function AdminSettingsClient() {
|
||||
{providerForm.type === "vip98k" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
VIP98K 使用 Bearer API Key 认证,Base URL 只填写
|
||||
https://api.vip98k.com。保存后会自动同步图片、视频、3D、
|
||||
多模态和工具模型。
|
||||
https://api.vip98k.com。保存后会自动同步目录中的图片、视频、3D 世界、
|
||||
文本和工具模型;图片模型会配置比例、1K/2K/4K 与多参考图能力。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "bfl-image" &&
|
||||
@ -16244,7 +16208,7 @@ export function AdminSettingsClient() {
|
||||
variant="secondary"
|
||||
>
|
||||
<ImageIcon className="mr-1 h-4 w-4" />
|
||||
同步 Atlas Cloud 模型
|
||||
同步 Atlas Cloud 图片 / 3D 模型
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "vip98k" && (
|
||||
@ -16776,7 +16740,10 @@ export function AdminSettingsClient() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label>vip模型</Label>
|
||||
<Label>
|
||||
前台隐藏(仅 Agent/故障迁移可用,第三方 API
|
||||
不可用)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后,该模型不会出现在前端,也不会参与第三方生图
|
||||
|
||||
@ -5933,7 +5933,7 @@ export function AgentWorkflowSettingsClient({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="settings-workspace container mx-auto p-4">
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
@ -5945,18 +5945,18 @@ export function AgentWorkflowSettingsClient({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-workspace min-h-screen bg-muted/10">
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
|
||||
<Sparkles className="h-4 w-4 text-background" />
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-violet-500/20">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">风格化工作流配置</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
|
||||
</p>
|
||||
</div>
|
||||
@ -6006,6 +6006,7 @@ export function AgentWorkflowSettingsClient({
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@ -6141,8 +6142,8 @@ export function AgentWorkflowSettingsClient({
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="border-b bg-muted/30">
|
||||
<div className="container mx-auto px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="container mx-auto px-6 py-3">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{enabledStepsCount}
|
||||
@ -6196,8 +6197,8 @@ export function AgentWorkflowSettingsClient({
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto p-4 sm:px-5">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<main className="container mx-auto p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-fit">
|
||||
<TabsTrigger value="showcase" className="gap-2">
|
||||
<MonitorPlay className="h-4 w-4" />
|
||||
|
||||
@ -2430,17 +2430,17 @@ export function PsdTemplatesSettingsClient({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="settings-workspace min-h-screen bg-muted/10">
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
|
||||
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
|
||||
<Sparkles className="h-4 w-4 text-background" />
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-rose-600 shadow-lg shadow-orange-500/20">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">PSD 模板配置</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h1 className="text-xl font-semibold tracking-tight">PSD 模板配置</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出
|
||||
</p>
|
||||
</div>
|
||||
@ -2457,6 +2457,7 @@ export function PsdTemplatesSettingsClient({
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!draftTemplate || isSaving}
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@ -2471,8 +2472,8 @@ export function PsdTemplatesSettingsClient({
|
||||
</header>
|
||||
|
||||
<div className="border-b bg-muted/30">
|
||||
<div className="container mx-auto px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="container mx-auto px-6 py-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{templates.length}
|
||||
</Badge>
|
||||
@ -2508,8 +2509,8 @@ export function PsdTemplatesSettingsClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-4 sm:px-5">
|
||||
<div className="space-y-4">
|
||||
<div className="container mx-auto px-6 py-6">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">品类标签</CardTitle>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Save, Trash2, Wand2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Plus, Save, Trash2, Wand2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { toast } from "sonner";
|
||||
@ -25,7 +25,6 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { AdminSettingsNavigation } from "../_components/admin-settings-navigation";
|
||||
import {
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
defaultStyleProConfig,
|
||||
@ -323,7 +322,6 @@ function CustomFieldEditor({
|
||||
}
|
||||
|
||||
export function StyleProSettingsClient() {
|
||||
const router = useRouter();
|
||||
const { data, mutate } = useSWR<SettingsResponse>(
|
||||
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
|
||||
fetcher
|
||||
@ -563,17 +561,19 @@ export function StyleProSettingsClient() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-workspace w-full px-3 py-3 sm:px-4 xl:px-5">
|
||||
<AdminSettingsNavigation
|
||||
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">
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-xl">风格化 PRO 设置</h1>
|
||||
<p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5">
|
||||
<div className="mb-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回设置
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">风格化 PRO 设置</h1>
|
||||
<p className="mt-2 max-w-2xl text-muted-foreground">
|
||||
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
|
||||
</p>
|
||||
</div>
|
||||
@ -592,7 +592,7 @@ export function StyleProSettingsClient() {
|
||||
{saving ? "保存中" : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -1193,7 +1193,6 @@ export function StyleProSettingsClient() {
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,6 @@ import {
|
||||
requiresSingleActiveSession,
|
||||
revokeSingleActiveSession,
|
||||
} from "@/lib/auth/session-control";
|
||||
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
|
||||
|
||||
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
|
||||
|
||||
@ -201,10 +200,7 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
|
||||
}
|
||||
|
||||
const nextAuth = NextAuth(async () => {
|
||||
const runtimeAuthSettings =
|
||||
process.env.CANVAS_E2E_LOCAL === "true"
|
||||
? null
|
||||
: await getAuthSettings().catch(() => null as any);
|
||||
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
|
||||
const resolvedAuthUrl =
|
||||
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
|
||||
const googleClientId =
|
||||
@ -287,32 +283,6 @@ const nextAuth = NextAuth(async () => {
|
||||
delete token.sessionKey;
|
||||
}
|
||||
|
||||
if (account && resolvedUserId) {
|
||||
await recordAccessIpEventFromHeaders({
|
||||
eventType: "login_success",
|
||||
userId: resolvedUserId,
|
||||
userEmail:
|
||||
typeof token.email === "string"
|
||||
? token.email
|
||||
: typeof user?.email === "string"
|
||||
? user.email
|
||||
: null,
|
||||
userRole: resolvedUserType || "regular",
|
||||
method: "POST",
|
||||
path: `/api/auth/${account.provider}`,
|
||||
routeGroup: "auth",
|
||||
statusCode: 200,
|
||||
outcome: "login_success",
|
||||
severity: "info",
|
||||
reasons: ["登录成功"],
|
||||
metadata: {
|
||||
provider: account.provider,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn("[Auth] Failed to record login access event:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
|
||||
@ -3,9 +3,9 @@ import path from "node:path";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { ZooDesignStudioFrame } from "@/components/ai-cad/zoo-design-studio-frame";
|
||||
|
||||
const ZOO_MODELING_APP_SRC =
|
||||
"/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
|
||||
const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata: Metadata = {
|
||||
@ -23,22 +23,10 @@ export default async function AiCadPage() {
|
||||
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>
|
||||
<ZooDesignStudioFrame
|
||||
isReady={isReady}
|
||||
src={ZOO_MODELING_APP_SRC}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import type {
|
||||
ResolvedTargetAssets,
|
||||
} from "@/lib/ai/context-memory/types";
|
||||
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
|
||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const chatApiLogger = createLogger("chat.stream");
|
||||
@ -212,8 +211,10 @@ export function writeImageGenerationStatusChunk(
|
||||
modelName?: string | null;
|
||||
imageCount?: number | null;
|
||||
queuePosition?: number | null;
|
||||
sameModelPendingCount?: number | null;
|
||||
sameModelRunningCount?: number | null;
|
||||
concurrencyLimit?: number | null;
|
||||
dispatchIntervalMs?: number | null;
|
||||
estimatedWaitMs?: number | null;
|
||||
},
|
||||
label: string
|
||||
@ -234,6 +235,11 @@ export function writeImageGenerationStatusChunk(
|
||||
Number(params.queuePosition) >= 0
|
||||
? Number(params.queuePosition)
|
||||
: undefined,
|
||||
sameModelPendingCount:
|
||||
Number.isFinite(Number(params.sameModelPendingCount)) &&
|
||||
Number(params.sameModelPendingCount) >= 0
|
||||
? Number(params.sameModelPendingCount)
|
||||
: undefined,
|
||||
sameModelRunningCount:
|
||||
Number.isFinite(Number(params.sameModelRunningCount)) &&
|
||||
Number(params.sameModelRunningCount) >= 0
|
||||
@ -244,6 +250,11 @@ export function writeImageGenerationStatusChunk(
|
||||
Number(params.concurrencyLimit) > 0
|
||||
? Number(params.concurrencyLimit)
|
||||
: undefined,
|
||||
dispatchIntervalMs:
|
||||
Number.isFinite(Number(params.dispatchIntervalMs)) &&
|
||||
Number(params.dispatchIntervalMs) >= 0
|
||||
? Number(params.dispatchIntervalMs)
|
||||
: undefined,
|
||||
estimatedWaitMs:
|
||||
Number.isFinite(Number(params.estimatedWaitMs)) &&
|
||||
Number(params.estimatedWaitMs) >= 0
|
||||
@ -426,19 +437,6 @@ export function writeCanvasViewOperationChunk(
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCanvasCapabilityCommandChunk(
|
||||
writer: StreamWriter,
|
||||
command: CanvasCapabilityClientCommand,
|
||||
label: string
|
||||
) {
|
||||
writeStructuredDataChunk(
|
||||
writer,
|
||||
"data-canvasCapabilityCommand",
|
||||
command,
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
type AgentTargetLockRefPreview = {
|
||||
url: string;
|
||||
title?: string;
|
||||
|
||||
@ -90,10 +90,6 @@ import {
|
||||
hasCanvasAgentPanViewIntent,
|
||||
} from "@/lib/canvas/agent-view-pan";
|
||||
import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback";
|
||||
import {
|
||||
createCanvasCapabilityTools,
|
||||
getCanvasCapabilityToolNames,
|
||||
} from "@/lib/canvas/capabilities/ai-sdk-tools";
|
||||
import {
|
||||
getCanvasAgentOperationErrorMessage,
|
||||
normalizeCanvasAgentOperationErrorCode,
|
||||
@ -142,7 +138,11 @@ import {
|
||||
generatedImage,
|
||||
message as messageTable,
|
||||
} from "@/lib/db/schema";
|
||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||
import {
|
||||
generateCanvasImage,
|
||||
type CanvasGenerateImageRequest,
|
||||
} from "@/lib/canvas/generate-image";
|
||||
import { generateCanvasImageWithPersistentQueue } from "@/lib/canvas/image-task-queue";
|
||||
import {
|
||||
detectRequestedStoryboardGrid,
|
||||
isGridConfirmationReply,
|
||||
@ -293,7 +293,6 @@ import {
|
||||
} from "@/lib/video-agent/task-store";
|
||||
import type { StoryboardModelCandidate } from "@/lib/video-agent/types";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { collectMessageImageAttachments } from "@/lib/chat/message-image-attachments";
|
||||
import type { AppUsage } from "@/lib/usage";
|
||||
import { convertToUIMessages, generateUUID } from "@/lib/utils";
|
||||
import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags";
|
||||
@ -396,7 +395,6 @@ import {
|
||||
writeAgentTargetLockChunk,
|
||||
writeAssistantTextChunk,
|
||||
writeCanvasMediaChunk,
|
||||
writeCanvasCapabilityCommandChunk,
|
||||
writeCanvasOperationChunk,
|
||||
writeCanvasViewOperationChunk,
|
||||
writeImageGenerationStatusChunk,
|
||||
@ -1312,7 +1310,7 @@ export async function POST(request: Request) {
|
||||
chatId: id,
|
||||
role: "user",
|
||||
parts: message.parts as any,
|
||||
attachments: collectMessageImageAttachments({ parts: message.parts }),
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
@ -2895,7 +2893,7 @@ export async function POST(request: Request) {
|
||||
id: message.id,
|
||||
role: "user",
|
||||
parts: message.parts as any,
|
||||
attachments: collectMessageImageAttachments({ parts: message.parts }),
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
@ -5998,44 +5996,59 @@ export async function POST(request: Request) {
|
||||
let normalizeElapsedMs: number | null = null;
|
||||
try {
|
||||
generationStartedAt = Date.now();
|
||||
const result = await generateCanvasImage({
|
||||
prompt: replacementPrompt,
|
||||
negativePrompt: effectiveImageParams?.negativePrompt,
|
||||
model: resolvedModelRef,
|
||||
ownerKey: session.user.id,
|
||||
aspectRatio: generationAspectRatio,
|
||||
imageSize: resolvedImageSize,
|
||||
imageCount: 1,
|
||||
referenceImages: sourceImageUrls,
|
||||
debugLabel: "agent-detail-replacement",
|
||||
debugMeta: {
|
||||
referenceLabel: reference.label,
|
||||
referenceWidth: referenceGeometry?.width,
|
||||
referenceHeight: referenceGeometry?.height,
|
||||
referenceAspectRatio: referenceGeometry?.aspectRatio,
|
||||
const result = await generateCanvasImageWithPersistentQueue({
|
||||
userId: session.user.id,
|
||||
request: {
|
||||
prompt: replacementPrompt,
|
||||
negativePrompt: effectiveImageParams?.negativePrompt,
|
||||
model: resolvedModelRef,
|
||||
ownerKey: session.user.id,
|
||||
aspectRatio: generationAspectRatio,
|
||||
imageSize: resolvedImageSize,
|
||||
imageCount: 1,
|
||||
referenceImages: sourceImageUrls,
|
||||
debugLabel: "agent-detail-replacement",
|
||||
debugMeta: {
|
||||
operation: "agent-detail-replacement",
|
||||
chatId: id,
|
||||
messageId: message.id,
|
||||
projectId: contextPayload?.projectId || null,
|
||||
referenceId: reference.id,
|
||||
referenceLabel: reference.label,
|
||||
referenceWidth: referenceGeometry?.width,
|
||||
referenceHeight: referenceGeometry?.height,
|
||||
referenceAspectRatio: referenceGeometry?.aspectRatio,
|
||||
},
|
||||
onQueueStatusChange:
|
||||
contextPayload?.source === "canvas"
|
||||
? async (queueStatus) => {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
: "queued",
|
||||
modelName:
|
||||
queueStatus.modelName ||
|
||||
resolvedModelDisplayName ||
|
||||
resolvedModelRef,
|
||||
imageCount: 1,
|
||||
queuePosition: queueStatus.queuePosition,
|
||||
sameModelPendingCount:
|
||||
queueStatus.sameModelPendingCount,
|
||||
sameModelRunningCount:
|
||||
queueStatus.sameModelRunningCount,
|
||||
concurrencyLimit: queueStatus.concurrencyLimit,
|
||||
dispatchIntervalMs:
|
||||
queueStatus.dispatchIntervalMs,
|
||||
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
||||
},
|
||||
`agent-mode:detail-replacement:queue:${reference.id}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
onQueueStatusChange:
|
||||
contextPayload?.source === "canvas"
|
||||
? async (queueStatus) => {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
: "queued",
|
||||
modelName:
|
||||
queueStatus.modelName || resolvedModelDisplayName || resolvedModelRef,
|
||||
imageCount: 1,
|
||||
queuePosition: queueStatus.queuePosition,
|
||||
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
||||
concurrencyLimit: queueStatus.concurrencyLimit,
|
||||
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
||||
},
|
||||
`agent-mode:detail-replacement:queue:${reference.id}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
generationElapsedMs = Date.now() - generationStartedAt;
|
||||
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
||||
@ -6885,7 +6898,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await generateCanvasImage({
|
||||
const generationRequest: CanvasGenerateImageRequest = {
|
||||
prompt: replacementPrompt,
|
||||
negativePrompt:
|
||||
typeof mergedImageParams.negativePrompt === "string"
|
||||
@ -6899,6 +6912,11 @@ export async function POST(request: Request) {
|
||||
referenceImages: sourceImageUrls,
|
||||
debugLabel: `agent-${replacementSourceType}`,
|
||||
debugMeta: {
|
||||
operation: `agent-${replacementSourceType}`,
|
||||
chatId: id,
|
||||
messageId: message.id,
|
||||
projectId: contextPayload?.projectId || null,
|
||||
referenceId: reference.id,
|
||||
referenceLabel: reference.label,
|
||||
referenceWidth: referenceGeometry?.width,
|
||||
referenceHeight: referenceGeometry?.height,
|
||||
@ -6920,15 +6938,25 @@ export async function POST(request: Request) {
|
||||
resolvedModelRef,
|
||||
imageCount: 1,
|
||||
queuePosition: queueStatus.queuePosition,
|
||||
sameModelPendingCount:
|
||||
queueStatus.sameModelPendingCount,
|
||||
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
||||
concurrencyLimit: queueStatus.concurrencyLimit,
|
||||
dispatchIntervalMs:
|
||||
queueStatus.dispatchIntervalMs,
|
||||
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
||||
},
|
||||
`agent-preset:replacement:queue:${reference.id}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
: undefined,
|
||||
};
|
||||
const result = isDetailReplacementPreset
|
||||
? await generateCanvasImageWithPersistentQueue({
|
||||
userId: session.user.id,
|
||||
request: generationRequest,
|
||||
})
|
||||
: await generateCanvasImage(generationRequest);
|
||||
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
||||
const firstImage = result.images[0];
|
||||
if (!firstImage) {
|
||||
@ -7239,8 +7267,12 @@ export async function POST(request: Request) {
|
||||
queueStatus.modelName || selectedAgentPreset.name,
|
||||
imageCount: resolvedImageCount,
|
||||
queuePosition: queueStatus.queuePosition,
|
||||
sameModelPendingCount:
|
||||
queueStatus.sameModelPendingCount,
|
||||
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
||||
concurrencyLimit: queueStatus.concurrencyLimit,
|
||||
dispatchIntervalMs:
|
||||
queueStatus.dispatchIntervalMs,
|
||||
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
||||
},
|
||||
"agent-preset:image:scene-generation:queue",
|
||||
@ -7466,8 +7498,12 @@ export async function POST(request: Request) {
|
||||
modelName: queueStatus.modelName || slotLabel,
|
||||
imageCount: 1,
|
||||
queuePosition: queueStatus.queuePosition,
|
||||
sameModelPendingCount:
|
||||
queueStatus.sameModelPendingCount,
|
||||
sameModelRunningCount: queueStatus.sameModelRunningCount,
|
||||
concurrencyLimit: queueStatus.concurrencyLimit,
|
||||
dispatchIntervalMs:
|
||||
queueStatus.dispatchIntervalMs,
|
||||
estimatedWaitMs: queueStatus.estimatedWaitMs,
|
||||
},
|
||||
`agent-preset:image:slot-queue:${slot.slotIndex}`,
|
||||
@ -7811,17 +7847,6 @@ export async function POST(request: Request) {
|
||||
const directAgentExecution = directAgentToolContext?.execution || null;
|
||||
const directAgentToolTargetImageUrls = directAgentToolContext?.targetImageUrls || [];
|
||||
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 (
|
||||
shouldRunAgentConversation &&
|
||||
@ -8371,7 +8396,6 @@ export async function POST(request: Request) {
|
||||
shouldRunAgentConversation &&
|
||||
!smartAutoNeedsClarification &&
|
||||
directAgentExecution &&
|
||||
!shouldUseCanvasCapabilityToolLoop &&
|
||||
isDirectAgentImageEditToolName(directAgentExecution.toolName)
|
||||
) {
|
||||
const directAgentToolCapability = directAgentExecution.capability;
|
||||
@ -8818,14 +8842,6 @@ export async function POST(request: Request) {
|
||||
agentIdentityInstruction,
|
||||
recentDialogueSystemContext,
|
||||
baseConversationSystemPrompt,
|
||||
shouldUseCanvasCapabilityToolLoop
|
||||
? [
|
||||
"当前请求来自无限画布,且用户已明确选中图片节点。",
|
||||
"涉及选中图片的识别、编辑、生成、3D、改名、批注或删除时,必须调用已提供的画布能力工具,不得只用文字声称完成。",
|
||||
"可以连续调用多个工具;下一步要处理上一步结果时,把上一步返回的 createdNodeIds 作为 nodeIds。",
|
||||
"只有工具返回成功后才能告诉用户已完成;若工具返回 interactive,说明已在画布打开对应交互界面并等待用户操作。",
|
||||
].join("\n")
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
@ -11094,52 +11110,6 @@ export async function POST(request: Request) {
|
||||
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] Provider type:", (languageModel as any).provider); // 调试日志
|
||||
|
||||
@ -12139,13 +12109,9 @@ export async function POST(request: Request) {
|
||||
model: languageModel,
|
||||
system: effectiveConversationSystemPrompt,
|
||||
messages: convertedMessages,
|
||||
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),
|
||||
stopWhen: stepCountIs(5),
|
||||
experimental_activeTools:
|
||||
(finalModelId === "chat-model-reasoning" &&
|
||||
!shouldUseCanvasCapabilityToolLoop) ||
|
||||
isVolcanoVideoModel
|
||||
? []
|
||||
: activeTools,
|
||||
finalModelId === "chat-model-reasoning" || isVolcanoVideoModel ? [] : activeTools,
|
||||
experimental_transform: shouldDisableSmoothStream
|
||||
? undefined
|
||||
: smoothStream({ chunking: "word" }),
|
||||
|
||||
@ -319,6 +319,7 @@ export async function POST(request: Request) {
|
||||
contentType,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
|
||||
@ -3,10 +3,23 @@ import { z } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
|
||||
import { upsertMediaAsset } from "@/lib/db/queries";
|
||||
import { getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
|
||||
import { isSiteAdminType } from "@/lib/middleware/admin";
|
||||
import { getContentType, uploadFile } from "@/lib/storage/service";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import {
|
||||
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||
} from "@/lib/video-canvas/subjects";
|
||||
import {
|
||||
VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE,
|
||||
VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE,
|
||||
} from "@/lib/video-canvas/uploaded-resource";
|
||||
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
|
||||
|
||||
const logger = createLogger("upload-api");
|
||||
@ -15,19 +28,21 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/tiff",
|
||||
"image/x-tiff",
|
||||
"image/svg+xml",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
]);
|
||||
const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
const ALLOWED_UPLOAD_AUDIO_TYPES = new Set([
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/mp4",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/webm",
|
||||
"audio/ogg",
|
||||
"audio/aac",
|
||||
]);
|
||||
const ALLOWED_OPENCLAW_VIDEO_TYPES = new Set([
|
||||
const ALLOWED_UPLOAD_VIDEO_TYPES = new Set([
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
@ -36,14 +51,10 @@ const ALLOWED_OPENCLAW_VIDEO_TYPES = new Set([
|
||||
]);
|
||||
|
||||
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
||||
const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
|
||||
"image/avif",
|
||||
"image/tiff",
|
||||
"image/x-tiff",
|
||||
]);
|
||||
const GAUSSIAN_SPLAT_EXTENSION_RE = /\.(ply|splat|spz|ksplat)$/i;
|
||||
|
||||
function normalizeUploadContentType(contentType: string, filename: string) {
|
||||
const normalized = contentType.trim().toLowerCase();
|
||||
const normalized = contentType.split(";", 1)[0].trim().toLowerCase();
|
||||
if (normalized && normalized !== "application/octet-stream") {
|
||||
return normalized;
|
||||
}
|
||||
@ -51,21 +62,22 @@ function normalizeUploadContentType(contentType: string, filename: string) {
|
||||
return getContentType(filename);
|
||||
}
|
||||
|
||||
function getAcceptedMimeTypes(accept: string) {
|
||||
return new Set(
|
||||
accept
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item) => item.includes("/") && !item.endsWith("/*")),
|
||||
);
|
||||
}
|
||||
|
||||
function createUnsupportedImageTypeMessage(contentType: string, filename: string) {
|
||||
const extension = filename.toLowerCase().split(".").pop() || "unknown";
|
||||
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
|
||||
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) {
|
||||
@ -77,20 +89,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) {
|
||||
const trimmed = filename.trim() || `upload-${Date.now()}`;
|
||||
if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
|
||||
if (/\.(heic|heif)$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
|
||||
}
|
||||
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
|
||||
@ -110,21 +112,6 @@ async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
|
||||
}
|
||||
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智能优化图片(保持原格式)
|
||||
* 策略:避免双重压缩,只在需要时优化
|
||||
@ -208,23 +195,16 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
}
|
||||
|
||||
// Use Blob instead of File since File is not available in Node.js environment
|
||||
const FileSchema = z.object({
|
||||
file: z
|
||||
.instanceof(Blob)
|
||||
.refine((file) => file.size <= 50 * 1024 * 1024, {
|
||||
message: "File size should be less than 50MB",
|
||||
function createFileSchema(maxBytes: number, maxSizeLabel: string) {
|
||||
return z.object({
|
||||
file: z.instanceof(Blob).refine((value) => value.size <= maxBytes, {
|
||||
message: `File size should be less than ${maxSizeLabel}`,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
logger.debug("[Upload API] Received upload request");
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
logger.debug("[Upload API] Unauthorized - no session");
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (request.body === null) {
|
||||
logger.debug("[Upload API] Request body is empty");
|
||||
@ -251,18 +231,88 @@ export async function POST(request: Request) {
|
||||
let filename = originalFilename;
|
||||
let contentType = originalContentType;
|
||||
const uploadPurpose = String(formData.get("purpose") || "");
|
||||
const isAtlasCloudReferenceVideo =
|
||||
uploadPurpose === "canvas-video-process" &&
|
||||
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
|
||||
const isDirectorGaussianUpload = uploadPurpose === "director-gaussian";
|
||||
const isCanvasAudioUpload = uploadPurpose === "canvas-audio-process";
|
||||
const isCanvasVideoUpload = uploadPurpose === "canvas-video-process";
|
||||
const isOpenClawChatVideoUpload =
|
||||
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
||||
ALLOWED_OPENCLAW_VIDEO_TYPES.has(contentType);
|
||||
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE;
|
||||
const isVideoCanvasAssetLibraryUpload =
|
||||
uploadPurpose === "video-canvas-asset-library";
|
||||
const isVideoCanvasResourceUpload =
|
||||
uploadPurpose === VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE;
|
||||
const isVideoCanvasSubjectUpload =
|
||||
uploadPurpose === "video-canvas-subject";
|
||||
const isVideoCanvasSubjectVoiceUpload =
|
||||
uploadPurpose === "video-canvas-subject-voice";
|
||||
const videoCanvasSetting =
|
||||
isDirectorGaussianUpload ||
|
||||
isVideoCanvasResourceUpload ||
|
||||
isVideoCanvasSubjectUpload
|
||||
? await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
|
||||
() => undefined,
|
||||
)
|
||||
: undefined;
|
||||
const videoCanvasConfig = videoCanvasSetting
|
||||
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const gaussianConfig = videoCanvasConfig.directorDesk.gaussian;
|
||||
const subjectLibraryConfig = videoCanvasConfig.subjectLibrary;
|
||||
if (
|
||||
isDirectorGaussianUpload &&
|
||||
!GAUSSIAN_SPLAT_EXTENSION_RE.test(originalFilename)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: `高斯泼溅仅支持 ${gaussianConfig.uploadAccept}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (isVideoCanvasSubjectUpload) {
|
||||
const acceptedTypes = new Set([
|
||||
...getAcceptedMimeTypes(subjectLibraryConfig.imageAccept),
|
||||
...getAcceptedMimeTypes(subjectLibraryConfig.videoAccept),
|
||||
]);
|
||||
if (!acceptedTypes.has(originalContentType)) {
|
||||
return NextResponse.json(
|
||||
{ error: "主体素材仅支持 JPG、PNG、WebP、MP4 或 MOV" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
isVideoCanvasSubjectVoiceUpload &&
|
||||
!ALLOWED_UPLOAD_AUDIO_TYPES.has(originalContentType) &&
|
||||
!ALLOWED_UPLOAD_VIDEO_TYPES.has(originalContentType)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "音色素材仅支持 MP3、WAV、MP4 或 MOV" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const requestHostname = new URL(request.url).hostname.toLowerCase();
|
||||
const isLoopbackRequest =
|
||||
requestHostname === "localhost" ||
|
||||
requestHostname === "127.0.0.1" ||
|
||||
requestHostname === "::1";
|
||||
const isLocalDevelopmentCanvasUpload =
|
||||
(process.env.NODE_ENV !== "production" || isLoopbackRequest) &&
|
||||
process.env.USE_REMOTE_STORAGE_IN_DEV !== "true" &&
|
||||
(uploadPurpose === "canvas-upload" ||
|
||||
isCanvasAudioUpload ||
|
||||
isCanvasVideoUpload ||
|
||||
isOpenClawChatVideoUpload ||
|
||||
isDirectorGaussianUpload);
|
||||
const session = isLocalDevelopmentCanvasUpload ? null : await auth();
|
||||
|
||||
if (!isLocalDevelopmentCanvasUpload && !session) {
|
||||
logger.debug("[Upload API] Unauthorized - no session");
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const clientUploadId =
|
||||
String(formData.get("clientUploadId") || "").trim() || null;
|
||||
const isSvgUpload = contentType === "image/svg+xml";
|
||||
if (
|
||||
isSvgUpload &&
|
||||
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
|
||||
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session?.user.type))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "SVG uploads are only allowed for administrator icon settings" },
|
||||
@ -276,7 +326,19 @@ export async function POST(request: Request) {
|
||||
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB"
|
||||
});
|
||||
|
||||
const validatedFile = FileSchema.safeParse({ file });
|
||||
const maxFileSizeMb = isDirectorGaussianUpload
|
||||
? gaussianConfig.maxFileSizeMb
|
||||
: isVideoCanvasResourceUpload
|
||||
? videoCanvasConfig.nodeCreation.upload.maxFileSizeMb
|
||||
: isVideoCanvasSubjectUpload
|
||||
? originalContentType.startsWith("video/")
|
||||
? subjectLibraryConfig.videoMaxFileSizeMb
|
||||
: subjectLibraryConfig.imageMaxFileSizeMb
|
||||
: 50;
|
||||
const validatedFile = createFileSchema(
|
||||
maxFileSizeMb * 1024 * 1024,
|
||||
`${maxFileSizeMb}MB`,
|
||||
).safeParse({ file });
|
||||
|
||||
if (!validatedFile.success) {
|
||||
const errorMessage = validatedFile.error.errors
|
||||
@ -288,9 +350,34 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (
|
||||
!isDirectorGaussianUpload &&
|
||||
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
|
||||
!isAtlasCloudReferenceVideo &&
|
||||
!isOpenClawChatVideoUpload
|
||||
!(isCanvasAudioUpload && ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType)) &&
|
||||
!(isCanvasVideoUpload && ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)) &&
|
||||
!(
|
||||
isOpenClawChatVideoUpload &&
|
||||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)
|
||||
) &&
|
||||
!(
|
||||
isVideoCanvasAssetLibraryUpload &&
|
||||
(ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
|
||||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
|
||||
) &&
|
||||
!(
|
||||
isVideoCanvasResourceUpload &&
|
||||
(ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) ||
|
||||
ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
|
||||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
|
||||
) &&
|
||||
!(
|
||||
isVideoCanvasSubjectUpload &&
|
||||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)
|
||||
) &&
|
||||
!(
|
||||
isVideoCanvasSubjectVoiceUpload &&
|
||||
(ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
|
||||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
|
||||
)
|
||||
) {
|
||||
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
||||
logger.warn("[Upload API] Unsupported upload content type", {
|
||||
@ -312,7 +399,7 @@ export async function POST(request: Request) {
|
||||
let uploadBuffer: Buffer = Buffer.from(fileBuffer);
|
||||
let convertedFrom: string | null = null;
|
||||
|
||||
if (isHeicUpload(contentType, filename)) {
|
||||
if (!isDirectorGaussianUpload && isHeicUpload(contentType, filename)) {
|
||||
try {
|
||||
const convertedBuffer = await convertHeicToJpeg(uploadBuffer);
|
||||
logger.info("[Upload API] HEIC image converted to JPEG", {
|
||||
@ -341,38 +428,6 @@ export async function POST(request: Request) {
|
||||
{ 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") {
|
||||
@ -385,7 +440,7 @@ export async function POST(request: Request) {
|
||||
let assetId: string | null = null;
|
||||
try {
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
userId: session?.user.id || "",
|
||||
mediaType: "image",
|
||||
sourceType: "agent_workflow_preview_upload",
|
||||
status: "ready",
|
||||
@ -431,7 +486,10 @@ export async function POST(request: Request) {
|
||||
// ✅ 优化原图(保持JPG/PNG格式)
|
||||
// WebP格式不优化(用于预览图,保持原样)
|
||||
const shouldOptimizeImage =
|
||||
!isCanvasAudioUpload &&
|
||||
!isCanvasVideoUpload &&
|
||||
uploadPurpose !== "canvas-upload" &&
|
||||
!isDirectorGaussianUpload &&
|
||||
(contentType === 'image/jpeg' || contentType === 'image/jpg' || contentType === 'image/png');
|
||||
|
||||
if (shouldOptimizeImage) {
|
||||
@ -439,7 +497,13 @@ export async function POST(request: Request) {
|
||||
} else if (contentType === 'image/webp' || contentType === 'image/svg+xml') {
|
||||
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
|
||||
logger.debug(`[Upload] ${contentType}格式,跳过优化,直接上传`);
|
||||
} else if (uploadPurpose === "canvas-upload") {
|
||||
} else if (
|
||||
uploadPurpose === "canvas-upload" ||
|
||||
isCanvasAudioUpload ||
|
||||
isCanvasVideoUpload ||
|
||||
isOpenClawChatVideoUpload ||
|
||||
isDirectorGaussianUpload
|
||||
) {
|
||||
logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop");
|
||||
}
|
||||
|
||||
@ -454,47 +518,83 @@ export async function POST(request: Request) {
|
||||
const data = await uploadFile(filename, optimizedArrayBuffer);
|
||||
|
||||
let assetId: string | null = null;
|
||||
try {
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? "video"
|
||||
: "image",
|
||||
sourceType: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: "upload",
|
||||
status: "ready",
|
||||
originalUrl: data.url,
|
||||
previewUrl:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? null
|
||||
: data.url,
|
||||
thumbnailUrl:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? null
|
||||
: data.url,
|
||||
mimeType: data.contentType || contentType || null,
|
||||
displayName: filename,
|
||||
sourceTable: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: "upload",
|
||||
metadata: {
|
||||
pathname: data.pathname,
|
||||
size: optimizedBuffer.length,
|
||||
purpose: uploadPurpose || null,
|
||||
clientUploadId,
|
||||
convertedFrom,
|
||||
},
|
||||
});
|
||||
assetId = asset?.id ?? null;
|
||||
} catch (assetError) {
|
||||
logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
|
||||
const isLocalDevelopmentUpload = data.pathname.startsWith("uploads/local/");
|
||||
if (
|
||||
!isLocalDevelopmentUpload ||
|
||||
isVideoCanvasResourceUpload ||
|
||||
isVideoCanvasAssetLibraryUpload ||
|
||||
isVideoCanvasSubjectUpload ||
|
||||
isVideoCanvasSubjectVoiceUpload
|
||||
) {
|
||||
try {
|
||||
const uploadedMediaType = isDirectorGaussianUpload
|
||||
? "model"
|
||||
: isCanvasAudioUpload || contentType.startsWith("audio/")
|
||||
? "audio"
|
||||
: isCanvasVideoUpload || contentType.startsWith("video/")
|
||||
? "video"
|
||||
: "image";
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session?.user.id || "",
|
||||
mediaType: uploadedMediaType,
|
||||
sourceType: isDirectorGaussianUpload
|
||||
? "director_gaussian_upload"
|
||||
: isCanvasAudioUpload
|
||||
? "canvas_audio_process"
|
||||
: isCanvasVideoUpload
|
||||
? "canvas_video_process"
|
||||
: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: isVideoCanvasResourceUpload
|
||||
? VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE
|
||||
: isVideoCanvasAssetLibraryUpload
|
||||
? "video_canvas_asset_library_upload"
|
||||
: isVideoCanvasSubjectUpload
|
||||
? VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE
|
||||
: isVideoCanvasSubjectVoiceUpload
|
||||
? VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE
|
||||
: "upload",
|
||||
status: "ready",
|
||||
originalUrl: data.url,
|
||||
previewUrl: isDirectorGaussianUpload ? null : data.url,
|
||||
thumbnailUrl: isDirectorGaussianUpload ? null : data.url,
|
||||
mimeType: contentType || data.contentType || null,
|
||||
displayName: filename,
|
||||
sourceTable: isDirectorGaussianUpload
|
||||
? "director_gaussian_upload"
|
||||
: isCanvasAudioUpload
|
||||
? "canvas_audio_process"
|
||||
: isCanvasVideoUpload
|
||||
? "canvas_video_process"
|
||||
: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: isVideoCanvasResourceUpload
|
||||
? VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE
|
||||
: isVideoCanvasSubjectUpload
|
||||
? VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE
|
||||
: isVideoCanvasSubjectVoiceUpload
|
||||
? VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE
|
||||
: "upload",
|
||||
metadata: {
|
||||
pathname: data.pathname,
|
||||
size: optimizedBuffer.length,
|
||||
clientUploadId,
|
||||
convertedFrom,
|
||||
purpose: uploadPurpose || null,
|
||||
category:
|
||||
String(formData.get("assetCategory") || "").trim() || null,
|
||||
},
|
||||
});
|
||||
assetId = asset?.id ?? null;
|
||||
} catch (assetError) {
|
||||
logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("[Upload API] Upload successful:", data.url);
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
contentType,
|
||||
assetId,
|
||||
clientUploadId,
|
||||
convertedFrom,
|
||||
|
||||
@ -72,73 +72,6 @@ function isWorkflowImageGenerationModel(model: {
|
||||
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
|
||||
if (isLocalCanvasE2E) {
|
||||
const models = [
|
||||
{
|
||||
id: "local-e2e-image-model",
|
||||
modelId: "nano-banana-pro-local",
|
||||
displayName: "Nano Banana Pro",
|
||||
avatarUrl: null,
|
||||
description: "本地视频画布测试图片模型",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
isMultimodal: true,
|
||||
isDefault: true,
|
||||
providerName: "Local E2E",
|
||||
providerType: "local",
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
capabilities: { image: true },
|
||||
videoToolCapability: null,
|
||||
imageGenConfig: {
|
||||
creditCostPerImage: 1,
|
||||
creditCostPerVideo: 20,
|
||||
videoDurationOptions: [5, 10],
|
||||
},
|
||||
sortOrder: 0,
|
||||
selectable: true,
|
||||
},
|
||||
{
|
||||
id: "local-e2e-video-model",
|
||||
modelId: "kling-3-local",
|
||||
displayName: "可灵 3.0",
|
||||
avatarUrl: null,
|
||||
description: "本地视频画布测试视频模型",
|
||||
supportsVision: true,
|
||||
supportsImageGen: false,
|
||||
isMultimodal: true,
|
||||
isDefault: false,
|
||||
providerName: "Local E2E",
|
||||
providerType: "local",
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
capabilities: { video: true },
|
||||
videoToolCapability: {
|
||||
supported: true,
|
||||
durations: [5, 10],
|
||||
resolutions: ["720p", "1080p"],
|
||||
},
|
||||
imageGenConfig: {
|
||||
creditCostPerVideo: 20,
|
||||
videoDurationOptions: [5, 10],
|
||||
},
|
||||
sortOrder: 1,
|
||||
selectable: true,
|
||||
},
|
||||
];
|
||||
|
||||
return Response.json({
|
||||
models,
|
||||
defaultModelId: models[0].id,
|
||||
agentWorkflowConfiguredDefaultModelId: null,
|
||||
agentWorkflowDefaultModelId: null,
|
||||
agentWorkflowConfiguredModels: [],
|
||||
agentModel: null,
|
||||
});
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
|
||||
@ -1,207 +0,0 @@
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { accessIpBlocklist, user } from "@/lib/db/schema";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
|
||||
import { normalizeManualBlockIp } from "@/lib/security/manual-ip-blocklist";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 10;
|
||||
|
||||
const createSchema = z.object({
|
||||
ip: z.string().trim().min(1).max(128),
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
expiresAt: z.string().trim().optional().nullable(),
|
||||
});
|
||||
|
||||
const patchSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
isActive: z.boolean(),
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
expiresAt: z.string().trim().optional().nullable(),
|
||||
});
|
||||
|
||||
function normalizeOptionalDate(value?: string | null) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return null;
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const entries = await db
|
||||
.select({
|
||||
id: accessIpBlocklist.id,
|
||||
ip: accessIpBlocklist.ip,
|
||||
reason: accessIpBlocklist.reason,
|
||||
isActive: accessIpBlocklist.isActive,
|
||||
expiresAt: accessIpBlocklist.expiresAt,
|
||||
createdAt: accessIpBlocklist.createdAt,
|
||||
updatedAt: accessIpBlocklist.updatedAt,
|
||||
createdBy: accessIpBlocklist.createdBy,
|
||||
createdByEmail: user.email,
|
||||
})
|
||||
.from(accessIpBlocklist)
|
||||
.leftJoin(user, eq(accessIpBlocklist.createdBy, user.id))
|
||||
.orderBy(desc(accessIpBlocklist.isActive), desc(accessIpBlocklist.updatedAt))
|
||||
.limit(300);
|
||||
|
||||
return NextResponse.json({ entries });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
console.error("[AccessIpBlocklist] failed to list entries", error);
|
||||
return new ChatSDKError("offline:admin").toResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
const body = createSchema.parse(await request.json());
|
||||
const normalizedIp = normalizeManualBlockIp(body.ip);
|
||||
|
||||
if (!normalizedIp) {
|
||||
return new ChatSDKError(
|
||||
"bad_request:api",
|
||||
"请输入有效的 IP 地址或 IPv4 CIDR,例如 1.2.3.4 或 1.2.3.0/24"
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = normalizeOptionalDate(body.expiresAt);
|
||||
const reason = body.reason?.trim() || null;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: accessIpBlocklist.id })
|
||||
.from(accessIpBlocklist)
|
||||
.where(sql`lower(${accessIpBlocklist.ip}) = lower(${normalizedIp})`)
|
||||
.limit(1);
|
||||
|
||||
const [entry] = existing
|
||||
? await db
|
||||
.update(accessIpBlocklist)
|
||||
.set({
|
||||
reason,
|
||||
isActive: true,
|
||||
expiresAt,
|
||||
updatedBy: session.user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(accessIpBlocklist.id, existing.id))
|
||||
.returning()
|
||||
: await db
|
||||
.insert(accessIpBlocklist)
|
||||
.values({
|
||||
ip: normalizedIp,
|
||||
reason,
|
||||
isActive: true,
|
||||
expiresAt,
|
||||
createdBy: session.user.id,
|
||||
updatedBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await recordAccessIpEventFromHeaders({
|
||||
eventType: "manual_block_added",
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email || null,
|
||||
userRole: session.user.type,
|
||||
method: "POST",
|
||||
path: "/api/admin/access-blocklist",
|
||||
routeGroup: "admin-api",
|
||||
statusCode: 200,
|
||||
outcome: "manual_block_added",
|
||||
severity: "high",
|
||||
reasons: ["管理员手动拉黑 IP"],
|
||||
metadata: {
|
||||
targetIp: normalizedIp,
|
||||
reason,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn("[AccessIpBlocklist] failed to record block action", error);
|
||||
});
|
||||
|
||||
return NextResponse.json({ entry });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||
}
|
||||
console.error("[AccessIpBlocklist] failed to create entry", error);
|
||||
return new ChatSDKError("offline:admin").toResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
const body = patchSchema.parse(await request.json());
|
||||
const now = new Date();
|
||||
const expiresAt = normalizeOptionalDate(body.expiresAt);
|
||||
|
||||
const [existing] = await db
|
||||
.select({ ip: accessIpBlocklist.ip })
|
||||
.from(accessIpBlocklist)
|
||||
.where(eq(accessIpBlocklist.id, body.id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return new ChatSDKError("bad_request:api", "IP 黑名单记录不存在").toResponse();
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.update(accessIpBlocklist)
|
||||
.set({
|
||||
isActive: body.isActive,
|
||||
reason: body.reason?.trim() || undefined,
|
||||
expiresAt,
|
||||
updatedBy: session.user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(accessIpBlocklist.id, body.id))
|
||||
.returning();
|
||||
|
||||
await recordAccessIpEventFromHeaders({
|
||||
eventType: body.isActive ? "manual_block_enabled" : "manual_block_released",
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email || null,
|
||||
userRole: session.user.type,
|
||||
method: "PATCH",
|
||||
path: "/api/admin/access-blocklist",
|
||||
routeGroup: "admin-api",
|
||||
statusCode: 200,
|
||||
outcome: body.isActive ? "manual_block_enabled" : "manual_block_released",
|
||||
severity: body.isActive ? "high" : "watch",
|
||||
reasons: [body.isActive ? "管理员启用 IP 拉黑" : "管理员解除 IP 拉黑"],
|
||||
metadata: {
|
||||
targetIp: existing.ip,
|
||||
blocklistId: body.id,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn("[AccessIpBlocklist] failed to record update action", error);
|
||||
});
|
||||
|
||||
return NextResponse.json({ entry });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||
}
|
||||
console.error("[AccessIpBlocklist] failed to update entry", error);
|
||||
return new ChatSDKError("offline:admin").toResponse();
|
||||
}
|
||||
}
|
||||
@ -1,271 +0,0 @@
|
||||
import { and, desc, gte, sql, type SQL } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { accessIpEvent } from "@/lib/db/schema";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 10;
|
||||
|
||||
type Severity = "info" | "watch" | "high" | "critical";
|
||||
|
||||
const severityByRank: Record<number, Severity> = {
|
||||
0: "info",
|
||||
1: "watch",
|
||||
2: "high",
|
||||
3: "critical",
|
||||
};
|
||||
|
||||
function clampInt(value: string | null, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(Math.max(parsed, min), max);
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string | null) {
|
||||
return String(value || "").trim().slice(0, 120);
|
||||
}
|
||||
|
||||
function buildSeverityFromAggregate(params: {
|
||||
eventSeverityRank: number;
|
||||
total: number;
|
||||
failureCount: number;
|
||||
rateLimitedCount: number;
|
||||
authFailureCount: number;
|
||||
suspiciousCount: number;
|
||||
}) {
|
||||
let severity = severityByRank[params.eventSeverityRank] || "info";
|
||||
const failureRate =
|
||||
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
|
||||
|
||||
if (params.rateLimitedCount > 0 || params.suspiciousCount > 0) {
|
||||
severity = "high";
|
||||
} else if (params.authFailureCount >= 5 || params.total >= 250) {
|
||||
severity = severity === "critical" ? severity : "high";
|
||||
} else if (
|
||||
params.failureCount >= 20 ||
|
||||
(params.total >= 10 && failureRate >= 0.45)
|
||||
) {
|
||||
severity = severity === "critical" ? severity : "high";
|
||||
} else if (params.authFailureCount > 0 || params.failureCount > 0) {
|
||||
severity = severity === "info" ? "watch" : severity;
|
||||
}
|
||||
|
||||
return severity;
|
||||
}
|
||||
|
||||
function buildAggregateReasons(params: {
|
||||
total: number;
|
||||
failureCount: number;
|
||||
rateLimitedCount: number;
|
||||
authFailureCount: number;
|
||||
suspiciousCount: number;
|
||||
loginSuccessCount: number;
|
||||
}) {
|
||||
const reasons: string[] = [];
|
||||
const failureRate =
|
||||
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
|
||||
|
||||
if (params.rateLimitedCount > 0) reasons.push("触发限流");
|
||||
if (params.authFailureCount >= 5) reasons.push("认证失败偏多");
|
||||
if (params.suspiciousCount > 0) reasons.push("命中异常规则");
|
||||
if (params.total >= 250) reasons.push("请求量偏高");
|
||||
if (params.failureCount >= 20 || (params.total >= 10 && failureRate >= 0.45)) {
|
||||
reasons.push("失败率偏高");
|
||||
}
|
||||
if (params.loginSuccessCount > 0 && reasons.length === 0) {
|
||||
reasons.push("有成功登录记录");
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const hours = clampInt(searchParams.get("hours"), 24, 1, 168);
|
||||
const limit = clampInt(searchParams.get("limit"), 120, 25, 500);
|
||||
const severityFilter = searchParams.get("severity") || "all";
|
||||
const search = normalizeSearch(searchParams.get("q"));
|
||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
const whereParts: SQL[] = [gte(accessIpEvent.createdAt, since)];
|
||||
|
||||
if (severityFilter === "abnormal") {
|
||||
whereParts.push(sql`${accessIpEvent.severity} <> 'info'`);
|
||||
} else if (["info", "watch", "high", "critical"].includes(severityFilter)) {
|
||||
whereParts.push(sql`${accessIpEvent.severity} = ${severityFilter}`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const pattern = `%${search}%`;
|
||||
whereParts.push(sql`(
|
||||
${accessIpEvent.ip} ILIKE ${pattern}
|
||||
OR ${accessIpEvent.path} ILIKE ${pattern}
|
||||
OR ${accessIpEvent.userEmail} ILIKE ${pattern}
|
||||
OR ${accessIpEvent.userAgent} ILIKE ${pattern}
|
||||
)`);
|
||||
}
|
||||
|
||||
const whereClause = and(...whereParts);
|
||||
|
||||
const [overview] = await db
|
||||
.select({
|
||||
totalEvents: sql<number>`count(*)::int`,
|
||||
uniqueIps: sql<number>`count(distinct ${accessIpEvent.ip})::int`,
|
||||
abnormalEvents: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||
abnormalIps: sql<number>`count(distinct ${accessIpEvent.ip}) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||
rateLimitedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
|
||||
manualBlockedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'manual_blocked')::int`,
|
||||
loginSuccessEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
|
||||
unauthorizedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'unauthorized' or ${accessIpEvent.statusCode} in (401, 403))::int`,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause);
|
||||
|
||||
const recentEvents = await db
|
||||
.select({
|
||||
id: accessIpEvent.id,
|
||||
eventType: accessIpEvent.eventType,
|
||||
ip: accessIpEvent.ip,
|
||||
userId: accessIpEvent.userId,
|
||||
userEmail: accessIpEvent.userEmail,
|
||||
userRole: accessIpEvent.userRole,
|
||||
method: accessIpEvent.method,
|
||||
path: accessIpEvent.path,
|
||||
routeGroup: accessIpEvent.routeGroup,
|
||||
statusCode: accessIpEvent.statusCode,
|
||||
outcome: accessIpEvent.outcome,
|
||||
severity: accessIpEvent.severity,
|
||||
reasons: accessIpEvent.reasons,
|
||||
userAgent: accessIpEvent.userAgent,
|
||||
referer: accessIpEvent.referer,
|
||||
occurredAt: accessIpEvent.occurredAt,
|
||||
createdAt: accessIpEvent.createdAt,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(accessIpEvent.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
const topIpRows = await db
|
||||
.select({
|
||||
ip: accessIpEvent.ip,
|
||||
total: sql<number>`count(*)::int`,
|
||||
uniqueUsers: sql<number>`count(distinct ${accessIpEvent.userId}) filter (where ${accessIpEvent.userId} is not null)::int`,
|
||||
failureCount: sql<number>`count(*) filter (where ${accessIpEvent.statusCode} >= 400)::int`,
|
||||
rateLimitedCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
|
||||
authFailureCount: sql<number>`count(*) filter (where ${accessIpEvent.path} like '/api/auth%' and ${accessIpEvent.statusCode} >= 400)::int`,
|
||||
loginSuccessCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
|
||||
suspiciousCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical'))::int`,
|
||||
firstSeen: sql<string>`min(${accessIpEvent.createdAt})::text`,
|
||||
lastSeen: sql<string>`max(${accessIpEvent.createdAt})::text`,
|
||||
eventSeverityRank: sql<number>`max(case ${accessIpEvent.severity}
|
||||
when 'critical' then 3
|
||||
when 'high' then 2
|
||||
when 'watch' then 1
|
||||
else 0
|
||||
end)::int`,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause)
|
||||
.groupBy(accessIpEvent.ip)
|
||||
.orderBy(
|
||||
desc(sql`(
|
||||
count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical')) * 8
|
||||
+ count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429) * 12
|
||||
+ count(*) filter (where ${accessIpEvent.statusCode} >= 400) * 3
|
||||
+ count(*)
|
||||
)`)
|
||||
)
|
||||
.limit(24);
|
||||
|
||||
const topIps = topIpRows.map((row) => {
|
||||
const severity = buildSeverityFromAggregate({
|
||||
eventSeverityRank: Number(row.eventSeverityRank || 0),
|
||||
total: Number(row.total || 0),
|
||||
failureCount: Number(row.failureCount || 0),
|
||||
rateLimitedCount: Number(row.rateLimitedCount || 0),
|
||||
authFailureCount: Number(row.authFailureCount || 0),
|
||||
suspiciousCount: Number(row.suspiciousCount || 0),
|
||||
});
|
||||
|
||||
return {
|
||||
...row,
|
||||
severity,
|
||||
failureRate:
|
||||
Number(row.total || 0) > 0
|
||||
? Number(row.failureCount || 0) / Number(row.total || 1)
|
||||
: 0,
|
||||
reasons: buildAggregateReasons({
|
||||
total: Number(row.total || 0),
|
||||
failureCount: Number(row.failureCount || 0),
|
||||
rateLimitedCount: Number(row.rateLimitedCount || 0),
|
||||
authFailureCount: Number(row.authFailureCount || 0),
|
||||
suspiciousCount: Number(row.suspiciousCount || 0),
|
||||
loginSuccessCount: Number(row.loginSuccessCount || 0),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const hourlyStats = await db
|
||||
.select({
|
||||
hour: sql<string>`to_char(date_trunc('hour', ${accessIpEvent.createdAt}), 'MM-DD HH24:00')`,
|
||||
count: sql<number>`count(*)::int`,
|
||||
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause)
|
||||
.groupBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`)
|
||||
.orderBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`);
|
||||
|
||||
const pathStats = await db
|
||||
.select({
|
||||
path: accessIpEvent.path,
|
||||
routeGroup: accessIpEvent.routeGroup,
|
||||
count: sql<number>`count(*)::int`,
|
||||
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause)
|
||||
.groupBy(accessIpEvent.path, accessIpEvent.routeGroup)
|
||||
.orderBy(desc(sql`count(*)`))
|
||||
.limit(12);
|
||||
|
||||
return NextResponse.json({
|
||||
window: {
|
||||
hours,
|
||||
since: since.toISOString(),
|
||||
},
|
||||
overview: {
|
||||
totalEvents: overview?.totalEvents || 0,
|
||||
uniqueIps: overview?.uniqueIps || 0,
|
||||
abnormalEvents: overview?.abnormalEvents || 0,
|
||||
abnormalIps: overview?.abnormalIps || 0,
|
||||
rateLimitedEvents: overview?.rateLimitedEvents || 0,
|
||||
manualBlockedEvents: overview?.manualBlockedEvents || 0,
|
||||
loginSuccessEvents: overview?.loginSuccessEvents || 0,
|
||||
unauthorizedEvents: overview?.unauthorizedEvents || 0,
|
||||
},
|
||||
topIps,
|
||||
hourlyStats,
|
||||
pathStats,
|
||||
recentEvents,
|
||||
rules: [
|
||||
"短时间请求量偏高",
|
||||
"4xx/5xx 或失败率偏高",
|
||||
"认证失败偏多",
|
||||
"触发限流",
|
||||
"命中敏感路径或异常 User-Agent",
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
console.error("[AccessIpEvent] failed to load admin analytics", error);
|
||||
return new ChatSDKError("offline:admin").toResponse();
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,6 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
fetchAtlasCloudModelPresets,
|
||||
resolveAtlasCloudDisplayName,
|
||||
shouldInsertMissingAtlasCloudPreset,
|
||||
} from "@/lib/ai/atlascloud-models";
|
||||
import { fetchAtlasCloudImagePresets } from "@/lib/ai/atlascloud-models";
|
||||
import { db } from "@/lib/db";
|
||||
import { getAiProviderById } from "@/lib/db/queries";
|
||||
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||
@ -16,7 +12,7 @@ export const maxDuration = 300;
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const result = await fetchAtlasCloudModelPresets();
|
||||
const result = await fetchAtlasCloudImagePresets();
|
||||
return NextResponse.json({
|
||||
presets: result.presets,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
@ -33,7 +29,7 @@ export async function GET() {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch Atlas Cloud models",
|
||||
: "Failed to fetch Atlas Cloud image models",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@ -62,50 +58,31 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const catalog = await fetchAtlasCloudModelPresets();
|
||||
const catalog = await fetchAtlasCloudImagePresets();
|
||||
const presets = requestedModelIds
|
||||
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||
: catalog.presets;
|
||||
const addedModels: AiModel[] = [];
|
||||
const updatedModels: AiModel[] = [];
|
||||
const errors = [...catalog.errors];
|
||||
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
|
||||
const existing = await db.query.aiModel.findFirst({
|
||||
where: and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
),
|
||||
});
|
||||
const values = {
|
||||
displayName: preset.displayName,
|
||||
avatarUrl: preset.avatarUrl || null,
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: preset.supportsImageGen,
|
||||
sortOrder: existing?.sortOrder ?? preset.sortOrder,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
sortOrder: preset.sortOrder,
|
||||
capabilities: preset.capabilities,
|
||||
defaultParams: preset.defaultParams,
|
||||
imageGenConfig: preset.imageGenConfig,
|
||||
@ -131,8 +108,6 @@ export async function POST(request: Request) {
|
||||
providerId,
|
||||
modelId: preset.modelId,
|
||||
...values,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
isDefault: false,
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
@ -163,8 +138,6 @@ export async function POST(request: Request) {
|
||||
requested: presets.length,
|
||||
total: catalog.catalogCount,
|
||||
available: catalog.presets.length,
|
||||
skippedUnsupported: catalog.skipped.length,
|
||||
preservedDeleted: preservedDeletedCount,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -174,7 +147,7 @@ export async function POST(request: Request) {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to sync Atlas Cloud models",
|
||||
: "Failed to sync Atlas Cloud image models",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
@ -18,8 +18,6 @@ import {
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
|
||||
isDetailAgentLocalMoonshotBaseUrl,
|
||||
normalizeDetailAgentLocalMoonshotBaseUrl,
|
||||
} from "@/lib/ai/detail-agent-model-config";
|
||||
import {
|
||||
normalizeProductPlanningApiConfig,
|
||||
@ -101,7 +99,7 @@ const moonshotDetailAgentConfigSchema = z.object({
|
||||
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
|
||||
|
||||
function isMoonshotOfficialBaseUrl(value: string) {
|
||||
return isDetailAgentLocalMoonshotBaseUrl(value);
|
||||
return String(value || "").trim().replace(/\/+$/, "") === MOONSHOT_OFFICIAL_BASE_URL;
|
||||
}
|
||||
|
||||
function isObjectLike(value: unknown): value is Record<string, unknown> {
|
||||
@ -203,27 +201,19 @@ export async function POST(request: Request) {
|
||||
? (existingSetting.value as Record<string, unknown>)
|
||||
: {};
|
||||
const config = moonshotDetailAgentConfigSchema.parse(value || {});
|
||||
const normalizedBaseUrl = normalizeDetailAgentLocalMoonshotBaseUrl(config.baseUrl);
|
||||
const normalizedBaseUrl = String(config.baseUrl || "").trim() || MOONSHOT_OFFICIAL_BASE_URL;
|
||||
const rawModelId = String(config.modelId || "").trim();
|
||||
const rawDisplayName = String(config.displayName || "").trim();
|
||||
const normalizedModelId =
|
||||
!rawModelId ||
|
||||
rawModelId === "kimi-k2.5" ||
|
||||
rawModelId === "kimi-k2.6" ||
|
||||
rawModelId === "kimi-k2.7-code" ||
|
||||
rawModelId === "kimi-k2.7-code-highspeed"
|
||||
!rawModelId || rawModelId === "kimi-k2.5"
|
||||
? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID
|
||||
: rawModelId;
|
||||
const normalizedDisplayName =
|
||||
!rawDisplayName ||
|
||||
rawDisplayName === "官方 Kimi K2.5" ||
|
||||
rawDisplayName === "官方 Kimi K2.6" ||
|
||||
rawDisplayName === "官方 Kimi K2.7 Code Highspeed" ||
|
||||
rawDisplayName === "Kimi Code 编程套餐 K2.7 Highspeed"
|
||||
!rawDisplayName || rawDisplayName === "官方 Kimi K2.5"
|
||||
? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME
|
||||
: rawDisplayName;
|
||||
const normalizedProviderName =
|
||||
String(config.providerName || "").trim() || "Kimi Code 编程套餐";
|
||||
String(config.providerName || "").trim() || "Moonshot AI (Official)";
|
||||
const normalizedApiKey =
|
||||
String(config.apiKey || "").trim() ||
|
||||
String(existingValue.apiKey || "").trim();
|
||||
@ -232,12 +222,12 @@ export async function POST(request: Request) {
|
||||
if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) {
|
||||
return new ChatSDKError(
|
||||
"bad_request:api",
|
||||
`Kimi Code 编程套餐专用地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
|
||||
`Moonshot 官方地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
if (!normalizedApiKey) {
|
||||
return new ChatSDKError("bad_request:api", "缺少 Kimi Code 控制台 API Key").toResponse();
|
||||
return new ChatSDKError("bad_request:api", "缺少 Moonshot 官方 API Key").toResponse();
|
||||
}
|
||||
|
||||
const saved = await upsertSystemSetting({
|
||||
|
||||
@ -7,7 +7,10 @@ import {
|
||||
extractAgentProjectStateFromCanvasData,
|
||||
isAgentProjectRow,
|
||||
} from "@/lib/agents/agent-project-compat";
|
||||
import { normalizeAgentGenerationProjectPersistedState } from "@/lib/agents/generation-project-state";
|
||||
import {
|
||||
getAllResultUrlsFromAgentProjectState,
|
||||
mergeAgentProjectStateForSave,
|
||||
} from "@/lib/agents/agent-project-save-merge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
@ -79,16 +82,28 @@ export async function PUT(
|
||||
return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const title = body.title !== undefined ? String(body.title || "").trim() || "未命名生图项目" : undefined;
|
||||
const title =
|
||||
body.title !== undefined
|
||||
? String(body.title || "").trim() || "未命名生图项目"
|
||||
: existingProject.title;
|
||||
const description =
|
||||
body.description !== undefined ? String(body.description || "").trim() || null : undefined;
|
||||
const thumbnail =
|
||||
body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined;
|
||||
body.description !== undefined
|
||||
? String(body.description || "").trim() || null
|
||||
: existingProject.description || null;
|
||||
|
||||
const existingProjectState = extractAgentProjectStateFromCanvasData(existingProject.canvasData);
|
||||
const nextProjectState =
|
||||
body.projectState !== undefined
|
||||
? normalizeAgentGenerationProjectPersistedState(body.projectState)
|
||||
: extractAgentProjectStateFromCanvasData(existingProject.canvasData);
|
||||
? mergeAgentProjectStateForSave({
|
||||
existingState: existingProjectState,
|
||||
incomingState: body.projectState,
|
||||
})
|
||||
: existingProjectState;
|
||||
const firstResultUrl = getAllResultUrlsFromAgentProjectState(nextProjectState)[0] || null;
|
||||
const requestedThumbnail =
|
||||
body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined;
|
||||
const thumbnail =
|
||||
requestedThumbnail || existingProject.thumbnail || firstResultUrl || null;
|
||||
|
||||
const [project] = await db
|
||||
.update(canvasProject)
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
467
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
467
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
@ -0,0 +1,467 @@
|
||||
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 KittyCadAsyncOperationResponse = KittyCadTaskResponse & {
|
||||
type?: 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
|
||||
);
|
||||
}
|
||||
|
||||
function extractOutputEntries(task: KittyCadTaskResponse) {
|
||||
return Object.entries(task.outputs || {}).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === "string" &&
|
||||
entry[0].trim().length > 0 &&
|
||||
typeof entry[1] === "string" &&
|
||||
entry[1].trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
async function enrichTextToCadOutputs(params: {
|
||||
config: Awaited<ReturnType<typeof getKittyCadProviderConfig>>;
|
||||
task: KittyCadTaskResponse;
|
||||
taskId: string;
|
||||
}) {
|
||||
if (extractOutputEntries(params.task).length > 0) {
|
||||
return params.task;
|
||||
}
|
||||
|
||||
try {
|
||||
const asyncOperation = await kittyCadRequest<KittyCadAsyncOperationResponse>(
|
||||
params.config,
|
||||
{
|
||||
method: "GET",
|
||||
path: `/async/operations/${encodeURIComponent(params.taskId)}`,
|
||||
}
|
||||
);
|
||||
|
||||
if (extractOutputEntries(asyncOperation).length === 0) {
|
||||
return params.task;
|
||||
}
|
||||
|
||||
return {
|
||||
...params.task,
|
||||
...asyncOperation,
|
||||
code: asyncOperation.code || params.task.code || null,
|
||||
conversation_id:
|
||||
asyncOperation.conversation_id || params.task.conversation_id || null,
|
||||
model_version:
|
||||
asyncOperation.model_version || params.task.model_version || null,
|
||||
output_format:
|
||||
asyncOperation.output_format || params.task.output_format || null,
|
||||
outputs: asyncOperation.outputs,
|
||||
prompt: asyncOperation.prompt || params.task.prompt || null,
|
||||
} satisfies KittyCadTaskResponse;
|
||||
} catch (error) {
|
||||
console.warn("[KittyCADStatus] async output fallback failed", {
|
||||
taskId: params.taskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return params.task;
|
||||
}
|
||||
}
|
||||
|
||||
function createClientSafeTaskOutput(task: KittyCadTaskResponse) {
|
||||
const outputEntries = extractOutputEntries(task);
|
||||
const outputSummary = Object.fromEntries(
|
||||
outputEntries.map(([name, base64]) => [
|
||||
name,
|
||||
{
|
||||
base64Length: base64.length,
|
||||
extension: getFileExtension(name) || null,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
...task,
|
||||
outputs: outputSummary,
|
||||
};
|
||||
}
|
||||
|
||||
async function archiveOutputIfNeeded(params: {
|
||||
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();
|
||||
let 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: createClientSafeTaskOutput(result),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
result = await enrichTextToCadOutputs({
|
||||
config,
|
||||
task: result,
|
||||
taskId: normalizedTaskId,
|
||||
});
|
||||
|
||||
const outputEntries = extractOutputEntries(result);
|
||||
|
||||
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: createClientSafeTaskOutput(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,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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,17 @@ import { auth } from '@/app/(auth)/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { canvasProject } from '@/lib/db/schema';
|
||||
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import {
|
||||
buildStaleCanvasSaveConflictPayload,
|
||||
isStaleCanvasSaveBaseRevision,
|
||||
} from '@/lib/canvas/canvas-save-conflict';
|
||||
import { normalizeCanvasProjectTitle } from '@/lib/canvas/project-title';
|
||||
import {
|
||||
getExplicitVideoCanvasWorkspaceMeta,
|
||||
getVideoCanvasWorkspaceProjects,
|
||||
withVideoCanvasWorkspaceMeta,
|
||||
} from '@/lib/canvas/video-workspace';
|
||||
|
||||
function resolveWorkspaceType(input: unknown) {
|
||||
return input === "agent" || input === "video" ? input : "canvas";
|
||||
@ -17,67 +23,19 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorksp
|
||||
return workspaceType === "video" ? "未命名视频画布" : "未命名画布";
|
||||
}
|
||||
|
||||
function shouldUseLocalCanvasE2E(request: NextRequest) {
|
||||
const hostname = request.nextUrl.hostname;
|
||||
return (
|
||||
process.env.CANVAS_E2E_LOCAL === "true" &&
|
||||
(hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
|
||||
);
|
||||
}
|
||||
|
||||
function createLocalCanvasProject(
|
||||
id: string,
|
||||
input: {
|
||||
canvasData?: unknown;
|
||||
description?: unknown;
|
||||
thumbnail?: unknown;
|
||||
title?: unknown;
|
||||
workspaceType?: unknown;
|
||||
} = {}
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const workspaceType = resolveWorkspaceType(input.workspaceType || "video");
|
||||
return {
|
||||
id,
|
||||
userId: "local-video-canvas-e2e-user",
|
||||
title: String(input.title || getDefaultWorkspaceTitle(workspaceType)),
|
||||
description: typeof input.description === "string" ? input.description : null,
|
||||
thumbnail: typeof input.thumbnail === "string" ? input.thumbnail : null,
|
||||
workspaceType,
|
||||
canvasData:
|
||||
input.canvasData && typeof input.canvasData === "object"
|
||||
? input.canvasData
|
||||
: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeIds: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
tool: "select",
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
// 获取单个画布
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
if (shouldUseLocalCanvasE2E(request)) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createLocalCanvasProject(id),
|
||||
});
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
@ -105,20 +63,13 @@ export async function PUT(
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
if (shouldUseLocalCanvasE2E(request)) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createLocalCanvasProject(id, body),
|
||||
});
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, description, thumbnail, canvasData } = body;
|
||||
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||
const workspaceType =
|
||||
@ -153,18 +104,56 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
const explicitWorkspaceMeta = getExplicitVideoCanvasWorkspaceMeta(
|
||||
existingProject.canvasData
|
||||
);
|
||||
const canvasDataWithWorkspace =
|
||||
canvasData !== undefined && explicitWorkspaceMeta
|
||||
? withVideoCanvasWorkspaceMeta(canvasData, explicitWorkspaceMeta)
|
||||
: canvasData;
|
||||
const syncedCanvasData =
|
||||
canvasData !== undefined
|
||||
canvasDataWithWorkspace !== undefined
|
||||
? await syncCanvasNodesForProject({
|
||||
projectId: id,
|
||||
userId: session.user.id,
|
||||
canvasData,
|
||||
canvasData: canvasDataWithWorkspace,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// 更新现有画布
|
||||
const updateData: any = { updatedAt: new Date() };
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (title !== undefined) {
|
||||
updateData.title = normalizeCanvasProjectTitle(
|
||||
title,
|
||||
existingProject.title
|
||||
);
|
||||
if (existingProject.workspaceType === "video") {
|
||||
const videoProjects = await db
|
||||
.select({
|
||||
canvasData: canvasProject.canvasData,
|
||||
id: canvasProject.id,
|
||||
})
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
eq(canvasProject.workspaceType, "video")
|
||||
)
|
||||
);
|
||||
const workspaceProjectIds = getVideoCanvasWorkspaceProjects(
|
||||
videoProjects,
|
||||
existingProject
|
||||
)
|
||||
.map((project) => project.id)
|
||||
.filter((projectId) => projectId !== id);
|
||||
if (workspaceProjectIds.length > 0) {
|
||||
await db
|
||||
.update(canvasProject)
|
||||
.set({ title: updateData.title, updatedAt: new Date() })
|
||||
.where(inArray(canvasProject.id, workspaceProjectIds));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
|
||||
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
|
||||
@ -198,7 +187,10 @@ export async function PUT(
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
};
|
||||
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
|
||||
const nextTitle = normalizeCanvasProjectTitle(
|
||||
title,
|
||||
getDefaultWorkspaceTitle(nextWorkspaceType)
|
||||
);
|
||||
const insertData: any = {
|
||||
id, // 使用指定的 ID
|
||||
userId: session.user.id,
|
||||
|
||||
266
app/api/canvas/[id]/video-workspace/route.ts
Normal file
266
app/api/canvas/[id]/video-workspace/route.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
duplicateVideoCanvasData,
|
||||
getExplicitVideoCanvasWorkspaceMeta,
|
||||
getNextVideoCanvasName,
|
||||
getVideoCanvasCopyName,
|
||||
getVideoCanvasWorkspaceProjects,
|
||||
normalizeVideoCanvasName,
|
||||
resolveVideoCanvasWorkspaceMeta,
|
||||
withVideoCanvasWorkspaceMeta,
|
||||
} from "@/lib/canvas/video-workspace";
|
||||
import { db } from "@/lib/db";
|
||||
import { syncCanvasNodesForProject } from "@/lib/db/queries";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
|
||||
type CanvasProjectRow = typeof canvasProject.$inferSelect;
|
||||
|
||||
function toCanvasItem(project: CanvasProjectRow, currentId: string) {
|
||||
const meta = resolveVideoCanvasWorkspaceMeta(project);
|
||||
return {
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
id: project.id,
|
||||
isCurrent: project.id === currentId,
|
||||
name: meta.canvasName,
|
||||
};
|
||||
}
|
||||
|
||||
async function getWorkspaceContext(userId: string, currentId: string) {
|
||||
const projects = await db
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.userId, userId),
|
||||
eq(canvasProject.workspaceType, "video"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(canvasProject.createdAt));
|
||||
const currentProject = projects.find((project) => project.id === currentId);
|
||||
if (!currentProject) return null;
|
||||
return {
|
||||
currentProject,
|
||||
projects: getVideoCanvasWorkspaceProjects(projects, currentProject),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureExplicitWorkspaceMetadata(projects: CanvasProjectRow[]) {
|
||||
for (const project of projects) {
|
||||
if (getExplicitVideoCanvasWorkspaceMeta(project.canvasData)) continue;
|
||||
const canvasData = withVideoCanvasWorkspaceMeta(
|
||||
project.canvasData,
|
||||
resolveVideoCanvasWorkspaceMeta(project),
|
||||
);
|
||||
await db
|
||||
.update(canvasProject)
|
||||
.set({ canvasData })
|
||||
.where(eq(canvasProject.id, project.id));
|
||||
project.canvasData = canvasData;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const context = await getWorkspaceContext(session.user.id, id);
|
||||
if (!context) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "画布不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
canvases: context.projects.map((project) => toCanvasItem(project, id)),
|
||||
currentCanvasId: id,
|
||||
workspaceTitle: context.currentProject.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
if (body?.action !== "create" && body?.action !== "copy") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "不支持的画布操作" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const action = body.action;
|
||||
const context = await getWorkspaceContext(session.user.id, id);
|
||||
if (!context) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "画布不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
await ensureExplicitWorkspaceMetadata(context.projects);
|
||||
const currentMeta = resolveVideoCanvasWorkspaceMeta(context.currentProject);
|
||||
const existingNames = context.projects.map(
|
||||
(project) => resolveVideoCanvasWorkspaceMeta(project).canvasName,
|
||||
);
|
||||
const source =
|
||||
action === "copy"
|
||||
? context.projects.find((project) => project.id === body?.targetId)
|
||||
: context.currentProject;
|
||||
if (!source) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "要复制的画布不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const canvasName =
|
||||
action === "copy"
|
||||
? getVideoCanvasCopyName(
|
||||
resolveVideoCanvasWorkspaceMeta(source).canvasName,
|
||||
existingNames,
|
||||
)
|
||||
: getNextVideoCanvasName(existingNames);
|
||||
const meta = {
|
||||
canvasName,
|
||||
workspaceId: currentMeta.workspaceId,
|
||||
};
|
||||
const canvasData =
|
||||
action === "copy"
|
||||
? duplicateVideoCanvasData(source.canvasData, meta)
|
||||
: withVideoCanvasWorkspaceMeta(null, meta);
|
||||
const [insertedProject] = await db
|
||||
.insert(canvasProject)
|
||||
.values({
|
||||
canvasData,
|
||||
description: context.currentProject.description,
|
||||
title: context.currentProject.title,
|
||||
userId: session.user.id,
|
||||
workspaceType: "video",
|
||||
})
|
||||
.returning();
|
||||
const syncedCanvasData = await syncCanvasNodesForProject({
|
||||
canvasData,
|
||||
projectId: insertedProject.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
const [createdProject] = await db
|
||||
.update(canvasProject)
|
||||
.set({ canvasData: syncedCanvasData })
|
||||
.where(eq(canvasProject.id, insertedProject.id))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: toCanvasItem(createdProject || insertedProject, id),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const context = await getWorkspaceContext(session.user.id, id);
|
||||
if (!context) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "画布不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const target = context.projects.find((project) => project.id === body?.targetId);
|
||||
if (!target) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "画布不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const currentMeta = resolveVideoCanvasWorkspaceMeta(context.currentProject);
|
||||
const targetMeta = resolveVideoCanvasWorkspaceMeta(target);
|
||||
const canvasName = normalizeVideoCanvasName(body?.name, targetMeta.canvasName);
|
||||
const [updatedProject] = await db
|
||||
.update(canvasProject)
|
||||
.set({
|
||||
canvasData: withVideoCanvasWorkspaceMeta(target.canvasData, {
|
||||
canvasName,
|
||||
workspaceId: currentMeta.workspaceId,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, target.id),
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: toCanvasItem(updatedProject, id),
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const context = await getWorkspaceContext(session.user.id, id);
|
||||
if (!context) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "画布不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const target = context.projects.find((project) => project.id === body?.targetId);
|
||||
if (!target) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "画布不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (context.projects.length <= 1) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "至少保留一个画布" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
await db
|
||||
.delete(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, target.id),
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
),
|
||||
);
|
||||
const remaining = context.projects.filter((project) => project.id !== target.id);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
nextCanvasId: target.id === id ? remaining[0]?.id || null : id,
|
||||
},
|
||||
});
|
||||
}
|
||||
16
app/api/canvas/audio-source/route.ts
Normal file
16
app/api/canvas/audio-source/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { proxyCanvasAudioSource } from "@/lib/video-canvas/audio-source-proxy";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestHostname = new URL(request.url).hostname.toLowerCase();
|
||||
const isLoopbackRequest =
|
||||
requestHostname === "localhost" ||
|
||||
requestHostname === "127.0.0.1" ||
|
||||
requestHostname === "::1";
|
||||
const session = isLoopbackRequest ? null : await auth();
|
||||
if (!isLoopbackRequest && !session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return proxyCanvasAudioSource(request);
|
||||
}
|
||||
@ -576,7 +576,7 @@ export async function POST(request: Request) {
|
||||
: "image/jpeg"),
|
||||
}));
|
||||
|
||||
await persistPendingClawdTask({
|
||||
const persistedTask = await persistPendingClawdTask({
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
assistantMessageId,
|
||||
@ -587,6 +587,22 @@ export async function POST(request: Request) {
|
||||
skillTagName,
|
||||
processingHint,
|
||||
});
|
||||
if (
|
||||
persistedTask &&
|
||||
persistedTask.assistantMessageId !== assistantMessageId
|
||||
) {
|
||||
return NextResponse.json(
|
||||
buildTaskResponse({
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
assistantMessageId: persistedTask.assistantMessageId,
|
||||
status: persistedTask.status,
|
||||
processingHint:
|
||||
persistedTask.processingHint || DEFAULT_PROCESSING_HINT,
|
||||
}),
|
||||
{ status: 202, headers: { "Retry-After": "2" } }
|
||||
);
|
||||
}
|
||||
|
||||
const userProfile = await getUserById({ id: session.user.id }).catch(
|
||||
() => null
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
getCanvasImageTaskForUser,
|
||||
getCanvasDetailReplacementTasksForUserContext,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import {
|
||||
generateCanvasImage,
|
||||
@ -30,8 +31,16 @@ function normalizeBillingFeature(
|
||||
}
|
||||
|
||||
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
|
||||
const requestedQuality =
|
||||
body.providerImageParams &&
|
||||
typeof body.providerImageParams === "object" &&
|
||||
!Array.isArray(body.providerImageParams)
|
||||
? String(body.providerImageParams.quality || "").trim().toLowerCase()
|
||||
: "";
|
||||
return {
|
||||
prompt: String(body.prompt || "").trim(),
|
||||
displayPrompt:
|
||||
typeof body.displayPrompt === "string" ? body.displayPrompt : undefined,
|
||||
negativePrompt:
|
||||
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
|
||||
model: typeof body.model === "string" ? body.model : undefined,
|
||||
@ -49,11 +58,24 @@ function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImage
|
||||
: 1,
|
||||
promptExtend:
|
||||
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
|
||||
providerImageParams: new Set(["low", "medium", "high"]).has(
|
||||
requestedQuality,
|
||||
)
|
||||
? { quality: requestedQuality }
|
||||
: undefined,
|
||||
referenceImage:
|
||||
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
|
||||
referenceImages: Array.isArray(body.referenceImages)
|
||||
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
|
||||
: [],
|
||||
debugLabel:
|
||||
typeof body.debugLabel === "string" ? body.debugLabel : undefined,
|
||||
debugMeta:
|
||||
body.debugMeta &&
|
||||
typeof body.debugMeta === "object" &&
|
||||
!Array.isArray(body.debugMeta)
|
||||
? body.debugMeta
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@ -66,6 +88,19 @@ export async function GET(request: Request) {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = String(searchParams.get("taskId") || "").trim();
|
||||
const chatId = String(searchParams.get("chatId") || "").trim();
|
||||
const projectId = String(searchParams.get("projectId") || "").trim();
|
||||
if (!taskId && chatId) {
|
||||
const tasks = await getCanvasDetailReplacementTasksForUserContext({
|
||||
userId: session.user.id,
|
||||
chatId,
|
||||
projectId: projectId || null,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tasks,
|
||||
});
|
||||
}
|
||||
if (!taskId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少 taskId 参数" },
|
||||
|
||||
@ -1,23 +1,61 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
|
||||
import {
|
||||
resolveVideoReferenceSelectionRule,
|
||||
selectVideoReferenceImages,
|
||||
} from "@/lib/canvas/video-reference-selection";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { db } from "@/lib/db";
|
||||
import { mediaAsset } from "@/lib/db/schema";
|
||||
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import { normalizeVideoCanvasGenerationRequestCount } from "@/lib/video-canvas/video-generation-parameters";
|
||||
import {
|
||||
buildVideoCanvasSubjectProviderReferences,
|
||||
composeVideoCanvasSubjectPrompt,
|
||||
normalizeVideoCanvasAttachedSubjects,
|
||||
supportsVideoCanvasSubjectUsage,
|
||||
toVideoCanvasAttachedSubject,
|
||||
VIDEO_CANVAS_SUBJECT_SOURCE_TYPE,
|
||||
type VideoCanvasAttachedSubject,
|
||||
} from "@/lib/video-canvas/subjects";
|
||||
import {
|
||||
composeVideoCanvasSmartStoryboardPrompt,
|
||||
normalizeVideoCanvasSmartStoryboard,
|
||||
supportsVideoCanvasSmartStoryboard,
|
||||
validateVideoCanvasSmartStoryboard,
|
||||
type VideoCanvasSmartStoryboard,
|
||||
} from "@/lib/video-canvas/smart-storyboard";
|
||||
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
interface GenerateVideoRequest {
|
||||
autoValidateMaterial?: boolean;
|
||||
prompt?: string;
|
||||
mode?: VideoGenerationMode;
|
||||
images?: string[];
|
||||
audioUrls?: string[];
|
||||
resolution?: VideoResolution;
|
||||
ratio?: VideoRatio;
|
||||
duration?: number;
|
||||
generateAudio?: boolean;
|
||||
count?: number;
|
||||
watermark?: boolean;
|
||||
cameraFixed?: boolean;
|
||||
seed?: number;
|
||||
returnLastFrame?: boolean;
|
||||
modelLabel?: string;
|
||||
preferredModelRef?: string;
|
||||
searchEnabled?: boolean;
|
||||
smartStoryboard?: VideoCanvasSmartStoryboard;
|
||||
subjects?: VideoCanvasAttachedSubject[];
|
||||
}
|
||||
|
||||
function inferMode(images: string[]): VideoGenerationMode {
|
||||
@ -39,18 +77,142 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as GenerateVideoRequest;
|
||||
const prompt = String(body.prompt || "").trim();
|
||||
if (!prompt) {
|
||||
const count = normalizeVideoCanvasGenerationRequestCount(body.count, 1);
|
||||
if (count === null) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "生成数量仅支持 1、2 或 4 个" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const generateAudio = body.generateAudio !== false;
|
||||
const setting = await getSystemSetting({
|
||||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const config = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const autoValidateMaterial =
|
||||
typeof body.autoValidateMaterial === "boolean"
|
||||
? body.autoValidateMaterial
|
||||
: config.videoAdvancedSettings.defaultAutoValidateMaterial;
|
||||
const searchEnabled =
|
||||
typeof body.searchEnabled === "boolean"
|
||||
? body.searchEnabled
|
||||
: config.videoAdvancedSettings.defaultSearchEnabled;
|
||||
const subjectUsageEnabled = supportsVideoCanvasSubjectUsage(
|
||||
config.subjectLibrary,
|
||||
`${body.modelLabel || ""} ${body.preferredModelRef || ""}`,
|
||||
);
|
||||
const requestedSubjects = subjectUsageEnabled
|
||||
? normalizeVideoCanvasAttachedSubjects(
|
||||
body.subjects,
|
||||
config.subjectLibrary.videoUsage.maxSubjects,
|
||||
)
|
||||
: [];
|
||||
const requestedSubjectIds = requestedSubjects
|
||||
.map((subject) => subject.id)
|
||||
.filter((id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id));
|
||||
const ownedSubjectRows = requestedSubjectIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
description: mediaAsset.summary,
|
||||
displayName: mediaAsset.displayName,
|
||||
id: mediaAsset.id,
|
||||
metadata: mediaAsset.metadata,
|
||||
originalUrl: mediaAsset.originalUrl,
|
||||
previewUrl: mediaAsset.previewUrl,
|
||||
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||
})
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
inArray(mediaAsset.id, requestedSubjectIds),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.mediaType, "subject"),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const ownedSubjectsById = new Map(
|
||||
ownedSubjectRows.map((subject) => [subject.id, subject]),
|
||||
);
|
||||
const subjects = requestedSubjects.flatMap((requestedSubject) => {
|
||||
const ownedSubject = ownedSubjectsById.get(requestedSubject.id);
|
||||
const normalized = ownedSubject
|
||||
? toVideoCanvasAttachedSubject(ownedSubject)
|
||||
: null;
|
||||
return normalized ? [normalized] : [];
|
||||
});
|
||||
const subjectReferences = buildVideoCanvasSubjectProviderReferences(subjects);
|
||||
const smartStoryboardSupported = supportsVideoCanvasSmartStoryboard(
|
||||
config.smartStoryboard,
|
||||
`${body.modelLabel || ""} ${body.preferredModelRef || ""}`,
|
||||
);
|
||||
const smartStoryboard = normalizeVideoCanvasSmartStoryboard(
|
||||
body.smartStoryboard,
|
||||
{
|
||||
config: config.smartStoryboard,
|
||||
fallbackPrompt: prompt,
|
||||
fallbackTotalDurationSeconds: body.duration,
|
||||
},
|
||||
);
|
||||
const storyboardValidation = validateVideoCanvasSmartStoryboard(
|
||||
smartStoryboard,
|
||||
);
|
||||
if (
|
||||
smartStoryboardSupported &&
|
||||
smartStoryboard.enabled &&
|
||||
!storyboardValidation.valid
|
||||
) {
|
||||
const message =
|
||||
storyboardValidation.emptyShotIndexes.length > 0
|
||||
? "智能分镜存在未填写的镜头描述"
|
||||
: storyboardValidation.status === "over"
|
||||
? "智能分镜总时长超出"
|
||||
: "智能分镜总时长不足";
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const promptWithStoryboard = smartStoryboardSupported
|
||||
? composeVideoCanvasSmartStoryboardPrompt(
|
||||
prompt,
|
||||
smartStoryboard,
|
||||
config.smartStoryboard,
|
||||
)
|
||||
: prompt;
|
||||
const generationPrompt = composeVideoCanvasSubjectPrompt(
|
||||
promptWithStoryboard,
|
||||
subjects,
|
||||
config.subjectLibrary.videoUsage.promptTemplate,
|
||||
);
|
||||
if (!generationPrompt) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请输入视频提示词" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const inputImages = Array.isArray(body.images)
|
||||
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
|
||||
const referenceRule = resolveVideoReferenceSelectionRule(
|
||||
config.videoReferenceSelection,
|
||||
`${body.modelLabel || ""} ${body.preferredModelRef || ""}`,
|
||||
);
|
||||
const rawImages = Array.isArray(body.images)
|
||||
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 20)
|
||||
: [];
|
||||
const inputImages = body.mode
|
||||
? body.mode === "text-to-video" || body.mode === "audio-to-video"
|
||||
? rawImages.slice(0, 4)
|
||||
: selectVideoReferenceImages(rawImages, body.mode, referenceRule.maxImages)
|
||||
: rawImages.slice(0, referenceRule.maxImages);
|
||||
const { kept: reachableImages, dropped: unreachableImages } =
|
||||
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
|
||||
const inputAudioUrls = Array.isArray(body.audioUrls)
|
||||
? body.audioUrls.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 3)
|
||||
: [];
|
||||
const { kept: reachableAudioUrls, dropped: unreachableAudioUrls } =
|
||||
await filterReachableRemoteUrls(inputAudioUrls, { timeoutMs: 3000 });
|
||||
|
||||
if (inputImages.length > 0 && reachableImages.length === 0) {
|
||||
return NextResponse.json(
|
||||
@ -59,17 +221,35 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const mode = body.mode || inferMode(reachableImages);
|
||||
const mode = body.mode || (reachableAudioUrls.length > 0 ? "audio-to-video" : inferMode(reachableImages));
|
||||
const selectedImages =
|
||||
mode === "text-to-video" || mode === "audio-to-video"
|
||||
? reachableImages
|
||||
: selectVideoReferenceImages(
|
||||
reachableImages,
|
||||
mode,
|
||||
referenceRule.maxImages,
|
||||
);
|
||||
if (mode === "audio-to-video" && reachableAudioUrls.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "音频生视频至少需要 1 个可访问的音频素材" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = await executeDirectVideoGeneration({
|
||||
prompt,
|
||||
prompt: generationPrompt,
|
||||
mode,
|
||||
images: reachableImages,
|
||||
images: selectedImages,
|
||||
audioUrls: reachableAudioUrls,
|
||||
resolution: body.resolution,
|
||||
ratio: body.ratio,
|
||||
duration:
|
||||
typeof body.duration === "number" && Number.isFinite(body.duration)
|
||||
? body.duration
|
||||
: undefined,
|
||||
smartStoryboardSupported && smartStoryboard.enabled
|
||||
? smartStoryboard.totalDurationSeconds
|
||||
: typeof body.duration === "number" && Number.isFinite(body.duration)
|
||||
? body.duration
|
||||
: undefined,
|
||||
generateAudio,
|
||||
watermark: body.watermark,
|
||||
cameraFixed: body.cameraFixed,
|
||||
seed:
|
||||
@ -95,8 +275,20 @@ export async function POST(request: Request) {
|
||||
modelId: result.modelId,
|
||||
modelRef: result.modelRef,
|
||||
mode,
|
||||
images: reachableImages,
|
||||
images: selectedImages,
|
||||
audioUrls: reachableAudioUrls,
|
||||
autoValidateMaterial,
|
||||
count,
|
||||
generateAudio,
|
||||
droppedImages: unreachableImages,
|
||||
droppedAudioUrls: unreachableAudioUrls,
|
||||
searchEnabled,
|
||||
subjects,
|
||||
subjectReferences,
|
||||
smartStoryboard:
|
||||
smartStoryboardSupported && smartStoryboard.enabled
|
||||
? smartStoryboard
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
116
app/api/canvas/layout-replace/route.ts
Normal file
116
app/api/canvas/layout-replace/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import type { CanvasLayoutReplaceTaskRequest } from "@/lib/canvas/layout-replace-task";
|
||||
|
||||
export const maxDuration = 1200;
|
||||
|
||||
function normalizeHttpUrl(value: unknown) {
|
||||
const url = String(value || "").trim();
|
||||
return /^https?:\/\//i.test(url) ? url : "";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const sourceImageUrl = normalizeHttpUrl(body?.sourceImageUrl);
|
||||
const referenceImageUrl = normalizeHttpUrl(body?.referenceImageUrl);
|
||||
if (!sourceImageUrl) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少当前产品图片" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!referenceImageUrl) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少排版参考图" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||||
reason: "image_repaint",
|
||||
modelId: "canvas-layout-replace",
|
||||
note: "排版替换消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, {
|
||||
status: pointsCharge.status,
|
||||
});
|
||||
}
|
||||
|
||||
const taskRequest: CanvasLayoutReplaceTaskRequest = {
|
||||
taskKind: "canvas-layout-replace",
|
||||
sourceImageUrl,
|
||||
referenceImageUrl,
|
||||
supplementalPrompt:
|
||||
typeof body?.supplementalPrompt === "string"
|
||||
? body.supplementalPrompt
|
||||
: undefined,
|
||||
model: typeof body?.model === "string" ? body.model : undefined,
|
||||
imageSize:
|
||||
typeof body?.imageSize === "string" && body.imageSize.trim()
|
||||
? body.imageSize.trim()
|
||||
: "2K",
|
||||
sourceNodeId:
|
||||
typeof body?.sourceNodeId === "string" ? body.sourceNodeId : undefined,
|
||||
referenceLabel:
|
||||
typeof body?.referenceLabel === "string" ? body.referenceLabel : undefined,
|
||||
debugMeta:
|
||||
body?.debugMeta &&
|
||||
typeof body.debugMeta === "object" &&
|
||||
!Array.isArray(body.debugMeta)
|
||||
? body.debugMeta
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const task = await createCanvasImageTask({
|
||||
userId: session.user.id,
|
||||
request: taskRequest as unknown as Record<string, unknown>,
|
||||
});
|
||||
await enqueueCanvasImageTask(task.id);
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note: `排版替换消耗积分(${task.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas layout replace points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Canvas Layout Replace] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "排版替换提交失败",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
16
app/api/canvas/video-source/route.ts
Normal file
16
app/api/canvas/video-source/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { proxyCanvasVideoSource } from "@/lib/video-canvas/audio-source-proxy";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestHostname = new URL(request.url).hostname.toLowerCase();
|
||||
const isLoopbackRequest =
|
||||
requestHostname === "localhost" ||
|
||||
requestHostname === "127.0.0.1" ||
|
||||
requestHostname === "::1";
|
||||
const session = isLoopbackRequest ? null : await auth();
|
||||
if (!isLoopbackRequest && !session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return proxyCanvasVideoSource(request);
|
||||
}
|
||||
@ -9,12 +9,26 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||
import { getAiModelAgentCodingModelId, isAiModelMultimodal } from "@/lib/db/ai-model-flags";
|
||||
import {
|
||||
getAgentAiModelWithProvider,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getSystemSetting,
|
||||
} from "@/lib/db/queries";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import { normalizeVideoAnalysisTimeline } from "@/lib/video-canvas/video-analysis";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
@ -23,6 +37,11 @@ const execFileAsync = promisify(execFile);
|
||||
const VIDEO_ANALYSIS_FRAME_COUNT = 4;
|
||||
const VIDEO_ANALYSIS_MAX_SIDE = 960;
|
||||
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
|
||||
const logger = createLogger("canvas-workflow-analyze");
|
||||
|
||||
type ModelCandidate = Awaited<
|
||||
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||
>[number];
|
||||
|
||||
const requestSchema = z.object({
|
||||
mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
|
||||
@ -30,6 +49,7 @@ const requestSchema = z.object({
|
||||
imageUrls: z.array(z.string().trim()).optional(),
|
||||
videoUrls: z.array(z.string().trim()).optional(),
|
||||
nodeTitle: z.string().trim().optional(),
|
||||
durationSeconds: z.number().positive().max(3600).optional(),
|
||||
preferredModelRef: z.string().trim().optional(),
|
||||
model: z.string().trim().optional(),
|
||||
});
|
||||
@ -56,6 +76,24 @@ function supportsVision(model: {
|
||||
);
|
||||
}
|
||||
|
||||
function isAnalysisModelCandidate(candidate: ModelCandidate) {
|
||||
const descriptor = {
|
||||
capabilities: candidate.model.capabilities,
|
||||
modelId: candidate.model.modelId,
|
||||
providerType: candidate.provider.type,
|
||||
supportsImageGen: candidate.model.supportsImageGen,
|
||||
};
|
||||
|
||||
return (
|
||||
candidate.model.isEnabled &&
|
||||
candidate.provider.isEnabled &&
|
||||
!candidate.model.isSubModel &&
|
||||
candidate.model.healthStatus !== "auto_disabled" &&
|
||||
!isImageGenerationModelDescriptor(descriptor) &&
|
||||
!isVideoModelDescriptor(descriptor)
|
||||
);
|
||||
}
|
||||
|
||||
function extractJsonBlock<T = Record<string, unknown>>(text: string): T | null {
|
||||
const matched = String(text || "").match(/\{[\s\S]*\}/);
|
||||
if (!matched) return null;
|
||||
@ -70,14 +108,6 @@ function normalizeText(value: unknown) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function shouldUseLocalWorkflowE2E(request: Request) {
|
||||
const hostname = new URL(request.url).hostname;
|
||||
return (
|
||||
process.env.CANVAS_E2E_LOCAL === "true" &&
|
||||
(hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown, limit = 8) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return Array.from(
|
||||
@ -115,9 +145,10 @@ function normalizeShotIdeas(value: unknown) {
|
||||
function normalizeVideoAnalysisRows(
|
||||
value: unknown,
|
||||
framePreviews: string[],
|
||||
totalDurationSeconds?: number | null,
|
||||
) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
const rows = value
|
||||
.map((item, index) => {
|
||||
const raw =
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
@ -218,6 +249,8 @@ function normalizeVideoAnalysisRows(
|
||||
} => Boolean(item),
|
||||
)
|
||||
.slice(0, 8);
|
||||
|
||||
return normalizeVideoAnalysisTimeline(rows, totalDurationSeconds);
|
||||
}
|
||||
|
||||
function normalizeStoryShot(value: unknown, index: number) {
|
||||
@ -497,6 +530,7 @@ function buildVideoAnalysisPrompt(params: {
|
||||
frameCount: number;
|
||||
imageCount: number;
|
||||
videoCount: number;
|
||||
durationSeconds?: number | null;
|
||||
}) {
|
||||
return [
|
||||
"你是视频画布工作流里的视频分析节点,需要为后续分镜和生成节点提供结构化输出。",
|
||||
@ -504,6 +538,9 @@ function buildVideoAnalysisPrompt(params: {
|
||||
`上游参考图数量:${params.imageCount}`,
|
||||
`上游视频数量:${params.videoCount}`,
|
||||
`已提供按时间顺序抽取的视频关键帧数量:${params.frameCount}`,
|
||||
params.durationSeconds
|
||||
? `视频总时长:${Number(params.durationSeconds.toFixed(2))} 秒`
|
||||
: "视频总时长未知,请按关键帧顺序稳健估算。",
|
||||
params.prompt ? `上游脚本/说明:\n${params.prompt}` : "上游没有额外脚本,请重点依据参考画面分析。",
|
||||
"",
|
||||
"请只输出 JSON,不要输出 markdown,不要输出解释。",
|
||||
@ -538,6 +575,9 @@ function buildVideoAnalysisPrompt(params: {
|
||||
"recommendedNextSteps": ["2-4条建议的下游动作"]
|
||||
}`,
|
||||
"analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。",
|
||||
params.durationSeconds
|
||||
? `所有镜头必须从 0 秒连续覆盖到 ${Number(params.durationSeconds.toFixed(2))} 秒,不能重叠、留空或超出视频总时长。`
|
||||
: "镜头时间不能重叠,开始时间、结束时间和时长必须相互一致。",
|
||||
"每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。",
|
||||
"imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
|
||||
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
|
||||
@ -602,8 +642,9 @@ async function downscaleVideoFrameForVision(inputBuffer: Buffer) {
|
||||
|
||||
async function extractVideoFrameDataUrls(videoUrl: string, frameCount = VIDEO_ANALYSIS_FRAME_COUNT) {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-analysis-"));
|
||||
let durationSeconds: number | null = null;
|
||||
try {
|
||||
const durationSeconds = await getVideoDurationSeconds(videoUrl);
|
||||
durationSeconds = await getVideoDurationSeconds(videoUrl);
|
||||
const timestamps = buildVideoFrameTimestamps(durationSeconds, frameCount);
|
||||
const frames = await Promise.all(
|
||||
timestamps.map(async (timestamp, index) => {
|
||||
@ -635,13 +676,13 @@ async function extractVideoFrameDataUrls(videoUrl: string, frameCount = VIDEO_AN
|
||||
return `data:image/jpeg;base64,${processed.toString("base64")}`;
|
||||
}),
|
||||
);
|
||||
return frames.filter(Boolean);
|
||||
return { durationSeconds, frames: frames.filter(Boolean) };
|
||||
} catch (error) {
|
||||
console.warn("[Canvas Workflow Analyze] extract video frames failed:", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
videoUrl,
|
||||
});
|
||||
return [];
|
||||
return { durationSeconds, frames: [] as string[] };
|
||||
} finally {
|
||||
await rm(tempDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
@ -788,10 +829,13 @@ export async function POST(request: Request) {
|
||||
const prompt = normalizeText(parsed.data.prompt);
|
||||
const imageUrls = sanitizeUrls(parsed.data.imageUrls, 4);
|
||||
const videoUrls = sanitizeUrls(parsed.data.videoUrls, 4);
|
||||
const extractedVideoFrames =
|
||||
const extractedVideo =
|
||||
parsed.data.mode === "video-analysis" && videoUrls.length > 0
|
||||
? await extractVideoFrameDataUrls(videoUrls[0], VIDEO_ANALYSIS_FRAME_COUNT)
|
||||
: [];
|
||||
: { durationSeconds: null, frames: [] as string[] };
|
||||
const extractedVideoFrames = extractedVideo.frames;
|
||||
const videoDurationSeconds =
|
||||
extractedVideo.durationSeconds || parsed.data.durationSeconds || null;
|
||||
const preferredModelRef = normalizeText(parsed.data.preferredModelRef || parsed.data.model);
|
||||
if (!prompt && imageUrls.length === 0 && videoUrls.length === 0) {
|
||||
return NextResponse.json(
|
||||
@ -805,60 +849,76 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({
|
||||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const videoCanvasConfig = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const functionDefinition = videoCanvasConfig.functions[parsed.data.mode];
|
||||
if (!videoCanvasConfig.enabled || functionDefinition?.enabled === false) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `${functionDefinition?.label || "当前功能"}已停用` },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
forceEnabled:
|
||||
parsed.data.mode === "video-analysis" && (functionDefinition?.cost || 0) > 0,
|
||||
userId: session.user.id,
|
||||
unitCost: parsed.data.mode === "video-analysis" ? functionDefinition?.cost || 0 : 0,
|
||||
reason: parsed.data.mode,
|
||||
note: functionDefinition?.label || "视频画布工作流分析",
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const [allModels, agentModel] = await Promise.all([
|
||||
getEnabledAiModelsWithProvider(false),
|
||||
getAgentAiModelWithProvider(),
|
||||
]);
|
||||
const compatibleModels = allModels.filter(
|
||||
(item) => !item.model.isSubModel && item.model.isEnabled
|
||||
);
|
||||
const compatibleModels = allModels.filter(isAnalysisModelCandidate);
|
||||
const hasVisionInputs = imageUrls.length > 0 || extractedVideoFrames.length > 0;
|
||||
const requestCandidates = hasVisionInputs
|
||||
? compatibleModels.filter((item) => supportsVision(item.model))
|
||||
: compatibleModels;
|
||||
|
||||
const preferredModel =
|
||||
preferredModelRef
|
||||
? compatibleModels.find(
|
||||
? requestCandidates.find(
|
||||
(item) =>
|
||||
item.model.id === preferredModelRef ||
|
||||
item.model.modelId === preferredModelRef,
|
||||
)
|
||||
: null;
|
||||
const compatibleAgentModel =
|
||||
agentModel &&
|
||||
isAnalysisModelCandidate(agentModel) &&
|
||||
(!hasVisionInputs || supportsVision(agentModel.model))
|
||||
? agentModel
|
||||
: null;
|
||||
const orderedCandidates = [
|
||||
preferredModel,
|
||||
compatibleAgentModel,
|
||||
...requestCandidates,
|
||||
].filter(
|
||||
(candidate, index, items): candidate is ModelCandidate =>
|
||||
Boolean(candidate) &&
|
||||
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
|
||||
);
|
||||
|
||||
const hasVisionInputs = imageUrls.length > 0 || extractedVideoFrames.length > 0;
|
||||
|
||||
let selectedModel =
|
||||
preferredModel && (!hasVisionInputs || supportsVision(preferredModel.model))
|
||||
? preferredModel
|
||||
: hasVisionInputs &&
|
||||
agentModel &&
|
||||
supportsVision(agentModel.model)
|
||||
? agentModel
|
||||
: hasVisionInputs
|
||||
? compatibleModels.find((item) => supportsVision(item.model))
|
||||
: agentModel || compatibleModels[0];
|
||||
|
||||
if (!selectedModel) {
|
||||
selectedModel = compatibleModels[0];
|
||||
}
|
||||
|
||||
if (!selectedModel) {
|
||||
if (orderedCandidates.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "当前站点没有可用的分析模型" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const useVision = hasVisionInputs && supportsVision(selectedModel.model);
|
||||
const codingModelId = getAiModelAgentCodingModelId(selectedModel.model);
|
||||
const model = await getLanguageModelById(selectedModel.model.id, codingModelId);
|
||||
if (!model) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "分析模型初始化失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const promptText =
|
||||
parsed.data.mode === "video-analysis"
|
||||
? buildVideoAnalysisPrompt({
|
||||
? buildVideoAnalysisPrompt({
|
||||
durationSeconds: videoDurationSeconds,
|
||||
frameCount: extractedVideoFrames.length,
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
@ -885,7 +945,7 @@ export async function POST(request: Request) {
|
||||
videoCount: videoUrls.length,
|
||||
});
|
||||
|
||||
const inlinedImageReferences = useVision
|
||||
const inlinedImageReferences = hasVisionInputs
|
||||
? (
|
||||
await inlineVisionImages(imageUrls, {
|
||||
maxImages: 4,
|
||||
@ -896,26 +956,79 @@ export async function POST(request: Request) {
|
||||
})
|
||||
).inlineDataUrls
|
||||
: [];
|
||||
const referenceImages = useVision
|
||||
? [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6)
|
||||
: [];
|
||||
const referenceImages = [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6);
|
||||
let generatedResult: {
|
||||
candidate: ModelCandidate;
|
||||
parsedJson: Record<string, unknown>;
|
||||
text: string;
|
||||
} | null = null;
|
||||
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: promptText },
|
||||
...referenceImages.map((image) => ({ type: "image" as const, image })),
|
||||
for (const candidate of orderedCandidates.slice(0, 3)) {
|
||||
try {
|
||||
const model = await getLanguageModelById(
|
||||
candidate.model.id,
|
||||
getAiModelAgentCodingModelId(candidate.model),
|
||||
);
|
||||
if (!model) continue;
|
||||
const { text } = await generateText({
|
||||
abortSignal: AbortSignal.timeout(55_000),
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: promptText },
|
||||
...referenceImages.map((image) => ({ type: "image" as const, image })),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
maxTokens: parsed.data.mode === "story-script" ? 4200 : parsed.data.mode === "video-analysis" ? 2200 : 1600,
|
||||
} as any);
|
||||
temperature: getCompatibleTextGenerationTemperature(candidate, 0.2),
|
||||
maxTokens:
|
||||
parsed.data.mode === "story-script"
|
||||
? 4200
|
||||
: parsed.data.mode === "video-analysis"
|
||||
? 2200
|
||||
: 1600,
|
||||
} as any);
|
||||
const parsedJson = extractJsonBlock<Record<string, unknown>>(text);
|
||||
if (!parsedJson) throw new Error("分析模型没有返回有效 JSON");
|
||||
if (
|
||||
parsed.data.mode === "video-analysis" &&
|
||||
normalizeVideoAnalysisRows(
|
||||
parsedJson.analysisRows,
|
||||
extractedVideoFrames,
|
||||
videoDurationSeconds,
|
||||
).length === 0
|
||||
) {
|
||||
throw new Error("分析模型没有返回镜头数据");
|
||||
}
|
||||
generatedResult = { candidate, parsedJson, text };
|
||||
break;
|
||||
} catch (error) {
|
||||
logger.warn("workflow analysis model candidate failed", {
|
||||
error,
|
||||
modelId: candidate.model.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
|
||||
if (!generatedResult) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "分析模型暂时不可用,请稍后重试" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const { candidate: selectedModel, parsedJson, text } = generatedResult;
|
||||
const chargeResult = await pointsCharge.consume({
|
||||
modelId: selectedModel.model.modelId,
|
||||
});
|
||||
if (pointsCharge.chargeable && chargeResult?.ok === false) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "积分扣除失败,请刷新余额后重试" },
|
||||
{ status: 402 },
|
||||
);
|
||||
}
|
||||
const summary =
|
||||
normalizeText(parsedJson.summary) ||
|
||||
normalizeText(text).slice(0, 160);
|
||||
@ -924,6 +1037,7 @@ export async function POST(request: Request) {
|
||||
const analysisRows = normalizeVideoAnalysisRows(
|
||||
parsedJson.analysisRows,
|
||||
extractedVideoFrames,
|
||||
videoDurationSeconds,
|
||||
);
|
||||
const shotIdeas =
|
||||
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
|
||||
|
||||
@ -83,10 +83,6 @@ async function readLocalFallbackFavicon() {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
if (process.env.CANVAS_E2E_LOCAL === "true") {
|
||||
return await readLocalFallbackFavicon();
|
||||
}
|
||||
|
||||
const preferDark = requestUrl.searchParams.get("theme") === "dark";
|
||||
const settings = await getSiteSettings();
|
||||
const configuredFavicon = String(settings?.favicon || "").trim();
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type AccessLogSeverity,
|
||||
recordAccessIpEvent,
|
||||
} from "@/lib/security/access-log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 10;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
eventType: z.string().trim().max(32).optional(),
|
||||
ip: z.string().trim().max(128).optional().nullable(),
|
||||
userId: z.string().trim().uuid().optional().nullable(),
|
||||
userEmail: z.string().trim().max(128).optional().nullable(),
|
||||
userRole: z.string().trim().max(32).optional().nullable(),
|
||||
method: z.string().trim().max(12).optional().nullable(),
|
||||
path: z.string().trim().max(256).optional().nullable(),
|
||||
routeGroup: z.string().trim().max(64).optional().nullable(),
|
||||
statusCode: z.coerce.number().int().min(100).max(599).optional().nullable(),
|
||||
outcome: z.string().trim().max(32).optional().nullable(),
|
||||
severity: z.enum(["info", "watch", "high", "critical"]).optional(),
|
||||
reasons: z.array(z.string().trim().max(80)).max(12).optional(),
|
||||
userAgent: z.string().trim().max(512).optional().nullable(),
|
||||
referer: z.string().trim().max(512).optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
occurredAt: z.union([z.string(), z.number(), z.date()]).optional(),
|
||||
});
|
||||
|
||||
const suspiciousPathPatterns = [
|
||||
/\.env/i,
|
||||
/\/\.git/i,
|
||||
/\/wp-admin/i,
|
||||
/\/phpmyadmin/i,
|
||||
/\/actuator/i,
|
||||
/\/vendor\/phpunit/i,
|
||||
/\/eval-stdin\.php/i,
|
||||
/\/containers\/json/i,
|
||||
/\/index\.php/i,
|
||||
];
|
||||
|
||||
const suspiciousUserAgentPatterns = [
|
||||
/libredtail|masscan|zgrab|sqlmap|nikto|acunetix|nessus/i,
|
||||
/wget|curl|python-requests|go-http-client/i,
|
||||
/nuclei|crawlergo|httpx/i,
|
||||
];
|
||||
|
||||
function pushReason(reasons: string[], reason: string) {
|
||||
if (!reasons.includes(reason)) reasons.push(reason);
|
||||
}
|
||||
|
||||
function rankSeverity(value: AccessLogSeverity) {
|
||||
return { info: 0, watch: 1, high: 2, critical: 3 }[value];
|
||||
}
|
||||
|
||||
function maxSeverity(
|
||||
current: AccessLogSeverity,
|
||||
next: AccessLogSeverity
|
||||
): AccessLogSeverity {
|
||||
return rankSeverity(next) > rankSeverity(current) ? next : current;
|
||||
}
|
||||
|
||||
function classifyPayload(
|
||||
payload: z.infer<typeof payloadSchema>
|
||||
): { severity: AccessLogSeverity; reasons: string[] } {
|
||||
let severity: AccessLogSeverity = payload.severity || "info";
|
||||
const reasons = [...(payload.reasons || [])];
|
||||
const path = payload.path || "";
|
||||
const userAgent = payload.userAgent || "";
|
||||
const statusCode = payload.statusCode || 0;
|
||||
const outcome = payload.outcome || "allowed";
|
||||
|
||||
if (outcome === "rate_limited") {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "触发访问频率限制");
|
||||
}
|
||||
|
||||
if (outcome === "unauthorized" || statusCode === 401 || statusCode === 403) {
|
||||
severity = maxSeverity(severity, "watch");
|
||||
pushReason(reasons, "出现未授权访问");
|
||||
}
|
||||
|
||||
if (statusCode === 429) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "返回 429 限流");
|
||||
} else if (statusCode >= 500) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "触发服务端错误");
|
||||
} else if (statusCode >= 400) {
|
||||
severity = maxSeverity(severity, "watch");
|
||||
pushReason(reasons, "返回 4xx 异常状态");
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/auth") && statusCode >= 400) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "认证接口失败");
|
||||
}
|
||||
|
||||
if (suspiciousPathPatterns.some((pattern) => pattern.test(path))) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "命中敏感探测路径");
|
||||
}
|
||||
|
||||
if (suspiciousUserAgentPatterns.some((pattern) => pattern.test(userAgent))) {
|
||||
severity = maxSeverity(severity, "watch");
|
||||
pushReason(reasons, "User-Agent 异常");
|
||||
}
|
||||
|
||||
if (reasons.includes("静态黑名单命中但未封禁")) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
}
|
||||
|
||||
return {
|
||||
severity,
|
||||
reasons: Array.from(new Set(reasons)).slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedSecret() {
|
||||
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const expectedSecret = getExpectedSecret();
|
||||
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
|
||||
|
||||
if (!expectedSecret || providedSecret !== expectedSecret) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const raw = await request.json().catch(() => null);
|
||||
const parsed = payloadSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const classification = classifyPayload(parsed.data);
|
||||
await recordAccessIpEvent({
|
||||
...parsed.data,
|
||||
severity: classification.severity,
|
||||
reasons: classification.reasons,
|
||||
}).catch((error) => {
|
||||
console.warn("[AccessIpEvent] failed to record access event", error);
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { accessIpBlocklist } from "@/lib/db/schema";
|
||||
import { matchManualIpBlock } from "@/lib/security/manual-ip-blocklist";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 5;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
ip: z.string().trim().max(128).optional().nullable(),
|
||||
});
|
||||
|
||||
const BLOCKLIST_UNAVAILABLE_CACHE_MS = 5 * 60 * 1000;
|
||||
|
||||
let blocklistUnavailableUntil = 0;
|
||||
let blocklistMissingTableWarned = false;
|
||||
|
||||
function getExpectedSecret() {
|
||||
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown) {
|
||||
const directCode =
|
||||
error && typeof error === "object"
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
if (typeof directCode === "string") return directCode;
|
||||
|
||||
const cause =
|
||||
error && typeof error === "object"
|
||||
? (error as { cause?: unknown }).cause
|
||||
: null;
|
||||
const causeCode =
|
||||
cause && typeof cause === "object"
|
||||
? (cause as { code?: unknown }).code
|
||||
: null;
|
||||
return typeof causeCode === "string" ? causeCode : null;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
const messages: string[] = [];
|
||||
if (error instanceof Error) messages.push(error.message);
|
||||
|
||||
const cause =
|
||||
error && typeof error === "object"
|
||||
? (error as { cause?: unknown }).cause
|
||||
: null;
|
||||
if (cause instanceof Error) messages.push(cause.message);
|
||||
|
||||
if (!messages.length) messages.push(String(error));
|
||||
return messages.join("\n");
|
||||
}
|
||||
|
||||
function isMissingAccessIpBlocklistTableError(error: unknown) {
|
||||
if (getErrorCode(error) === "42P01") return true;
|
||||
|
||||
const message = getErrorMessage(error);
|
||||
return (
|
||||
message.includes('relation "AccessIpBlocklist" does not exist') ||
|
||||
(message.includes("AccessIpBlocklist") && message.includes("does not exist"))
|
||||
);
|
||||
}
|
||||
|
||||
function markBlocklistUnavailable(error: unknown) {
|
||||
blocklistUnavailableUntil = Date.now() + BLOCKLIST_UNAVAILABLE_CACHE_MS;
|
||||
if (blocklistMissingTableWarned) return;
|
||||
|
||||
blocklistMissingTableWarned = true;
|
||||
console.warn(
|
||||
"[AccessIpBlocklist] table is unavailable; manual IP block checks will be skipped temporarily",
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const expectedSecret = getExpectedSecret();
|
||||
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
|
||||
|
||||
if (!expectedSecret || providedSecret !== expectedSecret) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const parsed = payloadSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success || !parsed.data.ip) {
|
||||
return NextResponse.json({ blocked: false });
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.MANUAL_IP_BLOCKLIST_ENABLED === "false" ||
|
||||
Date.now() < blocklistUnavailableUntil
|
||||
) {
|
||||
return NextResponse.json({ blocked: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const entries = await db
|
||||
.select({
|
||||
id: accessIpBlocklist.id,
|
||||
ip: accessIpBlocklist.ip,
|
||||
reason: accessIpBlocklist.reason,
|
||||
expiresAt: accessIpBlocklist.expiresAt,
|
||||
})
|
||||
.from(accessIpBlocklist)
|
||||
.where(
|
||||
and(
|
||||
eq(accessIpBlocklist.isActive, true),
|
||||
or(
|
||||
isNull(accessIpBlocklist.expiresAt),
|
||||
gt(accessIpBlocklist.expiresAt, now)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1000);
|
||||
|
||||
const activeEntries = entries.filter((entry) => entry.ip && entry.id);
|
||||
const matched = matchManualIpBlock(parsed.data.ip, activeEntries);
|
||||
|
||||
return NextResponse.json({
|
||||
blocked: Boolean(matched),
|
||||
entry: matched
|
||||
? {
|
||||
id: matched.id,
|
||||
ip: matched.ip,
|
||||
reason: matched.reason || null,
|
||||
expiresAt: matched.expiresAt || null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMissingAccessIpBlocklistTableError(error)) {
|
||||
markBlocklistUnavailable(error);
|
||||
return NextResponse.json({ blocked: false });
|
||||
}
|
||||
console.warn("[AccessIpBlocklist] internal check failed", error);
|
||||
return NextResponse.json({ blocked: false });
|
||||
}
|
||||
}
|
||||
@ -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,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;
|
||||
@ -1,15 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import sharp from "sharp";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import sharp from "sharp";
|
||||
import { readTrustedLocalImage } from "@/lib/video-canvas/local-image-input";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 30;
|
||||
@ -27,12 +27,12 @@ interface PickObjectRequest {
|
||||
imageData: string; // 带红色标记的图片 base64
|
||||
clickX: number; // 点击位置 X (0-1)
|
||||
clickY: number; // 点击位置 Y (0-1)
|
||||
mode?: "object" | "video-canvas-mark";
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
interface PickObjectResult {
|
||||
name: string;
|
||||
labels?: string[];
|
||||
labels: string[];
|
||||
position: string;
|
||||
confidence: number;
|
||||
boundingBox: {
|
||||
@ -80,7 +80,7 @@ function ensureImageSizeAllowed(byteLength: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function downscaleImageForVision(buffer: Buffer) {
|
||||
async function prepareImageForVision(buffer: Buffer, clickX: number, clickY: number) {
|
||||
const source = sharp(buffer, { failOn: "none" });
|
||||
const meta = await source.metadata();
|
||||
const hasAlpha = Boolean(meta.hasAlpha);
|
||||
@ -96,85 +96,50 @@ async function downscaleImageForVision(buffer: Buffer) {
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
|
||||
if (hasAlpha) {
|
||||
return {
|
||||
buffer: await resized.png({ compressionLevel: 9 }).toBuffer(),
|
||||
ext: "png",
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: await resized.jpeg({ quality: 84, mozjpeg: true }).toBuffer(),
|
||||
ext: "jpg",
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadVisionAnalysisImage(buffer: Buffer, filenamePrefix: string) {
|
||||
const { buffer: processedBuffer, ext, width, height } = await downscaleImageForVision(buffer);
|
||||
const filename = `${filenamePrefix}-${Date.now()}.${ext}`;
|
||||
const uploaded = await uploadFile(filename, processedBuffer as any);
|
||||
const resizedBuffer = hasAlpha
|
||||
? await resized.png({ compressionLevel: 9 }).toBuffer()
|
||||
: await resized.jpeg({ quality: 84, mozjpeg: true }).toBuffer();
|
||||
const resizedMeta = await sharp(resizedBuffer).metadata();
|
||||
const resizedWidth = resizedMeta.width || MAX_VISION_SIDE;
|
||||
const resizedHeight = resizedMeta.height || MAX_VISION_SIDE;
|
||||
const markerRadius = Math.max(10, Math.round(Math.min(resizedWidth, resizedHeight) * 0.035));
|
||||
const markerX = Math.round(Math.max(0, Math.min(1, clickX)) * resizedWidth);
|
||||
const markerY = Math.round(Math.max(0, Math.min(1, clickY)) * resizedHeight);
|
||||
const markerSvg = Buffer.from(
|
||||
`<svg width="${resizedWidth}" height="${resizedHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="${markerX}" cy="${markerY}" r="${markerRadius}" fill="none" stroke="#ff2d2d" stroke-width="${Math.max(4, Math.round(markerRadius * 0.28))}" />
|
||||
<line x1="${markerX - markerRadius}" y1="${markerY}" x2="${markerX + markerRadius}" y2="${markerY}" stroke="#ff2d2d" stroke-width="3" />
|
||||
<line x1="${markerX}" y1="${markerY - markerRadius}" x2="${markerX}" y2="${markerY + markerRadius}" stroke="#ff2d2d" stroke-width="3" />
|
||||
</svg>`,
|
||||
);
|
||||
const outputBuffer = await sharp(resizedBuffer)
|
||||
.composite([{ input: markerSvg, left: 0, top: 0 }])
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
logger.info("vision image prepared", {
|
||||
originalWidth: width,
|
||||
originalHeight: height,
|
||||
maxSide: MAX_VISION_SIDE,
|
||||
outputExt: ext,
|
||||
outputBytes: processedBuffer.length,
|
||||
outputExt: "png",
|
||||
outputBytes: outputBuffer.length,
|
||||
});
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
async function prepareMarkedVisionAnalysisImage(
|
||||
buffer: Buffer,
|
||||
clickX: number,
|
||||
clickY: number
|
||||
) {
|
||||
const prepared = await downscaleImageForVision(buffer);
|
||||
const metadata = await sharp(prepared.buffer, { failOn: "none" }).metadata();
|
||||
const width = Math.max(1, metadata.width || prepared.width || MAX_VISION_SIDE);
|
||||
const height = Math.max(1, metadata.height || prepared.height || MAX_VISION_SIDE);
|
||||
const markerX = Math.max(0, Math.min(width, clickX * width));
|
||||
const markerY = Math.max(0, Math.min(height, clickY * height));
|
||||
const radius = Math.max(10, Math.min(width, height) * 0.025);
|
||||
const overlay = Buffer.from(`
|
||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="${markerX}" cy="${markerY}" r="${radius + 3}" fill="#fff" />
|
||||
<circle cx="${markerX}" cy="${markerY}" r="${radius}" fill="#f00" />
|
||||
<g stroke="#f00" stroke-width="2">
|
||||
<line x1="${markerX - radius * 2}" y1="${markerY}" x2="${markerX - radius - 2}" y2="${markerY}" />
|
||||
<line x1="${markerX + radius + 2}" y1="${markerY}" x2="${markerX + radius * 2}" y2="${markerY}" />
|
||||
<line x1="${markerX}" y1="${markerY - radius * 2}" x2="${markerX}" y2="${markerY - radius - 2}" />
|
||||
<line x1="${markerX}" y1="${markerY + radius + 2}" x2="${markerX}" y2="${markerY + radius * 2}" />
|
||||
</g>
|
||||
</svg>
|
||||
`);
|
||||
const composited = sharp(prepared.buffer, { failOn: "none" }).composite([
|
||||
{ input: overlay, left: 0, top: 0 },
|
||||
]);
|
||||
const markedBuffer =
|
||||
prepared.ext === "png"
|
||||
? await composited.png({ compressionLevel: 9 }).toBuffer()
|
||||
: await composited.jpeg({ quality: 84, mozjpeg: true }).toBuffer();
|
||||
logger.info("marked vision image prepared", {
|
||||
clickX,
|
||||
clickY,
|
||||
outputBytes: markedBuffer.length,
|
||||
outputExt: prepared.ext,
|
||||
});
|
||||
const mimeType = prepared.ext === "png" ? "image/png" : "image/jpeg";
|
||||
return `data:${mimeType};base64,${markedBuffer.toString("base64")}`;
|
||||
return `data:image/png;base64,${outputBuffer.toString("base64")}`;
|
||||
}
|
||||
|
||||
async function normalizeImageInput(
|
||||
imageData: string,
|
||||
options?: { clickX: number; clickY: number; markPoint: boolean }
|
||||
clickX: number,
|
||||
clickY: number,
|
||||
): Promise<string> {
|
||||
const raw = String(imageData || "").trim();
|
||||
if (!raw) throw new Error("缺少图片数据");
|
||||
|
||||
const localImage = await readTrustedLocalImage(raw);
|
||||
if (localImage) {
|
||||
ensureImageSizeAllowed(localImage.byteLength);
|
||||
return prepareImageForVision(localImage, clickX, clickY);
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
const url = new URL(raw);
|
||||
if (!["http:", "https:"].includes(url.protocol) || isBlockedImageHost(url.hostname)) {
|
||||
@ -197,10 +162,7 @@ async function normalizeImageInput(
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
ensureImageSizeAllowed(arrayBuffer.byteLength);
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
return options?.markPoint
|
||||
? prepareMarkedVisionAnalysisImage(buffer, options.clickX, options.clickY)
|
||||
: uploadVisionAnalysisImage(buffer, "pick-object-url");
|
||||
return prepareImageForVision(Buffer.from(arrayBuffer), clickX, clickY);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@ -215,9 +177,7 @@ async function normalizeImageInput(
|
||||
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
|
||||
const buffer = Buffer.from(base64Body, "base64");
|
||||
ensureImageSizeAllowed(buffer.byteLength);
|
||||
return options?.markPoint
|
||||
? prepareMarkedVisionAnalysisImage(buffer, options.clickX, options.clickY)
|
||||
: uploadVisionAnalysisImage(buffer, "pick-object-base64");
|
||||
return prepareImageForVision(buffer, clickX, clickY);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@ -231,7 +191,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body: PickObjectRequest = await request.json();
|
||||
const { imageData, clickX, clickY, mode = "object" } = body;
|
||||
const { imageData, clickX, clickY } = body;
|
||||
|
||||
logger.info("request received", {
|
||||
imageDataLength: imageData?.length,
|
||||
@ -261,34 +221,19 @@ export async function POST(request: Request) {
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedImage = await normalizeImageInput(imageData, {
|
||||
clickX,
|
||||
clickY,
|
||||
markPoint: mode === "video-canvas-mark",
|
||||
});
|
||||
const normalizedImage = await normalizeImageInput(imageData, clickX, clickY);
|
||||
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
|
||||
() => null,
|
||||
);
|
||||
const config = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const definition = config.functions["element-mark"];
|
||||
|
||||
logger.info("vision model selected", { model: "qwen3-vl" });
|
||||
logger.info("vision model selected", { model: definition?.model || "qwen3-vl-plus" });
|
||||
|
||||
const videoCanvasConfig =
|
||||
mode === "video-canvas-mark"
|
||||
? normalizeVideoCanvasFunctionConfig(
|
||||
(
|
||||
await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
|
||||
() => null
|
||||
)
|
||||
)?.value || defaultVideoCanvasFunctionConfig
|
||||
)
|
||||
: null;
|
||||
const elementMarkDefinition = videoCanvasConfig?.functions["element-mark"];
|
||||
if (mode === "video-canvas-mark" && elementMarkDefinition?.enabled === false) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "视频元素标记功能当前未启用" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// AI 通过红色标记识别用户点中的主体;视频画布规则来自系统设置。
|
||||
const defaultObjectPrompt = `
|
||||
// 构建 prompt - AI 通过红色标记来识别用户选择的物品
|
||||
const fallbackPrompt = `
|
||||
图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。
|
||||
|
||||
【重要】
|
||||
@ -311,24 +256,17 @@ export async function POST(request: Request) {
|
||||
【position 格式】固定4字,从以下选择:
|
||||
${POSITION_OPTIONS.join('、')}
|
||||
`.trim();
|
||||
const prompt =
|
||||
mode === "video-canvas-mark"
|
||||
? String(
|
||||
elementMarkDefinition?.prompt ||
|
||||
defaultVideoCanvasFunctionConfig.functions["element-mark"]?.prompt ||
|
||||
defaultObjectPrompt
|
||||
).replaceAll("{{positionOptions}}", POSITION_OPTIONS.join("、"))
|
||||
: defaultObjectPrompt;
|
||||
const prompt = String(definition?.prompt || fallbackPrompt).replace(
|
||||
"{{positionOptions}}",
|
||||
POSITION_OPTIONS.join("、"),
|
||||
);
|
||||
|
||||
logger.info("vision model call started");
|
||||
|
||||
// 调用视觉模型,发送带红色标记的图片
|
||||
const { text: responseText, model } = await callQwen3Vl({
|
||||
imageUrl: normalizedImage,
|
||||
model:
|
||||
mode === "video-canvas-mark"
|
||||
? elementMarkDefinition?.model || videoCanvasConfig?.defaultMultimodalModel
|
||||
: undefined,
|
||||
modelIds: definition?.model ? [definition.model] : undefined,
|
||||
prompt,
|
||||
temperature: 0.1,
|
||||
maxTokens: 300,
|
||||
@ -357,17 +295,7 @@ ${POSITION_OPTIONS.join('、')}
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
// 验证和修正名称
|
||||
const labels =
|
||||
mode === "video-canvas-mark" && Array.isArray(parsed.labels)
|
||||
? Array.from(
|
||||
new Set(
|
||||
parsed.labels
|
||||
.map((item: unknown) => String(item || "").trim().slice(0, 8))
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, 4)
|
||||
: [];
|
||||
let name = labels[0] || parsed.name || '物品';
|
||||
let name = parsed.name || '物品';
|
||||
if (name.length > 8) {
|
||||
name = name.substring(0, 6);
|
||||
}
|
||||
@ -393,7 +321,13 @@ ${POSITION_OPTIONS.join('、')}
|
||||
|
||||
result = {
|
||||
name,
|
||||
labels: mode === "video-canvas-mark" ? (labels.length ? labels : [name]) : undefined,
|
||||
labels: Array.from(
|
||||
new Set<string>(
|
||||
(Array.isArray(parsed.labels) ? parsed.labels : [name])
|
||||
.map((label: unknown) => String(label || "").trim().slice(0, 8))
|
||||
.filter(Boolean),
|
||||
),
|
||||
).slice(0, 4),
|
||||
position,
|
||||
confidence,
|
||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
|
||||
@ -414,7 +348,7 @@ ${POSITION_OPTIONS.join('、')}
|
||||
|
||||
result = {
|
||||
name: '物品',
|
||||
labels: mode === "video-canvas-mark" ? ['物品'] : undefined,
|
||||
labels: ['物品'],
|
||||
position,
|
||||
confidence: 0.5,
|
||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },
|
||||
|
||||
@ -2134,6 +2134,7 @@ async function processSceneWithDiversity(
|
||||
sameModelPendingCount: number;
|
||||
sameModelRunningCount: number;
|
||||
concurrencyLimit: number;
|
||||
dispatchIntervalMs: number;
|
||||
estimatedWaitMs: number;
|
||||
source: "model_queue";
|
||||
}) => void | Promise<void>;
|
||||
@ -2217,12 +2218,13 @@ async function processSceneWithDiversity(
|
||||
onQueued: params.onQueueStatus,
|
||||
});
|
||||
let result: Awaited<ReturnType<typeof processSceneWithProvider>>;
|
||||
const runtimeModelId = resolveModelRuntimeId(candidate.model, {
|
||||
providerType: candidate.provider.type,
|
||||
});
|
||||
await params.onProcessingStart?.();
|
||||
let execution: ReturnType<typeof processSceneWithProvider> | null = null;
|
||||
try {
|
||||
const runtimeModelId = resolveModelRuntimeId(candidate.model, {
|
||||
providerType: candidate.provider.type,
|
||||
});
|
||||
await params.onProcessingStart?.();
|
||||
result = await processSceneWithProvider(
|
||||
execution = processSceneWithProvider(
|
||||
params.productImageUrl,
|
||||
params.sceneImageUrl,
|
||||
params.productName,
|
||||
@ -2246,6 +2248,10 @@ async function processSceneWithDiversity(
|
||||
} finally {
|
||||
await modelSlot.release();
|
||||
}
|
||||
if (!execution) {
|
||||
throw new Error("场景裂变请求未能提交到模型服务");
|
||||
}
|
||||
result = await execution;
|
||||
|
||||
const echoCheck = await detectEchoedProductResult({
|
||||
requestId: params.requestId,
|
||||
|
||||
@ -1020,32 +1020,6 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
const scope = request.nextUrl.searchParams.get("scope") || "";
|
||||
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
|
||||
if (
|
||||
isLocalCanvasE2E &&
|
||||
PUBLIC_SITE_SETTINGS_SCOPES.has(scope)
|
||||
) {
|
||||
const scopedScope = scope as PublicSiteSettingsScope;
|
||||
const data =
|
||||
scopedScope === "branding"
|
||||
? createBrandingSettings(undefined)
|
||||
: {
|
||||
...HOME_SITE_SETTINGS_FALLBACK,
|
||||
canvasEnabled: true,
|
||||
realtimeCanvasEnabled: true,
|
||||
videoCanvasEnabled: true,
|
||||
};
|
||||
|
||||
return createScopedSiteSettingsResponse(
|
||||
scopedScope,
|
||||
data,
|
||||
"FALLBACK",
|
||||
`local-e2e:${scopedScope}`,
|
||||
{ fallback: true, startedAt }
|
||||
);
|
||||
}
|
||||
|
||||
if (PUBLIC_SITE_SETTINGS_SCOPES.has(scope)) {
|
||||
const scopedScope = scope as PublicSiteSettingsScope;
|
||||
const brandName =
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import { getSystemSetting, getUserById } from "@/lib/db/queries";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
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] =
|
||||
await Promise.all([
|
||||
getStyleProImageModelDisplayEntries({
|
||||
includeHidden: allowHiddenModels,
|
||||
}),
|
||||
getStyleProImageModelEntries({ includeHidden: allowHiddenModels }),
|
||||
getStyleProImageModelDisplayEntries({ includeHidden: false }),
|
||||
getStyleProImageModelEntries({ includeHidden: false }),
|
||||
getStyleProImageModelEntries({ includeHidden: true }),
|
||||
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
|
||||
@ -6,6 +6,7 @@ import { model3dAsset, paymentOrder, systemSettings } from "@/lib/db/schema";
|
||||
import {
|
||||
getPointsLedgerByUserId,
|
||||
getPointsSystemConfig,
|
||||
getSystemSetting,
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
import { resolveThreeDModelDisplayName } from "@/lib/model3d/model-alias-config";
|
||||
@ -18,6 +19,12 @@ import {
|
||||
normalizeMembershipUserOverrides,
|
||||
resolveMembershipPlanIdForUser,
|
||||
} from "@/lib/membership/config";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
hasEnabledFixedVideoCanvasPointCosts,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
|
||||
const MEMBERSHIP_CONFIG_KEY = "membership_config";
|
||||
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
|
||||
@ -129,6 +136,7 @@ export async function GET() {
|
||||
paymentOrders,
|
||||
configSetting,
|
||||
overridesSetting,
|
||||
videoCanvasSetting,
|
||||
] =
|
||||
await Promise.all([
|
||||
getUserById({ id: session.user.id }),
|
||||
@ -157,6 +165,7 @@ export async function GET() {
|
||||
.where(eq(systemSettings.key, MEMBERSHIP_USER_OVERRIDES_KEY))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]),
|
||||
getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
|
||||
const threeDModelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||
@ -274,6 +283,11 @@ export async function GET() {
|
||||
].sort((a, b) => {
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
});
|
||||
const videoCanvasConfig = videoCanvasSetting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const fixedVideoCanvasPointsEnabled =
|
||||
hasEnabledFixedVideoCanvasPointCosts(videoCanvasConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
profile: {
|
||||
@ -281,7 +295,7 @@ export async function GET() {
|
||||
email: dbUser?.email || session.user.email || "",
|
||||
currentPlanName: currentPlan.name,
|
||||
currentPlanId,
|
||||
availablePoints: pointsConfig.enabled
|
||||
availablePoints: pointsConfig.enabled || fixedVideoCanvasPointsEnabled
|
||||
? Math.max(0, Number(dbUser?.pointsBalance || 0))
|
||||
: currentPlan.monthlyCredits,
|
||||
monthlyCredits: currentPlan.monthlyCredits,
|
||||
|
||||
@ -6,9 +6,16 @@ import { systemSettings } from "@/lib/db/schema";
|
||||
import {
|
||||
checkUserImageQuota,
|
||||
getPointsSystemConfig,
|
||||
getSystemSetting,
|
||||
getUserById,
|
||||
getUserMonthlyImageCount,
|
||||
} from "@/lib/db/queries";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
hasEnabledFixedVideoCanvasPointCosts,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import {
|
||||
defaultMembershipConfig,
|
||||
defaultMembershipUserOverrides,
|
||||
@ -17,42 +24,27 @@ import {
|
||||
normalizeMembershipUserOverrides,
|
||||
resolveMembershipPlanIdForUser,
|
||||
} from "@/lib/membership/config";
|
||||
import { defaultPointsSystemConfig } from "@/lib/points/config";
|
||||
|
||||
const MEMBERSHIP_CONFIG_KEY = "membership_config";
|
||||
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
|
||||
if (isLocalCanvasE2E) {
|
||||
const currentPlan = getMembershipPlanById(defaultMembershipConfig, "pro");
|
||||
return NextResponse.json({
|
||||
config: defaultMembershipConfig,
|
||||
pointsSystemConfig: defaultPointsSystemConfig,
|
||||
isHighVip: true,
|
||||
currentPlanId: currentPlan.id,
|
||||
currentPlan,
|
||||
summary: {
|
||||
currentCredits: currentPlan.monthlyCredits,
|
||||
dailyFreeCredits: currentPlan.dailyFreeCredits,
|
||||
usedThisMonth: 0,
|
||||
monthlyLimit: currentPlan.monthlyCredits,
|
||||
remainingThisMonth: currentPlan.monthlyCredits,
|
||||
totalImages: 0,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [configSetting, overridesSetting, quota, totalImages, pointsConfig, dbUser] = await Promise.all([
|
||||
const [
|
||||
configSetting,
|
||||
overridesSetting,
|
||||
quota,
|
||||
totalImages,
|
||||
pointsConfig,
|
||||
dbUser,
|
||||
videoCanvasSetting,
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
@ -69,6 +61,7 @@ export async function GET() {
|
||||
getUserMonthlyImageCount(session.user.id),
|
||||
getPointsSystemConfig(),
|
||||
getUserById({ id: session.user.id }),
|
||||
getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
|
||||
const config = configSetting?.value
|
||||
@ -86,7 +79,15 @@ export async function GET() {
|
||||
);
|
||||
const currentPlan = getMembershipPlanById(config, currentPlanId);
|
||||
|
||||
const currentCredits = pointsConfig.enabled
|
||||
const videoCanvasConfig = videoCanvasSetting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const fixedVideoCanvasPointsEnabled =
|
||||
hasEnabledFixedVideoCanvasPointCosts(videoCanvasConfig);
|
||||
const effectivePointsConfig = fixedVideoCanvasPointsEnabled
|
||||
? { ...pointsConfig, enabled: true }
|
||||
: pointsConfig;
|
||||
const currentCredits = effectivePointsConfig.enabled
|
||||
? Math.max(0, Number(dbUser?.pointsBalance || 0))
|
||||
: quota.remaining !== null
|
||||
? Math.max(0, quota.remaining)
|
||||
@ -94,7 +95,7 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json({
|
||||
config,
|
||||
pointsSystemConfig: pointsConfig,
|
||||
pointsSystemConfig: effectivePointsConfig,
|
||||
isHighVip: Boolean(dbUser?.isHighVip),
|
||||
currentPlanId,
|
||||
currentPlan,
|
||||
|
||||
267
app/api/video-canvas/assets/route.ts
Normal file
267
app/api/video-canvas/assets/route.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import { and, desc, eq, ilike, inArray, ne, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { mediaAsset } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||
} from "@/lib/video-canvas/subjects";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const logger = createLogger("video-canvas-assets");
|
||||
const SUPPORTED_MEDIA_TYPES = new Set(["image", "video", "audio", "model"]);
|
||||
const ASSET_CATEGORIES = new Set(["其它", "人物", "场景", "物品", "风格", "音效"]);
|
||||
|
||||
function normalizeAssetIds(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 50),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get("q")?.trim().slice(0, 120) || "";
|
||||
const requestedType = url.searchParams.get("mediaType")?.trim() || "";
|
||||
const mediaType = SUPPORTED_MEDIA_TYPES.has(requestedType)
|
||||
? requestedType
|
||||
: "";
|
||||
const filters = [
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
ne(mediaAsset.mediaType, "subject"),
|
||||
ne(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE),
|
||||
ne(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||
];
|
||||
if (mediaType) filters.push(eq(mediaAsset.mediaType, mediaType));
|
||||
if (query) filters.push(ilike(mediaAsset.displayName, `%${query}%`));
|
||||
|
||||
const assets = await db
|
||||
.select({
|
||||
createdAt: mediaAsset.createdAt,
|
||||
displayName: mediaAsset.displayName,
|
||||
durationMs: mediaAsset.durationMs,
|
||||
id: mediaAsset.id,
|
||||
mediaType: mediaAsset.mediaType,
|
||||
metadata: mediaAsset.metadata,
|
||||
mimeType: mediaAsset.mimeType,
|
||||
originalUrl: mediaAsset.originalUrl,
|
||||
previewUrl: mediaAsset.previewUrl,
|
||||
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||
})
|
||||
.from(mediaAsset)
|
||||
.where(and(...filters))
|
||||
.orderBy(desc(mediaAsset.updatedAt))
|
||||
.limit(100);
|
||||
|
||||
return NextResponse.json({ success: true, assets });
|
||||
} catch (error) {
|
||||
logger.error("asset list failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "资产加载失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
action?: string;
|
||||
name?: string;
|
||||
} | null;
|
||||
if (body?.action !== "create-folder") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "不支持的操作" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const name = String(body.name || "").trim().slice(0, 20);
|
||||
if (!name) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请输入文件夹名称" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const folderUrl = `asset-folder://${crypto.randomUUID()}`;
|
||||
const [folder] = await db
|
||||
.insert(mediaAsset)
|
||||
.values({
|
||||
displayName: name,
|
||||
mediaType: "folder",
|
||||
metadata: { isFolder: true },
|
||||
normalizedUrl: folderUrl,
|
||||
originalUrl: folderUrl,
|
||||
sourceTable: "video_canvas_asset_library",
|
||||
sourceType: "video_canvas_asset_folder",
|
||||
status: "ready",
|
||||
userId: session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, asset: folder });
|
||||
} catch (error) {
|
||||
logger.error("asset folder creation failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "文件夹创建失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
action?: string;
|
||||
assetIds?: unknown;
|
||||
category?: string;
|
||||
folderId?: string | null;
|
||||
} | null;
|
||||
const assetIds = normalizeAssetIds(body?.assetIds);
|
||||
if (!assetIds.length) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请选择资产" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let metadataPatch: Record<string, unknown>;
|
||||
if (body?.action === "update-category") {
|
||||
const category = String(body.category || "").trim();
|
||||
if (!ASSET_CATEGORIES.has(category)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "资产标签无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
metadataPatch = { category };
|
||||
} else if (body?.action === "move-to-folder") {
|
||||
const folderId = String(body.folderId || "").trim() || null;
|
||||
if (folderId) {
|
||||
const [folder] = await db
|
||||
.select({ id: mediaAsset.id })
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.id, folderId),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.mediaType, "folder"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!folder) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "文件夹不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
}
|
||||
metadataPatch = { folderId };
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "不支持的操作" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(mediaAsset)
|
||||
.set({
|
||||
metadata: sql`coalesce(${mediaAsset.metadata}, '{}'::jsonb) || ${JSON.stringify(metadataPatch)}::jsonb`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
inArray(mediaAsset.id, assetIds),
|
||||
ne(mediaAsset.mediaType, "folder"),
|
||||
),
|
||||
)
|
||||
.returning({ id: mediaAsset.id });
|
||||
|
||||
return NextResponse.json({ success: true, updated: updated.length });
|
||||
} catch (error) {
|
||||
logger.error("asset batch update failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "批量更新失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
assetIds?: unknown;
|
||||
} | null;
|
||||
const assetIds = normalizeAssetIds(body?.assetIds);
|
||||
if (!assetIds.length) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请选择资产" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const removed = await db
|
||||
.delete(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
inArray(mediaAsset.id, assetIds),
|
||||
ne(mediaAsset.mediaType, "folder"),
|
||||
),
|
||||
)
|
||||
.returning({ id: mediaAsset.id });
|
||||
|
||||
return NextResponse.json({ success: true, removed: removed.length });
|
||||
} catch (error) {
|
||||
logger.error("asset batch delete failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "批量删除失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,10 +8,6 @@ import {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
if (process.env.CANVAS_E2E_LOCAL === "true") {
|
||||
return Response.json({ config: defaultVideoCanvasFunctionConfig });
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null);
|
||||
const config = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
|
||||
309
app/api/video-canvas/director-ai-import/route.ts
Normal file
309
app/api/video-canvas/director-ai-import/route.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import { generateText } from "ai";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||
import {
|
||||
getAiModelAgentCodingModelId,
|
||||
isAiModelMultimodal,
|
||||
} from "@/lib/db/ai-model-flags";
|
||||
import {
|
||||
getAgentAiModelWithProvider,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getSystemSetting,
|
||||
} from "@/lib/db/queries";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 90;
|
||||
|
||||
const logger = createLogger("video-canvas-director-ai-import");
|
||||
|
||||
const requestSchema = z.object({
|
||||
imageUrl: z.string().trim().min(1).max(8_000_000),
|
||||
mode: z.enum(["insert", "overwrite"]),
|
||||
});
|
||||
|
||||
type ModelCandidate = Awaited<
|
||||
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||
>[number];
|
||||
|
||||
const BODY_TYPES = new Set([
|
||||
"mannequin",
|
||||
"female",
|
||||
"broad",
|
||||
"muscular",
|
||||
"slim",
|
||||
"teen",
|
||||
"child",
|
||||
"chibi",
|
||||
]);
|
||||
const GEOMETRY_TYPES = new Set([
|
||||
"box",
|
||||
"sphere",
|
||||
"cylinder",
|
||||
"torus",
|
||||
"cone",
|
||||
"pyramid",
|
||||
]);
|
||||
|
||||
function supportsVision(candidate: ModelCandidate) {
|
||||
return (
|
||||
candidate.model.supportsVision === true ||
|
||||
isAiModelMultimodal({
|
||||
capabilities: candidate.model.capabilities,
|
||||
defaultParams: candidate.model.defaultParams,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeModelRef(value: unknown) {
|
||||
const candidate = String(value || "").trim();
|
||||
return candidate && candidate !== "auto" ? candidate : "";
|
||||
}
|
||||
|
||||
function extractJson(value: unknown) {
|
||||
const match = String(value || "").match(/\{[\s\S]*\}/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(value: unknown, fallback: number, min: number, max: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed)
|
||||
? Number(Math.min(max, Math.max(min, parsed)).toFixed(4))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeVector(
|
||||
value: unknown,
|
||||
fallback: [number, number, number],
|
||||
min = -20,
|
||||
max = 20,
|
||||
): [number, number, number] {
|
||||
if (!Array.isArray(value) || value.length < 3) return fallback;
|
||||
return [
|
||||
clampNumber(value[0], fallback[0], min, max),
|
||||
clampNumber(value[1], fallback[1], min, max),
|
||||
clampNumber(value[2], fallback[2], min, max),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeColor(value: unknown, fallback: string) {
|
||||
const candidate = String(value || "").trim();
|
||||
return /^#[0-9a-f]{6}$/i.test(candidate) ? candidate : fallback;
|
||||
}
|
||||
|
||||
function normalizeName(value: unknown, fallback: string) {
|
||||
return String(value || fallback).trim().slice(0, 80) || fallback;
|
||||
}
|
||||
|
||||
function normalizeResult(value: Record<string, unknown>) {
|
||||
const rawCharacters = Array.isArray(value.characters) ? value.characters : [];
|
||||
const rawProps = Array.isArray(value.props) ? value.props : [];
|
||||
const rawCameras = Array.isArray(value.cameras) ? value.cameras : [];
|
||||
|
||||
return {
|
||||
summary: String(value.summary || "").trim().slice(0, 600),
|
||||
characters: rawCharacters.slice(0, 8).flatMap((item, index) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||
const record = item as Record<string, unknown>;
|
||||
const bodyType = String(record.bodyType || "mannequin");
|
||||
return [{
|
||||
bodyType: BODY_TYPES.has(bodyType) ? bodyType : "mannequin",
|
||||
color: normalizeColor(record.color, ["#7AA7FF", "#E56C5B", "#6CDB7A"][index % 3]!),
|
||||
name: normalizeName(record.name, `角色${String.fromCharCode(65 + index)}`),
|
||||
position: normalizeVector(record.position, [0, 0, index * 1.2]),
|
||||
}];
|
||||
}),
|
||||
props: rawProps.slice(0, 12).flatMap((item, index) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||
const record = item as Record<string, unknown>;
|
||||
const geometryType = String(record.geometryType || "box");
|
||||
return [{
|
||||
color: normalizeColor(record.color, "#d7e7ff"),
|
||||
geometryType: GEOMETRY_TYPES.has(geometryType) ? geometryType : "box",
|
||||
name: normalizeName(record.name, `道具${index + 1}`),
|
||||
position: normalizeVector(record.position, [0, 0, 0]),
|
||||
scale: normalizeVector(record.scale, [1, 1, 1], 0.05, 20),
|
||||
}];
|
||||
}),
|
||||
cameras: rawCameras.slice(0, 4).flatMap((item, index) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||
const record = item as Record<string, unknown>;
|
||||
return [{
|
||||
fov: clampNumber(record.fov, 50, 10, 120),
|
||||
name: normalizeName(record.name, `机位${index + 1}`),
|
||||
position: normalizeVector(record.position, [0, 2.2, 8 + index * 1.5], -50, 50),
|
||||
target: normalizeVector(record.target, [0, 1.2, 0], -50, 50),
|
||||
}];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let imageUrl = parsed.data.imageUrl;
|
||||
if (!imageUrl.startsWith("data:image/")) {
|
||||
try {
|
||||
const resolved = new URL(imageUrl, request.url);
|
||||
imageUrl = resolved.protocol === "http:" || resolved.protocol === "https:"
|
||||
? resolved.toString()
|
||||
: "";
|
||||
} catch {
|
||||
imageUrl = "";
|
||||
}
|
||||
}
|
||||
if (!imageUrl) {
|
||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null);
|
||||
const config = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const aiImportConfig = config.directorDesk.aiImport;
|
||||
const definition = config.functions[aiImportConfig.functionKey];
|
||||
if (!config.enabled || !aiImportConfig.enabled || !definition?.enabled) {
|
||||
return NextResponse.json({ success: false, error: "AI 识图导入已停用" }, { status: 403 });
|
||||
}
|
||||
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: aiImportConfig.cost,
|
||||
reason: "director-ai-import",
|
||||
note: "3D导演台 AI 识图导入",
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const [models, agentModel] = await Promise.all([
|
||||
getEnabledAiModelsWithProvider(false),
|
||||
getAgentAiModelWithProvider(),
|
||||
]);
|
||||
const compatible = models.filter(
|
||||
(candidate) =>
|
||||
candidate.model.isEnabled &&
|
||||
candidate.provider.isEnabled &&
|
||||
!candidate.model.isSubModel &&
|
||||
candidate.model.healthStatus !== "auto_disabled" &&
|
||||
supportsVision(candidate),
|
||||
);
|
||||
const preferredRef =
|
||||
normalizeModelRef(definition.model) || normalizeModelRef(config.defaultMultimodalModel);
|
||||
const preferred = preferredRef
|
||||
? compatible.find(
|
||||
(candidate) =>
|
||||
candidate.model.id === preferredRef ||
|
||||
candidate.model.modelId === preferredRef,
|
||||
)
|
||||
: null;
|
||||
const ordered = [
|
||||
preferred,
|
||||
agentModel && supportsVision(agentModel) ? agentModel : null,
|
||||
...compatible,
|
||||
].filter(
|
||||
(candidate, index, items): candidate is ModelCandidate =>
|
||||
Boolean(candidate) &&
|
||||
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
|
||||
);
|
||||
if (ordered.length === 0) {
|
||||
return NextResponse.json({ success: false, error: "当前站点没有可用的多模态模型" }, { status: 500 });
|
||||
}
|
||||
|
||||
const referenceImages = imageUrl.startsWith("data:image/")
|
||||
? [imageUrl]
|
||||
: (
|
||||
await inlineVisionImages([imageUrl], {
|
||||
maxImages: 1,
|
||||
maxSide: 1280,
|
||||
timeoutMs: 15_000,
|
||||
retries: 1,
|
||||
loggerPrefix: "[Director AI Import]",
|
||||
})
|
||||
).inlineDataUrls;
|
||||
if (referenceImages.length === 0) {
|
||||
return NextResponse.json({ success: false, error: "识图素材读取失败" }, { status: 400 });
|
||||
}
|
||||
|
||||
for (const candidate of ordered.slice(0, 3)) {
|
||||
try {
|
||||
const model = await getLanguageModelById(
|
||||
candidate.model.id,
|
||||
getAiModelAgentCodingModelId(candidate.model),
|
||||
);
|
||||
if (!model) continue;
|
||||
const { text } = await generateText({
|
||||
abortSignal: AbortSignal.timeout(45_000),
|
||||
maxTokens: 2600,
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${definition.prompt}\n\n导入模式:${parsed.data.mode === "overwrite" ? "覆盖当前导演台" : "插入当前导演台"}\n只输出 JSON,不要 Markdown。`,
|
||||
},
|
||||
{ type: "image", image: referenceImages[0]! },
|
||||
],
|
||||
}],
|
||||
model,
|
||||
temperature: 0.15,
|
||||
} as any);
|
||||
const json = extractJson(text);
|
||||
if (!json) continue;
|
||||
const result = normalizeResult(json);
|
||||
await pointsCharge.consume({ modelId: candidate.model.modelId });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...result,
|
||||
model: candidate.model.displayName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("director AI import model failed", {
|
||||
error,
|
||||
modelId: candidate.model.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "识图模型暂时不可用,请稍后重试" },
|
||||
{ status: 502 },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("director AI import failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "识图导入失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
113
app/api/video-canvas/director-captures/route.ts
Normal file
113
app/api/video-canvas/director-captures/route.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { del } from "@vercel/blob";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject, mediaAsset } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
deleteS3ObjectByPath,
|
||||
resolveS3UploadTarget,
|
||||
} from "@/lib/storage/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const logger = createLogger("video-canvas-director-captures");
|
||||
const deleteSchema = z.object({
|
||||
assetId: z.string().uuid().optional(),
|
||||
canvasId: z.string().uuid(),
|
||||
imageUrl: z.string().trim().max(4096).optional(),
|
||||
pathname: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1024)
|
||||
.transform((value) => value.replace(/^\/+/, ""))
|
||||
.refine((value) => !value.includes("..") && !value.includes("\\"), "文件路径无效")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function deleteStoredCapture(pathname: string, imageUrl: string) {
|
||||
if (pathname.startsWith("uploads/local/")) {
|
||||
const publicRoot = path.resolve(process.cwd(), "public");
|
||||
const absolutePath = path.resolve(publicRoot, pathname);
|
||||
if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) {
|
||||
throw new Error("文件路径越界");
|
||||
}
|
||||
await unlink(absolutePath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
imageUrl.includes(".public.blob.vercel-storage.com/") &&
|
||||
process.env.BLOB_READ_WRITE_TOKEN
|
||||
) {
|
||||
await del(imageUrl, { token: process.env.BLOB_READ_WRITE_TOKEN });
|
||||
return;
|
||||
}
|
||||
|
||||
const target = await resolveS3UploadTarget();
|
||||
if (!target) throw new Error("没有可用的对象存储配置");
|
||||
await deleteS3ObjectByPath(pathname, target.config);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const parsed = deleteSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const [project] = await db
|
||||
.select({ id: canvasProject.id })
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, parsed.data.canvasId),
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!project) {
|
||||
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (parsed.data.pathname) {
|
||||
await deleteStoredCapture(
|
||||
parsed.data.pathname,
|
||||
parsed.data.imageUrl || "",
|
||||
).catch((error) => {
|
||||
logger.warn("capture file cleanup failed", { error });
|
||||
});
|
||||
}
|
||||
if (parsed.data.assetId) {
|
||||
await db
|
||||
.delete(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.id, parsed.data.assetId),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error("capture cleanup failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "截图清理失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
56
app/api/video-canvas/effects/search/route.ts
Normal file
56
app/api/video-canvas/effects/search/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import { rankVideoCanvasEffectPresets } from "@/lib/video-canvas/video-effects";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const requestSchema = z.object({
|
||||
query: z.string().trim().max(100).default(""),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: parsed.error.issues[0]?.message || "搜索参数无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({
|
||||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const config = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
|
||||
if (!config.enabled) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "视频画布功能已停用" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
presets: rankVideoCanvasEffectPresets(
|
||||
config.effectPresets,
|
||||
parsed.data.query,
|
||||
),
|
||||
query: parsed.data.query,
|
||||
},
|
||||
});
|
||||
}
|
||||
153
app/api/video-canvas/history-assets/route.ts
Normal file
153
app/api/video-canvas/history-assets/route.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { and, desc, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { mediaAsset } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import {
|
||||
VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY,
|
||||
VIDEO_CANVAS_HISTORY_MEDIA_TYPES,
|
||||
normalizeVideoCanvasHistoryAssetIds,
|
||||
} from "@/lib/video-canvas/history-assets";
|
||||
import {
|
||||
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||
} from "@/lib/video-canvas/subjects";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const logger = createLogger("video-canvas-history-assets");
|
||||
|
||||
async function getHistoryConfig() {
|
||||
const setting = await getSystemSetting({
|
||||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
return setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value).historyAssets
|
||||
: defaultVideoCanvasFunctionConfig.historyAssets;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const [config, assets] = await Promise.all([
|
||||
getHistoryConfig(),
|
||||
db
|
||||
.select({
|
||||
createdAt: mediaAsset.createdAt,
|
||||
displayName: mediaAsset.displayName,
|
||||
durationMs: mediaAsset.durationMs,
|
||||
height: mediaAsset.height,
|
||||
id: mediaAsset.id,
|
||||
mediaType: mediaAsset.mediaType,
|
||||
metadata: mediaAsset.metadata,
|
||||
mimeType: mediaAsset.mimeType,
|
||||
modelId: mediaAsset.modelId,
|
||||
originalUrl: mediaAsset.originalUrl,
|
||||
previewUrl: mediaAsset.previewUrl,
|
||||
prompt: mediaAsset.prompt,
|
||||
sourceNodeId: mediaAsset.sourceNodeId,
|
||||
sourceType: mediaAsset.sourceType,
|
||||
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||
width: mediaAsset.width,
|
||||
})
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
inArray(mediaAsset.mediaType, [
|
||||
...VIDEO_CANVAS_HISTORY_MEDIA_TYPES,
|
||||
]),
|
||||
ne(
|
||||
mediaAsset.sourceType,
|
||||
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||
),
|
||||
ne(
|
||||
mediaAsset.sourceType,
|
||||
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||
),
|
||||
sql`coalesce(${mediaAsset.metadata}->>${VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY}, '') = ''`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(mediaAsset.createdAt))
|
||||
.limit(500),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
assets: config.enabled ? assets.slice(0, config.maxItems) : [],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("history asset list failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "历史资产加载失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
assetIds?: unknown;
|
||||
} | null;
|
||||
const assetIds = normalizeVideoCanvasHistoryAssetIds(body?.assetIds);
|
||||
if (assetIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请选择历史资产" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const hiddenAt = new Date().toISOString();
|
||||
const hidden = await db
|
||||
.update(mediaAsset)
|
||||
.set({
|
||||
metadata: sql`coalesce(${mediaAsset.metadata}, '{}'::jsonb) || ${JSON.stringify({
|
||||
[VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY]: hiddenAt,
|
||||
})}::jsonb`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
inArray(mediaAsset.id, assetIds),
|
||||
inArray(mediaAsset.mediaType, [...VIDEO_CANVAS_HISTORY_MEDIA_TYPES]),
|
||||
sql`coalesce(${mediaAsset.metadata}->>${VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY}, '') = ''`,
|
||||
),
|
||||
)
|
||||
.returning({ id: mediaAsset.id });
|
||||
|
||||
return NextResponse.json({ success: true, removed: hidden.length });
|
||||
} catch (error) {
|
||||
logger.error("history asset delete failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "历史记录删除失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
226
app/api/video-canvas/prompt-tools/route.ts
Normal file
226
app/api/video-canvas/prompt-tools/route.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { generateText } from "ai";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
import { getAiModelAgentCodingModelId } from "@/lib/db/ai-model-flags";
|
||||
import {
|
||||
getAgentAiModelWithProvider,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getSystemSetting,
|
||||
} from "@/lib/db/queries";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import {
|
||||
buildVideoPromptStructureInstructions,
|
||||
injectVideoPromptStructure,
|
||||
resolveVideoPromptStructure,
|
||||
} from "@/lib/video-canvas/prompt-structure";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
const logger = createLogger("video-canvas-prompt-tools");
|
||||
|
||||
const requestSchema = z.object({
|
||||
marks: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().trim().min(1).max(100),
|
||||
label: z.string().trim().min(1).max(100),
|
||||
})
|
||||
)
|
||||
.max(12)
|
||||
.default([]),
|
||||
mode: z.enum(["optimize", "translate"]),
|
||||
prompt: z.string().trim().min(1, "请输入提示词").max(50_000, "提示词过长"),
|
||||
references: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().trim().min(1).max(100),
|
||||
index: z.number().int().min(1).max(10),
|
||||
label: z.string().trim().min(1).max(100),
|
||||
mediaType: z.enum(["audio", "image", "video"]),
|
||||
offset: z.number().int().min(0).max(50_000),
|
||||
})
|
||||
)
|
||||
.max(20)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
type ModelCandidate = Awaited<
|
||||
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||
>[number];
|
||||
|
||||
function isTextModelCandidate(candidate: ModelCandidate) {
|
||||
const descriptor = {
|
||||
capabilities: candidate.model.capabilities,
|
||||
modelId: candidate.model.modelId,
|
||||
providerType: candidate.provider.type,
|
||||
supportsImageGen: candidate.model.supportsImageGen,
|
||||
};
|
||||
|
||||
return (
|
||||
candidate.model.isEnabled &&
|
||||
candidate.provider.isEnabled &&
|
||||
!candidate.model.isSubModel &&
|
||||
candidate.model.healthStatus !== "auto_disabled" &&
|
||||
!isImageGenerationModelDescriptor(descriptor) &&
|
||||
!isVideoModelDescriptor(descriptor)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeModelRef(value: unknown) {
|
||||
const normalized = String(value || "").trim();
|
||||
return normalized && normalized !== "auto" ? normalized : "";
|
||||
}
|
||||
|
||||
function normalizeGeneratedText(value: unknown) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/^```(?:text|markdown|md)?\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.replace(/^(?:优化后的提示词|译文|translation)\s*[::]\s*/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
|
||||
() => null,
|
||||
);
|
||||
const config = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const functionKey =
|
||||
parsed.data.mode === "optimize"
|
||||
? "video-prompt-optimize"
|
||||
: "video-prompt-translate";
|
||||
const definition = config.functions[functionKey];
|
||||
|
||||
if (!definition?.enabled) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `${definition?.label || "当前功能"}已停用` },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const [allModels, agentModel] = await Promise.all([
|
||||
getEnabledAiModelsWithProvider(false),
|
||||
getAgentAiModelWithProvider(),
|
||||
]);
|
||||
const candidates = allModels.filter(isTextModelCandidate);
|
||||
const preferredModelRef =
|
||||
normalizeModelRef(definition.model) || normalizeModelRef(config.defaultMultimodalModel);
|
||||
const preferredModel = preferredModelRef
|
||||
? candidates.find(
|
||||
(candidate) =>
|
||||
candidate.model.id === preferredModelRef ||
|
||||
candidate.model.modelId === preferredModelRef,
|
||||
)
|
||||
: null;
|
||||
const orderedCandidates = [
|
||||
preferredModel,
|
||||
agentModel && isTextModelCandidate(agentModel) ? agentModel : null,
|
||||
...candidates,
|
||||
].filter(
|
||||
(candidate, index, items): candidate is ModelCandidate =>
|
||||
Boolean(candidate) &&
|
||||
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
|
||||
);
|
||||
|
||||
if (orderedCandidates.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "当前站点没有可用的提示词处理模型" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
for (const candidate of orderedCandidates.slice(0, 3)) {
|
||||
try {
|
||||
const codingModelId = getAiModelAgentCodingModelId(candidate.model);
|
||||
const model = await getLanguageModelById(candidate.model.id, codingModelId);
|
||||
if (!model) continue;
|
||||
const structuredPrompt = injectVideoPromptStructure(
|
||||
parsed.data.prompt,
|
||||
parsed.data.references,
|
||||
parsed.data.marks,
|
||||
);
|
||||
const structureInstructions = buildVideoPromptStructureInstructions(
|
||||
parsed.data.references,
|
||||
parsed.data.marks,
|
||||
);
|
||||
|
||||
const { text } = await generateText({
|
||||
abortSignal: AbortSignal.timeout(18_000),
|
||||
maxTokens: parsed.data.mode === "optimize" ? 2200 : 1600,
|
||||
model,
|
||||
prompt:
|
||||
parsed.data.mode === "optimize"
|
||||
? `原始视频提示词:\n${structuredPrompt}\n\n请按照系统规则输出优化后的提示词正文。`
|
||||
: `待翻译的视频提示词:\n${structuredPrompt}\n\n请按照系统规则只输出译文。`,
|
||||
system: [definition.prompt, structureInstructions].filter(Boolean).join("\n\n"),
|
||||
temperature: getCompatibleTextGenerationTemperature(
|
||||
candidate,
|
||||
parsed.data.mode === "optimize" ? 0.65 : 0.25,
|
||||
),
|
||||
} as any);
|
||||
const generatedText = normalizeGeneratedText(text);
|
||||
if (!generatedText) continue;
|
||||
const result = resolveVideoPromptStructure(
|
||||
generatedText,
|
||||
parsed.data.references,
|
||||
parsed.data.marks,
|
||||
{ flatten: parsed.data.mode === "optimize" },
|
||||
);
|
||||
if (!result.text) continue;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
mentionOffsets: result.mentionOffsets,
|
||||
model: candidate.model.displayName,
|
||||
preservedMarkIds: result.preservedMarkIds,
|
||||
text: result.text,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("prompt model candidate failed", {
|
||||
error,
|
||||
modelId: candidate.model.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "提示词处理模型暂时不可用,请稍后重试" },
|
||||
{ status: 502 },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("prompt tool failed", { error });
|
||||
const message = error instanceof Error ? error.message : "提示词处理失败";
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
226
app/api/video-canvas/subjects/complete-views/route.ts
Normal file
226
app/api/video-canvas/subjects/complete-views/route.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||
import { db } from "@/lib/db";
|
||||
import { getPointsSystemConfig, getSystemSetting } from "@/lib/db/queries";
|
||||
import { mediaAsset } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import { VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE } from "@/lib/video-canvas/subjects";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 1200;
|
||||
|
||||
const logger = createLogger("video-canvas-subject-view-completion");
|
||||
|
||||
const requestSchema = z.object({
|
||||
primaryAssetId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const SUBJECT_COMPLETION_VIEW_DIRECTIONS = [
|
||||
"正面近景,突出面部与头部特征",
|
||||
"正面全身,完整展示服装与体态",
|
||||
"左前方四十五度全身视角",
|
||||
"右前方四十五度全身视角",
|
||||
"左侧面全身视角",
|
||||
"右侧面全身视角",
|
||||
"背面全身视角",
|
||||
"背面回首的全身视角",
|
||||
"轻微俯视的正面全身视角",
|
||||
] as const;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const parsed = requestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({
|
||||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const config = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const completionConfig = config.subjectLibrary.smartCompletion;
|
||||
if (!config.subjectLibrary.enabled || !completionConfig.enabled) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "智能补全已停用" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const [primaryAsset] = await db
|
||||
.select({
|
||||
id: mediaAsset.id,
|
||||
originalUrl: mediaAsset.originalUrl,
|
||||
})
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.id, parsed.data.primaryAssetId),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.mediaType, "image"),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!primaryAsset) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主参考图不存在或已失效" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
modelId: "video-canvas-subject-view-completion",
|
||||
note: "主体其他视角智能补全消耗积分",
|
||||
pointsConfig,
|
||||
reason: "video_canvas_subject_view_completion",
|
||||
unitCost: completionConfig.cost,
|
||||
userId: session.user.id,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const referenceImage = new URL(primaryAsset.originalUrl, request.url).toString();
|
||||
const selectedModel =
|
||||
completionConfig.model === "auto"
|
||||
? config.defaultImageModel
|
||||
: completionConfig.model;
|
||||
const generatedCandidates = (
|
||||
await Promise.all(
|
||||
Array.from({ length: completionConfig.candidateCount }, async (_, index) => {
|
||||
const viewDirection =
|
||||
SUBJECT_COMPLETION_VIEW_DIRECTIONS[
|
||||
index % SUBJECT_COMPLETION_VIEW_DIRECTIONS.length
|
||||
];
|
||||
const candidatePrompt = `${completionConfig.prompt}\n本次只生成一张独立候选图。候选方向:${viewDirection}。不要生成拼图、九宫格、文字或边框。`;
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
try {
|
||||
const result = await generateCanvasImage({
|
||||
aspectRatio: completionConfig.aspectRatio,
|
||||
debugLabel: "video-canvas-subject-view-completion",
|
||||
debugMeta: {
|
||||
candidateIndex: index,
|
||||
candidateViewDirection: viewDirection,
|
||||
primaryAssetId: primaryAsset.id,
|
||||
subjectViewCompletion: true,
|
||||
},
|
||||
imageCount: 1,
|
||||
imageSize: completionConfig.imageSize,
|
||||
model: selectedModel,
|
||||
ownerKey: `${session.user.id}:subject-view-${index}`,
|
||||
prompt: candidatePrompt,
|
||||
promptExtend: false,
|
||||
referenceImage,
|
||||
});
|
||||
const image = result.images[0];
|
||||
if (!image) throw new Error("模型未返回图片");
|
||||
return {
|
||||
image,
|
||||
model: result.model,
|
||||
modelRef: result.modelRef,
|
||||
prompt: result.prompt,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("subject completion candidate failed", {
|
||||
attempt,
|
||||
candidateIndex: index,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
)
|
||||
).filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate));
|
||||
if (generatedCandidates.length !== completionConfig.candidateCount) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `智能补全未能生成全部 ${completionConfig.candidateCount} 张候选图,请重试`,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const generatedRows = await db
|
||||
.insert(mediaAsset)
|
||||
.values(
|
||||
generatedCandidates.map((candidate, index) => ({
|
||||
displayName: `主体补充视角 ${index + 1}`,
|
||||
mediaType: "image",
|
||||
metadata: {
|
||||
candidateIndex: index,
|
||||
generatedBy: "subject-smart-completion",
|
||||
primaryAssetId: primaryAsset.id,
|
||||
},
|
||||
mimeType: "image/png",
|
||||
modelId: candidate.modelRef || candidate.model,
|
||||
normalizedUrl: candidate.image.originalUrl,
|
||||
originalUrl: candidate.image.originalUrl,
|
||||
previewUrl: candidate.image.thumbnailUrl || candidate.image.originalUrl,
|
||||
prompt: candidate.prompt,
|
||||
rootAssetId: primaryAsset.id,
|
||||
sourceTable: "video_canvas_subject",
|
||||
sourceType: VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||
status: "ready",
|
||||
thumbnailUrl: candidate.image.thumbnailUrl || candidate.image.originalUrl,
|
||||
userId: session.user.id,
|
||||
})),
|
||||
)
|
||||
.returning({
|
||||
assetId: mediaAsset.id,
|
||||
name: mediaAsset.displayName,
|
||||
url: mediaAsset.originalUrl,
|
||||
});
|
||||
|
||||
const primaryGeneratedCandidate = generatedCandidates[0];
|
||||
await pointsCharge.consume({
|
||||
modelId: primaryGeneratedCandidate.modelRef || primaryGeneratedCandidate.model,
|
||||
}).catch((error) => {
|
||||
logger.warn("subject completion points consumption failed", { error });
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
candidates: generatedRows.map((item) => ({
|
||||
assetId: item.assetId,
|
||||
mediaType: "image" as const,
|
||||
name: item.name || "补充视角",
|
||||
url: item.url,
|
||||
})),
|
||||
cost: pointsCharge.unitCost,
|
||||
maxSelected: completionConfig.maxSelected,
|
||||
model: primaryGeneratedCandidate.model,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("subject view completion failed", { error });
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "智能补全失败",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
274
app/api/video-canvas/subjects/generate-description/route.ts
Normal file
274
app/api/video-canvas/subjects/generate-description/route.ts
Normal file
@ -0,0 +1,274 @@
|
||||
import { generateText } from "ai";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
getAiModelAgentCodingModelId,
|
||||
isAiModelMultimodal,
|
||||
} from "@/lib/db/ai-model-flags";
|
||||
import {
|
||||
getAgentAiModelWithProvider,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getPointsSystemConfig,
|
||||
getSystemSetting,
|
||||
} from "@/lib/db/queries";
|
||||
import { mediaAsset } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import {
|
||||
normalizeGeneratedSubjectDescription,
|
||||
resolveVideoCanvasSubjectCategory,
|
||||
} from "@/lib/video-canvas/subjects";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
const logger = createLogger("video-canvas-subject-description");
|
||||
|
||||
const requestSchema = z.object({
|
||||
assetIds: z.array(z.string().uuid()).min(1).max(4),
|
||||
category: z.string().trim().max(20).default("无标签"),
|
||||
frameDataUrl: z
|
||||
.string()
|
||||
.max(12_000_000)
|
||||
.refine(
|
||||
(value) => !value || /^data:image\/(?:jpeg|png|webp);base64,/i.test(value),
|
||||
"视频预览帧无效",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type ModelCandidate = Awaited<
|
||||
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||
>[number];
|
||||
|
||||
function isDescriptionModelCandidate(candidate: ModelCandidate) {
|
||||
const descriptor = {
|
||||
capabilities: candidate.model.capabilities,
|
||||
modelId: candidate.model.modelId,
|
||||
providerType: candidate.provider.type,
|
||||
supportsImageGen: candidate.model.supportsImageGen,
|
||||
};
|
||||
return (
|
||||
candidate.model.isEnabled &&
|
||||
candidate.provider.isEnabled &&
|
||||
!candidate.model.isSubModel &&
|
||||
candidate.model.healthStatus !== "auto_disabled" &&
|
||||
!isImageGenerationModelDescriptor(descriptor) &&
|
||||
!isVideoModelDescriptor(descriptor) &&
|
||||
(Boolean(candidate.model.supportsVision) ||
|
||||
isAiModelMultimodal({
|
||||
capabilities: candidate.model.capabilities,
|
||||
defaultParams: candidate.model.defaultParams,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeModelRef(value: unknown) {
|
||||
const normalized = String(value || "").trim();
|
||||
return normalized && normalized !== "auto" ? normalized : "";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const parsed = requestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({
|
||||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const config = setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||||
: defaultVideoCanvasFunctionConfig;
|
||||
const subjectConfig = config.subjectLibrary;
|
||||
if (!subjectConfig.enabled || !subjectConfig.smartDescription.enabled) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "智能生成已停用" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const assets = await db
|
||||
.select({
|
||||
id: mediaAsset.id,
|
||||
mediaType: mediaAsset.mediaType,
|
||||
originalUrl: mediaAsset.originalUrl,
|
||||
})
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
inArray(mediaAsset.id, parsed.data.assetIds),
|
||||
),
|
||||
);
|
||||
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
|
||||
const orderedAssets = parsed.data.assetIds.flatMap((id) => {
|
||||
const asset = assetById.get(id);
|
||||
return asset ? [asset] : [];
|
||||
});
|
||||
if (orderedAssets.length !== parsed.data.assetIds.length) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体素材不存在或已失效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrls = orderedAssets
|
||||
.filter((asset) => asset.mediaType === "image")
|
||||
.map((asset) => new URL(asset.originalUrl, request.url).toString());
|
||||
const inlinedImages = imageUrls.length
|
||||
? (
|
||||
await inlineVisionImages(imageUrls, {
|
||||
loggerPrefix: "[Video Canvas Subject]",
|
||||
maxImages: subjectConfig.imageMaxCount,
|
||||
maxSide: 1024,
|
||||
retries: 1,
|
||||
timeoutMs: 12_000,
|
||||
})
|
||||
).inlineDataUrls
|
||||
: [];
|
||||
const references = [
|
||||
...inlinedImages,
|
||||
...(parsed.data.frameDataUrl ? [parsed.data.frameDataUrl] : []),
|
||||
].slice(0, subjectConfig.imageMaxCount);
|
||||
if (references.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未能读取主体画面" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const [allModels, agentModel] = await Promise.all([
|
||||
getEnabledAiModelsWithProvider(false),
|
||||
getAgentAiModelWithProvider(),
|
||||
]);
|
||||
const candidates = allModels.filter(isDescriptionModelCandidate);
|
||||
const preferredRef = normalizeModelRef(config.defaultMultimodalModel);
|
||||
const preferredModel = preferredRef
|
||||
? candidates.find(
|
||||
(candidate) =>
|
||||
candidate.model.id === preferredRef ||
|
||||
candidate.model.modelId === preferredRef,
|
||||
)
|
||||
: null;
|
||||
const orderedCandidates = [
|
||||
preferredModel,
|
||||
agentModel && isDescriptionModelCandidate(agentModel) ? agentModel : null,
|
||||
...candidates,
|
||||
].filter(
|
||||
(candidate, index, items): candidate is ModelCandidate =>
|
||||
Boolean(candidate) &&
|
||||
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
|
||||
);
|
||||
if (orderedCandidates.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "当前站点没有可用的多模态模型" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const category = resolveVideoCanvasSubjectCategory(
|
||||
parsed.data.category,
|
||||
subjectConfig,
|
||||
);
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
modelId: "video-canvas-subject-description",
|
||||
note: "主体描述智能生成消耗积分",
|
||||
pointsConfig,
|
||||
reason: "video_canvas_subject_description",
|
||||
unitCost: subjectConfig.smartDescription.cost,
|
||||
userId: session.user.id,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
for (const candidate of orderedCandidates.slice(0, 3)) {
|
||||
try {
|
||||
const model = await getLanguageModelById(
|
||||
candidate.model.id,
|
||||
getAiModelAgentCodingModelId(candidate.model),
|
||||
);
|
||||
if (!model) continue;
|
||||
const { text } = await generateText({
|
||||
abortSignal: AbortSignal.timeout(35_000),
|
||||
maxTokens: subjectConfig.smartDescription.maxTokens,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `主体标签:${category}\n参考画面数量:${references.length}\n请根据系统规则生成主体描述。`,
|
||||
},
|
||||
...references.map((image) => ({ type: "image" as const, image })),
|
||||
],
|
||||
},
|
||||
],
|
||||
model,
|
||||
system: subjectConfig.smartDescription.prompt,
|
||||
temperature: getCompatibleTextGenerationTemperature(
|
||||
candidate,
|
||||
subjectConfig.smartDescription.temperature,
|
||||
),
|
||||
} as any);
|
||||
const description = normalizeGeneratedSubjectDescription(
|
||||
text,
|
||||
subjectConfig.descriptionMaxLength,
|
||||
);
|
||||
if (!description) continue;
|
||||
await pointsCharge.consume({ modelId: candidate.model.id }).catch((error) => {
|
||||
logger.warn("subject description points consumption failed", { error });
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
cost: pointsCharge.unitCost,
|
||||
description,
|
||||
model: candidate.model.displayName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("subject description model failed", {
|
||||
error,
|
||||
modelId: candidate.model.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "智能描述模型暂时不可用,请稍后重试" },
|
||||
{ status: 502 },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("subject description generation failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体描述生成失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
569
app/api/video-canvas/subjects/route.ts
Normal file
569
app/api/video-canvas/subjects/route.ts
Normal file
@ -0,0 +1,569 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { mediaAsset } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import {
|
||||
VIDEO_CANVAS_SUBJECT_SOURCE_TYPE,
|
||||
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
|
||||
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
|
||||
normalizeVideoCanvasSubjectText,
|
||||
resolveVideoCanvasSubjectCategory,
|
||||
validateVideoCanvasSubjectDraft,
|
||||
} from "@/lib/video-canvas/subjects";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const logger = createLogger("video-canvas-subjects");
|
||||
|
||||
const createSubjectSchema = z.object({
|
||||
assetIds: z.array(z.string().uuid()).min(1).max(10),
|
||||
category: z.string().trim().max(20),
|
||||
description: z.string().trim().min(1).max(1000),
|
||||
durationSeconds: z.number().finite().positive().max(3600).nullish(),
|
||||
mediaMode: z.enum(["images", "video"]),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
voiceId: z.string().trim().min(1).max(160).default("none"),
|
||||
});
|
||||
const updateSubjectSchema = createSubjectSchema.extend({
|
||||
subjectId: z.string().uuid(),
|
||||
});
|
||||
|
||||
async function getSubjectLibraryConfig() {
|
||||
const setting = await getSystemSetting({
|
||||
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
return setting?.value
|
||||
? normalizeVideoCanvasFunctionConfig(setting.value).subjectLibrary
|
||||
: defaultVideoCanvasFunctionConfig.subjectLibrary;
|
||||
}
|
||||
|
||||
function readMetadataAssetIds(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
const assetIds = (value as Record<string, unknown>).assetIds;
|
||||
return Array.isArray(assetIds)
|
||||
? assetIds.map((item) => String(item || "")).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getSubjectLibraryConfig();
|
||||
const requestedCategory = new URL(request.url).searchParams.get("category");
|
||||
const category = requestedCategory
|
||||
? resolveVideoCanvasSubjectCategory(requestedCategory, config)
|
||||
: "";
|
||||
const subjects = await db
|
||||
.select({
|
||||
createdAt: mediaAsset.createdAt,
|
||||
description: mediaAsset.summary,
|
||||
displayName: mediaAsset.displayName,
|
||||
durationMs: mediaAsset.durationMs,
|
||||
id: mediaAsset.id,
|
||||
metadata: mediaAsset.metadata,
|
||||
originalUrl: mediaAsset.originalUrl,
|
||||
previewUrl: mediaAsset.previewUrl,
|
||||
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||
})
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.mediaType, "subject"),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(mediaAsset.updatedAt))
|
||||
.limit(100);
|
||||
const filteredSubjects = category
|
||||
? subjects.filter((subject) => {
|
||||
const metadata = subject.metadata;
|
||||
return (
|
||||
metadata &&
|
||||
typeof metadata === "object" &&
|
||||
!Array.isArray(metadata) &&
|
||||
(metadata as Record<string, unknown>).category === category
|
||||
);
|
||||
})
|
||||
: subjects;
|
||||
|
||||
return NextResponse.json({ success: true, subjects: filteredSubjects });
|
||||
} catch (error) {
|
||||
logger.error("subject list failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体加载失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const parsed = createSubjectSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getSubjectLibraryConfig();
|
||||
const category = resolveVideoCanvasSubjectCategory(parsed.data.category, config);
|
||||
const validationError = validateVideoCanvasSubjectDraft(
|
||||
{
|
||||
assetCount: parsed.data.assetIds.length,
|
||||
category,
|
||||
description: parsed.data.description,
|
||||
durationSeconds: parsed.data.durationSeconds,
|
||||
mediaMode: parsed.data.mediaMode,
|
||||
name: parsed.data.name,
|
||||
voiceId: parsed.data.voiceId,
|
||||
},
|
||||
config,
|
||||
);
|
||||
if (validationError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: validationError },
|
||||
{ status: config.enabled ? 400 : 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const uploadedAssets = await db
|
||||
.select({
|
||||
id: mediaAsset.id,
|
||||
mediaType: mediaAsset.mediaType,
|
||||
mimeType: mediaAsset.mimeType,
|
||||
originalUrl: mediaAsset.originalUrl,
|
||||
previewUrl: mediaAsset.previewUrl,
|
||||
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||
})
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
inArray(mediaAsset.id, parsed.data.assetIds),
|
||||
),
|
||||
);
|
||||
const assetById = new Map(uploadedAssets.map((asset) => [asset.id, asset]));
|
||||
const orderedAssets = parsed.data.assetIds.flatMap((id) => {
|
||||
const asset = assetById.get(id);
|
||||
return asset ? [asset] : [];
|
||||
});
|
||||
if (orderedAssets.length !== parsed.data.assetIds.length) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体素材不存在或已失效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const expectedMediaType = parsed.data.mediaMode === "images" ? "image" : "video";
|
||||
if (orderedAssets.some((asset) => asset.mediaType !== expectedMediaType)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体素材类型不一致" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let customVoiceAssetId: string | null = null;
|
||||
if (parsed.data.voiceId.startsWith("custom:")) {
|
||||
customVoiceAssetId = parsed.data.voiceId.slice("custom:".length);
|
||||
if (!z.string().uuid().safeParse(customVoiceAssetId).success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "自定义音色无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const [voiceAsset] = await db
|
||||
.select({ id: mediaAsset.id })
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.id, customVoiceAssetId),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!voiceAsset) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "自定义音色不存在或已失效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const primaryAsset = orderedAssets[0];
|
||||
const description = normalizeVideoCanvasSubjectText(
|
||||
parsed.data.description,
|
||||
config.descriptionMaxLength,
|
||||
);
|
||||
const name = normalizeVideoCanvasSubjectText(
|
||||
parsed.data.name,
|
||||
config.nameMaxLength,
|
||||
);
|
||||
const voice = config.voices.find((item) => item.id === parsed.data.voiceId);
|
||||
const durationMs =
|
||||
parsed.data.mediaMode === "video" && parsed.data.durationSeconds
|
||||
? Math.round(parsed.data.durationSeconds * 1000)
|
||||
: null;
|
||||
const subjectId = randomUUID();
|
||||
const [subject] = await db
|
||||
.insert(mediaAsset)
|
||||
.values({
|
||||
displayName: name,
|
||||
durationMs,
|
||||
id: subjectId,
|
||||
mediaType: "subject",
|
||||
metadata: {
|
||||
assetIds: parsed.data.assetIds,
|
||||
category,
|
||||
customVoiceAssetId,
|
||||
durationSeconds: parsed.data.durationSeconds || null,
|
||||
mediaMode: parsed.data.mediaMode,
|
||||
mediaUrls: orderedAssets.map((asset) => asset.originalUrl),
|
||||
voiceBadge: voice?.badge || (customVoiceAssetId ? "自定义" : ""),
|
||||
voiceId: parsed.data.voiceId,
|
||||
voiceName: voice?.name || (customVoiceAssetId ? "自定义音色" : "无配音"),
|
||||
},
|
||||
mimeType: "application/x-video-canvas-subject",
|
||||
normalizedUrl: `video-canvas-subject://${subjectId}`,
|
||||
originalUrl: primaryAsset.originalUrl,
|
||||
previewUrl: primaryAsset.previewUrl || primaryAsset.originalUrl,
|
||||
sourceTable: "video_canvas_subject",
|
||||
sourceType: VIDEO_CANVAS_SUBJECT_SOURCE_TYPE,
|
||||
status: "ready",
|
||||
summary: { description },
|
||||
thumbnailUrl:
|
||||
primaryAsset.thumbnailUrl || primaryAsset.previewUrl || primaryAsset.originalUrl,
|
||||
userId: session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, subject });
|
||||
} catch (error) {
|
||||
logger.error("subject creation failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体创建失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const parsed = updateSubjectSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const [existingSubject] = await db
|
||||
.select({ id: mediaAsset.id, metadata: mediaAsset.metadata })
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.id, parsed.data.subjectId),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existingSubject) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getSubjectLibraryConfig();
|
||||
const category = resolveVideoCanvasSubjectCategory(parsed.data.category, config);
|
||||
const validationError = validateVideoCanvasSubjectDraft(
|
||||
{
|
||||
assetCount: parsed.data.assetIds.length,
|
||||
category,
|
||||
description: parsed.data.description,
|
||||
durationSeconds: parsed.data.durationSeconds,
|
||||
mediaMode: parsed.data.mediaMode,
|
||||
name: parsed.data.name,
|
||||
voiceId: parsed.data.voiceId,
|
||||
},
|
||||
config,
|
||||
);
|
||||
if (validationError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: validationError },
|
||||
{ status: config.enabled ? 400 : 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const ownedAssets = await db
|
||||
.select({
|
||||
id: mediaAsset.id,
|
||||
mediaType: mediaAsset.mediaType,
|
||||
originalUrl: mediaAsset.originalUrl,
|
||||
previewUrl: mediaAsset.previewUrl,
|
||||
thumbnailUrl: mediaAsset.thumbnailUrl,
|
||||
})
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
inArray(mediaAsset.id, parsed.data.assetIds),
|
||||
),
|
||||
);
|
||||
const assetById = new Map(ownedAssets.map((asset) => [asset.id, asset]));
|
||||
const orderedAssets = parsed.data.assetIds.flatMap((id) => {
|
||||
const asset = assetById.get(id);
|
||||
return asset ? [asset] : [];
|
||||
});
|
||||
const expectedMediaType = parsed.data.mediaMode === "images" ? "image" : "video";
|
||||
if (
|
||||
orderedAssets.length !== parsed.data.assetIds.length ||
|
||||
orderedAssets.some((asset) => asset.mediaType !== expectedMediaType)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体素材不存在、已失效或类型不一致" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let customVoiceAssetId: string | null = null;
|
||||
if (parsed.data.voiceId.startsWith("custom:")) {
|
||||
customVoiceAssetId = parsed.data.voiceId.slice("custom:".length);
|
||||
if (!z.string().uuid().safeParse(customVoiceAssetId).success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "自定义音色无效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const [voiceAsset] = await db
|
||||
.select({ id: mediaAsset.id })
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.id, customVoiceAssetId),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||
eq(mediaAsset.status, "ready"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!voiceAsset) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "自定义音色不存在或已失效" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const primaryAsset = orderedAssets[0];
|
||||
const voice = config.voices.find((item) => item.id === parsed.data.voiceId);
|
||||
const previousMetadata =
|
||||
existingSubject.metadata &&
|
||||
typeof existingSubject.metadata === "object" &&
|
||||
!Array.isArray(existingSubject.metadata)
|
||||
? (existingSubject.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
const previousAssetIds = readMetadataAssetIds(existingSubject.metadata);
|
||||
const previousCustomVoiceId = String(previousMetadata.customVoiceAssetId || "").trim();
|
||||
const [subject] = await db
|
||||
.update(mediaAsset)
|
||||
.set({
|
||||
displayName: normalizeVideoCanvasSubjectText(
|
||||
parsed.data.name,
|
||||
config.nameMaxLength,
|
||||
),
|
||||
durationMs:
|
||||
parsed.data.mediaMode === "video" && parsed.data.durationSeconds
|
||||
? Math.round(parsed.data.durationSeconds * 1000)
|
||||
: null,
|
||||
metadata: {
|
||||
assetIds: parsed.data.assetIds,
|
||||
category,
|
||||
customVoiceAssetId,
|
||||
durationSeconds: parsed.data.durationSeconds || null,
|
||||
mediaMode: parsed.data.mediaMode,
|
||||
mediaUrls: orderedAssets.map((asset) => asset.originalUrl),
|
||||
voiceBadge: voice?.badge || (customVoiceAssetId ? "自定义" : ""),
|
||||
voiceId: parsed.data.voiceId,
|
||||
voiceName: voice?.name || (customVoiceAssetId ? "自定义音色" : "无配音"),
|
||||
},
|
||||
originalUrl: primaryAsset.originalUrl,
|
||||
previewUrl: primaryAsset.previewUrl || primaryAsset.originalUrl,
|
||||
summary: {
|
||||
description: normalizeVideoCanvasSubjectText(
|
||||
parsed.data.description,
|
||||
config.descriptionMaxLength,
|
||||
),
|
||||
},
|
||||
thumbnailUrl:
|
||||
primaryAsset.thumbnailUrl || primaryAsset.previewUrl || primaryAsset.originalUrl,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(mediaAsset.id, existingSubject.id))
|
||||
.returning();
|
||||
|
||||
const removedAssetIds = previousAssetIds.filter(
|
||||
(assetId) => !parsed.data.assetIds.includes(assetId),
|
||||
);
|
||||
if (removedAssetIds.length) {
|
||||
await db.delete(mediaAsset).where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE),
|
||||
inArray(mediaAsset.id, removedAssetIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
previousCustomVoiceId &&
|
||||
previousCustomVoiceId !== customVoiceAssetId &&
|
||||
z.string().uuid().safeParse(previousCustomVoiceId).success
|
||||
) {
|
||||
await db.delete(mediaAsset).where(
|
||||
and(
|
||||
eq(mediaAsset.id, previousCustomVoiceId),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, subject });
|
||||
} catch (error) {
|
||||
logger.error("subject update failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体保存失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
subjectId?: string;
|
||||
uploadAssetIds?: unknown;
|
||||
voiceAssetIds?: unknown;
|
||||
} | null;
|
||||
const uploadAssetIds = Array.isArray(body?.uploadAssetIds)
|
||||
? body.uploadAssetIds.map((item) => String(item || "")).filter(Boolean).slice(0, 10)
|
||||
: [];
|
||||
const voiceAssetIds = Array.isArray(body?.voiceAssetIds)
|
||||
? body.voiceAssetIds.map((item) => String(item || "")).filter(Boolean).slice(0, 10)
|
||||
: [];
|
||||
const subjectId = String(body?.subjectId || "").trim();
|
||||
|
||||
if (subjectId) {
|
||||
const [subject] = await db
|
||||
.select({ id: mediaAsset.id, metadata: mediaAsset.metadata })
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.id, subjectId),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!subject) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体不存在" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const referencedAssetIds = readMetadataAssetIds(subject.metadata);
|
||||
const subjectMetadata =
|
||||
subject.metadata &&
|
||||
typeof subject.metadata === "object" &&
|
||||
!Array.isArray(subject.metadata)
|
||||
? (subject.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
const customVoiceAssetId = String(
|
||||
subjectMetadata.customVoiceAssetId || "",
|
||||
).trim();
|
||||
await db.delete(mediaAsset).where(eq(mediaAsset.id, subject.id));
|
||||
if (referencedAssetIds.length > 0) {
|
||||
await db
|
||||
.delete(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE),
|
||||
inArray(mediaAsset.id, referencedAssetIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (z.string().uuid().safeParse(customVoiceAssetId).success) {
|
||||
await db
|
||||
.delete(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.id, customVoiceAssetId),
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
|
||||
),
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, removed: 1 });
|
||||
}
|
||||
|
||||
const cleanupGroups = [
|
||||
{ ids: uploadAssetIds, sourceType: VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE },
|
||||
{ ids: voiceAssetIds, sourceType: VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE },
|
||||
];
|
||||
let removed = 0;
|
||||
for (const group of cleanupGroups) {
|
||||
if (group.ids.length === 0) continue;
|
||||
const deleted = await db
|
||||
.delete(mediaAsset)
|
||||
.where(
|
||||
and(
|
||||
eq(mediaAsset.userId, session.user.id),
|
||||
eq(mediaAsset.sourceType, group.sourceType),
|
||||
inArray(mediaAsset.id, group.ids),
|
||||
),
|
||||
)
|
||||
.returning({ id: mediaAsset.id });
|
||||
removed += deleted.length;
|
||||
}
|
||||
return NextResponse.json({ success: true, removed });
|
||||
} catch (error) {
|
||||
logger.error("subject deletion failed", { error });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "主体删除失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -143,6 +143,7 @@ import {
|
||||
normalizeAgentGenerationProjectPersistedState,
|
||||
normalizeAgentGenerationProjectReplacementResultState,
|
||||
} from "@/lib/agents/generation-project-state";
|
||||
import { doesAgentProjectStateCoverReplacementResults } from "@/lib/agents/agent-project-save-merge";
|
||||
import {
|
||||
createEmptyAgentModeWorkspaceState,
|
||||
normalizeAgentModeWorkspaceState,
|
||||
@ -192,11 +193,6 @@ import {
|
||||
getAgentWorkflowPageFeatureSettings,
|
||||
sanitizeAgentWorkflowSelections,
|
||||
} from "@/lib/agents/workflow-ui-config";
|
||||
import {
|
||||
loadCanvasChatPanelModule,
|
||||
preloadCanvasChatPanel,
|
||||
} from "@/components/canvas/canvas-chat-panel-loader";
|
||||
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import {
|
||||
buildVideoWorkflowCanvasNode,
|
||||
@ -318,7 +314,7 @@ const CanvasWorkspace = dynamic(
|
||||
|
||||
const CanvasChatPanel = dynamic(
|
||||
() =>
|
||||
loadCanvasChatPanelModule().then(
|
||||
import("@/components/canvas/CanvasChatPanel").then(
|
||||
(module) => module.CanvasChatPanel
|
||||
),
|
||||
{
|
||||
@ -1557,22 +1553,6 @@ function areReplacementResultStatesEqual(
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
@ -2034,7 +2014,7 @@ export default function CanvasEditorLegacyPage({
|
||||
);
|
||||
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
||||
useState<string | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
|
||||
const [floatingPanelWidth, setFloatingPanelWidth] =
|
||||
useState(FLOATING_PANEL_WIDTH);
|
||||
const [
|
||||
@ -3355,15 +3335,6 @@ export default function CanvasEditorLegacyPage({
|
||||
};
|
||||
}, [checkCanvasAuthSession]);
|
||||
|
||||
useEffect(() => {
|
||||
preloadCanvasChatPanel();
|
||||
preloadCanvasChatData(id);
|
||||
|
||||
return scheduleCanvasIdleTask(() => {
|
||||
void import("@/components/canvas/CanvasFileListPanel");
|
||||
}, 700);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setAreSecondaryPanelsReady(true);
|
||||
@ -4466,26 +4437,18 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
const serverUpdatedAt =
|
||||
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;
|
||||
!doesAgentProjectStateCoverReplacementResults({
|
||||
candidateState: localDraft?.projectState,
|
||||
authoritativeState: result.data.projectState,
|
||||
});
|
||||
const localDraftHasStaleReplacementCollections =
|
||||
serverReplacementResultCount > 0 &&
|
||||
!localDraftMissesServerReplacementResults &&
|
||||
hasReplacementResultCollectionMismatch(localDraft?.projectState);
|
||||
const shouldUseLocalDraft =
|
||||
Boolean(localDraft?.projectState) &&
|
||||
Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
|
||||
!localDraftMissesServerReplacementResults &&
|
||||
!localDraftReplacementResultsDifferFromServer &&
|
||||
!localDraftHasStaleReplacementCollections;
|
||||
|
||||
if (shouldUseLocalDraft && localDraft?.projectState) {
|
||||
|
||||
@ -5,9 +5,8 @@ import { useParams, useSearchParams } from "next/navigation";
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
|
||||
import type { CanvasInitialProject } from "@/types/canvas";
|
||||
import { loadCanvasEditorModule } from "./canvas-editor-loader";
|
||||
|
||||
const CanvasEditorLegacyPage = dynamic(loadCanvasEditorModule, {
|
||||
const CanvasEditorLegacyPage = dynamic(() => import("./CanvasEditorLegacyPageClient"), {
|
||||
loading: () => <CanvasRouteLoader label="正在加载画布..." />,
|
||||
ssr: false,
|
||||
});
|
||||
@ -43,7 +42,10 @@ export function CanvasPageClientRouter({
|
||||
const id = Array.isArray(routeId) ? routeId[0] || initialId : typeof routeId === "string" ? routeId : initialId;
|
||||
const mode = searchParams.get("mode") || initialMode;
|
||||
const requestedWorkspaceType =
|
||||
searchParams.get("workspaceType") || initialWorkspaceType;
|
||||
searchParams.get("workspaceType") ||
|
||||
initialWorkspaceType ||
|
||||
initialProject?.workspaceType ||
|
||||
"";
|
||||
const requestedAgentMode = mode === "agent";
|
||||
const isVideoMode = !requestedAgentMode && (mode === "video" || requestedWorkspaceType === "video");
|
||||
const legacyParams = useMemo(() => Promise.resolve({ id }), [id]);
|
||||
@ -67,7 +69,7 @@ export function CanvasPageClientRouter({
|
||||
if (isVideoMode) {
|
||||
return (
|
||||
<CanvasLoadingBrandProvider value={loadingBrand}>
|
||||
<LiblibVideoCanvasReplica canvasId={id} initialProject={initialProject} />
|
||||
<LiblibVideoCanvasReplica canvasId={id} />
|
||||
</CanvasLoadingBrandProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
type CanvasEditorModule = typeof import("./CanvasEditorLegacyPageClient");
|
||||
|
||||
let canvasEditorModulePromise: Promise<CanvasEditorModule> | null = null;
|
||||
|
||||
export function loadCanvasEditorModule() {
|
||||
canvasEditorModulePromise ??= import("./CanvasEditorLegacyPageClient");
|
||||
return canvasEditorModulePromise;
|
||||
}
|
||||
|
||||
export function preloadCanvasEditor() {
|
||||
void loadCanvasEditorModule();
|
||||
}
|
||||
@ -2798,9 +2798,9 @@ export default function CanvasModel3DPage() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorldFirstPerson(
|
||||
workspaceMode === "world" && worldViewportMode === "splat"
|
||||
);
|
||||
if (workspaceMode !== "world" || worldViewportMode !== "splat") {
|
||||
setWorldFirstPerson(false);
|
||||
}
|
||||
}, [workspaceMode, worldViewportMode]);
|
||||
|
||||
return (
|
||||
@ -4008,7 +4008,6 @@ export default function CanvasModel3DPage() {
|
||||
previewImageUrl={currentPreviewImage}
|
||||
resetViewToken={viewerResetToken}
|
||||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||||
showInfoOverlay={false}
|
||||
showMovementControls={
|
||||
worldViewportMode === "splat" || worldViewportMode === "mesh"
|
||||
}
|
||||
|
||||
@ -50,13 +50,10 @@ export default async function CanvasEditorPage({
|
||||
}: CanvasEditorPageProps) {
|
||||
const { id } = await params;
|
||||
const resolvedSearchParams = searchParams ? await searchParams : {};
|
||||
const bypassLocalDatabase = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
const [siteSettings, initialProject] = bypassLocalDatabase
|
||||
? [null, null]
|
||||
: await Promise.all([
|
||||
getSiteSettings().catch(() => null),
|
||||
getInitialCanvasProject(id).catch(() => null),
|
||||
]);
|
||||
const [siteSettings, initialProject] = await Promise.all([
|
||||
getSiteSettings().catch(() => null),
|
||||
getInitialCanvasProject(id).catch(() => null),
|
||||
]);
|
||||
const loadingBrand = {
|
||||
siteLogo: siteSettings?.siteLogo,
|
||||
siteLogoDark: siteSettings?.siteLogoDark,
|
||||
|
||||
137
app/globals.css
137
app/globals.css
@ -3,7 +3,6 @@
|
||||
|
||||
/* include utility classes in streamdown */
|
||||
@source '../node_modules/streamdown/dist/index.js';
|
||||
@source not '../services/pascal-editor';
|
||||
|
||||
/* custom variant for setting dark mode programmatically */
|
||||
@custom-variant dark (&:is(.dark, .dark *));
|
||||
@ -93,15 +92,6 @@
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
/* border radius unit */
|
||||
--radius: 0.5rem;
|
||||
--ui-control-sm: 2rem;
|
||||
--ui-control-md: 2.25rem;
|
||||
--ui-control-lg: 2.5rem;
|
||||
--ui-icon-sm: 0.875rem;
|
||||
--ui-icon-md: 1rem;
|
||||
--ui-radius-control: 0.4375rem;
|
||||
--ui-radius-panel: 0.75rem;
|
||||
--ui-motion-fast: 120ms;
|
||||
--ui-motion-panel: 160ms;
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--chat-user-bubble-bg: hsl(214 100% 50%);
|
||||
--chat-user-bubble-foreground: hsl(0 0% 100%);
|
||||
@ -267,29 +257,6 @@
|
||||
font-family: var(--font-jetbrains-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
[role="button"] {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:not(:disabled):active,
|
||||
[role="button"]:not([aria-disabled="true"]):active {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
@media (width >= 768px) {
|
||||
html {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.drawnix,
|
||||
.drawnix *,
|
||||
.drawnix-tutorial,
|
||||
@ -403,107 +370,3 @@
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */
|
||||
.settings-workspace {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.settings-workspace [class*="tracking-"] {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card"] {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-header"] {
|
||||
row-gap: 4px;
|
||||
padding: 14px 16px 10px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-title"] {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-description"] {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-content"] {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-footer"] {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="label"] {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settings-workspace .text-3xl {
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.settings-workspace .text-2xl {
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settings-workspace .text-xl {
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-workspace .text-lg {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-workspace .rounded-2xl,
|
||||
.settings-workspace .rounded-xl {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.settings-workspace .gap-6 {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-workspace .space-y-6 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-workspace button .lucide,
|
||||
.settings-workspace a .lucide {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.settings-workspace button,
|
||||
.settings-workspace a,
|
||||
.settings-workspace [role="button"] {
|
||||
transition-duration: 120ms;
|
||||
}
|
||||
|
||||
@media (width < 640px) {
|
||||
.settings-workspace [data-slot="card-header"] {
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-content"] {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-footer"] {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
"!lib/db/migrations/meta",
|
||||
"!public/vendor",
|
||||
"!public/three",
|
||||
"!services/pascal-editor",
|
||||
"!vercel.json"
|
||||
]
|
||||
},
|
||||
|
||||
@ -52,30 +52,30 @@ export function AgentExecutionSnapshotDialog({
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !snapshot ? (
|
||||
<div className="rounded-[var(--ui-radius-control)] border border-dashed p-5 text-[13px] text-muted-foreground">
|
||||
<div className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
|
||||
暂无快照详情
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="text-[11px] text-muted-foreground">风格化</div>
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="text-xs text-muted-foreground">风格化</div>
|
||||
<div className="mt-1 font-medium">{snapshot.presetName}</div>
|
||||
<div className="mt-1 text-[13px] text-muted-foreground">
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
类型:{snapshot.presetType === "video" ? "视频" : "生图"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
分类:{snapshot.categoryName || "未分类"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="text-[11px] text-muted-foreground">模型</div>
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="text-xs text-muted-foreground">模型</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{snapshot.resolvedModel?.displayName ||
|
||||
snapshot.defaultModel?.displayName ||
|
||||
"未记录"}
|
||||
</div>
|
||||
<div className="mt-1 text-[13px] text-muted-foreground">
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
槽位:{snapshot.slotCount} 个
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@ -84,8 +84,8 @@ export function AgentExecutionSnapshotDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="mb-2 text-[13px] font-medium">本次使用的 3 张素材图</div>
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 text-sm font-medium">本次使用的 3 张素材图</div>
|
||||
{uploadedImageUrls.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{uploadedImageUrls.map((url, index) => (
|
||||
@ -107,18 +107,18 @@ export function AgentExecutionSnapshotDialog({
|
||||
</div>
|
||||
|
||||
{snapshot.userInputText ? (
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="mb-2 text-[13px] font-medium">用户补充提示</div>
|
||||
<div className="whitespace-pre-wrap text-[13px] text-muted-foreground">
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 text-sm font-medium">用户补充提示</div>
|
||||
<div className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{snapshot.userInputText}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-[13px] font-medium">槽位详情</div>
|
||||
<div className="text-sm font-medium">槽位详情</div>
|
||||
{snapshot.slots.map((slot, index) => (
|
||||
<div key={`snapshot-slot-${slot.slotIndex}-${index}`} className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div key={`snapshot-slot-${slot.slotIndex}-${index}`} className="rounded-xl border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="font-medium">
|
||||
{slot.title || slot.uploadLabel || `槽位 ${slot.slotIndex + 1}`}
|
||||
|
||||
@ -109,6 +109,23 @@ import {
|
||||
import {
|
||||
normalizeAgentPresetModeConfig,
|
||||
} from "@/lib/agents/preset-mode-config";
|
||||
import {
|
||||
AGENT_PENDING_SERVER_ACCEPTANCE_TIMEOUT_MS,
|
||||
getAgentGenerationAcceptanceRemainingMs,
|
||||
isAgentGenerationRequestAccepted,
|
||||
} from "@/lib/agents/pending-generation-guard";
|
||||
import {
|
||||
isReplacementResultMode,
|
||||
shouldShowAgentGridResultSection,
|
||||
} from "@/lib/agents/generation-result-visibility";
|
||||
import {
|
||||
buildLatestAgentReplacementTaskMap,
|
||||
getAgentReplacementTaskResultImageUrl,
|
||||
isAgentReplacementTaskInPendingRun,
|
||||
resolveAgentReplacementCardStatus,
|
||||
type AgentReplacementCardStatus,
|
||||
type AgentReplacementTaskSnapshot,
|
||||
} from "@/lib/agents/replacement-task-status";
|
||||
import {
|
||||
defaultAgentWorkflowBusinessModeSettings,
|
||||
normalizeAgentWorkflowBusinessModeSettings,
|
||||
@ -1238,7 +1255,7 @@ function DetailResultLocalEditDialog({
|
||||
);
|
||||
if (unchangedRegion) {
|
||||
setActiveRegionId(unchangedRegion.id);
|
||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`);
|
||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小变化不明显,请重新框选`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@ -2392,14 +2409,12 @@ const IMAGE_OVERLAY_DANGER_TEXT_CLASS =
|
||||
const IMAGE_OVERLAY_DANGER_ICON_CLASS =
|
||||
"absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-rose-200/55 bg-zinc-950/82 text-rose-50 shadow-[0_12px_34px_rgba(0,0,0,0.50)] backdrop-blur-md transition-colors hover:border-rose-100 hover:bg-rose-500/35";
|
||||
|
||||
type ReplacementCardStatus =
|
||||
| "waiting"
|
||||
| "queued"
|
||||
| "generating"
|
||||
| "completed"
|
||||
| "passthrough"
|
||||
| "fallback"
|
||||
| "missing";
|
||||
type ReplacementCardStatus = AgentReplacementCardStatus;
|
||||
|
||||
interface AgentReplacementTasksResponse {
|
||||
success?: boolean;
|
||||
tasks?: AgentReplacementTaskSnapshot[];
|
||||
}
|
||||
|
||||
type ReplacementModeType = Extract<
|
||||
AgentGenerationResultModeType,
|
||||
@ -2473,7 +2488,9 @@ function areReplacementResultStatesEqual(
|
||||
);
|
||||
}
|
||||
|
||||
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
|
||||
function inferReplacementCardStatusFromText(
|
||||
text: string | null | undefined
|
||||
): "completed" | "passthrough" | "fallback" | null {
|
||||
const normalized = normalizeAssistantText(String(text || ""))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
@ -2502,30 +2519,19 @@ function getReplacementCardStatus(params: {
|
||||
generatedCount: number;
|
||||
isGenerating: boolean;
|
||||
activeRunIndex?: number | null;
|
||||
taskStatus?: AgentReplacementTaskSnapshot["status"] | null;
|
||||
isRequestedInPendingRun?: boolean;
|
||||
}): ReplacementCardStatus {
|
||||
if (
|
||||
typeof params.activeRunIndex === "number" &&
|
||||
params.activeRunIndex >= 0 &&
|
||||
params.generatedCount <= params.activeRunIndex
|
||||
) {
|
||||
if (params.isGenerating) {
|
||||
return params.generatedCount === params.activeRunIndex ? "generating" : "queued";
|
||||
}
|
||||
if (!params.resultUrl && params.generatedCount > 0) {
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
if (params.resultUrl) {
|
||||
return inferReplacementCardStatusFromText(params.resultText) || "completed";
|
||||
}
|
||||
if (params.isGenerating) {
|
||||
return params.index === params.generatedCount ? "generating" : "queued";
|
||||
}
|
||||
if (params.generatedCount > 0 && params.index >= params.generatedCount) {
|
||||
return "missing";
|
||||
}
|
||||
return "waiting";
|
||||
return resolveAgentReplacementCardStatus({
|
||||
index: params.index,
|
||||
resultUrl: params.resultUrl,
|
||||
persistedResultStatus: inferReplacementCardStatusFromText(params.resultText),
|
||||
generatedCount: params.generatedCount,
|
||||
isGenerating: params.isGenerating,
|
||||
activeRunIndex: params.activeRunIndex,
|
||||
taskStatus: params.taskStatus,
|
||||
isRequestedInPendingRun: params.isRequestedInPendingRun === true,
|
||||
});
|
||||
}
|
||||
|
||||
function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
|
||||
@ -2541,6 +2547,11 @@ function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
|
||||
label: "失败已沿用",
|
||||
className: "border-amber-100/80 bg-amber-500/92 text-zinc-950",
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
label: "生成失败",
|
||||
className: "border-rose-100/80 bg-rose-600/92 text-white",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
label: "已生成",
|
||||
@ -2574,6 +2585,8 @@ interface LocalGenerationPendingState {
|
||||
modeType: AgentGenerationResultModeType;
|
||||
expectedCount: number;
|
||||
userTurnCountAtStart: number;
|
||||
serverUserTurnCountAtStart?: number;
|
||||
requestMessageId?: string | null;
|
||||
timestamp: number;
|
||||
requestedCardIds?: string[] | null;
|
||||
}
|
||||
@ -2628,6 +2641,15 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
|
||||
modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType,
|
||||
expectedCount: Number(record.expectedCount),
|
||||
userTurnCountAtStart: Number(record.userTurnCountAtStart),
|
||||
serverUserTurnCountAtStart: Number.isFinite(
|
||||
Number(record.serverUserTurnCountAtStart)
|
||||
)
|
||||
? Number(record.serverUserTurnCountAtStart)
|
||||
: undefined,
|
||||
requestMessageId:
|
||||
typeof record.requestMessageId === "string"
|
||||
? record.requestMessageId.trim() || null
|
||||
: null,
|
||||
timestamp: Number(record.timestamp),
|
||||
requestedCardIds: Array.isArray(record.requestedCardIds)
|
||||
? record.requestedCardIds
|
||||
@ -2660,16 +2682,6 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
|
||||
}
|
||||
}
|
||||
|
||||
const DETAIL_RESULT_PROCESSING_STAGES = [
|
||||
"解码图像",
|
||||
"定位选区",
|
||||
"分离主体",
|
||||
"重绘背景",
|
||||
"融合边缘",
|
||||
"检查细节",
|
||||
"输出结果",
|
||||
];
|
||||
|
||||
function DetailResultProcessingScanOverlay({
|
||||
imageUrl,
|
||||
label,
|
||||
@ -2677,29 +2689,6 @@ function DetailResultProcessingScanOverlay({
|
||||
imageUrl?: string;
|
||||
label: string;
|
||||
}) {
|
||||
const [progress, setProgress] = useState(6);
|
||||
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
const durationMs = 240_000;
|
||||
const timer = window.setInterval(() => {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const linear = Math.min(1, elapsed / durationMs);
|
||||
const eased = 1 - Math.pow(1 - linear, 1.8);
|
||||
const drift = Math.sin(elapsed / 3200) * 1.2;
|
||||
const next = Math.max(6, Math.min(96, Math.round(eased * 96 + drift)));
|
||||
setProgress((current) => Math.max(current, next));
|
||||
}, 720);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [imageUrl, label]);
|
||||
|
||||
const stageIndex = Math.min(
|
||||
DETAIL_RESULT_PROCESSING_STAGES.length - 1,
|
||||
Math.floor((progress / 100) * DETAIL_RESULT_PROCESSING_STAGES.length)
|
||||
);
|
||||
const stageName = DETAIL_RESULT_PROCESSING_STAGES[stageIndex] || "处理中";
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
|
||||
<style>{`
|
||||
@ -2718,6 +2707,11 @@ function DetailResultProcessingScanOverlay({
|
||||
70% { box-shadow: 0 0 0 8px rgba(125, 211, 252, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0); }
|
||||
}
|
||||
@keyframes detailResultIndeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
55% { transform: translateX(80%); }
|
||||
100% { transform: translateX(260%); }
|
||||
}
|
||||
`}</style>
|
||||
{isHttpImageUrl(imageUrl) ? (
|
||||
<img
|
||||
@ -2790,24 +2784,13 @@ function DetailResultProcessingScanOverlay({
|
||||
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
<span className="min-w-[34px] text-right tabular-nums text-slate-300">{progress}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute bottom-3 left-3 z-[70] isolate inline-flex items-center gap-2 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-slate-100 shadow-[0_12px_28px_rgba(0,0,0,0.32)]"
|
||||
style={{
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
textShadow: "0 1px 1px rgba(0,0,0,0.72)",
|
||||
}}
|
||||
>
|
||||
<span className="tabular-nums text-sky-300">{String(stageIndex + 1).padStart(2, "0")}</span>
|
||||
<span>{stageName}</span>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 z-[60] h-0.5 bg-white/5">
|
||||
<div
|
||||
className="h-full rounded-r-full shadow-[0_0_8px_rgba(125,211,252,0.6)] transition-[width] duration-300 ease-linear"
|
||||
className="h-full w-1/3 rounded-full shadow-[0_0_8px_rgba(125,211,252,0.6)]"
|
||||
style={{
|
||||
animation: "detailResultIndeterminate 1.8s ease-in-out infinite",
|
||||
background: "linear-gradient(90deg, #7dd3fc, #a78bfa)",
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -3141,6 +3124,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||
const requestImageNodeAction = useCanvasStore((state) => state.requestImageNodeAction);
|
||||
const workspaceRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const resultsSectionRef = useRef<HTMLElement | null>(null);
|
||||
const replacementResultsContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastAutoRevealedResultRequestRef = useRef<string | null>(null);
|
||||
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const hiddenPromptRef = useRef<string | null>(null);
|
||||
@ -3215,7 +3201,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
);
|
||||
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
|
||||
const [activeBusinessModeKey, setActiveBusinessModeKey] =
|
||||
useState<AgentWorkflowBusinessModeKey>("main_sku");
|
||||
useState<AgentWorkflowBusinessModeKey>(
|
||||
normalizedIncomingWorkspaceState.activeBusinessModeKey || "main_sku"
|
||||
);
|
||||
const [activeResultModeType, setActiveResultModeType] =
|
||||
useState<AgentGenerationResultModeType>("scene_generation");
|
||||
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
|
||||
@ -3417,6 +3405,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
);
|
||||
|
||||
const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]);
|
||||
const serverUserMessageCount = useMemo(
|
||||
() => historyMessages.filter((message) => message.role === "user").length,
|
||||
[historyMessages]
|
||||
);
|
||||
const referenceImages = workflowContext?.referenceImages || [];
|
||||
const resolvedAgentModeState = agentModeState || localAgentModeState;
|
||||
const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState;
|
||||
@ -3482,6 +3474,54 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
|
||||
const isReplacementPreset =
|
||||
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
|
||||
const replacementTasksUrl =
|
||||
currentResultModeType === "detail_replacement" && rawChatId
|
||||
? `/api/canvas/generate/image?chatId=${encodeURIComponent(
|
||||
rawChatId
|
||||
)}${
|
||||
isValidChatUuid(projectId)
|
||||
? `&projectId=${encodeURIComponent(projectId)}`
|
||||
: ""
|
||||
}`
|
||||
: null;
|
||||
const {
|
||||
data: replacementTasksData,
|
||||
mutate: refreshReplacementTasks,
|
||||
} = useSWR<AgentReplacementTasksResponse>(replacementTasksUrl, fetcher, {
|
||||
refreshInterval: (latestData) =>
|
||||
pendingGeneration ||
|
||||
latestData?.tasks?.some(
|
||||
(task) => task.status === "pending" || task.status === "running"
|
||||
)
|
||||
? 2500
|
||||
: 0,
|
||||
revalidateOnFocus: true,
|
||||
});
|
||||
const replacementTaskSnapshots = useMemo(
|
||||
() =>
|
||||
Array.isArray(replacementTasksData?.tasks)
|
||||
? replacementTasksData.tasks.filter(
|
||||
(task) => task.modeType === "detail_replacement"
|
||||
)
|
||||
: [],
|
||||
[replacementTasksData?.tasks]
|
||||
);
|
||||
const latestDetailTaskByCardId = useMemo(
|
||||
() =>
|
||||
buildLatestAgentReplacementTaskMap({
|
||||
tasks: replacementTaskSnapshots,
|
||||
modeType: "detail_replacement",
|
||||
}),
|
||||
[replacementTaskSnapshots]
|
||||
);
|
||||
const hasActiveReplacementTasks = replacementTaskSnapshots.some(
|
||||
(task) => task.status === "pending" || task.status === "running"
|
||||
);
|
||||
const hasActiveDetailReplacementTasks = replacementTaskSnapshots.some(
|
||||
(task) =>
|
||||
task.modeType === "detail_replacement" &&
|
||||
(task.status === "pending" || task.status === "running")
|
||||
);
|
||||
const isProductOnlyDetailReplacementMode =
|
||||
currentResultModeType === "detail_replacement" &&
|
||||
detailReplacementStrategy === "product_only";
|
||||
@ -4713,6 +4753,74 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
latestReplacementResults,
|
||||
skuReplacementReferenceCards,
|
||||
]);
|
||||
useEffect(() => {
|
||||
const succeededTasks = replacementTaskSnapshots.filter(
|
||||
(task) =>
|
||||
task.modeType === "detail_replacement" &&
|
||||
task.status === "succeeded" &&
|
||||
Boolean(getAgentReplacementTaskResultImageUrl(task.result))
|
||||
);
|
||||
if (succeededTasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const referenceCardById = new Map(
|
||||
detailReplacementReferenceCards.map((card) => [card.id, card])
|
||||
);
|
||||
setReplacementResultState((current) => {
|
||||
const nextDetailState = { ...current.detail_replacement };
|
||||
let changed = false;
|
||||
|
||||
succeededTasks.forEach((task) => {
|
||||
const referenceCard = referenceCardById.get(task.referenceId);
|
||||
const resultUrl = getAgentReplacementTaskResultImageUrl(task.result);
|
||||
if (!referenceCard || !resultUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskUpdatedAt = Date.parse(
|
||||
task.completedAt || task.startedAt || task.createdAt
|
||||
);
|
||||
const previousRecord = nextDetailState[task.referenceId];
|
||||
if (
|
||||
previousRecord?.url === resultUrl ||
|
||||
(Number.isFinite(taskUpdatedAt) &&
|
||||
Number(previousRecord?.updatedAt || 0) > taskUpdatedAt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextDetailState[task.referenceId] = {
|
||||
url: resultUrl,
|
||||
text: "已从后台详情替换任务恢复生图结果。",
|
||||
status: "completed",
|
||||
updatedAt: Number.isFinite(taskUpdatedAt) ? taskUpdatedAt : Date.now(),
|
||||
compareSourceUrl: normalizeResultCompareSourceUrl(
|
||||
resultUrl,
|
||||
previousRecord?.url ||
|
||||
referenceCard.imageUrl ||
|
||||
referenceCard.previewUrl ||
|
||||
previousRecord?.compareSourceUrl ||
|
||||
null
|
||||
),
|
||||
referenceImageUrl:
|
||||
referenceCard.imageUrl || previousRecord?.referenceImageUrl || null,
|
||||
referencePreviewUrl:
|
||||
referenceCard.previewUrl || previousRecord?.referencePreviewUrl || null,
|
||||
label: referenceCard.label || previousRecord?.label || null,
|
||||
};
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
detail_replacement: nextDetailState,
|
||||
};
|
||||
});
|
||||
}, [detailReplacementReferenceCards, replacementTaskSnapshots]);
|
||||
const detailReplacementResultUrls = useMemo(
|
||||
() => {
|
||||
const urlsFromCards = detailReplacementReferenceCards
|
||||
@ -4797,6 +4905,32 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (pendingGeneration.modeType === "detail_replacement") {
|
||||
const completedCardIds = new Set<string>();
|
||||
latestReplacementResults.forEach((result, index) => {
|
||||
const cardId =
|
||||
result.replacementCardId || pendingGeneration.requestedCardIds?.[index] || null;
|
||||
if (cardId) {
|
||||
completedCardIds.add(cardId);
|
||||
}
|
||||
});
|
||||
(pendingGeneration.requestedCardIds || []).forEach((cardId) => {
|
||||
const task = latestDetailTaskByCardId.get(cardId);
|
||||
if (
|
||||
task &&
|
||||
(task.status === "succeeded" || task.status === "failed") &&
|
||||
isAgentReplacementTaskInPendingRun({
|
||||
task,
|
||||
pendingStartedAt: pendingGeneration.timestamp,
|
||||
requestMessageId: pendingGeneration.requestMessageId,
|
||||
})
|
||||
) {
|
||||
completedCardIds.add(cardId);
|
||||
}
|
||||
});
|
||||
return completedCardIds.size;
|
||||
}
|
||||
|
||||
if (
|
||||
pendingGeneration.modeType !== "detail_generation" &&
|
||||
userMessageCount <= pendingGeneration.userTurnCountAtStart
|
||||
@ -4816,22 +4950,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}, [
|
||||
currentSceneGeneratedUrls.length,
|
||||
detailGenerationRunResults.length,
|
||||
latestReplacementResults.length,
|
||||
latestDetailTaskByCardId,
|
||||
latestReplacementResults,
|
||||
pendingGeneration,
|
||||
userMessageCount,
|
||||
]);
|
||||
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
|
||||
const isSceneResultModeGenerating =
|
||||
(activeResultModeType === "scene_generation" &&
|
||||
pendingGeneration?.modeType === "scene_generation") ||
|
||||
(activeResultModeType === currentResultModeType &&
|
||||
currentResultModeType === "scene_generation" &&
|
||||
transportIsGenerating);
|
||||
const isAnyGenerationRunning =
|
||||
transportIsGenerating || Boolean(pendingGeneration) || hasActiveReplacementTasks;
|
||||
const isDetailResultModeGenerating =
|
||||
pendingGeneration?.modeType === "detail_replacement" ||
|
||||
(currentResultModeType === "detail_replacement" && transportIsGenerating);
|
||||
const isDetailGenerationResultModeGenerating =
|
||||
pendingGeneration?.modeType === "detail_generation";
|
||||
(currentResultModeType === "detail_replacement" &&
|
||||
(transportIsGenerating || hasActiveDetailReplacementTasks));
|
||||
const isSkuResultModeGenerating =
|
||||
pendingGeneration?.modeType === "sku_replacement" ||
|
||||
(currentResultModeType === "sku_replacement" && transportIsGenerating);
|
||||
@ -4933,12 +5062,25 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
Array.isArray(pendingGeneration.requestedCardIds)
|
||||
? pendingGeneration.requestedCardIds.indexOf(card.id)
|
||||
: -1;
|
||||
const latestTask = latestDetailTaskByCardId.get(card.id) || null;
|
||||
const isRequestedInPendingRun = activeRunIndex >= 0;
|
||||
const currentRunTask =
|
||||
!pendingGeneration ||
|
||||
pendingGeneration.modeType !== "detail_replacement" ||
|
||||
isAgentReplacementTaskInPendingRun({
|
||||
task: latestTask,
|
||||
pendingStartedAt: pendingGeneration.timestamp,
|
||||
requestMessageId: pendingGeneration.requestMessageId,
|
||||
})
|
||||
? latestTask
|
||||
: null;
|
||||
return {
|
||||
...card,
|
||||
index,
|
||||
resultUrl,
|
||||
resultText,
|
||||
compareSourceUrl,
|
||||
taskQueueStatus: currentRunTask?.queueStatus || null,
|
||||
queuePosition:
|
||||
activeRunIndex >= 0 && pendingGeneration?.modeType === "detail_replacement"
|
||||
? activeRunIndex + 1
|
||||
@ -4957,14 +5099,18 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
: 0,
|
||||
isGenerating: isDetailResultModeGenerating,
|
||||
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
|
||||
taskStatus: currentRunTask?.status || null,
|
||||
isRequestedInPendingRun,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
[
|
||||
detailReplacementReferenceCards,
|
||||
isDetailResultModeGenerating,
|
||||
latestDetailTaskByCardId,
|
||||
latestReplacementResults,
|
||||
normalizedDeletedResultCollections.detail_replacement,
|
||||
pendingGeneration,
|
||||
pendingGeneration?.modeType,
|
||||
pendingGeneration?.requestedCardIds,
|
||||
replacementResultState.detail_replacement,
|
||||
@ -5227,11 +5373,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
resultCompareSourceByUrl,
|
||||
]
|
||||
);
|
||||
const shouldShowGridResultSection =
|
||||
activeResultModeUrls.length > 0 ||
|
||||
((activeResultModeType === "scene_generation" && isSceneResultModeGenerating) ||
|
||||
(activeResultModeType === "detail_generation" &&
|
||||
(isDetailGenerationResultModeGenerating || hasDetailGenerationPlaceholderState)));
|
||||
const shouldShowGridResultSection = shouldShowAgentGridResultSection({
|
||||
activeModeType: activeResultModeType,
|
||||
activeResultCount: activeResultModeUrls.length,
|
||||
currentModeType: currentResultModeType,
|
||||
pendingModeType: pendingGeneration?.modeType,
|
||||
transportIsGenerating,
|
||||
hasDetailGenerationPlaceholderState,
|
||||
});
|
||||
const hasBrandModelSelection = Boolean(
|
||||
selectedBrandModel?.imageUrl || referenceImages.some((image) => image.source === "brandModel")
|
||||
);
|
||||
@ -5350,6 +5499,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
};
|
||||
return areImageGenerationParamsEqual(current, nextParams) ? current : nextParams;
|
||||
});
|
||||
setActiveBusinessModeKey((current) => {
|
||||
const nextBusinessModeKey =
|
||||
normalizedIncomingWorkspaceState.activeBusinessModeKey || "main_sku";
|
||||
return current === nextBusinessModeKey ? current : nextBusinessModeKey;
|
||||
});
|
||||
}, [isWorkflowModelLocked, normalizedIncomingWorkspaceState]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -5366,6 +5520,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
: null
|
||||
),
|
||||
selectedModelId,
|
||||
activeBusinessModeKey,
|
||||
imageParams: {
|
||||
aspectRatio: imageParams.aspectRatio,
|
||||
imageSize: imageParams.imageSize,
|
||||
@ -5375,6 +5530,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
workspaceStateSignatureRef.current = JSON.stringify(nextWorkspaceState);
|
||||
onWorkspaceStateChange?.(nextWorkspaceState);
|
||||
}, [
|
||||
activeBusinessModeKey,
|
||||
attachments,
|
||||
imageParams.aspectRatio,
|
||||
imageParams.imageCount,
|
||||
@ -5398,6 +5554,29 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}
|
||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || !isReplacementResultMode(pendingGeneration.modeType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey =
|
||||
pendingGeneration.requestMessageId ||
|
||||
`${pendingGeneration.chatId}:${pendingGeneration.timestamp}`;
|
||||
if (lastAutoRevealedResultRequestRef.current === requestKey) {
|
||||
return;
|
||||
}
|
||||
lastAutoRevealedResultRequestRef.current = requestKey;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const target = replacementResultsContentRef.current || resultsSectionRef.current;
|
||||
target?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [pendingGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || !chatError) {
|
||||
return;
|
||||
@ -5413,6 +5592,72 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
clearError();
|
||||
}, [chatError, clearError, pendingGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!pendingGeneration ||
|
||||
(pendingGeneration.modeType !== "detail_replacement" &&
|
||||
pendingGeneration.modeType !== "sku_replacement")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (transportIsGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestAccepted = isAgentGenerationRequestAccepted({
|
||||
pending: pendingGeneration,
|
||||
serverMessages: historyMessages,
|
||||
});
|
||||
if (requestAccepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingMs = getAgentGenerationAcceptanceRemainingMs({
|
||||
startedAt: pendingGeneration.timestamp,
|
||||
timeoutMs: AGENT_PENDING_SERVER_ACCEPTANCE_TIMEOUT_MS,
|
||||
});
|
||||
const clearUnacceptedGeneration = () => {
|
||||
generationRequestSnapshotRef.current = {
|
||||
hiddenPrompt: null,
|
||||
replacementExecutionCardIds: null,
|
||||
replacementRerunReferenceImages: null,
|
||||
};
|
||||
try {
|
||||
stop();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AgentGenerationWorkspaceDarkV2] Failed to stop unaccepted generation request:",
|
||||
error
|
||||
);
|
||||
}
|
||||
setPendingGeneration((current) => {
|
||||
if (
|
||||
!current ||
|
||||
current.chatId !== pendingGeneration.chatId ||
|
||||
current.timestamp !== pendingGeneration.timestamp
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
toast.error("生成请求未被服务器接收,已结束处理中状态,请重新提交。");
|
||||
};
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
clearUnacceptedGeneration();
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(clearUnacceptedGeneration, remainingMs);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [
|
||||
historyMessages,
|
||||
pendingGeneration,
|
||||
serverUserMessageCount,
|
||||
stop,
|
||||
transportIsGenerating,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration) {
|
||||
return;
|
||||
@ -6344,6 +6589,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
);
|
||||
setActiveResultModeType(currentResultModeType);
|
||||
setSelectedResultUrl(null);
|
||||
const requestMessageId = generateUUID();
|
||||
setPendingGeneration({
|
||||
chatId,
|
||||
modeType: currentResultModeType,
|
||||
@ -6356,6 +6602,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
? detailReplacementReferenceCards.length
|
||||
: skuReplacementReferenceCards.length,
|
||||
userTurnCountAtStart: userMessageCount,
|
||||
serverUserTurnCountAtStart: serverUserMessageCount,
|
||||
requestMessageId,
|
||||
timestamp: Date.now(),
|
||||
requestedCardIds:
|
||||
replacementModeType && replacementExecutionCards.length > 0
|
||||
@ -6366,7 +6614,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
||||
}
|
||||
try {
|
||||
void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => {
|
||||
void Promise.resolve(
|
||||
sendMessage({ id: requestMessageId, role: "user", parts })
|
||||
).catch((error) => {
|
||||
generationRequestSnapshotRef.current = {
|
||||
hiddenPrompt: null,
|
||||
replacementExecutionCardIds: null,
|
||||
@ -6375,6 +6625,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
setPendingGeneration(null);
|
||||
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
|
||||
});
|
||||
if (replacementModeType === "detail_replacement") {
|
||||
window.setTimeout(() => {
|
||||
void refreshReplacementTasks();
|
||||
}, 500);
|
||||
}
|
||||
if (selectedAgentPreset?.type !== "image") {
|
||||
setInput("");
|
||||
}
|
||||
@ -6407,11 +6662,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
executableModelIdSet,
|
||||
lockedWorkflowModelId,
|
||||
requiredProductUploadCount,
|
||||
refreshReplacementTasks,
|
||||
requiresPresetSelection,
|
||||
selectedAgentPreset,
|
||||
selectedAgentPresetId,
|
||||
selectedModelId,
|
||||
sendMessage,
|
||||
serverUserMessageCount,
|
||||
setPendingGeneration,
|
||||
skuReplacementReferenceCards.length,
|
||||
skuResultCards,
|
||||
@ -9632,9 +9889,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
) : null}
|
||||
|
||||
<motion.section
|
||||
ref={resultsSectionRef}
|
||||
id="agent-anchor-results"
|
||||
className={cn(
|
||||
"relative flex min-h-[620px] flex-col rounded-[28px] border border-white/12 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
|
||||
"relative flex min-h-[620px] scroll-mt-24 flex-col rounded-[28px] border border-white/12 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
|
||||
isDetailReplacementPreset ? "overflow-visible" : "overflow-hidden"
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
@ -9807,84 +10065,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
{isDetailReplacementPreset &&
|
||||
!isDetailAiGenerationMethod &&
|
||||
activeResultModeType === currentResultModeType ? (
|
||||
<div className="grid flex-1 min-h-0 grid-cols-1 gap-0 xl:grid-cols-[320px_repeat(2,minmax(0,1fr))]">
|
||||
<div className="border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">输入图</div>
|
||||
<div className="text-xs text-white/40">
|
||||
选中的白底图会作为参考图替换的输入图组
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
|
||||
{selectedProductAttachments.length} 张
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{selectedProductAttachments.length > 0 ? (
|
||||
selectedProductAttachments.map((attachment, index) => (
|
||||
<div
|
||||
key={`${attachment?.url || "input"}-${index}`}
|
||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
||||
className="overflow-hidden rounded-[22px] border border-white/10 bg-black/20"
|
||||
>
|
||||
<div className="aspect-square overflow-hidden bg-black/30">
|
||||
<AgentPreviewImage
|
||||
src={attachment!.url}
|
||||
alt={attachment!.name || `输入图 ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
previewWidth={400}
|
||||
previewQuality={74}
|
||||
onError={(event) =>
|
||||
handlePreviewImageLoadError(event, attachment!.url)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-white/10 px-3 py-2">
|
||||
<div className="text-xs font-medium text-white/80">
|
||||
输入图 {index + 1}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-white/40">
|
||||
{attachment?.name || "已选择白底图"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex min-h-[160px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/42">
|
||||
请先在当前“选图与生成”页的输入图槽位中确认用于参考图替换的白底输入图。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedModelAttachment ? (
|
||||
<div className="overflow-hidden rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06]">
|
||||
<div className="relative aspect-[5/4] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_58%),linear-gradient(180deg,#08110d_0%,#101916_100%)]">
|
||||
<AgentPreviewImage
|
||||
src={selectedModelAttachment.url}
|
||||
alt={selectedModelAttachment.name || "模特图"}
|
||||
className="h-full w-full object-cover"
|
||||
previewWidth={720}
|
||||
previewQuality={76}
|
||||
onError={(event) =>
|
||||
handlePreviewImageLoadError(event, selectedModelAttachment.url)
|
||||
}
|
||||
/>
|
||||
<div className="absolute left-3 top-3 rounded-full border border-emerald-300/25 bg-black/45 px-2.5 py-1 text-[11px] text-emerald-100">
|
||||
模特图
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-emerald-400/15 px-3 py-2">
|
||||
<div className="truncate text-xs font-medium text-emerald-100">
|
||||
{selectedModelAttachment.name || "已选择模特图"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-0 lg:grid-cols-2">
|
||||
<div className="min-h-0 border-b border-white/10 p-4 lg:border-b-0 lg:border-r">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{detailReferenceSourceLabel}参考图</div>
|
||||
@ -10063,7 +10245,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 p-4">
|
||||
<div
|
||||
ref={replacementResultsContentRef}
|
||||
className="min-h-0 scroll-mt-24 p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">替换结果</div>
|
||||
@ -10330,10 +10515,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
{isPending
|
||||
? card.status === "generating"
|
||||
? "正在重做这一屏,新的结果回来后会直接覆盖当前展示。"
|
||||
: Number(card.taskQueueStatus?.queuePosition || 0) > 0
|
||||
? `已进入生图队列,前方还有 ${card.taskQueueStatus?.queuePosition} 个同模型任务。`
|
||||
: card.queuePosition && card.queueTotal
|
||||
? `当前批次位次 ${card.queuePosition}/${card.queueTotal},前面的屏完成后会继续处理这一屏。`
|
||||
: "前面的屏生成完成后,会继续处理这一屏。"
|
||||
: card.resultUrl
|
||||
? `当前批次位次 ${card.queuePosition}/${card.queueTotal},已进入生图队列。`
|
||||
: "已进入生图队列,正在等待可用并发。"
|
||||
: card.status === "failed"
|
||||
? card.resultUrl
|
||||
? "本次重新生成失败,已保留上一次结果,可再次重试。"
|
||||
: "本次生成失败,可点击开始处理重新生成。"
|
||||
: card.resultUrl
|
||||
? card.status === "passthrough"
|
||||
? "当前参考图未检测到可替换产品,本屏先沿用参考图展示;如需继续调整,仍可在当前页改字或重新生图。"
|
||||
: card.status === "fallback"
|
||||
@ -10724,7 +10915,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 p-4">
|
||||
<div
|
||||
ref={replacementResultsContentRef}
|
||||
className="min-h-0 scroll-mt-24 p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">替换结果</div>
|
||||
|
||||
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
47
components/ai-cad/zoo-design-studio-frame.tsx
Normal file
47
components/ai-cad/zoo-design-studio-frame.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
type ZooDesignStudioFrameProps = {
|
||||
isReady: boolean;
|
||||
src: string;
|
||||
};
|
||||
|
||||
export function ZooDesignStudioFrame({ isReady, src }: ZooDesignStudioFrameProps) {
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="flex h-svh w-full items-center justify-center overflow-hidden bg-[#10100e] p-6 text-[#f8f6ef]">
|
||||
<div className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-lg">
|
||||
HiCAD 官方前端尚未构建
|
||||
</h1>
|
||||
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
|
||||
请运行
|
||||
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
|
||||
pnpm zoo-modeling-app:vendor
|
||||
</code>
|
||||
生成官方 Zoo Design Studio 前端。生成后刷新本页即可使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-svh w-full overflow-hidden bg-[#10100e]">
|
||||
<iframe
|
||||
allow="clipboard-read; clipboard-write; fullscreen; gamepad; pointer-lock; web-share; xr-spatial-tracking"
|
||||
className="h-full w-full border-0"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
src={src}
|
||||
title="HiCAD"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -189,7 +189,7 @@ export function AngleEditDialog({
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
overlayClassName="bg-black"
|
||||
className="max-w-4xl gap-0 overflow-hidden border border-border !bg-white p-0 shadow-2xl sm:rounded-[var(--ui-radius-panel)] dark:!bg-zinc-950"
|
||||
className="max-w-4xl p-0 gap-0 overflow-hidden border border-border shadow-2xl !bg-white dark:!bg-zinc-950 sm:rounded-[20px]"
|
||||
onPointerDownCapture={stopDialogEventCapture}
|
||||
onPointerMoveCapture={stopDialogEventCapture}
|
||||
onPointerUpCapture={stopDialogEventCapture}
|
||||
|
||||
@ -63,7 +63,7 @@ function MaterialImageCard({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group overflow-hidden rounded-[var(--ui-radius-control)] border bg-background text-left transition-all",
|
||||
"group overflow-hidden rounded-xl border bg-background text-left transition-all",
|
||||
selected
|
||||
? "border-primary shadow-[0_0_0_1px_hsl(var(--primary)/0.25)]"
|
||||
: "border-border hover:border-primary/45"
|
||||
@ -91,11 +91,11 @@ function MaterialImageCard({
|
||||
{selected ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5 px-2.5 py-2">
|
||||
<div className="truncate text-[13px] font-medium">
|
||||
<div className="space-y-1 px-3 py-2.5">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{image.label || image.fileName || "白底图"}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{image.group === "fixed" ? "固定角度" : "补充图"}</span>
|
||||
<span className="truncate">{image.fileName || image.key}</span>
|
||||
</div>
|
||||
@ -114,7 +114,7 @@ function SkuOptionCard({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-[var(--ui-radius-control)] border bg-background p-2.5 text-left transition-colors",
|
||||
"flex items-center gap-3 rounded-xl border bg-background p-3 text-left transition-colors",
|
||||
option.hasWhiteBackground
|
||||
? "hover:border-primary/55 hover:bg-muted/30"
|
||||
: "cursor-not-allowed opacity-55"
|
||||
@ -123,7 +123,7 @@ function SkuOptionCard({
|
||||
onClick={() => onSelect(option.skuName)}
|
||||
type="button"
|
||||
>
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[6px] border bg-muted/40">
|
||||
<div className="h-14 w-14 overflow-hidden rounded-lg border bg-muted/40">
|
||||
{option.previewImageUrl ? (
|
||||
<AgentPreviewImage
|
||||
alt={option.previewFileName || option.skuName}
|
||||
@ -139,8 +139,8 @@ function SkuOptionCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium">{option.skuName}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
<div className="truncate text-sm font-medium">{option.skuName}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{option.hasWhiteBackground
|
||||
? `${option.whiteBackgroundCount} 张白底图`
|
||||
: "暂无白底图"}
|
||||
@ -266,9 +266,9 @@ export function CanvasAddImageDialog({
|
||||
return (
|
||||
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
|
||||
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-5 pb-3 pt-4">
|
||||
<DialogHeader className="border-b px-6 pt-5 pb-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
添加图片
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
@ -276,11 +276,11 @@ export function CanvasAddImageDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
|
||||
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row">
|
||||
<div className="max-h-[62vh] overflow-y-auto px-6 py-5">
|
||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button
|
||||
className="h-9 justify-start gap-2"
|
||||
className="h-11 justify-start gap-2 rounded-xl"
|
||||
onClick={() => {
|
||||
onCreateBlankImage();
|
||||
closeDialog();
|
||||
@ -293,14 +293,14 @@ export function CanvasAddImageDialog({
|
||||
</Button>
|
||||
|
||||
{materialSearchEnabled ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2.5 sm:flex-row">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row">
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setQueryMode(value === "sku" ? "sku" : "spu")
|
||||
}
|
||||
value={queryMode}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[108px]">
|
||||
<SelectTrigger className="h-11 w-full rounded-xl sm:w-[118px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -310,7 +310,7 @@ export function CanvasAddImageDialog({
|
||||
</Select>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
className="h-9 pr-9"
|
||||
className="h-11 rounded-xl pr-10"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setErrorText(null);
|
||||
@ -338,7 +338,7 @@ export function CanvasAddImageDialog({
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
className="h-9 gap-2"
|
||||
className="h-11 gap-2 rounded-xl"
|
||||
disabled={isSearching}
|
||||
onClick={() => void handleSearch()}
|
||||
type="button"
|
||||
@ -354,14 +354,14 @@ export function CanvasAddImageDialog({
|
||||
) : null}
|
||||
</div>
|
||||
{!materialSearchEnabled ? (
|
||||
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
|
||||
<div className="mt-3 rounded-xl border border-dashed bg-background/70 px-3 py-2 text-muted-foreground text-xs">
|
||||
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errorText ? (
|
||||
<div className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
||||
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive text-sm">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
@ -370,8 +370,8 @@ export function CanvasAddImageDialog({
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-medium">选择 SKU</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
<div className="font-medium text-sm">选择 SKU</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
命中 {skuOptions.length} 个 SKU,选择有白底图的 SKU 继续拉取图片。
|
||||
</div>
|
||||
</div>
|
||||
@ -392,11 +392,11 @@ export function CanvasAddImageDialog({
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
白底图素材
|
||||
{result?.matchedSku ? ` · ${result.matchedSku}` : ""}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
共 {images.length} 张,已选 {selectedImages.length} 张。
|
||||
</div>
|
||||
</div>
|
||||
@ -432,13 +432,13 @@ export function CanvasAddImageDialog({
|
||||
</div>
|
||||
</div>
|
||||
) : materialSearchEnabled && !isSearching && !skuOptions.length && result ? (
|
||||
<div className="mt-5 flex min-h-[160px] items-center justify-center rounded-[var(--ui-radius-panel)] border border-dashed text-[13px] text-muted-foreground">
|
||||
<div className="mt-5 flex min-h-[160px] items-center justify-center rounded-2xl border border-dashed text-muted-foreground text-sm">
|
||||
当前没有可添加的白底图。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t px-5 py-3">
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<Button
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -127,8 +127,8 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-md flex-col gap-0 overflow-hidden bg-background/95 p-0 backdrop-blur-sm">
|
||||
<DialogHeader className="border-b px-4 py-3">
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden bg-background/95 backdrop-blur-sm">
|
||||
<DialogHeader className="p-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>已生成文件列表</DialogTitle>
|
||||
<Button size="sm" onClick={handleCreateNew} className="h-8">
|
||||
@ -139,14 +139,14 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
<DialogDescription className="hidden">画布列表</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-3">
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="py-10 text-center text-[13px] text-muted-foreground">
|
||||
<FileImage className="mx-auto mb-2 h-8 w-8 opacity-20" />
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<FileImage className="h-12 w-12 mx-auto mb-3 opacity-20" />
|
||||
<p>暂无画布历史</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -156,7 +156,7 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
key={project.id}
|
||||
onClick={() => handleSwitch(project.id)}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center gap-2.5 rounded-[var(--ui-radius-control)] border p-2 transition-all",
|
||||
"flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all border",
|
||||
"hover:bg-muted/50 hover:shadow-sm",
|
||||
project.id === currentId
|
||||
? "bg-primary/5 border-primary/20 ring-1 ring-primary/10"
|
||||
@ -164,19 +164,19 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
)}
|
||||
>
|
||||
{/* 列表只展示轻量占位,不拉取未打开项目的云端图片 */}
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[6px] border bg-muted/50">
|
||||
<FileImage className="h-4 w-4 text-muted-foreground/40" />
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted/50">
|
||||
<FileImage className="h-5 w-5 text-muted-foreground/40" />
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={cn(
|
||||
"truncate text-[13px] font-medium",
|
||||
"font-medium text-sm truncate",
|
||||
project.id === currentId && "text-primary"
|
||||
)}>
|
||||
{project.title || "未命名画布"}
|
||||
</h4>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(project.updatedAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
@ -188,7 +188,7 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground opacity-0 hover:text-destructive group-hover:opacity-100"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => handleDelete(e, project.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@ -210,7 +210,7 @@ export function CanvasFileList({ open, onOpenChange, currentId }: CanvasFileList
|
||||
加载更多
|
||||
</Button>
|
||||
) : (
|
||||
<div className="py-3 text-center text-[11px] text-muted-foreground/50">
|
||||
<div className="text-center py-4 text-xs text-muted-foreground/50">
|
||||
到底了
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -34,24 +34,24 @@ export function CanvasImageDownloadProgressDialog({
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/42 px-4 backdrop-blur-[2px]"
|
||||
data-canvas-download-progress="true"
|
||||
>
|
||||
<div className="w-full max-w-[360px] rounded-[var(--ui-radius-panel)] border border-white/12 bg-[#111317] p-4 text-white shadow-[0_20px_64px_rgba(0,0,0,0.4)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-[var(--ui-radius-control)] bg-white/10 text-cyan-200">
|
||||
<div className="w-full max-w-[380px] rounded-[18px] border border-white/12 bg-[#111317] p-5 text-white shadow-[0_24px_80px_rgba(0,0,0,0.42)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/10 text-cyan-200">
|
||||
{progress.phase === "saving" ? (
|
||||
<Download className="h-4 w-4" />
|
||||
<Download className="h-5 w-5" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold">下载原图</div>
|
||||
<div className="text-[15px] font-semibold">下载原图</div>
|
||||
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{onCancel ? (
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--ui-radius-control)] text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||
onClick={onCancel}
|
||||
title="取消下载"
|
||||
type="button"
|
||||
@ -61,7 +61,7 @@ export function CanvasImageDownloadProgressDialog({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
|
||||
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
|
||||
<span>{percent !== null ? `${percent}%` : "..."}</span>
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useCallback, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||
import type { AgentSlotNodeData, CanvasEdge, CanvasNode, Position } from "@/types/canvas";
|
||||
import { ImageNode } from "./nodes/ImageNode";
|
||||
import { AgentSlotNode } from "./nodes/AgentSlotNode";
|
||||
import { Model3DNode } from "./nodes/Model3DNode";
|
||||
import { TextNode } from "./nodes/TextNode";
|
||||
import { UiScreenNode } from "./nodes/UiScreenNode";
|
||||
import { VideoNode } from "./nodes/VideoNode";
|
||||
import { WorkflowNode } from "./nodes/WorkflowNode";
|
||||
import { World3DNode } from "./nodes/World3DNode";
|
||||
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -20,11 +22,13 @@ import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Download,
|
||||
FileImage,
|
||||
Flag,
|
||||
GitBranch,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
ImagePlus,
|
||||
LayoutTemplate,
|
||||
LayoutGrid,
|
||||
Lightbulb,
|
||||
Minus,
|
||||
@ -66,76 +70,6 @@ import {
|
||||
} from "@/lib/canvas/agent-pending-requests";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
|
||||
type ImageNodeModule = typeof import("./nodes/ImageNode");
|
||||
type Model3DNodeModule = typeof import("./nodes/Model3DNode");
|
||||
type World3DNodeModule = typeof import("./nodes/World3DNode");
|
||||
|
||||
let imageNodeModulePromise: Promise<ImageNodeModule> | null = null;
|
||||
let model3DNodeModulePromise: Promise<Model3DNodeModule> | null = null;
|
||||
let world3DNodeModulePromise: Promise<World3DNodeModule> | null = null;
|
||||
|
||||
function loadImageNodeModule() {
|
||||
imageNodeModulePromise ??= import("./nodes/ImageNode");
|
||||
return imageNodeModulePromise;
|
||||
}
|
||||
|
||||
function loadModel3DNodeModule() {
|
||||
model3DNodeModulePromise ??= import("./nodes/Model3DNode");
|
||||
return model3DNodeModulePromise;
|
||||
}
|
||||
|
||||
function loadWorld3DNodeModule() {
|
||||
world3DNodeModulePromise ??= import("./nodes/World3DNode");
|
||||
return world3DNodeModulePromise;
|
||||
}
|
||||
|
||||
function preloadCanvas3DNodeRenderers() {
|
||||
void loadModel3DNodeModule();
|
||||
void loadWorld3DNodeModule();
|
||||
}
|
||||
|
||||
function preloadCanvasImageNodeRenderer() {
|
||||
void loadImageNodeModule();
|
||||
}
|
||||
|
||||
function CanvasImageNodeLoading() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-[12px] border border-dashed border-[#d8dee8] bg-[#f8f9fb] text-[12px] text-[#7a8494] dark:border-white/12 dark:bg-white/5 dark:text-white/45">
|
||||
正在加载图片节点
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Canvas3DNodeLoading() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-[12px] border border-dashed border-[#d8dee8] bg-[#f6f7f9] text-[12px] text-[#7a8494] dark:border-white/12 dark:bg-white/5 dark:text-white/45">
|
||||
正在加载 3D 预览
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ImageNode = dynamic(
|
||||
() => loadImageNodeModule().then((module) => module.ImageNode),
|
||||
{
|
||||
loading: () => <CanvasImageNodeLoading />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
const Model3DNode = dynamic(
|
||||
() => loadModel3DNodeModule().then((module) => module.Model3DNode),
|
||||
{
|
||||
loading: () => <Canvas3DNodeLoading />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
const World3DNode = dynamic(
|
||||
() => loadWorld3DNodeModule().then((module) => module.World3DNode),
|
||||
{
|
||||
loading: () => <Canvas3DNodeLoading />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const CANVAS_NO_DRAG_SELECTOR =
|
||||
'[data-canvas-no-drag="true"], [data-canvas-no-drag="true"] *';
|
||||
const CANVAS_DRAG_CANCEL_SELECTOR = [
|
||||
@ -420,14 +354,6 @@ export function CanvasWorkspace({
|
||||
const tool = useCanvasStore(canvasSelectors.tool);
|
||||
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
|
||||
const setViewport = useCanvasStore((state) => state.setViewport);
|
||||
const hasImageNodes = useMemo(
|
||||
() => nodes.some((node) => node.type === "image"),
|
||||
[nodes]
|
||||
);
|
||||
const has3DNodes = useMemo(
|
||||
() => nodes.some((node) => node.type === "model3d" || node.type === "world3d"),
|
||||
[nodes]
|
||||
);
|
||||
const viewportRef = useRef(viewport);
|
||||
const pendingViewportRef = useRef<typeof viewport | null>(null);
|
||||
const pendingViewportRafRef = useRef<number | null>(null);
|
||||
@ -446,92 +372,6 @@ export function CanvasWorkspace({
|
||||
viewportRef.current = viewport;
|
||||
}, [viewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasImageNodes) {
|
||||
preloadCanvasImageNodeRenderer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
const connection = (
|
||||
navigator as Navigator & {
|
||||
connection?: { effectiveType?: string; saveData?: boolean };
|
||||
}
|
||||
).connection;
|
||||
if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idleWindow = window as typeof window & {
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
requestIdleCallback?: (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions
|
||||
) => number;
|
||||
};
|
||||
let idleId: number | null = null;
|
||||
const warmupTimer = window.setTimeout(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
idleId = idleWindow.requestIdleCallback(preloadCanvasImageNodeRenderer, {
|
||||
timeout: 12000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
preloadCanvasImageNodeRenderer();
|
||||
}, 9000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(warmupTimer);
|
||||
if (idleId !== null) {
|
||||
idleWindow.cancelIdleCallback?.(idleId);
|
||||
}
|
||||
};
|
||||
}, [hasImageNodes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (has3DNodes) {
|
||||
preloadCanvas3DNodeRenderers();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
const connection = (
|
||||
navigator as Navigator & {
|
||||
connection?: { effectiveType?: string; saveData?: boolean };
|
||||
}
|
||||
).connection;
|
||||
if (connection?.saveData || /(^|-)2g$/i.test(connection?.effectiveType || "")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idleWindow = window as typeof window & {
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
requestIdleCallback?: (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions
|
||||
) => number;
|
||||
};
|
||||
let idleId: number | null = null;
|
||||
const warmupTimer = window.setTimeout(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
idleId = idleWindow.requestIdleCallback(preloadCanvas3DNodeRenderers, {
|
||||
timeout: 15000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
preloadCanvas3DNodeRenderers();
|
||||
}, 12000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(warmupTimer);
|
||||
if (idleId !== null) {
|
||||
idleWindow.cancelIdleCallback?.(idleId);
|
||||
}
|
||||
};
|
||||
}, [has3DNodes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pendingViewportRafRef.current !== null) {
|
||||
@ -715,8 +555,19 @@ export function CanvasWorkspace({
|
||||
canvasToolbarConfig,
|
||||
"imageLocalEdit"
|
||||
);
|
||||
const selectedImageLayoutReplaceItem = getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"imageLayoutReplace"
|
||||
);
|
||||
const selectedImagePsdTemplateReplaceItem = getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"imagePsdTemplateReplace"
|
||||
);
|
||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||
const selectedImageLocalEditEnabled = true;
|
||||
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
|
||||
const selectedImageLayoutReplaceEnabled = true;
|
||||
const selectedImagePsdTemplateReplaceEnabled = true;
|
||||
|
||||
// Store actions
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
@ -2529,6 +2380,53 @@ export function CanvasWorkspace({
|
||||
{selectedImageLocalEditItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedImagePsdTemplateReplaceEnabled && selectedImageNodes.length === 1 ? (
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-1.5 border border-border/60 px-3 text-xs shadow-sm",
|
||||
isWorkflowCanvas &&
|
||||
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||
)}
|
||||
onClick={() =>
|
||||
requestImageNodeAction(selectedImageNodes[0].id, "psd-template")
|
||||
}
|
||||
title={selectedImagePsdTemplateReplaceItem.label}
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-3.5 w-3.5"
|
||||
fallback={FileImage}
|
||||
item={selectedImagePsdTemplateReplaceItem}
|
||||
/>
|
||||
{selectedImagePsdTemplateReplaceItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedImageLayoutReplaceEnabled && selectedImageNodes.length === 1 ? (
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
|
||||
isWorkflowCanvas &&
|
||||
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||
)}
|
||||
onClick={() =>
|
||||
requestImageNodeAction(
|
||||
selectedImageNodes[0].id,
|
||||
"layout-replace"
|
||||
)
|
||||
}
|
||||
title={selectedImageLayoutReplaceItem.label}
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-3.5 w-3.5"
|
||||
fallback={LayoutTemplate}
|
||||
item={selectedImageLayoutReplaceItem}
|
||||
/>
|
||||
{selectedImageLayoutReplaceItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3599,7 +3599,7 @@ export function RealtimeCanvasDialog({
|
||||
const primaryActionButtonClass =
|
||||
"border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black dark:border-white dark:bg-white dark:text-[#111111] dark:hover:border-white/90 dark:hover:bg-white/90";
|
||||
const compactToolButtonClass =
|
||||
"h-9 w-9 rounded-[var(--ui-radius-control)] border border-transparent text-[#666666] transition hover:border-[#e5e5e5] hover:bg-[#fafafa] hover:text-[#111111] dark:text-white/58 dark:hover:border-white/12 dark:hover:bg-white/10 dark:hover:text-white";
|
||||
"h-9 w-9 rounded-[16px] border border-transparent text-[#666666] transition hover:border-[#e5e5e5] hover:bg-[#fafafa] hover:text-[#111111] dark:text-white/58 dark:hover:border-white/12 dark:hover:bg-white/10 dark:hover:text-white";
|
||||
const compactToolButtonActiveClass =
|
||||
"border-[#111111] bg-[#111111] text-white shadow-[0_10px_20px_rgba(0,0,0,0.12)] hover:bg-black hover:text-white dark:border-white dark:bg-white dark:text-[#111111] dark:hover:bg-white/90 dark:hover:text-[#111111]";
|
||||
const modeSwitchIconSlotClass =
|
||||
@ -3647,12 +3647,12 @@ export function RealtimeCanvasDialog({
|
||||
<div className="pointer-events-none absolute top-5 left-5 right-5 z-50 flex flex-wrap items-start gap-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto inline-flex max-w-full items-center gap-1.5 rounded-[var(--ui-radius-panel)] p-1.5",
|
||||
"pointer-events-auto inline-flex max-w-full items-center gap-1.5 rounded-[28px] p-1.5",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="inline-flex h-9 shrink-0 items-center gap-2 rounded-[var(--ui-radius-control)] border border-white/90 bg-white/78 px-2.5 text-left text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white dark:border-white/12 dark:bg-white/8 dark:text-white dark:shadow-none dark:hover:bg-white/12"
|
||||
className="inline-flex h-10 shrink-0 items-center gap-2 rounded-[20px] border border-white/90 bg-white/78 px-3 text-left text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white dark:border-white/12 dark:bg-white/8 dark:text-white dark:shadow-none dark:hover:bg-white/12"
|
||||
onClick={handleCloseRealtime}
|
||||
title={realtimeReturnCanvasItem.label}
|
||||
type="button"
|
||||
@ -3664,15 +3664,15 @@ export function RealtimeCanvasDialog({
|
||||
item={realtimeReturnCanvasItem}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[13px] font-semibold">
|
||||
<span className="font-semibold text-[13px] tracking-[-0.02em]">
|
||||
{realtimeReturnCanvasItem.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="inline-flex h-9 min-w-0 items-center gap-0.5 rounded-[var(--ui-radius-control)] border border-[#ebebeb] bg-[#fafafa]/90 p-0.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)] dark:border-white/12 dark:bg-white/8 dark:shadow-none">
|
||||
<div className="inline-flex h-10 min-w-0 items-center gap-0.5 rounded-[20px] border border-[#ebebeb] bg-[#fafafa]/90 p-0.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)] dark:border-white/12 dark:bg-white/8 dark:shadow-none">
|
||||
<button
|
||||
className={cn(
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[6px] px-2.5 text-[13px] font-medium transition",
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[17px] px-3 text-[13px] font-medium tracking-[-0.02em] transition",
|
||||
workspaceMode === "realtime"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111] dark:text-white/58 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
@ -3695,7 +3695,7 @@ export function RealtimeCanvasDialog({
|
||||
{isEnhanceProEnabled ? (
|
||||
<button
|
||||
className={cn(
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[6px] px-2.5 text-[13px] font-medium transition",
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[17px] px-3 text-[13px] font-medium tracking-[-0.02em] transition",
|
||||
workspaceMode === "enhance-pro"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111] dark:text-white/58 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
@ -3719,7 +3719,7 @@ export function RealtimeCanvasDialog({
|
||||
{isAiWashEnabled ? (
|
||||
<button
|
||||
className={cn(
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[6px] px-2.5 text-[13px] font-medium transition",
|
||||
"group inline-flex h-full min-w-0 items-center gap-1.5 rounded-[17px] px-3 text-[13px] font-medium tracking-[-0.02em] transition",
|
||||
workspaceMode === "ai-wash"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111] dark:text-white/58 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
@ -3744,21 +3744,21 @@ export function RealtimeCanvasDialog({
|
||||
{saveProjectDialogOpen ? (
|
||||
<div
|
||||
className={cn(
|
||||
"-translate-x-1/2 absolute top-24 left-1/2 z-40 w-[21rem] max-w-[calc(100vw-2rem)] rounded-[var(--ui-radius-panel)] p-3.5",
|
||||
"-translate-x-1/2 absolute top-24 left-1/2 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] p-4",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-[#111111] dark:text-white">
|
||||
<div className="font-semibold text-[#111111] text-sm dark:text-white">
|
||||
保存项目
|
||||
</div>
|
||||
<div className="text-[11px] text-[#666666] dark:text-white/55">
|
||||
<div className="text-[#666666] text-xs dark:text-white/55">
|
||||
请输入项目名称后保存当前画布
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-7 w-7"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setSaveProjectDialogOpen(false)}
|
||||
size="icon"
|
||||
type="button"
|
||||
@ -3769,7 +3769,7 @@ export function RealtimeCanvasDialog({
|
||||
</div>
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn("mt-3 h-9", controlSurfaceClass)}
|
||||
className={cn("mt-4 h-11 rounded-2xl", controlSurfaceClass)}
|
||||
onChange={(event) => setPendingProjectName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
@ -3780,9 +3780,9 @@ export function RealtimeCanvasDialog({
|
||||
placeholder="请输入项目名称"
|
||||
value={pendingProjectName}
|
||||
/>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
className={cn("h-9 flex-1", controlSurfaceClass)}
|
||||
className={cn("h-10 flex-1 rounded-2xl", controlSurfaceClass)}
|
||||
onClick={() => setSaveProjectDialogOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
@ -3791,7 +3791,7 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 flex-1",
|
||||
"h-10 flex-1 rounded-2xl",
|
||||
primaryActionButtonClass
|
||||
)}
|
||||
onClick={() => void confirmSaveCurrentProject()}
|
||||
@ -3805,21 +3805,21 @@ export function RealtimeCanvasDialog({
|
||||
{projectPanelOpen ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-20 left-4 z-40 w-[21rem] max-w-[calc(100vw-2rem)] rounded-[var(--ui-radius-panel)] p-3.5",
|
||||
"absolute top-20 left-4 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] p-4",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-[#111111] dark:text-white">
|
||||
<div className="font-semibold text-[#111111] text-sm dark:text-white">
|
||||
项目列表
|
||||
</div>
|
||||
<div className="text-[11px] text-[#666666] dark:text-white/55">
|
||||
<div className="text-[#666666] text-xs dark:text-white/55">
|
||||
本地保存,便于下次继续编辑
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-7 w-7"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setProjectPanelOpen(false)}
|
||||
size="icon"
|
||||
type="button"
|
||||
@ -3828,19 +3828,19 @@ export function RealtimeCanvasDialog({
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 max-h-[55vh] min-h-0 space-y-1.5 overflow-y-auto overscroll-contain pr-1">
|
||||
<div className="mt-4 max-h-[55vh] min-h-0 space-y-2 overflow-y-auto overscroll-contain pr-1">
|
||||
{savedProjects.length === 0 ? (
|
||||
<div className="rounded-[var(--ui-radius-control)] border border-[#e5e5e5] border-dashed px-4 py-7 text-center text-[13px] text-[#666666]">
|
||||
<div className="rounded-2xl border border-[#e5e5e5] border-dashed px-4 py-8 text-center text-[#666666] text-sm">
|
||||
暂无保存的项目
|
||||
</div>
|
||||
) : (
|
||||
savedProjects.map((project) => (
|
||||
<div
|
||||
className="rounded-[var(--ui-radius-control)] border border-[#e5e5e5] bg-white p-2.5 shadow-sm"
|
||||
className="rounded-2xl border border-[#e5e5e5] bg-white p-3 shadow-sm"
|
||||
key={project.id}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-[6px] border border-[#e5e5e5] bg-[#fafafa]">
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-[#e5e5e5] bg-[#fafafa]">
|
||||
{project.coverImageUrl || project.previewUrl ? (
|
||||
<img
|
||||
alt={project.name}
|
||||
@ -3866,13 +3866,13 @@ export function RealtimeCanvasDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium leading-5 text-[#111111]">
|
||||
<div className="truncate font-medium text-[#111111] text-sm leading-5">
|
||||
{project.name}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 px-3",
|
||||
"h-8 rounded-xl px-4",
|
||||
softActionButtonClass
|
||||
)}
|
||||
onClick={() => void openSavedProject(project.id)}
|
||||
@ -3883,7 +3883,7 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 px-3",
|
||||
"h-8 rounded-xl px-4",
|
||||
controlSurfaceClass
|
||||
)}
|
||||
onClick={() => deleteSavedProject(project.id)}
|
||||
@ -3950,7 +3950,7 @@ export function RealtimeCanvasDialog({
|
||||
>
|
||||
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.03]" />
|
||||
{isDragImportActive ? (
|
||||
<div className="absolute inset-6 z-20 flex items-center justify-center rounded-[var(--ui-radius-panel)] border-2 border-[#111111] border-dashed bg-white/92 text-[13px] font-medium text-[#111111] shadow-inner dark:border-white/70 dark:bg-[#111317]/92 dark:text-white">
|
||||
<div className="absolute inset-6 z-20 flex items-center justify-center rounded-[28px] border-2 border-[#111111] border-dashed bg-white/92 font-medium text-[#111111] text-sm shadow-inner dark:border-white/70 dark:bg-[#111317]/92 dark:text-white">
|
||||
松开鼠标,上传图片到图层
|
||||
</div>
|
||||
) : null}
|
||||
@ -4048,7 +4048,7 @@ export function RealtimeCanvasDialog({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-28 bottom-4 left-4 z-10 flex w-14 max-w-[18vw] flex-col rounded-[var(--ui-radius-panel)] p-1.5",
|
||||
"absolute top-28 bottom-4 left-4 z-10 flex w-16 max-w-[18vw] flex-col rounded-[24px] p-2",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
@ -4066,14 +4066,14 @@ export function RealtimeCanvasDialog({
|
||||
</div>
|
||||
<div className="mt-2 flex flex-1 flex-col items-center space-y-2 overflow-y-auto py-1">
|
||||
{imageLayers.length === 0 ? (
|
||||
<div className="rounded-[var(--ui-radius-control)] border border-[#e5e5e5] border-dashed px-1 py-4 text-center text-[10px] leading-4 text-[#666666] dark:border-white/12 dark:text-white/55">
|
||||
<div className="rounded-2xl border border-[#e5e5e5] border-dashed px-1 py-4 text-center text-[10px] text-[#666666] leading-4 dark:border-white/12 dark:text-white/55">
|
||||
暂无图层
|
||||
</div>
|
||||
) : (
|
||||
[...imageLayers].reverse().map((layer) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-[6px] p-1 transition hover:bg-[#f5f5f5] dark:hover:bg-white/10",
|
||||
"flex h-12 w-12 shrink-0 items-center justify-center rounded-[18px] p-1.5 transition hover:bg-[#f5f5f5] dark:hover:bg-white/10",
|
||||
selectedLayerId === layer.id
|
||||
? "bg-[#f5f5f5] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)] dark:bg-white/10 dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.14)]"
|
||||
: ""
|
||||
@ -4224,7 +4224,7 @@ export function RealtimeCanvasDialog({
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex flex-wrap items-center justify-center gap-1 rounded-[var(--ui-radius-panel)] p-1.5",
|
||||
"pointer-events-auto flex flex-wrap items-center justify-center gap-1 rounded-[24px] p-1.5",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
@ -4250,10 +4250,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-[12px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
{realtimeSelectToolItem.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
<div className="text-[#666666] text-xs">
|
||||
选择图层并拖动位置
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4288,18 +4288,18 @@ export function RealtimeCanvasDialog({
|
||||
</TooltipTrigger>
|
||||
<PopoverContent
|
||||
className={cn(
|
||||
"w-[224px] rounded-[var(--ui-radius-panel)] p-3 backdrop-blur-md",
|
||||
"w-[250px] rounded-[18px] p-4 backdrop-blur-md",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-[#666666] text-xs">
|
||||
颜色
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-[var(--ui-radius-control)] px-2.5 text-[13px] font-medium",
|
||||
"flex h-10 w-full items-center justify-between rounded-xl px-3 font-medium text-sm",
|
||||
controlSurfaceClass
|
||||
)}
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
@ -4345,10 +4345,10 @@ export function RealtimeCanvasDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>
|
||||
<div className="text-[12px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
{realtimeBrushToolItem.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
<div className="text-[#666666] text-xs">
|
||||
点击后直接调整颜色和粗细
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4383,7 +4383,7 @@ export function RealtimeCanvasDialog({
|
||||
</TooltipTrigger>
|
||||
<PopoverContent
|
||||
className={cn(
|
||||
"w-[208px] rounded-[var(--ui-radius-panel)] p-3 backdrop-blur-md",
|
||||
"w-[220px] rounded-[18px] p-4 backdrop-blur-md",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
@ -4409,10 +4409,10 @@ export function RealtimeCanvasDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>
|
||||
<div className="text-[12px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
{realtimeEraserToolItem.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
<div className="text-[#666666] text-xs">
|
||||
点击后直接调整橡皮粗细
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4443,7 +4443,7 @@ export function RealtimeCanvasDialog({
|
||||
</TooltipTrigger>
|
||||
<PopoverContent
|
||||
className={cn(
|
||||
"w-[208px] rounded-[var(--ui-radius-panel)] p-2.5 backdrop-blur-md",
|
||||
"w-[220px] rounded-[18px] p-3 backdrop-blur-md",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
@ -4453,7 +4453,7 @@ export function RealtimeCanvasDialog({
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 rounded-[var(--ui-radius-control)] border",
|
||||
"h-10 rounded-xl border",
|
||||
brushMode === "rect"
|
||||
? softActionButtonClass
|
||||
: "border-transparent text-[#666666] hover:bg-[#f5f5f5] hover:text-[#111111]"
|
||||
@ -4466,7 +4466,7 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 rounded-[var(--ui-radius-control)] border",
|
||||
"h-10 rounded-xl border",
|
||||
brushMode === "circle"
|
||||
? softActionButtonClass
|
||||
: "border-transparent text-[#666666] hover:bg-[#f5f5f5] hover:text-[#111111]"
|
||||
@ -4479,7 +4479,7 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 rounded-[var(--ui-radius-control)] border",
|
||||
"h-10 rounded-xl border",
|
||||
brushMode === "line"
|
||||
? softActionButtonClass
|
||||
: "border-transparent text-[#666666] hover:bg-[#f5f5f5] hover:text-[#111111]"
|
||||
@ -4494,10 +4494,10 @@ export function RealtimeCanvasDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>
|
||||
<div className="text-[12px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
{realtimeShapeToolItem.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
<div className="text-[#666666] text-xs">
|
||||
矩形、圆形与直线
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4521,10 +4521,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-[12px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
{realtimeSaveProjectItem.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
<div className="text-[#666666] text-xs">
|
||||
保存当前画布状态
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4550,10 +4550,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-[12px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
{realtimeProjectListItem.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666666]">打开已保存项目</div>
|
||||
<div className="text-[#666666] text-xs">打开已保存项目</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@ -4574,10 +4574,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-[12px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
{realtimeNewBlankItem.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666666]">
|
||||
<div className="text-[#666666] text-xs">
|
||||
清空并开始新项目
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@ -4602,10 +4602,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-[12px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
{realtimeUndoItem.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666666]">回退上一步编辑</div>
|
||||
<div className="text-[#666666] text-xs">回退上一步编辑</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@ -4627,10 +4627,10 @@ export function RealtimeCanvasDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-[12px] font-medium">
|
||||
<div className="font-medium text-sm">
|
||||
{realtimeRedoItem.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666666]">恢复已撤销操作</div>
|
||||
<div className="text-[#666666] text-xs">恢复已撤销操作</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@ -4638,12 +4638,12 @@ export function RealtimeCanvasDialog({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto w-full rounded-[var(--ui-radius-panel)] p-3",
|
||||
"pointer-events-auto w-full rounded-[24px] p-4",
|
||||
floatingSurfaceClass
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className="min-h-[48px] resize-none border-0 bg-transparent px-1 py-0.5 text-[13px] leading-6 text-[#111111] caret-[#111111] shadow-none placeholder:text-[#9b9b9b] focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white dark:caret-white dark:placeholder:text-white/45"
|
||||
className="min-h-[56px] resize-none border-0 bg-transparent px-1 py-0.5 text-[15px] text-[#111111] leading-7 caret-[#111111] shadow-none placeholder:text-[#9b9b9b] focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white dark:caret-white dark:placeholder:text-white/45"
|
||||
onBlur={() => scheduleFlush(true, false)}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
@ -4699,7 +4699,7 @@ export function RealtimeCanvasDialog({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
className="w-auto rounded-[var(--ui-radius-panel)] border border-[#e5e5e5] bg-white p-1 shadow-[0_12px_28px_rgba(0,0,0,0.12)] dark:border-white/12 dark:bg-[#171717]"
|
||||
className="w-auto rounded-2xl border border-[#e5e5e5] bg-white p-1 shadow-[0_16px_36px_rgba(0,0,0,0.12)] dark:border-white/12 dark:bg-[#171717]"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
@ -4709,7 +4709,7 @@ export function RealtimeCanvasDialog({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"h-8 rounded-[var(--ui-radius-control)] px-2.5 text-[12px] font-medium transition",
|
||||
"h-8 rounded-full px-3 font-medium text-xs transition",
|
||||
active
|
||||
? "bg-[#111111] text-white dark:bg-white dark:text-[#111111]"
|
||||
: "text-[#666666] hover:bg-[#f4f4f4] dark:text-white/58 dark:hover:bg-white/10"
|
||||
@ -4812,7 +4812,7 @@ export function RealtimeCanvasDialog({
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="h-[97vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[var(--ui-radius-panel)] border-0 bg-white p-0 text-[#111111] shadow-2xl dark:bg-[#050608] dark:text-white">
|
||||
<DialogContent className="h-[97vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-white p-0 text-[#111111] shadow-2xl dark:bg-[#050608] dark:text-white sm:rounded-[20px] md:rounded-[24px]">
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
947
components/canvas/VideoCanvasStoryboardCompositionEditor.tsx
Normal file
947
components/canvas/VideoCanvasStoryboardCompositionEditor.tsx
Normal file
@ -0,0 +1,947 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Gauge,
|
||||
Link2,
|
||||
Loader2,
|
||||
PanelsTopLeft,
|
||||
Maximize2,
|
||||
Minus,
|
||||
Play,
|
||||
Plus,
|
||||
Redo2,
|
||||
Scissors,
|
||||
TextCursorInput,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Video,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type DragEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
createVideoCanvasCompositionClip,
|
||||
getVideoCanvasCompositionClipDuration,
|
||||
getVideoCanvasCompositionClipStart,
|
||||
getVideoCanvasCompositionDuration,
|
||||
splitVideoCanvasCompositionClip,
|
||||
type VideoCanvasStoryboardCompositionProject,
|
||||
type VideoCanvasStoryboardCompositionTransition,
|
||||
} from "@/lib/video-canvas/storyboard-composition";
|
||||
import type {
|
||||
VideoCanvasStoryboardCompositionItem,
|
||||
VideoCanvasStoryboardItem,
|
||||
} from "@/lib/video-canvas/storyboard-overview";
|
||||
|
||||
type CompositionEditorProps = {
|
||||
composition: VideoCanvasStoryboardCompositionItem;
|
||||
compositionItems: VideoCanvasStoryboardCompositionItem[];
|
||||
exporting: boolean;
|
||||
onChange: (project: VideoCanvasStoryboardCompositionProject) => void;
|
||||
onClose: () => void;
|
||||
onExport: (destination: "canvas" | "local") => void;
|
||||
onSwitchComposition: (compositionId: string) => void;
|
||||
project: VideoCanvasStoryboardCompositionProject;
|
||||
videoItems: VideoCanvasStoryboardItem[];
|
||||
};
|
||||
|
||||
const TRANSITION_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: VideoCanvasStoryboardCompositionTransition;
|
||||
}> = [
|
||||
{ label: "淡入淡出", value: "fade" },
|
||||
{ label: "黑场过渡", value: "fade-black" },
|
||||
{ label: "白场过渡", value: "fade-white" },
|
||||
];
|
||||
|
||||
const SPEED_OPTIONS = [0.5, 1, 1.5, 2];
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const safeSeconds = Math.max(0, Math.floor(seconds));
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const remainder = safeSeconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
export function VideoCanvasStoryboardCompositionEditor({
|
||||
composition,
|
||||
compositionItems,
|
||||
exporting,
|
||||
onChange,
|
||||
onClose,
|
||||
onExport,
|
||||
onSwitchComposition,
|
||||
project,
|
||||
videoItems,
|
||||
}: CompositionEditorProps) {
|
||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const compositionMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
|
||||
const [selectedSubtitleId, setSelectedSubtitleId] = useState<string | null>(null);
|
||||
const [playheadSeconds, setPlayheadSeconds] = useState(0);
|
||||
const [undoStack, setUndoStack] = useState<VideoCanvasStoryboardCompositionProject[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<VideoCanvasStoryboardCompositionProject[]>([]);
|
||||
const [subtitlePanelOpen, setSubtitlePanelOpen] = useState(false);
|
||||
const [subtitleTab, setSubtitleTab] = useState<"subtitle" | "text">("subtitle");
|
||||
const [transitionPanelOpen, setTransitionPanelOpen] = useState(false);
|
||||
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
|
||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||
const [compositionMenuOpen, setCompositionMenuOpen] = useState(false);
|
||||
const [previewCollapsed, setPreviewCollapsed] = useState(false);
|
||||
const [previewFullscreen, setPreviewFullscreen] = useState(false);
|
||||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||||
const [subtitleTrackVisible, setSubtitleTrackVisible] = useState(true);
|
||||
const [timelineZoom, setTimelineZoom] = useState(20);
|
||||
const totalDuration = getVideoCanvasCompositionDuration(project);
|
||||
const timelineDuration = Math.max(totalDuration, 10);
|
||||
const selectedClip = project.clips.find((clip) => clip.id === selectedClipId) || null;
|
||||
const selectedSubtitle =
|
||||
project.subtitles.find((subtitle) => subtitle.id === selectedSubtitleId) || null;
|
||||
const previewClip = selectedClip || project.clips[0] || null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedClipId(null);
|
||||
setSelectedSubtitleId(null);
|
||||
setPlayheadSeconds(0);
|
||||
setUndoStack([]);
|
||||
setRedoStack([]);
|
||||
setSubtitlePanelOpen(false);
|
||||
setTransitionPanelOpen(false);
|
||||
setSpeedMenuOpen(false);
|
||||
setExportMenuOpen(false);
|
||||
setCompositionMenuOpen(false);
|
||||
setPreviewCollapsed(false);
|
||||
}, [composition.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedClipId && !selectedClip) setSelectedClipId(null);
|
||||
}, [selectedClip, selectedClipId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSubtitleId && !selectedSubtitle) setSelectedSubtitleId(null);
|
||||
}, [selectedSubtitle, selectedSubtitleId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPlayheadSeconds((current) => Math.min(current, totalDuration));
|
||||
}, [totalDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (previewFullscreen) {
|
||||
setPreviewFullscreen(false);
|
||||
return;
|
||||
}
|
||||
if (exportMenuOpen || speedMenuOpen) {
|
||||
setExportMenuOpen(false);
|
||||
setSpeedMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [exportMenuOpen, previewFullscreen, speedMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compositionMenuOpen) return;
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!compositionMenuRef.current?.contains(event.target)
|
||||
) {
|
||||
setCompositionMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
}, [compositionMenuOpen]);
|
||||
|
||||
const applyProject = useCallback(
|
||||
(nextProject: VideoCanvasStoryboardCompositionProject) => {
|
||||
if (nextProject === project) return;
|
||||
setUndoStack((current) => [...current.slice(-49), structuredClone(project)]);
|
||||
setRedoStack([]);
|
||||
onChange(nextProject);
|
||||
},
|
||||
[onChange, project],
|
||||
);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const previous = undoStack.at(-1);
|
||||
if (!previous) return;
|
||||
setUndoStack((current) => current.slice(0, -1));
|
||||
setRedoStack((current) => [...current.slice(-49), structuredClone(project)]);
|
||||
onChange(previous);
|
||||
}, [onChange, project, undoStack]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
const next = redoStack.at(-1);
|
||||
if (!next) return;
|
||||
setRedoStack((current) => current.slice(0, -1));
|
||||
setUndoStack((current) => [...current.slice(-49), structuredClone(project)]);
|
||||
onChange(next);
|
||||
}, [onChange, project, redoStack]);
|
||||
|
||||
const addVideoItem = useCallback(
|
||||
(item: VideoCanvasStoryboardItem) => {
|
||||
if (!item.mediaUrl) return;
|
||||
const clip = createVideoCanvasCompositionClip({
|
||||
durationSeconds: item.durationSeconds || 5,
|
||||
existingIds: project.clips.map((candidate) => candidate.id),
|
||||
mediaUrl: item.mediaUrl,
|
||||
nodeId: item.id,
|
||||
title: item.title,
|
||||
});
|
||||
applyProject({ ...project, clips: [...project.clips, clip] });
|
||||
setSelectedClipId(clip.id);
|
||||
setPlayheadSeconds(totalDuration);
|
||||
},
|
||||
[applyProject, project, totalDuration],
|
||||
);
|
||||
|
||||
const updateSelectedClip = useCallback(
|
||||
(update: (clip: NonNullable<typeof selectedClip>) => NonNullable<typeof selectedClip>) => {
|
||||
if (!selectedClip) return;
|
||||
applyProject({
|
||||
...project,
|
||||
clips: project.clips.map((clip) =>
|
||||
clip.id === selectedClip.id ? update(clip) : clip,
|
||||
),
|
||||
});
|
||||
},
|
||||
[applyProject, project, selectedClip],
|
||||
);
|
||||
|
||||
const removeSelectedClip = useCallback(() => {
|
||||
if (!selectedClip) return;
|
||||
applyProject({
|
||||
...project,
|
||||
clips: project.clips.filter((clip) => clip.id !== selectedClip.id),
|
||||
});
|
||||
setSelectedClipId(null);
|
||||
}, [applyProject, project, selectedClip]);
|
||||
|
||||
const trimSelectedClip = useCallback(
|
||||
(side: "left" | "right") => {
|
||||
if (!selectedClip) return;
|
||||
const clipStart = getVideoCanvasCompositionClipStart(project, selectedClip.id);
|
||||
if (clipStart === null) return;
|
||||
const sourcePoint =
|
||||
selectedClip.inPointSeconds +
|
||||
Math.max(0, playheadSeconds - clipStart) * selectedClip.speed;
|
||||
if (side === "left") {
|
||||
if (sourcePoint >= selectedClip.outPointSeconds - 0.1) return;
|
||||
updateSelectedClip((clip) => ({ ...clip, inPointSeconds: sourcePoint }));
|
||||
} else {
|
||||
if (sourcePoint <= selectedClip.inPointSeconds + 0.1) return;
|
||||
updateSelectedClip((clip) => ({ ...clip, outPointSeconds: sourcePoint }));
|
||||
}
|
||||
},
|
||||
[playheadSeconds, project, selectedClip, updateSelectedClip],
|
||||
);
|
||||
|
||||
const splitSelectedClip = useCallback(() => {
|
||||
if (!selectedClip) return;
|
||||
const next = splitVideoCanvasCompositionClip(
|
||||
project,
|
||||
selectedClip.id,
|
||||
playheadSeconds,
|
||||
);
|
||||
if (next !== project) applyProject(next);
|
||||
}, [applyProject, playheadSeconds, project, selectedClip]);
|
||||
|
||||
const addSubtitle = useCallback(() => {
|
||||
const id = `subtitle-${Date.now()}`;
|
||||
const startSeconds = Math.min(playheadSeconds, totalDuration);
|
||||
const subtitle = {
|
||||
endSeconds: Math.min(Math.max(totalDuration, 2), startSeconds + 2),
|
||||
id,
|
||||
startSeconds,
|
||||
text: "新建字幕",
|
||||
};
|
||||
applyProject({ ...project, subtitles: [...project.subtitles, subtitle] });
|
||||
setSelectedSubtitleId(id);
|
||||
setSubtitleTab("text");
|
||||
}, [applyProject, playheadSeconds, project, totalDuration]);
|
||||
|
||||
const seekTimeline = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
let next = ((event.clientX - rect.left) / rect.width) * timelineDuration;
|
||||
if (snapEnabled) next = Math.round(next * 2) / 2;
|
||||
setPlayheadSeconds(Math.min(totalDuration, Math.max(0, next)));
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const nodeId = event.dataTransfer.getData("application/x-video-canvas-node");
|
||||
const item = videoItems.find((candidate) => candidate.id === nodeId);
|
||||
if (item) addVideoItem(item);
|
||||
};
|
||||
|
||||
const togglePlayback = async () => {
|
||||
const video = previewVideoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) await video.play().catch(() => undefined);
|
||||
else video.pause();
|
||||
};
|
||||
|
||||
const renderPreview = (fullscreen = false) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 items-center justify-center overflow-hidden bg-[#1b1b1b]",
|
||||
fullscreen && "fixed inset-0 z-[300] bg-black",
|
||||
)}
|
||||
>
|
||||
{previewClip ? (
|
||||
<video
|
||||
className={cn(
|
||||
"max-h-full max-w-full object-contain",
|
||||
fullscreen ? "h-full w-full" : "h-full",
|
||||
)}
|
||||
key={`${composition.id}-${previewClip.id}`}
|
||||
onTimeUpdate={(event) => {
|
||||
const clipStart = getVideoCanvasCompositionClipStart(project, previewClip.id) || 0;
|
||||
const relative = Math.max(
|
||||
0,
|
||||
event.currentTarget.currentTime - previewClip.inPointSeconds,
|
||||
);
|
||||
setPlayheadSeconds(
|
||||
Math.min(totalDuration, clipStart + relative / previewClip.speed),
|
||||
);
|
||||
if (event.currentTarget.currentTime >= previewClip.outPointSeconds) {
|
||||
event.currentTarget.pause();
|
||||
}
|
||||
}}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
ref={previewVideoRef}
|
||||
src={previewClip.mediaUrl}
|
||||
/>
|
||||
) : (
|
||||
<Video className="h-7 w-7 text-white/10" />
|
||||
)}
|
||||
{fullscreen ? (
|
||||
<button
|
||||
aria-label="退出全屏预览"
|
||||
className="absolute right-5 top-5 flex size-9 items-center justify-center rounded-[6px] bg-black/60 text-white/72 hover:bg-black/80 hover:text-white"
|
||||
onClick={() => setPreviewFullscreen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 min-w-[760px] flex-1 grid-cols-[minmax(248px,0.5fr)_minmax(520px,1fr)] gap-3 pb-4">
|
||||
<section className="relative flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#242424]">
|
||||
<header className="flex h-[52px] shrink-0 items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2 text-[14px] font-medium text-white/78">
|
||||
<span>视频</span>
|
||||
<ChevronDown className="h-3 w-3 text-white/42" />
|
||||
</div>
|
||||
<button className="flex items-center gap-1 text-[12px] text-white/42" type="button">
|
||||
全部
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-20">
|
||||
{videoItems.length ? (
|
||||
<div className="space-y-3">
|
||||
{videoItems.map((item) => (
|
||||
<article
|
||||
className="group relative border-b border-white/7 pb-3"
|
||||
draggable={Boolean(item.mediaUrl)}
|
||||
key={item.id}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.setData(
|
||||
"application/x-video-canvas-node",
|
||||
item.id,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="text-[12px] text-white/48">{item.title}</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className="relative aspect-[2/3] w-[106px] overflow-hidden rounded-[7px] bg-[#191919]">
|
||||
{item.mediaUrl ? (
|
||||
<video
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
src={item.mediaUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Video className="h-5 w-5 text-white/15" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
aria-label={`添加 ${item.title} 到时间线`}
|
||||
className="absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-full bg-black/65 text-white opacity-0 transition-opacity hover:bg-black/85 focus-visible:opacity-100 group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-25"
|
||||
disabled={!item.mediaUrl}
|
||||
onClick={() => addVideoItem(item)}
|
||||
title="添加到时间线"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-1 text-[11px] text-white/32">
|
||||
<div>{item.modelName || "视频素材"}</div>
|
||||
{item.durationSeconds ? (
|
||||
<div className="mt-2">{item.durationSeconds}秒</div>
|
||||
) : null}
|
||||
<div className="mt-8 truncate">{item.subtitle || "参考素材"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-36 items-center justify-center text-[12px] text-white/28">
|
||||
暂无视频素材
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-5 right-4" ref={compositionMenuRef}>
|
||||
<button
|
||||
aria-expanded={compositionMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
className="flex size-[50px] flex-col items-center justify-center gap-0.5 rounded-full bg-[#2b2b2b] text-[11px] text-white/72 shadow-[0_10px_30px_rgba(0,0,0,0.36)] hover:bg-[#343434] hover:text-white"
|
||||
onClick={() => setCompositionMenuOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
<span>剪辑</span>
|
||||
</button>
|
||||
{compositionMenuOpen ? (
|
||||
<div
|
||||
aria-label="剪辑"
|
||||
className="absolute bottom-[58px] right-0 w-[208px] overflow-hidden rounded-[8px] border border-white/8 bg-[#242424] py-1 shadow-[0_16px_40px_rgba(0,0,0,0.46)]"
|
||||
role="menu"
|
||||
>
|
||||
{compositionItems.map((item) => (
|
||||
<button
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-[13px] text-white/76 hover:bg-white/7 hover:text-white"
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setCompositionMenuOpen(false);
|
||||
onSwitchComposition(item.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Video className="h-4 w-4 text-white/42" />
|
||||
<span className="min-w-0 flex-1 truncate">{item.title}</span>
|
||||
{item.id === composition.id ? <Check className="h-4 w-4" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#242424]">
|
||||
{!previewCollapsed ? (
|
||||
<>
|
||||
<header className="flex h-[48px] shrink-0 items-center justify-between px-3">
|
||||
<span className="text-[14px] font-medium text-white/78">视频合成</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
aria-expanded={exportMenuOpen}
|
||||
className="flex h-8 items-center gap-2 rounded-[8px] bg-white px-3 text-[13px] font-medium text-black hover:bg-white/88"
|
||||
disabled={exporting}
|
||||
onClick={() => setExportMenuOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
导出
|
||||
{!exporting ? <ChevronDown className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
{exportMenuOpen ? (
|
||||
<div
|
||||
aria-label="导出"
|
||||
className="absolute right-0 top-10 z-40 w-36 rounded-[8px] border border-white/8 bg-[#202020] p-1 shadow-[0_12px_30px_rgba(0,0,0,0.42)]"
|
||||
role="menu"
|
||||
>
|
||||
<div className="flex h-8 items-center px-3 text-[12px] text-white/34">
|
||||
导出位置
|
||||
</div>
|
||||
<button
|
||||
className="flex h-9 w-full items-center gap-2 rounded-[6px] px-3 text-left text-[12px] text-white/68 hover:bg-white/7 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
disabled={!project.clips.length || exporting}
|
||||
onClick={() => {
|
||||
setExportMenuOpen(false);
|
||||
onExport("local");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
导出到本地
|
||||
</button>
|
||||
<button
|
||||
className="flex h-9 w-full items-center gap-2 rounded-[6px] px-3 text-left text-[12px] text-white/68 hover:bg-white/7 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
disabled={!project.clips.length || exporting}
|
||||
onClick={() => {
|
||||
setExportMenuOpen(false);
|
||||
onExport("canvas");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PanelsTopLeft className="h-4 w-4" />
|
||||
导出到画布
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
aria-label="退出剪辑"
|
||||
className="flex size-8 items-center justify-center rounded-[6px] text-white/50 hover:bg-white/8 hover:text-white"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-[230px] shrink-0 gap-2 px-2 pb-2",
|
||||
transitionPanelOpen
|
||||
? "grid-cols-[minmax(300px,1.2fr)_minmax(220px,0.9fr)_296px]"
|
||||
: subtitlePanelOpen
|
||||
? "grid-cols-[minmax(0,1fr)_296px]"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{transitionPanelOpen ? (
|
||||
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
|
||||
<div className="flex h-10 items-center justify-between border-b border-white/7 px-3 text-[13px] text-white/76">
|
||||
<span>转场库</span>
|
||||
<button
|
||||
aria-label="关闭转场库"
|
||||
className="text-white/42 hover:text-white"
|
||||
onClick={() => setTransitionPanelOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="mb-3 text-[12px] text-white/34">基础转场</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TRANSITION_OPTIONS.map((option, index) => (
|
||||
<button
|
||||
aria-label={`${option.label} ${option.label}`}
|
||||
className={cn(
|
||||
"text-left text-[11px] text-white/54 hover:text-white",
|
||||
!selectedClip && "cursor-not-allowed opacity-35",
|
||||
)}
|
||||
disabled={!selectedClip}
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
updateSelectedClip((clip) => ({
|
||||
...clip,
|
||||
transition: option.value,
|
||||
}))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mb-1.5 block aspect-[1.45] rounded-[6px] border border-white/7",
|
||||
index === 0 && "bg-[linear-gradient(90deg,#665d51,#202020)]",
|
||||
index === 1 && "bg-[linear-gradient(90deg,#342d28,#050505)]",
|
||||
index === 2 && "bg-[linear-gradient(90deg,#5c554d,#dedede)]",
|
||||
selectedClip?.transition === option.value &&
|
||||
"border-white/70 ring-1 ring-white/40",
|
||||
)}
|
||||
/>
|
||||
<span className="block truncate">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
{renderPreview()}
|
||||
{transitionPanelOpen ? (
|
||||
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
|
||||
<div className="flex h-10 items-center justify-between border-b border-white/7 px-3 text-[13px] text-white/76">
|
||||
<span>转场</span>
|
||||
<button
|
||||
aria-label="关闭转场"
|
||||
className="text-white/42 hover:text-white"
|
||||
onClick={() => setTransitionPanelOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 text-[12px]">
|
||||
<div className="text-white/32">名称</div>
|
||||
<div className="mt-1 text-white/68">
|
||||
{TRANSITION_OPTIONS.find((item) => item.value === selectedClip?.transition)?.label || "未选择转场"}
|
||||
</div>
|
||||
<div className="mt-4 text-white/32">时长</div>
|
||||
<div className="mt-1 text-white/68">{selectedClip?.transition ? "0.5 秒" : "-"}</div>
|
||||
<button
|
||||
className="mt-5 flex h-8 w-full items-center justify-center gap-2 rounded-[6px] bg-white/6 text-white/56 hover:bg-white/9 disabled:opacity-30"
|
||||
disabled={!selectedClip?.transition}
|
||||
onClick={() => updateSelectedClip((clip) => ({ ...clip, transition: null }))}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
) : subtitlePanelOpen ? (
|
||||
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
|
||||
<div className="flex h-10 items-center gap-4 border-b border-white/7 px-3 text-[13px]">
|
||||
<button
|
||||
className={subtitleTab === "subtitle" ? "text-white" : "text-white/40"}
|
||||
onClick={() => setSubtitleTab("subtitle")}
|
||||
type="button"
|
||||
>
|
||||
字幕
|
||||
</button>
|
||||
<button
|
||||
className={subtitleTab === "text" ? "text-white" : "text-white/40"}
|
||||
onClick={() => setSubtitleTab("text")}
|
||||
type="button"
|
||||
>
|
||||
文本
|
||||
</button>
|
||||
</div>
|
||||
{subtitleTab === "subtitle" ? (
|
||||
<div className="p-3">
|
||||
<input
|
||||
aria-label="搜索字幕文本"
|
||||
className="h-8 w-full rounded-[6px] bg-white/8 px-3 text-[12px] text-white outline-none placeholder:text-white/30"
|
||||
placeholder="搜索字幕文本"
|
||||
/>
|
||||
<button
|
||||
className="mt-2 flex h-8 w-full items-center justify-center gap-1 rounded-[6px] bg-white/9 text-[12px] text-white/78 hover:bg-white/12"
|
||||
onClick={addSubtitle}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建字幕
|
||||
</button>
|
||||
<div className="mt-3 space-y-1">
|
||||
{project.subtitles.length ? project.subtitles.map((subtitle) => (
|
||||
<button
|
||||
className={cn(
|
||||
"w-full truncate rounded-[5px] px-2 py-1.5 text-left text-[12px] text-white/58 hover:bg-white/6",
|
||||
subtitle.id === selectedSubtitleId && "bg-white/8 text-white/86",
|
||||
)}
|
||||
key={subtitle.id}
|
||||
onClick={() => {
|
||||
setSelectedSubtitleId(subtitle.id);
|
||||
setSubtitleTab("text");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{subtitle.text}
|
||||
</button>
|
||||
)) : (
|
||||
<div className="py-3 text-[12px] text-white/28">暂无字幕</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedSubtitle ? (
|
||||
<div className="p-3">
|
||||
<textarea
|
||||
aria-label="字幕文本"
|
||||
className="h-24 w-full resize-none rounded-[6px] bg-white/7 p-3 text-[12px] leading-5 text-white outline-none"
|
||||
onChange={(event) =>
|
||||
applyProject({
|
||||
...project,
|
||||
subtitles: project.subtitles.map((subtitle) =>
|
||||
subtitle.id === selectedSubtitle.id
|
||||
? { ...subtitle, text: event.target.value }
|
||||
: subtitle,
|
||||
),
|
||||
})
|
||||
}
|
||||
value={selectedSubtitle.text}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 text-[12px] text-white/34">请先选择一条字幕</div>
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex h-12 shrink-0 items-center gap-1 border-y border-white/7 px-3">
|
||||
<EditorIconButton disabled={!undoStack.length} label="撤销" onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</EditorIconButton>
|
||||
<EditorIconButton disabled={!redoStack.length} label="重做" onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</EditorIconButton>
|
||||
<EditorIconButton disabled={!selectedClip} label="分割" onClick={splitSelectedClip}>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</EditorIconButton>
|
||||
<EditorIconButton disabled={!selectedClip} label="向左裁剪" onClick={() => trimSelectedClip("left")}>
|
||||
<span className="text-[16px]">⌈</span>
|
||||
</EditorIconButton>
|
||||
<EditorIconButton disabled={!selectedClip} label="向右裁剪" onClick={() => trimSelectedClip("right")}>
|
||||
<span className="text-[16px]">⌉</span>
|
||||
</EditorIconButton>
|
||||
<EditorIconButton
|
||||
active={subtitlePanelOpen}
|
||||
label="字幕轨道"
|
||||
onClick={() => {
|
||||
setSubtitlePanelOpen((current) => !current);
|
||||
setTransitionPanelOpen(false);
|
||||
}}
|
||||
>
|
||||
<TextCursorInput className="h-4 w-4" />
|
||||
</EditorIconButton>
|
||||
<EditorIconButton
|
||||
active={transitionPanelOpen}
|
||||
label="转场"
|
||||
onClick={() => {
|
||||
setTransitionPanelOpen((current) => !current);
|
||||
setSubtitlePanelOpen(false);
|
||||
}}
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
</EditorIconButton>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 text-[12px] tabular-nums text-white/58">
|
||||
<span>{formatTime(playheadSeconds)}</span>
|
||||
<button
|
||||
aria-label={previewVideoRef.current?.paused === false ? "暂停" : "播放"}
|
||||
className="flex size-8 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/16"
|
||||
onClick={() => void togglePlayback()}
|
||||
type="button"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
<EditorIconButton label="全屏预览" onClick={() => setPreviewFullscreen(true)}>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</EditorIconButton>
|
||||
</div>
|
||||
|
||||
<div className="relative ml-auto flex items-center gap-1">
|
||||
<EditorIconButton
|
||||
disabled={!selectedClip}
|
||||
label="变速"
|
||||
onClick={() => setSpeedMenuOpen((current) => !current)}
|
||||
>
|
||||
<Gauge className="h-4 w-4" />
|
||||
</EditorIconButton>
|
||||
{speedMenuOpen ? (
|
||||
<div
|
||||
aria-label="变速"
|
||||
className="absolute bottom-10 right-0 z-40 w-28 rounded-[8px] border border-white/8 bg-[#202020] p-1 shadow-xl"
|
||||
role="menu"
|
||||
>
|
||||
{SPEED_OPTIONS.map((speed) => (
|
||||
<button
|
||||
className="flex h-8 w-full items-center justify-between rounded-[5px] px-2 text-[12px] text-white/68 hover:bg-white/7"
|
||||
key={speed}
|
||||
onClick={() => {
|
||||
updateSelectedClip((clip) => ({ ...clip, speed }));
|
||||
setSpeedMenuOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{speed}x
|
||||
{selectedClip?.speed === speed ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<EditorIconButton active={snapEnabled} label="吸附" onClick={() => setSnapEnabled((current) => !current)}>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</EditorIconButton>
|
||||
<ZoomOut className="h-4 w-4 text-white/48" />
|
||||
<input
|
||||
aria-label="时间线缩放"
|
||||
className="h-1 w-20 accent-white"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(event) => setTimelineZoom(Number(event.target.value))}
|
||||
type="range"
|
||||
value={timelineZoom}
|
||||
/>
|
||||
<ZoomIn className="h-4 w-4 text-white/48" />
|
||||
<EditorIconButton
|
||||
label={previewCollapsed ? "展开预览(占满时间轴上方)" : "收起预览"}
|
||||
onClick={() => setPreviewCollapsed((current) => !current)}
|
||||
>
|
||||
{previewCollapsed ? <Plus className="h-4 w-4" /> : <Minus className="h-4 w-4" />}
|
||||
</EditorIconButton>
|
||||
{previewCollapsed ? (
|
||||
<EditorIconButton label="退出剪辑" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</EditorIconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative min-h-[180px] flex-1 overflow-auto bg-[#1c1c1c]"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
aria-label="时间线"
|
||||
className="relative min-h-full"
|
||||
onPointerDown={seekTimeline}
|
||||
style={{ minWidth: `${Math.max(100, timelineZoom + 80)}%` }}
|
||||
>
|
||||
<div className="absolute left-[88px] right-4 top-8 h-px bg-white/7" />
|
||||
<div className="absolute left-0 top-[50px] flex h-14 w-[72px] items-center justify-center text-[20px] text-white/50">
|
||||
<Video className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="absolute left-[88px] right-4 top-[50px] flex h-14 gap-px bg-white/[0.025]">
|
||||
{project.clips.map((clip) => (
|
||||
<button
|
||||
aria-label={`${clip.title} 时间线片段`}
|
||||
className={cn(
|
||||
"relative min-w-[52px] overflow-hidden border border-white/10 bg-[#303030] text-left",
|
||||
clip.id === selectedClipId && "border-[#7aa7ff] ring-1 ring-[#7aa7ff]",
|
||||
)}
|
||||
key={clip.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedClipId(clip.id);
|
||||
setPlayheadSeconds(getVideoCanvasCompositionClipStart(project, clip.id) || 0);
|
||||
}}
|
||||
style={{
|
||||
width: `${Math.max(4, (getVideoCanvasCompositionClipDuration(clip) / timelineDuration) * 100)}%`,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<video
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-55"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
src={clip.mediaUrl}
|
||||
/>
|
||||
<span className="absolute inset-x-1 bottom-1 truncate text-[10px] text-white/86">
|
||||
{clip.title}
|
||||
</span>
|
||||
{clip.transition ? (
|
||||
<span className="absolute left-0 top-0 h-full w-3 bg-white/25" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{subtitlePanelOpen ? (
|
||||
<>
|
||||
<div className="absolute left-0 top-[118px] flex h-10 w-[72px] items-center justify-between px-3 text-white/50">
|
||||
<TextCursorInput className="h-5 w-5" />
|
||||
<button
|
||||
aria-label={subtitleTrackVisible ? "关闭字幕" : "显示字幕"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSubtitleTrackVisible((current) => !current);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{subtitleTrackVisible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute left-[88px] right-4 top-[118px] h-10 bg-white/[0.025]">
|
||||
{subtitleTrackVisible ? project.subtitles.map((subtitle) => (
|
||||
<button
|
||||
className={cn(
|
||||
"absolute top-1 h-8 truncate border border-white/10 bg-[#505050] px-2 text-left text-[10px] text-white/80",
|
||||
subtitle.id === selectedSubtitleId && "border-[#7aa7ff]",
|
||||
)}
|
||||
key={subtitle.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedSubtitleId(subtitle.id);
|
||||
setSubtitleTab("text");
|
||||
}}
|
||||
style={{
|
||||
left: `${(subtitle.startSeconds / timelineDuration) * 100}%`,
|
||||
width: `${Math.max(3, ((subtitle.endSeconds - subtitle.startSeconds) / timelineDuration) * 100)}%`,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{subtitle.text}
|
||||
</button>
|
||||
)) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 z-20 w-px bg-white/90"
|
||||
style={{
|
||||
left: `calc(88px + (100% - 104px) * ${timelineDuration ? playheadSeconds / timelineDuration : 0})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{previewFullscreen ? renderPreview(true) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorIconButton({
|
||||
active = false,
|
||||
children,
|
||||
disabled = false,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-[6px] text-white/46 hover:bg-white/7 hover:text-white disabled:cursor-not-allowed disabled:opacity-20",
|
||||
active && "bg-white/9 text-white/88",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
type CanvasChatPanelModule = typeof import("./CanvasChatPanel");
|
||||
|
||||
let canvasChatPanelModulePromise: Promise<CanvasChatPanelModule> | null = null;
|
||||
|
||||
export function loadCanvasChatPanelModule() {
|
||||
canvasChatPanelModulePromise ??= import("./CanvasChatPanel");
|
||||
return canvasChatPanelModulePromise;
|
||||
}
|
||||
|
||||
export function preloadCanvasChatPanel() {
|
||||
void loadCanvasChatPanelModule();
|
||||
}
|
||||
@ -1,7 +1,4 @@
|
||||
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
|
||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
|
||||
export type CanvasCapabilityCommandStreamPayload = CanvasCapabilityClientCommand;
|
||||
|
||||
export type CanvasMediaStreamPayload = {
|
||||
messageId?: string;
|
||||
@ -158,8 +155,10 @@ export type ImageGenerationStatusStreamPayload = {
|
||||
modelName?: string;
|
||||
imageCount?: number;
|
||||
queuePosition?: number;
|
||||
sameModelPendingCount?: number;
|
||||
sameModelRunningCount?: number;
|
||||
concurrencyLimit?: number;
|
||||
dispatchIntervalMs?: number;
|
||||
estimatedWaitMs?: number;
|
||||
};
|
||||
|
||||
|
||||
@ -3,11 +3,8 @@ import {
|
||||
extractOriginalImageUrlFromAlt,
|
||||
inferOriginalImageUrl,
|
||||
} from "@/lib/image-url";
|
||||
import { getMessageImagePartUrl } from "@/lib/chat/message-image-attachments";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
|
||||
export { collectMessageImageAttachments } from "@/lib/chat/message-image-attachments";
|
||||
|
||||
export function extractImageUrls(text: string): string[] {
|
||||
if (!text) return [];
|
||||
return extractImageUrlsFromText(text);
|
||||
@ -111,7 +108,12 @@ export function collectMessageImageUrlKeys(message: ChatMessage | undefined): Se
|
||||
const parts = (message as any).parts || [];
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
pushUrl(getMessageImagePartUrl(part));
|
||||
if (
|
||||
(part.type === "file" || part.type === "image" || part.type === "input_image") &&
|
||||
(typeof part.url === "string" || typeof part.data === "string")
|
||||
) {
|
||||
pushUrl(typeof part.url === "string" ? part.url : part.data);
|
||||
}
|
||||
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
extractImageUrls(part.text).forEach((url) => pushUrl(url));
|
||||
@ -144,8 +146,11 @@ export function collectMessageImageUrls(message: ChatMessage | undefined): strin
|
||||
const parts = (message as any).parts || [];
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
const direct = getMessageImagePartUrl(part);
|
||||
if (direct) {
|
||||
if (
|
||||
(part.type === "file" || part.type === "image" || part.type === "input_image") &&
|
||||
(typeof part.url === "string" || typeof part.data === "string")
|
||||
) {
|
||||
const direct = typeof part.url === "string" ? part.url : part.data;
|
||||
pushUrl(inferOriginalImageUrl(String(direct || "").trim()));
|
||||
}
|
||||
|
||||
@ -297,7 +302,10 @@ export function getMarkdownTableImageUrlKeys(text: string): Set<string> {
|
||||
}
|
||||
|
||||
let endIndex = index + 2;
|
||||
while (endIndex < lines.length && isMarkdownTableRow(lines[endIndex] || "")) {
|
||||
while (
|
||||
endIndex < lines.length &&
|
||||
isMarkdownTableRow(lines[endIndex] || "")
|
||||
) {
|
||||
endIndex += 1;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { isOpenClawChatFileAttachment } from "@/lib/openclaw-chat/media-attachments";
|
||||
|
||||
export const SESSION_TITLE_MAX_LEN = 18;
|
||||
export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
|
||||
@ -34,6 +35,7 @@ const GENERIC_CANVAS_PROMPT_SET = new Set([
|
||||
]);
|
||||
|
||||
export interface PickedObjectAttachment extends Attachment {
|
||||
composerTagId?: string;
|
||||
pickedObject?: {
|
||||
objectName: string;
|
||||
objectPosition: string;
|
||||
@ -41,6 +43,10 @@ export interface PickedObjectAttachment extends Attachment {
|
||||
clickY: number;
|
||||
boundingBox: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
storyboardReference?: {
|
||||
nodeId: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function toRawCanvasChatId(value: string): string {
|
||||
@ -80,6 +86,9 @@ export function normalizeChatMediaType(value?: string | null): string {
|
||||
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
if (isOpenClawChatFileAttachment("", normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
|
||||
@ -298,29 +298,6 @@ function configureMaterialTextures(
|
||||
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(
|
||||
scene: Object3D,
|
||||
modelRotation?: [number, number, number]
|
||||
@ -377,10 +354,7 @@ function ModelScene({
|
||||
loader.setKTX2Loader(ktx2Loader);
|
||||
},
|
||||
);
|
||||
const scene = useMemo(
|
||||
() => cloneSceneWithIndependentMaterials(model.scene),
|
||||
[model.scene]
|
||||
);
|
||||
const scene = useMemo(() => model.scene.clone(), [model.scene]);
|
||||
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
|
||||
const modelRotationKey = modelRotation?.join("|") || "";
|
||||
const groundY = useMemo(
|
||||
@ -405,20 +379,6 @@ function ModelScene({
|
||||
}
|
||||
}, [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(() => {
|
||||
for (const { mesh } of meta.meshRefs) {
|
||||
mesh.castShadow = true;
|
||||
@ -428,6 +388,7 @@ function ModelScene({
|
||||
: [mesh.material];
|
||||
for (const material of materials) {
|
||||
if (!material) continue;
|
||||
configureMaterialTextures(material, mesh.geometry);
|
||||
if ("wireframe" in material) {
|
||||
(material as any).wireframe = settings.wireframe;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Box,
|
||||
@ -43,7 +43,6 @@ import {
|
||||
type CanvasToolbarConfig,
|
||||
} from "@/lib/canvas-toolbar-config";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { canvasService, model3DGenerationService } from "@/services/canvas";
|
||||
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
|
||||
@ -62,15 +61,6 @@ const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = {
|
||||
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) {
|
||||
const raw = String(modelUrl || "").trim();
|
||||
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
|
||||
@ -103,12 +93,7 @@ export function Model3DNode({
|
||||
}: Model3DNodeProps) {
|
||||
const data = node.data as Model3DNodeData;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const recoveryAttemptRef = useRef(0);
|
||||
const recoveryTaskRef = useRef<string | null>(null);
|
||||
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(
|
||||
canvasToolbarConfig,
|
||||
"model3dOpen3d"
|
||||
@ -142,17 +127,6 @@ export function Model3DNode({
|
||||
"model3dDelete"
|
||||
);
|
||||
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<{
|
||||
success?: boolean;
|
||||
data?: PublicThreeDModelAliasConfig;
|
||||
@ -225,224 +199,6 @@ export function Model3DNode({
|
||||
[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 = () => {
|
||||
if (!primaryOpenUrl) {
|
||||
toast.error("暂无可打开的地址");
|
||||
|
||||
@ -71,12 +71,12 @@ export function PromptRegenerateDialog({
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
提示词重绘
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-1">
|
||||
<p className="text-[13px] leading-5 text-muted-foreground">
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
在保留当前图片主体的基础上,用新提示词生成一张新图。原图不会被覆盖。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
@ -147,15 +147,15 @@ export function ThreeDGenerationDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isWorldMode ? (
|
||||
<Globe2 className="h-4 w-4" />
|
||||
<Globe2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Box className="h-4 w-4" />
|
||||
<Box className="h-5 w-5" />
|
||||
)}
|
||||
{isWorldMode ? "选择 3D 世界模型" : "选择 3D 模型"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-1">
|
||||
<p className="text-[13px] leading-5 text-muted-foreground">
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isWorldMode
|
||||
? "先选择用于图生世界的生成质量,再开始创建 3D 世界任务。"
|
||||
: "先选择用于图生 3D 的模型版本,再开始创建 3D 模型任务。"}
|
||||
@ -186,11 +186,11 @@ export function ThreeDGenerationDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-[var(--ui-radius-control)] border border-slate-200 bg-slate-50 p-2.5 text-[13px] dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{selectedEntry?.displayName || "未选择模型"}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-slate-600 dark:text-slate-400">
|
||||
<div className="mt-1 text-slate-600 text-xs dark:text-slate-400">
|
||||
{selectedEntry?.description || "当前模型暂无说明。"}
|
||||
</div>
|
||||
{costLabel ? (
|
||||
|
||||
@ -103,7 +103,6 @@ type World3DViewerProps = {
|
||||
resetViewToken?: number;
|
||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||
showMovementControls?: boolean;
|
||||
showInfoOverlay?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||
@ -2282,7 +2281,6 @@ export function World3DViewer({
|
||||
resetViewToken,
|
||||
onCaptureReady,
|
||||
showMovementControls = false,
|
||||
showInfoOverlay = !minimalFrame,
|
||||
}: World3DViewerProps) {
|
||||
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
||||
|
||||
@ -2389,7 +2387,7 @@ export function World3DViewer({
|
||||
minimalFrame={minimalFrame}
|
||||
resetViewToken={resetViewToken}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
showInfoOverlay={showInfoOverlay}
|
||||
showInfoOverlay={!minimalFrame}
|
||||
showMovementControls={showMovementControls}
|
||||
splatUrl={splatUrl}
|
||||
splatUrls={splatUrls}
|
||||
|
||||
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