Compare commits
15 Commits
main
...
codex/atla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0accff6de8 | ||
|
|
ebc0680903 | ||
|
|
42a42e496d | ||
|
|
5e46ea698c | ||
|
|
d0a3708e6d | ||
|
|
e1044261d3 | ||
|
|
c8c6d9132a | ||
|
|
e4d527f0f2 | ||
|
|
bb62fa680f | ||
|
|
b3abaeef8d | ||
|
|
1ab1495e62 | ||
|
|
da5c0699dd | ||
|
|
5f15b99e7c | ||
|
|
56658a4d16 | ||
|
|
bc7b6f983f |
10
.env.example
10
.env.example
@ -9,13 +9,6 @@
|
||||
# Generate with: openssl rand -base64 32
|
||||
AUTH_SECRET=replace-with-random-secret
|
||||
|
||||
# Stable pepper for user Access Key hashes. Generate independently from AUTH_SECRET.
|
||||
ACCESS_KEY_PEPPER=replace-with-random-secret
|
||||
|
||||
# Authorizes trusted third-party services to provision user-scoped MCP keys.
|
||||
# Must contain at least 32 characters. Never expose it to end users or browsers.
|
||||
MCP_KEY_PROVISIONING_SECRET=replace-with-random-secret
|
||||
|
||||
# Public application URL. Required for production and self-hosted Auth.js.
|
||||
AUTH_URL=https://your-domain.com
|
||||
NEXTAUTH_URL=https://your-domain.com
|
||||
@ -120,9 +113,6 @@ ZOO_BASE_URL=https://api.zoo.dev
|
||||
# /vendor/pascal-editor/index.html bundle.
|
||||
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=
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -11,8 +11,6 @@ coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
.next-build/
|
||||
.next-previous/
|
||||
out/
|
||||
build
|
||||
|
||||
@ -89,7 +87,7 @@ checkpoints/
|
||||
# 第三方参考源码(不提交到git)
|
||||
reference-sources/
|
||||
|
||||
# Legacy external Pascal Editor checkout (the managed source is under services/).
|
||||
# Local self-hosted Pascal Editor checkout.
|
||||
.pascal-editor/
|
||||
|
||||
# Local self-hosted Zoo Design Studio checkout.
|
||||
|
||||
@ -144,10 +144,18 @@ log.error("Operation failed", error, { userId: "123" });
|
||||
#### 7. 修复 service 层错误吞没
|
||||
**风险等级**: 🟡 中等风险
|
||||
**改动**:
|
||||
- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误
|
||||
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
|
||||
|
||||
**潜在问题**:
|
||||
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
|
||||
- 如果有其他代码依赖 ai-cad service 的错误格式,可能受影响
|
||||
|
||||
**测试建议**:
|
||||
```bash
|
||||
# 触发一个 AI-CAD 错误(如无效的 workspace ID)
|
||||
# 检查日志是否包含完整错误堆栈
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,706 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
Clock3,
|
||||
Globe2,
|
||||
LogIn,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Unlock,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
|
||||
type Severity = "info" | "watch" | "high" | "critical";
|
||||
|
||||
type AccessLogEvent = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
ip: string;
|
||||
userEmail: string | null;
|
||||
userRole: string | null;
|
||||
method: string | null;
|
||||
path: string | null;
|
||||
routeGroup: string | null;
|
||||
statusCode: number | null;
|
||||
outcome: string;
|
||||
severity: Severity;
|
||||
reasons: string[] | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AccessLogTopIp = {
|
||||
ip: string;
|
||||
total: number;
|
||||
uniqueUsers: number;
|
||||
failureCount: number;
|
||||
rateLimitedCount: number;
|
||||
authFailureCount: number;
|
||||
loginSuccessCount: number;
|
||||
suspiciousCount: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
severity: Severity;
|
||||
failureRate: number;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
type AccessLogResponse = {
|
||||
overview: {
|
||||
totalEvents: number;
|
||||
uniqueIps: number;
|
||||
abnormalEvents: number;
|
||||
abnormalIps: number;
|
||||
rateLimitedEvents: number;
|
||||
manualBlockedEvents: number;
|
||||
loginSuccessEvents: number;
|
||||
unauthorizedEvents: number;
|
||||
};
|
||||
topIps: AccessLogTopIp[];
|
||||
hourlyStats: Array<{
|
||||
hour: string;
|
||||
count: number;
|
||||
abnormalCount: number;
|
||||
}>;
|
||||
pathStats: Array<{
|
||||
path: string | null;
|
||||
routeGroup: string | null;
|
||||
count: number;
|
||||
abnormalCount: number;
|
||||
}>;
|
||||
recentEvents: AccessLogEvent[];
|
||||
rules: string[];
|
||||
};
|
||||
|
||||
type AccessBlocklistEntry = {
|
||||
id: string;
|
||||
ip: string;
|
||||
reason: string | null;
|
||||
isActive: boolean;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdByEmail: string | null;
|
||||
};
|
||||
|
||||
const severityLabels: Record<Severity, string> = {
|
||||
info: "正常",
|
||||
watch: "关注",
|
||||
high: "异常",
|
||||
critical: "高危",
|
||||
};
|
||||
|
||||
const severityClassNames: Record<Severity, string> = {
|
||||
info:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-300",
|
||||
watch:
|
||||
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/45 dark:text-amber-300",
|
||||
high:
|
||||
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-300",
|
||||
critical:
|
||||
"border-red-200 bg-red-50 text-red-700 dark:border-red-800/70 dark:bg-red-950/45 dark:text-red-300",
|
||||
};
|
||||
|
||||
function formatNumber(value: number | null | undefined) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null | undefined) {
|
||||
if (!value) return "--";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "--";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getStatusClassName(statusCode: number | null) {
|
||||
if (!statusCode) return "text-muted-foreground";
|
||||
if (statusCode >= 500) return "text-red-700 dark:text-red-300";
|
||||
if (statusCode >= 400) return "text-orange-700 dark:text-orange-300";
|
||||
if (statusCode >= 300) return "text-sky-700 dark:text-sky-300";
|
||||
return "text-emerald-700 dark:text-emerald-300";
|
||||
}
|
||||
|
||||
function AccessSeverityBadge({ severity }: { severity: Severity }) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn("shrink-0 border px-2 py-0.5", severityClassNames[severity])}
|
||||
variant="outline"
|
||||
>
|
||||
{severityLabels[severity]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessSecurityDashboard() {
|
||||
const [hours, setHours] = useState("24");
|
||||
const [severity, setSeverity] = useState("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [blockIp, setBlockIp] = useState("");
|
||||
const [blockReason, setBlockReason] = useState("");
|
||||
const [isSavingBlock, setIsSavingBlock] = useState(false);
|
||||
|
||||
const accessLogUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
hours,
|
||||
severity,
|
||||
limit: "160",
|
||||
});
|
||||
if (query.trim()) params.set("q", query.trim());
|
||||
return `/api/admin/access-logs?${params.toString()}`;
|
||||
}, [hours, query, severity]);
|
||||
|
||||
const { data, isLoading, mutate } = useSWR<AccessLogResponse>(
|
||||
accessLogUrl,
|
||||
fetcher,
|
||||
{ refreshInterval: 30_000 }
|
||||
);
|
||||
const { data: blocklistData, mutate: mutateBlocklist } = useSWR<{
|
||||
entries: AccessBlocklistEntry[];
|
||||
}>("/api/admin/access-blocklist", fetcher);
|
||||
|
||||
const activeBlocklistEntries = useMemo(
|
||||
() => (blocklistData?.entries || []).filter((entry) => entry.isActive),
|
||||
[blocklistData?.entries]
|
||||
);
|
||||
const blockedRules = useMemo(
|
||||
() => new Set(activeBlocklistEntries.map((entry) => entry.ip.toLowerCase())),
|
||||
[activeBlocklistEntries]
|
||||
);
|
||||
|
||||
const saveBlockEntry = async (targetIp?: string, targetReason?: string) => {
|
||||
const ip = String(targetIp || blockIp).trim();
|
||||
const reason = String(targetReason ?? blockReason).trim();
|
||||
if (!ip) {
|
||||
toast.error("请输入要拉黑的 IP");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingBlock(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/access-blocklist", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ip, reason }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || "拉黑失败");
|
||||
}
|
||||
|
||||
toast.success(`${ip} 已加入手动黑名单`);
|
||||
setBlockIp("");
|
||||
setBlockReason("");
|
||||
await Promise.all([mutateBlocklist(), mutate()]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "拉黑失败");
|
||||
} finally {
|
||||
setIsSavingBlock(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBlockEntry = async (entry: AccessBlocklistEntry, isActive: boolean) => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/access-blocklist", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: entry.id,
|
||||
isActive,
|
||||
reason: entry.reason || "",
|
||||
expiresAt: entry.expiresAt || null,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || "更新失败");
|
||||
}
|
||||
|
||||
toast.success(isActive ? `${entry.ip} 已重新拉黑` : `${entry.ip} 已解除拉黑`);
|
||||
await Promise.all([mutateBlocklist(), mutate()]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
const overviewCards = [
|
||||
{
|
||||
key: "events",
|
||||
label: "访问记录",
|
||||
value: formatNumber(data?.overview.totalEvents),
|
||||
note: `${hours} 小时窗口`,
|
||||
icon: Activity,
|
||||
className:
|
||||
"border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-700/70 dark:bg-slate-900/70 dark:text-slate-100",
|
||||
},
|
||||
{
|
||||
key: "ips",
|
||||
label: "独立 IP",
|
||||
value: formatNumber(data?.overview.uniqueIps),
|
||||
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
|
||||
icon: Globe2,
|
||||
className:
|
||||
"border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800/70 dark:bg-sky-950/45 dark:text-sky-100",
|
||||
},
|
||||
{
|
||||
key: "abnormal",
|
||||
label: "异常事件",
|
||||
value: formatNumber(data?.overview.abnormalEvents),
|
||||
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
|
||||
icon: ShieldAlert,
|
||||
className:
|
||||
"border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-100",
|
||||
},
|
||||
{
|
||||
key: "login",
|
||||
label: "成功登录",
|
||||
value: formatNumber(data?.overview.loginSuccessEvents),
|
||||
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
|
||||
icon: LogIn,
|
||||
className:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-100",
|
||||
},
|
||||
];
|
||||
|
||||
const maxHourlyCount = Math.max(
|
||||
1,
|
||||
...(data?.hourlyStats || []).map((item) => item.count)
|
||||
);
|
||||
const maxPathCount = Math.max(
|
||||
1,
|
||||
...(data?.pathStats || []).map((item) => item.count)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">访问安全分析</CardTitle>
|
||||
<CardDescription>
|
||||
IP 访问记录、登录来源、异常标注与请求趋势
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative sm:w-[260px]">
|
||||
<Search className="-translate-y-1/2 absolute left-3 top-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-9 pl-9"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索 IP、路径、用户"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Select onValueChange={setHours} value={hours}>
|
||||
<SelectTrigger className="h-9 sm:w-[132px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">近 1 小时</SelectItem>
|
||||
<SelectItem value="6">近 6 小时</SelectItem>
|
||||
<SelectItem value="24">近 24 小时</SelectItem>
|
||||
<SelectItem value="72">近 3 天</SelectItem>
|
||||
<SelectItem value="168">近 7 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={setSeverity} value={severity}>
|
||||
<SelectTrigger className="h-9 sm:w-[132px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部级别</SelectItem>
|
||||
<SelectItem value="abnormal">仅异常</SelectItem>
|
||||
<SelectItem value="watch">关注</SelectItem>
|
||||
<SelectItem value="high">异常</SelectItem>
|
||||
<SelectItem value="critical">高危</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="h-9 gap-2"
|
||||
onClick={() => mutate()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{overviewCards.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-lg border p-4", item.className)}
|
||||
key={item.key}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-current/70">{item.label}</div>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="mt-3 font-semibold text-3xl tracking-tight">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="mt-1 text-current/65 text-xs">{item.note}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 font-medium text-sm">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
小时趋势
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
橙色表示异常事件占比
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[180px] items-end gap-1">
|
||||
{(data?.hourlyStats || []).map((item) => {
|
||||
const height = Math.max(4, (item.count / maxHourlyCount) * 100);
|
||||
const abnormalHeight =
|
||||
item.count > 0
|
||||
? Math.max(0, (item.abnormalCount / item.count) * height)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200 dark:bg-slate-800"
|
||||
key={item.hour}
|
||||
style={{ height: `${height}%` }}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-orange-400"
|
||||
style={{ height: `${abnormalHeight}%` }}
|
||||
/>
|
||||
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-white text-xs shadow-lg group-hover:block">
|
||||
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "}
|
||||
{formatNumber(item.abnormalCount)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!data?.hourlyStats || data.hourlyStats.length === 0) && (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
|
||||
暂无访问记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">手动 IP 黑名单</CardTitle>
|
||||
<CardDescription>
|
||||
管理员手动加入后,命中 IP 会被阻断并写入访问日志
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(240px,1fr)_auto]">
|
||||
<Input
|
||||
className="h-9"
|
||||
onChange={(event) => setBlockIp(event.target.value)}
|
||||
placeholder="IP 或 IPv4 CIDR"
|
||||
value={blockIp}
|
||||
/>
|
||||
<Input
|
||||
className="h-9"
|
||||
onChange={(event) => setBlockReason(event.target.value)}
|
||||
placeholder="拉黑原因"
|
||||
value={blockReason}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 gap-2"
|
||||
disabled={isSavingBlock}
|
||||
onClick={() => saveBlockEntry()}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
加入黑名单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{activeBlocklistEntries.map((entry) => (
|
||||
<div
|
||||
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3 dark:border-red-900/60 dark:bg-red-950/30"
|
||||
key={entry.id}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
<span className="truncate font-mono font-semibold text-sm text-red-900 dark:text-red-200">
|
||||
{entry.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs dark:text-red-300/75">
|
||||
{entry.reason || "未填写原因"}
|
||||
</div>
|
||||
<div className="mt-2 text-red-700/70 text-xs dark:text-red-400/70">
|
||||
{formatTime(entry.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-950/60"
|
||||
onClick={() => updateBlockEntry(entry, false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
解除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeBlocklistEntries.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed py-8 text-center text-muted-foreground text-sm md:col-span-2 xl:col-span-3">
|
||||
暂无手动拉黑 IP
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">异常 IP 标注</CardTitle>
|
||||
<CardDescription>
|
||||
按限流、失败率、认证异常、敏感路径和请求量综合排序
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(data?.topIps || []).map((item) => (
|
||||
<div
|
||||
className="rounded-lg border bg-background px-4 py-3"
|
||||
key={item.ip}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono font-semibold text-sm">
|
||||
{item.ip}
|
||||
</span>
|
||||
<AccessSeverityBadge severity={item.severity} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-muted-foreground text-xs">
|
||||
<span>{formatNumber(item.total)} 次访问</span>
|
||||
<span>{formatNumber(item.uniqueUsers)} 个用户</span>
|
||||
<span>失败率 {formatPercent(item.failureRate)}</span>
|
||||
<span>最近 {formatTime(item.lastSeen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 md:justify-end">
|
||||
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
|
||||
(reason) => (
|
||||
<Badge
|
||||
className="border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||||
key={reason}
|
||||
variant="outline"
|
||||
>
|
||||
{reason}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
disabled={blockedRules.has(item.ip.toLowerCase()) || isSavingBlock}
|
||||
onClick={() =>
|
||||
saveBlockEntry(
|
||||
item.ip,
|
||||
item.reasons.join("、") || "管理员从异常 IP 排行手动拉黑"
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
{blockedRules.has(item.ip.toLowerCase()) ? "已拉黑" : "拉黑"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!data?.topIps || data.topIps.length === 0) && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
||||
暂无 IP 聚合数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">访问路径</CardTitle>
|
||||
<CardDescription>按请求数量排序</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{(data?.pathStats || []).map((item) => {
|
||||
const abnormalRate =
|
||||
item.count > 0 ? item.abnormalCount / item.count : 0;
|
||||
return (
|
||||
<div key={`${item.routeGroup || "page"}-${item.path || "/"}`}>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{item.path || "/"}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{formatNumber(item.count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-zinc-900 dark:bg-zinc-200"
|
||||
style={{
|
||||
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="-mt-2 h-full rounded-full bg-orange-400"
|
||||
style={{
|
||||
width: `${Math.round(abnormalRate * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!data?.pathStats || data.pathStats.length === 0) && (
|
||||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
||||
暂无路径统计
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">访问日志列表</CardTitle>
|
||||
<CardDescription>最近 {formatNumber(data?.recentEvents.length)} 条</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(data?.rules || []).map((rule) => (
|
||||
<Badge
|
||||
className="border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||||
key={rule}
|
||||
variant="outline"
|
||||
>
|
||||
{rule}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<div className="min-w-[980px]">
|
||||
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs dark:bg-zinc-900/70">
|
||||
<div>时间</div>
|
||||
<div>IP</div>
|
||||
<div>状态</div>
|
||||
<div>路径</div>
|
||||
<div>用户</div>
|
||||
<div>标注</div>
|
||||
</div>
|
||||
{(data?.recentEvents || []).map((event) => (
|
||||
<div
|
||||
className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] items-center border-b px-4 py-3 text-sm last:border-b-0"
|
||||
key={event.id}
|
||||
>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatTime(event.createdAt)}
|
||||
</div>
|
||||
<div className="font-mono text-xs">{event.ip}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-semibold text-xs",
|
||||
getStatusClassName(event.statusCode)
|
||||
)}
|
||||
>
|
||||
{event.statusCode || "--"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{event.method || "GET"} {event.path || "/"}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-muted-foreground text-xs">
|
||||
{event.reasons?.join("、") || event.outcome}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs">
|
||||
{event.userEmail || "匿名"}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
|
||||
<UserCheck className="h-3 w-3" />
|
||||
{event.userRole || "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{event.severity === "high" || event.severity === "critical" ? (
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
) : null}
|
||||
<AccessSeverityBadge severity={event.severity} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!data?.recentEvents || data.recentEvents.length === 0) && (
|
||||
<div className="py-10 text-center text-muted-foreground text-sm">
|
||||
暂无访问日志
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,9 +11,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ADMIN_SETTINGS_NAV_ITEMS } from "../_lib/settings-navigation";
|
||||
import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation";
|
||||
import type { AdminSettingsNavItem } from "../_lib/types";
|
||||
|
||||
const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items);
|
||||
|
||||
function SettingsQuickAction({
|
||||
activeTab,
|
||||
item,
|
||||
@ -31,8 +33,8 @@ function SettingsQuickAction({
|
||||
? "border-foreground/10 bg-foreground text-background shadow-sm hover:!bg-foreground/90 hover:!text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:!bg-white/90 dark:hover:!text-black"
|
||||
: "border-transparent bg-transparent text-muted-foreground hover:!border-border/80 hover:!bg-zinc-100 hover:!text-foreground dark:hover:!bg-zinc-900/50";
|
||||
const layoutClassName = stretched
|
||||
? "h-8 w-full min-w-0 justify-center gap-1.5 rounded-md border px-2.5 text-xs"
|
||||
: "h-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs";
|
||||
? "h-10 w-full min-w-0 justify-center gap-2 rounded-2xl border px-4"
|
||||
: "h-9 shrink-0 gap-2 rounded-full border px-3.5";
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
@ -43,7 +45,7 @@ function SettingsQuickAction({
|
||||
variant="ghost"
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
@ -57,7 +59,7 @@ function SettingsQuickAction({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className={stretched ? "truncate" : ""}>{item.label}</span>
|
||||
</Button>
|
||||
);
|
||||
@ -71,13 +73,9 @@ export function AdminSettingsNavigation({
|
||||
onTabChange: (value: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const activeItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
||||
(item) => item.value === activeTab
|
||||
);
|
||||
const activeItem = ALL_SETTINGS_ITEMS.find((item) => item.value === activeTab);
|
||||
const handleSelectChange = (value: string) => {
|
||||
const nextItem = ADMIN_SETTINGS_NAV_ITEMS.find(
|
||||
(item) => item.value === value
|
||||
);
|
||||
const nextItem = ALL_SETTINGS_ITEMS.find((item) => item.value === value);
|
||||
if (nextItem?.href) {
|
||||
router.push(nextItem.href);
|
||||
return;
|
||||
@ -86,18 +84,15 @@ export function AdminSettingsNavigation({
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="系统设置导航"
|
||||
className="sticky top-2 z-30 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-background/85"
|
||||
>
|
||||
<div className="md:hidden">
|
||||
<div className="rounded-2xl border bg-background px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-2 md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select onValueChange={handleSelectChange} value={activeTab}>
|
||||
<SelectTrigger className="h-8 flex-1 border-border/70 bg-background shadow-none">
|
||||
<SelectTrigger className="h-9 flex-1 border-border/70 bg-background shadow-none">
|
||||
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
|
||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
@ -107,7 +102,7 @@ export function AdminSettingsNavigation({
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="h-8 rounded-md border-border/70 px-2.5 text-xs"
|
||||
className="h-9 rounded-full border-border/70 px-3 text-[13px]"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
@ -118,22 +113,35 @@ export function AdminSettingsNavigation({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||
<SettingsQuickAction
|
||||
activeTab={activeTab}
|
||||
item={item}
|
||||
key={item.value}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]">
|
||||
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => (
|
||||
<SettingsQuickAction
|
||||
activeTab={activeTab}
|
||||
item={item}
|
||||
key={item.value}
|
||||
onTabChange={onTabChange}
|
||||
stretched
|
||||
/>
|
||||
))}
|
||||
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
|
||||
{ALL_SETTINGS_ITEMS.map((item) => (
|
||||
<div className="min-w-[118px] flex-1" key={item.value}>
|
||||
<SettingsQuickAction
|
||||
activeTab={activeTab}
|
||||
item={item}
|
||||
onTabChange={onTabChange}
|
||||
stretched
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="h-8 min-w-0 rounded-md border-border/70 px-2.5 text-xs"
|
||||
className="h-9 shrink-0 rounded-full border-border/70 px-3.5 text-[13px]"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
@ -143,6 +151,6 @@ export function AdminSettingsNavigation({
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,22 +26,22 @@ export function LocalAiCommandCenter({
|
||||
onSetAllExpanded: (expanded: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<div className="border-b bg-muted/20 px-4 py-3">
|
||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-[10px] text-muted-foreground">
|
||||
<div className="overflow-hidden rounded-xl border bg-background">
|
||||
<div className="border-b bg-muted/20 px-5 py-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-semibold text-muted-foreground text-xs">
|
||||
SYSTEM INTEGRATIONS
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg">
|
||||
<h2 className="font-semibold text-2xl tracking-normal">
|
||||
高级能力与外部服务
|
||||
</h2>
|
||||
<p className="max-w-3xl text-muted-foreground text-xs leading-5">
|
||||
<p className="max-w-3xl text-muted-foreground text-sm leading-6">
|
||||
把商品策划、图像处理、3D/CAD、实时画布和开放 API
|
||||
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SettingStatusBadge tone="neutral">
|
||||
已展开 {openSectionCount} 个模块
|
||||
</SettingStatusBadge>
|
||||
@ -52,7 +52,7 @@ export function LocalAiCommandCenter({
|
||||
模型 {modelCount}
|
||||
</SettingStatusBadge>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[11px]"
|
||||
className="h-7 rounded-md px-2 text-xs"
|
||||
onClick={() => onSetAllExpanded(true)}
|
||||
size="sm"
|
||||
type="button"
|
||||
@ -61,7 +61,7 @@ export function LocalAiCommandCenter({
|
||||
全部展开
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[11px]"
|
||||
className="h-7 rounded-md px-2 text-xs"
|
||||
onClick={() => onSetAllExpanded(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
@ -77,21 +77,21 @@ export function LocalAiCommandCenter({
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className="group bg-background p-3 text-left transition-colors hover:bg-muted/30"
|
||||
className="group bg-background p-4 text-left transition-colors hover:bg-muted/30"
|
||||
key={item.key}
|
||||
onClick={() => onFocusSection(item.key)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-muted/30">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-muted/30">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-xs">
|
||||
<div className="truncate font-semibold text-sm">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
<div className="mt-0.5 truncate text-muted-foreground text-xs">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
@ -104,20 +104,20 @@ export function LocalAiCommandCenter({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t bg-muted/10 px-4 py-3">
|
||||
<div className="grid gap-3 lg:grid-cols-4">
|
||||
<div className="border-t bg-muted/10 px-5 py-4">
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
|
||||
<div className="space-y-1.5" key={group}>
|
||||
<div className="font-semibold text-[11px] text-muted-foreground">
|
||||
<div className="space-y-2" key={group}>
|
||||
<div className="font-semibold text-muted-foreground text-xs">
|
||||
{group}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickLinks
|
||||
.filter((item) => item.group === group)
|
||||
.map((item) => (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center gap-1 rounded-md border px-2 text-[11px] transition-colors hover:bg-background",
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs transition-colors hover:bg-background",
|
||||
isSectionExpanded(item.key)
|
||||
? "border-foreground/20 bg-background text-foreground"
|
||||
: "border-border bg-transparent text-muted-foreground"
|
||||
|
||||
@ -24,13 +24,13 @@ export function LocalAiGroupHeading({
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<div className="font-semibold text-[10px] text-muted-foreground uppercase">
|
||||
<div className="rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div className="font-semibold text-[11px] text-muted-foreground uppercase tracking-[0.16em]">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
|
||||
<h3 className="font-semibold text-sm text-foreground">{title}</h3>
|
||||
<p className="max-w-3xl text-muted-foreground text-xs">{description}</p>
|
||||
<h3 className="font-semibold text-base text-foreground">{title}</h3>
|
||||
<p className="max-w-3xl text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -45,7 +45,7 @@ export function LocalAiSectionSummary({
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{items.map((item) => (
|
||||
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
|
||||
{item.label}
|
||||
@ -65,16 +65,16 @@ export function LocalAiSectionToggleButton({
|
||||
return (
|
||||
<Button
|
||||
aria-expanded={expanded}
|
||||
className="h-7 shrink-0 gap-1 rounded-md px-2 text-[11px]"
|
||||
className="h-8 shrink-0 gap-1.5 rounded-md px-2.5 text-xs"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{expanded ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{expanded ? "收起配置" : "展开配置"}
|
||||
</Button>
|
||||
@ -100,9 +100,9 @@ export function LocalAiSectionShell({
|
||||
}) {
|
||||
return (
|
||||
<Card id={id}>
|
||||
<CardHeader className="flex flex-col gap-3 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
{summary}
|
||||
</div>
|
||||
@ -110,7 +110,7 @@ export function LocalAiSectionShell({
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"local-ai-config-card-content space-y-4 pt-4",
|
||||
"local-ai-config-card-content space-y-6 pt-6",
|
||||
!expanded && "hidden"
|
||||
)}
|
||||
>
|
||||
|
||||
@ -75,8 +75,6 @@ export function LocalAiImagePipelineSections({
|
||||
aiWashConfig,
|
||||
setAiWashConfig,
|
||||
aiWashModeIconUploading,
|
||||
canvasLocalEditConfig,
|
||||
setCanvasLocalEditConfig,
|
||||
extendConfig,
|
||||
setExtendConfig,
|
||||
watermarkRemoveConfig,
|
||||
@ -105,7 +103,6 @@ export function LocalAiImagePipelineSections({
|
||||
handleMoveEnhanceProPreset,
|
||||
handleAiWashModeIconUpload,
|
||||
handleSaveAiWashConfig,
|
||||
handleSaveCanvasLocalEditConfig,
|
||||
handleSaveExtendConfig,
|
||||
handleSaveWatermarkRemoveConfig,
|
||||
handleSaveAngleEditConfig,
|
||||
@ -159,15 +156,12 @@ export function LocalAiImagePipelineSections({
|
||||
},
|
||||
{
|
||||
label:
|
||||
rmbgConfig.provider === "vip98k"
|
||||
? "服务商 Key 已配置"
|
||||
: rmbgConfig.apiKey ||
|
||||
rmbgConfig.apiKey ||
|
||||
rmbgConfig.photoroomApiKey ||
|
||||
rmbgConfig.aosApiKey
|
||||
? "Key 已配置"
|
||||
: "Key 待配置",
|
||||
tone:
|
||||
rmbgConfig.provider === "vip98k" ||
|
||||
rmbgConfig.apiKey ||
|
||||
rmbgConfig.photoroomApiKey ||
|
||||
rmbgConfig.aosApiKey
|
||||
@ -182,26 +176,7 @@ export function LocalAiImagePipelineSections({
|
||||
{/* 服务提供商选择 */}
|
||||
<div className="space-y-3">
|
||||
<Label>抠图服务提供商</Label>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div
|
||||
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
||||
rmbgConfig.provider === "vip98k"
|
||||
? "border-primary bg-accent ring-1 ring-primary"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "vip98k" })}
|
||||
>
|
||||
<div
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
|
||||
rmbgConfig.provider === "vip98k"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
{rmbgConfig.provider === "vip98k" && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
<p className="font-medium text-sm leading-none">VIP98K 统一服务</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div
|
||||
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
||||
rmbgConfig.provider === "rmbg"
|
||||
@ -288,13 +263,6 @@ export function LocalAiImagePipelineSections({
|
||||
|
||||
<Separator />
|
||||
|
||||
{rmbgConfig.provider === "vip98k" && (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
使用 VIP98K 的 remove-background 模型。API Key 从“AI 模型设置”中的
|
||||
VIP98K 服务商读取,不会在此重复保存。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RMBG 配置 */}
|
||||
{rmbgConfig.provider === "rmbg" && (
|
||||
<>
|
||||
@ -500,7 +468,7 @@ export function LocalAiImagePipelineSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
|
||||
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("enhance")}
|
||||
@ -510,21 +478,17 @@ export function LocalAiImagePipelineSections({
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label: enhanceConfig.provider === "vip98k"
|
||||
? "VIP98K"
|
||||
: enhanceConfig.useBackupApi
|
||||
label: enhanceConfig.useBackupApi
|
||||
? "备用 Real-ESRGAN"
|
||||
: "主 API",
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
label:
|
||||
enhanceConfig.provider === "vip98k" ||
|
||||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||||
? "Key 已配置"
|
||||
: "Key 待配置",
|
||||
tone:
|
||||
enhanceConfig.provider === "vip98k" ||
|
||||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||||
? "enabled"
|
||||
: "warning",
|
||||
@ -534,13 +498,6 @@ export function LocalAiImagePipelineSections({
|
||||
}
|
||||
title="变清晰API配置"
|
||||
>
|
||||
{enhanceConfig.provider === "vip98k" ? (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
使用 VIP98K 的 enhance 模型,默认 4 倍增强。API Key 从“AI 模型设置”中的
|
||||
VIP98K 服务商读取。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* API切换开关 */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
@ -789,8 +746,6 @@ export function LocalAiImagePipelineSections({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSaveEnhanceConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存配置
|
||||
@ -1466,112 +1421,6 @@ export function LocalAiImagePipelineSections({
|
||||
</Button>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
|
||||
id="local-ai-section-canvas-local-edit"
|
||||
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
|
||||
summary={
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label: canvasLocalEditConfig.modelId
|
||||
? "普通局部编辑已指定"
|
||||
: "普通局部编辑继承默认",
|
||||
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
|
||||
},
|
||||
{
|
||||
label: canvasLocalEditConfig.scaleAdjustModelId
|
||||
? "比例调整已指定"
|
||||
: "比例调整兼容旧默认",
|
||||
tone: canvasLocalEditConfig.scaleAdjustModelId
|
||||
? "enabled"
|
||||
: "neutral",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
title="局部编辑模型配置"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setCanvasLocalEditConfig((prev) => ({
|
||||
...prev,
|
||||
modelId: value === "__none__" ? "" : value,
|
||||
}))
|
||||
}
|
||||
value={canvasLocalEditConfig.modelId || "__none__"}
|
||||
>
|
||||
<SelectTrigger id="canvasLocalEditModel">
|
||||
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
|
||||
{availableImageModels.length > 0 ? (
|
||||
availableImageModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.modelId}>
|
||||
{formatAiWashModelOptionLabel(model)}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem disabled value="__empty__">
|
||||
暂无可用生图模型,请先到 AI 模型里启用
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
适用于手动画框并填写提示词的局部编辑。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setCanvasLocalEditConfig((prev) => ({
|
||||
...prev,
|
||||
scaleAdjustModelId: value === "__none__" ? "" : value,
|
||||
}))
|
||||
}
|
||||
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
|
||||
>
|
||||
<SelectTrigger id="canvasScaleAdjustModel">
|
||||
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
|
||||
{availableImageModels.length > 0 ? (
|
||||
availableImageModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.modelId}>
|
||||
{formatAiWashModelOptionLabel(model)}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem disabled value="__empty__">
|
||||
暂无可用生图模型,请先到 AI 模型里启用
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSaveCanvasLocalEditConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
|
||||
</Button>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
@ -2366,7 +2215,7 @@ export function LocalAiImagePipelineSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
|
||||
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("extend")}
|
||||
@ -2376,14 +2225,8 @@ export function LocalAiImagePipelineSections({
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label:
|
||||
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
||||
? "Key 已配置"
|
||||
: "Key 待配置",
|
||||
tone:
|
||||
extendConfig.provider === "vip98k" || extendConfig.apiKey
|
||||
? "enabled"
|
||||
: "warning",
|
||||
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
|
||||
tone: extendConfig.apiKey ? "enabled" : "warning",
|
||||
},
|
||||
{
|
||||
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
||||
@ -2394,12 +2237,6 @@ export function LocalAiImagePipelineSections({
|
||||
}
|
||||
title="图像拓展API配置"
|
||||
>
|
||||
{extendConfig.provider === "vip98k" ? (
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||||
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
|
||||
VIP98K 服务商读取,仅在这里维护扩展比例。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extendApiKey">API密钥 *</Label>
|
||||
<Input
|
||||
@ -2416,7 +2253,6 @@ export function LocalAiImagePipelineSections({
|
||||
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
|
||||
|
||||
@ -916,8 +916,8 @@ export function LocalAiModel3dSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI
|
||||
提供商配置。
|
||||
配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
|
||||
服务,保存后写入数据库 AI 提供商配置。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("kittycad-cad")}
|
||||
@ -1003,7 +1003,7 @@ export function LocalAiModel3dSections({
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">启用 KittyCAD CAD</Label>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
|
||||
关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -590,7 +590,7 @@ export function LocalAiPlanningSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
为 AI详情生成 单独配置 Kimi Code 编程套餐
|
||||
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.6
|
||||
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
|
||||
</>
|
||||
}
|
||||
@ -615,7 +615,7 @@ export function LocalAiPlanningSections({
|
||||
]}
|
||||
/>
|
||||
}
|
||||
title={<>Kimi Code 编程套餐配置</>}
|
||||
title={<>Moonshot 官方 Kimi K2.6 配置</>}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotProviderName">提供商名称</Label>
|
||||
@ -627,13 +627,13 @@ export function LocalAiPlanningSections({
|
||||
providerName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Kimi Code 编程套餐"
|
||||
placeholder="Moonshot AI (Official)"
|
||||
value={moonshotDetailAgentConfig.providerName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotBaseUrl">编程套餐专用 API 地址</Label>
|
||||
<Label htmlFor="moonshotBaseUrl">官方 API 地址</Label>
|
||||
<Input
|
||||
id="moonshotBaseUrl"
|
||||
onChange={(e) =>
|
||||
@ -646,7 +646,7 @@ export function LocalAiPlanningSections({
|
||||
value={moonshotDetailAgentConfig.baseUrl}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
根据 Kimi Code 文档,编程套餐 OpenAI 兼容接口地址为 `
|
||||
根据 Moonshot 官方文档,OpenAI 兼容接口地址为 `
|
||||
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
|
||||
</div>
|
||||
</div>
|
||||
@ -667,7 +667,7 @@ export function LocalAiPlanningSections({
|
||||
placeholder={
|
||||
moonshotDetailAgentConfig.providerId
|
||||
? "留空则不修改当前 Key"
|
||||
: "请输入 Kimi Code 控制台 API Key"
|
||||
: "请输入 Moonshot 官方 API Key"
|
||||
}
|
||||
type="password"
|
||||
value={moonshotDetailAgentConfig.apiKey}
|
||||
@ -675,7 +675,7 @@ export function LocalAiPlanningSections({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="moonshotModelId">Kimi Code API 模型 ID *</Label>
|
||||
<Label htmlFor="moonshotModelId">Kimi K2.6 API 模型 ID *</Label>
|
||||
<Input
|
||||
id="moonshotModelId"
|
||||
onChange={(e) =>
|
||||
@ -688,8 +688,8 @@ export function LocalAiPlanningSections({
|
||||
value={moonshotDetailAgentConfig.modelId}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Kimi Code 编程套餐专用接口固定使用
|
||||
`kimi-for-coding`;保存后系统会自动创建/更新对应模型记录。
|
||||
已按你提供的 Moonshot 官方文档默认填写
|
||||
`kimi-k2.6`;保存后系统会自动创建/更新对应模型记录。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -745,7 +745,7 @@ export function LocalAiPlanningSections({
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存 Kimi Code 编程套餐配置
|
||||
保存 Moonshot Kimi K2.6 配置
|
||||
</Button>
|
||||
</div>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
@ -15,7 +15,7 @@ export function SettingStatusBadge({
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
"h-5 rounded-md border px-1.5 font-medium text-[10px]",
|
||||
"h-6 rounded-md border px-2 font-medium text-[11px]",
|
||||
tone === "enabled" &&
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",
|
||||
|
||||
@ -12,7 +12,6 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
|
||||
rmbg: false,
|
||||
enhance: false,
|
||||
"enhance-pro": false,
|
||||
"canvas-local-edit": false,
|
||||
"ai-wash": false,
|
||||
extend: false,
|
||||
"watermark-remove": false,
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
|
||||
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
|
||||
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
|
||||
import type { AiWashConfig } from "@/lib/ai-wash/config";
|
||||
import type {
|
||||
EnhanceProKreaConfig,
|
||||
@ -92,7 +91,6 @@ export type AdminAiProvider = {
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "vip98k"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
@ -249,7 +247,7 @@ export type EnhanceProKeyRow = {
|
||||
};
|
||||
|
||||
export type RmbgConfig = {
|
||||
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn";
|
||||
provider: "rmbg" | "photoroom" | "aoscdn";
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
photoroomApiKey: string;
|
||||
@ -261,7 +259,6 @@ export type RmbgConfig = {
|
||||
};
|
||||
|
||||
export type EnhanceConfig = {
|
||||
provider: "vip98k" | "legacy";
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
type: string;
|
||||
@ -274,7 +271,6 @@ export type EnhanceConfig = {
|
||||
};
|
||||
|
||||
export type ExtendConfig = {
|
||||
provider: "vip98k" | "dashscope";
|
||||
apiKey: string;
|
||||
xScale: string;
|
||||
yScale: string;
|
||||
@ -334,8 +330,6 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
aiWashConfig: AiWashConfig;
|
||||
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
|
||||
aiWashModeIconUploading: boolean;
|
||||
canvasLocalEditConfig: CanvasLocalEditConfig;
|
||||
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
|
||||
extendConfig: ExtendConfig;
|
||||
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
|
||||
watermarkRemoveConfig: WatermarkRemoveConfig;
|
||||
@ -378,7 +372,6 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
|
||||
handleAiWashModeIconUpload: (file: File) => Promise<void>;
|
||||
handleSaveAiWashConfig: AsyncAction;
|
||||
handleSaveCanvasLocalEditConfig: AsyncAction;
|
||||
handleSaveExtendConfig: AsyncAction;
|
||||
handleSaveWatermarkRemoveConfig: AsyncAction;
|
||||
handleSaveAngleEditConfig: AsyncAction;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5933,7 +5933,7 @@ export function AgentWorkflowSettingsClient({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="settings-workspace container mx-auto p-4">
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
@ -5945,18 +5945,18 @@ export function AgentWorkflowSettingsClient({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-workspace min-h-screen bg-muted/10">
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
|
||||
<Sparkles className="h-4 w-4 text-background" />
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-violet-500/20">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">风格化工作流配置</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
|
||||
</p>
|
||||
</div>
|
||||
@ -6006,6 +6006,7 @@ export function AgentWorkflowSettingsClient({
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@ -6141,8 +6142,8 @@ export function AgentWorkflowSettingsClient({
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="border-b bg-muted/30">
|
||||
<div className="container mx-auto px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="container mx-auto px-6 py-3">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{enabledStepsCount}
|
||||
@ -6196,8 +6197,8 @@ export function AgentWorkflowSettingsClient({
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto p-4 sm:px-5">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<main className="container mx-auto p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-fit">
|
||||
<TabsTrigger value="showcase" className="gap-2">
|
||||
<MonitorPlay className="h-4 w-4" />
|
||||
|
||||
@ -2430,17 +2430,17 @@ export function PsdTemplatesSettingsClient({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="settings-workspace min-h-screen bg-muted/10">
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
|
||||
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
|
||||
<Sparkles className="h-4 w-4 text-background" />
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-rose-600 shadow-lg shadow-orange-500/20">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">PSD 模板配置</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h1 className="text-xl font-semibold tracking-tight">PSD 模板配置</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出
|
||||
</p>
|
||||
</div>
|
||||
@ -2457,6 +2457,7 @@ export function PsdTemplatesSettingsClient({
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!draftTemplate || isSaving}
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@ -2471,8 +2472,8 @@ export function PsdTemplatesSettingsClient({
|
||||
</header>
|
||||
|
||||
<div className="border-b bg-muted/30">
|
||||
<div className="container mx-auto px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="container mx-auto px-6 py-3">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{templates.length}
|
||||
</Badge>
|
||||
@ -2508,8 +2509,8 @@ export function PsdTemplatesSettingsClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-4 sm:px-5">
|
||||
<div className="space-y-4">
|
||||
<div className="container mx-auto px-6 py-6">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">品类标签</CardTitle>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Save, Trash2, Wand2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Plus, Save, Trash2, Wand2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { toast } from "sonner";
|
||||
@ -25,7 +25,6 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { AdminSettingsNavigation } from "../_components/admin-settings-navigation";
|
||||
import {
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
defaultStyleProConfig,
|
||||
@ -323,7 +322,6 @@ function CustomFieldEditor({
|
||||
}
|
||||
|
||||
export function StyleProSettingsClient() {
|
||||
const router = useRouter();
|
||||
const { data, mutate } = useSWR<SettingsResponse>(
|
||||
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
|
||||
fetcher
|
||||
@ -563,17 +561,19 @@ export function StyleProSettingsClient() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-workspace w-full px-3 py-3 sm:px-4 xl:px-5">
|
||||
<AdminSettingsNavigation
|
||||
activeTab="style-pro"
|
||||
onTabChange={(value) => router.push(`/settings?tab=${value}`)}
|
||||
/>
|
||||
|
||||
<main className="mt-6 flex w-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-xl">风格化 PRO 设置</h1>
|
||||
<p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5">
|
||||
<div className="mb-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回设置
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="font-semibold text-3xl tracking-tight">风格化 PRO 设置</h1>
|
||||
<p className="mt-2 max-w-2xl text-muted-foreground">
|
||||
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
|
||||
</p>
|
||||
</div>
|
||||
@ -592,7 +592,7 @@ export function StyleProSettingsClient() {
|
||||
{saving ? "保存中" : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -1193,7 +1193,6 @@ export function StyleProSettingsClient() {
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,7 +17,6 @@ import {
|
||||
requiresSingleActiveSession,
|
||||
revokeSingleActiveSession,
|
||||
} from "@/lib/auth/session-control";
|
||||
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
|
||||
|
||||
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
|
||||
|
||||
@ -201,10 +200,7 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
|
||||
}
|
||||
|
||||
const nextAuth = NextAuth(async () => {
|
||||
const runtimeAuthSettings =
|
||||
process.env.CANVAS_E2E_LOCAL === "true"
|
||||
? null
|
||||
: await getAuthSettings().catch(() => null as any);
|
||||
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
|
||||
const resolvedAuthUrl =
|
||||
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
|
||||
const googleClientId =
|
||||
@ -287,32 +283,6 @@ const nextAuth = NextAuth(async () => {
|
||||
delete token.sessionKey;
|
||||
}
|
||||
|
||||
if (account && resolvedUserId) {
|
||||
await recordAccessIpEventFromHeaders({
|
||||
eventType: "login_success",
|
||||
userId: resolvedUserId,
|
||||
userEmail:
|
||||
typeof token.email === "string"
|
||||
? token.email
|
||||
: typeof user?.email === "string"
|
||||
? user.email
|
||||
: null,
|
||||
userRole: resolvedUserType || "regular",
|
||||
method: "POST",
|
||||
path: `/api/auth/${account.provider}`,
|
||||
routeGroup: "auth",
|
||||
statusCode: 200,
|
||||
outcome: "login_success",
|
||||
severity: "info",
|
||||
reasons: ["登录成功"],
|
||||
metadata: {
|
||||
provider: account.provider,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn("[Auth] Failed to record login access event:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
|
||||
@ -3,9 +3,9 @@ import path from "node:path";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { ZooDesignStudioFrame } from "@/components/ai-cad/zoo-design-studio-frame";
|
||||
|
||||
const ZOO_MODELING_APP_SRC =
|
||||
"/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
|
||||
const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata: Metadata = {
|
||||
@ -23,22 +23,10 @@ export default async function AiCadPage() {
|
||||
path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
|
||||
);
|
||||
|
||||
if (isReady) {
|
||||
redirect(ZOO_MODELING_APP_SRC);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-svh w-full items-center justify-center bg-[#10100e] p-6 text-[#f8f6ef]">
|
||||
<section className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
|
||||
<h1 className="font-semibold text-lg">HiCAD 官方前端尚未构建</h1>
|
||||
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
|
||||
请运行
|
||||
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
|
||||
pnpm zoo-modeling-app:vendor
|
||||
</code>
|
||||
生成官方前端,然后刷新本页。
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<ZooDesignStudioFrame
|
||||
isReady={isReady}
|
||||
src={ZOO_MODELING_APP_SRC}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,7 +18,6 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"runway",
|
||||
"krea",
|
||||
@ -250,7 +249,6 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "vip98k":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
|
||||
@ -527,7 +527,6 @@ export async function convertMessagesWithImages(
|
||||
normalizedProviderType === "bananaimage" ||
|
||||
normalizedProviderType === "visionary" ||
|
||||
normalizedProviderType === "atlascloud" ||
|
||||
normalizedProviderType === "vip98k" ||
|
||||
normalizedProviderType === "stepfun-image" ||
|
||||
normalizedProviderType === "midjourney" ||
|
||||
normalizedProviderType === "runway" ||
|
||||
|
||||
@ -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");
|
||||
@ -191,7 +190,6 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
||||
normalized === "bananaimage" ||
|
||||
normalized === "visionary" ||
|
||||
normalized === "atlascloud" ||
|
||||
normalized === "vip98k" ||
|
||||
normalized === "stepfun-image" ||
|
||||
normalized === "midjourney" ||
|
||||
normalized === "runway" ||
|
||||
@ -426,19 +424,6 @@ export function writeCanvasViewOperationChunk(
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCanvasCapabilityCommandChunk(
|
||||
writer: StreamWriter,
|
||||
command: CanvasCapabilityClientCommand,
|
||||
label: string
|
||||
) {
|
||||
writeStructuredDataChunk(
|
||||
writer,
|
||||
"data-canvasCapabilityCommand",
|
||||
command,
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
type AgentTargetLockRefPreview = {
|
||||
url: string;
|
||||
title?: string;
|
||||
|
||||
@ -90,10 +90,6 @@ import {
|
||||
hasCanvasAgentPanViewIntent,
|
||||
} from "@/lib/canvas/agent-view-pan";
|
||||
import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback";
|
||||
import {
|
||||
createCanvasCapabilityTools,
|
||||
getCanvasCapabilityToolNames,
|
||||
} from "@/lib/canvas/capabilities/ai-sdk-tools";
|
||||
import {
|
||||
getCanvasAgentOperationErrorMessage,
|
||||
normalizeCanvasAgentOperationErrorCode,
|
||||
@ -293,7 +289,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 +391,6 @@ import {
|
||||
writeAgentTargetLockChunk,
|
||||
writeAssistantTextChunk,
|
||||
writeCanvasMediaChunk,
|
||||
writeCanvasCapabilityCommandChunk,
|
||||
writeCanvasOperationChunk,
|
||||
writeCanvasViewOperationChunk,
|
||||
writeImageGenerationStatusChunk,
|
||||
@ -1312,7 +1306,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 +2889,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,
|
||||
@ -5745,53 +5739,44 @@ export async function POST(request: Request) {
|
||||
);
|
||||
const assistantMessageId = generateUUID();
|
||||
|
||||
try {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: assistantMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: assistantText }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await saveGeneratedImages(
|
||||
assistantText,
|
||||
session.user.id,
|
||||
id,
|
||||
assistantMessageId,
|
||||
params.promptForPersistence || baseReplacementPrompt,
|
||||
resolvedModelRef,
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
sourceAssetIds,
|
||||
sourceImageUrls: params.sourceImageUrls,
|
||||
relationType: "edited_from",
|
||||
sourceType: "detail_replacement",
|
||||
projectId: contextPayload?.projectId || null,
|
||||
userPrompt: requestTextContent,
|
||||
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
||||
requestedImageCount: 1,
|
||||
aspectRatio: resolvedAspectRatio,
|
||||
imageSize: persistedImageSize,
|
||||
id: assistantMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: assistantText }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
);
|
||||
} catch (persistError) {
|
||||
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
|
||||
chatId: id,
|
||||
referenceId: params.reference.id,
|
||||
referenceLabel: params.reference.label,
|
||||
error: persistError,
|
||||
});
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
await saveGeneratedImages(
|
||||
assistantText,
|
||||
session.user.id,
|
||||
id,
|
||||
assistantMessageId,
|
||||
params.promptForPersistence || baseReplacementPrompt,
|
||||
resolvedModelRef,
|
||||
{
|
||||
sourceAssetIds,
|
||||
sourceImageUrls: params.sourceImageUrls,
|
||||
relationType: "edited_from",
|
||||
sourceType: "detail_replacement",
|
||||
projectId: contextPayload?.projectId || null,
|
||||
userPrompt: requestTextContent,
|
||||
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
|
||||
requestedImageCount: 1,
|
||||
aspectRatio: resolvedAspectRatio,
|
||||
imageSize: persistedImageSize,
|
||||
},
|
||||
);
|
||||
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
@ -5819,7 +5804,6 @@ export async function POST(request: Request) {
|
||||
Array.from(replacementReferences.entries()),
|
||||
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
|
||||
async ([referenceIndex, reference]) => {
|
||||
const itemStartedAt = Date.now();
|
||||
if (reference.passthrough === true) {
|
||||
const referenceSourceImageUrls = Array.from(
|
||||
new Set([
|
||||
@ -5840,29 +5824,9 @@ export async function POST(request: Request) {
|
||||
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
|
||||
});
|
||||
successCount += 1;
|
||||
chatApiLogger.info("[Chat API] Direct detail replacement item passthrough completed:", {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
referenceLabel: reference.label,
|
||||
elapsedMs: Date.now() - itemStartedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackSourceImageUrls = Array.from(
|
||||
new Set([
|
||||
...productInputImageUrls,
|
||||
...(modelInputImageUrl ? [modelInputImageUrl] : []),
|
||||
...(reference.rerunReferenceImages || [])
|
||||
.map((image) => String(image.url || "").trim())
|
||||
.filter((url) => /^https?:\/\//i.test(url))
|
||||
.slice(0, 6),
|
||||
reference.imageUrl,
|
||||
]),
|
||||
);
|
||||
|
||||
try {
|
||||
const planningStartedAt = Date.now();
|
||||
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
|
||||
modeType: "detail_replacement",
|
||||
replacementStrategy: detailReplacementStrategy,
|
||||
@ -5877,12 +5841,10 @@ export async function POST(request: Request) {
|
||||
planningRuntime: replacementScenePlanningRuntime,
|
||||
models: replacementVisionModels,
|
||||
});
|
||||
const planningElapsedMs = Date.now() - planningStartedAt;
|
||||
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
referenceLabel: reference.label,
|
||||
planningElapsedMs,
|
||||
shouldReplace: replacementExecutionPlan.shouldReplace,
|
||||
reason: replacementExecutionPlan.reason,
|
||||
detectionSource: replacementExecutionPlan.detectionSource,
|
||||
@ -5992,12 +5954,7 @@ export async function POST(request: Request) {
|
||||
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
|
||||
});
|
||||
|
||||
let generationStartedAt = 0;
|
||||
let generationElapsedMs: number | null = null;
|
||||
let normalizeStartedAt = 0;
|
||||
let normalizeElapsedMs: number | null = null;
|
||||
try {
|
||||
generationStartedAt = Date.now();
|
||||
const result = await generateCanvasImage({
|
||||
prompt: replacementPrompt,
|
||||
negativePrompt: effectiveImageParams?.negativePrompt,
|
||||
@ -6037,7 +5994,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
generationElapsedMs = Date.now() - generationStartedAt;
|
||||
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
||||
const firstImage = result.images[0];
|
||||
if (!firstImage) {
|
||||
@ -6047,7 +6003,6 @@ export async function POST(request: Request) {
|
||||
let finalImage = firstImage;
|
||||
let finalImageSizeLabel: string = resolvedImageSize;
|
||||
try {
|
||||
normalizeStartedAt = Date.now();
|
||||
const normalizedImage = await normalizeReplacementResultImage({
|
||||
generatedImageUrl: firstImage.originalUrl,
|
||||
generatedThumbnailUrl: firstImage.thumbnailUrl,
|
||||
@ -6061,11 +6016,7 @@ export async function POST(request: Request) {
|
||||
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
|
||||
};
|
||||
finalImageSizeLabel = normalizedImage.imageSize;
|
||||
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||
} catch (normalizeError) {
|
||||
if (normalizeStartedAt > 0 && normalizeElapsedMs === null) {
|
||||
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||
}
|
||||
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
|
||||
referenceLabel: reference.label,
|
||||
referenceImageUrl: reference.imageUrl,
|
||||
@ -6083,22 +6034,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
chatApiLogger.info("[Chat API] Direct detail replacement image generation completed:", {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
referenceLabel: reference.label,
|
||||
modelDisplayName: resolvedModelDisplayName,
|
||||
resultImageSize: finalImageSizeLabel,
|
||||
sourceImageCount: sourceImageUrls.length,
|
||||
generationAspectRatio,
|
||||
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
elapsedMs: Date.now() - itemStartedAt,
|
||||
planningElapsedMs,
|
||||
generationElapsedMs,
|
||||
normalizeElapsedMs,
|
||||
});
|
||||
await emitReplacementResultMessage({
|
||||
reference,
|
||||
image: finalImage,
|
||||
@ -6110,16 +6045,9 @@ export async function POST(request: Request) {
|
||||
});
|
||||
successCount += 1;
|
||||
} catch (replacementItemError) {
|
||||
if (generationStartedAt > 0 && generationElapsedMs === null) {
|
||||
generationElapsedMs = Date.now() - generationStartedAt;
|
||||
}
|
||||
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
|
||||
referenceIndex,
|
||||
referenceLabel: reference.label,
|
||||
elapsedMs: Date.now() - itemStartedAt,
|
||||
planningElapsedMs,
|
||||
generationElapsedMs,
|
||||
normalizeElapsedMs,
|
||||
error: replacementItemError,
|
||||
});
|
||||
await emitReplacementResultMessage({
|
||||
@ -6139,30 +6067,6 @@ export async function POST(request: Request) {
|
||||
});
|
||||
successCount += 1;
|
||||
}
|
||||
} catch (replacementPlanError) {
|
||||
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
|
||||
referenceIndex,
|
||||
referenceLabel: reference.label,
|
||||
elapsedMs: Date.now() - itemStartedAt,
|
||||
error: replacementPlanError,
|
||||
});
|
||||
await emitReplacementResultMessage({
|
||||
reference,
|
||||
bodyText: `生成失败,当前先沿用参考图:${
|
||||
replacementPlanError instanceof Error
|
||||
? replacementPlanError.message
|
||||
: "请稍后重试"
|
||||
}`,
|
||||
image: {
|
||||
originalUrl: reference.imageUrl,
|
||||
thumbnailUrl: reference.imageUrl,
|
||||
},
|
||||
modelDisplayName: null,
|
||||
sourceImageUrls: fallbackSourceImageUrls,
|
||||
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
||||
});
|
||||
successCount += 1;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
stopKeepalive();
|
||||
@ -7811,17 +7715,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 +8264,6 @@ export async function POST(request: Request) {
|
||||
shouldRunAgentConversation &&
|
||||
!smartAutoNeedsClarification &&
|
||||
directAgentExecution &&
|
||||
!shouldUseCanvasCapabilityToolLoop &&
|
||||
isDirectAgentImageEditToolName(directAgentExecution.toolName)
|
||||
) {
|
||||
const directAgentToolCapability = directAgentExecution.capability;
|
||||
@ -8818,14 +8710,6 @@ export async function POST(request: Request) {
|
||||
agentIdentityInstruction,
|
||||
recentDialogueSystemContext,
|
||||
baseConversationSystemPrompt,
|
||||
shouldUseCanvasCapabilityToolLoop
|
||||
? [
|
||||
"当前请求来自无限画布,且用户已明确选中图片节点。",
|
||||
"涉及选中图片的识别、编辑、生成、3D、改名、批注或删除时,必须调用已提供的画布能力工具,不得只用文字声称完成。",
|
||||
"可以连续调用多个工具;下一步要处理上一步结果时,把上一步返回的 createdNodeIds 作为 nodeIds。",
|
||||
"只有工具返回成功后才能告诉用户已完成;若工具返回 interactive,说明已在画布打开对应交互界面并等待用户操作。",
|
||||
].join("\n")
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
@ -9222,7 +9106,6 @@ export async function POST(request: Request) {
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
vip98k: 7,
|
||||
"stepfun-image": 7,
|
||||
"grsai-gpt-image": 8,
|
||||
runway: 9,
|
||||
@ -9617,7 +9500,6 @@ export async function POST(request: Request) {
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -9846,8 +9728,6 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: (languageModel as any).provider === "atlascloud"
|
||||
? "atlascloud"
|
||||
: (languageModel as any).provider === "vip98k"
|
||||
? "vip98k"
|
||||
: (languageModel as any).provider === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: (languageModel as any).provider === "midjourney"
|
||||
@ -9954,7 +9834,6 @@ export async function POST(request: Request) {
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
vip98k: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -10004,8 +9883,6 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "vip98k"
|
||||
? "vip98k"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -10395,7 +10272,6 @@ export async function POST(request: Request) {
|
||||
bananaimage: 6,
|
||||
visionary: 6,
|
||||
atlascloud: 6,
|
||||
vip98k: 6,
|
||||
"stepfun-image": 6,
|
||||
runway: 7,
|
||||
krea: 8,
|
||||
@ -10446,8 +10322,6 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "vip98k"
|
||||
? "vip98k"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -10661,7 +10535,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// ========== 千问/FAL/RUXA 视频生成独立处理 ==========
|
||||
if (
|
||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video", "vip98k"].includes((languageModel as any).provider) &&
|
||||
["qianwen-video", "fal-video", "ruxa-video", "runway", "krea", "bltcy-video"].includes((languageModel as any).provider) &&
|
||||
typeof (languageModel as any).doGenerate === "function"
|
||||
) {
|
||||
const currentVideoProvider = (languageModel as any).provider as
|
||||
@ -10670,8 +10544,7 @@ export async function POST(request: Request) {
|
||||
| "ruxa-video"
|
||||
| "runway"
|
||||
| "krea"
|
||||
| "bltcy-video"
|
||||
| "vip98k";
|
||||
| "bltcy-video";
|
||||
chatApiLogger.info(`[Chat API] Using ${currentVideoProvider} video generation`);
|
||||
|
||||
const videoGenerationStream = createUIMessageStream({
|
||||
@ -11094,52 +10967,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); // 调试日志
|
||||
|
||||
@ -11327,7 +11154,6 @@ export async function POST(request: Request) {
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
vip98k: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -11380,8 +11206,6 @@ export async function POST(request: Request) {
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "vip98k"
|
||||
? "vip98k"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -12139,13 +11963,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" }),
|
||||
|
||||
@ -3,7 +3,6 @@ import {
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
HeadObjectCommand,
|
||||
ListPartsCommand,
|
||||
PutObjectCommand,
|
||||
UploadPartCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
@ -22,22 +21,12 @@ import {
|
||||
maybeEnqueueStorageMigrationTask,
|
||||
resolveS3UploadTarget,
|
||||
} from "@/lib/storage/service";
|
||||
import {
|
||||
getOpenClawChatFileContentType,
|
||||
isOpenClawChatFolderFileAllowed,
|
||||
OPENCLAW_CHAT_FILE_MAX_BYTES,
|
||||
OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE,
|
||||
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
|
||||
OPENCLAW_CHAT_VIDEO_MAX_BYTES,
|
||||
OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE,
|
||||
} from "@/lib/openclaw-chat/media-attachments";
|
||||
|
||||
const logger = createLogger("direct-upload-api");
|
||||
const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
|
||||
const DIRECT_UPLOAD_MEDIA_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
|
||||
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
|
||||
const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60;
|
||||
const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
|
||||
const DIRECT_UPLOAD_PREFIX = "uploads/direct";
|
||||
|
||||
function elapsedMs(startedAt: number) {
|
||||
@ -50,13 +39,6 @@ const allowedDirectUploadTypes = new Set([
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
const allowedOpenClawVideoUploadTypes = new Set([
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
"video/x-m4v",
|
||||
"video/x-matroska",
|
||||
]);
|
||||
|
||||
const InitiateSchema = z.object({
|
||||
action: z.literal("initiate"),
|
||||
@ -86,18 +68,6 @@ const CompleteSchema = z.object({
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const ResumeSchema = z.object({
|
||||
action: z.literal("resume"),
|
||||
key: z.string().min(1).max(512),
|
||||
uploadId: z.string().min(1).max(512),
|
||||
filename: z.string().min(1).max(256),
|
||||
contentType: z.string().min(1).max(128),
|
||||
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
|
||||
partSize: z.number().int().min(5 * 1024 * 1024).max(128 * 1024 * 1024),
|
||||
purpose: z.string().max(64).optional(),
|
||||
clientUploadId: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
const AbortSchema = z.object({
|
||||
action: z.literal("abort"),
|
||||
key: z.string().min(1).max(512),
|
||||
@ -107,7 +77,6 @@ const AbortSchema = z.object({
|
||||
|
||||
const RequestSchema = z.discriminatedUnion("action", [
|
||||
InitiateSchema,
|
||||
ResumeSchema,
|
||||
CompleteSchema,
|
||||
AbortSchema,
|
||||
]);
|
||||
@ -135,49 +104,19 @@ function assertUserOwnedKey(key: string, userId: string) {
|
||||
const normalized = String(key || "").replace(/^\/+/, "");
|
||||
const prefix = `${userUploadPrefix(userId)}/`;
|
||||
if (!normalized.startsWith(prefix)) {
|
||||
const error = new Error("Invalid upload object key");
|
||||
error.name = "InvalidUploadObjectKeyError";
|
||||
throw error;
|
||||
throw new Error("Invalid upload object key");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isAllowedDirectUpload(
|
||||
contentType: string,
|
||||
purpose: string | undefined,
|
||||
filename: string
|
||||
) {
|
||||
return (
|
||||
allowedDirectUploadTypes.has(contentType) ||
|
||||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
||||
allowedOpenClawVideoUploadTypes.has(contentType)) ||
|
||||
(purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
|
||||
Boolean(
|
||||
getOpenClawChatFileContentType({ name: filename, type: contentType })
|
||||
)) ||
|
||||
(purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
|
||||
isOpenClawChatFolderFileAllowed({ name: filename, type: contentType }))
|
||||
);
|
||||
}
|
||||
|
||||
function getPurposeMaxBytes(purpose?: string) {
|
||||
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
|
||||
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
|
||||
}
|
||||
if (
|
||||
purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
) {
|
||||
return DIRECT_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
return DIRECT_UPLOAD_MEDIA_MAX_BYTES;
|
||||
function isAllowedDirectUpload(contentType: string) {
|
||||
return allowedDirectUploadTypes.has(contentType);
|
||||
}
|
||||
|
||||
function computePartSize(size: number) {
|
||||
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
|
||||
if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
|
||||
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
|
||||
return 64 * 1024 * 1024;
|
||||
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
|
||||
return 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
async function createPartUrls(params: {
|
||||
@ -185,24 +124,24 @@ async function createPartUrls(params: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
uploadId: string;
|
||||
partNumbers: number[];
|
||||
partCount: number;
|
||||
}) {
|
||||
return Promise.all(
|
||||
params.partNumbers.map(async (partNumber) => {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: params.bucket,
|
||||
Key: params.key,
|
||||
UploadId: params.uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
return {
|
||||
partNumber,
|
||||
url: await getSignedUrl(params.client, command, {
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
const urls = [];
|
||||
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: params.bucket,
|
||||
Key: params.key,
|
||||
UploadId: params.uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
urls.push({
|
||||
partNumber,
|
||||
url: await getSignedUrl(params.client, command, {
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@ -265,7 +204,7 @@ export async function POST(request: Request) {
|
||||
payload.filename
|
||||
);
|
||||
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
if (!isAllowedDirectUpload(contentType)) {
|
||||
logger.warn("unsupported_direct_upload_type", {
|
||||
contentType,
|
||||
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
|
||||
@ -274,19 +213,10 @@ export async function POST(request: Request) {
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||
},
|
||||
{ error: "Direct upload only supports JPG, PNG and WebP images" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||
return NextResponse.json(
|
||||
{ error: `文件不能超过 ${getPurposeMaxBytes(payload.purpose) / 1024 / 1024}MB` },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = sanitizeDisplayName(payload.filename);
|
||||
const key = buildStorageObjectKey(
|
||||
@ -294,13 +224,7 @@ export async function POST(request: Request) {
|
||||
userUploadPrefix(session.user.id)
|
||||
);
|
||||
|
||||
const multipartThreshold =
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? DEFAULT_PART_SIZE_BYTES
|
||||
: MULTIPART_THRESHOLD_BYTES;
|
||||
if (payload.size < multipartThreshold) {
|
||||
if (payload.size < MULTIPART_THRESHOLD_BYTES) {
|
||||
const presignStartedAt = Date.now();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
@ -319,6 +243,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",
|
||||
@ -360,7 +285,7 @@ export async function POST(request: Request) {
|
||||
bucket,
|
||||
key,
|
||||
uploadId,
|
||||
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
|
||||
partCount,
|
||||
});
|
||||
logger.info("direct_upload_multipart_prepared", {
|
||||
elapsedMs: elapsedMs(multipartStartedAt),
|
||||
@ -384,98 +309,6 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.action === "resume") {
|
||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 });
|
||||
}
|
||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
||||
}
|
||||
const expectedPartSize = computePartSize(payload.size);
|
||||
if (payload.partSize !== expectedPartSize) {
|
||||
return NextResponse.json(
|
||||
{ error: "续传分片参数已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const listed = await client.send(
|
||||
new ListPartsCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
UploadId: payload.uploadId,
|
||||
MaxParts: 1000,
|
||||
})
|
||||
);
|
||||
const uploadedParts = (listed.Parts || []).flatMap((part) => {
|
||||
if (!part.ETag || typeof part.PartNumber !== "number") return [];
|
||||
return [
|
||||
{
|
||||
ETag: part.ETag,
|
||||
PartNumber: part.PartNumber,
|
||||
size: Number(part.Size || 0),
|
||||
},
|
||||
];
|
||||
});
|
||||
const uploadedPartNumbers = new Set(
|
||||
uploadedParts.map((part) => part.PartNumber)
|
||||
);
|
||||
const partCount = Math.ceil(payload.size / expectedPartSize);
|
||||
const missingPartNumbers = Array.from(
|
||||
{ length: partCount },
|
||||
(_, index) => index + 1
|
||||
).filter((partNumber) => !uploadedPartNumbers.has(partNumber));
|
||||
const parts = await createPartUrls({
|
||||
client,
|
||||
bucket,
|
||||
key,
|
||||
uploadId: payload.uploadId,
|
||||
partNumbers: missingPartNumbers,
|
||||
});
|
||||
|
||||
logger.info("direct_upload_multipart_resumed", {
|
||||
totalElapsedMs: elapsedMs(requestStartedAt),
|
||||
size: payload.size,
|
||||
uploadedPartCount: uploadedParts.length,
|
||||
missingPartCount: missingPartNumbers.length,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json({
|
||||
mode: "multipart",
|
||||
key,
|
||||
uploadId: payload.uploadId,
|
||||
partSize: expectedPartSize,
|
||||
partCount,
|
||||
parts,
|
||||
uploadedParts,
|
||||
resumed: true,
|
||||
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
} catch (error) {
|
||||
const head = await client
|
||||
.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
|
||||
.catch(() => null);
|
||||
if (head?.ContentLength === payload.size) {
|
||||
return NextResponse.json({
|
||||
mode: "completed",
|
||||
key,
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
}
|
||||
logger.warn("direct_upload_resume_session_missing", error, {
|
||||
clientUploadId: payload.clientUploadId || null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "续传会话已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.action === "abort") {
|
||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||
const abortStartedAt = Date.now();
|
||||
@ -496,18 +329,12 @@ export async function POST(request: Request) {
|
||||
|
||||
const key = assertUserOwnedKey(payload.key, session.user.id);
|
||||
const contentType = normalizeContentType(payload.contentType, payload.filename);
|
||||
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
|
||||
if (!isAllowedDirectUpload(contentType)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
|
||||
},
|
||||
{ error: "Direct upload only supports JPG, PNG and WebP images" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
|
||||
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
|
||||
}
|
||||
|
||||
if (payload.uploadId) {
|
||||
const completeMultipartStartedAt = Date.now();
|
||||
@ -553,10 +380,8 @@ export async function POST(request: Request) {
|
||||
|
||||
const actualSize =
|
||||
typeof head.ContentLength === "number" ? head.ContentLength : null;
|
||||
if (actualSize !== null && actualSize !== payload.size) {
|
||||
throw new Error(
|
||||
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
|
||||
);
|
||||
if (actualSize !== null && actualSize <= 0) {
|
||||
throw new Error("Uploaded object is empty");
|
||||
}
|
||||
|
||||
const url = buildS3PublicUrl(target.config, key);
|
||||
@ -565,43 +390,15 @@ export async function POST(request: Request) {
|
||||
const assetStartedAt = Date.now();
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? "file"
|
||||
: resolvedContentType.startsWith("video/")
|
||||
? "video"
|
||||
: "image",
|
||||
sourceType:
|
||||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_video_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_file_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_folder_file_upload"
|
||||
: "upload",
|
||||
mediaType: "image",
|
||||
sourceType: "upload",
|
||||
status: "ready",
|
||||
originalUrl: url,
|
||||
previewUrl:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? null
|
||||
: url,
|
||||
thumbnailUrl:
|
||||
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
|
||||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? null
|
||||
: url,
|
||||
previewUrl: url,
|
||||
thumbnailUrl: url,
|
||||
mimeType: resolvedContentType,
|
||||
displayName: payload.filename,
|
||||
sourceTable:
|
||||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_video_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_file_upload"
|
||||
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
|
||||
? "openclaw_chat_folder_file_upload"
|
||||
: "direct_upload",
|
||||
sourceTable: "direct_upload",
|
||||
metadata: {
|
||||
pathname: key,
|
||||
size: actualSize ?? payload.size,
|
||||
@ -648,13 +445,7 @@ export async function POST(request: Request) {
|
||||
error: "Direct upload failed",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{
|
||||
status:
|
||||
error instanceof Error &&
|
||||
error.name === "InvalidUploadObjectKeyError"
|
||||
? 403
|
||||
: 500,
|
||||
}
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import { 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 { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
|
||||
|
||||
const logger = createLogger("upload-api");
|
||||
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
|
||||
@ -15,9 +14,6 @@ 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",
|
||||
@ -27,20 +23,8 @@ const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
]);
|
||||
const ALLOWED_OPENCLAW_VIDEO_TYPES = new Set([
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
"video/x-m4v",
|
||||
"video/x-matroska",
|
||||
]);
|
||||
|
||||
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
|
||||
const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
|
||||
"image/avif",
|
||||
"image/tiff",
|
||||
"image/x-tiff",
|
||||
]);
|
||||
|
||||
function normalizeUploadContentType(contentType: string, filename: string) {
|
||||
const normalized = contentType.trim().toLowerCase();
|
||||
@ -56,16 +40,8 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
|
||||
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 +53,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 +76,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智能优化图片(保持原格式)
|
||||
* 策略:避免双重压缩,只在需要时优化
|
||||
@ -254,9 +205,6 @@ export async function POST(request: Request) {
|
||||
const isAtlasCloudReferenceVideo =
|
||||
uploadPurpose === "canvas-video-process" &&
|
||||
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
|
||||
const isOpenClawChatVideoUpload =
|
||||
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
|
||||
ALLOWED_OPENCLAW_VIDEO_TYPES.has(contentType);
|
||||
const clientUploadId =
|
||||
String(formData.get("clientUploadId") || "").trim() || null;
|
||||
const isSvgUpload = contentType === "image/svg+xml";
|
||||
@ -289,8 +237,7 @@ export async function POST(request: Request) {
|
||||
|
||||
if (
|
||||
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
|
||||
!isAtlasCloudReferenceVideo &&
|
||||
!isOpenClawChatVideoUpload
|
||||
!isAtlasCloudReferenceVideo
|
||||
) {
|
||||
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
|
||||
logger.warn("[Upload API] Unsupported upload content type", {
|
||||
@ -341,38 +288,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") {
|
||||
@ -457,28 +372,15 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? "video"
|
||||
: "image",
|
||||
sourceType: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: "upload",
|
||||
mediaType: isAtlasCloudReferenceVideo ? "video" : "image",
|
||||
sourceType: "upload",
|
||||
status: "ready",
|
||||
originalUrl: data.url,
|
||||
previewUrl:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? null
|
||||
: data.url,
|
||||
thumbnailUrl:
|
||||
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
|
||||
? null
|
||||
: data.url,
|
||||
previewUrl: isAtlasCloudReferenceVideo ? null : data.url,
|
||||
thumbnailUrl: isAtlasCloudReferenceVideo ? null : data.url,
|
||||
mimeType: data.contentType || contentType || null,
|
||||
displayName: filename,
|
||||
sourceTable: isOpenClawChatVideoUpload
|
||||
? "openclaw_chat_video_upload"
|
||||
: "upload",
|
||||
sourceTable: "upload",
|
||||
metadata: {
|
||||
pathname: data.pathname,
|
||||
size: optimizedBuffer.length,
|
||||
|
||||
@ -72,73 +72,6 @@ function isWorkflowImageGenerationModel(model: {
|
||||
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
|
||||
if (isLocalCanvasE2E) {
|
||||
const models = [
|
||||
{
|
||||
id: "local-e2e-image-model",
|
||||
modelId: "nano-banana-pro-local",
|
||||
displayName: "Nano Banana Pro",
|
||||
avatarUrl: null,
|
||||
description: "本地视频画布测试图片模型",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
isMultimodal: true,
|
||||
isDefault: true,
|
||||
providerName: "Local E2E",
|
||||
providerType: "local",
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
capabilities: { image: true },
|
||||
videoToolCapability: null,
|
||||
imageGenConfig: {
|
||||
creditCostPerImage: 1,
|
||||
creditCostPerVideo: 20,
|
||||
videoDurationOptions: [5, 10],
|
||||
},
|
||||
sortOrder: 0,
|
||||
selectable: true,
|
||||
},
|
||||
{
|
||||
id: "local-e2e-video-model",
|
||||
modelId: "kling-3-local",
|
||||
displayName: "可灵 3.0",
|
||||
avatarUrl: null,
|
||||
description: "本地视频画布测试视频模型",
|
||||
supportsVision: true,
|
||||
supportsImageGen: false,
|
||||
isMultimodal: true,
|
||||
isDefault: false,
|
||||
providerName: "Local E2E",
|
||||
providerType: "local",
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
capabilities: { video: true },
|
||||
videoToolCapability: {
|
||||
supported: true,
|
||||
durations: [5, 10],
|
||||
resolutions: ["720p", "1080p"],
|
||||
},
|
||||
imageGenConfig: {
|
||||
creditCostPerVideo: 20,
|
||||
videoDurationOptions: [5, 10],
|
||||
},
|
||||
sortOrder: 1,
|
||||
selectable: true,
|
||||
},
|
||||
];
|
||||
|
||||
return Response.json({
|
||||
models,
|
||||
defaultModelId: models[0].id,
|
||||
agentWorkflowConfiguredDefaultModelId: null,
|
||||
agentWorkflowDefaultModelId: null,
|
||||
agentWorkflowConfiguredModels: [],
|
||||
agentModel: null,
|
||||
});
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
|
||||
@ -1,207 +0,0 @@
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { accessIpBlocklist, user } from "@/lib/db/schema";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
|
||||
import { normalizeManualBlockIp } from "@/lib/security/manual-ip-blocklist";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 10;
|
||||
|
||||
const createSchema = z.object({
|
||||
ip: z.string().trim().min(1).max(128),
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
expiresAt: z.string().trim().optional().nullable(),
|
||||
});
|
||||
|
||||
const patchSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
isActive: z.boolean(),
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
expiresAt: z.string().trim().optional().nullable(),
|
||||
});
|
||||
|
||||
function normalizeOptionalDate(value?: string | null) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return null;
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const entries = await db
|
||||
.select({
|
||||
id: accessIpBlocklist.id,
|
||||
ip: accessIpBlocklist.ip,
|
||||
reason: accessIpBlocklist.reason,
|
||||
isActive: accessIpBlocklist.isActive,
|
||||
expiresAt: accessIpBlocklist.expiresAt,
|
||||
createdAt: accessIpBlocklist.createdAt,
|
||||
updatedAt: accessIpBlocklist.updatedAt,
|
||||
createdBy: accessIpBlocklist.createdBy,
|
||||
createdByEmail: user.email,
|
||||
})
|
||||
.from(accessIpBlocklist)
|
||||
.leftJoin(user, eq(accessIpBlocklist.createdBy, user.id))
|
||||
.orderBy(desc(accessIpBlocklist.isActive), desc(accessIpBlocklist.updatedAt))
|
||||
.limit(300);
|
||||
|
||||
return NextResponse.json({ entries });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
console.error("[AccessIpBlocklist] failed to list entries", error);
|
||||
return new ChatSDKError("offline:admin").toResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
const body = createSchema.parse(await request.json());
|
||||
const normalizedIp = normalizeManualBlockIp(body.ip);
|
||||
|
||||
if (!normalizedIp) {
|
||||
return new ChatSDKError(
|
||||
"bad_request:api",
|
||||
"请输入有效的 IP 地址或 IPv4 CIDR,例如 1.2.3.4 或 1.2.3.0/24"
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = normalizeOptionalDate(body.expiresAt);
|
||||
const reason = body.reason?.trim() || null;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: accessIpBlocklist.id })
|
||||
.from(accessIpBlocklist)
|
||||
.where(sql`lower(${accessIpBlocklist.ip}) = lower(${normalizedIp})`)
|
||||
.limit(1);
|
||||
|
||||
const [entry] = existing
|
||||
? await db
|
||||
.update(accessIpBlocklist)
|
||||
.set({
|
||||
reason,
|
||||
isActive: true,
|
||||
expiresAt,
|
||||
updatedBy: session.user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(accessIpBlocklist.id, existing.id))
|
||||
.returning()
|
||||
: await db
|
||||
.insert(accessIpBlocklist)
|
||||
.values({
|
||||
ip: normalizedIp,
|
||||
reason,
|
||||
isActive: true,
|
||||
expiresAt,
|
||||
createdBy: session.user.id,
|
||||
updatedBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await recordAccessIpEventFromHeaders({
|
||||
eventType: "manual_block_added",
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email || null,
|
||||
userRole: session.user.type,
|
||||
method: "POST",
|
||||
path: "/api/admin/access-blocklist",
|
||||
routeGroup: "admin-api",
|
||||
statusCode: 200,
|
||||
outcome: "manual_block_added",
|
||||
severity: "high",
|
||||
reasons: ["管理员手动拉黑 IP"],
|
||||
metadata: {
|
||||
targetIp: normalizedIp,
|
||||
reason,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn("[AccessIpBlocklist] failed to record block action", error);
|
||||
});
|
||||
|
||||
return NextResponse.json({ entry });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||
}
|
||||
console.error("[AccessIpBlocklist] failed to create entry", error);
|
||||
return new ChatSDKError("offline:admin").toResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
const body = patchSchema.parse(await request.json());
|
||||
const now = new Date();
|
||||
const expiresAt = normalizeOptionalDate(body.expiresAt);
|
||||
|
||||
const [existing] = await db
|
||||
.select({ ip: accessIpBlocklist.ip })
|
||||
.from(accessIpBlocklist)
|
||||
.where(eq(accessIpBlocklist.id, body.id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return new ChatSDKError("bad_request:api", "IP 黑名单记录不存在").toResponse();
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.update(accessIpBlocklist)
|
||||
.set({
|
||||
isActive: body.isActive,
|
||||
reason: body.reason?.trim() || undefined,
|
||||
expiresAt,
|
||||
updatedBy: session.user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(accessIpBlocklist.id, body.id))
|
||||
.returning();
|
||||
|
||||
await recordAccessIpEventFromHeaders({
|
||||
eventType: body.isActive ? "manual_block_enabled" : "manual_block_released",
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email || null,
|
||||
userRole: session.user.type,
|
||||
method: "PATCH",
|
||||
path: "/api/admin/access-blocklist",
|
||||
routeGroup: "admin-api",
|
||||
statusCode: 200,
|
||||
outcome: body.isActive ? "manual_block_enabled" : "manual_block_released",
|
||||
severity: body.isActive ? "high" : "watch",
|
||||
reasons: [body.isActive ? "管理员启用 IP 拉黑" : "管理员解除 IP 拉黑"],
|
||||
metadata: {
|
||||
targetIp: existing.ip,
|
||||
blocklistId: body.id,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.warn("[AccessIpBlocklist] failed to record update action", error);
|
||||
});
|
||||
|
||||
return NextResponse.json({ entry });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
||||
}
|
||||
console.error("[AccessIpBlocklist] failed to update entry", error);
|
||||
return new ChatSDKError("offline:admin").toResponse();
|
||||
}
|
||||
}
|
||||
@ -1,271 +0,0 @@
|
||||
import { and, desc, gte, sql, type SQL } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { accessIpEvent } from "@/lib/db/schema";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 10;
|
||||
|
||||
type Severity = "info" | "watch" | "high" | "critical";
|
||||
|
||||
const severityByRank: Record<number, Severity> = {
|
||||
0: "info",
|
||||
1: "watch",
|
||||
2: "high",
|
||||
3: "critical",
|
||||
};
|
||||
|
||||
function clampInt(value: string | null, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(Math.max(parsed, min), max);
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string | null) {
|
||||
return String(value || "").trim().slice(0, 120);
|
||||
}
|
||||
|
||||
function buildSeverityFromAggregate(params: {
|
||||
eventSeverityRank: number;
|
||||
total: number;
|
||||
failureCount: number;
|
||||
rateLimitedCount: number;
|
||||
authFailureCount: number;
|
||||
suspiciousCount: number;
|
||||
}) {
|
||||
let severity = severityByRank[params.eventSeverityRank] || "info";
|
||||
const failureRate =
|
||||
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
|
||||
|
||||
if (params.rateLimitedCount > 0 || params.suspiciousCount > 0) {
|
||||
severity = "high";
|
||||
} else if (params.authFailureCount >= 5 || params.total >= 250) {
|
||||
severity = severity === "critical" ? severity : "high";
|
||||
} else if (
|
||||
params.failureCount >= 20 ||
|
||||
(params.total >= 10 && failureRate >= 0.45)
|
||||
) {
|
||||
severity = severity === "critical" ? severity : "high";
|
||||
} else if (params.authFailureCount > 0 || params.failureCount > 0) {
|
||||
severity = severity === "info" ? "watch" : severity;
|
||||
}
|
||||
|
||||
return severity;
|
||||
}
|
||||
|
||||
function buildAggregateReasons(params: {
|
||||
total: number;
|
||||
failureCount: number;
|
||||
rateLimitedCount: number;
|
||||
authFailureCount: number;
|
||||
suspiciousCount: number;
|
||||
loginSuccessCount: number;
|
||||
}) {
|
||||
const reasons: string[] = [];
|
||||
const failureRate =
|
||||
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
|
||||
|
||||
if (params.rateLimitedCount > 0) reasons.push("触发限流");
|
||||
if (params.authFailureCount >= 5) reasons.push("认证失败偏多");
|
||||
if (params.suspiciousCount > 0) reasons.push("命中异常规则");
|
||||
if (params.total >= 250) reasons.push("请求量偏高");
|
||||
if (params.failureCount >= 20 || (params.total >= 10 && failureRate >= 0.45)) {
|
||||
reasons.push("失败率偏高");
|
||||
}
|
||||
if (params.loginSuccessCount > 0 && reasons.length === 0) {
|
||||
reasons.push("有成功登录记录");
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const hours = clampInt(searchParams.get("hours"), 24, 1, 168);
|
||||
const limit = clampInt(searchParams.get("limit"), 120, 25, 500);
|
||||
const severityFilter = searchParams.get("severity") || "all";
|
||||
const search = normalizeSearch(searchParams.get("q"));
|
||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
const whereParts: SQL[] = [gte(accessIpEvent.createdAt, since)];
|
||||
|
||||
if (severityFilter === "abnormal") {
|
||||
whereParts.push(sql`${accessIpEvent.severity} <> 'info'`);
|
||||
} else if (["info", "watch", "high", "critical"].includes(severityFilter)) {
|
||||
whereParts.push(sql`${accessIpEvent.severity} = ${severityFilter}`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const pattern = `%${search}%`;
|
||||
whereParts.push(sql`(
|
||||
${accessIpEvent.ip} ILIKE ${pattern}
|
||||
OR ${accessIpEvent.path} ILIKE ${pattern}
|
||||
OR ${accessIpEvent.userEmail} ILIKE ${pattern}
|
||||
OR ${accessIpEvent.userAgent} ILIKE ${pattern}
|
||||
)`);
|
||||
}
|
||||
|
||||
const whereClause = and(...whereParts);
|
||||
|
||||
const [overview] = await db
|
||||
.select({
|
||||
totalEvents: sql<number>`count(*)::int`,
|
||||
uniqueIps: sql<number>`count(distinct ${accessIpEvent.ip})::int`,
|
||||
abnormalEvents: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||
abnormalIps: sql<number>`count(distinct ${accessIpEvent.ip}) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||
rateLimitedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
|
||||
manualBlockedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'manual_blocked')::int`,
|
||||
loginSuccessEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
|
||||
unauthorizedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'unauthorized' or ${accessIpEvent.statusCode} in (401, 403))::int`,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause);
|
||||
|
||||
const recentEvents = await db
|
||||
.select({
|
||||
id: accessIpEvent.id,
|
||||
eventType: accessIpEvent.eventType,
|
||||
ip: accessIpEvent.ip,
|
||||
userId: accessIpEvent.userId,
|
||||
userEmail: accessIpEvent.userEmail,
|
||||
userRole: accessIpEvent.userRole,
|
||||
method: accessIpEvent.method,
|
||||
path: accessIpEvent.path,
|
||||
routeGroup: accessIpEvent.routeGroup,
|
||||
statusCode: accessIpEvent.statusCode,
|
||||
outcome: accessIpEvent.outcome,
|
||||
severity: accessIpEvent.severity,
|
||||
reasons: accessIpEvent.reasons,
|
||||
userAgent: accessIpEvent.userAgent,
|
||||
referer: accessIpEvent.referer,
|
||||
occurredAt: accessIpEvent.occurredAt,
|
||||
createdAt: accessIpEvent.createdAt,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(accessIpEvent.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
const topIpRows = await db
|
||||
.select({
|
||||
ip: accessIpEvent.ip,
|
||||
total: sql<number>`count(*)::int`,
|
||||
uniqueUsers: sql<number>`count(distinct ${accessIpEvent.userId}) filter (where ${accessIpEvent.userId} is not null)::int`,
|
||||
failureCount: sql<number>`count(*) filter (where ${accessIpEvent.statusCode} >= 400)::int`,
|
||||
rateLimitedCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
|
||||
authFailureCount: sql<number>`count(*) filter (where ${accessIpEvent.path} like '/api/auth%' and ${accessIpEvent.statusCode} >= 400)::int`,
|
||||
loginSuccessCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
|
||||
suspiciousCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical'))::int`,
|
||||
firstSeen: sql<string>`min(${accessIpEvent.createdAt})::text`,
|
||||
lastSeen: sql<string>`max(${accessIpEvent.createdAt})::text`,
|
||||
eventSeverityRank: sql<number>`max(case ${accessIpEvent.severity}
|
||||
when 'critical' then 3
|
||||
when 'high' then 2
|
||||
when 'watch' then 1
|
||||
else 0
|
||||
end)::int`,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause)
|
||||
.groupBy(accessIpEvent.ip)
|
||||
.orderBy(
|
||||
desc(sql`(
|
||||
count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical')) * 8
|
||||
+ count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429) * 12
|
||||
+ count(*) filter (where ${accessIpEvent.statusCode} >= 400) * 3
|
||||
+ count(*)
|
||||
)`)
|
||||
)
|
||||
.limit(24);
|
||||
|
||||
const topIps = topIpRows.map((row) => {
|
||||
const severity = buildSeverityFromAggregate({
|
||||
eventSeverityRank: Number(row.eventSeverityRank || 0),
|
||||
total: Number(row.total || 0),
|
||||
failureCount: Number(row.failureCount || 0),
|
||||
rateLimitedCount: Number(row.rateLimitedCount || 0),
|
||||
authFailureCount: Number(row.authFailureCount || 0),
|
||||
suspiciousCount: Number(row.suspiciousCount || 0),
|
||||
});
|
||||
|
||||
return {
|
||||
...row,
|
||||
severity,
|
||||
failureRate:
|
||||
Number(row.total || 0) > 0
|
||||
? Number(row.failureCount || 0) / Number(row.total || 1)
|
||||
: 0,
|
||||
reasons: buildAggregateReasons({
|
||||
total: Number(row.total || 0),
|
||||
failureCount: Number(row.failureCount || 0),
|
||||
rateLimitedCount: Number(row.rateLimitedCount || 0),
|
||||
authFailureCount: Number(row.authFailureCount || 0),
|
||||
suspiciousCount: Number(row.suspiciousCount || 0),
|
||||
loginSuccessCount: Number(row.loginSuccessCount || 0),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const hourlyStats = await db
|
||||
.select({
|
||||
hour: sql<string>`to_char(date_trunc('hour', ${accessIpEvent.createdAt}), 'MM-DD HH24:00')`,
|
||||
count: sql<number>`count(*)::int`,
|
||||
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause)
|
||||
.groupBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`)
|
||||
.orderBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`);
|
||||
|
||||
const pathStats = await db
|
||||
.select({
|
||||
path: accessIpEvent.path,
|
||||
routeGroup: accessIpEvent.routeGroup,
|
||||
count: sql<number>`count(*)::int`,
|
||||
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
||||
})
|
||||
.from(accessIpEvent)
|
||||
.where(whereClause)
|
||||
.groupBy(accessIpEvent.path, accessIpEvent.routeGroup)
|
||||
.orderBy(desc(sql`count(*)`))
|
||||
.limit(12);
|
||||
|
||||
return NextResponse.json({
|
||||
window: {
|
||||
hours,
|
||||
since: since.toISOString(),
|
||||
},
|
||||
overview: {
|
||||
totalEvents: overview?.totalEvents || 0,
|
||||
uniqueIps: overview?.uniqueIps || 0,
|
||||
abnormalEvents: overview?.abnormalEvents || 0,
|
||||
abnormalIps: overview?.abnormalIps || 0,
|
||||
rateLimitedEvents: overview?.rateLimitedEvents || 0,
|
||||
manualBlockedEvents: overview?.manualBlockedEvents || 0,
|
||||
loginSuccessEvents: overview?.loginSuccessEvents || 0,
|
||||
unauthorizedEvents: overview?.unauthorizedEvents || 0,
|
||||
},
|
||||
topIps,
|
||||
hourlyStats,
|
||||
pathStats,
|
||||
recentEvents,
|
||||
rules: [
|
||||
"短时间请求量偏高",
|
||||
"4xx/5xx 或失败率偏高",
|
||||
"认证失败偏多",
|
||||
"触发限流",
|
||||
"命中敏感路径或异常 User-Agent",
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
console.error("[AccessIpEvent] failed to load admin analytics", error);
|
||||
return new ChatSDKError("offline:admin").toResponse();
|
||||
}
|
||||
}
|
||||
@ -33,11 +33,6 @@ const imageGenConfigSchema = z.object({
|
||||
supportsNegativePrompt: z.boolean().optional(),
|
||||
supportsWatermark: z.boolean().optional(),
|
||||
supportsSeed: z.boolean().optional(),
|
||||
supportsQuality: z.boolean().optional(),
|
||||
supportedAspectRatios: z.array(z.string().min(1).max(32)).max(32).optional(),
|
||||
supportedImageSizes: z.array(z.string().min(1).max(32)).max(32).optional(),
|
||||
maxReferenceImages: z.number().min(0).max(64).optional(),
|
||||
maxVideoReferences: z.number().min(0).max(16).optional(),
|
||||
ruxaTaskInputMode: z
|
||||
.enum(["size-image", "resolution-based"])
|
||||
.optional(),
|
||||
@ -50,7 +45,6 @@ const imageGenConfigSchema = z.object({
|
||||
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
|
||||
enableLoadingPlaceholder: z.boolean().optional(),
|
||||
loadingColor: z.string().optional(),
|
||||
vip98k: z.record(z.string(), z.unknown()).optional(),
|
||||
}).optional();
|
||||
|
||||
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
} from "@/lib/db/queries";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
|
||||
import { VIP98K_BASE_URL } from "@/lib/ai/vip98k-models";
|
||||
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
|
||||
|
||||
const providerTypes = [
|
||||
@ -36,7 +35,6 @@ const providerTypes = [
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"fal-video",
|
||||
@ -81,12 +79,11 @@ const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
||||
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
|
||||
const VISIONARY_BASE_URL = "https://api.visionary.beer";
|
||||
const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
|
||||
const VIP98K_PROVIDER_BASE_URL = VIP98K_BASE_URL;
|
||||
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||
const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com";
|
||||
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
|
||||
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
|
||||
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
||||
const KREA_BASE_URL = "https://api.krea.ai";
|
||||
@ -252,8 +249,6 @@ export async function POST(request: Request) {
|
||||
? data.baseUrl || VISIONARY_BASE_URL
|
||||
: data.type === "atlascloud"
|
||||
? data.baseUrl || ATLASCLOUD_BASE_URL
|
||||
: data.type === "vip98k"
|
||||
? data.baseUrl || VIP98K_PROVIDER_BASE_URL
|
||||
: data.type === "stepfun-image"
|
||||
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
||||
: data.type === "midjourney"
|
||||
@ -316,12 +311,6 @@ export async function PUT(request: Request) {
|
||||
...data,
|
||||
apiKey: normalizedApiKey,
|
||||
type: normalizeProviderTypeForStorage(data.type),
|
||||
baseUrl:
|
||||
data.baseUrl !== undefined
|
||||
? data.baseUrl
|
||||
: data.type === "vip98k"
|
||||
? VIP98K_PROVIDER_BASE_URL
|
||||
: undefined,
|
||||
updatedBy: session.user.id,
|
||||
});
|
||||
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
fetchAtlasCloudModelPresets,
|
||||
resolveAtlasCloudDisplayName,
|
||||
shouldInsertMissingAtlasCloudPreset,
|
||||
} from "@/lib/ai/atlascloud-models";
|
||||
import { fetchAtlasCloudImagePresets } from "@/lib/ai/atlascloud-models";
|
||||
import { db } from "@/lib/db";
|
||||
import { getAiProviderById } from "@/lib/db/queries";
|
||||
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||
@ -16,7 +12,7 @@ export const maxDuration = 300;
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const result = await fetchAtlasCloudModelPresets();
|
||||
const result = await fetchAtlasCloudImagePresets();
|
||||
return NextResponse.json({
|
||||
presets: result.presets,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
@ -33,7 +29,7 @@ export async function GET() {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch Atlas Cloud models",
|
||||
: "Failed to fetch Atlas Cloud image models",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@ -62,50 +58,31 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const catalog = await fetchAtlasCloudModelPresets();
|
||||
const catalog = await fetchAtlasCloudImagePresets();
|
||||
const presets = requestedModelIds
|
||||
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||
: catalog.presets;
|
||||
const addedModels: AiModel[] = [];
|
||||
const updatedModels: AiModel[] = [];
|
||||
const errors = [...catalog.errors];
|
||||
const providerModels = await db.query.aiModel.findMany({
|
||||
where: eq(aiModel.providerId, providerId),
|
||||
});
|
||||
const existingByModelId = new Map(
|
||||
providerModels.map((model) => [model.modelId, model])
|
||||
);
|
||||
const existingMediaKinds = new Set(
|
||||
providerModels.map((model) => {
|
||||
const capabilities = model.capabilities as Record<string, unknown> | null;
|
||||
return String(capabilities?.mediaType || "image");
|
||||
})
|
||||
);
|
||||
let preservedDeletedCount = 0;
|
||||
|
||||
for (const preset of presets) {
|
||||
try {
|
||||
const existing = existingByModelId.get(preset.modelId);
|
||||
const presetCapabilities = preset.capabilities as Record<string, unknown>;
|
||||
const mediaKind = String(presetCapabilities.mediaType || "image");
|
||||
if (
|
||||
!existing &&
|
||||
!shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
|
||||
) {
|
||||
preservedDeletedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const values = {
|
||||
displayName: resolveAtlasCloudDisplayName(
|
||||
existing?.displayName,
|
||||
preset.displayName,
|
||||
preset.modelId
|
||||
const existing = await db.query.aiModel.findFirst({
|
||||
where: and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
),
|
||||
});
|
||||
const values = {
|
||||
displayName: preset.displayName,
|
||||
avatarUrl: preset.avatarUrl || null,
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: preset.supportsImageGen,
|
||||
sortOrder: existing?.sortOrder ?? preset.sortOrder,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
sortOrder: preset.sortOrder,
|
||||
capabilities: preset.capabilities,
|
||||
defaultParams: preset.defaultParams,
|
||||
imageGenConfig: preset.imageGenConfig,
|
||||
@ -131,8 +108,6 @@ export async function POST(request: Request) {
|
||||
providerId,
|
||||
modelId: preset.modelId,
|
||||
...values,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
isDefault: false,
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
@ -163,8 +138,6 @@ export async function POST(request: Request) {
|
||||
requested: presets.length,
|
||||
total: catalog.catalogCount,
|
||||
available: catalog.presets.length,
|
||||
skippedUnsupported: catalog.skipped.length,
|
||||
preservedDeleted: preservedDeletedCount,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -174,7 +147,7 @@ export async function POST(request: Request) {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to sync Atlas Cloud models",
|
||||
: "Failed to sync Atlas Cloud image models",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
@ -18,8 +18,6 @@ import {
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
|
||||
isDetailAgentLocalMoonshotBaseUrl,
|
||||
normalizeDetailAgentLocalMoonshotBaseUrl,
|
||||
} from "@/lib/ai/detail-agent-model-config";
|
||||
import {
|
||||
normalizeProductPlanningApiConfig,
|
||||
@ -53,10 +51,6 @@ import {
|
||||
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
|
||||
normalizeCanvasImageTaskQueueConfig,
|
||||
} from "@/lib/canvas/image-task-config";
|
||||
import {
|
||||
CANVAS_LOCAL_EDIT_CONFIG_KEY,
|
||||
normalizeCanvasLocalEditConfig,
|
||||
} from "@/lib/canvas/local-edit-config";
|
||||
import {
|
||||
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
|
||||
normalizeDingTalkRobotNotificationConfig,
|
||||
@ -101,7 +95,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> {
|
||||
@ -130,12 +124,6 @@ export async function GET(request: Request) {
|
||||
setting: setting ? { ...setting, value } : { key, value },
|
||||
});
|
||||
}
|
||||
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
|
||||
const value = normalizeCanvasLocalEditConfig(setting?.value);
|
||||
return Response.json({
|
||||
setting: setting ? { ...setting, value } : { key, value },
|
||||
});
|
||||
}
|
||||
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
||||
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
|
||||
return Response.json({
|
||||
@ -203,27 +191,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 +212,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({
|
||||
@ -379,10 +359,6 @@ export async function POST(request: Request) {
|
||||
nextValue = normalizeCanvasImageTaskQueueConfig(value);
|
||||
}
|
||||
|
||||
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
|
||||
nextValue = normalizeCanvasLocalEditConfig(value);
|
||||
}
|
||||
|
||||
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
|
||||
nextValue = normalizeDingTalkRobotNotificationConfig(value);
|
||||
}
|
||||
|
||||
@ -1,213 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
fetchVip98kModelPresets,
|
||||
type Vip98kModelPreset,
|
||||
} from "@/lib/ai/vip98k-models";
|
||||
import { db } from "@/lib/db";
|
||||
import { getAiProviderById } from "@/lib/db/queries";
|
||||
import { aiModel, type AiModel } from "@/lib/db/schema";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
function summarizeByType(presets: Vip98kModelPreset[]) {
|
||||
return presets.reduce<Record<string, number>>((acc, preset) => {
|
||||
const type = String(preset.type || "model");
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const providerId = String(searchParams.get("providerId") || "").trim();
|
||||
if (!providerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider ID is required to fetch VIP98K catalog" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const provider = await getAiProviderById({ id: providerId });
|
||||
if (!provider || provider.type !== "vip98k") {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider must be a VIP98K provider" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const presets = await fetchVip98kModelPresets({
|
||||
baseURL: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
return NextResponse.json({
|
||||
presets,
|
||||
counts: {
|
||||
total: presets.length,
|
||||
byType: summarizeByType(presets),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[VIP98K Models API] GET error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch VIP98K models",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const body = await request.json();
|
||||
const providerId = String(body?.providerId || "").trim();
|
||||
const requestedModelIds = Array.isArray(body?.modelIds)
|
||||
? new Set(
|
||||
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
||||
)
|
||||
: null;
|
||||
const preserveDeletedModels = body?.preserveDeletedModels !== false;
|
||||
if (!providerId) {
|
||||
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const provider = await getAiProviderById({ id: providerId });
|
||||
if (!provider || provider.type !== "vip98k") {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider must be a VIP98K provider" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const catalogPresets = await fetchVip98kModelPresets({
|
||||
baseURL: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
const existingModels = await db.query.aiModel.findMany({
|
||||
where: eq(aiModel.providerId, providerId),
|
||||
});
|
||||
const existingModelIds = new Set(existingModels.map((model) => model.modelId));
|
||||
const preserveExistingCatalog =
|
||||
requestedModelIds === null &&
|
||||
preserveDeletedModels &&
|
||||
existingModels.length > 0;
|
||||
const presets = requestedModelIds
|
||||
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||
: preserveExistingCatalog
|
||||
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||
: catalogPresets;
|
||||
|
||||
const addedModels: AiModel[] = [];
|
||||
const updatedModels: AiModel[] = [];
|
||||
const disabledModels: AiModel[] = [];
|
||||
const errors: Array<{ modelId: string; error: string }> = [];
|
||||
|
||||
if (requestedModelIds === null) {
|
||||
const catalogModelIds = new Set(catalogPresets.map((preset) => preset.modelId));
|
||||
for (const existing of existingModels) {
|
||||
if (catalogModelIds.has(existing.modelId)) continue;
|
||||
const [disabled] = await db
|
||||
.update(aiModel)
|
||||
.set({ isEnabled: false, isHidden: true, updatedAt: new Date() })
|
||||
.where(eq(aiModel.id, existing.id))
|
||||
.returning();
|
||||
if (disabled) disabledModels.push(disabled);
|
||||
}
|
||||
}
|
||||
|
||||
for (const preset of presets) {
|
||||
try {
|
||||
const existing = await db.query.aiModel.findFirst({
|
||||
where: and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId),
|
||||
),
|
||||
});
|
||||
const values = {
|
||||
displayName: preset.displayName,
|
||||
avatarUrl: null,
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: preset.supportsImageGen,
|
||||
isEnabled:
|
||||
requestedModelIds !== null ? true : (existing?.isEnabled ?? true),
|
||||
isHidden: preset.isHidden,
|
||||
sortOrder: preset.sortOrder,
|
||||
capabilities: preset.capabilities,
|
||||
defaultParams: preset.defaultParams,
|
||||
imageGenConfig: preset.imageGenConfig,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(aiModel)
|
||||
.set(values)
|
||||
.where(
|
||||
and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (updated) updatedModels.push(updated);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(aiModel)
|
||||
.values({
|
||||
providerId,
|
||||
modelId: preset.modelId,
|
||||
...values,
|
||||
isDefault: false,
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
})
|
||||
.returning();
|
||||
if (created) addedModels.push(created);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[VIP98K Models API] Failed to sync ${preset.modelId}:`, error);
|
||||
errors.push({
|
||||
modelId: preset.modelId,
|
||||
error: error instanceof Error ? error.message : "Sync failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
addedCount: addedModels.length,
|
||||
updatedCount: updatedModels.length,
|
||||
disabledCount: disabledModels.length,
|
||||
addedModels,
|
||||
updatedModels,
|
||||
disabledModelIds: disabledModels.map((model) => model.modelId),
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
counts: {
|
||||
requested: presets.length,
|
||||
total: catalogPresets.length,
|
||||
byType: summarizeByType(catalogPresets),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[VIP98K Models API] POST error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to sync VIP98K models",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
122
app/api/ai-cad/modeling/command/route.ts
Normal file
122
app/api/ai-cad/modeling/command/route.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
getKittyCadModelingCommandError,
|
||||
isKittyCadModelingCommandSuccessful,
|
||||
sendKittyCadModelingCommand,
|
||||
takeKittyCadModelingSnapshot,
|
||||
} from "@/lib/kittycad/modeling-session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type ModelingCommandRequest = {
|
||||
captureSnapshot?: boolean;
|
||||
cmd?: Record<string, unknown>;
|
||||
cmdId?: string;
|
||||
sessionId?: string;
|
||||
snapshotFormat?: "jpeg" | "png";
|
||||
};
|
||||
|
||||
function readOptionalRecord(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as ModelingCommandRequest;
|
||||
const sessionId = String(body.sessionId || "").trim();
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少官方建模会话 ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cmd = readOptionalRecord(body.cmd);
|
||||
const cmdType = String(cmd?.type || "").trim();
|
||||
if (!cmd || !cmdType) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少有效的官方建模命令" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cmdId = String(body.cmdId || "").trim() || randomUUID();
|
||||
const response = await sendKittyCadModelingCommand({
|
||||
cmd: {
|
||||
...cmd,
|
||||
type: cmdType,
|
||||
},
|
||||
cmdId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!isKittyCadModelingCommandSuccessful(response)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getKittyCadModelingCommandError(response),
|
||||
payload: response.errors || null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let snapshotUrl: string | null = null;
|
||||
let snapshotError: string | null = null;
|
||||
|
||||
const shouldCaptureSnapshot =
|
||||
body.captureSnapshot === true && cmdType !== "take_snapshot";
|
||||
|
||||
if (cmdType === "take_snapshot") {
|
||||
const contents = String(response.resp?.data?.contents || "").trim();
|
||||
const snapshotFormat = body.snapshotFormat || "png";
|
||||
snapshotUrl = contents
|
||||
? `data:${snapshotFormat === "jpeg" ? "image/jpeg" : "image/png"};base64,${contents}`
|
||||
: null;
|
||||
} else if (shouldCaptureSnapshot) {
|
||||
try {
|
||||
const snapshot = await takeKittyCadModelingSnapshot({
|
||||
format: body.snapshotFormat || "png",
|
||||
sessionId,
|
||||
});
|
||||
snapshotUrl = snapshot.snapshotUrl;
|
||||
} catch (error) {
|
||||
snapshotError =
|
||||
error instanceof Error ? error.message : "获取官方建模快照失败";
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
raw: response,
|
||||
requestId: response.request_id || cmdId,
|
||||
responseData: response.resp?.data || null,
|
||||
responseType: response.resp?.type || cmdType,
|
||||
snapshotError,
|
||||
snapshotUrl,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "发送官方建模命令失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
app/api/ai-cad/modeling/session/[sessionId]/route.ts
Normal file
48
app/api/ai-cad/modeling/session/[sessionId]/route.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { closeKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type RouteParams = {
|
||||
params: Promise<{
|
||||
sessionId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { sessionId } = await params;
|
||||
const normalizedSessionId = String(sessionId || "").trim();
|
||||
if (!normalizedSessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少 sessionId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await closeKittyCadModelingSession(normalizedSessionId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "关闭官方建模会话失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
33
app/api/ai-cad/modeling/session/route.ts
Normal file
33
app/api/ai-cad/modeling/session/route.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { createKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const modelingSession = await createKittyCadModelingSession();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: modelingSession,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "创建官方建模会话失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
app/api/ai-cad/provider/route.ts
Normal file
27
app/api/ai-cad/provider/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { findKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await findKittyCadProviderConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
configured: Boolean(config),
|
||||
source: config?.source || "none",
|
||||
providerName: config?.providerName || null,
|
||||
baseUrl: config?.baseUrl || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
467
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
467
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
@ -0,0 +1,467 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
||||
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type KittyCadTaskResponse = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
outputs?: Record<string, string> | null;
|
||||
error?: string | null;
|
||||
code?: string | null;
|
||||
prompt?: string | null;
|
||||
conversation_id?: string | null;
|
||||
model_version?: string | null;
|
||||
output_format?: string | null;
|
||||
};
|
||||
|
||||
type KittyCadAsyncOperationResponse = KittyCadTaskResponse & {
|
||||
type?: string | null;
|
||||
};
|
||||
|
||||
type ArchivedFile = {
|
||||
sourceHash: string;
|
||||
archivedUrl: string;
|
||||
};
|
||||
|
||||
type RouteParams = {
|
||||
params: Promise<{ taskId: string }>;
|
||||
};
|
||||
|
||||
function getArchiveCache() {
|
||||
const globalState = globalThis as Record<string, unknown>;
|
||||
if (!globalState.__kittyCadAssetArchiveCache) {
|
||||
globalState.__kittyCadAssetArchiveCache = new Map<string, ArchivedFile>();
|
||||
}
|
||||
return globalState.__kittyCadAssetArchiveCache as Map<string, ArchivedFile>;
|
||||
}
|
||||
|
||||
function getArchiveInflight() {
|
||||
const globalState = globalThis as Record<string, unknown>;
|
||||
if (!globalState.__kittyCadAssetArchiveInflight) {
|
||||
globalState.__kittyCadAssetArchiveInflight = new Map<
|
||||
string,
|
||||
Promise<string | null>
|
||||
>();
|
||||
}
|
||||
return globalState.__kittyCadAssetArchiveInflight as Map<
|
||||
string,
|
||||
Promise<string | null>
|
||||
>;
|
||||
}
|
||||
|
||||
function mapKittyCadStatus(status: unknown) {
|
||||
const normalized = String(status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) {
|
||||
return "queued" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "queued" ||
|
||||
normalized === "pending" ||
|
||||
normalized === "created" ||
|
||||
normalized === "submitted"
|
||||
) {
|
||||
return "queued" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "running" ||
|
||||
normalized === "processing" ||
|
||||
normalized === "in_progress" ||
|
||||
normalized === "working" ||
|
||||
normalized === "generating"
|
||||
) {
|
||||
return "running" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "completed" ||
|
||||
normalized === "success" ||
|
||||
normalized === "succeeded" ||
|
||||
normalized === "done" ||
|
||||
normalized === "finished"
|
||||
) {
|
||||
return "success" as const;
|
||||
}
|
||||
if (normalized === "cancelled" || normalized === "canceled") {
|
||||
return "cancelled" as const;
|
||||
}
|
||||
if (normalized === "expired" || normalized === "timed_out") {
|
||||
return "expired" as const;
|
||||
}
|
||||
if (normalized === "banned" || normalized === "blocked") {
|
||||
return "banned" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "failed" ||
|
||||
normalized === "error" ||
|
||||
normalized === "errored"
|
||||
) {
|
||||
return "failed" as const;
|
||||
}
|
||||
return "unknown" as const;
|
||||
}
|
||||
|
||||
function getFileExtension(filename: string) {
|
||||
const normalized = String(filename || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
||||
const segments = cleanName.split(".");
|
||||
return segments.length > 1 ? segments.at(-1) || "" : "";
|
||||
}
|
||||
|
||||
function isImageExtension(extension: string) {
|
||||
return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension);
|
||||
}
|
||||
|
||||
function pickPreferredModelFile(files: Array<{ name: string; url: string }>) {
|
||||
const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"];
|
||||
for (const extension of preferredOrder) {
|
||||
const matched = files.find(
|
||||
(item) => getFileExtension(item.name) === extension
|
||||
);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
|
||||
return (
|
||||
files.find((item) => isImageExtension(getFileExtension(item.name))) || null
|
||||
);
|
||||
}
|
||||
|
||||
function extractOutputEntries(task: KittyCadTaskResponse) {
|
||||
return Object.entries(task.outputs || {}).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === "string" &&
|
||||
entry[0].trim().length > 0 &&
|
||||
typeof entry[1] === "string" &&
|
||||
entry[1].trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
async function enrichTextToCadOutputs(params: {
|
||||
config: Awaited<ReturnType<typeof getKittyCadProviderConfig>>;
|
||||
task: KittyCadTaskResponse;
|
||||
taskId: string;
|
||||
}) {
|
||||
if (extractOutputEntries(params.task).length > 0) {
|
||||
return params.task;
|
||||
}
|
||||
|
||||
try {
|
||||
const asyncOperation = await kittyCadRequest<KittyCadAsyncOperationResponse>(
|
||||
params.config,
|
||||
{
|
||||
method: "GET",
|
||||
path: `/async/operations/${encodeURIComponent(params.taskId)}`,
|
||||
}
|
||||
);
|
||||
|
||||
if (extractOutputEntries(asyncOperation).length === 0) {
|
||||
return params.task;
|
||||
}
|
||||
|
||||
return {
|
||||
...params.task,
|
||||
...asyncOperation,
|
||||
code: asyncOperation.code || params.task.code || null,
|
||||
conversation_id:
|
||||
asyncOperation.conversation_id || params.task.conversation_id || null,
|
||||
model_version:
|
||||
asyncOperation.model_version || params.task.model_version || null,
|
||||
output_format:
|
||||
asyncOperation.output_format || params.task.output_format || null,
|
||||
outputs: asyncOperation.outputs,
|
||||
prompt: asyncOperation.prompt || params.task.prompt || null,
|
||||
} satisfies KittyCadTaskResponse;
|
||||
} catch (error) {
|
||||
console.warn("[KittyCADStatus] async output fallback failed", {
|
||||
taskId: params.taskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return params.task;
|
||||
}
|
||||
}
|
||||
|
||||
function createClientSafeTaskOutput(task: KittyCadTaskResponse) {
|
||||
const outputEntries = extractOutputEntries(task);
|
||||
const outputSummary = Object.fromEntries(
|
||||
outputEntries.map(([name, base64]) => [
|
||||
name,
|
||||
{
|
||||
base64Length: base64.length,
|
||||
extension: getFileExtension(name) || null,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
...task,
|
||||
outputs: outputSummary,
|
||||
};
|
||||
}
|
||||
|
||||
async function archiveOutputIfNeeded(params: {
|
||||
taskId: string;
|
||||
filename: string;
|
||||
base64: string;
|
||||
}) {
|
||||
const normalizedFilename = String(params.filename || "").trim();
|
||||
const normalizedBase64 = String(params.base64 || "").trim();
|
||||
if (!normalizedFilename || !normalizedBase64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
||||
const cache = getArchiveCache();
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && cached.sourceHash === normalizedBase64) {
|
||||
return cached.archivedUrl;
|
||||
}
|
||||
|
||||
const inflight = getArchiveInflight();
|
||||
const existingTask = inflight.get(cacheKey);
|
||||
if (existingTask) {
|
||||
return (await existingTask) || null;
|
||||
}
|
||||
|
||||
const archiveTask = (async () => {
|
||||
try {
|
||||
const binary = Buffer.from(normalizedBase64, "base64");
|
||||
if (binary.byteLength <= 0) {
|
||||
throw new Error("KittyCAD 输出文件为空");
|
||||
}
|
||||
const arrayBuffer = Uint8Array.from(binary).buffer;
|
||||
|
||||
const safeFilename =
|
||||
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin";
|
||||
const uploaded = await uploadFile(
|
||||
`kittycad-${params.taskId}-${safeFilename}`,
|
||||
arrayBuffer
|
||||
);
|
||||
cache.set(cacheKey, {
|
||||
sourceHash: normalizedBase64,
|
||||
archivedUrl: uploaded.url,
|
||||
});
|
||||
return uploaded.url;
|
||||
} catch (error) {
|
||||
console.error("[KittyCADArchive] failed", {
|
||||
taskId: params.taskId,
|
||||
filename: normalizedFilename,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(cacheKey, archiveTask);
|
||||
return await archiveTask;
|
||||
}
|
||||
|
||||
async function archiveTextOutputIfNeeded(params: {
|
||||
taskId: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
}) {
|
||||
const normalizedFilename = String(params.filename || "").trim();
|
||||
const normalizedContent = String(params.content || "");
|
||||
if (!normalizedFilename || !normalizedContent.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
||||
const cache = getArchiveCache();
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && cached.sourceHash === normalizedContent) {
|
||||
return cached.archivedUrl;
|
||||
}
|
||||
|
||||
const inflight = getArchiveInflight();
|
||||
const existingTask = inflight.get(cacheKey);
|
||||
if (existingTask) {
|
||||
return (await existingTask) || null;
|
||||
}
|
||||
|
||||
const archiveTask = (async () => {
|
||||
try {
|
||||
const arrayBuffer = Uint8Array.from(
|
||||
new TextEncoder().encode(normalizedContent)
|
||||
).buffer;
|
||||
const safeFilename =
|
||||
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl";
|
||||
const uploaded = await uploadFile(
|
||||
`kittycad-${params.taskId}-${safeFilename}`,
|
||||
arrayBuffer
|
||||
);
|
||||
cache.set(cacheKey, {
|
||||
sourceHash: normalizedContent,
|
||||
archivedUrl: uploaded.url,
|
||||
});
|
||||
return uploaded.url;
|
||||
} catch (error) {
|
||||
console.error("[KittyCADArchive:text] failed", {
|
||||
taskId: params.taskId,
|
||||
filename: normalizedFilename,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(cacheKey, archiveTask);
|
||||
return await archiveTask;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { taskId } = await params;
|
||||
const normalizedTaskId = String(taskId || "").trim();
|
||||
if (!normalizedTaskId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少 taskId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getKittyCadProviderConfig();
|
||||
let result = await kittyCadRequest<KittyCadTaskResponse>(config, {
|
||||
method: "GET",
|
||||
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
|
||||
});
|
||||
|
||||
const status = mapKittyCadStatus(result.status);
|
||||
if (status !== "success") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: normalizedTaskId,
|
||||
type: "cad",
|
||||
status,
|
||||
progress: status === "running" ? 65 : null,
|
||||
errorMessage: result.error || null,
|
||||
conversationId: result.conversation_id || null,
|
||||
modelVersion: result.model_version || null,
|
||||
outputFormat: result.output_format || null,
|
||||
output: createClientSafeTaskOutput(result),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
result = await enrichTextToCadOutputs({
|
||||
config,
|
||||
task: result,
|
||||
taskId: normalizedTaskId,
|
||||
});
|
||||
|
||||
const outputEntries = extractOutputEntries(result);
|
||||
|
||||
const archivedEntries = await Promise.all(
|
||||
outputEntries.map(async ([name, base64]) => {
|
||||
const archivedUrl = await archiveOutputIfNeeded({
|
||||
taskId: normalizedTaskId,
|
||||
filename: name,
|
||||
base64,
|
||||
});
|
||||
return archivedUrl ? { name, url: archivedUrl } : null;
|
||||
})
|
||||
);
|
||||
|
||||
const archivedFiles = archivedEntries.filter(
|
||||
(item): item is { name: string; url: string } =>
|
||||
Boolean(item?.name && item?.url)
|
||||
);
|
||||
|
||||
const hasArchivedKclFile = archivedFiles.some(
|
||||
(item) => getFileExtension(item.name) === "kcl"
|
||||
);
|
||||
if (
|
||||
!hasArchivedKclFile &&
|
||||
typeof result.code === "string" &&
|
||||
result.code.trim()
|
||||
) {
|
||||
const archivedCodeUrl = await archiveTextOutputIfNeeded({
|
||||
taskId: normalizedTaskId,
|
||||
filename: "main.kcl",
|
||||
content: result.code,
|
||||
});
|
||||
if (archivedCodeUrl) {
|
||||
archivedFiles.push({
|
||||
name: "main.kcl",
|
||||
url: archivedCodeUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const modelFile = pickPreferredModelFile(archivedFiles);
|
||||
const previewImageFile = pickPreviewImageFile(archivedFiles);
|
||||
const downloadUrls = Object.fromEntries(
|
||||
archivedFiles.map((item) => [item.name, item.url])
|
||||
);
|
||||
const codeFile =
|
||||
archivedFiles.find((item) => getFileExtension(item.name) === "kcl") ||
|
||||
null;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: normalizedTaskId,
|
||||
type: "cad",
|
||||
status: "success",
|
||||
progress: 100,
|
||||
modelUrl:
|
||||
modelFile &&
|
||||
["glb", "gltf"].includes(getFileExtension(modelFile.name))
|
||||
? modelFile.url
|
||||
: null,
|
||||
previewImageUrl: previewImageFile?.url || null,
|
||||
errorMessage: result.error || null,
|
||||
conversationId: result.conversation_id || null,
|
||||
modelVersion: result.model_version || null,
|
||||
outputFormat: result.output_format || null,
|
||||
codeUrl: codeFile?.url || null,
|
||||
downloadUrls,
|
||||
output: createClientSafeTaskOutput(result),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof KittyCadApiError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
payload: error.payload,
|
||||
},
|
||||
{ status: error.httpStatus || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
331
app/api/ai-cad/tasks/route.ts
Normal file
331
app/api/ai-cad/tasks/route.ts
Normal file
@ -0,0 +1,331 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
consumeUserPoints,
|
||||
getPointsSystemConfig,
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
||||
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type CreateAiCadTaskRequest = {
|
||||
prompt?: string;
|
||||
outputFormat?: string;
|
||||
projectName?: string;
|
||||
iterationSourceTaskId?: string;
|
||||
sourceCode?: string;
|
||||
sourceRanges?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
type KittyCadCreateResponse = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
conversation_id?: string | null;
|
||||
model_version?: string | null;
|
||||
output_format?: string | null;
|
||||
};
|
||||
|
||||
type KittyCadSourceTaskResponse = {
|
||||
code?: string | null;
|
||||
outputs?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
type KittyCadIterationResponse = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
conversation_id?: string | null;
|
||||
model_version?: string | null;
|
||||
output_format?: string | null;
|
||||
};
|
||||
|
||||
function getFileExtension(filename: string) {
|
||||
const normalized = String(filename || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
||||
const segments = cleanName.split(".");
|
||||
return segments.length > 1 ? segments.at(-1) || "" : "";
|
||||
}
|
||||
|
||||
function decodeBase64Text(value: unknown) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(normalized, "base64").toString("utf8").trim();
|
||||
return decoded || null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractSourceCodeFromTask(task: KittyCadSourceTaskResponse) {
|
||||
const directCode = String(task.code || "").trim();
|
||||
if (directCode) {
|
||||
return directCode;
|
||||
}
|
||||
|
||||
const kclOutputEntry = Object.entries(task.outputs || {}).find(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === "string" &&
|
||||
entry[0].trim().length > 0 &&
|
||||
getFileExtension(entry[0]) === "kcl" &&
|
||||
typeof entry[1] === "string" &&
|
||||
entry[1].trim().length > 0
|
||||
);
|
||||
|
||||
if (!kclOutputEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeBase64Text(kclOutputEntry[1]);
|
||||
}
|
||||
|
||||
function mapKittyCadStatus(status: unknown) {
|
||||
const normalized = String(status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) {
|
||||
return "queued" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "queued" ||
|
||||
normalized === "pending" ||
|
||||
normalized === "created" ||
|
||||
normalized === "submitted"
|
||||
) {
|
||||
return "queued" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "running" ||
|
||||
normalized === "processing" ||
|
||||
normalized === "in_progress" ||
|
||||
normalized === "working" ||
|
||||
normalized === "generating"
|
||||
) {
|
||||
return "running" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "completed" ||
|
||||
normalized === "success" ||
|
||||
normalized === "succeeded" ||
|
||||
normalized === "done" ||
|
||||
normalized === "finished"
|
||||
) {
|
||||
return "success" as const;
|
||||
}
|
||||
if (normalized === "cancelled" || normalized === "canceled") {
|
||||
return "cancelled" as const;
|
||||
}
|
||||
if (normalized === "expired" || normalized === "timed_out") {
|
||||
return "expired" as const;
|
||||
}
|
||||
if (normalized === "banned" || normalized === "blocked") {
|
||||
return "banned" as const;
|
||||
}
|
||||
if (
|
||||
normalized === "failed" ||
|
||||
normalized === "error" ||
|
||||
normalized === "errored"
|
||||
) {
|
||||
return "failed" as const;
|
||||
}
|
||||
return "unknown" as const;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as CreateAiCadTaskRequest;
|
||||
const prompt = String(body.prompt || "").trim();
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
|
||||
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
|
||||
: 0;
|
||||
|
||||
if (
|
||||
pointsConfig.enabled &&
|
||||
unitCost > 0 &&
|
||||
pointsConfig.blockOnInsufficient !== false
|
||||
) {
|
||||
const userData = await getUserById({ id: session.user.id });
|
||||
if (!userData?.isHighVip) {
|
||||
const currentBalance = Math.max(
|
||||
0,
|
||||
Number(userData?.pointsBalance || 0)
|
||||
);
|
||||
if (currentBalance < unitCost) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "积分不足",
|
||||
code: "insufficient_points",
|
||||
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
|
||||
balance: currentBalance,
|
||||
expectedCost: unitCost,
|
||||
unitCost,
|
||||
},
|
||||
{ status: 402 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outputFormat =
|
||||
String(body.outputFormat || "glb")
|
||||
.trim()
|
||||
.toLowerCase() || "glb";
|
||||
const normalizedProjectName = String(body.projectName || "").trim();
|
||||
const iterationSourceTaskId = String(
|
||||
body.iterationSourceTaskId || ""
|
||||
).trim();
|
||||
const sourceCode = String(body.sourceCode || "");
|
||||
const normalizedSourceCode = sourceCode.trim();
|
||||
const sourceRanges = Array.isArray(body.sourceRanges)
|
||||
? body.sourceRanges.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
Boolean(item) && typeof item === "object" && !Array.isArray(item)
|
||||
)
|
||||
: [];
|
||||
const config = await getKittyCadProviderConfig();
|
||||
const generationMode =
|
||||
iterationSourceTaskId || normalizedSourceCode ? "iterate" : "create";
|
||||
|
||||
if (generationMode === "create" && !prompt) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请输入 CAD 描述" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const iterationPrompt =
|
||||
prompt || "Apply the edited KCL source and rebuild the CAD model.";
|
||||
const result =
|
||||
generationMode === "iterate"
|
||||
? await (async () => {
|
||||
const originalSourceCode =
|
||||
normalizedSourceCode ||
|
||||
(await (async () => {
|
||||
if (!iterationSourceTaskId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceTask =
|
||||
await kittyCadRequest<KittyCadSourceTaskResponse>(config, {
|
||||
method: "GET",
|
||||
path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`,
|
||||
});
|
||||
return extractSourceCodeFromTask(sourceTask);
|
||||
})());
|
||||
|
||||
if (!originalSourceCode) {
|
||||
throw new KittyCadApiError({
|
||||
message:
|
||||
"当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。",
|
||||
httpStatus: 409,
|
||||
});
|
||||
}
|
||||
|
||||
return kittyCadRequest<KittyCadIterationResponse>(config, {
|
||||
method: "POST",
|
||||
path: "/ml/text-to-cad/iteration",
|
||||
json: {
|
||||
original_source_code: originalSourceCode,
|
||||
prompt: iterationPrompt,
|
||||
source_ranges: sourceRanges,
|
||||
...(normalizedProjectName
|
||||
? { project_name: normalizedProjectName }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
})()
|
||||
: await kittyCadRequest<KittyCadCreateResponse>(config, {
|
||||
method: "POST",
|
||||
path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`,
|
||||
json: {
|
||||
prompt,
|
||||
...(normalizedProjectName
|
||||
? { project_name: normalizedProjectName }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
const taskId = String(result.id || "").trim();
|
||||
if (!taskId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "KittyCAD 返回缺少任务 ID",
|
||||
raw: result,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pointsConfig.enabled && unitCost > 0) {
|
||||
try {
|
||||
await consumeUserPoints({
|
||||
userId: session.user.id,
|
||||
amount: unitCost,
|
||||
reason: "model3d_generation",
|
||||
modelId: "kittycad-cad",
|
||||
note:
|
||||
generationMode === "iterate"
|
||||
? "AI-CAD 连续迭代消耗积分"
|
||||
: "AI-CAD 生成消耗积分",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Points] Failed to consume AI-CAD points:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId,
|
||||
status: mapKittyCadStatus(result.status),
|
||||
conversationId: result.conversation_id || null,
|
||||
modelVersion: result.model_version || null,
|
||||
outputFormat:
|
||||
generationMode === "iterate"
|
||||
? result.output_format || null
|
||||
: result.output_format || outputFormat,
|
||||
generationMode,
|
||||
sourceTaskId: iterationSourceTaskId || null,
|
||||
raw: result,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof KittyCadApiError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
payload: error.payload,
|
||||
},
|
||||
{ status: error.httpStatus || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "创建 AI-CAD 任务失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
195
app/api/ai-cad/workspace-utils.ts
Normal file
195
app/api/ai-cad/workspace-utils.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import type { AiCadWorkspace } from "@/lib/db/schema";
|
||||
|
||||
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
|
||||
|
||||
const FINAL_TASK_STATUS = new Set([
|
||||
"success",
|
||||
"failed",
|
||||
"banned",
|
||||
"expired",
|
||||
"cancelled",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
const DEFAULT_VIEWER_SETTINGS = {
|
||||
cameraPreset: "iso",
|
||||
envPreset: "studio",
|
||||
lightIntensity: 1.1,
|
||||
autoRotate: true,
|
||||
showGrid: true,
|
||||
wireframe: false,
|
||||
} as const;
|
||||
|
||||
type WorkspaceVersionLike = {
|
||||
status?: unknown;
|
||||
prompt?: unknown;
|
||||
};
|
||||
|
||||
export type UpdateAiCadWorkspaceRequest = {
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
generationProvider?: string;
|
||||
modelVersion?: string;
|
||||
viewerSettings?: unknown;
|
||||
messages?: unknown;
|
||||
versions?: unknown;
|
||||
activeVersionId?: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is WorkspaceVersionLike => isRecord(item));
|
||||
}
|
||||
|
||||
export function normalizeViewerSettings(
|
||||
value: unknown
|
||||
): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
return { ...DEFAULT_VIEWER_SETTINGS };
|
||||
}
|
||||
|
||||
return {
|
||||
cameraPreset:
|
||||
value.cameraPreset === "top" ||
|
||||
value.cameraPreset === "front" ||
|
||||
value.cameraPreset === "right" ||
|
||||
value.cameraPreset === "iso"
|
||||
? value.cameraPreset
|
||||
: DEFAULT_VIEWER_SETTINGS.cameraPreset,
|
||||
envPreset:
|
||||
typeof value.envPreset === "string" && value.envPreset.trim()
|
||||
? value.envPreset
|
||||
: DEFAULT_VIEWER_SETTINGS.envPreset,
|
||||
lightIntensity:
|
||||
typeof value.lightIntensity === "number" &&
|
||||
Number.isFinite(value.lightIntensity)
|
||||
? value.lightIntensity
|
||||
: DEFAULT_VIEWER_SETTINGS.lightIntensity,
|
||||
autoRotate:
|
||||
typeof value.autoRotate === "boolean"
|
||||
? value.autoRotate
|
||||
: DEFAULT_VIEWER_SETTINGS.autoRotate,
|
||||
showGrid:
|
||||
typeof value.showGrid === "boolean"
|
||||
? value.showGrid
|
||||
: DEFAULT_VIEWER_SETTINGS.showGrid,
|
||||
wireframe:
|
||||
typeof value.wireframe === "boolean"
|
||||
? value.wireframe
|
||||
: DEFAULT_VIEWER_SETTINGS.wireframe,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeJsonRecordArray(
|
||||
value: unknown
|
||||
): Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is Record<string, unknown> =>
|
||||
isRecord(item)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceName(
|
||||
value: unknown,
|
||||
fallbackName = "AI-CAD 工作台"
|
||||
) {
|
||||
return typeof value === "string" && value.trim()
|
||||
? value.trim()
|
||||
: fallbackName;
|
||||
}
|
||||
|
||||
export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): {
|
||||
name?: string;
|
||||
prompt: string;
|
||||
generationProvider: CadGenerationProvider;
|
||||
modelVersion: string;
|
||||
viewerSettings: Record<string, unknown>;
|
||||
messages: Record<string, unknown>[];
|
||||
versions: Record<string, unknown>[];
|
||||
activeVersionId: string | null;
|
||||
} {
|
||||
return {
|
||||
...(typeof body.name === "string" && body.name.trim()
|
||||
? { name: body.name.trim() }
|
||||
: {}),
|
||||
prompt: String(body.prompt || ""),
|
||||
generationProvider:
|
||||
body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad",
|
||||
modelVersion:
|
||||
typeof body.modelVersion === "string" && body.modelVersion.trim()
|
||||
? body.modelVersion
|
||||
: "Turbo-v1.0-20250506",
|
||||
viewerSettings: normalizeViewerSettings(body.viewerSettings),
|
||||
messages: normalizeJsonRecordArray(body.messages),
|
||||
versions: normalizeJsonRecordArray(body.versions),
|
||||
activeVersionId:
|
||||
typeof body.activeVersionId === "string" && body.activeVersionId.trim()
|
||||
? body.activeVersionId
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultWorkspaceName(index: number) {
|
||||
return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台";
|
||||
}
|
||||
|
||||
export function createWorkspaceSummary(workspace: AiCadWorkspace) {
|
||||
const versions = getWorkspaceVersions(workspace.versions);
|
||||
const versionCount = versions.length;
|
||||
const completedCount = versions.filter(
|
||||
(version) => version.status === "success"
|
||||
).length;
|
||||
const hasPendingTasks = versions.some(
|
||||
(version) =>
|
||||
typeof version.status !== "string" ||
|
||||
!FINAL_TASK_STATUS.has(version.status)
|
||||
);
|
||||
const latestPrompt =
|
||||
typeof workspace.prompt === "string" && workspace.prompt.trim()
|
||||
? workspace.prompt.trim()
|
||||
: versions.find(
|
||||
(version) =>
|
||||
typeof version.prompt === "string" && version.prompt.trim()
|
||||
)?.prompt || null;
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
generationProvider: workspace.generationProvider,
|
||||
versionCount,
|
||||
completedCount,
|
||||
hasPendingTasks,
|
||||
latestPrompt:
|
||||
typeof latestPrompt === "string" && latestPrompt.trim()
|
||||
? latestPrompt.trim()
|
||||
: null,
|
||||
createdAt: workspace.createdAt?.toISOString() || null,
|
||||
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkspaceDocument(workspace: AiCadWorkspace) {
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
prompt: workspace.prompt,
|
||||
generationProvider: workspace.generationProvider,
|
||||
modelVersion: workspace.modelVersion,
|
||||
viewerSettings: workspace.viewerSettings,
|
||||
messages: workspace.messages,
|
||||
versions: workspace.versions,
|
||||
activeVersionId: workspace.activeVersionId,
|
||||
createdAt: workspace.createdAt?.toISOString() || null,
|
||||
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||
};
|
||||
}
|
||||
89
app/api/ai-cad/workspace/route.ts
Normal file
89
app/api/ai-cad/workspace/route.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createWorkspaceDocument,
|
||||
normalizeWorkspacePayload,
|
||||
type UpdateAiCadWorkspaceRequest,
|
||||
} from "@/app/api/ai-cad/workspace-utils";
|
||||
import {
|
||||
createAiCadWorkspace,
|
||||
listAiCadWorkspacesByUserId,
|
||||
updateAiCadWorkspace,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const workspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
const workspace = workspaces[0] ?? null;
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createWorkspaceDocument(workspace),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
||||
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
const targetWorkspace =
|
||||
existingWorkspaces[0] ??
|
||||
(await createAiCadWorkspace({
|
||||
userId: session.user.id,
|
||||
name: "AI-CAD 工作台",
|
||||
}));
|
||||
|
||||
if (!targetWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "初始化 AI-CAD 工作台失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const savedWorkspace = await updateAiCadWorkspace({
|
||||
workspaceId: targetWorkspace.id,
|
||||
userId: session.user.id,
|
||||
...normalizeWorkspacePayload(body),
|
||||
});
|
||||
|
||||
if (!savedWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "保存 AI-CAD 工作台失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
114
app/api/ai-cad/workspaces/[workspaceId]/route.ts
Normal file
114
app/api/ai-cad/workspaces/[workspaceId]/route.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createWorkspaceDocument,
|
||||
createWorkspaceSummary,
|
||||
normalizeWorkspacePayload,
|
||||
type UpdateAiCadWorkspaceRequest,
|
||||
} from "@/app/api/ai-cad/workspace-utils";
|
||||
import {
|
||||
deleteAiCadWorkspaceById,
|
||||
getAiCadWorkspaceById,
|
||||
updateAiCadWorkspace,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
workspaceId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId } = await context.params;
|
||||
const workspace = await getAiCadWorkspaceById({
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createWorkspaceDocument(workspace),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId } = await context.params;
|
||||
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
||||
const savedWorkspace = await updateAiCadWorkspace({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
...normalizeWorkspacePayload(body),
|
||||
});
|
||||
|
||||
if (!savedWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "AI-CAD 工作台不存在或保存失败" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: savedWorkspace.id,
|
||||
name: savedWorkspace.name,
|
||||
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||
summary: createWorkspaceSummary(savedWorkspace),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId } = await context.params;
|
||||
const deletedWorkspace = await deleteAiCadWorkspaceById({
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
if (!deletedWorkspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: deletedWorkspace.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
69
app/api/ai-cad/workspaces/route.ts
Normal file
69
app/api/ai-cad/workspaces/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
buildDefaultWorkspaceName,
|
||||
createWorkspaceSummary,
|
||||
normalizeWorkspaceName,
|
||||
} from "@/app/api/ai-cad/workspace-utils";
|
||||
import {
|
||||
createAiCadWorkspace,
|
||||
listAiCadWorkspacesByUserId,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const workspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: workspaces.map(createWorkspaceSummary),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { name?: unknown };
|
||||
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
const workspace = await createAiCadWorkspace({
|
||||
userId: session.user.id,
|
||||
name: normalizeWorkspaceName(
|
||||
body.name,
|
||||
buildDefaultWorkspaceName(existingWorkspaces.length + 1)
|
||||
),
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "创建 AI-CAD 工作台失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data: createWorkspaceSummary(workspace),
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
@ -13,7 +13,6 @@ import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelEntries,
|
||||
} from "@/lib/style-pro/model-options";
|
||||
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||||
|
||||
const requestSchema = z.object({
|
||||
prompt: z.string().min(1).max(5000),
|
||||
@ -53,16 +52,6 @@ export async function POST(request: Request) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const availability = getAiModelFrontendAvailability(selectedModel.model);
|
||||
if (availability.selectable === false) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: availability.disabledReason || "所选 AI洗图 生图模型当前不可用",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const imageSize = body.imageSize?.trim() || "2K";
|
||||
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
|
||||
|
||||
@ -7,9 +7,9 @@ import {
|
||||
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
|
||||
CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||
CANVAS_SKILL_TAGS_SETTING_KEY,
|
||||
type CanvasSkillTag,
|
||||
normalizeCanvasSkillTagSettings,
|
||||
} from "@/lib/canvas-skill-tags";
|
||||
import { applyCanvasSkillTagOrder } from "@/lib/canvas-skill-tag-order";
|
||||
|
||||
function getUserSkillTagOrderKey(userId: string) {
|
||||
return `${CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX}${userId}`;
|
||||
@ -32,6 +32,18 @@ function normalizeSkillTagOrder(value: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function applySkillTagOrder(tags: CanvasSkillTag[], order: string[]) {
|
||||
if (tags.length < 2 || order.length === 0) return tags;
|
||||
|
||||
const tagById = new Map(tags.map((tag) => [tag.id, tag]));
|
||||
const ordered = order
|
||||
.map((id) => tagById.get(id))
|
||||
.filter((tag): tag is CanvasSkillTag => Boolean(tag));
|
||||
const orderedIds = new Set(ordered.map((tag) => tag.id));
|
||||
const remaining = tags.filter((tag) => !orderedIds.has(tag.id));
|
||||
return [...ordered, ...remaining];
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth().catch(() => null);
|
||||
@ -59,7 +71,7 @@ export async function GET() {
|
||||
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
|
||||
return NextResponse.json({
|
||||
...settings,
|
||||
tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
|
||||
tags: applySkillTagOrder(settings.tags, userOrder),
|
||||
userOrder,
|
||||
});
|
||||
} catch (error) {
|
||||
@ -121,7 +133,7 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
userOrder: tagIds,
|
||||
tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
|
||||
tags: applySkillTagOrder(settings.tags, tagIds),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CanvasSkillTags API] Save order error:", error);
|
||||
@ -140,12 +152,10 @@ export async function PUT(request: NextRequest) {
|
||||
const normalized = normalizeCanvasSkillTagSettings(body);
|
||||
const enabled = normalized.enabled;
|
||||
const title = normalized.title;
|
||||
const categories = normalized.categories;
|
||||
const tags = normalized.tags;
|
||||
const nextValue = {
|
||||
enabled,
|
||||
title,
|
||||
categories,
|
||||
tags,
|
||||
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
|
||||
};
|
||||
@ -173,7 +183,7 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, enabled, title, categories, tags });
|
||||
return NextResponse.json({ success: true, enabled, title, tags });
|
||||
} catch (error) {
|
||||
console.error("[CanvasSkillTags API] Update error:", error);
|
||||
return NextResponse.json({ error: "Failed to update" }, { status: 500 });
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
||||
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
||||
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
||||
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
||||
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
||||
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 600;
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const { id } = await params;
|
||||
const requestId = new URL(request.url).searchParams.get("requestId");
|
||||
if (!requestId) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
capabilities: getCanvasCapabilityManifest(),
|
||||
});
|
||||
}
|
||||
const task = await getCanvasCapabilityTask({
|
||||
requestId,
|
||||
userId: session.user.id,
|
||||
projectId: id,
|
||||
});
|
||||
if (!task) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "能力任务不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, task });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
||||
const result = await executeCanvasCapabilityRuntime({
|
||||
command,
|
||||
projectId: id,
|
||||
requestHeaders: request.headers,
|
||||
requestUrl: request.url,
|
||||
userId: session.user.id,
|
||||
});
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof CanvasCapabilityRuntimeError ||
|
||||
error instanceof CanvasAgentMutationError
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message, code: error.code },
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "画布能力参数无效",
|
||||
issues: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
|
||||
const canvasIdSchema = z.string().uuid();
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return Response.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: rawId } = await params;
|
||||
const parsedId = canvasIdSchema.safeParse(rawId);
|
||||
if (!parsedId.success) {
|
||||
return Response.json(
|
||||
{ success: false, error: "画布 ID 无效" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const id = parsedId.data;
|
||||
const [project] = await db
|
||||
.select({ updatedAt: canvasProject.updatedAt })
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!project) {
|
||||
return Response.json(
|
||||
{ success: false, error: "画布不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ success: true, data: { updatedAt: project.updatedAt.toISOString() } },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
@ -17,67 +17,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 +57,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 =
|
||||
@ -192,53 +137,23 @@ export async function PUT(
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const nextWorkspaceType = workspaceType || "canvas";
|
||||
const nextCanvasData = syncedCanvasData || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
};
|
||||
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
|
||||
const insertData: any = {
|
||||
id, // 使用指定的 ID
|
||||
userId: session.user.id,
|
||||
title: nextTitle,
|
||||
workspaceType: nextWorkspaceType,
|
||||
canvasData: nextCanvasData,
|
||||
};
|
||||
const conflictUpdateData: any = {
|
||||
title: nextTitle,
|
||||
workspaceType: nextWorkspaceType,
|
||||
canvasData: nextCanvasData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (description !== undefined) {
|
||||
insertData.description = description;
|
||||
conflictUpdateData.description = description;
|
||||
}
|
||||
if (thumbnail !== undefined) {
|
||||
insertData.thumbnail = thumbnail;
|
||||
conflictUpdateData.thumbnail = thumbnail;
|
||||
}
|
||||
|
||||
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
|
||||
// 创建新画布(使用指定 ID)
|
||||
const [newProject] = await db
|
||||
.insert(canvasProject)
|
||||
.values(insertData)
|
||||
.onConflictDoUpdate({
|
||||
target: canvasProject.id,
|
||||
set: conflictUpdateData,
|
||||
where: eq(canvasProject.userId, session.user.id),
|
||||
.values({
|
||||
id, // 使用指定的 ID
|
||||
userId: session.user.id,
|
||||
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
|
||||
description,
|
||||
workspaceType: workspaceType || "canvas",
|
||||
canvasData: syncedCanvasData || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!newProject) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '画布ID已被占用' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: newProject });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -1,27 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById, getSystemSetting } from "@/lib/db/queries";
|
||||
import { getChatById } from "@/lib/db/queries";
|
||||
import {
|
||||
buildClawdCancelKey,
|
||||
cancelClawdRequest,
|
||||
} from "@/lib/openclaw-chat/cancel-registry";
|
||||
import {
|
||||
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||
} from "@/lib/openclaw-chat/errors";
|
||||
import {
|
||||
normalizeOpenClawChatConfig,
|
||||
OPENCLAW_CHAT_CONFIG_KEY,
|
||||
} from "@/lib/openclaw-chat/config";
|
||||
import {
|
||||
deletePersistedClawdTask,
|
||||
getPersistedClawdTask,
|
||||
} from "@/lib/openclaw-chat/task-store";
|
||||
import {
|
||||
getPersistedUpstreamTaskState,
|
||||
stopOpenClawDurableTask,
|
||||
} from "@/lib/openclaw-chat/upstream-task-client";
|
||||
|
||||
const cancelSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
@ -31,13 +15,7 @@ const cancelSchema = z.object({
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth().catch(() => null);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
|
||||
@ -50,43 +28,13 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const cancelKey = buildClawdCancelKey({
|
||||
userId: session.user.id,
|
||||
chatId: parsed.data.id,
|
||||
userMessageId: parsed.data.userMessageId,
|
||||
});
|
||||
const task = await getPersistedClawdTask({
|
||||
chatId: parsed.data.id,
|
||||
userMessageId: parsed.data.userMessageId,
|
||||
}).catch(() => null);
|
||||
const active = cancelClawdRequest(cancelKey);
|
||||
const upstreamTask = task
|
||||
? getPersistedUpstreamTaskState(task.metadata)
|
||||
: null;
|
||||
if (upstreamTask) {
|
||||
const setting = await getSystemSetting({
|
||||
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const config = normalizeOpenClawChatConfig(setting?.value);
|
||||
if (config.enabled && config.baseUrl && config.token) {
|
||||
await stopOpenClawDurableTask({ config, upstreamTask }).catch((error) => {
|
||||
console.warn("[ClawdChat Cancel] Upstream stop deferred:", {
|
||||
chatId: parsed.data.id,
|
||||
taskId: upstreamTask.taskId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
const active = cancelClawdRequest(
|
||||
buildClawdCancelKey({
|
||||
userId: session.user.id,
|
||||
chatId: parsed.data.id,
|
||||
userMessageId: parsed.data.userMessageId,
|
||||
})
|
||||
);
|
||||
|
||||
const deleted = await deletePersistedClawdTask({
|
||||
chatId: parsed.data.id,
|
||||
userMessageId: parsed.data.userMessageId,
|
||||
}).catch(() => []);
|
||||
|
||||
return NextResponse.json({
|
||||
canceled: true,
|
||||
active,
|
||||
deleted: deleted.length > 0,
|
||||
});
|
||||
return NextResponse.json({ canceled: true, active });
|
||||
}
|
||||
|
||||
@ -1,141 +0,0 @@
|
||||
import JSZip from "jszip";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getAllStorageConfigs } from "@/lib/db/queries";
|
||||
import {
|
||||
countOpenClawArchiveRootFolders,
|
||||
sanitizeOpenClawArchiveEntryPath,
|
||||
sanitizeOpenClawArchiveName,
|
||||
} from "@/lib/openclaw-chat/folder-archive";
|
||||
import {
|
||||
OPENCLAW_CHAT_FOLDER_MAX_FILES,
|
||||
} from "@/lib/openclaw-chat/media-attachments";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
toDbS3UploadConfig,
|
||||
uploadFileWithS3Config,
|
||||
} from "@/lib/storage/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 600;
|
||||
|
||||
const logger = createLogger("openclaw-folder-upload");
|
||||
const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
|
||||
|
||||
function isUploadedFile(value: FormDataEntryValue): value is File {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"arrayBuffer" in value &&
|
||||
"name" in value &&
|
||||
"size" in value
|
||||
);
|
||||
}
|
||||
|
||||
function toArrayBuffer(buffer: Buffer) {
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const files = formData
|
||||
.getAll("files")
|
||||
.filter((entry): entry is File => isUploadedFile(entry));
|
||||
const relativePaths = formData
|
||||
.getAll("relativePaths")
|
||||
.map((entry) => (typeof entry === "string" ? entry : ""));
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "所选文件夹中没有可上传文件" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) {
|
||||
return NextResponse.json(
|
||||
{ error: `单次最多打包 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件` },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
const originalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
const archivePaths = files.map((file, index) =>
|
||||
sanitizeOpenClawArchiveEntryPath(
|
||||
relativePaths[index] || file.name,
|
||||
file.name,
|
||||
index
|
||||
)
|
||||
);
|
||||
const zip = new JSZip();
|
||||
for (const [index, file] of files.entries()) {
|
||||
zip.file(archivePaths[index], await file.arrayBuffer(), {
|
||||
date: new Date(file.lastModified || Date.now()),
|
||||
});
|
||||
}
|
||||
|
||||
const archiveBaseName = sanitizeOpenClawArchiveName(
|
||||
String(formData.get("archiveName") || archivePaths[0]?.split("/")[0] || "")
|
||||
);
|
||||
const archiveName = `${archiveBaseName.replace(/\.zip$/i, "")}.zip`;
|
||||
const archiveBuffer = await zip.generateAsync({
|
||||
type: "nodebuffer",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
platform: "UNIX",
|
||||
});
|
||||
|
||||
const storageConfigs = await getAllStorageConfigs();
|
||||
const qiniuStorage = storageConfigs
|
||||
.filter((config) => config.type === "qiniu-kodo" && config.isEnabled)
|
||||
.sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
|
||||
.find((config) => toDbS3UploadConfig(config));
|
||||
const qiniuConfig = toDbS3UploadConfig(qiniuStorage);
|
||||
if (!qiniuConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: "系统尚未配置可用的七牛云储存桶" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const uploaded = await uploadFileWithS3Config(
|
||||
archiveName,
|
||||
toArrayBuffer(archiveBuffer),
|
||||
qiniuConfig
|
||||
);
|
||||
if (!/^https?:\/\//i.test(uploaded.url)) {
|
||||
throw new Error("七牛云未返回可访问的公网地址");
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: uploaded.url,
|
||||
name: archiveName,
|
||||
contentType: "application/zip",
|
||||
size: archiveBuffer.byteLength,
|
||||
originalSize,
|
||||
fileCount: files.length,
|
||||
folderCount: countOpenClawArchiveRootFolders(archivePaths),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to package and upload Claw folders", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "文件夹上传失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getClawdChatsByUserId } from "@/lib/db/queries";
|
||||
import {
|
||||
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||
} from "@/lib/openclaw-chat/errors";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
@ -13,13 +9,7 @@ export async function GET(request: Request) {
|
||||
const session = await auth().catch(() => null);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
@ -1,59 +1,34 @@
|
||||
import { after, NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
getChatById,
|
||||
getMessagesByChatId,
|
||||
getSystemSetting,
|
||||
getUserById,
|
||||
saveChat,
|
||||
saveMessages,
|
||||
} from "@/lib/db/queries";
|
||||
import {
|
||||
normalizeOpenClawChatConfig,
|
||||
OPENCLAW_CHAT_CONFIG_KEY,
|
||||
} from "@/lib/openclaw-chat/config";
|
||||
import {
|
||||
buildClawdCancelKey,
|
||||
cancelClawdRequest,
|
||||
clearClawdRequestCancellation,
|
||||
isClawdRequestCanceled,
|
||||
registerActiveClawdRequest,
|
||||
unregisterActiveClawdRequest,
|
||||
} from "@/lib/openclaw-chat/cancel-registry";
|
||||
import {
|
||||
normalizeOpenClawChatConfig,
|
||||
OPENCLAW_CHAT_CONFIG_KEY,
|
||||
} from "@/lib/openclaw-chat/config";
|
||||
import {
|
||||
getOpenClawTaskFailureMessage,
|
||||
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||
OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
||||
} from "@/lib/openclaw-chat/errors";
|
||||
import {
|
||||
normalizeOpenClawChatMediaAttachments,
|
||||
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
|
||||
type OpenClawChatMediaAttachment,
|
||||
} from "@/lib/openclaw-chat/media-attachments";
|
||||
import {
|
||||
buildOpenClawDisplayMessage,
|
||||
buildOpenClawMessageWithSpreadsheetMarkdown,
|
||||
buildOpenClawSpreadsheetMarkdown,
|
||||
} from "@/lib/openclaw-chat/spreadsheet-markdown";
|
||||
import {
|
||||
appendPersistedClawdTaskProgress,
|
||||
getPersistedClawdTask,
|
||||
markClawdTaskCanceled,
|
||||
markClawdTaskCompleted,
|
||||
markClawdTaskFailed,
|
||||
markClawdTaskRunning,
|
||||
persistPendingClawdTask,
|
||||
} from "@/lib/openclaw-chat/task-store";
|
||||
import {
|
||||
OpenClawTaskDetachedError,
|
||||
submitOpenClawDurableTask,
|
||||
waitForOpenClawDurableTask,
|
||||
} from "@/lib/openclaw-chat/upstream-task-client";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_PROCESSING_HINT =
|
||||
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
||||
|
||||
export const maxDuration = 1800;
|
||||
const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试";
|
||||
|
||||
const requestSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
@ -61,63 +36,12 @@ const requestSchema = z.object({
|
||||
displayMessage: z.string().trim().max(8000).optional(),
|
||||
userMessageId: z.string().uuid().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
sensitiveInput: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("taobao_credentials"),
|
||||
username: z.string().min(1).max(160),
|
||||
password: z.string().min(1).max(256),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("taobao_verification"),
|
||||
verification_code: z.string().min(4).max(12),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
attachments: z
|
||||
.array(z.unknown())
|
||||
.max(OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
type ParsedClawdChatRequest = Omit<
|
||||
z.infer<typeof requestSchema>,
|
||||
"attachments"
|
||||
> & {
|
||||
attachments: OpenClawChatMediaAttachment[];
|
||||
type ParsedClawdChatRequest = z.infer<typeof requestSchema> & {
|
||||
tableFiles: File[];
|
||||
};
|
||||
|
||||
type ClawdSpreadsheetAttachment = {
|
||||
name: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
sheetCount: number;
|
||||
rowCount: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
type ExecuteClawdTaskParams = {
|
||||
assistantMessageId: string;
|
||||
cancelKey: string;
|
||||
chatId: string;
|
||||
config: { baseUrl: string; token: string; timeoutMs: number };
|
||||
conversationId: string;
|
||||
departmentName: string;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
sensitiveInput?: z.infer<typeof requestSchema>["sensitiveInput"];
|
||||
mediaAttachments: OpenClawChatMediaAttachment[];
|
||||
skillTagId: string;
|
||||
skillTagName: string;
|
||||
spreadsheetFiles: unknown[];
|
||||
spreadsheetMarkdownTruncated: boolean;
|
||||
upstreamMessage: string;
|
||||
userId: string;
|
||||
userMessageId: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
function buildChatTitle(message: string) {
|
||||
const normalized = message.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return "Claw 对话";
|
||||
@ -133,15 +57,17 @@ function getOpenClawUserName(user: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}) {
|
||||
return String(user.name || user.email || user.id).trim();
|
||||
const name = String(user.name || "").trim();
|
||||
if (name) return name;
|
||||
|
||||
const email = String(user.email || "").trim();
|
||||
if (email) return email;
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
function getMetadataString(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
) {
|
||||
const value = metadata?.[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
function getOpenClawDepartmentName(department?: string | null) {
|
||||
return String(department || "").trim();
|
||||
}
|
||||
|
||||
function isRequestFile(value: FormDataEntryValue): value is File {
|
||||
@ -160,39 +86,25 @@ async function parseClawdChatRequest(
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return null;
|
||||
const attachments = normalizeOpenClawChatMediaAttachments(
|
||||
parsed.data.attachments
|
||||
);
|
||||
if (attachments.length !== parsed.data.attachments.length) return null;
|
||||
return { ...parsed.data, attachments, tableFiles: [] };
|
||||
return parsed.success ? { ...parsed.data, tableFiles: [] } : null;
|
||||
}
|
||||
|
||||
const formData = await request.formData().catch(() => null);
|
||||
if (!formData) return null;
|
||||
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
let attachments: unknown[] = [];
|
||||
let sensitiveInput: unknown;
|
||||
const metadataValue = formData.get("metadata");
|
||||
const attachmentsValue = formData.get("attachments");
|
||||
const sensitiveInputValue = formData.get("sensitiveInput");
|
||||
try {
|
||||
if (typeof metadataValue === "string" && metadataValue.trim()) {
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
if (typeof metadataValue === "string" && metadataValue.trim()) {
|
||||
try {
|
||||
const parsedMetadata = JSON.parse(metadataValue);
|
||||
if (!parsedMetadata || typeof parsedMetadata !== "object") return null;
|
||||
metadata = parsedMetadata as Record<string, unknown>;
|
||||
if (parsedMetadata && typeof parsedMetadata === "object") {
|
||||
metadata = parsedMetadata as Record<string, unknown>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof attachmentsValue === "string" && attachmentsValue.trim()) {
|
||||
const parsedAttachments = JSON.parse(attachmentsValue);
|
||||
if (!Array.isArray(parsedAttachments)) return null;
|
||||
attachments = parsedAttachments;
|
||||
}
|
||||
if (typeof sensitiveInputValue === "string" && sensitiveInputValue.trim()) {
|
||||
sensitiveInput = JSON.parse(sensitiveInputValue);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse({
|
||||
@ -201,275 +113,68 @@ async function parseClawdChatRequest(
|
||||
displayMessage: formData.get("displayMessage") || undefined,
|
||||
userMessageId: formData.get("userMessageId") || undefined,
|
||||
metadata,
|
||||
attachments,
|
||||
sensitiveInput,
|
||||
});
|
||||
if (!parsed.success) return null;
|
||||
const normalizedAttachments = normalizeOpenClawChatMediaAttachments(
|
||||
parsed.data.attachments
|
||||
);
|
||||
if (normalizedAttachments.length !== parsed.data.attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed.data,
|
||||
attachments: normalizedAttachments,
|
||||
tableFiles: formData
|
||||
.getAll("tableFiles")
|
||||
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0),
|
||||
};
|
||||
const tableFiles = formData
|
||||
.getAll("tableFiles")
|
||||
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0);
|
||||
|
||||
return { ...parsed.data, tableFiles };
|
||||
}
|
||||
|
||||
async function readOpenClawConfig() {
|
||||
const setting = await getSystemSetting({
|
||||
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
|
||||
() => null
|
||||
);
|
||||
return normalizeOpenClawChatConfig(setting?.value);
|
||||
}
|
||||
|
||||
function getAnswer(payload: unknown) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return "";
|
||||
}
|
||||
return String((payload as Record<string, unknown>).answer || "").trim();
|
||||
function getOpenClawErrorCode(payload: unknown) {
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
const raw = payload as Record<string, unknown>;
|
||||
const nestedError =
|
||||
raw.error && typeof raw.error === "object"
|
||||
? (raw.error as Record<string, unknown>)
|
||||
: null;
|
||||
const code = nestedError?.code || raw.code;
|
||||
return typeof code === "string" ? code.trim() : "";
|
||||
}
|
||||
|
||||
function getRequestId(payload: unknown) {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
const value = (payload as Record<string, unknown>).request_id;
|
||||
return value ? String(value) : null;
|
||||
function getMetadataString(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
) {
|
||||
const value = metadata?.[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
||||
const controller = new AbortController();
|
||||
registerActiveClawdRequest(
|
||||
params.cancelKey,
|
||||
controller,
|
||||
Math.max(params.config.timeoutMs, 10 * 60 * 1000)
|
||||
);
|
||||
|
||||
try {
|
||||
if (isClawdRequestCanceled(params.cancelKey)) return;
|
||||
await markClawdTaskRunning({
|
||||
chatId: params.chatId,
|
||||
assistantMessageId: params.assistantMessageId,
|
||||
});
|
||||
|
||||
const upstreamBody: Record<string, unknown> = {
|
||||
conversation_id: params.conversationId,
|
||||
user_id: params.userId,
|
||||
user_name: params.userName,
|
||||
username: params.userName,
|
||||
department: params.departmentName,
|
||||
department_name: params.departmentName,
|
||||
user_department: params.departmentName,
|
||||
skill_tag_id: params.skillTagId || undefined,
|
||||
skill_tag_name: params.skillTagName || undefined,
|
||||
message: params.upstreamMessage,
|
||||
attachments:
|
||||
params.mediaAttachments.length > 0
|
||||
? params.mediaAttachments
|
||||
: undefined,
|
||||
metadata: {
|
||||
source: "ai_chatbot_canvas",
|
||||
page: "/canvas",
|
||||
tenant: "default",
|
||||
chat_id: params.chatId,
|
||||
user_id: params.userId,
|
||||
user_name: params.userName,
|
||||
username: params.userName,
|
||||
department: params.departmentName,
|
||||
department_name: params.departmentName,
|
||||
user_department: params.departmentName,
|
||||
skill_tag_id: params.skillTagId || undefined,
|
||||
skill_tag_name: params.skillTagName || undefined,
|
||||
media_attachments:
|
||||
params.mediaAttachments.length > 0
|
||||
? params.mediaAttachments
|
||||
: undefined,
|
||||
spreadsheet_files:
|
||||
params.spreadsheetFiles.length > 0
|
||||
? params.spreadsheetFiles
|
||||
: undefined,
|
||||
spreadsheet_markdown_truncated:
|
||||
params.spreadsheetMarkdownTruncated || undefined,
|
||||
...params.metadata,
|
||||
},
|
||||
stream_progress: false,
|
||||
sensitive_input: params.sensitiveInput,
|
||||
};
|
||||
|
||||
if (params.sensitiveInput) {
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
controller.abort(
|
||||
new DOMException("OpenClaw request timed out", "TimeoutError")
|
||||
),
|
||||
params.config.timeoutMs
|
||||
);
|
||||
try {
|
||||
const response = await fetch(`${params.config.baseUrl}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(upstreamBody),
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenClaw request failed: ${response.status}`);
|
||||
}
|
||||
if (!payload) throw new Error("OpenClaw returned an invalid response");
|
||||
const answer = getAnswer(payload);
|
||||
if (!answer) throw new Error("OpenClaw returned an empty answer");
|
||||
await markClawdTaskCompleted({
|
||||
chatId: params.chatId,
|
||||
assistantMessageId: params.assistantMessageId,
|
||||
answer,
|
||||
requestId: getRequestId(payload),
|
||||
});
|
||||
clearClawdRequestCancellation(params.cancelKey);
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const target = {
|
||||
chatId: params.chatId,
|
||||
assistantMessageId: params.assistantMessageId,
|
||||
};
|
||||
const upstreamTask = await submitOpenClawDurableTask({
|
||||
config: params.config,
|
||||
body: upstreamBody,
|
||||
idempotencyKey: `${params.userId}:${params.chatId}:${params.userMessageId}`,
|
||||
target,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const outcome = await waitForOpenClawDurableTask({
|
||||
config: params.config,
|
||||
upstreamTask,
|
||||
target,
|
||||
deadlineAt: Date.now() + params.config.timeoutMs,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (isClawdRequestCanceled(params.cancelKey)) return;
|
||||
if (outcome.status === "completed") {
|
||||
await markClawdTaskCompleted({
|
||||
...target,
|
||||
answer: outcome.answer,
|
||||
requestId: outcome.requestId,
|
||||
});
|
||||
} else if (outcome.status === "canceled") {
|
||||
await markClawdTaskCanceled({
|
||||
...target,
|
||||
message: outcome.error,
|
||||
});
|
||||
} else if (outcome.status === "failed") {
|
||||
await markClawdTaskFailed({
|
||||
...target,
|
||||
error: outcome.error,
|
||||
});
|
||||
}
|
||||
clearClawdRequestCancellation(params.cancelKey);
|
||||
} catch (error) {
|
||||
if (isClawdRequestCanceled(params.cancelKey)) return;
|
||||
if (error instanceof OpenClawTaskDetachedError) {
|
||||
await appendPersistedClawdTaskProgress({
|
||||
chatId: params.chatId,
|
||||
assistantMessageId: params.assistantMessageId,
|
||||
progress: {
|
||||
step_code: "task.detached",
|
||||
step_state: "waiting",
|
||||
message: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
|
||||
display: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
|
||||
},
|
||||
}).catch(() => null);
|
||||
return;
|
||||
}
|
||||
console.error("[ClawdChat API] Durable task failed:", error);
|
||||
await markClawdTaskFailed({
|
||||
chatId: params.chatId,
|
||||
assistantMessageId: params.assistantMessageId,
|
||||
error: getOpenClawTaskFailureMessage(error),
|
||||
}).catch(() => null);
|
||||
} finally {
|
||||
if (params.sensitiveInput?.type === "taobao_credentials") {
|
||||
params.sensitiveInput.username = "";
|
||||
params.sensitiveInput.password = "";
|
||||
} else if (params.sensitiveInput?.type === "taobao_verification") {
|
||||
params.sensitiveInput.verification_code = "";
|
||||
}
|
||||
params.sensitiveInput = undefined;
|
||||
unregisterActiveClawdRequest(params.cancelKey);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskResponse(params: {
|
||||
chatId: string;
|
||||
userMessageId: string;
|
||||
assistantMessageId: string;
|
||||
status: string;
|
||||
processingHint: string;
|
||||
}) {
|
||||
const statusUrl = `/api/canvas/clawd-chat/status?id=${encodeURIComponent(
|
||||
params.chatId
|
||||
)}&userMessageId=${encodeURIComponent(params.userMessageId)}`;
|
||||
return {
|
||||
accepted: true,
|
||||
task: {
|
||||
chatId: params.chatId,
|
||||
userMessageId: params.userMessageId,
|
||||
assistantMessageId: params.assistantMessageId,
|
||||
status: params.status,
|
||||
processingHint: params.processingHint,
|
||||
statusUrl,
|
||||
},
|
||||
};
|
||||
function createCanceledResponse() {
|
||||
return NextResponse.json({ canceled: true }, { status: 499 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth().catch(() => null);
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = await parseClawdChatRequest(request);
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||
}
|
||||
if (
|
||||
!parsed.message &&
|
||||
parsed.tableFiles.length === 0 &&
|
||||
parsed.attachments.length === 0
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "请输入问题或上传附件" },
|
||||
{ status: 400 }
|
||||
);
|
||||
if (!parsed.message && parsed.tableFiles.length === 0) {
|
||||
return NextResponse.json({ error: "请输入问题或上传表格" }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await readOpenClawConfig();
|
||||
if (!config.enabled || !config.baseUrl || !config.token) {
|
||||
if (!config.enabled) {
|
||||
return NextResponse.json({ error: CLAW_UPGRADE_RETRY_MESSAGE }, { status: 503 });
|
||||
}
|
||||
if (!config.baseUrl || !config.token) {
|
||||
return NextResponse.json(
|
||||
{ error: OPENCLAW_UPGRADE_RETRY_MESSAGE },
|
||||
{ error: CLAW_UPGRADE_RETRY_MESSAGE },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
@ -480,22 +185,13 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const userMessageId = parsed.userMessageId || generateUUID();
|
||||
const existingTask = await getPersistedClawdTask({
|
||||
const cancelKey = buildClawdCancelKey({
|
||||
userId: session.user.id,
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
}).catch(() => null);
|
||||
if (existingTask) {
|
||||
return NextResponse.json(
|
||||
buildTaskResponse({
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
assistantMessageId: existingTask.metadata.assistantMessageId,
|
||||
status: existingTask.metadata.status,
|
||||
processingHint:
|
||||
existingTask.metadata.processingHint || DEFAULT_PROCESSING_HINT,
|
||||
}),
|
||||
{ status: 202, headers: { "Retry-After": "2" } }
|
||||
);
|
||||
});
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
|
||||
let spreadsheetMarkdownResult: Awaited<
|
||||
@ -525,23 +221,10 @@ export async function POST(request: Request) {
|
||||
message: parsed.message,
|
||||
spreadsheetMarkdown: spreadsheetMarkdownResult?.markdown || "",
|
||||
});
|
||||
const spreadsheetAttachments: ClawdSpreadsheetAttachment[] = (
|
||||
spreadsheetMarkdownResult?.files || []
|
||||
).map((summary, index) => {
|
||||
const sourceFile = parsed.tableFiles[index];
|
||||
return {
|
||||
name: summary.fileName,
|
||||
size: sourceFile?.size || 0,
|
||||
contentType:
|
||||
sourceFile?.type ||
|
||||
(summary.fileName.toLowerCase().endsWith(".xlsx")
|
||||
? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
: "text/csv"),
|
||||
sheetCount: summary.sheetCount,
|
||||
rowCount: summary.rowCount,
|
||||
truncated: summary.truncated,
|
||||
};
|
||||
});
|
||||
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
|
||||
if (!existingChat) {
|
||||
await saveChat({
|
||||
@ -553,91 +236,220 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const assistantMessageId = generateUUID();
|
||||
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
|
||||
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
|
||||
const processingHint =
|
||||
getMetadataString(parsed.metadata, "processingHint") ||
|
||||
DEFAULT_PROCESSING_HINT;
|
||||
const messageAttachments = parsed.attachments.map((attachment) => ({
|
||||
name:
|
||||
attachment.name ||
|
||||
(attachment.type === "video"
|
||||
? "视频附件"
|
||||
: attachment.type === "file"
|
||||
? "文件附件"
|
||||
: "图片附件"),
|
||||
url: attachment.url,
|
||||
contentType:
|
||||
attachment.content_type ||
|
||||
(attachment.type === "video"
|
||||
? "video/mp4"
|
||||
: attachment.type === "file"
|
||||
? "application/octet-stream"
|
||||
: "image/jpeg"),
|
||||
}));
|
||||
|
||||
await persistPendingClawdTask({
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
assistantMessageId,
|
||||
displayMessage,
|
||||
attachments: messageAttachments,
|
||||
spreadsheetAttachments,
|
||||
skillTagId,
|
||||
skillTagName,
|
||||
processingHint,
|
||||
});
|
||||
|
||||
const userProfile = await getUserById({ id: session.user.id }).catch(
|
||||
() => null
|
||||
);
|
||||
const userName = getOpenClawUserName({
|
||||
const conversationId = createConversationId(session.user.id, parsed.id);
|
||||
const userProfile = await getUserById({ id: session.user.id }).catch(() => null);
|
||||
const openClawUserName = getOpenClawUserName({
|
||||
id: session.user.id,
|
||||
name: userProfile?.name ?? session.user.name,
|
||||
email: userProfile?.email ?? session.user.email,
|
||||
});
|
||||
const cancelKey = buildClawdCancelKey({
|
||||
userId: session.user.id,
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
});
|
||||
const openClawDepartmentName = getOpenClawDepartmentName(userProfile?.department);
|
||||
const skillTagId = getMetadataString(parsed.metadata, "skillTagId");
|
||||
const skillTagName = getMetadataString(parsed.metadata, "skillTagName");
|
||||
const endpoint = `${config.baseUrl}/api/chat`;
|
||||
|
||||
after(async () => {
|
||||
await executeClawdTask({
|
||||
assistantMessageId,
|
||||
cancelKey,
|
||||
chatId: parsed.id,
|
||||
config,
|
||||
conversationId: createConversationId(session.user.id, parsed.id),
|
||||
departmentName: String(userProfile?.department || "").trim(),
|
||||
metadata: parsed.metadata,
|
||||
sensitiveInput: parsed.sensitiveInput,
|
||||
mediaAttachments: parsed.attachments,
|
||||
skillTagId,
|
||||
skillTagName,
|
||||
spreadsheetFiles: spreadsheetMarkdownResult?.files || [],
|
||||
spreadsheetMarkdownTruncated:
|
||||
spreadsheetMarkdownResult?.truncated || false,
|
||||
upstreamMessage,
|
||||
userId: session.user.id,
|
||||
userMessageId,
|
||||
userName,
|
||||
let upstreamPayload: unknown = null;
|
||||
let upstreamStatus = 502;
|
||||
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
const handleRequestAbort = () => {
|
||||
cancelClawdRequest(cancelKey);
|
||||
};
|
||||
request.signal.addEventListener("abort", handleRequestAbort, { once: true });
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversation_id: conversationId,
|
||||
user_id: session.user.id,
|
||||
user_name: openClawUserName,
|
||||
username: openClawUserName,
|
||||
department: openClawDepartmentName,
|
||||
department_name: openClawDepartmentName,
|
||||
user_department: openClawDepartmentName,
|
||||
skill_tag_id: skillTagId || undefined,
|
||||
skill_tag_name: skillTagName || undefined,
|
||||
message: upstreamMessage,
|
||||
metadata: {
|
||||
source: "ai_chatbot_canvas",
|
||||
page: "/canvas",
|
||||
tenant: "default",
|
||||
chat_id: parsed.id,
|
||||
user_id: session.user.id,
|
||||
user_name: openClawUserName,
|
||||
username: openClawUserName,
|
||||
department: openClawDepartmentName,
|
||||
department_name: openClawDepartmentName,
|
||||
user_department: openClawDepartmentName,
|
||||
skill_tag_id: skillTagId || undefined,
|
||||
skill_tag_name: skillTagName || undefined,
|
||||
spreadsheet_files: spreadsheetMarkdownResult?.files || undefined,
|
||||
spreadsheet_markdown_truncated:
|
||||
spreadsheetMarkdownResult?.truncated || undefined,
|
||||
...parsed.metadata,
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
request.signal.removeEventListener("abort", handleRequestAbort);
|
||||
unregisterActiveClawdRequest(cancelKey);
|
||||
});
|
||||
});
|
||||
|
||||
const responseBody = buildTaskResponse({
|
||||
upstreamStatus = response.status;
|
||||
upstreamPayload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorCode = getOpenClawErrorCode(upstreamPayload);
|
||||
console.error("[ClawdChat API] OpenClaw upstream error:", {
|
||||
status: response.status,
|
||||
code: errorCode || undefined,
|
||||
payload: upstreamPayload,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: CLAW_UPGRADE_RETRY_MESSAGE,
|
||||
code: errorCode || undefined,
|
||||
},
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
unregisterActiveClawdRequest(cancelKey);
|
||||
const errorName =
|
||||
error && typeof error === "object" && "name" in error
|
||||
? String((error as { name?: unknown }).name || "")
|
||||
: "";
|
||||
const isAbortError = errorName === "AbortError";
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
console.info("[ClawdChat API] OpenClaw request canceled:", {
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
});
|
||||
return createCanceledResponse();
|
||||
}
|
||||
console.error("[ClawdChat API] OpenClaw request failed:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: CLAW_UPGRADE_RETRY_MESSAGE,
|
||||
},
|
||||
{ status: isAbortError ? 504 : upstreamStatus }
|
||||
);
|
||||
}
|
||||
|
||||
const answer =
|
||||
upstreamPayload && typeof upstreamPayload === "object"
|
||||
? String((upstreamPayload as Record<string, unknown>).answer || "").trim()
|
||||
: "";
|
||||
|
||||
if (!answer) {
|
||||
console.error("[ClawdChat API] OpenClaw empty answer:", upstreamPayload);
|
||||
return NextResponse.json(
|
||||
{ error: CLAW_UPGRADE_RETRY_MESSAGE },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (isClawdRequestCanceled(cancelKey)) {
|
||||
return createCanceledResponse();
|
||||
}
|
||||
|
||||
const existingMessages = await getMessagesByChatId({ id: parsed.id }).catch(
|
||||
() => []
|
||||
);
|
||||
const latestMessageTimestamp = existingMessages.reduce((latest, item) => {
|
||||
const timestamp =
|
||||
item.createdAt instanceof Date
|
||||
? item.createdAt.getTime()
|
||||
: Date.parse(String(item.createdAt || ""));
|
||||
return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
|
||||
}, 0);
|
||||
const userCreatedAt = new Date(
|
||||
Math.max(Date.now(), latestMessageTimestamp + 1)
|
||||
);
|
||||
const assistantCreatedAt = new Date(userCreatedAt.getTime() + 1);
|
||||
|
||||
const userMessage = {
|
||||
id: userMessageId,
|
||||
chatId: parsed.id,
|
||||
userMessageId,
|
||||
assistantMessageId,
|
||||
status: "queued",
|
||||
processingHint,
|
||||
});
|
||||
return NextResponse.json(responseBody, {
|
||||
status: 202,
|
||||
headers: {
|
||||
"Cache-Control": "no-store, max-age=0",
|
||||
Location: responseBody.task.statusUrl,
|
||||
"Retry-After": "2",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: displayMessage }],
|
||||
attachments: [],
|
||||
createdAt: userCreatedAt,
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: {
|
||||
composerMode: "clawd",
|
||||
source: "openclaw",
|
||||
skillTagId: skillTagId || null,
|
||||
skillTagName: skillTagName || null,
|
||||
},
|
||||
};
|
||||
const assistantMessage = {
|
||||
id: assistantMessageId,
|
||||
chatId: parsed.id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: answer }],
|
||||
attachments: [],
|
||||
createdAt: assistantCreatedAt,
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: {
|
||||
composerMode: "clawd",
|
||||
source: "openclaw",
|
||||
requestId:
|
||||
upstreamPayload && typeof upstreamPayload === "object"
|
||||
? (upstreamPayload as Record<string, unknown>).request_id || null
|
||||
: null,
|
||||
},
|
||||
};
|
||||
|
||||
await saveMessages({ messages: [userMessage, assistantMessage] });
|
||||
clearClawdRequestCancellation(cancelKey);
|
||||
|
||||
return NextResponse.json({
|
||||
conversationId,
|
||||
requestId:
|
||||
upstreamPayload && typeof upstreamPayload === "object"
|
||||
? (upstreamPayload as Record<string, unknown>).request_id || null
|
||||
: null,
|
||||
messages: [
|
||||
{
|
||||
id: userMessage.id,
|
||||
role: "user",
|
||||
parts: userMessage.parts,
|
||||
createdAt: userMessage.createdAt.toISOString(),
|
||||
metadata: {
|
||||
createdAt: userMessage.createdAt.toISOString(),
|
||||
composerMode: "clawd",
|
||||
},
|
||||
videoMetadata: userMessage.videoMetadata,
|
||||
},
|
||||
{
|
||||
id: assistantMessage.id,
|
||||
role: "assistant",
|
||||
parts: assistantMessage.parts,
|
||||
createdAt: assistantMessage.createdAt.toISOString(),
|
||||
metadata: {
|
||||
createdAt: assistantMessage.createdAt.toISOString(),
|
||||
composerMode: "clawd",
|
||||
},
|
||||
videoMetadata: assistantMessage.videoMetadata,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,190 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById, getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
buildClawdCancelKey,
|
||||
hasActiveClawdRequest,
|
||||
registerActiveClawdRequest,
|
||||
unregisterActiveClawdRequest,
|
||||
} from "@/lib/openclaw-chat/cancel-registry";
|
||||
import {
|
||||
normalizeOpenClawChatConfig,
|
||||
OPENCLAW_CHAT_CONFIG_KEY,
|
||||
} from "@/lib/openclaw-chat/config";
|
||||
import {
|
||||
OPENCLAW_AUTH_REQUIRED_CODE,
|
||||
OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||
} from "@/lib/openclaw-chat/errors";
|
||||
import {
|
||||
getPersistedClawdTask,
|
||||
markClawdTaskCanceled,
|
||||
markClawdTaskCompleted,
|
||||
markClawdTaskFailed,
|
||||
} from "@/lib/openclaw-chat/task-store";
|
||||
import { isPendingClawdTaskStatus } from "@/lib/openclaw-chat/task-state";
|
||||
import {
|
||||
getPersistedUpstreamTaskState,
|
||||
refreshOpenClawDurableTask,
|
||||
} from "@/lib/openclaw-chat/upstream-task-client";
|
||||
|
||||
const statusSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
userMessageId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
export const maxDuration = 60;
|
||||
|
||||
async function reconcileDetachedOpenClawTask(params: {
|
||||
chatId: string;
|
||||
userId: string;
|
||||
task: NonNullable<Awaited<ReturnType<typeof getPersistedClawdTask>>>;
|
||||
}) {
|
||||
if (!isPendingClawdTaskStatus(params.task.metadata.status)) return;
|
||||
const upstreamTask = getPersistedUpstreamTaskState(params.task.metadata);
|
||||
if (!upstreamTask) return;
|
||||
const cancelKey = buildClawdCancelKey({
|
||||
userId: params.userId,
|
||||
chatId: params.chatId,
|
||||
userMessageId: params.task.metadata.userMessageId,
|
||||
});
|
||||
if (hasActiveClawdRequest(cancelKey)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
registerActiveClawdRequest(cancelKey, controller, 30_000);
|
||||
const target = {
|
||||
chatId: params.chatId,
|
||||
assistantMessageId: params.task.metadata.assistantMessageId,
|
||||
};
|
||||
try {
|
||||
const setting = await getSystemSetting({
|
||||
key: OPENCLAW_CHAT_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
const config = normalizeOpenClawChatConfig(setting?.value);
|
||||
if (!config.enabled || !config.baseUrl || !config.token) return;
|
||||
const outcome = await refreshOpenClawDurableTask({
|
||||
config,
|
||||
upstreamTask,
|
||||
target,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (outcome.status === "completed") {
|
||||
await markClawdTaskCompleted({
|
||||
...target,
|
||||
answer: outcome.answer,
|
||||
requestId: outcome.requestId,
|
||||
});
|
||||
} else if (outcome.status === "failed") {
|
||||
await markClawdTaskFailed({ ...target, error: outcome.error });
|
||||
} else if (outcome.status === "canceled") {
|
||||
await markClawdTaskCanceled({ ...target, message: outcome.error });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn("[ClawdChat Status] Upstream reconciliation deferred:", {
|
||||
chatId: params.chatId,
|
||||
taskId: upstreamTask.taskId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
unregisterActiveClawdRequest(cancelKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth().catch(() => null);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: OPENCLAW_AUTH_REQUIRED_MESSAGE,
|
||||
code: OPENCLAW_AUTH_REQUIRED_CODE,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const parsed = statusSchema.safeParse({
|
||||
id: url.searchParams.get("id"),
|
||||
userMessageId: url.searchParams.get("userMessageId") || undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id: parsed.data.id }).catch(() => null);
|
||||
if (!chat) {
|
||||
return NextResponse.json({ task: null }, { status: 404 });
|
||||
}
|
||||
if (chat.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
let task = await getPersistedClawdTask({
|
||||
chatId: parsed.data.id,
|
||||
userMessageId: parsed.data.userMessageId,
|
||||
});
|
||||
if (!task) {
|
||||
return NextResponse.json(
|
||||
{ task: null },
|
||||
{ headers: { "Cache-Control": "no-store, max-age=0" } }
|
||||
);
|
||||
}
|
||||
|
||||
await reconcileDetachedOpenClawTask({
|
||||
chatId: parsed.data.id,
|
||||
userId: session.user.id,
|
||||
task,
|
||||
});
|
||||
task =
|
||||
(await getPersistedClawdTask({
|
||||
chatId: parsed.data.id,
|
||||
userMessageId: parsed.data.userMessageId,
|
||||
})) || task;
|
||||
|
||||
const userTextPart = Array.isArray(task.userMessage?.parts)
|
||||
? task.userMessage.parts.find(
|
||||
(part) =>
|
||||
part &&
|
||||
typeof part === "object" &&
|
||||
!Array.isArray(part) &&
|
||||
(part as { type?: unknown }).type === "text"
|
||||
)
|
||||
: null;
|
||||
const userText =
|
||||
userTextPart && typeof userTextPart === "object"
|
||||
? String((userTextPart as { text?: unknown }).text || "")
|
||||
: "";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
task: {
|
||||
chatId: parsed.data.id,
|
||||
userMessageId: task.metadata.userMessageId,
|
||||
assistantMessageId: task.metadata.assistantMessageId,
|
||||
status: task.metadata.status,
|
||||
processingHint: task.metadata.processingHint,
|
||||
progressEvents: task.metadata.progressEvents,
|
||||
skillTagId: task.metadata.skillTagId || null,
|
||||
skillTagName: task.metadata.skillTagName || null,
|
||||
requestId: task.metadata.requestId || null,
|
||||
error: task.metadata.error || null,
|
||||
createdAt: task.metadata.createdAt,
|
||||
updatedAt: task.metadata.updatedAt,
|
||||
userMessage: {
|
||||
text: userText,
|
||||
attachments: task.userMessage?.attachments || [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store, max-age=0",
|
||||
"Retry-After": "2",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -1956,7 +1956,7 @@ export async function POST(request: Request) {
|
||||
(item) =>
|
||||
!item.model.isSubModel &&
|
||||
item.model.isEnabled &&
|
||||
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
|
||||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
||||
);
|
||||
const configuredDetailAgentConfig =
|
||||
currentSiteSettings?.canvasDetailAgentModelId &&
|
||||
|
||||
@ -204,7 +204,7 @@ export async function POST(request: Request) {
|
||||
(item) =>
|
||||
!item.model.isSubModel &&
|
||||
item.model.isEnabled &&
|
||||
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type),
|
||||
["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
|
||||
);
|
||||
const configuredDetailAgentConfig =
|
||||
currentSiteSettings?.canvasDetailAgentModelId &&
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,7 +14,6 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||
import { createVip98k } from "@/lib/ai/providers/vip98k";
|
||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||
import { createRunway } from "@/lib/ai/providers/runway";
|
||||
@ -58,7 +57,6 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"vip98k",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -129,8 +127,6 @@ function createTextEditProvider(
|
||||
return createVisionary({ baseURL, apiKey });
|
||||
case "atlascloud":
|
||||
return createAtlasCloud({ baseURL, apiKey });
|
||||
case "vip98k":
|
||||
return createVip98k({ baseURL, apiKey });
|
||||
case "stepfun-image":
|
||||
return createStepfunImage({ baseURL, apiKey });
|
||||
case "midjourney":
|
||||
@ -497,17 +493,10 @@ async function generateTextEditImageWithFailover(params: {
|
||||
imageCount: 1,
|
||||
},
|
||||
};
|
||||
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {
|
||||
if (selected.provider.type === "atlascloud") {
|
||||
generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
|
||||
}
|
||||
const modelInstance =
|
||||
selected.provider.type === "vip98k"
|
||||
? (provider as any).chat(runtimeModelId, {
|
||||
capabilities: selected.model.capabilities || null,
|
||||
imageGenConfig: selected.model.imageGenConfig || null,
|
||||
})
|
||||
: provider.chat(runtimeModelId);
|
||||
const result = await modelInstance.doGenerate(generationOptions);
|
||||
const result = await provider.chat(runtimeModelId).doGenerate(generationOptions);
|
||||
|
||||
const resultText = String(result?.text || "").trim();
|
||||
const finishReason = String(result?.finishReason || "").toLowerCase();
|
||||
|
||||
@ -70,14 +70,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(
|
||||
|
||||
@ -7,16 +7,9 @@ import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
|
||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import {
|
||||
isWorldLabsExternalTaskApi,
|
||||
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||
WorldLabsApiError,
|
||||
worldLabsRequest,
|
||||
} from "@/lib/worldlabs/client";
|
||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
|
||||
import type {
|
||||
WorldLabsExternalTaskResponse,
|
||||
WorldLabsExternalTaskResultData,
|
||||
WorldLabsOperation,
|
||||
WorldLabsWorld,
|
||||
WorldLabsWorldAssets,
|
||||
@ -426,214 +419,6 @@ function buildAssetPayload(
|
||||
};
|
||||
}
|
||||
|
||||
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
|
||||
const root = getObjectValue(payload);
|
||||
const data = getObjectValue(root?.data);
|
||||
return (data || root || {}) as T;
|
||||
}
|
||||
|
||||
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
|
||||
const normalized = String(status || "").trim().toLowerCase();
|
||||
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
|
||||
return "success" as const;
|
||||
}
|
||||
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||||
return "failed" as const;
|
||||
}
|
||||
return "running" as const;
|
||||
}
|
||||
|
||||
function readStringRecord(value: unknown) {
|
||||
const record = getObjectValue(value);
|
||||
if (!record) return null;
|
||||
const entries = Object.entries(record).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0
|
||||
);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
||||
}
|
||||
|
||||
function findNestedString(
|
||||
value: unknown,
|
||||
keys: string[],
|
||||
depth = 0
|
||||
): string | null {
|
||||
if (depth > 8) return null;
|
||||
const record = getObjectValue(value);
|
||||
if (!record) return null;
|
||||
|
||||
for (const key of keys) {
|
||||
const directValue = getStringValue(record[key]);
|
||||
if (directValue) return directValue;
|
||||
}
|
||||
|
||||
for (const child of Object.values(record)) {
|
||||
const found = findNestedString(child, keys, depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNestedObject(
|
||||
value: unknown,
|
||||
keys: string[],
|
||||
depth = 0
|
||||
): Record<string, unknown> | null {
|
||||
if (depth > 8) return null;
|
||||
const record = getObjectValue(value);
|
||||
if (!record) return null;
|
||||
|
||||
for (const key of keys) {
|
||||
const directValue = getObjectValue(record[key]);
|
||||
if (directValue) return directValue;
|
||||
}
|
||||
|
||||
for (const child of Object.values(record)) {
|
||||
const found = findNestedObject(child, keys, depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNestedStringRecord(
|
||||
value: unknown,
|
||||
keys: string[],
|
||||
depth = 0
|
||||
): Record<string, string> | null {
|
||||
if (depth > 8) return null;
|
||||
const record = getObjectValue(value);
|
||||
if (!record) return null;
|
||||
|
||||
for (const key of keys) {
|
||||
const directValue = readStringRecord(record[key]);
|
||||
if (directValue) return directValue;
|
||||
}
|
||||
|
||||
for (const child of Object.values(record)) {
|
||||
const found = findNestedStringRecord(child, keys, depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractExternalTaskErrorMessage(
|
||||
data: WorldLabsExternalTaskResultData,
|
||||
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
||||
) {
|
||||
const dataError = getObjectValue(data.error);
|
||||
const payloadError = getObjectValue(payload.error);
|
||||
return (
|
||||
getStringValue(
|
||||
data.errorMessage,
|
||||
data.message,
|
||||
dataError?.message,
|
||||
payload.message,
|
||||
payloadError?.message,
|
||||
payload.error
|
||||
) || "World Labs 外部任务失败"
|
||||
);
|
||||
}
|
||||
|
||||
function extractExternalWorldAssetPayload(data: WorldLabsExternalTaskResultData) {
|
||||
const root = getObjectValue(data) || {};
|
||||
const resultRoot =
|
||||
getObjectValue(root.result) ||
|
||||
getObjectValue(root.output) ||
|
||||
getObjectValue(root.world) ||
|
||||
root;
|
||||
const assets = findNestedObject(resultRoot, ["assets"]);
|
||||
const worldAssets = assets as Partial<WorldLabsWorldAssets> | null;
|
||||
const splats = getObjectValue(worldAssets?.splats);
|
||||
|
||||
return {
|
||||
worldId: getStringValue(
|
||||
findNestedString(resultRoot, ["worldId", "world_id"]),
|
||||
findNestedString(root, ["worldId", "world_id"])
|
||||
),
|
||||
worldUrl: getStringValue(
|
||||
findNestedString(resultRoot, [
|
||||
"worldUrl",
|
||||
"world_marble_url",
|
||||
"sourceWorldUrl",
|
||||
"viewerUrl",
|
||||
"marbleUrl",
|
||||
]),
|
||||
findNestedString(root, [
|
||||
"worldUrl",
|
||||
"world_marble_url",
|
||||
"sourceWorldUrl",
|
||||
"viewerUrl",
|
||||
"marbleUrl",
|
||||
])
|
||||
),
|
||||
displayName: getStringValue(
|
||||
findNestedString(resultRoot, ["displayName", "display_name"]),
|
||||
findNestedString(root, ["displayName", "display_name"])
|
||||
),
|
||||
model:
|
||||
getStringValue(
|
||||
findNestedString(resultRoot, ["model", "modelId", "model_id"]),
|
||||
findNestedString(root, ["model", "modelId", "model_id"])
|
||||
) || WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||
caption: getStringValue(
|
||||
worldAssets?.caption,
|
||||
findNestedString(resultRoot, ["caption"]),
|
||||
findNestedString(root, ["caption"])
|
||||
),
|
||||
thumbnailUrl: getStringValue(
|
||||
worldAssets?.thumbnail_url,
|
||||
findNestedString(resultRoot, [
|
||||
"thumbnailUrl",
|
||||
"thumbnail_url",
|
||||
"previewImageUrl",
|
||||
"preview_image_url",
|
||||
]),
|
||||
findNestedString(root, [
|
||||
"thumbnailUrl",
|
||||
"thumbnail_url",
|
||||
"previewImageUrl",
|
||||
"preview_image_url",
|
||||
])
|
||||
),
|
||||
panoUrl: getStringValue(
|
||||
worldAssets?.imagery?.pano_url,
|
||||
findNestedString(resultRoot, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"]),
|
||||
findNestedString(root, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"])
|
||||
),
|
||||
meshUrl: getStringValue(
|
||||
worldAssets?.mesh?.collider_mesh_url,
|
||||
findNestedString(resultRoot, [
|
||||
"meshUrl",
|
||||
"mesh_url",
|
||||
"modelUrl",
|
||||
"model_url",
|
||||
"glbUrl",
|
||||
"glb_url",
|
||||
"colliderMeshUrl",
|
||||
"collider_mesh_url",
|
||||
]),
|
||||
findNestedString(root, [
|
||||
"meshUrl",
|
||||
"mesh_url",
|
||||
"modelUrl",
|
||||
"model_url",
|
||||
"glbUrl",
|
||||
"glb_url",
|
||||
"colliderMeshUrl",
|
||||
"collider_mesh_url",
|
||||
])
|
||||
),
|
||||
spzUrls:
|
||||
readStringRecord(splats?.spz_urls) ||
|
||||
findNestedStringRecord(resultRoot, ["spzUrls", "spz_urls"]) ||
|
||||
findNestedStringRecord(root, ["spzUrls", "spz_urls"]),
|
||||
semanticsMetadata:
|
||||
getObjectValue(splats?.semantics_metadata) ||
|
||||
findNestedObject(resultRoot, ["semanticsMetadata", "semantics_metadata"]) ||
|
||||
findNestedObject(root, ["semanticsMetadata", "semantics_metadata"]),
|
||||
};
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ taskId: string }>;
|
||||
}
|
||||
@ -667,187 +452,6 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
});
|
||||
providerForLog = config.providerId || null;
|
||||
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||
const persistedAssetPayload =
|
||||
getObjectValue(persistedWorldAsset?.assetPayload) || {};
|
||||
const shouldUseExternalTaskResultApi =
|
||||
isWorldLabsExternalTaskApi(config.baseUrl) &&
|
||||
persistedAssetPayload.externalTaskApi !== false &&
|
||||
persistedAssetPayload.marbleApi !== true;
|
||||
if (shouldUseExternalTaskResultApi) {
|
||||
const externalPayload = await worldLabsRequest<
|
||||
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
||||
>(config, {
|
||||
method: "GET",
|
||||
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
|
||||
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
||||
});
|
||||
const externalData =
|
||||
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
|
||||
const externalStatusText = getStringValue(externalData.status) || "running";
|
||||
const externalStatus = normalizeExternalTaskStatus(
|
||||
externalStatusText,
|
||||
externalData.done === true
|
||||
);
|
||||
const externalAssetPayloadBase = {
|
||||
...persistedAssetPayload,
|
||||
providerId: config.providerId || null,
|
||||
providerName: config.providerName || null,
|
||||
externalTaskApi: true,
|
||||
externalTaskStatus: externalStatusText,
|
||||
requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
|
||||
};
|
||||
|
||||
if (externalStatus === "running") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: normalizedTaskId,
|
||||
type: "world",
|
||||
status: "running",
|
||||
progress: null,
|
||||
output: externalData,
|
||||
assetPayload: externalAssetPayloadBase,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (externalStatus === "failed") {
|
||||
const errorMessage = extractExternalTaskErrorMessage(
|
||||
externalData,
|
||||
externalPayload
|
||||
);
|
||||
await updatePersistedWorldAsset({
|
||||
id: persistedWorldAsset?.id,
|
||||
status: "failed",
|
||||
progress: null,
|
||||
error: errorMessage,
|
||||
assetPayload: externalAssetPayloadBase,
|
||||
});
|
||||
logger.warn("external task failed status", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
taskIdLength: normalizedTaskId.length,
|
||||
providerId: config.providerId || null,
|
||||
error: errorMessage,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: normalizedTaskId,
|
||||
type: "world",
|
||||
status: "failed",
|
||||
progress: null,
|
||||
errorMessage,
|
||||
output: externalData,
|
||||
assetPayload: externalAssetPayloadBase,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
|
||||
const rawMeshUrl = extractedAssetPayload.meshUrl;
|
||||
const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
|
||||
const rawPanoUrl = extractedAssetPayload.panoUrl;
|
||||
const stableWorldUrl: string | null = null;
|
||||
const stableModelUrl = await archiveRemoteAssetIfNeeded({
|
||||
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
|
||||
sourceUrl: rawMeshUrl,
|
||||
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
|
||||
fallbackExtension: "glb",
|
||||
validateAs: "glb",
|
||||
});
|
||||
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
|
||||
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
|
||||
sourceUrl: rawThumbnailUrl,
|
||||
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
|
||||
fallbackExtension: "jpg",
|
||||
});
|
||||
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
|
||||
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
|
||||
sourceUrl: rawPanoUrl,
|
||||
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
|
||||
fallbackExtension: "jpg",
|
||||
});
|
||||
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
|
||||
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
|
||||
sourceMap: extractedAssetPayload.spzUrls,
|
||||
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
|
||||
fallbackExtension: "spz",
|
||||
validateAs: "spz",
|
||||
});
|
||||
const assetPayload = {
|
||||
...externalAssetPayloadBase,
|
||||
worldId: extractedAssetPayload.worldId,
|
||||
worldUrl: stableWorldUrl,
|
||||
displayName: extractedAssetPayload.displayName,
|
||||
model: resolveThreeDModelAliasId(
|
||||
extractedAssetPayload.model,
|
||||
modelAliasConfig.worldModels
|
||||
),
|
||||
caption: extractedAssetPayload.caption,
|
||||
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
|
||||
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
|
||||
meshUrl: stableModelUrl,
|
||||
spzUrls: stableSpzUrls,
|
||||
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
|
||||
storageArchivedAt: new Date().toISOString(),
|
||||
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
|
||||
sourceMeshUrl:
|
||||
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
||||
? rawMeshUrl
|
||||
: null,
|
||||
sourceThumbnailUrl:
|
||||
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
|
||||
? rawThumbnailUrl
|
||||
: null,
|
||||
sourcePanoUrl:
|
||||
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
|
||||
? rawPanoUrl
|
||||
: null,
|
||||
sourceSpzUrls:
|
||||
extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls
|
||||
? extractedAssetPayload.spzUrls
|
||||
: null,
|
||||
};
|
||||
|
||||
await updatePersistedWorldAsset({
|
||||
id: persistedWorldAsset?.id,
|
||||
status: "success",
|
||||
progress: 100,
|
||||
modelUrl: stableModelUrl,
|
||||
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||||
error: null,
|
||||
assetPayload,
|
||||
});
|
||||
|
||||
logger.info("external task success status", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
taskIdLength: normalizedTaskId.length,
|
||||
providerId: config.providerId || null,
|
||||
hasMesh: Boolean(stableModelUrl),
|
||||
hasPano: Boolean(stablePanoUrl),
|
||||
hasThumbnail: Boolean(stableThumbnailUrl),
|
||||
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: normalizedTaskId,
|
||||
type: "world",
|
||||
status: "success",
|
||||
progress: 100,
|
||||
output: externalData,
|
||||
modelUrl: stableModelUrl,
|
||||
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||||
worldId: assetPayload.worldId,
|
||||
worldUrl: stableWorldUrl,
|
||||
panoUrl: stablePanoUrl,
|
||||
caption: assetPayload.caption,
|
||||
assetPayload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
||||
method: "GET",
|
||||
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
||||
|
||||
@ -12,23 +12,13 @@ import {
|
||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { get3DWorldCreditCost } from "@/lib/points/config";
|
||||
import {
|
||||
isWorldLabsExternalTaskApi,
|
||||
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||
WorldLabsApiError,
|
||||
worldLabsRequest,
|
||||
} from "@/lib/worldlabs/client";
|
||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||
import {
|
||||
getWorldLabsProviderConfig,
|
||||
getWorldLabsProviderConfigs,
|
||||
reportWorldLabsProviderRuntimeFailure,
|
||||
} from "@/lib/worldlabs/server";
|
||||
import type {
|
||||
WorldLabsExternalTaskCreateData,
|
||||
WorldLabsExternalTaskResponse,
|
||||
WorldLabsModel,
|
||||
WorldLabsOperation,
|
||||
} from "@/lib/worldlabs/types";
|
||||
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
@ -57,10 +47,8 @@ type CreateWorldTaskRequest = {
|
||||
makePublic?: boolean;
|
||||
reconstructImages?: boolean;
|
||||
mediaAssetId?: string;
|
||||
sourceUrl?: string;
|
||||
mediaAssets?: Array<{
|
||||
mediaAssetId?: string;
|
||||
sourceUrl?: string;
|
||||
azimuth?: number | null;
|
||||
}>;
|
||||
};
|
||||
@ -70,89 +58,10 @@ function readOptionalTrimmedText(input: unknown) {
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function readOptionalHttpUrl(input: unknown) {
|
||||
const value = readOptionalTrimmedText(input);
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||||
? parsed.toString()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getObjectValue(value: unknown) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
|
||||
const root = getObjectValue(payload);
|
||||
const data = getObjectValue(root?.data);
|
||||
return (data || root || {}) as T;
|
||||
}
|
||||
|
||||
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
|
||||
const normalized = String(status || "").trim().toLowerCase();
|
||||
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
|
||||
return "success" as const;
|
||||
}
|
||||
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||||
return "failed" as const;
|
||||
}
|
||||
return "running" as const;
|
||||
}
|
||||
|
||||
function normalizeExternalTaskCreateOperation(
|
||||
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||||
): WorldLabsOperation {
|
||||
const data = unwrapExternalTaskData<WorldLabsExternalTaskCreateData>(payload);
|
||||
const taskId = readOptionalTrimmedText(data.taskId || data.task_id || data.id);
|
||||
const status = readOptionalTrimmedText(data.status) || "accepted";
|
||||
const normalizedStatus = normalizeExternalTaskStatus(status, data.done === true);
|
||||
return {
|
||||
operation_id: taskId || undefined,
|
||||
done: normalizedStatus === "success",
|
||||
error:
|
||||
normalizedStatus === "failed"
|
||||
? {
|
||||
message:
|
||||
readOptionalTrimmedText(
|
||||
(payload as Record<string, unknown>)?.message ||
|
||||
(payload as Record<string, unknown>)?.error
|
||||
) || "World Labs 外部任务创建失败",
|
||||
}
|
||||
: null,
|
||||
metadata: {
|
||||
externalTaskApi: true,
|
||||
status,
|
||||
resultPath: readOptionalTrimmedText(data.resultPath || data.result_path),
|
||||
requestId: readOptionalTrimmedText(payload.requestId || payload.request_id),
|
||||
},
|
||||
response: data,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldCharge3DPoints(mode: WorldTaskMode) {
|
||||
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
|
||||
}
|
||||
|
||||
function shouldUseExternalTaskCreateApi(
|
||||
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||||
mode: WorldTaskMode
|
||||
) {
|
||||
return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "text";
|
||||
}
|
||||
|
||||
function resolveOfficialMarbleModel(model?: string | null) {
|
||||
const normalized = readOptionalTrimmedText(model);
|
||||
if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
|
||||
return "marble-1.1";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
const textPrompt = readOptionalTrimmedText(body.prompt);
|
||||
|
||||
@ -171,21 +80,15 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
|
||||
if (mode === "image") {
|
||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||
if (!mediaAssetId && !sourceUrl) {
|
||||
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
|
||||
if (!mediaAssetId) {
|
||||
throw new Error("图生世界缺少 mediaAssetId");
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
image_prompt: mediaAssetId
|
||||
? {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
}
|
||||
: {
|
||||
source: "uri",
|
||||
uri: sourceUrl,
|
||||
},
|
||||
image_prompt: {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
},
|
||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||
...(body.disableRecaption !== undefined
|
||||
? { disable_recaption: body.disableRecaption }
|
||||
@ -199,8 +102,7 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
const multiImagePrompt = mediaAssets
|
||||
.map((item) => {
|
||||
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
|
||||
const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
|
||||
if (!mediaAssetId && !sourceUrl) return null;
|
||||
if (!mediaAssetId) return null;
|
||||
const azimuth =
|
||||
item?.azimuth === null || item?.azimuth === undefined
|
||||
? null
|
||||
@ -209,15 +111,10 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
: null;
|
||||
return {
|
||||
...(azimuth === null ? {} : { azimuth }),
|
||||
content: mediaAssetId
|
||||
? {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
}
|
||||
: {
|
||||
source: "uri",
|
||||
uri: sourceUrl,
|
||||
},
|
||||
content: {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
@ -240,22 +137,16 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
}
|
||||
|
||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||
if (!mediaAssetId && !sourceUrl) {
|
||||
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
|
||||
if (!mediaAssetId) {
|
||||
throw new Error("视频生成世界缺少 mediaAssetId");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "video",
|
||||
video_prompt: mediaAssetId
|
||||
? {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
}
|
||||
: {
|
||||
source: "uri",
|
||||
uri: sourceUrl,
|
||||
},
|
||||
video_prompt: {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
},
|
||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||
};
|
||||
}
|
||||
@ -296,29 +187,12 @@ export async function POST(request: Request) {
|
||||
);
|
||||
modelForLog = resolvedWorldModel || "marble-1.1";
|
||||
|
||||
const preferredProviderId = readOptionalTrimmedText(body.providerId);
|
||||
const displayName =
|
||||
readOptionalTrimmedText(body.displayName) ||
|
||||
readOptionalTrimmedText(body.prompt) ||
|
||||
`World ${new Date().toLocaleString("zh-CN")}`;
|
||||
const worldPrompt = buildWorldPrompt(body, mode);
|
||||
|
||||
const candidates = preferredProviderId
|
||||
? [await getWorldLabsProviderConfig(preferredProviderId)]
|
||||
: await getWorldLabsProviderConfigs();
|
||||
if (candidates.length === 0) {
|
||||
throw new Error("未找到可用的 World Labs Key");
|
||||
}
|
||||
|
||||
const officialMarbleModel = resolveOfficialMarbleModel(resolvedWorldModel);
|
||||
const predictedBillingModel = candidates.some((candidate) =>
|
||||
shouldUseExternalTaskCreateApi(candidate, mode)
|
||||
)
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: officialMarbleModel;
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const shouldChargePoints = shouldCharge3DPoints(mode);
|
||||
const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
|
||||
const unitCost = get3DWorldCreditCost(
|
||||
pointsConfig,
|
||||
resolvedWorldModel || "marble-1.1",
|
||||
);
|
||||
|
||||
if (
|
||||
shouldChargePoints &&
|
||||
@ -346,51 +220,42 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const preferredProviderId = readOptionalTrimmedText(body.providerId);
|
||||
const displayName =
|
||||
readOptionalTrimmedText(body.displayName) ||
|
||||
readOptionalTrimmedText(body.prompt) ||
|
||||
`World ${new Date().toLocaleString("zh-CN")}`;
|
||||
const worldPrompt = buildWorldPrompt(body, mode);
|
||||
|
||||
const candidates = preferredProviderId
|
||||
? [await getWorldLabsProviderConfig(preferredProviderId)]
|
||||
: await getWorldLabsProviderConfigs();
|
||||
if (candidates.length === 0) {
|
||||
throw new Error("未找到可用的 World Labs Key");
|
||||
}
|
||||
|
||||
let result: WorldLabsOperation | null = null;
|
||||
let usedProvider = null as Awaited<
|
||||
ReturnType<typeof getWorldLabsProviderConfig>
|
||||
> | null;
|
||||
let usedExternalTaskApi = false;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
|
||||
if (useExternalTaskApi) {
|
||||
const prompt = readOptionalTrimmedText(body.prompt);
|
||||
if (!prompt) {
|
||||
throw new Error("请输入世界描述");
|
||||
}
|
||||
const externalResult = await worldLabsRequest<
|
||||
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||||
>(candidate, {
|
||||
method: "POST",
|
||||
path: "/api/external/task/generate",
|
||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||
json: {
|
||||
model: WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||
displayName,
|
||||
prompt,
|
||||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||
method: "POST",
|
||||
path: "/marble/v1/worlds:generate",
|
||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||
json: {
|
||||
display_name: displayName,
|
||||
model: resolvedWorldModel || "marble-1.1",
|
||||
permission: {
|
||||
public: body.makePublic === true,
|
||||
},
|
||||
});
|
||||
result = normalizeExternalTaskCreateOperation(externalResult);
|
||||
} else {
|
||||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||
method: "POST",
|
||||
path: "/marble/v1/worlds:generate",
|
||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||
json: {
|
||||
display_name: displayName,
|
||||
model: officialMarbleModel,
|
||||
permission: {
|
||||
public: body.makePublic === true,
|
||||
},
|
||||
world_prompt: worldPrompt,
|
||||
},
|
||||
});
|
||||
}
|
||||
world_prompt: worldPrompt,
|
||||
},
|
||||
});
|
||||
usedProvider = candidate;
|
||||
usedExternalTaskApi = useExternalTaskApi;
|
||||
providerForLog = candidate.providerId || null;
|
||||
break;
|
||||
} catch (error) {
|
||||
@ -427,18 +292,12 @@ export async function POST(request: Request) {
|
||||
|
||||
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
||||
try {
|
||||
const usedBillingModel = usedExternalTaskApi
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: officialMarbleModel;
|
||||
const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: worldModelDisplayName;
|
||||
await consumeUserPoints({
|
||||
userId: session.user.id,
|
||||
amount: unitCost,
|
||||
reason: "world3d_generation",
|
||||
modelId: `worldlabs-3d:${usedBillingModel}`,
|
||||
note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
|
||||
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
|
||||
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("points consume failed", error, {
|
||||
@ -454,13 +313,10 @@ export async function POST(request: Request) {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
taskIdLength: taskId.length,
|
||||
mode,
|
||||
model: usedExternalTaskApi
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: officialMarbleModel,
|
||||
model: resolvedWorldModel || "marble-1.1",
|
||||
providerId: usedProvider.providerId || null,
|
||||
status: result.done ? "success" : "running",
|
||||
unitCost,
|
||||
externalTaskApi: usedExternalTaskApi,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@ -471,8 +327,6 @@ export async function POST(request: Request) {
|
||||
assetPayload: {
|
||||
providerId: usedProvider.providerId || null,
|
||||
providerName: usedProvider.providerName || null,
|
||||
externalTaskApi: usedExternalTaskApi,
|
||||
marbleApi: !usedExternalTaskApi,
|
||||
},
|
||||
raw: result,
|
||||
},
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import {
|
||||
WorldLabsApiError,
|
||||
worldLabsRequest,
|
||||
} from "@/lib/worldlabs/client";
|
||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||
import {
|
||||
getWorldLabsProviderConfig,
|
||||
getWorldLabsProviderConfigs,
|
||||
@ -116,81 +112,6 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
|
||||
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown) {
|
||||
const text = String(value || "").trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function extractUploadInfoFromCurlExample(curlExample: unknown) {
|
||||
const curl = String(curlExample || "");
|
||||
const uploadUrl =
|
||||
[...curl.matchAll(/https?:\/\/[^\s'"\\]+/g)]
|
||||
.map((match) => match[0])
|
||||
.find(Boolean) || null;
|
||||
const requiredHeaders: Record<string, string> = {};
|
||||
const headerPattern = /(?:-H|--header)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
|
||||
let headerMatch: RegExpExecArray | null;
|
||||
|
||||
while ((headerMatch = headerPattern.exec(curl))) {
|
||||
const header = String(
|
||||
headerMatch[1] || headerMatch[2] || headerMatch[3] || ""
|
||||
).trim();
|
||||
const colonIndex = header.indexOf(":");
|
||||
if (colonIndex <= 0) continue;
|
||||
const name = header.slice(0, colonIndex).trim();
|
||||
const value = header.slice(colonIndex + 1).trim();
|
||||
if (name && value) {
|
||||
requiredHeaders[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { uploadUrl, requiredHeaders };
|
||||
}
|
||||
|
||||
function resolvePrepareUploadDetails(data: WorldLabsPrepareUploadResponse) {
|
||||
const uploadInfo = data.upload_info || {};
|
||||
const uploadInfoRecord = uploadInfo as Record<string, unknown>;
|
||||
const curlDetails = extractUploadInfoFromCurlExample(uploadInfo.curl_example);
|
||||
const explicitHeaders =
|
||||
uploadInfo.required_headers && typeof uploadInfo.required_headers === "object"
|
||||
? uploadInfo.required_headers
|
||||
: {};
|
||||
|
||||
return {
|
||||
uploadUrl:
|
||||
readOptionalString(uploadInfo.upload_url) ||
|
||||
readOptionalString(uploadInfoRecord.uploadUrl) ||
|
||||
readOptionalString(uploadInfoRecord.url) ||
|
||||
curlDetails.uploadUrl,
|
||||
uploadMethod:
|
||||
readOptionalString(uploadInfo.upload_method) ||
|
||||
readOptionalString(uploadInfoRecord.method) ||
|
||||
"PUT",
|
||||
requiredHeaders: {
|
||||
...curlDetails.requiredHeaders,
|
||||
...explicitHeaders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isSignedUploadFailure(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
return (
|
||||
message.includes("SignatureDoesNotMatch") ||
|
||||
message.includes("Access denied") ||
|
||||
message.includes("Google secret key") ||
|
||||
message.includes("签名")
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadWorldLabsInputToStorage(file: File, extension: string) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const safeExtension = extension && extension !== "bin" ? extension : "bin";
|
||||
const filename = `worldlabs-input-${Date.now()}-${crypto.randomUUID()}.${safeExtension}`;
|
||||
const uploaded = await uploadFile(filename, buffer);
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
function isCredentialOrBillingProviderError(error: unknown) {
|
||||
return (
|
||||
error instanceof WorldLabsApiError &&
|
||||
@ -281,24 +202,24 @@ export async function POST(request: Request) {
|
||||
);
|
||||
|
||||
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
||||
const { uploadUrl, uploadMethod, requiredHeaders } =
|
||||
resolvePrepareUploadDetails(prepareUpload);
|
||||
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
|
||||
if (!mediaAssetId || !uploadUrl) {
|
||||
throw new Error("World Labs 返回的上传信息不完整");
|
||||
}
|
||||
|
||||
const uploadHeaders = new Headers();
|
||||
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
|
||||
const requiredHeaders = prepareUpload.upload_info?.required_headers || {};
|
||||
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
|
||||
if (!headerName || headerValue === undefined || headerValue === null) continue;
|
||||
uploadHeaders.set(headerName, String(headerValue));
|
||||
}
|
||||
if (!uploadHeaders.has("Content-Type") && !prepareUpload.upload_info?.curl_example) {
|
||||
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
|
||||
}
|
||||
|
||||
const normalizedUploadMethod = uploadMethod.toUpperCase();
|
||||
const uploadMethod = String(
|
||||
prepareUpload.upload_info?.upload_method || "PUT"
|
||||
).toUpperCase();
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
|
||||
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
|
||||
headers: uploadHeaders,
|
||||
body: file,
|
||||
cache: "no-store",
|
||||
@ -331,28 +252,6 @@ export async function POST(request: Request) {
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isSignedUploadFailure(error)) {
|
||||
try {
|
||||
const sourceUrl = await uploadWorldLabsInputToStorage(file, extension);
|
||||
logger.warn("worldlabs signed upload failed; using storage source url", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
providerId: config.providerId || null,
|
||||
kind: isImage ? "image" : "video",
|
||||
size: file.size,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
sourceUrl,
|
||||
providerId: config.providerId || null,
|
||||
providerName: config.providerName || null,
|
||||
uploadFallback: "storage-source-url",
|
||||
},
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
lastError = fallbackError;
|
||||
}
|
||||
}
|
||||
if (isCredentialOrBillingProviderError(error)) {
|
||||
await reportWorldLabsProviderRuntimeFailure(
|
||||
config.providerId,
|
||||
|
||||
@ -2,7 +2,6 @@ import { sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||
import { db } from "@/lib/db";
|
||||
import { clientPerformanceEvent } from "@/lib/db/schema";
|
||||
|
||||
@ -168,12 +167,9 @@ export async function POST(request: Request) {
|
||||
console.warn("[ClientPerformance] prune failed", error);
|
||||
});
|
||||
|
||||
const persistedChatId =
|
||||
event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
|
||||
|
||||
await db.insert(clientPerformanceEvent).values({
|
||||
userId: session?.user?.id || null,
|
||||
chatId: persistedChatId,
|
||||
chatId: event.chatId || null,
|
||||
projectId: clampText(event.projectId, 64),
|
||||
eventType: clampText(event.eventType, 64) || "unknown",
|
||||
section: event.section || "unknown",
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createMediaAssetRelation,
|
||||
getAiProvidersByType,
|
||||
getMediaAssetsByUserAndUrls,
|
||||
getSystemSetting,
|
||||
upsertMediaAsset,
|
||||
@ -10,12 +9,9 @@ import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
|
||||
import { createFalClient } from "@fal-ai/client";
|
||||
|
||||
// 变清晰API配置类型
|
||||
type EnhanceApiConfig = {
|
||||
provider?: "vip98k" | "aoscdn";
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
type?: string; // "clean" | "face"
|
||||
@ -321,155 +317,6 @@ async function processWithRealESRGANBase64(
|
||||
};
|
||||
}
|
||||
|
||||
async function processWithFalEsrgan(
|
||||
imageBase64: string,
|
||||
requestedScaleFactor: number,
|
||||
): Promise<{ imageBase64: string; processingTime: number }> {
|
||||
const providers = await getAiProvidersByType({
|
||||
type: "fal-image",
|
||||
enabledOnly: true,
|
||||
});
|
||||
const provider = providers.find((item) => item.apiKey?.trim());
|
||||
if (!provider?.apiKey) {
|
||||
throw new Error("未配置可用的 FAL 专用高清服务");
|
||||
}
|
||||
|
||||
const scale = Math.max(2, Math.min(8, requestedScaleFactor || 4));
|
||||
const model = scale <= 2 ? "RealESRGAN_x2plus" : "RealESRGAN_x4plus";
|
||||
const credentials = provider.apiKey
|
||||
.trim()
|
||||
.replace(/^(?:Key|Bearer)\s+/i, "");
|
||||
const fal = createFalClient({ credentials });
|
||||
const startedAt = Date.now();
|
||||
const result: any = await fal.subscribe("fal-ai/esrgan", {
|
||||
input: {
|
||||
image_url: `data:image/jpeg;base64,${imageBase64}`,
|
||||
scale,
|
||||
model,
|
||||
output_format: "png",
|
||||
},
|
||||
logs: true,
|
||||
timeout: 180_000,
|
||||
});
|
||||
const outputUrl = result?.data?.image?.url || result?.image?.url;
|
||||
if (!outputUrl) {
|
||||
throw new Error("FAL ESRGAN 专用高清服务未返回图片");
|
||||
}
|
||||
|
||||
const outputResponse = await fetch(outputUrl, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
if (!outputResponse.ok) {
|
||||
throw new Error(
|
||||
`FAL ESRGAN 高清结果下载失败(HTTP ${outputResponse.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
imageBase64: Buffer.from(await outputResponse.arrayBuffer()).toString(
|
||||
"base64",
|
||||
),
|
||||
processingTime: (Date.now() - startedAt) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function processWithVip98kEnhance(params: {
|
||||
userId: string;
|
||||
imageBase64: string;
|
||||
prompt: string;
|
||||
chatId: string | null;
|
||||
messageId: string | null;
|
||||
sourceImageUrl: string | null;
|
||||
scaleFactor: number;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
const result = await executeConfiguredVip98kTask({
|
||||
modelId: "enhance",
|
||||
kind: "image-tool",
|
||||
prompt: `${params.prompt || "Enhance image clarity"}. Upscale ${params.scaleFactor}x while preserving composition, color and texture.`,
|
||||
imageUrls: [`data:image/jpeg;base64,${params.imageBase64}`],
|
||||
imageParams: { scaleFactor: params.scaleFactor },
|
||||
});
|
||||
const outputUrl = result.outputUrls[0];
|
||||
if (!outputUrl) throw new Error("VIP98K 变清晰未返回图片");
|
||||
|
||||
const response = await fetch(outputUrl, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`VIP98K 变清晰结果下载失败(HTTP ${response.status})`);
|
||||
}
|
||||
const outputBuffer = Buffer.from(await response.arrayBuffer());
|
||||
const outputBase64 = outputBuffer.toString("base64");
|
||||
const uploaded = await uploadBase64ToS3(outputBase64, "image/png");
|
||||
const [savedImage] = await db
|
||||
.insert(generatedImage)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
thumbnailUrl: uploaded.thumbnail,
|
||||
originalUrl: uploaded.original,
|
||||
imageSize: `${params.scaleFactor}x放大`,
|
||||
prompt: params.prompt,
|
||||
modelId: "enhance",
|
||||
})
|
||||
.returning();
|
||||
|
||||
try {
|
||||
const sourceAssets = params.sourceImageUrl
|
||||
? await getMediaAssetsByUserAndUrls({
|
||||
userId: params.userId,
|
||||
urls: [params.sourceImageUrl],
|
||||
})
|
||||
: [];
|
||||
const media = await upsertMediaAsset({
|
||||
userId: params.userId,
|
||||
chatId: params.chatId,
|
||||
mediaType: "image",
|
||||
sourceType: "edited",
|
||||
status: "ready",
|
||||
originalUrl: uploaded.original,
|
||||
previewUrl: uploaded.original,
|
||||
thumbnailUrl: uploaded.thumbnail,
|
||||
displayName: params.prompt.slice(0, 80),
|
||||
prompt: params.prompt,
|
||||
modelId: "enhance",
|
||||
sourceMessageId: params.messageId,
|
||||
sourceTable: "GeneratedImage",
|
||||
sourceRowId: savedImage?.id || null,
|
||||
primaryParentAssetId: sourceAssets[0]?.id ?? null,
|
||||
metadata: { provider: "vip98k", scaleFactor: params.scaleFactor },
|
||||
});
|
||||
if (media) {
|
||||
for (const sourceAsset of sourceAssets) {
|
||||
await createMediaAssetRelation({
|
||||
userId: params.userId,
|
||||
chatId: params.chatId,
|
||||
fromAssetId: sourceAsset.id,
|
||||
toAssetId: media.id,
|
||||
relationType: "edited_from",
|
||||
sourceMessageId: params.messageId,
|
||||
metadata: { prompt: params.prompt, provider: "vip98k" },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Enhance-VIP98K] MediaAsset registration failed", error);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
image_base64: outputBase64,
|
||||
thumbnail_url: uploaded.thumbnail,
|
||||
original_url: uploaded.original,
|
||||
processing_time: (Date.now() - startedAt) / 1000,
|
||||
api_type: "vip98k",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询获取任务结果(根据官方示例)
|
||||
*/
|
||||
@ -689,42 +536,6 @@ async function finalizeEnhanceTaskResult(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function executeFalEsrganFallback(params: {
|
||||
userId: string;
|
||||
imageBase64: string;
|
||||
prompt: string;
|
||||
chatId: string | null;
|
||||
messageId: string | null;
|
||||
sourceImageUrl: string | null;
|
||||
requestedScaleFactor?: number;
|
||||
reason: string;
|
||||
}) {
|
||||
const scaleFactor = params.requestedScaleFactor || 4;
|
||||
console.warn("[Enhance-FAL-ESRGAN] 启用专用高清备用链路", {
|
||||
reason: params.reason.slice(0, 500),
|
||||
scaleFactor,
|
||||
});
|
||||
const result = await processWithFalEsrgan(params.imageBase64, scaleFactor);
|
||||
const payload = await finalizeEnhanceTaskResult({
|
||||
userId: params.userId,
|
||||
taskResult: {
|
||||
status: 200,
|
||||
data: { image_base64: result.imageBase64 },
|
||||
},
|
||||
prompt: params.prompt || "图片变清晰处理",
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
sourceImageUrls: params.sourceImageUrl
|
||||
? [params.sourceImageUrl]
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
processing_time: result.processingTime,
|
||||
api_type: "fal-esrgan",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
@ -752,12 +563,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = enhanceConfig.value as EnhanceApiConfig;
|
||||
if (config.provider === "vip98k") {
|
||||
return NextResponse.json(
|
||||
{ error: "VIP98K 变清晰任务在 POST 请求内完成,无需 taskId 轮询" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (config.useBackupApi) {
|
||||
return NextResponse.json(
|
||||
{ error: "当前启用了Real-ESRGAN备用模式,不支持 taskId 轮询" },
|
||||
@ -846,15 +651,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = enhanceConfig.value as EnhanceApiConfig;
|
||||
const useVip98k = config.provider === "vip98k";
|
||||
|
||||
// 检查是否使用备用API
|
||||
const useBackupApi = !useVip98k && config.useBackupApi === true;
|
||||
const useBackupApi = config.useBackupApi === true;
|
||||
console.log(`[Enhance API] 使用${useBackupApi ? "备用API (Real-ESRGAN)" : "主API"}`);
|
||||
|
||||
if (useVip98k) {
|
||||
// VIP98K 密钥只从 AiProvider 读取。
|
||||
} else if (useBackupApi) {
|
||||
if (useBackupApi) {
|
||||
// 验证备用API配置
|
||||
if (!config.backupApiUrl || !config.backupApiKey) {
|
||||
return NextResponse.json(
|
||||
@ -955,31 +757,6 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (useVip98k) {
|
||||
try {
|
||||
const scaleFactor = requestedScaleFactor || 4;
|
||||
const payload = await processWithVip98kEnhance({
|
||||
userId,
|
||||
imageBase64,
|
||||
prompt: prompt || "图片变清晰处理",
|
||||
chatId,
|
||||
messageId,
|
||||
sourceImageUrl,
|
||||
scaleFactor,
|
||||
});
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
console.error("[Enhance-VIP98K] Processing failed", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "VIP98K 变清晰处理失败",
|
||||
detail: error instanceof Error ? error.message : "未知错误",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 备用API处理路径 (Real-ESRGAN) ==========
|
||||
if (useBackupApi) {
|
||||
console.log("[Enhance API] 使用Real-ESRGAN备用API处理(URL方式)...");
|
||||
@ -1117,21 +894,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
} catch (error) {
|
||||
console.error("[Enhance-RealESRGAN] ❌ 处理失败:", error);
|
||||
try {
|
||||
const fallbackPayload = await executeFalEsrganFallback({
|
||||
userId,
|
||||
imageBase64,
|
||||
prompt,
|
||||
chatId,
|
||||
messageId,
|
||||
sourceImageUrl,
|
||||
requestedScaleFactor,
|
||||
reason: error instanceof Error ? error.message : "RealESRGAN 备用服务失败",
|
||||
});
|
||||
return NextResponse.json(fallbackPayload);
|
||||
} catch (falError) {
|
||||
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Real-ESRGAN变清晰处理失败",
|
||||
@ -1286,22 +1048,6 @@ export async function POST(request: NextRequest) {
|
||||
enhanceUrl,
|
||||
processingTime,
|
||||
});
|
||||
|
||||
try {
|
||||
const fallbackPayload = await executeFalEsrganFallback({
|
||||
userId,
|
||||
imageBase64,
|
||||
prompt,
|
||||
chatId,
|
||||
messageId,
|
||||
sourceImageUrl,
|
||||
requestedScaleFactor,
|
||||
reason: fetchError instanceof Error ? fetchError.message : "AOS 高清任务创建失败",
|
||||
});
|
||||
return NextResponse.json(fallbackPayload);
|
||||
} catch (falError) {
|
||||
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
|
||||
}
|
||||
|
||||
// 检查是否是网络连接错误
|
||||
if (fetchError instanceof Error) {
|
||||
@ -1368,21 +1114,6 @@ export async function POST(request: NextRequest) {
|
||||
return buildEnhanceTaskPendingResponse(taskId);
|
||||
}
|
||||
const detail = pollError instanceof Error ? pollError.message : "轮询失败";
|
||||
try {
|
||||
const fallbackPayload = await executeFalEsrganFallback({
|
||||
userId,
|
||||
imageBase64,
|
||||
prompt,
|
||||
chatId,
|
||||
messageId,
|
||||
sourceImageUrl,
|
||||
requestedScaleFactor,
|
||||
reason: detail,
|
||||
});
|
||||
return NextResponse.json(fallbackPayload);
|
||||
} catch (falError) {
|
||||
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
|
||||
}
|
||||
const friendlyMessage = getAosFriendlyErrorMessage(detail);
|
||||
return NextResponse.json(
|
||||
{ error: "变清晰处理失败", detail: friendlyMessage || detail },
|
||||
|
||||
119
app/api/external/image/generate/route.ts
vendored
119
app/api/external/image/generate/route.ts
vendored
@ -21,6 +21,103 @@ import {
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 1200;
|
||||
|
||||
const EXTERNAL_IMAGE_API_LIMITER_KEY = "__external_image_api_limiter__";
|
||||
const DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT = 2;
|
||||
const DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE = 8;
|
||||
const DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS = 15_000;
|
||||
|
||||
class ExternalImageApiBusyError extends Error {
|
||||
readonly retryAfterSeconds: number;
|
||||
|
||||
constructor(message: string, retryAfterSeconds = 15) {
|
||||
super(message);
|
||||
this.name = "ExternalImageApiBusyError";
|
||||
this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
type ExternalImageApiWaiter = {
|
||||
resolve: (release: () => void) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type ExternalImageApiLimiterState = {
|
||||
running: number;
|
||||
queue: ExternalImageApiWaiter[];
|
||||
};
|
||||
|
||||
function parsePositiveInteger(value: unknown, fallback: number) {
|
||||
const parsed = Number.parseInt(String(value || ""), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function getExternalImageApiLimiterState(): ExternalImageApiLimiterState {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[EXTERNAL_IMAGE_API_LIMITER_KEY]?: ExternalImageApiLimiterState;
|
||||
};
|
||||
|
||||
if (!globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]) {
|
||||
globalState[EXTERNAL_IMAGE_API_LIMITER_KEY] = {
|
||||
running: 0,
|
||||
queue: [],
|
||||
};
|
||||
}
|
||||
|
||||
return globalState[EXTERNAL_IMAGE_API_LIMITER_KEY]!;
|
||||
}
|
||||
|
||||
function acquireExternalImageApiSlot(): Promise<() => void> {
|
||||
const state = getExternalImageApiLimiterState();
|
||||
const maxConcurrent = parsePositiveInteger(
|
||||
process.env.EXTERNAL_IMAGE_API_MAX_CONCURRENT,
|
||||
DEFAULT_EXTERNAL_IMAGE_API_MAX_CONCURRENT,
|
||||
);
|
||||
const maxQueue = parsePositiveInteger(
|
||||
process.env.EXTERNAL_IMAGE_API_MAX_QUEUE,
|
||||
DEFAULT_EXTERNAL_IMAGE_API_MAX_QUEUE,
|
||||
);
|
||||
const queueTimeoutMs = parsePositiveInteger(
|
||||
process.env.EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
|
||||
DEFAULT_EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const release = () => {
|
||||
state.running = Math.max(0, state.running - 1);
|
||||
const next = state.queue.shift();
|
||||
if (!next) return;
|
||||
clearTimeout(next.timer);
|
||||
state.running += 1;
|
||||
next.resolve(release);
|
||||
};
|
||||
|
||||
if (state.running < maxConcurrent) {
|
||||
state.running += 1;
|
||||
return Promise.resolve(release);
|
||||
}
|
||||
|
||||
if (state.queue.length >= maxQueue) {
|
||||
return Promise.reject(
|
||||
new ExternalImageApiBusyError("图片生成服务繁忙,请稍后重试"),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter: ExternalImageApiWaiter = {
|
||||
resolve,
|
||||
reject,
|
||||
timer: setTimeout(() => {
|
||||
const index = state.queue.indexOf(waiter);
|
||||
if (index >= 0) {
|
||||
state.queue.splice(index, 1);
|
||||
}
|
||||
reject(new ExternalImageApiBusyError("图片生成排队超时,请稍后重试"));
|
||||
}, queueTimeoutMs),
|
||||
};
|
||||
state.queue.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
const requestSchema = z.object({
|
||||
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
||||
negativePrompt: z.string().trim().optional(),
|
||||
@ -197,8 +294,10 @@ export async function POST(request: Request) {
|
||||
|
||||
const startedAt = Date.now();
|
||||
let logId: string | null = null;
|
||||
let releaseExternalImageApiSlot: (() => void) | null = null;
|
||||
|
||||
try {
|
||||
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
|
||||
const payload = await parseRequestPayload(request);
|
||||
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
|
||||
const candidateModels = await buildExternalImageApiModelCandidates({
|
||||
@ -245,7 +344,6 @@ export async function POST(request: Request) {
|
||||
|
||||
const generationResult = await generateImagesWithFailover({
|
||||
candidates: candidateModels,
|
||||
ownerKey: `external-image-api:${authResult.accessKey.id}`,
|
||||
prompt: payload.prompt,
|
||||
negativePrompt: payload.negativePrompt || null,
|
||||
imageCount: payload.imageCount || 1,
|
||||
@ -369,6 +467,21 @@ export async function POST(request: Request) {
|
||||
.where(eq(externalImageApiRequestLog.id, logId));
|
||||
}
|
||||
|
||||
if (error instanceof ExternalImageApiBusyError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": String(error.retryAfterSeconds),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
@ -376,5 +489,9 @@ export async function POST(request: Request) {
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
} finally {
|
||||
if (releaseExternalImageApiSlot) {
|
||||
releaseExternalImageApiSlot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,10 +83,6 @@ async function readLocalFallbackFavicon() {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
if (process.env.CANVAS_E2E_LOCAL === "true") {
|
||||
return await readLocalFallbackFavicon();
|
||||
}
|
||||
|
||||
const preferDark = requestUrl.searchParams.get("theme") === "dark";
|
||||
const settings = await getSiteSettings();
|
||||
const configuredFavicon = String(settings?.favicon || "").trim();
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type AccessLogSeverity,
|
||||
recordAccessIpEvent,
|
||||
} from "@/lib/security/access-log";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 10;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
eventType: z.string().trim().max(32).optional(),
|
||||
ip: z.string().trim().max(128).optional().nullable(),
|
||||
userId: z.string().trim().uuid().optional().nullable(),
|
||||
userEmail: z.string().trim().max(128).optional().nullable(),
|
||||
userRole: z.string().trim().max(32).optional().nullable(),
|
||||
method: z.string().trim().max(12).optional().nullable(),
|
||||
path: z.string().trim().max(256).optional().nullable(),
|
||||
routeGroup: z.string().trim().max(64).optional().nullable(),
|
||||
statusCode: z.coerce.number().int().min(100).max(599).optional().nullable(),
|
||||
outcome: z.string().trim().max(32).optional().nullable(),
|
||||
severity: z.enum(["info", "watch", "high", "critical"]).optional(),
|
||||
reasons: z.array(z.string().trim().max(80)).max(12).optional(),
|
||||
userAgent: z.string().trim().max(512).optional().nullable(),
|
||||
referer: z.string().trim().max(512).optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
occurredAt: z.union([z.string(), z.number(), z.date()]).optional(),
|
||||
});
|
||||
|
||||
const suspiciousPathPatterns = [
|
||||
/\.env/i,
|
||||
/\/\.git/i,
|
||||
/\/wp-admin/i,
|
||||
/\/phpmyadmin/i,
|
||||
/\/actuator/i,
|
||||
/\/vendor\/phpunit/i,
|
||||
/\/eval-stdin\.php/i,
|
||||
/\/containers\/json/i,
|
||||
/\/index\.php/i,
|
||||
];
|
||||
|
||||
const suspiciousUserAgentPatterns = [
|
||||
/libredtail|masscan|zgrab|sqlmap|nikto|acunetix|nessus/i,
|
||||
/wget|curl|python-requests|go-http-client/i,
|
||||
/nuclei|crawlergo|httpx/i,
|
||||
];
|
||||
|
||||
function pushReason(reasons: string[], reason: string) {
|
||||
if (!reasons.includes(reason)) reasons.push(reason);
|
||||
}
|
||||
|
||||
function rankSeverity(value: AccessLogSeverity) {
|
||||
return { info: 0, watch: 1, high: 2, critical: 3 }[value];
|
||||
}
|
||||
|
||||
function maxSeverity(
|
||||
current: AccessLogSeverity,
|
||||
next: AccessLogSeverity
|
||||
): AccessLogSeverity {
|
||||
return rankSeverity(next) > rankSeverity(current) ? next : current;
|
||||
}
|
||||
|
||||
function classifyPayload(
|
||||
payload: z.infer<typeof payloadSchema>
|
||||
): { severity: AccessLogSeverity; reasons: string[] } {
|
||||
let severity: AccessLogSeverity = payload.severity || "info";
|
||||
const reasons = [...(payload.reasons || [])];
|
||||
const path = payload.path || "";
|
||||
const userAgent = payload.userAgent || "";
|
||||
const statusCode = payload.statusCode || 0;
|
||||
const outcome = payload.outcome || "allowed";
|
||||
|
||||
if (outcome === "rate_limited") {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "触发访问频率限制");
|
||||
}
|
||||
|
||||
if (outcome === "unauthorized" || statusCode === 401 || statusCode === 403) {
|
||||
severity = maxSeverity(severity, "watch");
|
||||
pushReason(reasons, "出现未授权访问");
|
||||
}
|
||||
|
||||
if (statusCode === 429) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "返回 429 限流");
|
||||
} else if (statusCode >= 500) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "触发服务端错误");
|
||||
} else if (statusCode >= 400) {
|
||||
severity = maxSeverity(severity, "watch");
|
||||
pushReason(reasons, "返回 4xx 异常状态");
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/auth") && statusCode >= 400) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "认证接口失败");
|
||||
}
|
||||
|
||||
if (suspiciousPathPatterns.some((pattern) => pattern.test(path))) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
pushReason(reasons, "命中敏感探测路径");
|
||||
}
|
||||
|
||||
if (suspiciousUserAgentPatterns.some((pattern) => pattern.test(userAgent))) {
|
||||
severity = maxSeverity(severity, "watch");
|
||||
pushReason(reasons, "User-Agent 异常");
|
||||
}
|
||||
|
||||
if (reasons.includes("静态黑名单命中但未封禁")) {
|
||||
severity = maxSeverity(severity, "high");
|
||||
}
|
||||
|
||||
return {
|
||||
severity,
|
||||
reasons: Array.from(new Set(reasons)).slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedSecret() {
|
||||
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const expectedSecret = getExpectedSecret();
|
||||
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
|
||||
|
||||
if (!expectedSecret || providedSecret !== expectedSecret) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const raw = await request.json().catch(() => null);
|
||||
const parsed = payloadSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const classification = classifyPayload(parsed.data);
|
||||
await recordAccessIpEvent({
|
||||
...parsed.data,
|
||||
severity: classification.severity,
|
||||
reasons: classification.reasons,
|
||||
}).catch((error) => {
|
||||
console.warn("[AccessIpEvent] failed to record access event", error);
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { accessIpBlocklist } from "@/lib/db/schema";
|
||||
import { matchManualIpBlock } from "@/lib/security/manual-ip-blocklist";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 5;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
ip: z.string().trim().max(128).optional().nullable(),
|
||||
});
|
||||
|
||||
const BLOCKLIST_UNAVAILABLE_CACHE_MS = 5 * 60 * 1000;
|
||||
|
||||
let blocklistUnavailableUntil = 0;
|
||||
let blocklistMissingTableWarned = false;
|
||||
|
||||
function getExpectedSecret() {
|
||||
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown) {
|
||||
const directCode =
|
||||
error && typeof error === "object"
|
||||
? (error as { code?: unknown }).code
|
||||
: null;
|
||||
if (typeof directCode === "string") return directCode;
|
||||
|
||||
const cause =
|
||||
error && typeof error === "object"
|
||||
? (error as { cause?: unknown }).cause
|
||||
: null;
|
||||
const causeCode =
|
||||
cause && typeof cause === "object"
|
||||
? (cause as { code?: unknown }).code
|
||||
: null;
|
||||
return typeof causeCode === "string" ? causeCode : null;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
const messages: string[] = [];
|
||||
if (error instanceof Error) messages.push(error.message);
|
||||
|
||||
const cause =
|
||||
error && typeof error === "object"
|
||||
? (error as { cause?: unknown }).cause
|
||||
: null;
|
||||
if (cause instanceof Error) messages.push(cause.message);
|
||||
|
||||
if (!messages.length) messages.push(String(error));
|
||||
return messages.join("\n");
|
||||
}
|
||||
|
||||
function isMissingAccessIpBlocklistTableError(error: unknown) {
|
||||
if (getErrorCode(error) === "42P01") return true;
|
||||
|
||||
const message = getErrorMessage(error);
|
||||
return (
|
||||
message.includes('relation "AccessIpBlocklist" does not exist') ||
|
||||
(message.includes("AccessIpBlocklist") && message.includes("does not exist"))
|
||||
);
|
||||
}
|
||||
|
||||
function markBlocklistUnavailable(error: unknown) {
|
||||
blocklistUnavailableUntil = Date.now() + BLOCKLIST_UNAVAILABLE_CACHE_MS;
|
||||
if (blocklistMissingTableWarned) return;
|
||||
|
||||
blocklistMissingTableWarned = true;
|
||||
console.warn(
|
||||
"[AccessIpBlocklist] table is unavailable; manual IP block checks will be skipped temporarily",
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const expectedSecret = getExpectedSecret();
|
||||
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
|
||||
|
||||
if (!expectedSecret || providedSecret !== expectedSecret) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const parsed = payloadSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success || !parsed.data.ip) {
|
||||
return NextResponse.json({ blocked: false });
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.MANUAL_IP_BLOCKLIST_ENABLED === "false" ||
|
||||
Date.now() < blocklistUnavailableUntil
|
||||
) {
|
||||
return NextResponse.json({ blocked: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const entries = await db
|
||||
.select({
|
||||
id: accessIpBlocklist.id,
|
||||
ip: accessIpBlocklist.ip,
|
||||
reason: accessIpBlocklist.reason,
|
||||
expiresAt: accessIpBlocklist.expiresAt,
|
||||
})
|
||||
.from(accessIpBlocklist)
|
||||
.where(
|
||||
and(
|
||||
eq(accessIpBlocklist.isActive, true),
|
||||
or(
|
||||
isNull(accessIpBlocklist.expiresAt),
|
||||
gt(accessIpBlocklist.expiresAt, now)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1000);
|
||||
|
||||
const activeEntries = entries.filter((entry) => entry.ip && entry.id);
|
||||
const matched = matchManualIpBlock(parsed.data.ip, activeEntries);
|
||||
|
||||
return NextResponse.json({
|
||||
blocked: Boolean(matched),
|
||||
entry: matched
|
||||
? {
|
||||
id: matched.id,
|
||||
ip: matched.ip,
|
||||
reason: matched.reason || null,
|
||||
expiresAt: matched.expiresAt || null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMissingAccessIpBlocklistTableError(error)) {
|
||||
markBlocklistUnavailable(error);
|
||||
return NextResponse.json({ blocked: false });
|
||||
}
|
||||
console.warn("[AccessIpBlocklist] internal check failed", error);
|
||||
return NextResponse.json({ blocked: false });
|
||||
}
|
||||
}
|
||||
@ -1,138 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderDefaultBaseUrl } from "@/lib/ai/provider-defaults";
|
||||
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
|
||||
import { getMultimodalAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { isPascalInternalRequest } from "@/lib/pascal/internal-auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 600;
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const logger = createLogger("pascal-model-gateway");
|
||||
const OPENAI_COMPATIBLE_PROVIDER_TYPES = new Set([
|
||||
"alibaba",
|
||||
"custom",
|
||||
"openai",
|
||||
"zhipu",
|
||||
]);
|
||||
|
||||
function upstreamChatUrl(baseUrl: string) {
|
||||
const normalized = baseUrl.replace(/\/+$/, "");
|
||||
return normalized.endsWith("/chat/completions")
|
||||
? normalized
|
||||
: `${normalized}/chat/completions`;
|
||||
}
|
||||
|
||||
function openAiError(message: string, status: number) {
|
||||
return NextResponse.json(
|
||||
{ error: { message, type: "pascal_gateway_error" } },
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
async function getConfiguredMultimodalModel() {
|
||||
const candidates = await getMultimodalAiModelsWithProvider();
|
||||
return candidates.find(({ provider }) =>
|
||||
OPENAI_COMPATIBLE_PROVIDER_TYPES.has(provider.type)
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isPascalInternalRequest(request)) {
|
||||
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const configured = await getConfiguredMultimodalModel();
|
||||
if (!configured) {
|
||||
return NextResponse.json({ configured: false, selection: "multimodal" });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
configured: true,
|
||||
compatible: OPENAI_COMPATIBLE_PROVIDER_TYPES.has(configured.provider.type),
|
||||
model: configured.model.displayName || configured.model.modelId,
|
||||
provider: configured.provider.name,
|
||||
providerType: configured.provider.type,
|
||||
selection: "multimodal",
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isPascalInternalRequest(request)) {
|
||||
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = await request.json();
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return openAiError("Invalid request body.", 400);
|
||||
}
|
||||
body = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return openAiError("Invalid JSON body.", 400);
|
||||
}
|
||||
|
||||
const configured = await getConfiguredMultimodalModel();
|
||||
if (!configured) {
|
||||
return openAiError("No enabled multimodal model is configured.", 503);
|
||||
}
|
||||
|
||||
const { model, provider } = configured;
|
||||
if (!OPENAI_COMPATIBLE_PROVIDER_TYPES.has(provider.type)) {
|
||||
return openAiError(
|
||||
`The configured multimodal provider type '${provider.type}' is not OpenAI-compatible.`,
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
provider.baseUrl?.trim() || getProviderDefaultBaseUrl(provider.type) || "";
|
||||
const runtimeModelId = resolveModelRuntimeId(model, {
|
||||
providerType: provider.type,
|
||||
});
|
||||
if (!baseUrl || !runtimeModelId || !provider.apiKey) {
|
||||
return openAiError("The configured multimodal model is incomplete.", 503);
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const upstream = await fetch(upstreamChatUrl(baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...body, model: runtimeModelId }),
|
||||
cache: "no-store",
|
||||
signal: request.signal,
|
||||
});
|
||||
|
||||
logger.info("upstream completed", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
modelId: model.id,
|
||||
providerId: provider.id,
|
||||
status: upstream.status,
|
||||
});
|
||||
|
||||
const headers = new Headers({
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Content-Type":
|
||||
upstream.headers.get("content-type") || "application/json; charset=utf-8",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
return new Response(upstream.body, { status: upstream.status, headers });
|
||||
} catch (error) {
|
||||
logger.error("upstream failed", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
modelId: model.id,
|
||||
providerId: provider.id,
|
||||
});
|
||||
return openAiError(
|
||||
"The configured multimodal model is temporarily unavailable.",
|
||||
502
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { isPascalInternalRequest } from "@/lib/pascal/internal-auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isPascalInternalRequest(request)) {
|
||||
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id?.trim();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"X-Pascal-User-Id": userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { createCanvasCapabilityMcpServer } from "@/lib/canvas/capabilities/mcp-server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 600;
|
||||
|
||||
async function handleMcpRequest(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "未登录,无法访问画布 MCP 服务" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id: projectId } = await params;
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
const server = await createCanvasCapabilityMcpServer({
|
||||
projectId,
|
||||
requestHeaders: request.headers,
|
||||
requestUrl: request.url,
|
||||
userId: session.user.id,
|
||||
});
|
||||
await server.connect(transport);
|
||||
return transport.handleRequest(request);
|
||||
}
|
||||
|
||||
export const POST = handleMcpRequest;
|
||||
export const GET = handleMcpRequest;
|
||||
export const DELETE = handleMcpRequest;
|
||||
@ -32,15 +32,6 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
|
||||
const falTaskLastPolledAt = new Map<string, number>();
|
||||
const falTaskSyncInFlight = new Set<string>();
|
||||
|
||||
function toPublicVideoMetadata(value: DBMessage["videoMetadata"]) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const metadata = value as Record<string, unknown>;
|
||||
if (metadata.taskKind !== "clawd_chat") return value;
|
||||
const publicMetadata = { ...metadata };
|
||||
delete publicMetadata.upstreamTaskKey;
|
||||
return publicMetadata;
|
||||
}
|
||||
|
||||
function createEmptyMessagesResponse() {
|
||||
return NextResponse.json(
|
||||
{ messages: [] },
|
||||
@ -286,7 +277,7 @@ export async function GET(request: NextRequest) {
|
||||
createdAt: msg.createdAt,
|
||||
parts: parts,
|
||||
attachments: msg.attachments || [],
|
||||
videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null,
|
||||
videoMetadata: msg.videoMetadata || null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -1,148 +0,0 @@
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createUserAccessKey,
|
||||
listUserAccessKeys,
|
||||
} from "@/lib/access-keys/service";
|
||||
import {
|
||||
MIN_PROVISIONING_SECRET_LENGTH,
|
||||
parseProvisioningBearerSecret,
|
||||
verifyProvisioningSecret,
|
||||
} from "@/lib/access-keys/provisioning-secret";
|
||||
import { db } from "@/lib/db";
|
||||
import { user } from "@/lib/db/schema";
|
||||
|
||||
const provisionAccessKeySchema = z.object({
|
||||
dingTalkUsername: z.string().trim().min(1).max(100),
|
||||
dingTalkUserId: z.string().trim().min(1).max(128).optional(),
|
||||
keyName: z.string().trim().min(1).max(100).optional(),
|
||||
scopes: z
|
||||
.array(z.enum(["canvas:read", "canvas:write"]))
|
||||
.min(1)
|
||||
.optional(),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
function jsonError(error: string, code: string, status: number) {
|
||||
return Response.json(
|
||||
{ success: false, error, code },
|
||||
{ status, headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const expectedSecret = String(
|
||||
process.env.MCP_KEY_PROVISIONING_SECRET || ""
|
||||
).trim();
|
||||
if (expectedSecret.length < MIN_PROVISIONING_SECRET_LENGTH) {
|
||||
return jsonError(
|
||||
"Access Key provisioning is not configured",
|
||||
"PROVISIONING_NOT_CONFIGURED",
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const providedSecret = parseProvisioningBearerSecret(
|
||||
request.headers.get("authorization")
|
||||
);
|
||||
if (!verifyProvisioningSecret(providedSecret, expectedSecret)) {
|
||||
return jsonError(
|
||||
"Missing or invalid provisioning credential",
|
||||
"PROVISIONING_UNAUTHORIZED",
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = provisionAccessKeySchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return jsonError(
|
||||
"Invalid provisioning request",
|
||||
"PROVISIONING_REQUEST_INVALID",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const conditions = [
|
||||
eq(user.name, body.dingTalkUsername),
|
||||
eq(user.isActive, true),
|
||||
isNotNull(user.dingtalkUserId),
|
||||
];
|
||||
if (body.dingTalkUserId) {
|
||||
conditions.push(eq(user.dingtalkUserId, body.dingTalkUserId));
|
||||
}
|
||||
|
||||
const matches = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
dingtalkUserId: user.dingtalkUserId,
|
||||
})
|
||||
.from(user)
|
||||
.where(and(...conditions))
|
||||
.limit(2);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return jsonError(
|
||||
"No active DingTalk user matched the supplied identity",
|
||||
"DINGTALK_USER_NOT_FOUND",
|
||||
404
|
||||
);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return jsonError(
|
||||
"DingTalk username is not unique; provide dingTalkUserId",
|
||||
"DINGTALK_USERNAME_AMBIGUOUS",
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const matchedUser = matches[0];
|
||||
const existingKeys = await listUserAccessKeys(matchedUser.id);
|
||||
const activeKeyCount = existingKeys.filter(
|
||||
(item) =>
|
||||
!item.revokedAt &&
|
||||
(!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
|
||||
).length;
|
||||
if (activeKeyCount >= 10) {
|
||||
return jsonError(
|
||||
"The user already has 10 active Access Keys",
|
||||
"ACCESS_KEY_LIMIT_REACHED",
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const accessKey = await createUserAccessKey({
|
||||
userId: matchedUser.id,
|
||||
name: body.keyName || "第三方 OpenClaw",
|
||||
scopes: body.scopes,
|
||||
expiresInDays: body.expiresInDays,
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
user: matchedUser,
|
||||
accessKey,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return jsonError(
|
||||
"Failed to provision Access Key",
|
||||
"ACCESS_KEY_PROVISION_FAILED",
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
|
||||
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
||||
import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
openApiErrorResponse,
|
||||
requireOpenApiPrincipal,
|
||||
} from "@/lib/access-keys/openapi-auth";
|
||||
|
||||
const canvasIdSchema = z.string().uuid();
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const principal = await requireOpenApiPrincipal(request, "canvas:write");
|
||||
const { id: rawId } = await params;
|
||||
const parsedId = canvasIdSchema.safeParse(rawId);
|
||||
if (!parsedId.success) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Invalid canvas ID",
|
||||
code: "INVALID_CANVAS_ID",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const id = parsedId.data;
|
||||
const response = await handleCanvasAgentOperationRouteRequest({
|
||||
userId: principal.userId,
|
||||
projectId: id,
|
||||
readBody: () => request.json(),
|
||||
features: await getCanvasAgentFeatureFlagsFromSystemSettings(),
|
||||
applyOperation: applyCanvasOperationPlan,
|
||||
logger: console,
|
||||
});
|
||||
|
||||
return Response.json(response.body, {
|
||||
status: response.status,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
return openApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import {
|
||||
openApiErrorResponse,
|
||||
requireOpenApiPrincipal,
|
||||
} from "@/lib/access-keys/openapi-auth";
|
||||
|
||||
const canvasIdSchema = z.string().uuid();
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const principal = await requireOpenApiPrincipal(request, "canvas:read");
|
||||
const { id: rawId } = await params;
|
||||
const parsedId = canvasIdSchema.safeParse(rawId);
|
||||
if (!parsedId.success) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Invalid canvas ID",
|
||||
code: "INVALID_CANVAS_ID",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const id = parsedId.data;
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, principal.userId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!project) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Canvas not found", code: "CANVAS_NOT_FOUND" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ success: true, data: project },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
return openApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import {
|
||||
openApiErrorResponse,
|
||||
requireOpenApiPrincipal,
|
||||
} from "@/lib/access-keys/openapi-auth";
|
||||
|
||||
function normalizeWorkspaceType(value: string | null) {
|
||||
return value === "agent" || value === "video" ? value : "canvas";
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const principal = await requireOpenApiPrincipal(request, "canvas:read");
|
||||
const url = new URL(request.url);
|
||||
const workspaceType = normalizeWorkspaceType(
|
||||
url.searchParams.get("workspaceType")
|
||||
);
|
||||
const requestedLimit = Number(url.searchParams.get("limit") || 24);
|
||||
const limit = Number.isFinite(requestedLimit)
|
||||
? Math.min(50, Math.max(1, Math.round(requestedLimit)))
|
||||
: 24;
|
||||
|
||||
const projects = await db
|
||||
.select({
|
||||
id: canvasProject.id,
|
||||
title: canvasProject.title,
|
||||
description: canvasProject.description,
|
||||
thumbnail: canvasProject.thumbnail,
|
||||
workspaceType: canvasProject.workspaceType,
|
||||
createdAt: canvasProject.createdAt,
|
||||
updatedAt: canvasProject.updatedAt,
|
||||
})
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.userId, principal.userId),
|
||||
eq(canvasProject.workspaceType, workspaceType)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(canvasProject.updatedAt))
|
||||
.limit(limit);
|
||||
|
||||
return Response.json(
|
||||
{ success: true, data: projects },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
return openApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { user } from "@/lib/db/schema";
|
||||
import {
|
||||
openApiErrorResponse,
|
||||
requireOpenApiPrincipal,
|
||||
} from "@/lib/access-keys/openapi-auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const principal = await requireOpenApiPrincipal(request, "canvas:read");
|
||||
const [currentUser] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
department: user.department,
|
||||
role: user.role,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, principal.userId))
|
||||
.limit(1);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
user: currentUser,
|
||||
accessKey: {
|
||||
id: principal.accessKeyId,
|
||||
name: principal.accessKeyName,
|
||||
scopes: principal.scopes,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
return openApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@ -27,12 +21,10 @@ interface PickObjectRequest {
|
||||
imageData: string; // 带红色标记的图片 base64
|
||||
clickX: number; // 点击位置 X (0-1)
|
||||
clickY: number; // 点击位置 Y (0-1)
|
||||
mode?: "object" | "video-canvas-mark";
|
||||
}
|
||||
|
||||
interface PickObjectResult {
|
||||
name: string;
|
||||
labels?: string[];
|
||||
position: string;
|
||||
confidence: number;
|
||||
boundingBox: {
|
||||
@ -127,51 +119,7 @@ async function uploadVisionAnalysisImage(buffer: Buffer, filenamePrefix: string)
|
||||
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")}`;
|
||||
}
|
||||
|
||||
async function normalizeImageInput(
|
||||
imageData: string,
|
||||
options?: { clickX: number; clickY: number; markPoint: boolean }
|
||||
): Promise<string> {
|
||||
async function normalizeImageInput(imageData: string): Promise<string> {
|
||||
const raw = String(imageData || "").trim();
|
||||
if (!raw) throw new Error("缺少图片数据");
|
||||
|
||||
@ -197,10 +145,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 uploadVisionAnalysisImage(Buffer.from(arrayBuffer), "pick-object-url");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@ -215,9 +160,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 uploadVisionAnalysisImage(buffer, "pick-object-base64");
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@ -231,7 +174,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 +204,12 @@ 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);
|
||||
|
||||
logger.info("vision model selected", { model: "qwen3-vl" });
|
||||
|
||||
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 prompt = `
|
||||
图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。
|
||||
|
||||
【重要】
|
||||
@ -311,24 +232,12 @@ 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;
|
||||
|
||||
logger.info("vision model call started");
|
||||
|
||||
// 调用视觉模型,发送带红色标记的图片
|
||||
const { text: responseText, model } = await callQwen3Vl({
|
||||
imageUrl: normalizedImage,
|
||||
model:
|
||||
mode === "video-canvas-mark"
|
||||
? elementMarkDefinition?.model || videoCanvasConfig?.defaultMultimodalModel
|
||||
: undefined,
|
||||
prompt,
|
||||
temperature: 0.1,
|
||||
maxTokens: 300,
|
||||
@ -357,17 +266,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 +292,6 @@ ${POSITION_OPTIONS.join('、')}
|
||||
|
||||
result = {
|
||||
name,
|
||||
labels: mode === "video-canvas-mark" ? (labels.length ? labels : [name]) : undefined,
|
||||
position,
|
||||
confidence,
|
||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
|
||||
@ -414,7 +312,6 @@ ${POSITION_OPTIONS.join('、')}
|
||||
|
||||
result = {
|
||||
name: '物品',
|
||||
labels: mode === "video-canvas-mark" ? ['物品'] : undefined,
|
||||
position,
|
||||
confidence: 0.5,
|
||||
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },
|
||||
|
||||
@ -28,7 +28,6 @@ import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||
import { createVip98k } from "@/lib/ai/providers/vip98k";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import {
|
||||
canProcessSceneFusionProviderType,
|
||||
@ -1566,18 +1565,6 @@ async function processSceneWithProvider(
|
||||
imageGenConfig,
|
||||
);
|
||||
|
||||
case "vip98k":
|
||||
return processWithVip98k(
|
||||
productImageUrl,
|
||||
sceneImageUrl,
|
||||
prompt,
|
||||
provider,
|
||||
runtimeModelId || modelId,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
imageGenConfig,
|
||||
);
|
||||
|
||||
case "gemini":
|
||||
case "gemini-image":
|
||||
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||
@ -1946,45 +1933,6 @@ async function processWithAtlasCloud(
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
async function processWithVip98k(
|
||||
productImageUrl: string,
|
||||
sceneImageUrl: string,
|
||||
prompt: string,
|
||||
provider: { baseUrl: string; apiKey: string },
|
||||
modelId: string,
|
||||
aspectRatio?: string,
|
||||
imageSize?: string,
|
||||
imageGenConfig?: Record<string, any> | null
|
||||
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||
logger.info("provider_processing", { providerType: "vip98k", modelId });
|
||||
|
||||
const vip98kProvider = createVip98k({
|
||||
baseURL: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
const result = await vip98kProvider.chat(modelId, imageGenConfig || null).doGenerate({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
imageParams: {
|
||||
aspectRatio: aspectRatio || "auto",
|
||||
imageSize: imageSize || "2K",
|
||||
imageCount: 1,
|
||||
},
|
||||
imageGenConfig,
|
||||
});
|
||||
|
||||
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用既梦/火山模型处理
|
||||
*/
|
||||
|
||||
@ -579,10 +579,6 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
||||
favicon: null,
|
||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: "立即升级",
|
||||
bannerEnabled: true,
|
||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: null,
|
||||
bannerActionText: "立即查看",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||
surpriseCategories: ["全部", "精选"],
|
||||
@ -901,7 +897,6 @@ async function getScopedPublicSiteSettings(
|
||||
bannerText:
|
||||
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: typedSettings?.bannerLink || null,
|
||||
bannerActionText: typedSettings?.bannerActionText || "立即查看",
|
||||
lovartComposerPrefix: normalizeLovartComposerPrefix(
|
||||
valueByKey.get(LOVART_COMPOSER_PREFIX_KEY)
|
||||
),
|
||||
@ -944,11 +939,6 @@ async function getScopedPublicSiteSettings(
|
||||
|
||||
return createScopedSiteSettingsResponse(scope, {
|
||||
...createBrandingSettings(typedSettings),
|
||||
bannerEnabled: typedSettings?.bannerEnabled ?? true,
|
||||
bannerText:
|
||||
typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: typedSettings?.bannerLink || null,
|
||||
bannerActionText: typedSettings?.bannerActionText || "立即查看",
|
||||
lovartPromoText:
|
||||
typedSettings?.lovartPromoText ||
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
@ -1020,32 +1010,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 =
|
||||
@ -1480,7 +1444,6 @@ export async function GET(request: NextRequest) {
|
||||
bannerEnabled: true,
|
||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: null,
|
||||
bannerActionText: "立即查看",
|
||||
toolbox1Name: "工具箱",
|
||||
toolbox1Enabled: true,
|
||||
toolbox2Name: "工具箱2",
|
||||
@ -1662,7 +1625,6 @@ const updateSchema = z.object({
|
||||
bannerEnabled: z.boolean().optional(),
|
||||
bannerText: z.string().optional(),
|
||||
bannerLink: z.string().nullable().optional(),
|
||||
bannerActionText: z.string().max(32).optional(),
|
||||
toolbox1Name: z.string().max(64).optional(),
|
||||
toolbox1Enabled: z.boolean().optional(),
|
||||
toolbox2Name: z.string().max(64).optional(),
|
||||
@ -3123,7 +3085,6 @@ export async function PUT(request: NextRequest) {
|
||||
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
|
||||
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: siteSettingsPayload.bannerLink,
|
||||
bannerActionText: siteSettingsPayload.bannerActionText ?? "立即查看",
|
||||
toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱",
|
||||
toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true,
|
||||
toolbox2Name: siteSettingsPayload.toolbox2Name ?? "工具箱2",
|
||||
|
||||
@ -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),
|
||||
]);
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { revokeUserAccessKey } from "@/lib/access-keys/service";
|
||||
|
||||
const idSchema = z.string().uuid();
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.type === "guest") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请先登录正式账号" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsedId = idSchema.safeParse((await params).id);
|
||||
if (!parsedId.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Access Key ID 无效" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const revoked = await revokeUserAccessKey({
|
||||
userId: session.user.id,
|
||||
accessKeyId: parsedId.data,
|
||||
});
|
||||
if (!revoked) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Access Key 不存在" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createUserAccessKey,
|
||||
listUserAccessKeys,
|
||||
} from "@/lib/access-keys/service";
|
||||
|
||||
const createAccessKeySchema = z.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
scopes: z
|
||||
.array(z.enum(["canvas:read", "canvas:write"]))
|
||||
.min(1)
|
||||
.optional(),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.type === "guest") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请先登录正式账号" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const keys = await listUserAccessKeys(session.user.id);
|
||||
return NextResponse.json(
|
||||
{ success: true, data: keys },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.type === "guest") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请先登录正式账号" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const existingKeys = await listUserAccessKeys(session.user.id);
|
||||
const activeKeyCount = existingKeys.filter(
|
||||
(item) =>
|
||||
!item.revokedAt &&
|
||||
(!item.expiresAt || new Date(item.expiresAt).getTime() > Date.now())
|
||||
).length;
|
||||
if (activeKeyCount >= 10) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "最多保留 10 个有效 Access Key" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = createAccessKeySchema.parse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
const created = await createUserAccessKey({
|
||||
userId: session.user.id,
|
||||
name: body?.name,
|
||||
scopes: body?.scopes,
|
||||
expiresInDays: body?.expiresInDays,
|
||||
});
|
||||
return NextResponse.json({ success: true, data: created }, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "创建 Access Key 失败",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -17,35 +17,12 @@ 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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -12,10 +12,6 @@ import {
|
||||
} from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||
import { FAL_NANO_BANANA_2_EDIT_MODEL_ID } from "@/lib/ai/fal-image-models";
|
||||
|
||||
export const maxDuration = 600;
|
||||
|
||||
// PicWish API 需要公网可访问的 HTTP/HTTPS URL
|
||||
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
|
||||
@ -119,10 +115,11 @@ function buildWatermarkTaskPendingResponse(taskId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function persistWatermarkResult(params: {
|
||||
processedImageUrl: string;
|
||||
async function finalizeWatermarkTaskResult(params: {
|
||||
taskId: string;
|
||||
userId: string;
|
||||
startedAt?: number;
|
||||
apiKey: string;
|
||||
sourceImageUrls?: string[];
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
@ -130,8 +127,17 @@ async function persistWatermarkResult(params: {
|
||||
thumbnail_url: string;
|
||||
processing_time?: number;
|
||||
}> {
|
||||
const pollResult = await pollForResult(params.taskId, params.apiKey, WATERMARK_SYNC_WAIT_TIMEOUT_MS);
|
||||
if (pollResult.pending) {
|
||||
throw new Error("WATERMARK_TASK_PENDING");
|
||||
}
|
||||
if (!pollResult.success || !pollResult.imageUrl) {
|
||||
throw new Error(pollResult.error || "去水印处理失败");
|
||||
}
|
||||
|
||||
const processedImageUrl = pollResult.imageUrl;
|
||||
try {
|
||||
const imageResponse = await fetch(params.processedImageUrl);
|
||||
const imageResponse = await fetch(processedImageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error("下载处理后的图片失败");
|
||||
}
|
||||
@ -211,7 +217,7 @@ async function persistWatermarkResult(params: {
|
||||
}
|
||||
}
|
||||
} catch (assetError) {
|
||||
console.error("[WatermarkRemove] MediaAsset 注册失败:", assetError);
|
||||
console.error("[WatermarkRemove] ⚠️ MediaAsset 注册失败:", assetError);
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,85 +232,14 @@ async function persistWatermarkResult(params: {
|
||||
console.error("[WatermarkRemove] 上传结果失败,回退原始URL:", uploadError);
|
||||
return {
|
||||
success: true,
|
||||
original_url: params.processedImageUrl,
|
||||
thumbnail_url: params.processedImageUrl,
|
||||
original_url: processedImageUrl,
|
||||
thumbnail_url: processedImageUrl,
|
||||
processing_time:
|
||||
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseFalWatermarkFallback(status: number, detail: string) {
|
||||
return (
|
||||
status === 401 ||
|
||||
status === 402 ||
|
||||
status === 403 ||
|
||||
status === 429 ||
|
||||
status >= 500 ||
|
||||
/quota|credit|balance|exceed|expired|invalid|unauthor/i.test(detail)
|
||||
);
|
||||
}
|
||||
|
||||
async function removeWatermarkWithFal(params: {
|
||||
imageUrl: string;
|
||||
userId: string;
|
||||
startedAt: number;
|
||||
sourceImageUrls?: string[];
|
||||
}) {
|
||||
console.warn("[WatermarkRemove] AOS unavailable; using FAL Nano Banana 2 edit fallback");
|
||||
const result = await generateCanvasImage({
|
||||
prompt: [
|
||||
"仅移除图片中后期叠加的水印、平台角标和覆盖式文字标记。",
|
||||
"必须完整保留原图主体、商品、背景、构图、尺寸、颜色、光影和清晰度。",
|
||||
"用周围真实纹理自然补全被遮挡区域,不得新增文字、标志或其他物体。",
|
||||
].join(""),
|
||||
model: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
|
||||
aspectRatio: "auto",
|
||||
imageSize: "2K",
|
||||
imageCount: 1,
|
||||
referenceImages: [params.imageUrl],
|
||||
allowFailover: false,
|
||||
debugLabel: "watermark-remove-fal-fallback",
|
||||
});
|
||||
const processedImageUrl = result.images[0]?.originalUrl;
|
||||
if (!processedImageUrl) {
|
||||
throw new Error("FAL 去水印未返回可用图片");
|
||||
}
|
||||
return persistWatermarkResult({
|
||||
processedImageUrl,
|
||||
userId: params.userId,
|
||||
startedAt: params.startedAt,
|
||||
sourceImageUrls: params.sourceImageUrls,
|
||||
});
|
||||
}
|
||||
|
||||
async function finalizeWatermarkTaskResult(params: {
|
||||
taskId: string;
|
||||
userId: string;
|
||||
startedAt?: number;
|
||||
apiKey: string;
|
||||
sourceImageUrls?: string[];
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
original_url: string;
|
||||
thumbnail_url: string;
|
||||
processing_time?: number;
|
||||
}> {
|
||||
const pollResult = await pollForResult(params.taskId, params.apiKey, WATERMARK_SYNC_WAIT_TIMEOUT_MS);
|
||||
if (pollResult.pending) {
|
||||
throw new Error("WATERMARK_TASK_PENDING");
|
||||
}
|
||||
if (!pollResult.success || !pollResult.imageUrl) {
|
||||
throw new Error(pollResult.error || "去水印处理失败");
|
||||
}
|
||||
return persistWatermarkResult({
|
||||
processedImageUrl: pollResult.imageUrl,
|
||||
userId: params.userId,
|
||||
startedAt: params.startedAt,
|
||||
sourceImageUrls: params.sourceImageUrls,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
@ -371,6 +306,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 获取API配置
|
||||
const config = await getWatermarkRemoveConfig();
|
||||
if (!config?.apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "去水印服务未配置,请联系管理员在系统设置中配置API密钥" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[WatermarkRemove] 使用原图URL:", image_url.substring(0, 100) + "...");
|
||||
|
||||
// 处理图片URL,确保获取公网可访问的URL
|
||||
@ -454,24 +396,6 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const sourceImageUrls =
|
||||
typeof image_url === "string" && /^https?:\/\//i.test(image_url)
|
||||
? [image_url]
|
||||
: undefined;
|
||||
|
||||
if (!config?.apiKey) {
|
||||
const fallbackResult = await removeWatermarkWithFal({
|
||||
imageUrl: publicImageUrl,
|
||||
userId: session.user.id,
|
||||
startedAt: startTime,
|
||||
sourceImageUrls,
|
||||
});
|
||||
if (pointsCharge.chargeable) {
|
||||
await pointsCharge.consume({ note: "FAL 去水印备用链路消耗积分" });
|
||||
}
|
||||
return NextResponse.json(fallbackResult);
|
||||
}
|
||||
|
||||
// 创建去水印任务
|
||||
const formData = new FormData();
|
||||
formData.append("sync", "0");
|
||||
@ -491,24 +415,8 @@ export async function POST(request: NextRequest) {
|
||||
if (!createResponse.ok) {
|
||||
const errorText = await createResponse.text();
|
||||
console.error("[WatermarkRemove] 创建任务失败:", errorText);
|
||||
if (shouldUseFalWatermarkFallback(createResponse.status, errorText)) {
|
||||
try {
|
||||
const fallbackResult = await removeWatermarkWithFal({
|
||||
imageUrl: publicImageUrl,
|
||||
userId: session.user.id,
|
||||
startedAt: startTime,
|
||||
sourceImageUrls,
|
||||
});
|
||||
if (pointsCharge.chargeable) {
|
||||
await pointsCharge.consume({ note: "FAL 去水印备用链路消耗积分" });
|
||||
}
|
||||
return NextResponse.json(fallbackResult);
|
||||
} catch (fallbackError) {
|
||||
console.error("[WatermarkRemove] FAL 备用链路失败:", fallbackError);
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "去水印服务暂时不可用,请稍后重试" },
|
||||
{ error: "创建去水印任务失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@ -540,7 +448,10 @@ export async function POST(request: NextRequest) {
|
||||
userId: session.user.id,
|
||||
startedAt: startTime,
|
||||
apiKey: config.apiKey,
|
||||
sourceImageUrls,
|
||||
sourceImageUrls:
|
||||
typeof image_url === "string" && /^https?:\/\//i.test(image_url)
|
||||
? [image_url]
|
||||
: undefined,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
|
||||
@ -141,7 +141,6 @@ import {
|
||||
createEmptyAgentGenerationProjectWorkspaceState,
|
||||
createEmptyAgentWorkflowPersistedState,
|
||||
normalizeAgentGenerationProjectPersistedState,
|
||||
normalizeAgentGenerationProjectReplacementResultState,
|
||||
} from "@/lib/agents/generation-project-state";
|
||||
import {
|
||||
createEmptyAgentModeWorkspaceState,
|
||||
@ -192,11 +191,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,
|
||||
@ -233,7 +227,6 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
||||
import type {
|
||||
AgentGenerationProject,
|
||||
AgentGenerationProjectPersistedState,
|
||||
AgentGenerationProjectReplacementResultState,
|
||||
AgentGenerationProjectResultCollections,
|
||||
AgentGenerationProjectWorkspaceAttachment,
|
||||
AgentGenerationProjectWorkspaceState,
|
||||
@ -318,7 +311,7 @@ const CanvasWorkspace = dynamic(
|
||||
|
||||
const CanvasChatPanel = dynamic(
|
||||
() =>
|
||||
loadCanvasChatPanelModule().then(
|
||||
import("@/components/canvas/CanvasChatPanel").then(
|
||||
(module) => module.CanvasChatPanel
|
||||
),
|
||||
{
|
||||
@ -1547,57 +1540,6 @@ function normalizeResultCollectionUrls(urls: string[] | undefined | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function areReplacementResultStatesEqual(
|
||||
left: AgentGenerationProjectReplacementResultState | undefined | null,
|
||||
right: AgentGenerationProjectReplacementResultState | undefined | null
|
||||
) {
|
||||
return (
|
||||
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
|
||||
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
|
||||
);
|
||||
}
|
||||
|
||||
function getReplacementResultUrlCount(
|
||||
state: AgentGenerationProjectPersistedState | undefined | null
|
||||
) {
|
||||
const normalized = normalizeAgentGenerationProjectReplacementResultState(
|
||||
state?.replacementResultState
|
||||
);
|
||||
return (
|
||||
Object.values(normalized.detail_replacement || {}).filter((record) =>
|
||||
Boolean(record.url)
|
||||
).length +
|
||||
Object.values(normalized.sku_replacement || {}).filter((record) =>
|
||||
Boolean(record.url)
|
||||
).length
|
||||
);
|
||||
}
|
||||
|
||||
function hasReplacementResultCollectionMismatch(
|
||||
state: AgentGenerationProjectPersistedState | undefined | null
|
||||
) {
|
||||
const normalized = normalizeAgentGenerationProjectReplacementResultState(
|
||||
state?.replacementResultState
|
||||
);
|
||||
const resultCollections = state?.resultCollections || {};
|
||||
const countModeRecords = (
|
||||
modeType: "detail_replacement" | "sku_replacement"
|
||||
) =>
|
||||
Object.values(normalized[modeType] || {}).filter((record) =>
|
||||
Boolean(record.url)
|
||||
).length;
|
||||
const countModeCollection = (
|
||||
modeType: "detail_replacement" | "sku_replacement"
|
||||
) => normalizeResultCollectionUrls(resultCollections[modeType]).length;
|
||||
|
||||
return (["detail_replacement", "sku_replacement"] as const).some(
|
||||
(modeType) => {
|
||||
const recordCount = countModeRecords(modeType);
|
||||
return recordCount > 0 && countModeCollection(modeType) !== recordCount;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:";
|
||||
|
||||
function getAgentProjectDraftStorageKey(projectId: string) {
|
||||
@ -2034,7 +1976,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 [
|
||||
@ -2146,10 +2088,6 @@ export default function CanvasEditorLegacyPage({
|
||||
agentProjectDeletedResultCollections,
|
||||
setAgentProjectDeletedResultCollections,
|
||||
] = useState<AgentGenerationProjectResultCollections>({});
|
||||
const [
|
||||
agentProjectReplacementResultState,
|
||||
setAgentProjectReplacementResultState,
|
||||
] = useState<AgentGenerationProjectReplacementResultState>({});
|
||||
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
|
||||
const [loadedAgentProjectId, setLoadedAgentProjectId] = useState<
|
||||
string | null
|
||||
@ -2240,9 +2178,6 @@ export default function CanvasEditorLegacyPage({
|
||||
const [aspectRatio, setAspectRatio] = useState("auto");
|
||||
const [imageCount, setImageCount] = useState(1);
|
||||
const previousNodeIdsRef = useRef<Set<string>>(new Set());
|
||||
const lastLocalCanvasMutationAtRef = useRef(0);
|
||||
const externalCanvasConflictRef = useRef(false);
|
||||
const externalCanvasConflictNotifiedRef = useRef(false);
|
||||
const previousBrandForModelRef = useRef("");
|
||||
const autoSelectedBrandModelBrandRef = useRef("");
|
||||
const autoTitleAttemptedRef = useRef<Set<string>>(new Set());
|
||||
@ -3355,15 +3290,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);
|
||||
@ -3373,8 +3299,6 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
const hydrateCanvasProject = useCallback(
|
||||
(project: CanvasInitialProject) => {
|
||||
externalCanvasConflictRef.current = false;
|
||||
externalCanvasConflictNotifiedRef.current = false;
|
||||
useCanvasStore.getState().setProject(id, project.title);
|
||||
const projectUpdatedAt = String(project.updatedAt || "");
|
||||
useCanvasStore
|
||||
@ -3536,7 +3460,6 @@ export default function CanvasEditorLegacyPage({
|
||||
setAgentProjectThumbnail(null);
|
||||
setAgentProjectResultCollections({});
|
||||
setAgentProjectDeletedResultCollections({});
|
||||
setAgentProjectReplacementResultState({});
|
||||
setLoadedAgentProjectId(null);
|
||||
}
|
||||
|
||||
@ -4028,15 +3951,8 @@ export default function CanvasEditorLegacyPage({
|
||||
...resultCollections.sku_replacement,
|
||||
])
|
||||
),
|
||||
replacementResultState: normalizeAgentGenerationProjectReplacementResultState(
|
||||
agentProjectReplacementResultState
|
||||
),
|
||||
};
|
||||
}, [
|
||||
agentProjectDeletedResultCollections,
|
||||
agentProjectReplacementResultState,
|
||||
agentProjectResultCollections,
|
||||
]);
|
||||
}, [agentProjectDeletedResultCollections, agentProjectResultCollections]);
|
||||
|
||||
const buildPersistedAgentProjectState = useCallback(() => {
|
||||
const persistedMaterials = appearanceRecognitionEnabled
|
||||
@ -4221,9 +4137,6 @@ export default function CanvasEditorLegacyPage({
|
||||
setAgentProjectDeletedResultCollections(
|
||||
persistedState.deletedResultCollections || {}
|
||||
);
|
||||
setAgentProjectReplacementResultState(
|
||||
persistedState.replacementResultState || {}
|
||||
);
|
||||
setLoadedAgentProjectId(params.projectId);
|
||||
},
|
||||
[]
|
||||
@ -4398,9 +4311,7 @@ export default function CanvasEditorLegacyPage({
|
||||
const result = await agentProjectService.updateProject(agentProjectId, {
|
||||
title: nextTitle,
|
||||
thumbnail: agentProjectThumbnail,
|
||||
projectState:
|
||||
persistedAgentProjectStateRef.current ||
|
||||
buildPersistedAgentProjectState(),
|
||||
projectState: buildPersistedAgentProjectState(),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@ -4466,27 +4377,9 @@ 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;
|
||||
const localDraftHasStaleReplacementCollections =
|
||||
serverReplacementResultCount > 0 &&
|
||||
hasReplacementResultCollectionMismatch(localDraft?.projectState);
|
||||
const shouldUseLocalDraft =
|
||||
Boolean(localDraft?.projectState) &&
|
||||
Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
|
||||
!localDraftMissesServerReplacementResults &&
|
||||
!localDraftReplacementResultsDifferFromServer &&
|
||||
!localDraftHasStaleReplacementCollections;
|
||||
Number(localDraft?.savedAt || 0) > serverUpdatedAt;
|
||||
|
||||
if (shouldUseLocalDraft && localDraft?.projectState) {
|
||||
hydrateAgentProjectSnapshot({
|
||||
@ -4588,7 +4481,6 @@ export default function CanvasEditorLegacyPage({
|
||||
}, [
|
||||
agentModeState,
|
||||
agentProjectId,
|
||||
agentProjectReplacementResultState,
|
||||
agentProjectResultCollections,
|
||||
agentProjectThumbnail,
|
||||
agentProjectTitle,
|
||||
@ -4879,70 +4771,36 @@ export default function CanvasEditorLegacyPage({
|
||||
const handleAgentResultPreviewUrlsChange = useCallback(
|
||||
(modeType: AgentGenerationResultModeType, urls: string[]) => {
|
||||
const normalizedUrls = normalizeResultCollectionUrls(urls);
|
||||
const currentProjectState =
|
||||
persistedAgentProjectStateRef.current ||
|
||||
createEmptyAgentGenerationProjectPersistedState();
|
||||
const currentCollections = currentProjectState.resultCollections || {};
|
||||
const nextCollections: AgentGenerationProjectResultCollections = {
|
||||
...currentCollections,
|
||||
[modeType]: normalizedUrls,
|
||||
};
|
||||
setAgentProjectResultCollections((current) => {
|
||||
const nextCollections: AgentGenerationProjectResultCollections = {
|
||||
...current,
|
||||
[modeType]: normalizedUrls,
|
||||
};
|
||||
|
||||
if (areResultCollectionsEqual(current, nextCollections)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (!areResultCollectionsEqual(currentCollections, nextCollections)) {
|
||||
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||
...currentProjectState,
|
||||
...persistedAgentProjectStateRef.current,
|
||||
resultCollections: nextCollections,
|
||||
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
|
||||
};
|
||||
persistedAgentProjectStateRef.current = nextProjectState;
|
||||
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||
}
|
||||
|
||||
setAgentProjectResultCollections((current) =>
|
||||
areResultCollectionsEqual(current, nextCollections)
|
||||
? current
|
||||
: nextCollections
|
||||
);
|
||||
setAgentProjectThumbnail((currentThumbnail) => {
|
||||
const nextThumbnail =
|
||||
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
|
||||
return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
|
||||
setAgentProjectThumbnail((currentThumbnail) => {
|
||||
const nextThumbnail =
|
||||
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
|
||||
return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
|
||||
});
|
||||
|
||||
return nextCollections;
|
||||
});
|
||||
},
|
||||
[writeAgentProjectDraftSnapshot]
|
||||
);
|
||||
|
||||
const handleAgentReplacementResultStateChange = useCallback(
|
||||
(nextState: AgentGenerationProjectReplacementResultState) => {
|
||||
const normalizedState =
|
||||
normalizeAgentGenerationProjectReplacementResultState(nextState);
|
||||
|
||||
const currentProjectState =
|
||||
persistedAgentProjectStateRef.current ||
|
||||
createEmptyAgentGenerationProjectPersistedState();
|
||||
if (
|
||||
!areReplacementResultStatesEqual(
|
||||
currentProjectState.replacementResultState,
|
||||
normalizedState
|
||||
)
|
||||
) {
|
||||
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||
...currentProjectState,
|
||||
replacementResultState: normalizedState,
|
||||
};
|
||||
persistedAgentProjectStateRef.current = nextProjectState;
|
||||
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||
}
|
||||
|
||||
setAgentProjectReplacementResultState((current) =>
|
||||
areReplacementResultStatesEqual(current, normalizedState)
|
||||
? current
|
||||
: normalizedState
|
||||
);
|
||||
},
|
||||
[writeAgentProjectDraftSnapshot]
|
||||
);
|
||||
|
||||
const handleAgentResultImageDelete = useCallback(
|
||||
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
||||
const normalizedUrl = String(imageUrl || "").trim();
|
||||
@ -4976,33 +4834,6 @@ export default function CanvasEditorLegacyPage({
|
||||
? current
|
||||
: nextDeletedCollections
|
||||
);
|
||||
if (modeType === "detail_replacement" || modeType === "sku_replacement") {
|
||||
setAgentProjectReplacementResultState((current) => {
|
||||
const normalizedState =
|
||||
normalizeAgentGenerationProjectReplacementResultState(current);
|
||||
const currentModeState = normalizedState[modeType] || {};
|
||||
const nextModeState = Object.fromEntries(
|
||||
Object.entries(currentModeState).filter(
|
||||
([, record]) => record.url !== normalizedUrl
|
||||
)
|
||||
);
|
||||
const nextState: AgentGenerationProjectReplacementResultState = {
|
||||
...normalizedState,
|
||||
[modeType]: nextModeState,
|
||||
};
|
||||
if (areReplacementResultStatesEqual(normalizedState, nextState)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||
...persistedAgentProjectStateRef.current,
|
||||
replacementResultState: nextState,
|
||||
};
|
||||
persistedAgentProjectStateRef.current = nextProjectState;
|
||||
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||
return nextState;
|
||||
});
|
||||
}
|
||||
setAgentProjectThumbnail((current) => {
|
||||
const nextThumbnail =
|
||||
current === normalizedUrl
|
||||
@ -5011,11 +4842,7 @@ export default function CanvasEditorLegacyPage({
|
||||
return current === nextThumbnail ? current : nextThumbnail;
|
||||
});
|
||||
},
|
||||
[
|
||||
agentProjectDeletedResultCollections,
|
||||
agentProjectResultCollections,
|
||||
writeAgentProjectDraftSnapshot,
|
||||
]
|
||||
[agentProjectDeletedResultCollections, agentProjectResultCollections]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -5088,19 +4915,12 @@ export default function CanvasEditorLegacyPage({
|
||||
if (!result.success) {
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
if (result.status === 409) {
|
||||
externalCanvasConflictRef.current = true;
|
||||
console.warn("[Canvas] stale save revision handled without user prompt", {
|
||||
reason,
|
||||
canvasUpdatedAt: result.canvasUpdatedAt,
|
||||
});
|
||||
if (!externalCanvasConflictNotifiedRef.current) {
|
||||
externalCanvasConflictNotifiedRef.current = true;
|
||||
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
|
||||
}
|
||||
}
|
||||
} else if (result.data?.updatedAt) {
|
||||
externalCanvasConflictRef.current = false;
|
||||
externalCanvasConflictNotifiedRef.current = false;
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||
@ -5127,70 +4947,6 @@ export default function CanvasEditorLegacyPage({
|
||||
return () => clearTimeout(saveTimer);
|
||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isAgentOperationRefreshActive()) return;
|
||||
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||
}, [isLoading, isAgentOperationRefreshActive, nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isAgentMode) return;
|
||||
|
||||
let cancelled = false;
|
||||
const refreshIfExternallyUpdated = async () => {
|
||||
if (
|
||||
cancelled ||
|
||||
document.visibilityState !== "visible" ||
|
||||
isSaving ||
|
||||
externalCanvasConflictRef.current ||
|
||||
Date.now() - lastLocalCanvasMutationAtRef.current < 5000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const revisionPayload = await revisionResponse.json().catch(() => ({}));
|
||||
const remoteRevision = String(
|
||||
revisionPayload?.data?.updatedAt || ""
|
||||
).trim();
|
||||
const localRevision = String(
|
||||
useCanvasStore.getState().canvasRevision || ""
|
||||
).trim();
|
||||
if (
|
||||
cancelled ||
|
||||
!revisionResponse.ok ||
|
||||
!remoteRevision ||
|
||||
remoteRevision === localRevision
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await canvasService.getProject(id);
|
||||
if (cancelled || !result.success || !result.data) return;
|
||||
useCanvasStore.getState().markAgentOperationRefresh();
|
||||
hydrateCanvasProject(result.data);
|
||||
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||
toast.success("已同步 OpenClaw 的画布操作");
|
||||
} catch (error) {
|
||||
console.error("[Canvas] external operation refresh failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshIfExternallyUpdated();
|
||||
}, 4000);
|
||||
const handleFocus = () => void refreshIfExternallyUpdated();
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [hydrateCanvasProject, id, isAgentMode, isLoading, isSaving]);
|
||||
|
||||
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
|
||||
useEffect(() => {
|
||||
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) {
|
||||
@ -6989,7 +6745,6 @@ export default function CanvasEditorLegacyPage({
|
||||
deletedResultCollections:
|
||||
existingPersistedState.deletedResultCollections || {},
|
||||
resultImageUrls: existingPersistedState.resultImageUrls || [],
|
||||
replacementResultState: existingPersistedState.replacementResultState || {},
|
||||
};
|
||||
|
||||
const payload = {
|
||||
@ -10276,13 +10031,10 @@ export default function CanvasEditorLegacyPage({
|
||||
mode: null,
|
||||
});
|
||||
}}
|
||||
onPersistProject={() => persistAgentProject("manual")}
|
||||
onReplacementResultStateChange={handleAgentReplacementResultStateChange}
|
||||
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
|
||||
onWorkspaceStateChange={setAgentWorkspaceState}
|
||||
projectId={agentProjectId}
|
||||
key={agentProjectId}
|
||||
replacementResultState={agentProjectReplacementResultState}
|
||||
resultCollections={agentProjectResultCollections}
|
||||
selectedAgentPresetId={
|
||||
isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null
|
||||
@ -11110,7 +10862,6 @@ export default function CanvasEditorLegacyPage({
|
||||
chatScope="canvas"
|
||||
onClose={() => setIsPanelOpen(false)}
|
||||
onOpenFileList={() => setPanelMode("fileList")}
|
||||
onRequireLogin={openLoginDialog}
|
||||
selectedAgentPresetId={
|
||||
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
@ -67,7 +66,7 @@ export function CanvasPageClientRouter({
|
||||
if (isVideoMode) {
|
||||
return (
|
||||
<CanvasLoadingBrandProvider value={loadingBrand}>
|
||||
<LiblibVideoCanvasReplica canvasId={id} initialProject={initialProject} />
|
||||
<LiblibVideoCanvasReplica canvasId={id} />
|
||||
</CanvasLoadingBrandProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
type CanvasEditorModule = typeof import("./CanvasEditorLegacyPageClient");
|
||||
|
||||
let canvasEditorModulePromise: Promise<CanvasEditorModule> | null = null;
|
||||
|
||||
export function loadCanvasEditorModule() {
|
||||
canvasEditorModulePromise ??= import("./CanvasEditorLegacyPageClient");
|
||||
return canvasEditorModulePromise;
|
||||
}
|
||||
|
||||
export function preloadCanvasEditor() {
|
||||
void loadCanvasEditorModule();
|
||||
}
|
||||
@ -1970,18 +1970,15 @@ export default function CanvasModel3DPage() {
|
||||
worldImageFile as File,
|
||||
"image"
|
||||
);
|
||||
const uploadData = uploadResult.data || null;
|
||||
const mediaAssetId = uploadData?.mediaAssetId || null;
|
||||
const sourceUrl = uploadData?.sourceUrl || null;
|
||||
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
|
||||
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
||||
throw new Error(uploadResult.error || "上传世界参考图失败");
|
||||
}
|
||||
await submitTask({
|
||||
provider: "worldlabs-3d",
|
||||
payload: {
|
||||
mode: "image",
|
||||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||
providerId: uploadData.providerId,
|
||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
||||
providerId: uploadResult.data.providerId,
|
||||
prompt: normalizedPrompt || undefined,
|
||||
displayName: worldDisplayName.trim() || undefined,
|
||||
model: worldModel,
|
||||
@ -2003,18 +2000,15 @@ export default function CanvasModel3DPage() {
|
||||
worldVideoFile as File,
|
||||
"video"
|
||||
);
|
||||
const uploadData = uploadResult.data || null;
|
||||
const mediaAssetId = uploadData?.mediaAssetId || null;
|
||||
const sourceUrl = uploadData?.sourceUrl || null;
|
||||
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
|
||||
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
||||
throw new Error(uploadResult.error || "上传世界视频失败");
|
||||
}
|
||||
await submitTask({
|
||||
provider: "worldlabs-3d",
|
||||
payload: {
|
||||
mode: "video",
|
||||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||
providerId: uploadData.providerId,
|
||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
||||
providerId: uploadResult.data.providerId,
|
||||
prompt: normalizedPrompt || undefined,
|
||||
displayName: worldDisplayName.trim() || undefined,
|
||||
model: worldModel,
|
||||
@ -2049,13 +2043,11 @@ export default function CanvasModel3DPage() {
|
||||
}
|
||||
|
||||
const mediaAssets = uploads.map(({ slot, result }) => {
|
||||
const mediaAssetId = result.data?.mediaAssetId || null;
|
||||
const sourceUrl = result.data?.sourceUrl || null;
|
||||
if (!result.success || (!mediaAssetId && !sourceUrl)) {
|
||||
if (!result.success || !result.data?.mediaAssetId) {
|
||||
throw new Error(result.error || "上传多图世界图片失败");
|
||||
}
|
||||
return {
|
||||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||
mediaAssetId: result.data.mediaAssetId,
|
||||
azimuth: slot.azimuth,
|
||||
};
|
||||
});
|
||||
@ -2798,9 +2790,9 @@ export default function CanvasModel3DPage() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorldFirstPerson(
|
||||
workspaceMode === "world" && worldViewportMode === "splat"
|
||||
);
|
||||
if (workspaceMode !== "world" || worldViewportMode !== "splat") {
|
||||
setWorldFirstPerson(false);
|
||||
}
|
||||
}, [workspaceMode, worldViewportMode]);
|
||||
|
||||
return (
|
||||
@ -4008,7 +4000,6 @@ export default function CanvasModel3DPage() {
|
||||
previewImageUrl={currentPreviewImage}
|
||||
resetViewToken={viewerResetToken}
|
||||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||||
showInfoOverlay={false}
|
||||
showMovementControls={
|
||||
worldViewportMode === "splat" || worldViewportMode === "mesh"
|
||||
}
|
||||
|
||||
@ -50,13 +50,10 @@ export default async function CanvasEditorPage({
|
||||
}: CanvasEditorPageProps) {
|
||||
const { id } = await params;
|
||||
const resolvedSearchParams = searchParams ? await searchParams : {};
|
||||
const bypassLocalDatabase = process.env.CANVAS_E2E_LOCAL === "true";
|
||||
const [siteSettings, initialProject] = bypassLocalDatabase
|
||||
? [null, null]
|
||||
: await Promise.all([
|
||||
getSiteSettings().catch(() => null),
|
||||
getInitialCanvasProject(id).catch(() => null),
|
||||
]);
|
||||
const [siteSettings, initialProject] = await Promise.all([
|
||||
getSiteSettings().catch(() => null),
|
||||
getInitialCanvasProject(id).catch(() => null),
|
||||
]);
|
||||
const loadingBrand = {
|
||||
siteLogo: siteSettings?.siteLogo,
|
||||
siteLogoDark: siteSettings?.siteLogoDark,
|
||||
|
||||
137
app/globals.css
137
app/globals.css
@ -3,7 +3,6 @@
|
||||
|
||||
/* include utility classes in streamdown */
|
||||
@source '../node_modules/streamdown/dist/index.js';
|
||||
@source not '../services/pascal-editor';
|
||||
|
||||
/* custom variant for setting dark mode programmatically */
|
||||
@custom-variant dark (&:is(.dark, .dark *));
|
||||
@ -93,15 +92,6 @@
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
/* border radius unit */
|
||||
--radius: 0.5rem;
|
||||
--ui-control-sm: 2rem;
|
||||
--ui-control-md: 2.25rem;
|
||||
--ui-control-lg: 2.5rem;
|
||||
--ui-icon-sm: 0.875rem;
|
||||
--ui-icon-md: 1rem;
|
||||
--ui-radius-control: 0.4375rem;
|
||||
--ui-radius-panel: 0.75rem;
|
||||
--ui-motion-fast: 120ms;
|
||||
--ui-motion-panel: 160ms;
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--chat-user-bubble-bg: hsl(214 100% 50%);
|
||||
--chat-user-bubble-foreground: hsl(0 0% 100%);
|
||||
@ -267,29 +257,6 @@
|
||||
font-family: var(--font-jetbrains-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
[role="button"] {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:not(:disabled):active,
|
||||
[role="button"]:not([aria-disabled="true"]):active {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
@media (width >= 768px) {
|
||||
html {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.drawnix,
|
||||
.drawnix *,
|
||||
.drawnix-tutorial,
|
||||
@ -403,107 +370,3 @@
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dense, shared system-settings language. Scoped so canvas tools keep their own scale. */
|
||||
.settings-workspace {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.settings-workspace [class*="tracking-"] {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card"] {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-header"] {
|
||||
row-gap: 4px;
|
||||
padding: 14px 16px 10px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-title"] {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-description"] {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-content"] {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-footer"] {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="label"] {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settings-workspace .text-3xl {
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.settings-workspace .text-2xl {
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settings-workspace .text-xl {
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-workspace .text-lg {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-workspace .rounded-2xl,
|
||||
.settings-workspace .rounded-xl {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.settings-workspace .gap-6 {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-workspace .space-y-6 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-workspace button .lucide,
|
||||
.settings-workspace a .lucide {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.settings-workspace button,
|
||||
.settings-workspace a,
|
||||
.settings-workspace [role="button"] {
|
||||
transition-duration: 120ms;
|
||||
}
|
||||
|
||||
@media (width < 640px) {
|
||||
.settings-workspace [data-slot="card-header"] {
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-content"] {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.settings-workspace [data-slot="card-footer"] {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
"!lib/db/migrations/meta",
|
||||
"!public/vendor",
|
||||
"!public/three",
|
||||
"!services/pascal-editor",
|
||||
"!vercel.json"
|
||||
]
|
||||
},
|
||||
|
||||
@ -52,30 +52,30 @@ export function AgentExecutionSnapshotDialog({
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !snapshot ? (
|
||||
<div className="rounded-[var(--ui-radius-control)] border border-dashed p-5 text-[13px] text-muted-foreground">
|
||||
<div className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
|
||||
暂无快照详情
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="text-[11px] text-muted-foreground">风格化</div>
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="text-xs text-muted-foreground">风格化</div>
|
||||
<div className="mt-1 font-medium">{snapshot.presetName}</div>
|
||||
<div className="mt-1 text-[13px] text-muted-foreground">
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
类型:{snapshot.presetType === "video" ? "视频" : "生图"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
分类:{snapshot.categoryName || "未分类"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="text-[11px] text-muted-foreground">模型</div>
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="text-xs text-muted-foreground">模型</div>
|
||||
<div className="mt-1 font-medium">
|
||||
{snapshot.resolvedModel?.displayName ||
|
||||
snapshot.defaultModel?.displayName ||
|
||||
"未记录"}
|
||||
</div>
|
||||
<div className="mt-1 text-[13px] text-muted-foreground">
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
槽位:{snapshot.slotCount} 个
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@ -84,8 +84,8 @@ export function AgentExecutionSnapshotDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="mb-2 text-[13px] font-medium">本次使用的 3 张素材图</div>
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 text-sm font-medium">本次使用的 3 张素材图</div>
|
||||
{uploadedImageUrls.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{uploadedImageUrls.map((url, index) => (
|
||||
@ -107,18 +107,18 @@ export function AgentExecutionSnapshotDialog({
|
||||
</div>
|
||||
|
||||
{snapshot.userInputText ? (
|
||||
<div className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div className="mb-2 text-[13px] font-medium">用户补充提示</div>
|
||||
<div className="whitespace-pre-wrap text-[13px] text-muted-foreground">
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 text-sm font-medium">用户补充提示</div>
|
||||
<div className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{snapshot.userInputText}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-[13px] font-medium">槽位详情</div>
|
||||
<div className="text-sm font-medium">槽位详情</div>
|
||||
{snapshot.slots.map((slot, index) => (
|
||||
<div key={`snapshot-slot-${slot.slotIndex}-${index}`} className="rounded-[var(--ui-radius-control)] border p-3">
|
||||
<div key={`snapshot-slot-${slot.slotIndex}-${index}`} className="rounded-xl border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="font-medium">
|
||||
{slot.title || slot.uploadLabel || `槽位 ${slot.slotIndex + 1}`}
|
||||
|
||||
@ -82,7 +82,6 @@ import {
|
||||
LOCAL_EDIT_MAX_REGIONS,
|
||||
createLocalEditReferenceImageId,
|
||||
createLocalEditRegionId,
|
||||
hasScaleAdjustTargetChanged,
|
||||
reindexLocalEditRegions,
|
||||
type LocalEditReferenceImage,
|
||||
type LocalEditRegion,
|
||||
@ -123,8 +122,6 @@ import type {
|
||||
AgentDetailBriefForm,
|
||||
AgentDetailGenerationProgressState,
|
||||
AgentDetailReplacementStrategy,
|
||||
AgentGenerationProjectReplacementResultState,
|
||||
AgentGenerationReplacementResultRecord,
|
||||
AgentGenerationProjectWorkspaceState,
|
||||
AgentGenerationProjectResultCollections,
|
||||
AgentGenerationResultModeType,
|
||||
@ -149,7 +146,6 @@ import {
|
||||
import {
|
||||
createEmptyAgentGenerationProjectWorkspaceState,
|
||||
normalizeAgentGenerationProjectPersistedState,
|
||||
normalizeAgentGenerationProjectReplacementResultState,
|
||||
} from "@/lib/agents/generation-project-state";
|
||||
|
||||
export interface AgentGenerationWorkspaceDarkV2Props {
|
||||
@ -177,10 +173,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
||||
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
|
||||
resultCollections?: AgentGenerationProjectResultCollections | null;
|
||||
deletedResultCollections?: AgentGenerationProjectResultCollections | null;
|
||||
replacementResultState?: AgentGenerationProjectReplacementResultState | null;
|
||||
onReplacementResultStateChange?: (
|
||||
next: AgentGenerationProjectReplacementResultState
|
||||
) => void;
|
||||
onResultPreviewUrlsChange?: (
|
||||
modeType: AgentGenerationResultModeType,
|
||||
urls: string[]
|
||||
@ -189,7 +181,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
||||
modeType: AgentGenerationResultModeType,
|
||||
imageUrl: string
|
||||
) => void;
|
||||
onPersistProject?: () => Promise<boolean> | boolean;
|
||||
topNavRightSlot?: ReactNode;
|
||||
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
|
||||
workflowStylePanel?: ReactNode;
|
||||
@ -256,8 +247,6 @@ type DetailResultLocalEditTarget = {
|
||||
|
||||
type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
|
||||
|
||||
type DetailResultLocalEditImageSize = "1K" | "2K";
|
||||
|
||||
type DetailResultLocalEditRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
@ -280,7 +269,6 @@ type DetailResultLocalEditRegionInteraction = {
|
||||
|
||||
type DetailResultLocalEditSubmitParams = {
|
||||
imageUrl: string;
|
||||
imageSize: DetailResultLocalEditImageSize;
|
||||
mode: DetailResultLocalEditMode;
|
||||
title: string;
|
||||
regions: LocalEditRegion[];
|
||||
@ -481,15 +469,6 @@ function normalizeResultCollectionUrls(urls?: string[] | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function areResultUrlListsEqual(left?: string[] | null, right?: string[] | null) {
|
||||
const normalizedLeft = normalizeResultCollectionUrls(left);
|
||||
const normalizedRight = normalizeResultCollectionUrls(right);
|
||||
return (
|
||||
normalizedLeft.length === normalizedRight.length &&
|
||||
normalizedLeft.every((url, index) => normalizedRight[index] === url)
|
||||
);
|
||||
}
|
||||
|
||||
function excludeDeletedResultUrls(
|
||||
urls: string[] | undefined | null,
|
||||
deletedUrls: string[] | undefined | null
|
||||
@ -915,7 +894,6 @@ function DetailResultLocalEditDialog({
|
||||
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
|
||||
const [drawingTargetRegionId, setDrawingTargetRegionId] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<DetailResultLocalEditMode>("local_edit");
|
||||
const [imageSize, setImageSize] = useState<DetailResultLocalEditImageSize>("1K");
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const editSurfaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const isScaleAdjustMode = mode === "scale_adjust";
|
||||
@ -940,7 +918,6 @@ function DetailResultLocalEditDialog({
|
||||
setRegionInteraction(null);
|
||||
setUploadingRegionId(null);
|
||||
setDrawingTargetRegionId(null);
|
||||
setImageSize("1K");
|
||||
dragStartRef.current = null;
|
||||
}, [target?.cardId, target?.imageUrl, target?.mode]);
|
||||
|
||||
@ -1234,11 +1211,11 @@ function DetailResultLocalEditDialog({
|
||||
return;
|
||||
}
|
||||
const unchangedRegion = validRegions.find(
|
||||
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect)
|
||||
(region) => deriveDetailScalePercentFromRects(region.rect, region.targetRect) === 0
|
||||
);
|
||||
if (unchangedRegion) {
|
||||
setActiveRegionId(unchangedRegion.id);
|
||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`);
|
||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小变化不明显,请重新框选`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@ -1251,12 +1228,11 @@ function DetailResultLocalEditDialog({
|
||||
}
|
||||
onSubmit({
|
||||
imageUrl: target.imageUrl,
|
||||
imageSize,
|
||||
mode,
|
||||
title: target.title,
|
||||
regions: validRegions,
|
||||
});
|
||||
}, [imageSize, isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
|
||||
}, [isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
|
||||
|
||||
const completeDraftRegion = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@ -1841,36 +1817,6 @@ function DetailResultLocalEditDialog({
|
||||
? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
|
||||
: `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
|
||||
</div>
|
||||
{!activePreviewResult ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3">
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
||||
输出尺寸
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{ label: "快速 1K", value: "1K" },
|
||||
{ label: "高清 2K", value: "2K" },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
className={cn(
|
||||
"h-9 rounded-xl border px-3 text-sm font-semibold transition",
|
||||
imageSize === option.value
|
||||
? "border-white bg-white text-black"
|
||||
: "border-white/10 bg-black/20 text-white/58 hover:bg-white/[0.08] hover:text-white"
|
||||
)}
|
||||
disabled={submitting}
|
||||
key={option.value}
|
||||
onClick={() => setImageSize(option.value)}
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!activePreviewResult && isScaleAdjustMode ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3 text-sm text-white/58">
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
||||
@ -2406,7 +2352,13 @@ type ReplacementModeType = Extract<
|
||||
"detail_replacement" | "sku_replacement"
|
||||
>;
|
||||
|
||||
interface ReplacementResultRecord extends AgentGenerationReplacementResultRecord {}
|
||||
interface ReplacementResultRecord {
|
||||
url: string | null;
|
||||
text: string;
|
||||
status: Extract<ReplacementCardStatus, "completed" | "passthrough" | "fallback">;
|
||||
updatedAt: number;
|
||||
compareSourceUrl?: string | null;
|
||||
}
|
||||
|
||||
interface ReplacementExecutionContext {
|
||||
modeType: ReplacementModeType;
|
||||
@ -2453,26 +2405,13 @@ type ReplacementResultState = Record<
|
||||
const MANUAL_PASSTHROUGH_RESULT_TEXT =
|
||||
"已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。";
|
||||
|
||||
function normalizeReplacementResultStateForWorkspace(
|
||||
value: AgentGenerationProjectReplacementResultState | null | undefined
|
||||
): ReplacementResultState {
|
||||
const normalized = normalizeAgentGenerationProjectReplacementResultState(value);
|
||||
function createEmptyReplacementResultState(): ReplacementResultState {
|
||||
return {
|
||||
detail_replacement: { ...(normalized.detail_replacement || {}) },
|
||||
sku_replacement: { ...(normalized.sku_replacement || {}) },
|
||||
detail_replacement: {},
|
||||
sku_replacement: {},
|
||||
};
|
||||
}
|
||||
|
||||
function areReplacementResultStatesEqual(
|
||||
left: ReplacementResultState,
|
||||
right: ReplacementResultState
|
||||
) {
|
||||
return (
|
||||
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
|
||||
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
|
||||
);
|
||||
}
|
||||
|
||||
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
|
||||
const normalized = normalizeAssistantText(String(text || ""))
|
||||
.replace(/\s+/g, " ")
|
||||
@ -3120,12 +3059,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
workspaceState = null,
|
||||
resultCollections = null,
|
||||
deletedResultCollections = null,
|
||||
replacementResultState: persistedReplacementResultState = null,
|
||||
onWorkspaceStateChange,
|
||||
onReplacementResultStateChange,
|
||||
onResultPreviewUrlsChange,
|
||||
onDeleteResultImage,
|
||||
onPersistProject,
|
||||
topNavRightSlot,
|
||||
showcaseConfig,
|
||||
workflowStylePanel,
|
||||
@ -3277,7 +3213,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
|
||||
() => normalizeReplacementResultStateForWorkspace(persistedReplacementResultState)
|
||||
() => createEmptyReplacementResultState()
|
||||
);
|
||||
const [replacementExecutionContext, setReplacementExecutionContext] =
|
||||
useState<ReplacementExecutionContext | null>(null);
|
||||
@ -3293,7 +3229,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const processedResultHintTimerRef = useRef<Record<string, number>>({});
|
||||
const transportGenerationStartedAtRef = useRef<number | null>(null);
|
||||
const closePsdTemplateDialog = useCallback(() => {
|
||||
setIsPsdTemplateDialogOpen(false);
|
||||
setPsdTemplateDialogSourceUrl(null);
|
||||
@ -3327,22 +3262,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
},
|
||||
[activeResultModeType, isPsdTemplateEnabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nextState = normalizeReplacementResultStateForWorkspace(
|
||||
persistedReplacementResultState
|
||||
);
|
||||
setReplacementResultState((current) =>
|
||||
areReplacementResultStatesEqual(current, nextState) ? current : nextState
|
||||
);
|
||||
}, [persistedReplacementResultState]);
|
||||
|
||||
useEffect(() => {
|
||||
onReplacementResultStateChange?.(
|
||||
normalizeAgentGenerationProjectReplacementResultState(replacementResultState)
|
||||
);
|
||||
}, [onReplacementResultStateChange, replacementResultState]);
|
||||
|
||||
const normalizedDeletedResultCollections = useMemo(
|
||||
() => ({
|
||||
scene_generation: normalizeResultCollectionUrls(
|
||||
@ -4118,15 +4037,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
? "agent-anchor-materials"
|
||||
: "agent-anchor-generate";
|
||||
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
error: chatError,
|
||||
clearError,
|
||||
} = useChat<ChatMessage>({
|
||||
const { messages, setMessages, sendMessage, status, stop } = useChat<ChatMessage>({
|
||||
id: chatId,
|
||||
messages: historyMessages,
|
||||
experimental_throttle: 100,
|
||||
@ -4591,48 +4502,63 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
skuReplacementReferenceCards,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (latestReplacementResults.length === 0) {
|
||||
setReplacementResultState((current) => {
|
||||
const detailCardIds = new Set(detailReplacementReferenceCards.map((card) => card.id));
|
||||
const skuCardIds = new Set(skuReplacementReferenceCards.map((card) => card.id));
|
||||
let changed = false;
|
||||
|
||||
const nextDetail = Object.fromEntries(
|
||||
Object.entries(current.detail_replacement).filter(([cardId]) => detailCardIds.has(cardId))
|
||||
);
|
||||
if (Object.keys(nextDetail).length !== Object.keys(current.detail_replacement).length) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const nextSku = Object.fromEntries(
|
||||
Object.entries(current.sku_replacement).filter(([cardId]) => skuCardIds.has(cardId))
|
||||
);
|
||||
if (Object.keys(nextSku).length !== Object.keys(current.sku_replacement).length) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
detail_replacement: nextDetail,
|
||||
sku_replacement: nextSku,
|
||||
};
|
||||
});
|
||||
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
(currentResultModeType !== "detail_replacement" && currentResultModeType !== "sku_replacement") ||
|
||||
latestReplacementResults.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailReferenceCardById = new Map(
|
||||
detailReplacementReferenceCards.map((card) => [card.id, card])
|
||||
);
|
||||
const skuReferenceCardById = new Map(
|
||||
skuReplacementReferenceCards.map((card) => [card.id, card])
|
||||
);
|
||||
const fallbackModeType: ReplacementModeType | null =
|
||||
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement"
|
||||
? currentResultModeType
|
||||
: null;
|
||||
const cardIds = currentReplacementExecutionCardIds;
|
||||
if (cardIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReplacementResultState((current) => {
|
||||
const nextState: ReplacementResultState = {
|
||||
detail_replacement: { ...current.detail_replacement },
|
||||
sku_replacement: { ...current.sku_replacement },
|
||||
};
|
||||
const nextModeState = { ...current[currentResultModeType] };
|
||||
let changed = false;
|
||||
|
||||
const applyResult = (
|
||||
modeType: ReplacementModeType,
|
||||
cardId: string | null | undefined,
|
||||
result: {
|
||||
url: string | null;
|
||||
text: string;
|
||||
replacementCardId: string | null;
|
||||
},
|
||||
referenceCard?: {
|
||||
imageUrl?: string | null;
|
||||
label?: string | null;
|
||||
previewUrl?: string | null;
|
||||
} | null
|
||||
) => {
|
||||
latestReplacementResults.forEach((result, index) => {
|
||||
const cardId = result.replacementCardId || cardIds[index];
|
||||
if (!cardId) {
|
||||
return;
|
||||
}
|
||||
const inferredStatus = inferReplacementCardStatusFromText(result.text);
|
||||
const nextModeState = nextState[modeType];
|
||||
const previousRecord = nextModeState[cardId];
|
||||
const referenceCard =
|
||||
currentResultModeType === "detail_replacement"
|
||||
? detailReplacementReferenceCards.find((card) => card.id === cardId)
|
||||
: skuReplacementReferenceCards.find((card) => card.id === cardId);
|
||||
const resultUrl = result.url || null;
|
||||
const previousUrl =
|
||||
previousRecord?.url && previousRecord.url !== resultUrl
|
||||
@ -4652,59 +4578,27 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
resultUrl,
|
||||
previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null
|
||||
),
|
||||
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
|
||||
referencePreviewUrl:
|
||||
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
|
||||
label: referenceCard?.label || previousRecord?.label || null,
|
||||
};
|
||||
if (
|
||||
previousRecord?.url === nextRecord.url &&
|
||||
previousRecord?.text === nextRecord.text &&
|
||||
previousRecord?.status === nextRecord.status &&
|
||||
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
|
||||
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
|
||||
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
|
||||
previousRecord?.label === nextRecord.label
|
||||
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
nextModeState[cardId] = nextRecord;
|
||||
changed = true;
|
||||
};
|
||||
|
||||
latestReplacementResults.forEach((result, index) => {
|
||||
const explicitCardId = result.replacementCardId || null;
|
||||
if (explicitCardId) {
|
||||
const detailReferenceCard = detailReferenceCardById.get(explicitCardId);
|
||||
if (detailReferenceCard) {
|
||||
applyResult("detail_replacement", explicitCardId, result, detailReferenceCard);
|
||||
return;
|
||||
}
|
||||
|
||||
const skuReferenceCard = skuReferenceCardById.get(explicitCardId);
|
||||
if (skuReferenceCard) {
|
||||
applyResult("sku_replacement", explicitCardId, result, skuReferenceCard);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackModeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackCardId = currentReplacementExecutionCardIds[index];
|
||||
const fallbackReferenceCard =
|
||||
fallbackModeType === "detail_replacement"
|
||||
? detailReferenceCardById.get(fallbackCardId)
|
||||
: skuReferenceCardById.get(fallbackCardId);
|
||||
applyResult(fallbackModeType, fallbackCardId, result, fallbackReferenceCard);
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
return {
|
||||
...current,
|
||||
[currentResultModeType]: nextModeState,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
currentReplacementExecutionCardIds,
|
||||
@ -4714,8 +4608,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
skuReplacementReferenceCards,
|
||||
]);
|
||||
const detailReplacementResultUrls = useMemo(
|
||||
() => {
|
||||
const urlsFromCards = detailReplacementReferenceCards
|
||||
() =>
|
||||
detailReplacementReferenceCards
|
||||
.map((card) => {
|
||||
const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null;
|
||||
const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null;
|
||||
@ -4725,15 +4619,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
? nextUrl
|
||||
: null;
|
||||
})
|
||||
.filter((url): url is string => Boolean(url));
|
||||
const urlsFromRecords = Object.values(replacementResultState.detail_replacement)
|
||||
.map((record) => record.url)
|
||||
.filter((url): url is string => Boolean(url))
|
||||
.filter(
|
||||
(url) => !normalizedDeletedResultCollections.detail_replacement.includes(url)
|
||||
);
|
||||
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
|
||||
},
|
||||
.filter((url): url is string => Boolean(url)),
|
||||
[
|
||||
detailReplacementReferenceCards,
|
||||
normalizedDeletedResultCollections.detail_replacement,
|
||||
@ -4741,21 +4627,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
]
|
||||
);
|
||||
const skuReplacementResultUrls = useMemo(
|
||||
() => {
|
||||
const urlsFromCards = skuReplacementReferenceCards
|
||||
() =>
|
||||
skuReplacementReferenceCards
|
||||
.map((card) => {
|
||||
const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null;
|
||||
return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl)
|
||||
? nextUrl
|
||||
: null;
|
||||
})
|
||||
.filter((url): url is string => Boolean(url));
|
||||
const urlsFromRecords = Object.values(replacementResultState.sku_replacement)
|
||||
.map((record) => record.url)
|
||||
.filter((url): url is string => Boolean(url))
|
||||
.filter((url) => !normalizedDeletedResultCollections.sku_replacement.includes(url));
|
||||
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
|
||||
},
|
||||
.filter((url): url is string => Boolean(url)),
|
||||
[
|
||||
normalizedDeletedResultCollections.sku_replacement,
|
||||
replacementResultState.sku_replacement,
|
||||
@ -4846,20 +4726,24 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
resultCollections?.detail_generation,
|
||||
normalizedDeletedResultCollections.detail_generation
|
||||
),
|
||||
detail_replacement:
|
||||
detailReplacementResultUrls.length > 0
|
||||
? detailReplacementResultUrls
|
||||
: excludeDeletedResultUrls(
|
||||
resultCollections?.detail_replacement,
|
||||
normalizedDeletedResultCollections.detail_replacement
|
||||
),
|
||||
sku_replacement:
|
||||
skuReplacementResultUrls.length > 0
|
||||
? skuReplacementResultUrls
|
||||
: excludeDeletedResultUrls(
|
||||
resultCollections?.sku_replacement,
|
||||
normalizedDeletedResultCollections.sku_replacement
|
||||
),
|
||||
detail_replacement: Array.from(
|
||||
new Set([
|
||||
...detailReplacementResultUrls,
|
||||
...excludeDeletedResultUrls(
|
||||
resultCollections?.detail_replacement,
|
||||
normalizedDeletedResultCollections.detail_replacement
|
||||
),
|
||||
])
|
||||
),
|
||||
sku_replacement: Array.from(
|
||||
new Set([
|
||||
...skuReplacementResultUrls,
|
||||
...excludeDeletedResultUrls(
|
||||
resultCollections?.sku_replacement,
|
||||
normalizedDeletedResultCollections.sku_replacement
|
||||
),
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
@ -5065,52 +4949,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
skuReplacementBatchDownloadUrls.length > 0
|
||||
? skuReplacementBatchDownloadUrls
|
||||
: mergedResultCollections.sku_replacement;
|
||||
const syncReplacementResultCollectionsToParent = useCallback(
|
||||
(targetModeType?: ReplacementModeType) => {
|
||||
if (!onResultPreviewUrlsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncMode = (modeType: ReplacementModeType, liveUrls: string[]) => {
|
||||
if (targetModeType && targetModeType !== modeType) {
|
||||
return;
|
||||
}
|
||||
const normalizedLiveUrls = normalizeResultCollectionUrls(liveUrls);
|
||||
if (normalizedLiveUrls.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedUrlSet = new Set(
|
||||
normalizedDeletedResultCollections[modeType]
|
||||
);
|
||||
const nextUrls = Array.from(
|
||||
new Set(normalizedLiveUrls.filter((url) => !deletedUrlSet.has(url)))
|
||||
);
|
||||
|
||||
if (!areResultUrlListsEqual(resultCollections?.[modeType], nextUrls)) {
|
||||
onResultPreviewUrlsChange(modeType, nextUrls);
|
||||
}
|
||||
};
|
||||
|
||||
onReplacementResultStateChange?.(
|
||||
normalizeAgentGenerationProjectReplacementResultState(
|
||||
replacementResultState
|
||||
)
|
||||
);
|
||||
syncMode("detail_replacement", detailReplacementResultUrls);
|
||||
syncMode("sku_replacement", skuReplacementResultUrls);
|
||||
},
|
||||
[
|
||||
detailReplacementResultUrls,
|
||||
normalizedDeletedResultCollections,
|
||||
onReplacementResultStateChange,
|
||||
onResultPreviewUrlsChange,
|
||||
replacementResultState,
|
||||
resultCollections,
|
||||
skuReplacementResultUrls,
|
||||
]
|
||||
);
|
||||
|
||||
const detailRemainingCount = useMemo(
|
||||
() =>
|
||||
detailResultCards.filter(
|
||||
@ -5398,115 +5236,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}
|
||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || !chatError) {
|
||||
return;
|
||||
}
|
||||
|
||||
generationRequestSnapshotRef.current = {
|
||||
hiddenPrompt: null,
|
||||
replacementExecutionCardIds: null,
|
||||
replacementRerunReferenceImages: null,
|
||||
};
|
||||
setPendingGeneration(null);
|
||||
toast.error(chatError.message || "生成请求失败,请稍后重试");
|
||||
clearError();
|
||||
}, [chatError, clearError, pendingGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - pendingGeneration.timestamp;
|
||||
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
|
||||
const clearStalePendingGeneration = () => {
|
||||
generationRequestSnapshotRef.current = {
|
||||
hiddenPrompt: null,
|
||||
replacementExecutionCardIds: null,
|
||||
replacementRerunReferenceImages: null,
|
||||
};
|
||||
try {
|
||||
stop();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AgentGenerationWorkspaceDarkV2] Failed to stop stale generation transport:",
|
||||
error
|
||||
);
|
||||
}
|
||||
transportGenerationStartedAtRef.current = null;
|
||||
setPendingGeneration((current) => {
|
||||
if (
|
||||
!current ||
|
||||
current.chatId !== pendingGeneration.chatId ||
|
||||
current.timestamp !== pendingGeneration.timestamp
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
toast.warning("生成请求超过 30 分钟未回传,已结束处理中状态,可重新生成未回传结果。");
|
||||
};
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
clearStalePendingGeneration();
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(clearStalePendingGeneration, remainingMs);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [pendingGeneration, stop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transportIsGenerating) {
|
||||
transportGenerationStartedAtRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (transportGenerationStartedAtRef.current === null) {
|
||||
transportGenerationStartedAtRef.current =
|
||||
pendingGeneration?.timestamp || Date.now();
|
||||
}
|
||||
|
||||
const startedAt = transportGenerationStartedAtRef.current;
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
|
||||
const abortStaleTransport = () => {
|
||||
const currentStartedAt = transportGenerationStartedAtRef.current;
|
||||
if (
|
||||
currentStartedAt === null ||
|
||||
Date.now() - currentStartedAt < AGENT_PENDING_GENERATION_EXPIRY_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
generationRequestSnapshotRef.current = {
|
||||
hiddenPrompt: null,
|
||||
replacementExecutionCardIds: null,
|
||||
replacementRerunReferenceImages: null,
|
||||
};
|
||||
try {
|
||||
stop();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AgentGenerationWorkspaceDarkV2] Failed to abort stale generation transport:",
|
||||
error
|
||||
);
|
||||
}
|
||||
transportGenerationStartedAtRef.current = null;
|
||||
setPendingGeneration(null);
|
||||
toast.warning("生成请求超过 30 分钟未回传,已中断卡住连接,可重新生成未回传结果。");
|
||||
};
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
abortStaleTransport();
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(abortStaleTransport, remainingMs);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [pendingGeneration?.timestamp, stop, transportIsGenerating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingGeneration || transportIsGenerating) {
|
||||
return;
|
||||
@ -5544,19 +5273,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onResultPreviewUrlsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
([
|
||||
"scene_generation",
|
||||
"detail_generation",
|
||||
"detail_replacement",
|
||||
"sku_replacement",
|
||||
] as AgentGenerationResultModeType[]).forEach((modeType) => {
|
||||
onResultPreviewUrlsChange(modeType, mergedResultCollections[modeType] || []);
|
||||
});
|
||||
}, [mergedResultCollections, onResultPreviewUrlsChange]);
|
||||
onResultPreviewUrlsChange?.(
|
||||
currentResultModeType,
|
||||
mergedResultCollections[currentResultModeType] || []
|
||||
);
|
||||
}, [currentResultModeType, mergedResultCollections, onResultPreviewUrlsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeResultModeUrls.length === 0) {
|
||||
@ -5698,7 +5419,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: string[];
|
||||
zipName: string;
|
||||
scope: BatchDownloadScope;
|
||||
modeType?: AgentGenerationResultModeType;
|
||||
}) => {
|
||||
const normalizedUrls = (params.imageUrls || [])
|
||||
.map((item) => String(item || "").trim())
|
||||
@ -5708,19 +5428,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
params.modeType === "detail_replacement" ||
|
||||
params.modeType === "sku_replacement"
|
||||
) {
|
||||
syncReplacementResultCollectionsToParent(params.modeType);
|
||||
}
|
||||
const persisted = await Promise.resolve(onPersistProject?.() ?? true).catch(
|
||||
() => false
|
||||
);
|
||||
if (!persisted) {
|
||||
toast.info("结果已保留在当前页面,项目同步稍后会自动重试");
|
||||
}
|
||||
|
||||
setBatchDownloadState(params.scope);
|
||||
setBatchDownloadDialog({
|
||||
scope: params.scope,
|
||||
@ -5800,7 +5507,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}));
|
||||
toast.error(message);
|
||||
}
|
||||
}, [onPersistProject, syncReplacementResultCollectionsToParent]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const taskId = batchDownloadDialog?.taskId;
|
||||
@ -5899,9 +5606,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const link = document.createElement("a");
|
||||
link.href = batchDownloadDialog.downloadUrl;
|
||||
link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@ -5967,10 +5671,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
setReplacementResultState((current) => {
|
||||
const currentModeState = current[params.modeType];
|
||||
const previousRecord = currentModeState[params.cardId];
|
||||
const referenceCard =
|
||||
params.modeType === "detail_replacement"
|
||||
? detailReplacementReferenceCards.find((card) => card.id === params.cardId)
|
||||
: skuReplacementReferenceCards.find((card) => card.id === params.cardId);
|
||||
const inferredStatus = inferReplacementCardStatusFromText(params.text);
|
||||
const explicitCompareSourceUrl =
|
||||
params.compareSourceUrl === undefined
|
||||
@ -5991,20 +5691,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
explicitCompareSourceUrl === undefined
|
||||
? previousRecord?.compareSourceUrl || null
|
||||
: explicitCompareSourceUrl,
|
||||
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
|
||||
referencePreviewUrl:
|
||||
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
|
||||
label: referenceCard?.label || previousRecord?.label || null,
|
||||
};
|
||||
|
||||
if (
|
||||
previousRecord?.url === nextRecord.url &&
|
||||
previousRecord?.text === nextRecord.text &&
|
||||
previousRecord?.status === nextRecord.status &&
|
||||
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
|
||||
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
|
||||
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
|
||||
previousRecord?.label === nextRecord.label
|
||||
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
@ -6017,7 +5710,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
|
||||
}, []);
|
||||
const handleOpenReplacementTextEdit = useCallback((params: {
|
||||
modeType: ReplacementModeType;
|
||||
cardId: string;
|
||||
@ -6366,15 +6059,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
||||
}
|
||||
try {
|
||||
void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => {
|
||||
generationRequestSnapshotRef.current = {
|
||||
hiddenPrompt: null,
|
||||
replacementExecutionCardIds: null,
|
||||
replacementRerunReferenceImages: null,
|
||||
};
|
||||
setPendingGeneration(null);
|
||||
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
|
||||
});
|
||||
sendMessage({ role: "user", parts });
|
||||
if (selectedAgentPreset?.type !== "image") {
|
||||
setInput("");
|
||||
}
|
||||
@ -6961,19 +6646,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
: selectedModelId !== "auto"
|
||||
? selectedModelId
|
||||
: effectiveDefaultModelId || undefined;
|
||||
const requestedImageSize: DetailResultLocalEditImageSize =
|
||||
params.imageSize === "2K" ? "2K" : "1K";
|
||||
|
||||
setDetailLocalEditSubmittingId(cardId);
|
||||
setDetailLocalEditSubmittingMode(editMode);
|
||||
toast.info(
|
||||
`${isScaleAdjust ? "正在提交比例调整" : "正在提交局部编辑"}(${requestedImageSize})...`
|
||||
);
|
||||
toast.info(isScaleAdjust ? "正在提交比例调整..." : "正在提交局部编辑...");
|
||||
|
||||
try {
|
||||
const submitResult = await imageGenerationService.localEdit({
|
||||
aspectRatio: "auto",
|
||||
imageSize: requestedImageSize,
|
||||
imageSize: "2K",
|
||||
model:
|
||||
resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto"
|
||||
? resolvedLocalEditModelId
|
||||
@ -7191,23 +6872,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
delete next[normalizedUrl];
|
||||
return next;
|
||||
});
|
||||
if (modeType === "detail_replacement" || modeType === "sku_replacement") {
|
||||
setReplacementResultState((current) => {
|
||||
const currentModeState = current[modeType];
|
||||
const nextModeState = Object.fromEntries(
|
||||
Object.entries(currentModeState).filter(
|
||||
([, record]) => record.url !== normalizedUrl
|
||||
)
|
||||
);
|
||||
if (Object.keys(nextModeState).length === Object.keys(currentModeState).length) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[modeType]: nextModeState,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onDeleteResultImage(modeType, normalizedUrl);
|
||||
toast.success("结果图已删除");
|
||||
@ -9686,7 +9350,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: detailReplacementOrderedDownloadUrls,
|
||||
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
|
||||
scope: "detail",
|
||||
modeType: "detail_replacement",
|
||||
})
|
||||
}
|
||||
disabled={batchDownloadState === "detail"}
|
||||
@ -9704,7 +9367,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: mergedResultCollections.detail_generation,
|
||||
zipName: `${selectedAgentPreset?.name || "ai-detail"}-results`,
|
||||
scope: "detail",
|
||||
modeType: "detail_generation",
|
||||
})
|
||||
}
|
||||
disabled={batchDownloadState === "detail"}
|
||||
@ -9722,7 +9384,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: skuReplacementOrderedDownloadUrls,
|
||||
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
|
||||
scope: "sku",
|
||||
modeType: "sku_replacement",
|
||||
})
|
||||
}
|
||||
disabled={batchDownloadState === "sku"}
|
||||
@ -9739,7 +9400,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: activeResultModeUrls,
|
||||
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
|
||||
scope: "scene",
|
||||
modeType: "scene_generation",
|
||||
})
|
||||
}
|
||||
disabled={batchDownloadState === "scene"}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||
|
||||
export type AgentStyleSection =
|
||||
| "brand"
|
||||
@ -141,9 +140,6 @@ export function useAgentStylePerformanceMonitor({
|
||||
chatId,
|
||||
projectId,
|
||||
}: UseAgentStylePerformanceMonitorParams) {
|
||||
const persistedChatId =
|
||||
chatId && !isAgentModeScopedChatId(chatId) ? chatId : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) ||
|
||||
@ -204,7 +200,7 @@ export function useAgentStylePerformanceMonitor({
|
||||
browser,
|
||||
path: window.location.pathname,
|
||||
projectId: projectId || null,
|
||||
chatId: persistedChatId,
|
||||
chatId: chatId || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
@ -241,5 +237,5 @@ export function useAgentStylePerformanceMonitor({
|
||||
root.removeEventListener("keydown", markAction, true);
|
||||
root.removeEventListener("scroll", markAction, true);
|
||||
};
|
||||
}, [persistedChatId, projectId, rootRef]);
|
||||
}, [chatId, projectId, rootRef]);
|
||||
}
|
||||
|
||||
7017
components/ai-cad/ai-cad-workbench.tsx
Normal file
7017
components/ai-cad/ai-cad-workbench.tsx
Normal file
File diff suppressed because it is too large
Load Diff
47
components/ai-cad/zoo-design-studio-frame.tsx
Normal file
47
components/ai-cad/zoo-design-studio-frame.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
type ZooDesignStudioFrameProps = {
|
||||
isReady: boolean;
|
||||
src: string;
|
||||
};
|
||||
|
||||
export function ZooDesignStudioFrame({ isReady, src }: ZooDesignStudioFrameProps) {
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="flex h-svh w-full items-center justify-center overflow-hidden bg-[#10100e] p-6 text-[#f8f6ef]">
|
||||
<div className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-lg">
|
||||
HiCAD 官方前端尚未构建
|
||||
</h1>
|
||||
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
|
||||
请运行
|
||||
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
|
||||
pnpm zoo-modeling-app:vendor
|
||||
</code>
|
||||
生成官方 Zoo Design Studio 前端。生成后刷新本页即可使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-svh w-full overflow-hidden bg-[#10100e]">
|
||||
<iframe
|
||||
allow="clipboard-read; clipboard-write; fullscreen; gamepad; pointer-lock; web-share; xr-spatial-tracking"
|
||||
className="h-full w-full border-0"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts"
|
||||
src={src}
|
||||
title="HiCAD"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -189,7 +189,7 @@ export function AngleEditDialog({
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
overlayClassName="bg-black"
|
||||
className="max-w-4xl gap-0 overflow-hidden border border-border !bg-white p-0 shadow-2xl sm:rounded-[var(--ui-radius-panel)] dark:!bg-zinc-950"
|
||||
className="max-w-4xl p-0 gap-0 overflow-hidden border border-border shadow-2xl !bg-white dark:!bg-zinc-950 sm:rounded-[20px]"
|
||||
onPointerDownCapture={stopDialogEventCapture}
|
||||
onPointerMoveCapture={stopDialogEventCapture}
|
||||
onPointerUpCapture={stopDialogEventCapture}
|
||||
|
||||
@ -610,9 +610,7 @@ export function AiWashWorkspace({
|
||||
[presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId]
|
||||
);
|
||||
const selectedPresetHasExecutableModel = Boolean(
|
||||
selectedExecutionState?.effectiveModelRef &&
|
||||
selectedExecutionState?.resolvedModel &&
|
||||
selectedExecutionState.resolvedModel.selectable !== false
|
||||
selectedExecutionState?.effectiveModelRef
|
||||
);
|
||||
const shouldShowUserModelSelector = Boolean(
|
||||
!config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel
|
||||
@ -759,10 +757,7 @@ export function AiWashWorkspace({
|
||||
return;
|
||||
}
|
||||
if (!selectedPresetHasExecutableModel) {
|
||||
toast.error(
|
||||
selectedExecutionState?.resolvedModel?.disabledReason ||
|
||||
"当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"
|
||||
);
|
||||
toast.error("当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -823,15 +818,6 @@ export function AiWashWorkspace({
|
||||
if (!executionState.effectiveModelRef) {
|
||||
throw new Error(`${preset.title} 未配置可用模型`);
|
||||
}
|
||||
if (
|
||||
!executionState.resolvedModel ||
|
||||
executionState.resolvedModel.selectable === false
|
||||
) {
|
||||
throw new Error(
|
||||
executionState.resolvedModel?.disabledReason ||
|
||||
`${preset.title} 配置的模型不可用`
|
||||
);
|
||||
}
|
||||
|
||||
const submitResponse = await fetch("/api/ai-wash/generate", {
|
||||
method: "POST",
|
||||
@ -839,9 +825,7 @@ export function AiWashWorkspace({
|
||||
body: JSON.stringify({
|
||||
prompt: executionState.resolvedPrompt,
|
||||
referenceImage: requestReferenceImage,
|
||||
selectedModelId:
|
||||
executionState.resolvedModel.id ||
|
||||
executionState.effectiveModelRef,
|
||||
selectedModelId: executionState.effectiveModelRef,
|
||||
imageSize: config.imageSize,
|
||||
promptExtend: config.promptExtend,
|
||||
title: preset.title,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user