Compare commits

..

65 Commits

Author SHA1 Message Date
134
b4e3561510 fix(claw): restore spreadsheet attachment badges 2026-08-11 21:09:38 +08:00
134
92e0f49fbc fix(claw): preserve per-task credentials on reconnect 2026-08-11 18:15:44 +08:00
134
a913f78f8b feat(claw): support resumable 2GB file uploads 2026-08-11 15:02:07 +08:00
134
d65ebc253b feat(batch): sync dedicated image tools 2026-08-11 14:04:35 +08:00
134
4a6afb753d fix(image-tools): restore dedicated processing fallbacks 2026-08-11 13:47:13 +08:00
134
835b60f61c fix(claw): resume durable long-running tasks 2026-08-11 11:54:35 +08:00
134
8a075045f0 feat(claw): add secure forms and folder uploads 2026-08-11 10:04:33 +08:00
134
f3f1fdafbd fix: reconcile VIP98K media catalog 2026-08-10 23:09:26 +08:00
134
712d4350a0 fix(ai): keep dedicated 3d provider frontend route 2026-08-09 18:44:55 +08:00
134
e2f8a253a7 fix(ai): store provider model metadata as jsonb objects 2026-08-09 18:36:49 +08:00
134
7a837c2180 fix(ai): preserve realtime canvas and normalize model types 2026-08-09 18:01:33 +08:00
134
94d6a9008c fix(ai): preserve HiCAD provider integration 2026-08-09 17:28:40 +08:00
134
1b281a76dc fix(model3d): preserve Tripo provider integration 2026-08-09 15:26:10 +08:00
134
2489ce54f2 fix(ai): add VIP98K expansion fallback 2026-08-08 17:55:59 +08:00
134
86c196ce51 fix(ai): honor VIP98K invocation contracts 2026-08-08 17:33:35 +08:00
134
a0a144f506 fix(ai): route VIP98K reference edits through task API 2026-08-08 17:18:43 +08:00
134
cef15871ac fix(ai): allow VIP98K catalog in server scripts 2026-08-08 16:59:08 +08:00
134
9ae24394fe feat(ai): consolidate providers around VIP98K and FAL 2026-08-08 16:20:48 +08:00
134
6ca7feb1a0 fix(canvas): restore PSD template replacement entry 2026-08-07 16:08:11 +08:00
134
265b667628 fix(claw): use static thirty-minute route duration 2026-08-07 15:24:17 +08:00
134
18c4044c66 fix(claw): default chat timeout to thirty minutes 2026-08-07 14:59:05 +08:00
134
643a0128d3 feat: improve Claw workflows and reliability 2026-08-07 14:42:07 +08:00
134
219dc4a4ac feat: add user-scoped OpenClaw MCP access 2026-08-07 11:25:34 +08:00
134
d642470210 fix(claw): persist and recover background chat tasks 2026-08-06 12:30:03 +08:00
134
b3161d2ebc fix(canvas): restore skill search and drag ordering 2026-08-05 00:02:23 +08:00
134
39e8107f23 fix(canvas): open login dialog when Claw session expires 2026-08-04 23:54:50 +08:00
134
4b53f4056a style: lighten homepage promo banner text 2026-08-04 23:37:56 +08:00
134
dd11942da7 feat: add configurable homepage promo banner 2026-08-04 21:35:45 +08:00
134
8773b108e4 feat(canvas): allow resizing model selector 2026-08-04 21:25:04 +08:00
134
05de6d005c feat: add Claw skill search 2026-08-04 13:49:10 +08:00
134
58f1cf1ae2 fix(canvas): make layout replace dialog responsive 2026-08-03 19:49:21 +08:00
134
231be15459 fix: release image queue slots after dispatch 2026-08-03 14:43:21 +08:00
134
9e6db98195 fix(queue): improve image throughput and live status 2026-08-03 13:47:56 +08:00
134
5e74c44430 fix(catalog): isolate claw requests and dedupe table images 2026-08-03 11:10:00 +08:00
134
c5c49b52a9 fix(agent): sync detail replacement task status 2026-08-01 15:59:32 +08:00
134
c988f2dee0 fix(agent): restore detail replacement result pane 2026-08-01 14:43:49 +08:00
134
87bbcf0ccd fix(agent): persist detail replacement tasks 2026-08-01 13:48:42 +08:00
134
9093b0370b fix(claw): clear restored submitted composer text 2026-08-01 10:52:27 +08:00
134
288cd7bf79 fix(claw): prevent sent text returning to composer 2026-08-01 10:33:49 +08:00
134
8b4fd29fce fix(agent): harden detail replacement recovery 2026-07-31 20:05:18 +08:00
134
3c6b7b7778 test(agent): cover detail replacement recovery 2026-07-31 19:43:13 +08:00
134
d5737b70c0 feat(canvas): add layout replace and vip98k provider 2026-07-31 18:17:29 +08:00
134
0a81f15f11 fix(agent): preserve detail replacement results 2026-07-31 17:18:18 +08:00
134
7c9fd8a796 fix: avoid invented task percentages 2026-07-31 14:48:41 +08:00
134
55be81d3d8 fix: stream Claw background task progress 2026-07-31 14:23:32 +08:00
134
000e5c75c9 fix: persist Claw spreadsheet attachments 2026-07-28 21:09:24 +08:00
134
f13b4e8909 feat: add Claw video uploads and complete video canvas workflows 2026-07-28 15:33:04 +08:00
134
78b0da908c fix: harden canvas image generation flows 2026-07-27 22:27:13 +08:00
134
f7fc3561f5 feat: add product planning v2 claw skill 2026-07-24 14:10:13 +08:00
134
1b7d674bce fix: fail over on upstream image provider errors 2026-07-24 13:51:06 +08:00
134
79755bf9e8 fix: verify AtlasCloud frontend model requests 2026-07-22 10:12:03 +08:00
134
ebc0680903 test: preserve image model classification coverage 2026-07-21 11:59:11 +08:00
134
42a42e496d fix: classify media models and open canvas chat 2026-07-21 11:53:58 +08:00
134
5e46ea698c feat: add Atlas Cloud image provider 2026-07-21 11:01:43 +08:00
134
d0a3708e6d feat: add Visionary image provider 2026-07-14 14:10:18 +08:00
134
e1044261d3 feat: add BananaImage image provider 2026-07-13 22:59:34 +08:00
134
c8c6d9132a perf: precompress HiCAD frontend assets 2026-07-13 17:33:02 +08:00
134
e4d527f0f2 fix: translate HiCAD input and spaced copy 2026-07-13 17:07:18 +08:00
134
bb62fa680f feat: complete HiCAD Chinese welcome copy 2026-07-13 16:45:45 +08:00
134
b3abaeef8d feat: localize and brand HiCAD workspace 2026-07-13 16:20:08 +08:00
134
1ab1495e62 fix: dismiss HiCAD loading state reliably 2026-07-13 15:55:07 +08:00
134
da5c0699dd fix: serve versioned HiCAD assets directly 2026-07-13 15:43:07 +08:00
134
5f15b99e7c fix: resolve embedded HiCAD websocket URLs 2026-07-13 15:24:36 +08:00
134
56658a4d16 feat: replace ai-cad with official HiCAD workspace 2026-07-13 14:52:12 +08:00
134
bc7b6f983f fix: restore ai-cad model outputs 2026-07-13 11:57:48 +08:00
2545 changed files with 95751 additions and 472218 deletions

View File

@ -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
View File

@ -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.

View File

@ -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)
# 检查日志是否包含完整错误堆栈
```
---

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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"

View File

@ -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"
)}
>

View File

@ -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">

View File

@ -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>

View File

@ -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",

View File

@ -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
);

View File

@ -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">
开启后,该模型不会出现在前端,也不会参与第三方生图

View File

@ -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" />

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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 }) {

View File

@ -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}
/>
);
}

View File

@ -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;

View File

@ -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" }),

View File

@ -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",

View File

@ -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,

View File

@ -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) {

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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 }
);

View File

@ -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({

View File

@ -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)

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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,
},
});
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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,
};
}

View 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,
},
});
}

View 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,
},
});
}

View 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 }
);
}

View File

@ -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 }
);
}
}

View File

@ -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,

View 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,
},
});
}

View 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);
}

View File

@ -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

View File

@ -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 参数" },

View File

@ -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) {

View 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

View 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);
}

View File

@ -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

View File

@ -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();

View File

@ -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 });
}

View File

@ -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 });
}
}

View File

@ -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
);
}
}

View File

@ -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,
},
});
}

View File

@ -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;

View File

@ -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 },

View File

@ -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,

View File

@ -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 =

View File

@ -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),
]);

View File

@ -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,

View File

@ -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,

View 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 },
);
}
}

View File

@ -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)

View 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 },
);
}
}

View 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 },
);
}
}

View 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,
},
});
}

View 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 },
);
}
}

View 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 });
}
}

View 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 },
);
}
}

View 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 },
);
}
}

View 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 },
);
}
}

View File

@ -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) {

View File

@ -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>
);
}

View File

@ -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();
}

View File

@ -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"
}

View File

@ -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,

View File

@ -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;
}
}

View File

@ -13,7 +13,6 @@
"!lib/db/migrations/meta",
"!public/vendor",
"!public/three",
"!services/pascal-editor",
"!vercel.json"
]
},

View File

@ -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}`}

View File

@ -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>

File diff suppressed because it is too large Load Diff

View 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>
);
}

View File

@ -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}

View File

@ -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

View File

@ -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>
)}

View File

@ -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>

View File

@ -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

View File

@ -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>

View 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>
);
}

View File

@ -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();
}

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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";
}

View File

@ -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

View File

@ -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("暂无可打开的地址");

View File

@ -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 ? (

View File

@ -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