Compare commits

..

26 Commits

Author SHA1 Message Date
Your Name
6937217634 fix: resolve detail reference sku to spu 2026-06-02 13:18:39 +08:00
Your Name
84f105d4c8 fix: streamline detail reference template lookup 2026-06-02 12:59:14 +08:00
Your Name
824806406a fix: show regenerating state for style batch results 2026-06-02 11:43:44 +08:00
Your Name
6b72771701 fix: keep PSD clipped replacements in target layer order 2026-06-01 20:57:25 +08:00
Your Name
97cc9e4a0a fix: anchor PSD replacements to clip targets 2026-06-01 20:38:45 +08:00
Your Name
154ec44a4c fix: avoid empty PSD replacement masks 2026-06-01 20:26:56 +08:00
Your Name
2f0c533b87 fix: support PSD clipping mask target layers 2026-06-01 20:03:11 +08:00
Your Name
3ac209d149 fix: improve PSD template management and clipping masks 2026-06-01 19:40:22 +08:00
Your Name
97d7604e7c feat: add PSD force clipping mask option 2026-06-01 18:43:42 +08:00
Your Name
641cd800b2 Revert "fix: preserve PSD super lock mask editing"
This reverts commit 6d60d53910cf8cb3d65aade75496cf514d3df617.
2026-06-01 18:37:26 +08:00
Your Name
f5ee1fe592 Revert "fix: clip PSD super lock replacements"
This reverts commit 6e647021cdc52a083cace27801044838f11a5864.
2026-06-01 18:37:26 +08:00
Your Name
674a8d143e Revert "fix: honor PSD super locked image slots"
This reverts commit b189ca1b58e92df28f46d0c99dd3ae86a7edb4bc.
2026-06-01 18:37:02 +08:00
Your Name
b189ca1b58 fix: honor PSD super locked image slots 2026-06-01 18:33:12 +08:00
Your Name
6e647021cd fix: clip PSD super lock replacements 2026-06-01 18:23:13 +08:00
Your Name
6d60d53910 fix: preserve PSD super lock mask editing 2026-06-01 17:59:37 +08:00
Your Name
f050c807a4 fix: precompile PSD template render segments 2026-06-01 17:52:25 +08:00
Your Name
c15f6d435f fix: proxy material card previews 2026-06-01 16:46:54 +08:00
Your Name
42adff8545 fix: polish ai wash layout and sidebar overflow 2026-06-01 16:37:09 +08:00
Your Name
a701ddde50 fix: preserve manual detail cutlines 2026-06-01 15:06:17 +08:00
Your Name
d14fc0c188 fix: stabilize material grid hook order 2026-06-01 14:51:37 +08:00
Your Name
f455b433ec fix: stabilize detail text edits and cutline deletion 2026-06-01 14:43:34 +08:00
Your Name
6e0e4a5454 fix: guard material search client crashes 2026-06-01 14:24:28 +08:00
Your Name
5761fa77b1 fix: load material previews from originals 2026-06-01 14:08:30 +08:00
Your Name
fab8826123 fix: improve material preview loading 2026-06-01 13:56:21 +08:00
Your Name
c83a16545c fix: avoid canvas redirect shell flash 2026-06-01 13:21:10 +08:00
Your Name
f7cae037d5 refactor: move ai wash toolbar right 2026-06-01 13:09:57 +08:00
3609 changed files with 25761 additions and 710858 deletions

View File

@ -9,13 +9,6 @@
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
AUTH_SECRET=replace-with-random-secret AUTH_SECRET=replace-with-random-secret
# Stable pepper for user Access Key hashes. Generate independently from AUTH_SECRET.
ACCESS_KEY_PEPPER=replace-with-random-secret
# Authorizes trusted third-party services to provision user-scoped MCP keys.
# Must contain at least 32 characters. Never expose it to end users or browsers.
MCP_KEY_PROVISIONING_SECRET=replace-with-random-secret
# Public application URL. Required for production and self-hosted Auth.js. # Public application URL. Required for production and self-hosted Auth.js.
AUTH_URL=https://your-domain.com AUTH_URL=https://your-domain.com
NEXTAUTH_URL=https://your-domain.com NEXTAUTH_URL=https://your-domain.com
@ -105,25 +98,12 @@ WORLDLABS_API_KEY=
WORLDLABS_BASE_URL= WORLDLABS_BASE_URL=
WORLDLABS_IMPORT_TOKEN= WORLDLABS_IMPORT_TOKEN=
WORLDLABS_KEY_POOL_IMPORT_TOKEN= WORLDLABS_KEY_POOL_IMPORT_TOKEN=
WORLDLABS_API_TIMEOUT_MS=180000
WORLDLABS_TASK_CREATE_TIMEOUT_MS=180000
WORLDLABS_TASK_STATUS_TIMEOUT_MS=180000
KITTYCAD_API_TOKEN= KITTYCAD_API_TOKEN=
KITTYCAD_API_KEY= KITTYCAD_API_KEY=
KITTYCAD_BASE_URL=https://api.zoo.dev KITTYCAD_BASE_URL=https://api.zoo.dev
ZOO_API_TOKEN= ZOO_API_TOKEN=
ZOO_BASE_URL=https://api.zoo.dev ZOO_BASE_URL=https://api.zoo.dev
# Pascal Editor self-hosted workspace. In local development, leaving this blank
# makes /pascal-editor auto-detect http://localhost:3002. For production or a
# fixed deployment, point this to your self-hosted Pascal service or static
# /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 / video callback.
RUXA_CALLBACK_URL= RUXA_CALLBACK_URL=
RUXA_VIDEO_CALLBACK_URL= RUXA_VIDEO_CALLBACK_URL=
@ -153,9 +133,8 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues # Home Performance / Queues
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled. CANVAS_IMAGE_TASK_CONCURRENCY=3
CANVAS_IMAGE_TASK_CONCURRENCY=0 CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3 EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20 EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000 EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000

20
.gitattributes vendored
View File

@ -1,20 +0,0 @@
# Keep source checkouts consistent across Windows and Linux.
.gitattributes text eol=lf
.gitignore text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.json text eol=lf
*.jsonc text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.sh text eol=lf
*.py text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf

49
.gitignore vendored
View File

@ -11,9 +11,6 @@ coverage
# next.js # next.js
.next/ .next/
.next-build/
.next-previous/
/.next-*/
out/ out/
build build
@ -51,7 +48,6 @@ yarn-error.log*
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/* /playwright/*
/output/
deploy.tar.gz deploy.tar.gz
libvips-8.14.5-linux-x64.tar.br libvips-8.14.5-linux-x64.tar.br
.temp-packages/ .temp-packages/
@ -68,19 +64,10 @@ migration-backup-complete/
migration-*.log migration-*.log
migration-*.txt migration-*.txt
# Local release backups, model credentials and migration scratch data.
/.model-config-backups/
/backups/
/tmp/
# 本地抓包/竞品参考产物,不能提交 # 本地抓包/竞品参考产物,不能提交
artifacts/liblib-*/ artifacts/liblib-*/
analysis/qiaodou-extract/ analysis/qiaodou-extract/
# 本地运行时数据库与上传文件
.local/
public/uploads/local/
# 模型文件 # 模型文件
*.model *.model
*.pth *.pth
@ -100,41 +87,5 @@ checkpoints/
# 第三方参考源码(不提交到git) # 第三方参考源码(不提交到git)
reference-sources/ reference-sources/
# Legacy external Pascal Editor checkout (the managed source is under services/).
.pascal-editor/
# Local self-hosted Zoo Design Studio checkout.
.zoo-modeling-app/
# Generated official Zoo Design Studio web bundle.
public/vendor/zoo-modeling-app/
# Drizzle migrations must be versioned (override the global *.sql ignore above) # Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql !lib/db/migrations/*.sql
# Private smart edit runtime and media/model caches
.venv-smart-edit/
.smart-edit-models/
.cache/smart-edit/
services/video-render/dist/
# Downloaded and normalized subtitle fonts are runtime assets.
.cache/smart-edit-fonts/
services/smart-edit/__pycache__/
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
.qa/
.next-video-*/
.next-tvstar-*/
services/video-upscale/dist/
services/video-upscale/__pycache__/
services/video-upscale/vendor/__pycache__/
/services/audio-processing/dist/
/services/audio-processing/__pycache__/
# Python release-tool and test bytecode.
__pycache__/
*.pyc

View File

@ -94,7 +94,7 @@ pnpm db:generate
推荐提交前至少运行: 推荐提交前至少运行:
```bash ```bash
git fetch origin --prune git fetch github main --prune
pnpm check:branch pnpm check:branch
pnpm check:quality pnpm check:quality
pnpm audit:security pnpm audit:security

View File

@ -144,10 +144,18 @@ log.error("Operation failed", error, { userId: "123" });
#### 7. 修复 service 层错误吞没 #### 7. 修复 service 层错误吞没
**风险等级**: 🟡 中等风险 **风险等级**: 🟡 中等风险
**改动**: **改动**:
- `services/ai-cad/index.ts`: 完全重写,所有 catch 块现在记录错误
- `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移) - `services/canvas/index.ts`: 添加 logger 导入和辅助函数(未全部迁移)
**潜在问题**: **潜在问题**:
- `handleError(err, context, fallbackMessage)` 参数顺序与之前不同 - `handleError(err, context, fallbackMessage)` 参数顺序与之前不同
- 如果有其他代码依赖 ai-cad service 的错误格式,可能受影响
**测试建议**:
```bash
# 触发一个 AI-CAD 错误(如无效的 workspace ID)
# 检查日志是否包含完整错误堆栈
```
--- ---

View File

@ -1,706 +0,0 @@
"use client";
import {
Activity,
AlertTriangle,
Ban,
Clock3,
Globe2,
LogIn,
Plus,
RefreshCw,
Search,
ShieldAlert,
Unlock,
UserCheck,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn, fetcher } from "@/lib/utils";
type Severity = "info" | "watch" | "high" | "critical";
type AccessLogEvent = {
id: string;
eventType: string;
ip: string;
userEmail: string | null;
userRole: string | null;
method: string | null;
path: string | null;
routeGroup: string | null;
statusCode: number | null;
outcome: string;
severity: Severity;
reasons: string[] | null;
userAgent: string | null;
createdAt: string;
};
type AccessLogTopIp = {
ip: string;
total: number;
uniqueUsers: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
loginSuccessCount: number;
suspiciousCount: number;
firstSeen: string;
lastSeen: string;
severity: Severity;
failureRate: number;
reasons: string[];
};
type AccessLogResponse = {
overview: {
totalEvents: number;
uniqueIps: number;
abnormalEvents: number;
abnormalIps: number;
rateLimitedEvents: number;
manualBlockedEvents: number;
loginSuccessEvents: number;
unauthorizedEvents: number;
};
topIps: AccessLogTopIp[];
hourlyStats: Array<{
hour: string;
count: number;
abnormalCount: number;
}>;
pathStats: Array<{
path: string | null;
routeGroup: string | null;
count: number;
abnormalCount: number;
}>;
recentEvents: AccessLogEvent[];
rules: string[];
};
type AccessBlocklistEntry = {
id: string;
ip: string;
reason: string | null;
isActive: boolean;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
createdByEmail: string | null;
};
const severityLabels: Record<Severity, string> = {
info: "正常",
watch: "关注",
high: "异常",
critical: "高危",
};
const severityClassNames: Record<Severity, string> = {
info:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-300",
watch:
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/45 dark:text-amber-300",
high:
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-300",
critical:
"border-red-200 bg-red-50 text-red-700 dark:border-red-800/70 dark:bg-red-950/45 dark:text-red-300",
};
function formatNumber(value: number | null | undefined) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}
function formatPercent(value: number | null | undefined) {
return `${Math.round(Number(value || 0) * 100)}%`;
}
function formatTime(value: string | null | undefined) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function getStatusClassName(statusCode: number | null) {
if (!statusCode) return "text-muted-foreground";
if (statusCode >= 500) return "text-red-700 dark:text-red-300";
if (statusCode >= 400) return "text-orange-700 dark:text-orange-300";
if (statusCode >= 300) return "text-sky-700 dark:text-sky-300";
return "text-emerald-700 dark:text-emerald-300";
}
function AccessSeverityBadge({ severity }: { severity: Severity }) {
return (
<Badge
className={cn("shrink-0 border px-2 py-0.5", severityClassNames[severity])}
variant="outline"
>
{severityLabels[severity]}
</Badge>
);
}
export function AccessSecurityDashboard() {
const [hours, setHours] = useState("24");
const [severity, setSeverity] = useState("all");
const [query, setQuery] = useState("");
const [blockIp, setBlockIp] = useState("");
const [blockReason, setBlockReason] = useState("");
const [isSavingBlock, setIsSavingBlock] = useState(false);
const accessLogUrl = useMemo(() => {
const params = new URLSearchParams({
hours,
severity,
limit: "160",
});
if (query.trim()) params.set("q", query.trim());
return `/api/admin/access-logs?${params.toString()}`;
}, [hours, query, severity]);
const { data, isLoading, mutate } = useSWR<AccessLogResponse>(
accessLogUrl,
fetcher,
{ refreshInterval: 30_000 }
);
const { data: blocklistData, mutate: mutateBlocklist } = useSWR<{
entries: AccessBlocklistEntry[];
}>("/api/admin/access-blocklist", fetcher);
const activeBlocklistEntries = useMemo(
() => (blocklistData?.entries || []).filter((entry) => entry.isActive),
[blocklistData?.entries]
);
const blockedRules = useMemo(
() => new Set(activeBlocklistEntries.map((entry) => entry.ip.toLowerCase())),
[activeBlocklistEntries]
);
const saveBlockEntry = async (targetIp?: string, targetReason?: string) => {
const ip = String(targetIp || blockIp).trim();
const reason = String(targetReason ?? blockReason).trim();
if (!ip) {
toast.error("请输入要拉黑的 IP");
return;
}
setIsSavingBlock(true);
try {
const response = await fetch("/api/admin/access-blocklist", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ip, reason }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "拉黑失败");
}
toast.success(`${ip} 已加入手动黑名单`);
setBlockIp("");
setBlockReason("");
await Promise.all([mutateBlocklist(), mutate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "拉黑失败");
} finally {
setIsSavingBlock(false);
}
};
const updateBlockEntry = async (entry: AccessBlocklistEntry, isActive: boolean) => {
try {
const response = await fetch("/api/admin/access-blocklist", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: entry.id,
isActive,
reason: entry.reason || "",
expiresAt: entry.expiresAt || null,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "更新失败");
}
toast.success(isActive ? `${entry.ip} 已重新拉黑` : `${entry.ip} 已解除拉黑`);
await Promise.all([mutateBlocklist(), mutate()]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新失败");
}
};
const overviewCards = [
{
key: "events",
label: "访问记录",
value: formatNumber(data?.overview.totalEvents),
note: `${hours} 小时窗口`,
icon: Activity,
className:
"border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-700/70 dark:bg-slate-900/70 dark:text-slate-100",
},
{
key: "ips",
label: "独立 IP",
value: formatNumber(data?.overview.uniqueIps),
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
icon: Globe2,
className:
"border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800/70 dark:bg-sky-950/45 dark:text-sky-100",
},
{
key: "abnormal",
label: "异常事件",
value: formatNumber(data?.overview.abnormalEvents),
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
icon: ShieldAlert,
className:
"border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-100",
},
{
key: "login",
label: "成功登录",
value: formatNumber(data?.overview.loginSuccessEvents),
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
icon: LogIn,
className:
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-100",
},
];
const maxHourlyCount = Math.max(
1,
...(data?.hourlyStats || []).map((item) => item.count)
);
const maxPathCount = Math.max(
1,
...(data?.pathStats || []).map((item) => item.count)
);
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<CardTitle className="text-xl">访问安全分析</CardTitle>
<CardDescription>
IP 访问记录、登录来源、异常标注与请求趋势
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative sm:w-[260px]">
<Search className="-translate-y-1/2 absolute left-3 top-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="h-9 pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索 IP、路径、用户"
value={query}
/>
</div>
<Select onValueChange={setHours} value={hours}>
<SelectTrigger className="h-9 sm:w-[132px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">近 1 小时</SelectItem>
<SelectItem value="6">近 6 小时</SelectItem>
<SelectItem value="24">近 24 小时</SelectItem>
<SelectItem value="72">近 3 天</SelectItem>
<SelectItem value="168">近 7 天</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={setSeverity} value={severity}>
<SelectTrigger className="h-9 sm:w-[132px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="abnormal">仅异常</SelectItem>
<SelectItem value="watch">关注</SelectItem>
<SelectItem value="high">异常</SelectItem>
<SelectItem value="critical">高危</SelectItem>
</SelectContent>
</Select>
<Button
className="h-9 gap-2"
onClick={() => mutate()}
size="sm"
variant="outline"
>
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
刷新
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{overviewCards.map((item) => {
const Icon = item.icon;
return (
<div
className={cn("rounded-lg border p-4", item.className)}
key={item.key}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-current/70">{item.label}</div>
<Icon className="h-4 w-4" />
</div>
<div className="mt-3 font-semibold text-3xl tracking-tight">
{item.value}
</div>
<div className="mt-1 text-current/65 text-xs">{item.note}</div>
</div>
);
})}
</div>
<div className="rounded-lg border bg-background p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium text-sm">
<Clock3 className="h-4 w-4" />
小时趋势
</div>
<div className="text-muted-foreground text-xs">
橙色表示异常事件占比
</div>
</div>
<div className="flex h-[180px] items-end gap-1">
{(data?.hourlyStats || []).map((item) => {
const height = Math.max(4, (item.count / maxHourlyCount) * 100);
const abnormalHeight =
item.count > 0
? Math.max(0, (item.abnormalCount / item.count) * height)
: 0;
return (
<div
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200 dark:bg-slate-800"
key={item.hour}
style={{ height: `${height}%` }}
>
<div
className="w-full bg-orange-400"
style={{ height: `${abnormalHeight}%` }}
/>
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-white text-xs shadow-lg group-hover:block">
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "}
{formatNumber(item.abnormalCount)}
</div>
</div>
);
})}
{(!data?.hourlyStats || data.hourlyStats.length === 0) && (
<div className="flex h-full w-full items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
暂无访问记录
</div>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">手动 IP 黑名单</CardTitle>
<CardDescription>
管理员手动加入后,命中 IP 会被阻断并写入访问日志
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[220px_minmax(240px,1fr)_auto]">
<Input
className="h-9"
onChange={(event) => setBlockIp(event.target.value)}
placeholder="IP 或 IPv4 CIDR"
value={blockIp}
/>
<Input
className="h-9"
onChange={(event) => setBlockReason(event.target.value)}
placeholder="拉黑原因"
value={blockReason}
/>
<Button
className="h-9 gap-2"
disabled={isSavingBlock}
onClick={() => saveBlockEntry()}
size="sm"
>
<Plus className="h-4 w-4" />
加入黑名单
</Button>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{activeBlocklistEntries.map((entry) => (
<div
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3 dark:border-red-900/60 dark:bg-red-950/30"
key={entry.id}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ban className="h-4 w-4 text-red-600 dark:text-red-400" />
<span className="truncate font-mono font-semibold text-sm text-red-900 dark:text-red-200">
{entry.ip}
</span>
</div>
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs dark:text-red-300/75">
{entry.reason || "未填写原因"}
</div>
<div className="mt-2 text-red-700/70 text-xs dark:text-red-400/70">
{formatTime(entry.updatedAt)}
</div>
</div>
<Button
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-950/60"
onClick={() => updateBlockEntry(entry, false)}
size="sm"
variant="outline"
>
<Unlock className="h-3.5 w-3.5" />
解除
</Button>
</div>
</div>
))}
{activeBlocklistEntries.length === 0 ? (
<div className="rounded-lg border border-dashed py-8 text-center text-muted-foreground text-sm md:col-span-2 xl:col-span-3">
暂无手动拉黑 IP
</div>
) : null}
</div>
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">异常 IP 标注</CardTitle>
<CardDescription>
按限流、失败率、认证异常、敏感路径和请求量综合排序
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(data?.topIps || []).map((item) => (
<div
className="rounded-lg border bg-background px-4 py-3"
key={item.ip}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-semibold text-sm">
{item.ip}
</span>
<AccessSeverityBadge severity={item.severity} />
</div>
<div className="mt-2 flex flex-wrap gap-2 text-muted-foreground text-xs">
<span>{formatNumber(item.total)} 次访问</span>
<span>{formatNumber(item.uniqueUsers)} 个用户</span>
<span>失败率 {formatPercent(item.failureRate)}</span>
<span>最近 {formatTime(item.lastSeen)}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-1 md:justify-end">
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
(reason) => (
<Badge
className="border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
key={reason}
variant="outline"
>
{reason}
</Badge>
)
)}
<Button
className="h-7 gap-1 px-2 text-xs"
disabled={blockedRules.has(item.ip.toLowerCase()) || isSavingBlock}
onClick={() =>
saveBlockEntry(
item.ip,
item.reasons.join("、") || "管理员从异常 IP 排行手动拉黑"
)
}
size="sm"
variant="outline"
>
<Ban className="h-3.5 w-3.5" />
{blockedRules.has(item.ip.toLowerCase()) ? "已拉黑" : "拉黑"}
</Button>
</div>
</div>
</div>
))}
{(!data?.topIps || data.topIps.length === 0) && (
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
暂无 IP 聚合数据
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">访问路径</CardTitle>
<CardDescription>按请求数量排序</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(data?.pathStats || []).map((item) => {
const abnormalRate =
item.count > 0 ? item.abnormalCount / item.count : 0;
return (
<div key={`${item.routeGroup || "page"}-${item.path || "/"}`}>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="min-w-0 truncate font-medium">
{item.path || "/"}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{formatNumber(item.count)}
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<div
className="h-full rounded-full bg-zinc-900 dark:bg-zinc-200"
style={{
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
}}
/>
<div
className="-mt-2 h-full rounded-full bg-orange-400"
style={{
width: `${Math.round(abnormalRate * 100)}%`,
}}
/>
</div>
</div>
);
})}
{(!data?.pathStats || data.pathStats.length === 0) && (
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
暂无路径统计
</div>
)}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-lg">访问日志列表</CardTitle>
<CardDescription>最近 {formatNumber(data?.recentEvents.length)} 条</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
{(data?.rules || []).map((rule) => (
<Badge
className="border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
key={rule}
variant="outline"
>
{rule}
</Badge>
))}
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-lg border">
<div className="min-w-[980px]">
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs dark:bg-zinc-900/70">
<div>时间</div>
<div>IP</div>
<div>状态</div>
<div>路径</div>
<div>用户</div>
<div>标注</div>
</div>
{(data?.recentEvents || []).map((event) => (
<div
className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] items-center border-b px-4 py-3 text-sm last:border-b-0"
key={event.id}
>
<div className="text-muted-foreground text-xs">
{formatTime(event.createdAt)}
</div>
<div className="font-mono text-xs">{event.ip}</div>
<div
className={cn(
"font-semibold text-xs",
getStatusClassName(event.statusCode)
)}
>
{event.statusCode || "--"}
</div>
<div className="min-w-0">
<div className="truncate font-medium">
{event.method || "GET"} {event.path || "/"}
</div>
<div className="mt-1 truncate text-muted-foreground text-xs">
{event.reasons?.join("、") || event.outcome}
</div>
</div>
<div className="min-w-0">
<div className="truncate text-xs">
{event.userEmail || "匿名"}
</div>
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
<UserCheck className="h-3 w-3" />
{event.userRole || "unknown"}
</div>
</div>
<div className="flex items-center gap-2">
{event.severity === "high" || event.severity === "critical" ? (
<AlertTriangle className="h-4 w-4 text-orange-500" />
) : null}
<AccessSeverityBadge severity={event.severity} />
</div>
</div>
))}
{(!data?.recentEvents || data.recentEvents.length === 0) && (
<div className="py-10 text-center text-muted-foreground text-sm">
暂无访问日志
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -11,9 +11,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ADMIN_SETTINGS_NAV_ITEMS } from "../_lib/settings-navigation"; import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation";
import type { AdminSettingsNavItem } from "../_lib/types"; import type { AdminSettingsNavItem } from "../_lib/types";
const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items);
function SettingsQuickAction({ function SettingsQuickAction({
activeTab, activeTab,
item, item,
@ -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-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"; : "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 const layoutClassName = stretched
? "h-8 w-full min-w-0 justify-center 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-8 shrink-0 gap-1.5 rounded-md border px-2.5 text-xs"; : "h-9 shrink-0 gap-2 rounded-full border px-3.5";
if (item.href) { if (item.href) {
return ( return (
@ -43,7 +45,7 @@ function SettingsQuickAction({
variant="ghost" variant="ghost"
> >
<Link href={item.href}> <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> <span className={stretched ? "truncate" : ""}>{item.label}</span>
</Link> </Link>
</Button> </Button>
@ -57,7 +59,7 @@ function SettingsQuickAction({
size="sm" size="sm"
variant="ghost" variant="ghost"
> >
<Icon className="h-3.5 w-3.5" /> <Icon className="h-4 w-4" />
<span className={stretched ? "truncate" : ""}>{item.label}</span> <span className={stretched ? "truncate" : ""}>{item.label}</span>
</Button> </Button>
); );
@ -71,13 +73,9 @@ export function AdminSettingsNavigation({
onTabChange: (value: string) => void; onTabChange: (value: string) => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const activeItem = ADMIN_SETTINGS_NAV_ITEMS.find( const activeItem = ALL_SETTINGS_ITEMS.find((item) => item.value === activeTab);
(item) => item.value === activeTab
);
const handleSelectChange = (value: string) => { const handleSelectChange = (value: string) => {
const nextItem = ADMIN_SETTINGS_NAV_ITEMS.find( const nextItem = ALL_SETTINGS_ITEMS.find((item) => item.value === value);
(item) => item.value === value
);
if (nextItem?.href) { if (nextItem?.href) {
router.push(nextItem.href); router.push(nextItem.href);
return; return;
@ -86,18 +84,15 @@ export function AdminSettingsNavigation({
}; };
return ( return (
<nav <div className="rounded-2xl border bg-background px-4 py-3 shadow-sm">
aria-label="系统设置导航" <div className="flex flex-col gap-2 md:hidden">
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="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select onValueChange={handleSelectChange} value={activeTab}> <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 || "选择设置项"} /> <SelectValue placeholder={activeItem?.label || "选择设置项"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => ( {ALL_SETTINGS_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}> <SelectItem key={item.value} value={item.value}>
{item.label} {item.label}
</SelectItem> </SelectItem>
@ -107,7 +102,7 @@ export function AdminSettingsNavigation({
<Button <Button
asChild 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" size="sm"
variant="outline" variant="outline"
> >
@ -118,22 +113,35 @@ export function AdminSettingsNavigation({
</Button> </Button>
</div> </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>
<div className="hidden min-w-0 grid-cols-7 gap-1 md:grid xl:grid-cols-[repeat(13,minmax(78px,1fr))_auto]"> <div className="hidden min-w-0 items-center gap-3 md:flex">
{ADMIN_SETTINGS_NAV_ITEMS.map((item) => ( <div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
<SettingsQuickAction {ALL_SETTINGS_ITEMS.map((item) => (
activeTab={activeTab} <div className="min-w-[118px] flex-1" key={item.value}>
item={item} <SettingsQuickAction
key={item.value} activeTab={activeTab}
onTabChange={onTabChange} item={item}
stretched onTabChange={onTabChange}
/> stretched
))} />
</div>
))}
</div>
<Button <Button
asChild 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" size="sm"
variant="outline" variant="outline"
> >
@ -143,6 +151,6 @@ export function AdminSettingsNavigation({
</Link> </Link>
</Button> </Button>
</div> </div>
</nav> </div>
); );
} }

View File

@ -26,22 +26,22 @@ export function LocalAiCommandCenter({
onSetAllExpanded: (expanded: boolean) => void; onSetAllExpanded: (expanded: boolean) => void;
}) { }) {
return ( return (
<div className="overflow-hidden rounded-lg border bg-background"> <div className="overflow-hidden rounded-xl border bg-background">
<div className="border-b bg-muted/20 px-4 py-3"> <div className="border-b bg-muted/20 px-5 py-4">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1"> <div className="space-y-1.5">
<div className="font-semibold text-[10px] text-muted-foreground"> <div className="font-semibold text-muted-foreground text-xs">
SYSTEM INTEGRATIONS SYSTEM INTEGRATIONS
</div> </div>
<h2 className="font-semibold text-lg"> <h2 className="font-semibold text-2xl tracking-normal">
高级能力与外部服务 高级能力与外部服务
</h2> </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 把商品策划、图像处理、3D/CAD、实时画布和开放 API
按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。 按运维任务重新归类。折叠状态会保留,常用模块可以保持展开。
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-2">
<SettingStatusBadge tone="neutral"> <SettingStatusBadge tone="neutral">
已展开 {openSectionCount} 个模块 已展开 {openSectionCount} 个模块
</SettingStatusBadge> </SettingStatusBadge>
@ -52,7 +52,7 @@ export function LocalAiCommandCenter({
模型 {modelCount} 模型 {modelCount}
</SettingStatusBadge> </SettingStatusBadge>
<Button <Button
className="h-6 rounded-md px-2 text-[11px]" className="h-7 rounded-md px-2 text-xs"
onClick={() => onSetAllExpanded(true)} onClick={() => onSetAllExpanded(true)}
size="sm" size="sm"
type="button" type="button"
@ -61,7 +61,7 @@ export function LocalAiCommandCenter({
全部展开 全部展开
</Button> </Button>
<Button <Button
className="h-6 rounded-md px-2 text-[11px]" className="h-7 rounded-md px-2 text-xs"
onClick={() => onSetAllExpanded(false)} onClick={() => onSetAllExpanded(false)}
size="sm" size="sm"
type="button" type="button"
@ -77,21 +77,21 @@ export function LocalAiCommandCenter({
const Icon = item.icon; const Icon = item.icon;
return ( return (
<button <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} key={item.key}
onClick={() => onFocusSection(item.key)} onClick={() => onFocusSection(item.key)}
type="button" type="button"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2"> <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"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-muted/30">
<Icon className="h-3.5 w-3.5 text-muted-foreground" /> <Icon className="h-4 w-4 text-muted-foreground" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-semibold text-xs"> <div className="truncate font-semibold text-sm">
{item.title} {item.title}
</div> </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} {item.description}
</div> </div>
</div> </div>
@ -104,20 +104,20 @@ export function LocalAiCommandCenter({
); );
})} })}
</div> </div>
<div className="border-t bg-muted/10 px-4 py-3"> <div className="border-t bg-muted/10 px-5 py-4">
<div className="grid gap-3 lg:grid-cols-4"> <div className="grid gap-4 lg:grid-cols-4">
{LOCAL_AI_QUICK_LINK_GROUPS.map((group) => ( {LOCAL_AI_QUICK_LINK_GROUPS.map((group) => (
<div className="space-y-1.5" key={group}> <div className="space-y-2" key={group}>
<div className="font-semibold text-[11px] text-muted-foreground"> <div className="font-semibold text-muted-foreground text-xs">
{group} {group}
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-2">
{quickLinks {quickLinks
.filter((item) => item.group === group) .filter((item) => item.group === group)
.map((item) => ( .map((item) => (
<button <button
className={cn( 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) isSectionExpanded(item.key)
? "border-foreground/20 bg-background text-foreground" ? "border-foreground/20 bg-background text-foreground"
: "border-border bg-transparent text-muted-foreground" : "border-border bg-transparent text-muted-foreground"

View File

@ -24,13 +24,13 @@ export function LocalAiGroupHeading({
description: string; description: string;
}) { }) {
return ( return (
<div className="rounded-lg border bg-muted/20 px-3 py-2.5"> <div className="rounded-lg border bg-muted/20 px-4 py-3">
<div className="font-semibold text-[10px] text-muted-foreground uppercase"> <div className="font-semibold text-[11px] text-muted-foreground uppercase tracking-[0.16em]">
{eyebrow} {eyebrow}
</div> </div>
<div className="mt-1 flex flex-col gap-1 md:flex-row md:items-end md:justify-between"> <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> <h3 className="font-semibold text-base text-foreground">{title}</h3>
<p className="max-w-3xl text-muted-foreground text-xs">{description}</p> <p className="max-w-3xl text-muted-foreground text-sm">{description}</p>
</div> </div>
</div> </div>
); );
@ -45,7 +45,7 @@ export function LocalAiSectionSummary({
}>; }>;
}) { }) {
return ( return (
<div className="flex flex-wrap gap-1.5 pt-1"> <div className="flex flex-wrap gap-2 pt-1">
{items.map((item) => ( {items.map((item) => (
<SettingStatusBadge key={item.label} tone={item.tone || "neutral"}> <SettingStatusBadge key={item.label} tone={item.tone || "neutral"}>
{item.label} {item.label}
@ -65,16 +65,16 @@ export function LocalAiSectionToggleButton({
return ( return (
<Button <Button
aria-expanded={expanded} 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} onClick={onClick}
size="sm" size="sm"
type="button" type="button"
variant="outline" variant="outline"
> >
{expanded ? ( {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 ? "收起配置" : "展开配置"} {expanded ? "收起配置" : "展开配置"}
</Button> </Button>
@ -100,9 +100,9 @@ export function LocalAiSectionShell({
}) { }) {
return ( return (
<Card id={id}> <Card id={id}>
<CardHeader className="flex flex-col gap-3 space-y-0 md:flex-row md:items-start md:justify-between"> <CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1"> <div className="space-y-1.5">
<CardTitle className="text-base">{title}</CardTitle> <CardTitle className="text-xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>
{summary} {summary}
</div> </div>
@ -110,7 +110,7 @@ export function LocalAiSectionShell({
</CardHeader> </CardHeader>
<CardContent <CardContent
className={cn( className={cn(
"local-ai-config-card-content space-y-4 pt-4", "local-ai-config-card-content space-y-6 pt-6",
!expanded && "hidden" !expanded && "hidden"
)} )}
> >

View File

@ -75,16 +75,12 @@ export function LocalAiImagePipelineSections({
aiWashConfig, aiWashConfig,
setAiWashConfig, setAiWashConfig,
aiWashModeIconUploading, aiWashModeIconUploading,
canvasLocalEditConfig,
setCanvasLocalEditConfig,
extendConfig, extendConfig,
setExtendConfig, setExtendConfig,
watermarkRemoveConfig, watermarkRemoveConfig,
setWatermarkRemoveConfig, setWatermarkRemoveConfig,
angleEditConfig, angleEditConfig,
setAngleEditConfig, setAngleEditConfig,
pointsSystemConfig,
updatePointsCostField,
layerSplitConfig, layerSplitConfig,
setLayerSplitConfig, setLayerSplitConfig,
isLocalAiSectionExpanded, isLocalAiSectionExpanded,
@ -105,17 +101,14 @@ export function LocalAiImagePipelineSections({
handleMoveEnhanceProPreset, handleMoveEnhanceProPreset,
handleAiWashModeIconUpload, handleAiWashModeIconUpload,
handleSaveAiWashConfig, handleSaveAiWashConfig,
handleSaveCanvasLocalEditConfig,
handleSaveExtendConfig, handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig, handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig, handleSaveAngleEditConfig,
handleSaveLayerSplitConfig, handleSaveLayerSplitConfig,
}: LocalAiImagePipelineSectionsProps) { }: LocalAiImagePipelineSectionsProps) {
const availableImageModels = (modelsData?.models || []).filter( const availableImageModels = (modelsData?.models || []).filter(
(model) => model.supportsImageGen && model.isEnabled (model) => model.supportsImageGen && model.isEnabled && !model.isHidden
); );
const formatAiWashModelOptionLabel = (model: (typeof availableImageModels)[number]) =>
`${model.displayName || model.modelId}${model.isHidden ? " · 前台隐藏" : ""}`;
const getAiWashControlToneCopy = (controlId: AiWashControlId) => { const getAiWashControlToneCopy = (controlId: AiWashControlId) => {
if (controlId === "creativity") { if (controlId === "creativity") {
return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。"; return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。";
@ -159,15 +152,12 @@ export function LocalAiImagePipelineSections({
}, },
{ {
label: label:
rmbgConfig.provider === "vip98k" rmbgConfig.apiKey ||
? "服务商 Key 已配置"
: rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey || rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey rmbgConfig.aosApiKey
? "Key 已配置" ? "Key 已配置"
: "Key 待配置", : "Key 待配置",
tone: tone:
rmbgConfig.provider === "vip98k" ||
rmbgConfig.apiKey || rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey || rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey rmbgConfig.aosApiKey
@ -182,26 +172,7 @@ export function LocalAiImagePipelineSections({
{/* 服务提供商选择 */} {/* 服务提供商选择 */}
<div className="space-y-3"> <div className="space-y-3">
<Label>抠图服务提供商</Label> <Label>抠图服务提供商</Label>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "vip98k"
? "border-primary bg-accent ring-1 ring-primary"
: ""
}`}
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "vip98k" })}
>
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "vip98k"
? "bg-primary text-primary-foreground"
: "bg-transparent"
}`}
>
{rmbgConfig.provider === "vip98k" && <Check className="h-3 w-3" />}
</div>
<p className="font-medium text-sm leading-none">VIP98K 统一服务</p>
</div>
<div <div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${ className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "rmbg" rmbgConfig.provider === "rmbg"
@ -288,13 +259,6 @@ export function LocalAiImagePipelineSections({
<Separator /> <Separator />
{rmbgConfig.provider === "vip98k" && (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 remove-background 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,不会在此重复保存。
</div>
)}
{/* RMBG 配置 */} {/* RMBG 配置 */}
{rmbgConfig.provider === "rmbg" && ( {rmbgConfig.provider === "rmbg" && (
<> <>
@ -500,7 +464,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
配置图片变清晰服务,用于画图标注功能中的图片增强处理。 配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
</> </>
} }
expanded={isLocalAiSectionExpanded("enhance")} expanded={isLocalAiSectionExpanded("enhance")}
@ -510,21 +474,17 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary <LocalAiSectionSummary
items={[ items={[
{ {
label: enhanceConfig.provider === "vip98k" label: enhanceConfig.useBackupApi
? "VIP98K"
: enhanceConfig.useBackupApi
? "备用 Real-ESRGAN" ? "备用 Real-ESRGAN"
: "主 API", : "主 API",
tone: "neutral", tone: "neutral",
}, },
{ {
label: label:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "Key 已配置" ? "Key 已配置"
: "Key 待配置", : "Key 待配置",
tone: tone:
enhanceConfig.provider === "vip98k" ||
enhanceConfig.apiKey || enhanceConfig.backupApiKey enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "enabled" ? "enabled"
: "warning", : "warning",
@ -534,13 +494,6 @@ export function LocalAiImagePipelineSections({
} }
title="变清晰API配置" title="变清晰API配置"
> >
{enhanceConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 enhance 模型,默认 4 倍增强。API Key 从“AI 模型设置”中的
VIP98K 服务商读取。
</div>
) : (
<>
{/* API切换开关 */} {/* API切换开关 */}
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
@ -665,7 +618,7 @@ export function LocalAiImagePipelineSections({
</p> </p>
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p> <p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
<p> <p>
• 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸) • 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)
</p> </p>
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p> <p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
</div> </div>
@ -784,13 +737,11 @@ export function LocalAiImagePipelineSections({
<p>• 支持2x、4x、8x放大倍数</p> <p>• 支持2x、4x、8x放大倍数</p>
<p>• 处理时间:通常1-10秒,取决于图片大小</p> <p>• 处理时间:通常1-10秒,取决于图片大小</p>
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p> <p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
<p>• 文件限制:最大20MB</p> <p>• 文件限制:最大50MB</p>
</div> </div>
</div> </div>
</> </>
)} )}
</>
)}
<Button onClick={handleSaveEnhanceConfig}> <Button onClick={handleSaveEnhanceConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置 <Save className="mr-2 h-4 w-4" /> 保存配置
@ -1466,112 +1417,6 @@ export function LocalAiImagePipelineSections({
</Button> </Button>
</LocalAiSectionShell> </LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
</>
}
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
id="local-ai-section-canvas-local-edit"
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
summary={
<LocalAiSectionSummary
items={[
{
label: canvasLocalEditConfig.modelId
? "普通局部编辑已指定"
: "普通局部编辑继承默认",
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
},
{
label: canvasLocalEditConfig.scaleAdjustModelId
? "比例调整已指定"
: "比例调整兼容旧默认",
tone: canvasLocalEditConfig.scaleAdjustModelId
? "enabled"
: "neutral",
},
]}
/>
}
title="局部编辑模型配置"
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
modelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.modelId || "__none__"}
>
<SelectTrigger id="canvasLocalEditModel">
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于手动画框并填写提示词的局部编辑。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
scaleAdjustModelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
>
<SelectTrigger id="canvasScaleAdjustModel">
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
</div>
</div>
</div>
<Button onClick={handleSaveCanvasLocalEditConfig}>
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
@ -1602,12 +1447,6 @@ export function LocalAiImagePipelineSections({
label: aiWashConfig.modeLabel || "AI洗图", label: aiWashConfig.modeLabel || "AI洗图",
tone: "neutral", tone: "neutral",
}, },
{
label: aiWashConfig.hideModelSelector
? "前台隐藏模型"
: "前台可选模型",
tone: aiWashConfig.hideModelSelector ? "enabled" : "neutral",
},
]} ]}
/> />
} }
@ -1794,23 +1633,6 @@ export function LocalAiImagePipelineSections({
} }
/> />
</div> </div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className="font-medium text-sm">隐藏前台生图模型选择</div>
<div className="text-muted-foreground text-xs">
开启后,AI洗图页面不再显示生图模型下拉框,直接使用后台预设或默认模型。
</div>
</div>
<Switch
checked={aiWashConfig.hideModelSelector}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
hideModelSelector: checked,
}))
}
/>
</div>
</div> </div>
<div className="space-y-4 rounded-xl border p-4"> <div className="space-y-4 rounded-xl border p-4">
@ -1921,7 +1743,7 @@ export function LocalAiImagePipelineSections({
{availableImageModels.length > 0 ? ( {availableImageModels.length > 0 ? (
availableImageModels.map((model) => ( availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}> <SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)} {model.displayName || model.modelId}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
@ -2283,7 +2105,7 @@ export function LocalAiImagePipelineSections({
key={model.id} key={model.id}
value={model.modelId} value={model.modelId}
> >
{formatAiWashModelOptionLabel(model)} {model.displayName || model.modelId}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
@ -2366,7 +2188,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理 配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
</> </>
} }
expanded={isLocalAiSectionExpanded("extend")} expanded={isLocalAiSectionExpanded("extend")}
@ -2376,11 +2198,8 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionSummary <LocalAiSectionSummary
items={[ items={[
{ {
label: label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
extendConfig.provider === "vip98k" tone: extendConfig.apiKey ? "enabled" : "warning",
? "VIP98K 服务商"
: "阿里云服务商",
tone: "enabled",
}, },
{ {
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`, label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
@ -2391,18 +2210,22 @@ export function LocalAiImagePipelineSections({
} }
title="图像拓展API配置" title="图像拓展API配置"
> >
{extendConfig.provider === "vip98k" ? ( <div className="space-y-2">
<div className="rounded-lg border bg-muted/40 p-4 text-sm"> <Label htmlFor="extendApiKey">API密钥 *</Label>
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的 <Input
VIP98K 服务商读取,仅在这里维护扩展比例。 id="extendApiKey"
onChange={(e) =>
setExtendConfig({ ...extendConfig, apiKey: e.target.value })
}
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={extendConfig.apiKey}
/>
<div className="text-muted-foreground text-xs">
请输入阿里云Dashscope API密钥,例如:
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</div> </div>
) : ( </div>
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用阿里云 DashScope 的 image-out-painting 模型。API Key
从“AI 模型设置”中已启用的阿里云官方生图服务商读取,
仅在这里维护拓展比例。
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="extendXScale">X轴放大倍数 *</Label> <Label htmlFor="extendXScale">X轴放大倍数 *</Label>
@ -2456,7 +2279,7 @@ export function LocalAiImagePipelineSections({
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
</p> </p>
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p> <p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
<p>• 最大图片大小:10MB</p> <p>• 最大图片大小:4MB</p>
<p>• 处理时间:约5-15秒/张</p> <p>• 处理时间:约5-15秒/张</p>
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p> <p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
</div> </div>
@ -2560,13 +2383,6 @@ export function LocalAiImagePipelineSections({
label: `${angleEditConfig.inferenceSteps} steps`, label: `${angleEditConfig.inferenceSteps} steps`,
tone: "neutral", tone: "neutral",
}, },
{
label: `${pointsSystemConfig.creditCostPerImageAngleEdit || 0} 积分/次`,
tone:
pointsSystemConfig.creditCostPerImageAngleEdit > 0
? "neutral"
: "disabled",
},
]} ]}
/> />
} }
@ -2637,28 +2453,6 @@ export function LocalAiImagePipelineSections({
<Separator /> <Separator />
{/* 积分消耗 */}
<div className="space-y-2">
<Label htmlFor="angleEditCreditCost">积分消耗</Label>
<Input
id="angleEditCreditCost"
min={0}
onChange={(e) =>
updatePointsCostField(
"creditCostPerImageAngleEdit",
e.target.value
)
}
type="number"
value={pointsSystemConfig.creditCostPerImageAngleEdit}
/>
<div className="text-muted-foreground text-xs">
每次成功生成多角度图片后扣除的积分;填写 0 表示免费。保存本配置时会同步保存到全站积分设置。
</div>
</div>
<Separator />
{/* LoRA 强度 */} {/* LoRA 强度 */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between">

View File

@ -372,10 +372,9 @@ export function LocalAiModel3dSections({
<Input <Input
id="worldlabsKeyPoolImportToken" id="worldlabsKeyPoolImportToken"
onChange={(e) => onChange={(e) =>
setWorldlabsKeyPoolConfig((prev) => ({ setWorldlabsKeyPoolConfig({
...prev,
importToken: e.target.value, importToken: e.target.value,
})) })
} }
placeholder="供 /api/worldlabs/key-pool/import 调用的 Bearer Token" placeholder="供 /api/worldlabs/key-pool/import 调用的 Bearer Token"
type="password" type="password"
@ -916,8 +915,8 @@ export function LocalAiModel3dSections({
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI 配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
提供商配置。 服务,保存后写入数据库 AI 提供商配置。
</> </>
} }
expanded={isLocalAiSectionExpanded("kittycad-cad")} expanded={isLocalAiSectionExpanded("kittycad-cad")}
@ -1003,7 +1002,7 @@ export function LocalAiModel3dSections({
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label className="text-sm">启用 KittyCAD CAD</Label> <Label className="text-sm">启用 KittyCAD CAD</Label>
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务 关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -590,7 +590,7 @@ export function LocalAiPlanningSections({
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
为 AI详情生成 单独配置 Kimi Code 编程套餐 为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.6
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。 模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
</> </>
} }
@ -615,7 +615,7 @@ export function LocalAiPlanningSections({
]} ]}
/> />
} }
title={<>Kimi Code 编程套餐配置</>} title={<>Moonshot 官方 Kimi K2.6 配置</>}
> >
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="moonshotProviderName">提供商名称</Label> <Label htmlFor="moonshotProviderName">提供商名称</Label>
@ -627,13 +627,13 @@ export function LocalAiPlanningSections({
providerName: e.target.value, providerName: e.target.value,
})) }))
} }
placeholder="Kimi Code 编程套餐" placeholder="Moonshot AI (Official)"
value={moonshotDetailAgentConfig.providerName} value={moonshotDetailAgentConfig.providerName}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="moonshotBaseUrl">编程套餐专用 API 地址</Label> <Label htmlFor="moonshotBaseUrl">官方 API 地址</Label>
<Input <Input
id="moonshotBaseUrl" id="moonshotBaseUrl"
onChange={(e) => onChange={(e) =>
@ -646,7 +646,7 @@ export function LocalAiPlanningSections({
value={moonshotDetailAgentConfig.baseUrl} value={moonshotDetailAgentConfig.baseUrl}
/> />
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
根据 Kimi Code 文档,编程套餐 OpenAI 兼容接口地址为 ` 根据 Moonshot 官方文档,OpenAI 兼容接口地址为 `
{DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}` {DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL}`
</div> </div>
</div> </div>
@ -667,7 +667,7 @@ export function LocalAiPlanningSections({
placeholder={ placeholder={
moonshotDetailAgentConfig.providerId moonshotDetailAgentConfig.providerId
? "留空则不修改当前 Key" ? "留空则不修改当前 Key"
: "请输入 Kimi Code 控制台 API Key" : "请输入 Moonshot 官方 API Key"
} }
type="password" type="password"
value={moonshotDetailAgentConfig.apiKey} value={moonshotDetailAgentConfig.apiKey}
@ -675,7 +675,7 @@ export function LocalAiPlanningSections({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="moonshotModelId">Kimi Code API 模型 ID *</Label> <Label htmlFor="moonshotModelId">Kimi K2.6 API 模型 ID *</Label>
<Input <Input
id="moonshotModelId" id="moonshotModelId"
onChange={(e) => onChange={(e) =>
@ -688,8 +688,8 @@ export function LocalAiPlanningSections({
value={moonshotDetailAgentConfig.modelId} value={moonshotDetailAgentConfig.modelId}
/> />
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
Kimi Code 编程套餐专用接口固定使用 已按你提供的 Moonshot 官方文档默认填写
`kimi-for-coding`;保存后系统会自动创建/更新对应模型记录。 `kimi-k2.6`;保存后系统会自动创建/更新对应模型记录。
</div> </div>
</div> </div>
@ -745,7 +745,7 @@ export function LocalAiPlanningSections({
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handleSaveMoonshotDetailAgentConfig}> <Button onClick={handleSaveMoonshotDetailAgentConfig}>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
保存 Kimi Code 编程套餐配置 保存 Moonshot Kimi K2.6 配置
</Button> </Button>
</div> </div>
</LocalAiSectionShell> </LocalAiSectionShell>

View File

@ -1078,15 +1078,139 @@ export function LocalAiRuntimeSections({
</Button> </Button>
</LocalAiSectionShell> </LocalAiSectionShell>
{/* 全站生图单模型并发 */}
<LocalAiSectionShell <LocalAiSectionShell
description="生图任务直接发起,图片先显示,后台转存到存储桶。" description={
<>
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
</>
}
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")} expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
id="local-ai-section-canvas-image-task-queue" id="local-ai-section-canvas-image-task-queue"
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")} onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />} summary={
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>} <LocalAiSectionSummary
items={[
{
label: `${canvasImageTaskQueueConfig.concurrency} 并发/模型`,
tone: "neutral",
},
{
label: `${(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒补位`,
tone: "neutral",
},
{ label: "全站生图生效", tone: "enabled" },
]}
/>
}
title={
<span className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
全站生图单模型并发与批次间隔
</span>
}
> >
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p> <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 text-sm">
<div className="font-medium">生效范围</div>
<div className="mt-2 space-y-1 text-amber-800 text-xs leading-5">
<p>
•
影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。
</p>
<p>
•
多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。
</p>
<p>
• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。
</p>
<p>
•
批次间隔只控制排队补位的“下一批”发起节奏,不影响首批任务即时启动。
</p>
<p>
• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key
池继续细分。
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="canvasImageTaskQueueConcurrency">
单模型最大同时运行任务数
</Label>
<Input
id="canvasImageTaskQueueConcurrency"
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
min={MIN_CANVAS_IMAGE_TASK_CONCURRENCY}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
concurrency: e.target.value,
})
)
}
type="number"
value={canvasImageTaskQueueConfig.concurrency}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_TASK_CONCURRENCY}-
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
{canvasImageTaskQueueConfig.concurrency} 并发,默认值为{" "}
{DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasImageTaskBatchDispatchIntervalSeconds">
下一批排队任务发起间隔(秒)
</Label>
<Input
id="canvasImageTaskBatchDispatchIntervalSeconds"
max={MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
min={MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
batchDispatchIntervalMs: Math.round(
Number.parseFloat(e.target.value || "0") * 1000
),
})
)
}
step="0.5"
type="number"
value={String(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
)}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}-
{MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000} 秒,当前为{" "}
{(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)}{" "}
秒,默认值为{" "}
{(
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000
).toFixed(1)}{" "}
秒。
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
<Save className="mr-2 h-4 w-4" /> 保存并发与间隔
</Button>
</div>
</LocalAiSectionShell> </LocalAiSectionShell>
{/* 批量处理配置 */} {/* 批量处理配置 */}

View File

@ -15,7 +15,7 @@ export function SettingStatusBadge({
return ( return (
<Badge <Badge
className={cn( 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" && tone === "enabled" &&
"border-emerald-200 bg-emerald-50 text-emerald-700", "border-emerald-200 bg-emerald-50 text-emerald-700",
tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500", tone === "disabled" && "border-zinc-200 bg-zinc-50 text-zinc-500",

View File

@ -12,7 +12,6 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
rmbg: false, rmbg: false,
enhance: false, enhance: false,
"enhance-pro": false, "enhance-pro": false,
"canvas-local-edit": false,
"ai-wash": false, "ai-wash": false,
extend: false, extend: false,
"watermark-remove": false, "watermark-remove": false,

View File

@ -9,7 +9,6 @@ import {
MessageSquare, MessageSquare,
Scissors, Scissors,
Settings, Settings,
ShieldAlert,
Sparkles, Sparkles,
User, User,
} from "lucide-react"; } from "lucide-react";
@ -38,12 +37,6 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
description: "用量、趋势与运营指标", description: "用量、趋势与运营指标",
icon: BarChart3, icon: BarChart3,
}, },
{
value: "access-security",
label: "访问安全",
description: "访问 IP、异常标注与登录来源",
icon: ShieldAlert,
},
], ],
}, },
{ {
@ -114,7 +107,3 @@ export const ADMIN_SETTINGS_NAV_GROUPS: AdminSettingsNavGroup[] = [
], ],
}, },
]; ];
export const ADMIN_SETTINGS_NAV_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap(
(group) => group.items
);

View File

@ -1,7 +1,6 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react"; import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config"; import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
import type { AiWashConfig } from "@/lib/ai-wash/config"; import type { AiWashConfig } from "@/lib/ai-wash/config";
import type { import type {
EnhanceProKreaConfig, EnhanceProKreaConfig,
@ -9,7 +8,6 @@ import type {
EnhanceProPreset, EnhanceProPreset,
} from "@/lib/enhance-pro/config"; } from "@/lib/enhance-pro/config";
import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-config"; import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-config";
import type { PointsSystemConfig } from "@/lib/points/config";
import type { ProductPlanningApiConfig } from "@/lib/product-planning/config"; import type { ProductPlanningApiConfig } from "@/lib/product-planning/config";
import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config"; import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config";
import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections"; import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections";
@ -87,12 +85,7 @@ export type AdminAiProvider = {
| "bfl-image" | "bfl-image"
| "ruxa-image" | "ruxa-image"
| "fal-image" | "fal-image"
| "snapgen-image"
| "openai-image" | "openai-image"
| "bananaimage"
| "visionary"
| "atlascloud"
| "vip98k"
| "stepfun-image" | "stepfun-image"
| "midjourney" | "midjourney"
| "fal-video" | "fal-video"
@ -174,9 +167,6 @@ export type AiProviderSettingsConfig = {
export type WorldLabsKeyPoolConfig = { export type WorldLabsKeyPoolConfig = {
importToken: string; importToken: string;
providerName: string;
baseUrl: string;
isEnabled: boolean;
}; };
export type LocalAiModel3dSectionsProps = LocalAiSectionControlProps & { export type LocalAiModel3dSectionsProps = LocalAiSectionControlProps & {
@ -249,7 +239,7 @@ export type EnhanceProKeyRow = {
}; };
export type RmbgConfig = { export type RmbgConfig = {
provider: "vip98k" | "rmbg" | "photoroom" | "aoscdn"; provider: "rmbg" | "photoroom" | "aoscdn";
apiUrl: string; apiUrl: string;
apiKey: string; apiKey: string;
photoroomApiKey: string; photoroomApiKey: string;
@ -261,7 +251,6 @@ export type RmbgConfig = {
}; };
export type EnhanceConfig = { export type EnhanceConfig = {
provider: "vip98k" | "legacy";
apiUrl: string; apiUrl: string;
apiKey: string; apiKey: string;
type: string; type: string;
@ -274,7 +263,7 @@ export type EnhanceConfig = {
}; };
export type ExtendConfig = { export type ExtendConfig = {
provider: "vip98k" | "dashscope"; apiKey: string;
xScale: string; xScale: string;
yScale: string; yScale: string;
}; };
@ -333,19 +322,12 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
aiWashConfig: AiWashConfig; aiWashConfig: AiWashConfig;
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>; setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
aiWashModeIconUploading: boolean; aiWashModeIconUploading: boolean;
canvasLocalEditConfig: CanvasLocalEditConfig;
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
extendConfig: ExtendConfig; extendConfig: ExtendConfig;
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>; setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
watermarkRemoveConfig: WatermarkRemoveConfig; watermarkRemoveConfig: WatermarkRemoveConfig;
setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>; setWatermarkRemoveConfig: Dispatch<SetStateAction<WatermarkRemoveConfig>>;
angleEditConfig: AngleEditConfig; angleEditConfig: AngleEditConfig;
setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>; setAngleEditConfig: Dispatch<SetStateAction<AngleEditConfig>>;
pointsSystemConfig: PointsSystemConfig;
updatePointsCostField: (
key: keyof PointsSystemConfig,
value: unknown
) => void;
layerSplitConfig: LayerSplitConfig; layerSplitConfig: LayerSplitConfig;
setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>; setLayerSplitConfig: Dispatch<SetStateAction<LayerSplitConfig>>;
renderEnhanceProPresetEditor: ( renderEnhanceProPresetEditor: (
@ -377,7 +359,6 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void; handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
handleAiWashModeIconUpload: (file: File) => Promise<void>; handleAiWashModeIconUpload: (file: File) => Promise<void>;
handleSaveAiWashConfig: AsyncAction; handleSaveAiWashConfig: AsyncAction;
handleSaveCanvasLocalEditConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction; handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction; handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction; handleSaveAngleEditConfig: AsyncAction;

File diff suppressed because it is too large Load Diff

View File

@ -1926,7 +1926,7 @@ const AGENT_WORKFLOW_SETTINGS_FIELD_LABELS: Record<AgentWorkflowSettingsField, s
agentWorkflowDefaultModelId: "风格化专用默认模型", agentWorkflowDefaultModelId: "风格化专用默认模型",
agentPresetLibraryConfig: "预设库", agentPresetLibraryConfig: "预设库",
agentWorkflowUiConfig: "工作流步骤", agentWorkflowUiConfig: "工作流步骤",
agentWorkflowMaterialApiConfig: "第三方图片素材", agentWorkflowMaterialApiConfig: "素材搜索接口",
agentWorkflowGoodsInfoApiConfig: "商品信息接口", agentWorkflowGoodsInfoApiConfig: "商品信息接口",
agentWorkflowBrandModelConfig: "品牌模特", agentWorkflowBrandModelConfig: "品牌模特",
agentWorkflowShowcaseConfig: "页面展示", agentWorkflowShowcaseConfig: "页面展示",
@ -5933,7 +5933,7 @@ export function AgentWorkflowSettingsClient({
if (isLoading) { if (isLoading) {
return ( 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 h-[60vh] items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
@ -5945,18 +5945,18 @@ export function AgentWorkflowSettingsClient({
} }
return ( 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 */}
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <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="container mx-auto px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground"> <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-4 w-4 text-background" /> <Sparkles className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
<h1 className="text-base font-semibold">风格化工作流配置</h1> <h1 className="text-xl font-semibold tracking-tight">风格化工作流配置</h1>
<p className="text-xs text-muted-foreground"> <p className="text-sm text-muted-foreground">
配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤 配置风格化页面的视频海报、素材搜索、品牌模特及工作流步骤
</p> </p>
</div> </div>
@ -6006,6 +6006,7 @@ export function AgentWorkflowSettingsClient({
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
size="sm" size="sm"
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500"
> >
{isSaving ? ( {isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -6141,8 +6142,8 @@ export function AgentWorkflowSettingsClient({
{/* Stats Bar */} {/* Stats Bar */}
<div className="border-b bg-muted/30"> <div className="border-b bg-muted/30">
<div className="container mx-auto px-4 py-2.5 sm:px-5"> <div className="container mx-auto px-6 py-3">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono"> <Badge variant="secondary" className="font-mono">
{enabledStepsCount} {enabledStepsCount}
@ -6196,8 +6197,8 @@ export function AgentWorkflowSettingsClient({
</div> </div>
{/* Main Content */} {/* Main Content */}
<main className="container mx-auto p-4 sm:px-5"> <main className="container mx-auto p-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-5 lg:w-fit"> <TabsList className="grid w-full grid-cols-5 lg:w-fit">
<TabsTrigger value="showcase" className="gap-2"> <TabsTrigger value="showcase" className="gap-2">
<MonitorPlay className="h-4 w-4" /> <MonitorPlay className="h-4 w-4" />
@ -6858,19 +6859,17 @@ export function AgentWorkflowSettingsClient({
<Search className="h-4 w-4 text-emerald-600" /> <Search className="h-4 w-4 text-emerald-600" />
</div> </div>
<div> <div>
<CardTitle className="text-base">第三方图片素材配置</CardTitle> <CardTitle className="text-base">素材搜索接口</CardTitle>
<CardDescription> <CardDescription>配置白底图、详情参考图、SKU 参考图共用接口</CardDescription>
配置白底图、任务-详情整套预览、详情参考图和 SKU 参考图共用接口
</CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3"> <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div> <div>
<div className="text-sm font-medium">启用第三方图片素材</div> <div className="text-sm font-medium">启用素材搜索</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
开启后前台和预留接口都可以查询标准模型白底图与任务-详情整套素材 开启后支持搜索白底素材图、详情参考图和 SKU 参考图
</div> </div>
</div> </div>
<Switch <Switch
@ -6891,22 +6890,6 @@ export function AgentWorkflowSettingsClient({
placeholder="https://..." placeholder="https://..."
/> />
</div> </div>
<div className="space-y-2">
<Label className="text-xs">统一接口路径</Label>
<Input
value={materialApiConfig.endpointPath || "/standard-model-white-background"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
endpointPath: e.target.value,
}))
}
placeholder="/standard-model-white-background"
/>
<p className="text-xs text-muted-foreground">
按文档默认调用 /api/external/standard-model-white-background;也支持填写完整 URL。
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs">API Key</Label> <Label className="text-xs">API Key</Label>
<Input <Input
@ -6919,104 +6902,6 @@ export function AgentWorkflowSettingsClient({
/> />
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">标准模型白底图</div>
<div className="text-xs text-muted-foreground">
对应 resourceType=white-background
</div>
</div>
<Switch
checked={materialApiConfig.whiteBackgroundEnabled !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
whiteBackgroundEnabled: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">任务-详情整套预览</div>
<div className="text-xs text-muted-foreground">
对应 resourceType=task-detail
</div>
</div>
<Switch
checked={materialApiConfig.taskDetailEnabled !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
taskDetailEnabled: checked,
}))
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label className="text-xs">任务详情资源主域名</Label>
<Input
value={materialApiConfig.resourceBaseUrl || "https://s3.jiansun.vip"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
resourceBaseUrl: e.target.value,
}))
}
placeholder="https://s3.jiansun.vip"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">备用资源域名</Label>
<Input
value={materialApiConfig.fallbackResourceBaseUrl || "https://s3.yydd.ai"}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
fallbackResourceBaseUrl: e.target.value,
}))
}
placeholder="https://s3.yydd.ai"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">请求超时 ms</Label>
<Input
type="number"
min={3000}
max={120000}
step={1000}
value={materialApiConfig.requestTimeoutMs || 30000}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
requestTimeoutMs: Number(e.target.value) || 30000,
}))
}
/>
</div>
</div>
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
已预留前端接口:
<code className="mx-1 rounded bg-muted px-1 py-0.5">
/api/third-party-image-materials/search
</code>
,支持
<code className="mx-1 rounded bg-muted px-1 py-0.5">
resourceType=white-background|task-detail
</code>
与
<code className="mx-1 rounded bg-muted px-1 py-0.5">
sku/spu
</code>
查询。
</div>
<Separator /> <Separator />
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3"> <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">

View File

@ -2430,17 +2430,17 @@ export function PsdTemplatesSettingsClient({
); );
return ( 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"> <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="container mx-auto px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground"> <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-4 w-4 text-background" /> <Sparkles className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
<h1 className="text-base font-semibold">PSD 模板配置</h1> <h1 className="text-xl font-semibold tracking-tight">PSD 模板配置</h1>
<p className="text-xs text-muted-foreground"> <p className="text-sm text-muted-foreground">
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出 上传 PSD 模板、管理字体库、配置图片和文字槽位,并在风格化结果区直接套版导出
</p> </p>
</div> </div>
@ -2457,6 +2457,7 @@ export function PsdTemplatesSettingsClient({
onClick={handleSaveTemplate} onClick={handleSaveTemplate}
disabled={!draftTemplate || isSaving} disabled={!draftTemplate || isSaving}
size="sm" size="sm"
className="bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
> >
{isSaving ? ( {isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -2471,8 +2472,8 @@ export function PsdTemplatesSettingsClient({
</header> </header>
<div className="border-b bg-muted/30"> <div className="border-b bg-muted/30">
<div className="container mx-auto px-4 py-2.5 sm:px-5"> <div className="container mx-auto px-6 py-3">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-4">
<Badge variant="secondary" className="font-mono"> <Badge variant="secondary" className="font-mono">
{templates.length} {templates.length}
</Badge> </Badge>
@ -2508,8 +2509,8 @@ export function PsdTemplatesSettingsClient({
</div> </div>
</div> </div>
<div className="container mx-auto px-4 py-4 sm:px-5"> <div className="container mx-auto px-6 py-6">
<div className="space-y-4"> <div className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">品类标签</CardTitle> <CardTitle className="text-base">品类标签</CardTitle>
@ -2580,10 +2581,7 @@ export function PsdTemplatesSettingsClient({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">上传新模板</CardTitle> <CardTitle className="text-base">上传新模板</CardTitle>
<CardDescription> <CardDescription>支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。</CardDescription>
支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。复杂 PSB
如提示不兼容,请先在 Photoshop 中另存为 PSD 后上传。
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { Plus, Save, Trash2, Wand2 } from "lucide-react"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { ArrowLeft, Plus, Save, Trash2, Wand2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { toast } from "sonner"; import { toast } from "sonner";
@ -25,7 +25,6 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { fetcher } from "@/lib/utils"; import { fetcher } from "@/lib/utils";
import { AdminSettingsNavigation } from "../_components/admin-settings-navigation";
import { import {
STYLE_PRO_CONFIG_KEY, STYLE_PRO_CONFIG_KEY,
defaultStyleProConfig, defaultStyleProConfig,
@ -323,7 +322,6 @@ function CustomFieldEditor({
} }
export function StyleProSettingsClient() { export function StyleProSettingsClient() {
const router = useRouter();
const { data, mutate } = useSWR<SettingsResponse>( const { data, mutate } = useSWR<SettingsResponse>(
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`, `/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
fetcher fetcher
@ -563,17 +561,19 @@ export function StyleProSettingsClient() {
}; };
return ( return (
<div className="settings-workspace w-full px-3 py-3 sm:px-4 xl:px-5"> <main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<AdminSettingsNavigation <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
activeTab="style-pro"
onTabChange={(value) => router.push(`/settings?tab=${value}`)}
/>
<main className="mt-6 flex w-full flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div> <div>
<h1 className="font-semibold text-xl">风格化 PRO 设置</h1> <div className="mb-3">
<p className="mt-1 max-w-2xl text-muted-foreground text-xs leading-5"> <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 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。 配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
</p> </p>
</div> </div>
@ -592,7 +592,7 @@ export function StyleProSettingsClient() {
{saving ? "保存中" : "保存配置"} {saving ? "保存中" : "保存配置"}
</Button> </Button>
</div> </div>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
@ -1193,7 +1193,6 @@ export function StyleProSettingsClient() {
))} ))}
</CardContent> </CardContent>
</Card> </Card>
</main> </main>
</div>
); );
} }

View File

@ -1,8 +1,5 @@
"use client"; "use client";
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { toast } from "sonner"; import { toast } from "sonner";
@ -15,7 +12,6 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -25,14 +21,15 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { fetcher } from "@/lib/utils"; import { fetcher } from "@/lib/utils";
import { import {
getVideoCanvasFunctionBillingCopy, getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig, type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition, type VideoCanvasFunctionDefinition,
type VideoCanvasEffectPreset,
type VideoCanvasMotionPreset,
type VideoCanvasRolePreset,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY, VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig, defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig, normalizeVideoCanvasFunctionConfig,
@ -84,23 +81,56 @@ function getModelOptionLabel(model: AiModelOption) {
return `${name}${id}`; return `${name}${id}`;
} }
function isImageModelOption(model: AiModelOption) {
return isImageGenerationModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function isVideoModelOption(model: AiModelOption) {
return isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function filterModelsForFunctionKind(
models: AiModelOption[],
kind: VideoCanvasFunctionDefinition["kind"],
) {
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
let filtered = enabled;
if (kind === "image" || kind === "image-edit") {
filtered = enabled.filter(isImageModelOption);
} else if (kind === "video") {
filtered = enabled.filter(isVideoModelOption);
} else if (kind === "multimodal") {
filtered = enabled.filter(
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
);
}
return filtered.length ? filtered : enabled;
}
function ModelSelect({ function ModelSelect({
allowInherit = false, allowInherit = false,
kind, kind,
functionKey,
models, models,
onChange, onChange,
value, value,
}: { }: {
allowInherit?: boolean; allowInherit?: boolean;
kind: VideoCanvasFunctionDefinition["kind"]; kind: VideoCanvasFunctionDefinition["kind"];
functionKey?: string;
models: AiModelOption[]; models: AiModelOption[];
onChange: (value: string) => void; onChange: (value: string) => void;
value: string; value: string;
}) { }) {
const modelKind = functionKey === "audio-to-video" ? "audio-video" : kind === "image" || kind === "image-edit" || kind === "video" || kind === "multimodal" ? kind : "text"; const filteredModels = filterModelsForFunctionKind(models, kind);
const filteredModels = filterCanvasModels(models, modelKind);
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto"); const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
const hasCurrent = const hasCurrent =
normalizedValue === "auto" || normalizedValue === "auto" ||
@ -169,9 +199,8 @@ export function VideoCanvasSettingsClient() {
const functions = useMemo( const functions = useMemo(
() => () =>
Object.values(config.functions).sort((a, b) => { Object.values(config.functions).sort((a, b) => {
const compare = (left: string, right: string) => const kindCompare = a.kind.localeCompare(b.kind);
left === right ? 0 : left < right ? -1 : 1; return kindCompare || a.label.localeCompare(b.label);
return compare(a.kind, b.kind) || compare(a.label, b.label);
}), }),
[config.functions], [config.functions],
); );
@ -196,135 +225,6 @@ export function VideoCanvasSettingsClient() {
})); }));
}; };
const updateEffectPreset = (
index: number,
updates: Partial<VideoCanvasEffectPreset>,
) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const updateMotionPreset = (
index: number,
updates: Partial<VideoCanvasMotionPreset>,
) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addMotionPreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
motionPresets: [
...current.motionPresets,
{
id: `motion-${createdAt}`,
name: "新运镜预设",
previewUrl: "/video-canvas/effects/multi-angle.webp",
prompt: "镜头平稳移动,保持主体和空间关系清晰。",
},
],
}));
};
const removeMotionPreset = (index: number) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.filter(
(_, presetIndex) => presetIndex !== index,
),
}));
};
const addEffectPreset = () => {
setConfig((current) => ({
...current,
effectPresets: [
...current.effectPresets,
{
id: `effect-${Date.now()}`,
title: "新视频特效",
author: "官方预设",
usageCount: 0,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/product-light-sweep.webp",
prompt: "保持参考主体稳定,应用自然、连续的视频特效和镜头运动。",
commercial: true,
},
],
}));
};
const removeEffectPreset = (index: number) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const updateRolePreset = (
index: number,
updates: Partial<VideoCanvasRolePreset>,
) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addRolePreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
rolePresets: [
...current.rolePresets,
{
id: `role-${createdAt}`,
assetId: `public-role:role-${createdAt}`,
name: "新角色预设",
accent: "#525252",
description: "管理员配置的公共角色素材。",
prompt: "保持角色身份、五官、发型、服装和体型在所有视频镜头中一致。",
tags: "角色",
compliant: true,
images: {
expression: "",
face: "",
full: "/video-canvas/effects/product-light-sweep.webp",
threeView: "",
},
filters: {
age: "青年",
build: "匀称",
culture: "东亚",
era: "现代",
gender: "中性",
hairColor: "黑发",
species: "人类",
},
},
],
}));
};
const removeRolePreset = (index: number) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
try { try {
@ -355,17 +255,11 @@ export function VideoCanvasSettingsClient() {
}; };
return ( return (
<main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5"> <main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div> <div>
<Button asChild className="mb-2" size="sm" variant="outline"> <h1 className="text-2xl font-semibold tracking-tight">视频画布功能设置</h1>
<Link href="/settings"> <p className="mt-2 max-w-3xl text-sm text-muted-foreground">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
返回设置
</Link>
</Button>
<h1 className="text-xl font-semibold">视频画布功能设置</h1>
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。 这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
</p> </p>
</div> </div>
@ -427,354 +321,6 @@ export function VideoCanvasSettingsClient() {
</CardContent> </CardContent>
</Card> </Card>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频运镜素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“运镜广场”的名称、预览图和插入提示词都从这里读取。点击运镜卡会按竞品规则把提示词追加到用户输入框,允许组合和重复使用。
</p>
</div>
<Button onClick={addMotionPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加运镜
</Button>
</div>
<div className="grid gap-3">
{config.motionPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-14 shrink-0 rounded-md object-cover"
src={preset.previewUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.prompt || "未设置插入提示词"}
</div>
</div>
<Button
aria-label={`删除运镜 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeMotionPreset(index);
}}
size="icon"
title={`删除运镜 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`motion-name-${index}`}>运镜名称</Label>
<Input
id={`motion-name-${index}`}
onChange={(event) =>
updateMotionPreset(index, { name: event.target.value })
}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`motion-preview-${index}`}>预览图地址</Label>
<Input
id={`motion-preview-${index}`}
onChange={(event) =>
updateMotionPreset(index, { previewUrl: event.target.value })
}
value={preset.previewUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`motion-prompt-${index}`}>插入提示词</Label>
<Textarea
id={`motion-prompt-${index}`}
onChange={(event) =>
updateMotionPreset(index, { prompt: event.target.value })
}
value={preset.prompt}
/>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频特效素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“特效”面板、素材节点封面、模型约束和生成提示词都从这里读取。选择特效时会创建结构化素材节点,不会把描述硬写进用户提示词。
</p>
</div>
<Button onClick={addEffectPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加特效
</Button>
</div>
<div className="grid gap-3">
{config.effectPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-12 w-12 shrink-0 rounded-md object-cover"
src={preset.thumbnailUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.title}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.author} · {preset.model} · {preset.usageCount.toLocaleString()} 次使用
</div>
</div>
<Button
aria-label={`删除特效 ${preset.title}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeEffectPreset(index);
}}
size="icon"
title={`删除特效 ${preset.title}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`effect-title-${index}`}>特效名称</Label>
<Input
id={`effect-title-${index}`}
onChange={(event) => updateEffectPreset(index, { title: event.target.value })}
value={preset.title}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-author-${index}`}>作者</Label>
<Input
id={`effect-author-${index}`}
onChange={(event) => updateEffectPreset(index, { author: event.target.value })}
value={preset.author}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-model-${index}`}>适用模型</Label>
<Input
id={`effect-model-${index}`}
onChange={(event) => updateEffectPreset(index, { model: event.target.value })}
value={preset.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-count-${index}`}>展示使用次数</Label>
<Input
id={`effect-count-${index}`}
min={0}
onChange={(event) =>
updateEffectPreset(index, {
usageCount: Math.max(0, Number(event.target.value) || 0),
})
}
type="number"
value={preset.usageCount}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-thumbnail-${index}`}>封面地址</Label>
<Input
id={`effect-thumbnail-${index}`}
onChange={(event) =>
updateEffectPreset(index, { thumbnailUrl: event.target.value })
}
value={preset.thumbnailUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-prompt-${index}`}>生成提示词 / 后端规则</Label>
<Textarea
id={`effect-prompt-${index}`}
onChange={(event) => updateEffectPreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.commercial}
id={`effect-commercial-${index}`}
onCheckedChange={(checked) => updateEffectPreset(index, { commercial: checked })}
/>
<Label htmlFor={`effect-commercial-${index}`}>标记为可商用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频角色素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“角色库”的公共角色、Seedance 合规资产 ID、四类角色图和隐藏生成提示词都从这里读取。角色引用通过画布连线保存,不会改写用户输入框。
</p>
</div>
<Button onClick={addRolePreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加角色
</Button>
</div>
<div className="grid gap-3">
{config.rolePresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-11 shrink-0 rounded-md object-cover object-top"
src={preset.images.full}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.assetId} · {preset.tags || "未设置标签"}
</div>
</div>
<Button
aria-label={`删除角色 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeRolePreset(index);
}}
size="icon"
title={`删除角色 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`role-name-${index}`}>角色名称</Label>
<Input
id={`role-name-${index}`}
onChange={(event) => updateRolePreset(index, { name: event.target.value })}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`role-asset-${index}`}>合规资产 ID</Label>
<Input
id={`role-asset-${index}`}
onChange={(event) => updateRolePreset(index, { assetId: event.target.value })}
value={preset.assetId}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-tags-${index}`}>展示标签</Label>
<Input
id={`role-tags-${index}`}
onChange={(event) => updateRolePreset(index, { tags: event.target.value })}
value={preset.tags}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-description-${index}`}>角色说明</Label>
<Textarea
id={`role-description-${index}`}
onChange={(event) => updateRolePreset(index, { description: event.target.value })}
value={preset.description}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-prompt-${index}`}>生成提示词 / 身份一致性规则</Label>
<Textarea
id={`role-prompt-${index}`}
onChange={(event) => updateRolePreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
{([
["full", "角色立绘"],
["face", "脸部近景"],
["expression", "表情参考"],
["threeView", "三视图"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-${key}-${index}`}>{label}地址</Label>
<Input
id={`role-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
images: { ...preset.images, [key]: event.target.value },
})
}
value={preset.images[key]}
/>
</div>
))}
{([
["gender", "性别"],
["age", "年龄段"],
["species", "种族"],
["era", "时代"],
["culture", "文化区域"],
["build", "体型"],
["hairColor", "发色"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-filter-${key}-${index}`}>{label}</Label>
<Input
id={`role-filter-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
filters: { ...preset.filters, [key]: event.target.value },
})
}
value={preset.filters[key]}
/>
</div>
))}
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.compliant}
id={`role-compliant-${index}`}
onCheckedChange={(checked) => updateRolePreset(index, { compliant: checked })}
/>
<Label htmlFor={`role-compliant-${index}`}>可在 Seedance2.0 合规素材库中使用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2> <h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
@ -815,14 +361,13 @@ export function VideoCanvasSettingsClient() {
<div className="grid gap-4 md:grid-cols-1"> <div className="grid gap-4 md:grid-cols-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label> <Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
{["canvas-generate-image", "canvas-generate-video", "canvas-workflow-analyze", "pick-object"].includes(item.route) ? <ModelSelect <ModelSelect
allowInherit allowInherit
functionKey={item.key}
kind={item.kind} kind={item.kind}
models={modelOptions} models={modelOptions}
onChange={(value) => updateFunction(item.key, { model: value })} onChange={(value) => updateFunction(item.key, { model: value })}
value={item.model || ""} value={item.model || ""}
/> : <p className="text-sm text-muted-foreground">{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}</p>} />
</div> </div>
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2"> <div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
<div className="text-xs font-medium text-foreground"> <div className="text-xs font-medium text-foreground">

View File

@ -17,7 +17,6 @@ import {
requiresSingleActiveSession, requiresSingleActiveSession,
revokeSingleActiveSession, revokeSingleActiveSession,
} from "@/lib/auth/session-control"; } from "@/lib/auth/session-control";
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
export type UserType = "guest" | "regular" | "admin" | "agent_admin"; export type UserType = "guest" | "regular" | "admin" | "agent_admin";
@ -201,10 +200,7 @@ async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
} }
const nextAuth = NextAuth(async () => { const nextAuth = NextAuth(async () => {
const runtimeAuthSettings = const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
process.env.CANVAS_E2E_LOCAL === "true"
? null
: await getAuthSettings().catch(() => null as any);
const resolvedAuthUrl = const resolvedAuthUrl =
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined; (await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
const googleClientId = const googleClientId =
@ -287,32 +283,6 @@ const nextAuth = NextAuth(async () => {
delete token.sessionKey; 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; return token;
}, },
session({ session, token }) { session({ session, token }) {

View File

@ -501,6 +501,11 @@ export default function Page() {
const displayLogo = mounted const displayLogo = mounted
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo) ? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
: siteLogo; : siteLogo;
const shouldInvertLogo =
mounted &&
resolvedTheme === "dark" &&
!siteLogoDark?.trim() &&
Boolean(siteLogo?.trim());
const hasThirdPartyLogin = const hasThirdPartyLogin =
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) || (isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
(isGoogleEnabled && isGoogleConfigured); (isGoogleEnabled && isGoogleConfigured);
@ -540,7 +545,7 @@ export default function Page() {
<img <img
src={displayLogo} src={displayLogo}
alt={siteName} alt={siteName}
className="mb-2 h-12 w-auto object-contain" className={`mb-2 h-12 w-auto object-contain ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
/> />
) : null} ) : null}
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3> <h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>

View File

@ -1,44 +1,7 @@
import { existsSync } from "node:fs"; import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
import path from "node:path";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
const ZOO_MODELING_APP_SRC =
"/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "HiCAD",
};
export const runtime = "nodejs";
export default async function AiCadPage() { export default function AiCadPage() {
const session = await auth(); return <AiCadWorkbench />;
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fai-cad");
}
const isReady = existsSync(
path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
);
if (isReady) {
redirect(ZOO_MODELING_APP_SRC);
}
return (
<main className="flex h-svh w-full items-center justify-center bg-[#10100e] p-6 text-[#f8f6ef]">
<section className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
<h1 className="font-semibold text-lg">HiCAD 官方前端尚未构建</h1>
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
请运行
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
pnpm zoo-modeling-app:vendor
</code>
生成官方前端,然后刷新本页。
</p>
</section>
</main>
);
} }

View File

@ -52,8 +52,6 @@ export function normalizeAgentReportHeadingLine(line: string): string {
.trim(); .trim();
const knownTitles = [ const knownTitles = [
"意图分析",
"任务规划",
"需求理解", "需求理解",
"图片分析", "图片分析",
"执行计划", "执行计划",

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getRequestErrorMessage, isProviderBalanceError, isRateLimitError } from "@/lib/request-error"; import { getRequestErrorMessage } from "@/lib/request-error";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.errors"); const chatApiLogger = createLogger("chat.errors");
@ -20,14 +20,6 @@ export function getChatStreamErrorMessage(error: unknown): string {
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim(); error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
const normalized = rawMessage.toLowerCase(); const normalized = rawMessage.toLowerCase();
if (isProviderBalanceError(error)) {
return "当前模型服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
}
if (isRateLimitError(error)) {
return "当前模型服务请求过于频繁,请稍后重试或切换模型。";
}
if ( if (
normalized.includes("token quota is not enough") || normalized.includes("token quota is not enough") ||
normalized.includes("pre_consume_token_quota_failed") || normalized.includes("pre_consume_token_quota_failed") ||
@ -55,16 +47,6 @@ function resolveChatApiFailure(error: unknown) {
const detailMessage = getRequestErrorMessage(error); const detailMessage = getRequestErrorMessage(error);
const normalized = String(detailMessage || "").toLowerCase(); const normalized = String(detailMessage || "").toLowerCase();
if (isProviderBalanceError(error)) {
return {
code: "server_error:chat",
message: "模型服务商线路余额或额度不足,请切换模型或联系管理员。",
status: 503,
category: "provider_balance",
detailMessage,
};
}
if ( if (
normalized.includes("payload too large") || normalized.includes("payload too large") ||
normalized.includes("request entity too large") || normalized.includes("request entity too large") ||

View File

@ -1,11 +1,8 @@
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image"; import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media"; import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
const chatApiLogger = createLogger("chat.imageFailover"); const chatApiLogger = createLogger("chat.imageFailover");
@ -15,12 +12,7 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"bfl-image", "bfl-image",
"ruxa-image", "ruxa-image",
"fal-image", "fal-image",
"snapgen-image",
"openai-image", "openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image", "stepfun-image",
"runway", "runway",
"krea", "krea",
@ -33,47 +25,10 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000; const IMAGE_FAILOVER_MODEL_COOLDOWN_MS = 30 * 60 * 1000;
const imageFailoverModelCooldown = new Map<string, { until: number; reason: string }>(); const imageFailoverModelCooldown = new Map<string, { until: number; reason: string }>();
export function resolveImageModelRuntimeId(
model: { defaultParams?: unknown; modelId?: unknown; id?: unknown } | null | undefined,
) {
const defaultParams = model?.defaultParams;
const defaultEndpoint =
typeof defaultParams === "object" &&
defaultParams !== null &&
"endpoint" in defaultParams
? String((defaultParams as Record<string, unknown>).endpoint || "").trim()
: "";
if (defaultEndpoint) {
return defaultEndpoint;
}
const modelId = String(model?.modelId || "").trim();
if (modelId) {
return modelId;
}
return String(model?.id || "").trim();
}
export function normalizeFailoverErrorMessage(error: unknown): string { export function normalizeFailoverErrorMessage(error: unknown): string {
if (!error) return ""; if (!error) return "";
if (typeof error === "string") return error; if (typeof error === "string") return error;
if (error instanceof Error) { if (error instanceof Error) return error.message || String(error);
const record = error as Error & {
status?: unknown;
statusCode?: unknown;
httpStatus?: unknown;
response?: { status?: unknown };
};
const status =
record.status ??
record.statusCode ??
record.httpStatus ??
record.response?.status;
const message = error.message || String(error);
return status ? `[HTTP ${String(status)}] ${message}` : message;
}
try { try {
return JSON.stringify(error); return JSON.stringify(error);
} catch { } catch {
@ -84,7 +39,6 @@ export function normalizeFailoverErrorMessage(error: unknown): string {
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean { export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase(); const msg = errorMessage.toLowerCase();
return ( return (
isProviderBalanceError(errorMessage) ||
msg.includes("model not found") || msg.includes("model not found") ||
msg.includes("model_not_found") || msg.includes("model_not_found") ||
msg.includes("no available channel for model") || msg.includes("no available channel for model") ||
@ -93,23 +47,7 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
msg.includes("api key is missing") || msg.includes("api key is missing") ||
msg.includes("invalid api key") || msg.includes("invalid api key") ||
msg.includes("unauthorized") || msg.includes("unauthorized") ||
/(?:^|\D)401(?:\D|$)/.test(msg) || msg.includes("cannot convert argument to a bytestring")
msg === "forbidden" ||
msg.includes("access forbidden") ||
msg.includes("access denied") ||
msg.includes("permission denied") ||
/(?:^|\D)403(?:\D|$)/.test(msg) ||
msg.includes("vip98k task failed") ||
msg.includes("cannot convert argument to a bytestring") ||
msg.includes("model maintenance") ||
msg.includes("maintenance") ||
msg.includes("维护中") ||
msg.includes("维护") ||
msg.includes("service unavailable") ||
msg.includes("temporarily unavailable") ||
msg.includes("系统繁忙") ||
msg.includes("无可用渠道") ||
/(?:^|\D)(?:429|500|502|503|504)(?:\D|$)/.test(msg)
); );
} }
@ -139,142 +77,14 @@ export type ImageFallbackCandidate = {
provider: any; provider: any;
configured: boolean; configured: boolean;
sameProvider: boolean; sameProvider: boolean;
nameSimilarity: number;
sameName: boolean;
}; };
const IMAGE_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
function getModelRefKeys(item: { model?: any } | null | undefined) {
return [
String(item?.model?.id || "").trim(),
String(item?.model?.modelId || "").trim(),
].filter(Boolean);
}
function normalizeModelNameForSimilarity(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[「」『』【】[\]()(){}]/g, " ")
.replace(/[_/|::,,.。+]+/g, "-")
.replace(/\s+/g, " ");
}
function tokenizeModelNameForSimilarity(value: unknown) {
const normalized = normalizeModelNameForSimilarity(value);
const compact = normalized.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
const tokens = new Set(
normalized
.split(/[^a-z0-9\u4e00-\u9fa5]+/g)
.map((item) => item.trim())
.filter((item) => item.length >= 2)
);
if (compact.length >= 2) {
tokens.add(compact);
for (let index = 0; index < compact.length - 1; index += 1) {
tokens.add(compact.slice(index, index + 2));
}
}
return tokens;
}
function scoreModelNameSimilarity(
source: { model?: any } | null | undefined,
candidate: { model?: any } | null | undefined
) {
const sourceNames = [
source?.model?.displayName,
source?.model?.modelId,
].filter(Boolean);
const candidateNames = [
candidate?.model?.displayName,
candidate?.model?.modelId,
].filter(Boolean);
let bestScore = 0;
for (const sourceName of sourceNames) {
const sourceTokens = tokenizeModelNameForSimilarity(sourceName);
if (sourceTokens.size === 0) continue;
for (const candidateName of candidateNames) {
const candidateTokens = tokenizeModelNameForSimilarity(candidateName);
if (candidateTokens.size === 0) continue;
let overlap = 0;
sourceTokens.forEach((token) => {
if (candidateTokens.has(token)) overlap += 1;
});
const score = overlap / Math.max(sourceTokens.size, candidateTokens.size);
bestScore = Math.max(bestScore, score);
}
}
return bestScore;
}
function resolveCurrentImageModelEntry(
allModels: Array<{ model: any; provider: any }>,
refs: string[]
) {
const normalizedRefs = new Set(refs.map((item) => String(item || "").trim()).filter(Boolean));
if (normalizedRefs.size === 0) return null;
return (
allModels.find((item) =>
getModelRefKeys(item).some((key) => normalizedRefs.has(key))
) || null
);
}
function messagePartHasImageInput(part: any) {
if (!part || typeof part !== "object") return false;
if (part.type === "file" || part.type === "image" || part.type === "image_url") {
return true;
}
if (typeof part.url === "string" && /^https?:\/\//i.test(part.url)) {
return true;
}
if (typeof part.image === "string" && part.image.trim()) {
return true;
}
if (typeof part.image_url?.url === "string" && part.image_url.url.trim()) {
return true;
}
return false;
}
function countImageInputPartsInMessage(message: any) {
const countParts = (parts: any[] | undefined) =>
Array.isArray(parts) ? parts.filter(messagePartHasImageInput).length : 0;
const contentCount = countParts(Array.isArray(message?.content) ? message.content : undefined);
if (contentCount > 0) return contentCount;
return countParts(Array.isArray(message?.parts) ? message.parts : undefined);
}
export function countChatMessageImageInputs(messages: ChatMessage[]) {
return messages.reduce(
(count, message) => count + countImageInputPartsInMessage(message),
0
);
}
export function chatMessagesHaveImageInput(messages: ChatMessage[]) {
return countChatMessageImageInputs(messages) > 0;
}
export function getImageFailoverConvertProvider(providerType?: string | null) { export function getImageFailoverConvertProvider(providerType?: string | null) {
switch (providerType) { switch (providerType) {
case "bfl-image": case "bfl-image":
case "ruxa-image": case "ruxa-image":
case "fal-image": case "fal-image":
case "snapgen-image":
case "openai-image": case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "stepfun-image": case "stepfun-image":
case "midjourney": case "midjourney":
case "runway": case "runway":
@ -301,10 +111,6 @@ export function buildImageFallbackCandidates(
} }
) { ) {
const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options; const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options;
const currentEntry = resolveCurrentImageModelEntry(allModels, [
currentModelId || "",
currentModelKey || "",
]);
const configuredRefs = configuredFallbackIds const configuredRefs = configuredFallbackIds
.map((item) => String(item || "").trim()) .map((item) => String(item || "").trim())
.filter(Boolean); .filter(Boolean);
@ -326,9 +132,9 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) { if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false; return false;
} }
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) { if (isImageFallbackModelCoolingDown(item.model.id)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id); const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`);
return false; return false;
} }
return true; return true;
@ -344,7 +150,6 @@ export function buildImageFallbackCandidates(
const candidates: ImageFallbackCandidate[] = []; const candidates: ImageFallbackCandidate[] = [];
const seenModelIds = new Set<string>(); const seenModelIds = new Set<string>();
const candidateOrder = new Map<string, number>();
const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => { const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => {
if (!item?.model) return; if (!item?.model) return;
const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId); const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId);
@ -352,15 +157,11 @@ export function buildImageFallbackCandidates(
const modelId = String(item.model.id || ""); const modelId = String(item.model.id || "");
if (!modelId || seenModelIds.has(modelId)) return; if (!modelId || seenModelIds.has(modelId)) return;
seenModelIds.add(modelId); seenModelIds.add(modelId);
candidateOrder.set(modelId, candidateOrder.size);
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
candidates.push({ candidates.push({
model: item.model, model: item.model,
provider: item.provider, provider: item.provider,
configured, configured,
sameProvider, sameProvider,
nameSimilarity,
sameName: nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
}); });
}; };
@ -374,24 +175,7 @@ export function buildImageFallbackCandidates(
.forEach((item) => pushCandidate(item, false)); .forEach((item) => pushCandidate(item, false));
} }
if (currentEntry) { return candidates;
allModels
.filter((item) => scoreModelNameSimilarity(currentEntry, item) >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD)
.forEach((item) => pushCandidate(item, false));
}
return candidates.sort((left, right) => {
if (left.sameName !== right.sameName) return left.sameName ? -1 : 1;
if (left.configured !== right.configured) return left.configured ? -1 : 1;
if (left.sameProvider !== right.sameProvider) return left.sameProvider ? -1 : 1;
if (right.nameSimilarity !== left.nameSimilarity) {
return right.nameSimilarity - left.nameSimilarity;
}
return (
(candidateOrder.get(String(left.model.id || "")) ?? 0) -
(candidateOrder.get(String(right.model.id || "")) ?? 0)
);
});
} }
export async function generateImageWithFailoverCandidate(params: { export async function generateImageWithFailoverCandidate(params: {
@ -402,25 +186,14 @@ export async function generateImageWithFailoverCandidate(params: {
}) { }) {
const { candidate, messages, imageParams, fallbackImageParams } = params; const { candidate, messages, imageParams, fallbackImageParams } = params;
const providerType = String(candidate.provider?.type || ""); const providerType = String(candidate.provider?.type || "");
const candidateRuntimeModelId = resolveImageModelRuntimeId(candidate.model); const candidateRuntimeModelId = String(candidate.model?.modelId || candidate.model?.id || "");
if (
chatMessagesHaveImageInput(messages) &&
modelLacksReferenceImageInputSupport(candidate.model, candidate.provider)
) {
throw new Error(
`备用模型不支持参考图/图生图输入:${candidate.model.displayName || candidate.model.id}`
);
}
if ( if (
providerType === "fal-image" && providerType === "fal-image" &&
isFalGptImage2EditModelId(candidateRuntimeModelId) isFalGptImage2EditModelId(candidateRuntimeModelId)
) { ) {
throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移"); throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移");
} }
const candidateLanguageModel = await getLanguageModelById( const candidateLanguageModel = await getLanguageModelById(candidate.model.id);
candidate.model.id,
candidateRuntimeModelId
);
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") { if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`); throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`);
} }
@ -429,24 +202,11 @@ export async function generateImageWithFailoverCandidate(params: {
messages, messages,
getImageFailoverConvertProvider(providerType) getImageFailoverConvertProvider(providerType)
); );
const effectiveImageParams = imageParams || fallbackImageParams;
const imageInputCount = countChatMessageImageInputs(messages);
chatApiLogger.info("[Chat API] Image failover candidate params:", {
model: candidate.model?.modelId || candidate.model?.id,
providerType,
aspectRatio: effectiveImageParams?.aspectRatio,
imageSize: effectiveImageParams?.imageSize,
imageCount: effectiveImageParams?.imageCount,
hasImageInput: imageInputCount > 0,
imageInputCount,
multiImageInput: imageInputCount > 1,
});
const candidateResult = await (candidateLanguageModel as any).doGenerate({ const candidateResult = await (candidateLanguageModel as any).doGenerate({
messages: candidateMessages, messages: candidateMessages,
imageParams: effectiveImageParams, imageParams: imageParams || fallbackImageParams,
imageGenConfig: candidate.model.imageGenConfig || null,
modelDisplayName: candidate.model.displayName, modelDisplayName: candidate.model.displayName,
overrideModelId: candidateRuntimeModelId || candidate.model.modelId, overrideModelId: candidate.model.modelId,
}); });
if (candidateResult?.finishReason === "error") { if (candidateResult?.finishReason === "error") {
@ -454,7 +214,7 @@ export async function generateImageWithFailoverCandidate(params: {
} }
return { return {
text: candidateResult.text || "", text: candidateResult.text || "图片生成失败",
usedModelId: String(candidate.model.modelId || candidate.model.id || ""), usedModelId: String(candidate.model.modelId || candidate.model.id || ""),
displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""), displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""),
}; };
@ -464,15 +224,10 @@ export function getImageCrossProviderCandidates(
allModels: any[], allModels: any[],
options: { options: {
currentModelId: string; currentModelId: string;
currentModelKey?: string;
currentProviderId?: string; currentProviderId?: string;
providerPriority: Record<string, number>; providerPriority: Record<string, number>;
} }
) { ) {
const currentEntry = resolveCurrentImageModelEntry(allModels, [
options.currentModelId,
options.currentModelKey || "",
]);
return allModels return allModels
.filter((m) => { .filter((m) => {
if ( if (
@ -488,44 +243,17 @@ export function getImageCrossProviderCandidates(
} }
if (m.model.id === options.currentModelId) return false; if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false; if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) { if (isImageFallbackModelCoolingDown(m.model.id)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id); const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`); chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}`);
return false; return false;
} }
return true; return true;
}) })
.map((item, index) => {
const nameSimilarity = scoreModelNameSimilarity(currentEntry, item);
return {
...item,
__imageFailoverIndex: index,
__imageFailoverNameSimilarity: nameSimilarity,
__imageFailoverSameName:
nameSimilarity >= IMAGE_FAILOVER_SAME_NAME_THRESHOLD,
};
})
.sort((a, b) => { .sort((a, b) => {
if (a.__imageFailoverSameName !== b.__imageFailoverSameName) {
return a.__imageFailoverSameName ? -1 : 1;
}
if (b.__imageFailoverNameSimilarity !== a.__imageFailoverNameSimilarity) {
return b.__imageFailoverNameSimilarity - a.__imageFailoverNameSimilarity;
}
const left = options.providerPriority[a.provider.type as string] ?? 99; const left = options.providerPriority[a.provider.type as string] ?? 99;
const right = options.providerPriority[b.provider.type as string] ?? 99; const right = options.providerPriority[b.provider.type as string] ?? 99;
if (left !== right) return left - right; if (left !== right) return left - right;
const sortOrderDiff = (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0); return (a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0);
if (sortOrderDiff !== 0) return sortOrderDiff;
return (a.__imageFailoverIndex ?? 0) - (b.__imageFailoverIndex ?? 0);
})
.map((item) => {
const {
__imageFailoverIndex,
__imageFailoverNameSimilarity,
__imageFailoverSameName,
...candidate
} = item;
return candidate;
}); });
} }

View File

@ -138,14 +138,14 @@ export function buildAutoModeFallbackMessage(params: {
: "基于当前上传图片处理。"; : "基于当前上传图片处理。";
return [ return [
"### 意图分析", "### 需求理解",
`- ${demand}`, `- ${demand}`,
"### 图片分析", "### 图片分析",
params.imageSummary params.imageSummary
? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}` ? `- ${params.imageSummary.replace(/\s+/g, " ").trim()}`
: `- ${sourceLine}`, : `- ${sourceLine}`,
"- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。", "- 我会优先保持主体、构图和整体风格一致,只处理你指定的改动。",
"### 任务规划", "### 执行计划",
"- 先完成本轮核心改动,再检查边缘与光影一致性。", "- 先完成本轮核心改动,再检查边缘与光影一致性。",
"- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。", "- 若结果偏差,我会按“保留A,修改B”的方式继续迭代。",
"### 输出设置", "### 输出设置",
@ -154,9 +154,9 @@ export function buildAutoModeFallbackMessage(params: {
} }
return [ return [
"### 意图分析", "### 需求理解",
`- ${demand}`, `- ${demand}`,
"### 任务规划", "### 执行计划",
"- 先锁定主体与场景,再细化风格、材质与光影。", "- 先锁定主体与场景,再细化风格、材质与光影。",
"### 输出设置", "### 输出设置",
"- 默认单图输出,优先稳定命中。", "- 默认单图输出,优先稳定命中。",
@ -222,7 +222,7 @@ export function buildPromptOnlyReply(params: {
export function hasStructuredAgentSections(message?: string): boolean { export function hasStructuredAgentSections(message?: string): boolean {
if (!message) return false; if (!message) return false;
return /(?:###\s*意图分析|【\s*意图分析\s*】|###\s*任务规划|【\s*任务规划\s*】|###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test( return /(?:###\s*需求理解|【\s*需求理解\s*】|###\s*图片分析|【\s*图片分析\s*】|###\s*执行计划|【\s*执行计划\s*】|###\s*优化后提示词|【\s*优化后提示词\s*】)/.test(
message, message,
); );
} }
@ -253,13 +253,6 @@ export function ensureStructuredAgentFriendlyMessage(params: {
return fallback; return fallback;
} }
if (fallback.includes("### 任务规划\n")) {
return fallback.replace(
"### 任务规划\n",
`### 任务规划\n- 补充说明:${normalizedSupplement}\n`,
);
}
return fallback.replace( return fallback.replace(
"### 执行计划\n", "### 执行计划\n",
`### 执行计划\n- 补充说明:${normalizedSupplement}\n`, `### 执行计划\n- 补充说明:${normalizedSupplement}\n`,
@ -522,12 +515,7 @@ export async function convertMessagesWithImages(
normalizedProviderType === "bfl-image" || normalizedProviderType === "bfl-image" ||
normalizedProviderType === "ruxa-image" || normalizedProviderType === "ruxa-image" ||
normalizedProviderType === "fal-image" || normalizedProviderType === "fal-image" ||
normalizedProviderType === "snapgen-image" ||
normalizedProviderType === "openai-image" || normalizedProviderType === "openai-image" ||
normalizedProviderType === "bananaimage" ||
normalizedProviderType === "visionary" ||
normalizedProviderType === "atlascloud" ||
normalizedProviderType === "vip98k" ||
normalizedProviderType === "stepfun-image" || normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" || normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" || normalizedProviderType === "runway" ||
@ -593,6 +581,7 @@ export async function convertMessagesWithImages(
if (hasAnnotation) { if (hasAnnotation) {
chatApiLogger.info("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示"); chatApiLogger.info("[Message Convert] 🎨 检测到标注图片,将自动添加系统提示");
} }
// 对于 Gemini,需要特殊处理图片 // 对于 Gemini,需要特殊处理图片
const convertedMessages = []; const convertedMessages = [];
@ -618,43 +607,16 @@ export async function convertMessagesWithImages(
type: "text", type: "text",
text: textContent, text: textContent,
}); });
continue; } else if (part.type === "file" && part.url) {
} // 直接使用 URL 格式(不再转换为 Base64)
// gemini-image 和 huanke-gemini provider 内部会处理图片上传
const typedPart = part as {
type: string;
url?: string;
image?: string;
image_url?: { url?: string };
data?: string;
};
const candidateImageUrl =
typedPart.type === "file"
? typedPart.url || typedPart.data
: typedPart.type === "image"
? typedPart.image || typedPart.url
: typedPart.type === "image_url"
? typedPart.image_url?.url || typedPart.url
: undefined;
if (typeof candidateImageUrl !== "string" || !candidateImageUrl.trim()) {
continue;
}
const normalizedImageUrl = candidateImageUrl.trim();
// 兼容 file/image/image_url 三类输入;Gemini 内部会将图片交给 S3 后再请求生图网关。
if (typedPart.type === "file" || typedPart.type === "image" || typedPart.type === "image_url") {
content.push({ content.push({
type: "image_url", type: "image_url",
image_url: { image_url: {
url: normalizedImageUrl, url: part.url,
}, },
}); });
chatApiLogger.info( chatApiLogger.info("[Message Convert] Image kept as URL format:", part.url.substring(0, 80) + "...");
"[Message Convert] Image kept as URL format:",
normalizedImageUrl.substring(0, 80) + "..."
);
} }
} }
@ -669,7 +631,9 @@ export async function convertMessagesWithImages(
convertedMessages.push({ convertedMessages.push({
role: message.role, role: message.role,
content: content.length === 1 && content[0].type === "text" ? content[0].text : content, content: content.length === 1 && content[0].type === "text"
? content[0].text
: content,
}); });
} }

View File

@ -6,8 +6,6 @@ import type {
ResolvedImageRef, ResolvedImageRef,
ResolvedTargetAssets, ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types"; } 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"; import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.stream"); const chatApiLogger = createLogger("chat.stream");
@ -68,104 +66,6 @@ export function writeStructuredDataChunk(
); );
} }
function normalizeStreamIdList(value: unknown) {
return Array.isArray(value)
? value.map((id) => String(id || "").trim()).filter(Boolean)
: [];
}
function normalizeStreamMediaUrls(value: unknown) {
return Array.isArray(value)
? value.map((url) => String(url || "").trim())
: [];
}
function isValidStreamMediaUrl(url: string) {
return /^https?:\/\//i.test(url);
}
export function normalizeCanvasMediaStreamPayload(params: {
imageUrls?: unknown;
videoUrls?: unknown;
mediaItemIds?: unknown;
handledCanvasMediaItemIds?: unknown;
}) {
const rawImageUrls = normalizeStreamMediaUrls(params.imageUrls);
const rawVideoUrls = normalizeStreamMediaUrls(params.videoUrls);
const rawEntries = [
...rawImageUrls.map((url, index) => ({
type: "image" as const,
url,
originalIndex: index,
})),
...rawVideoUrls.map((url, index) => ({
type: "video" as const,
url,
originalIndex: rawImageUrls.length + index,
})),
];
const rawMediaItemIds = normalizeStreamIdList(params.mediaItemIds);
const validEntries = rawEntries.filter((entry) =>
isValidStreamMediaUrl(entry.url)
);
const idByValidIndex = new Map<number, string>();
if (rawMediaItemIds.length === rawEntries.length) {
validEntries.forEach((entry, validIndex) => {
const id = rawMediaItemIds[entry.originalIndex];
if (id) idByValidIndex.set(validIndex, id);
});
} else if (rawMediaItemIds.length === validEntries.length) {
rawMediaItemIds.forEach((id, index) => {
if (id) idByValidIndex.set(index, id);
});
}
const dedupedEntries: Array<{
type: "image" | "video";
url: string;
mediaItemId?: string;
}> = [];
const seenMediaKeys = new Set<string>();
validEntries.forEach((entry, validIndex) => {
const key = `${entry.type}:${entry.url}`;
if (seenMediaKeys.has(key)) return;
seenMediaKeys.add(key);
dedupedEntries.push({
type: entry.type,
url: entry.url,
mediaItemId: idByValidIndex.get(validIndex),
});
});
if (
dedupedEntries.length > 0 &&
dedupedEntries.some((entry) => !entry.mediaItemId) &&
rawMediaItemIds.length === dedupedEntries.length
) {
dedupedEntries.forEach((entry, index) => {
entry.mediaItemId = rawMediaItemIds[index];
});
}
const mediaItemIds = dedupedEntries.every((entry) => entry.mediaItemId)
? dedupedEntries.map((entry) => entry.mediaItemId!)
: [];
return {
imageUrls: dedupedEntries
.filter((entry) => entry.type === "image")
.map((entry) => entry.url),
videoUrls: dedupedEntries
.filter((entry) => entry.type === "video")
.map((entry) => entry.url),
mediaItemIds,
handledCanvasMediaItemIds: normalizeStreamIdList(
params.handledCanvasMediaItemIds
),
};
}
export function writeProtocolDebugChunk( export function writeProtocolDebugChunk(
writer: StreamWriter, writer: StreamWriter,
enabled: boolean, enabled: boolean,
@ -186,12 +86,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "bfl-image" || normalized === "bfl-image" ||
normalized === "ruxa-image" || normalized === "ruxa-image" ||
normalized === "fal-image" || normalized === "fal-image" ||
normalized === "snapgen-image" ||
normalized === "openai-image" || normalized === "openai-image" ||
normalized === "bananaimage" ||
normalized === "visionary" ||
normalized === "atlascloud" ||
normalized === "vip98k" ||
normalized === "stepfun-image" || normalized === "stepfun-image" ||
normalized === "midjourney" || normalized === "midjourney" ||
normalized === "runway" || normalized === "runway" ||
@ -302,25 +197,29 @@ export function writeCanvasMediaChunk(
agentCategoryId?: string | null; agentCategoryId?: string | null;
agentCategoryName?: string | null; agentCategoryName?: string | null;
agentExecutionSnapshotId?: string | null; agentExecutionSnapshotId?: string | null;
mediaItemIds?: string[] | null;
handledCanvasMediaItemIds?: string[] | null;
}, },
label: string label: string
) { ) {
const inputImageUrls = Array.isArray(params.imageUrls) const imageUrls = Array.isArray(params.imageUrls)
? params.imageUrls ? params.imageUrls
: extractImageUrlsFromText(String(params.text || "")); : extractImageUrlsFromText(String(params.text || ""));
const normalizedPayload = normalizeCanvasMediaStreamPayload({ const videoUrls = Array.isArray(params.videoUrls) ? params.videoUrls : [];
imageUrls: inputImageUrls, const normalizedImageUrls = Array.from(
videoUrls: params.videoUrls, new Set(
mediaItemIds: params.mediaItemIds, imageUrls
handledCanvasMediaItemIds: params.handledCanvasMediaItemIds, .map((url) => String(url || "").trim())
}); .filter((url) => /^https?:\/\//i.test(url))
)
);
const normalizedVideoUrls = Array.from(
new Set(
videoUrls
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
);
if ( if (normalizedImageUrls.length === 0 && normalizedVideoUrls.length === 0) {
normalizedPayload.imageUrls.length === 0 &&
normalizedPayload.videoUrls.length === 0
) {
return; return;
} }
@ -329,8 +228,8 @@ export function writeCanvasMediaChunk(
"data-canvasMedia", "data-canvasMedia",
{ {
messageId: params.messageId, messageId: params.messageId,
imageUrls: normalizedPayload.imageUrls, imageUrls: normalizedImageUrls,
videoUrls: normalizedPayload.videoUrls, videoUrls: normalizedVideoUrls,
prompt: String(params.prompt || "").trim() || undefined, prompt: String(params.prompt || "").trim() || undefined,
sourceImageUrls: Array.isArray(params.sourceImageUrls) sourceImageUrls: Array.isArray(params.sourceImageUrls)
? params.sourceImageUrls ? params.sourceImageUrls
@ -343,102 +242,11 @@ export function writeCanvasMediaChunk(
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined, agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
agentExecutionSnapshotId: agentExecutionSnapshotId:
String(params.agentExecutionSnapshotId || "").trim() || undefined, String(params.agentExecutionSnapshotId || "").trim() || undefined,
mediaItemIds: normalizedPayload.mediaItemIds,
handledCanvasMediaItemIds: normalizedPayload.handledCanvasMediaItemIds,
}, },
label label
); );
} }
export function writeCanvasOperationChunk(
writer: StreamWriter,
params: {
projectId: string;
operationId: string;
operation: string;
status?: "applied" | "failed" | "shadowed";
createdNodeIds: string[];
createdNodeIdsByMediaItemId?: Record<string, string>;
updatedNodeIds?: string[];
createdEdgeIds?: string[];
canvasRevision?: string;
anchorNodeId?: string | null;
handledCanvasMediaItemIds?: string[];
mediaItemIds?: string[];
errorCode?: string;
errorMessage?: string;
fallbackToCanvasMedia?: boolean;
message?: string;
},
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasOperation",
{
projectId: params.projectId,
operationId: params.operationId,
operation: params.operation,
status: params.status || "applied",
createdNodeIds: params.createdNodeIds || [],
createdNodeIdsByMediaItemId: params.createdNodeIdsByMediaItemId || {},
updatedNodeIds: params.updatedNodeIds || [],
createdEdgeIds: params.createdEdgeIds || [],
canvasRevision: params.canvasRevision,
anchorNodeId: params.anchorNodeId || undefined,
handledCanvasMediaItemIds:
params.status && params.status !== "applied"
? []
: normalizeStreamIdList(params.handledCanvasMediaItemIds),
mediaItemIds: normalizeStreamIdList(params.mediaItemIds),
errorCode: params.errorCode,
errorMessage: params.errorMessage,
fallbackToCanvasMedia: params.fallbackToCanvasMedia,
message: params.message,
},
label
);
}
export function writeCanvasViewOperationChunk(
writer: StreamWriter,
params: CanvasViewOperationPlan,
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasViewOperation",
{
version: 1,
viewOperationId: params.viewOperationId,
operation: params.operation,
projectId: params.projectId,
nodeIds: params.nodeIds || [],
anchorNodeId: params.anchorNodeId,
zoom: params.zoom,
padding: params.padding,
durationMs: params.durationMs,
persist: false,
center: params.center,
reason: params.reason,
},
label
);
}
export function writeCanvasCapabilityCommandChunk(
writer: StreamWriter,
command: CanvasCapabilityClientCommand,
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasCapabilityCommand",
command,
label
);
}
type AgentTargetLockRefPreview = { type AgentTargetLockRefPreview = {
url: string; url: string;
title?: string; title?: string;

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
import { z } from "zod"; import { z } from "zod";
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
const textPartSchema = z.object({ const textPartSchema = z.object({
type: z.enum(["text"]), type: z.enum(["text"]),
@ -12,8 +11,6 @@ const filePartSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
url: z.string().url(), url: z.string().url(),
assetId: z.string().uuid().optional(), assetId: z.string().uuid().optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
}); });
const partSchema = z.union([textPartSchema, filePartSchema]); const partSchema = z.union([textPartSchema, filePartSchema]);
@ -33,22 +30,11 @@ const videoParamsSchema = z.object({
returnLastFrame: z.boolean().optional(), returnLastFrame: z.boolean().optional(),
}).optional(); }).optional();
const imageSizeValues = ["0.5K", "1K", "2K", "4K", "8K", "10K", "12K"] as const;
function normalizeImageSizeInput(value: unknown) {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toUpperCase();
return imageSizeValues.includes(normalized as (typeof imageSizeValues)[number])
? normalized
: undefined;
}
// 🆕 图片生成参数 schema // 🆕 图片生成参数 schema
const imageParamsSchema = z.object({ const imageParamsSchema = z.object({
aspectRatio: z.string().optional(), aspectRatio: z.string().optional(),
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()), imageSize: z.string().optional(),
imageCount: z.number().min(1).max(10).optional(), imageCount: z.number().min(1).max(10).optional(),
quality: z.enum(["auto", "low", "medium", "high"]).optional(),
// 阿里生图专用参数 // 阿里生图专用参数
size: z.string().optional(), // 自定义分辨率,如 "1024*1024" size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
negativePrompt: z.string().optional(), // 反向提示词 negativePrompt: z.string().optional(), // 反向提示词
@ -64,8 +50,7 @@ const contextPayloadSchema = z
projectId: z.string().uuid().optional(), projectId: z.string().uuid().optional(),
selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(), selectedNodeIds: z.array(z.string().min(1).max(128)).max(20).optional(),
selectedAssetIds: z.array(z.string().uuid()).max(20).optional(), selectedAssetIds: z.array(z.string().uuid()).max(20).optional(),
focusPolicy: z.enum(["auto", "selected", "latest_result", "original", "slot"]).optional(), focusPolicy: z.enum(["auto", "selected", "latest_result", "original"]).optional(),
canvasAgentContext: z.custom<CanvasAgentContext>().optional(),
canvasImageNodes: z canvasImageNodes: z
.array( .array(
z.object({ z.object({
@ -86,13 +71,6 @@ const contextPayloadSchema = z
}) })
.optional(); .optional();
const agentModeSourceImageSchema = z.object({
url: z.string().url(),
label: z.string().max(255).optional(),
name: z.string().max(255).optional(),
assetId: z.string().min(1).max(128).nullable().optional(),
});
const agentModeReferenceSchema = z.object({ const agentModeReferenceSchema = z.object({
id: z.string().min(1).max(128), id: z.string().min(1).max(128),
label: z.string().min(1).max(255), label: z.string().min(1).max(255),
@ -101,7 +79,13 @@ const agentModeReferenceSchema = z.object({
sortOrder: z.number().min(0).max(999).optional(), sortOrder: z.number().min(0).max(999).optional(),
assignedProductIndex: z.number().min(0).max(9).nullable().optional(), assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
passthrough: z.boolean().nullable().optional(), passthrough: z.boolean().nullable().optional(),
rerunReferenceImages: z.array(agentModeSourceImageSchema).max(6).optional(), });
const agentModeSourceImageSchema = z.object({
url: z.string().url(),
label: z.string().max(255).optional(),
name: z.string().max(255).optional(),
assetId: z.string().min(1).max(128).nullable().optional(),
}); });
const agentModePayloadSchema = z const agentModePayloadSchema = z

View File

@ -3,7 +3,6 @@ import {
CompleteMultipartUploadCommand, CompleteMultipartUploadCommand,
CreateMultipartUploadCommand, CreateMultipartUploadCommand,
HeadObjectCommand, HeadObjectCommand,
ListPartsCommand,
PutObjectCommand, PutObjectCommand,
UploadPartCommand, UploadPartCommand,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
@ -13,10 +12,6 @@ import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { upsertMediaAsset } from "@/lib/db/queries"; import { upsertMediaAsset } from "@/lib/db/queries";
import {
IMAGE_UPLOAD_MAX_BYTES,
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
} from "@/lib/image-upload-limits";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { import {
buildS3PublicUrl, buildS3PublicUrl,
@ -26,41 +21,20 @@ import {
maybeEnqueueStorageMigrationTask, maybeEnqueueStorageMigrationTask,
resolveS3UploadTarget, resolveS3UploadTarget,
} from "@/lib/storage/service"; } from "@/lib/storage/service";
import {
getOpenClawChatFileContentType,
isOpenClawChatFolderFileAllowed,
OPENCLAW_CHAT_FILE_MAX_BYTES,
OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE,
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
OPENCLAW_CHAT_VIDEO_MAX_BYTES,
OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE,
} from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("direct-upload-api"); const logger = createLogger("direct-upload-api");
const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES; const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const DIRECT_UPLOAD_MEDIA_MAX_BYTES = 500 * 1024 * 1024;
const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024; const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024; const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024;
const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60; const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60;
const DIRECT_UPLOAD_PREFIX = "uploads/direct"; const DIRECT_UPLOAD_PREFIX = "uploads/direct";
function elapsedMs(startedAt: number) {
return Date.now() - startedAt;
}
const allowedDirectUploadTypes = new Set([ const allowedDirectUploadTypes = new Set([
"image/jpeg", "image/jpeg",
"image/jpg", "image/jpg",
"image/png", "image/png",
"image/webp", "image/webp",
]); ]);
const allowedOpenClawVideoUploadTypes = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const InitiateSchema = z.object({ const InitiateSchema = z.object({
action: z.literal("initiate"), action: z.literal("initiate"),
@ -90,18 +64,6 @@ const CompleteSchema = z.object({
clientUploadId: z.string().max(128).optional(), clientUploadId: z.string().max(128).optional(),
}); });
const ResumeSchema = z.object({
action: z.literal("resume"),
key: z.string().min(1).max(512),
uploadId: z.string().min(1).max(512),
filename: z.string().min(1).max(256),
contentType: z.string().min(1).max(128),
size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES),
partSize: z.number().int().min(5 * 1024 * 1024).max(128 * 1024 * 1024),
purpose: z.string().max(64).optional(),
clientUploadId: z.string().max(128).optional(),
});
const AbortSchema = z.object({ const AbortSchema = z.object({
action: z.literal("abort"), action: z.literal("abort"),
key: z.string().min(1).max(512), key: z.string().min(1).max(512),
@ -111,7 +73,6 @@ const AbortSchema = z.object({
const RequestSchema = z.discriminatedUnion("action", [ const RequestSchema = z.discriminatedUnion("action", [
InitiateSchema, InitiateSchema,
ResumeSchema,
CompleteSchema, CompleteSchema,
AbortSchema, AbortSchema,
]); ]);
@ -139,52 +100,19 @@ function assertUserOwnedKey(key: string, userId: string) {
const normalized = String(key || "").replace(/^\/+/, ""); const normalized = String(key || "").replace(/^\/+/, "");
const prefix = `${userUploadPrefix(userId)}/`; const prefix = `${userUploadPrefix(userId)}/`;
if (!normalized.startsWith(prefix)) { if (!normalized.startsWith(prefix)) {
const error = new Error("Invalid upload object key"); throw new Error("Invalid upload object key");
error.name = "InvalidUploadObjectKeyError";
throw error;
} }
return normalized; return normalized;
} }
function isAllowedDirectUpload( function isAllowedDirectUpload(contentType: string) {
contentType: string, return allowedDirectUploadTypes.has(contentType);
purpose: string | undefined,
filename: string
) {
return (
allowedDirectUploadTypes.has(contentType) ||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
allowedOpenClawVideoUploadTypes.has(contentType)) ||
(purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
Boolean(
getOpenClawChatFileContentType({ name: filename, type: contentType })
)) ||
(purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
isOpenClawChatFolderFileAllowed({ name: filename, type: contentType }))
);
}
function getPurposeMaxBytes(purpose?: string, contentType?: string) {
if (String(contentType || "").toLowerCase().startsWith("image/")) {
return IMAGE_UPLOAD_MAX_BYTES;
}
if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) {
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
}
if (
purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
) {
return DIRECT_UPLOAD_MAX_BYTES;
}
return DIRECT_UPLOAD_MEDIA_MAX_BYTES;
} }
function computePartSize(size: number) { function computePartSize(size: number) {
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES; if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024; if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024;
if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024; return 16 * 1024 * 1024;
return 64 * 1024 * 1024;
} }
async function createPartUrls(params: { async function createPartUrls(params: {
@ -192,28 +120,27 @@ async function createPartUrls(params: {
bucket: string; bucket: string;
key: string; key: string;
uploadId: string; uploadId: string;
partNumbers: number[]; partCount: number;
}) { }) {
return Promise.all( const urls = [];
params.partNumbers.map(async (partNumber) => { for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) {
const command = new UploadPartCommand({ const command = new UploadPartCommand({
Bucket: params.bucket, Bucket: params.bucket,
Key: params.key, Key: params.key,
UploadId: params.uploadId, UploadId: params.uploadId,
PartNumber: partNumber, PartNumber: partNumber,
}); });
return { urls.push({
partNumber, partNumber,
url: await getSignedUrl(params.client, command, { url: await getSignedUrl(params.client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}), }),
}; });
}) }
); return urls;
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const requestStartedAt = Date.now();
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@ -232,16 +159,6 @@ export async function POST(request: Request) {
); );
} }
logger.info("direct_upload_request_start", {
action: payload.action,
size: "size" in payload ? payload.size : null,
contentType: "contentType" in payload ? payload.contentType : null,
purpose: "purpose" in payload ? payload.purpose || null : null,
clientUploadId:
"clientUploadId" in payload ? payload.clientUploadId || null : null,
});
const resolveStartedAt = Date.now();
const target = await resolveS3UploadTarget().catch((error) => { const target = await resolveS3UploadTarget().catch((error) => {
logger.error("resolve_storage_target_failed", error); logger.error("resolve_storage_target_failed", error);
return null; return null;
@ -253,17 +170,9 @@ export async function POST(request: Request) {
{ status: 409 } { status: 409 }
); );
} }
logger.info("direct_upload_storage_target_resolved", {
action: payload.action,
elapsedMs: elapsedMs(resolveStartedAt),
provider: target.config.provider || "s3",
bucket: target.config.bucket,
hasPublicUrl: Boolean(target.config.publicUrl),
});
const client = createS3Client(target.config); const client = createS3Client(target.config);
const bucket = target.config.bucket; const bucket = target.config.bucket;
const usePublicReadAcl = target.config.provider !== "qiniu-kodo";
try { try {
if (payload.action === "initiate") { if (payload.action === "initiate") {
@ -272,7 +181,7 @@ export async function POST(request: Request) {
payload.filename payload.filename
); );
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) { if (!isAllowedDirectUpload(contentType)) {
logger.warn("unsupported_direct_upload_type", { logger.warn("unsupported_direct_upload_type", {
contentType, contentType,
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null, filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
@ -281,24 +190,10 @@ export async function POST(request: Request) {
clientUploadId: payload.clientUploadId || null, clientUploadId: payload.clientUploadId || null,
}); });
return NextResponse.json( return NextResponse.json(
{ { error: "Direct upload only supports JPG, PNG and WebP images" },
error:
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
},
{ status: 400 } { status: 400 }
); );
} }
const maxBytes = getPurposeMaxBytes(payload.purpose, contentType);
if (payload.size > maxBytes) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: `文件不能超过 ${maxBytes / 1024 / 1024}MB`,
},
{ status: 413 }
);
}
const displayName = sanitizeDisplayName(payload.filename); const displayName = sanitizeDisplayName(payload.filename);
const key = buildStorageObjectKey( const key = buildStorageObjectKey(
@ -306,57 +201,39 @@ export async function POST(request: Request) {
userUploadPrefix(session.user.id) userUploadPrefix(session.user.id)
); );
const multipartThreshold = if (payload.size < MULTIPART_THRESHOLD_BYTES) {
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? DEFAULT_PART_SIZE_BYTES
: MULTIPART_THRESHOLD_BYTES;
if (payload.size < multipartThreshold) {
const presignStartedAt = Date.now();
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: bucket, Bucket: bucket,
Key: key, Key: key,
ContentType: contentType, ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable", CacheControl: "public, max-age=31536000, immutable",
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}), ACL: "public-read",
}); });
const url = await getSignedUrl(client, command, { const url = await getSignedUrl(client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}); });
logger.info("direct_upload_single_presigned", {
elapsedMs: elapsedMs(presignStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
contentType,
clientUploadId: payload.clientUploadId || null,
});
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
};
if (usePublicReadAcl) {
headers["x-amz-acl"] = "public-read";
}
return NextResponse.json({ return NextResponse.json({
mode: "single", mode: "single",
key, key,
url, url,
headers, headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"x-amz-acl": "public-read",
},
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
clientUploadId: payload.clientUploadId || null, clientUploadId: payload.clientUploadId || null,
}); });
} }
const multipartStartedAt = Date.now();
const createResult = await client.send( const createResult = await client.send(
new CreateMultipartUploadCommand({ new CreateMultipartUploadCommand({
Bucket: bucket, Bucket: bucket,
Key: key, Key: key,
ContentType: contentType, ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable", CacheControl: "public, max-age=31536000, immutable",
...(usePublicReadAcl ? { ACL: "public-read" as const } : {}), ACL: "public-read",
}) })
); );
@ -372,16 +249,7 @@ export async function POST(request: Request) {
bucket, bucket,
key, key,
uploadId, uploadId,
partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
});
logger.info("direct_upload_multipart_prepared", {
elapsedMs: elapsedMs(multipartStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
partCount, partCount,
partSize,
contentType,
clientUploadId: payload.clientUploadId || null,
}); });
return NextResponse.json({ return NextResponse.json({
@ -396,108 +264,8 @@ 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, contentType)) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: "文件超过允许的上传大小",
},
{ status: 413 }
);
}
const expectedPartSize = computePartSize(payload.size);
if (payload.partSize !== expectedPartSize) {
return NextResponse.json(
{ error: "续传分片参数已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
{ status: 409 }
);
}
try {
const listed = await client.send(
new ListPartsCommand({
Bucket: bucket,
Key: key,
UploadId: payload.uploadId,
MaxParts: 1000,
})
);
const uploadedParts = (listed.Parts || []).flatMap((part) => {
if (!part.ETag || typeof part.PartNumber !== "number") return [];
return [
{
ETag: part.ETag,
PartNumber: part.PartNumber,
size: Number(part.Size || 0),
},
];
});
const uploadedPartNumbers = new Set(
uploadedParts.map((part) => part.PartNumber)
);
const partCount = Math.ceil(payload.size / expectedPartSize);
const missingPartNumbers = Array.from(
{ length: partCount },
(_, index) => index + 1
).filter((partNumber) => !uploadedPartNumbers.has(partNumber));
const parts = await createPartUrls({
client,
bucket,
key,
uploadId: payload.uploadId,
partNumbers: missingPartNumbers,
});
logger.info("direct_upload_multipart_resumed", {
totalElapsedMs: elapsedMs(requestStartedAt),
size: payload.size,
uploadedPartCount: uploadedParts.length,
missingPartCount: missingPartNumbers.length,
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({
mode: "multipart",
key,
uploadId: payload.uploadId,
partSize: expectedPartSize,
partCount,
parts,
uploadedParts,
resumed: true,
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
clientUploadId: payload.clientUploadId || null,
});
} catch (error) {
const head = await client
.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
.catch(() => null);
if (head?.ContentLength === payload.size) {
return NextResponse.json({
mode: "completed",
key,
clientUploadId: payload.clientUploadId || null,
});
}
logger.warn("direct_upload_resume_session_missing", error, {
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json(
{ error: "续传会话已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" },
{ status: 409 }
);
}
}
if (payload.action === "abort") { if (payload.action === "abort") {
const key = assertUserOwnedKey(payload.key, session.user.id); const key = assertUserOwnedKey(payload.key, session.user.id);
const abortStartedAt = Date.now();
await client.send( await client.send(
new AbortMultipartUploadCommand({ new AbortMultipartUploadCommand({
Bucket: bucket, Bucket: bucket,
@ -505,38 +273,19 @@ export async function POST(request: Request) {
UploadId: payload.uploadId, UploadId: payload.uploadId,
}) })
); );
logger.info("direct_upload_multipart_aborted", {
elapsedMs: elapsedMs(abortStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
const key = assertUserOwnedKey(payload.key, session.user.id); const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename); const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) { if (!isAllowedDirectUpload(contentType)) {
return NextResponse.json( return NextResponse.json(
{ { error: "Direct upload only supports JPG, PNG and WebP images" },
error:
"直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
},
{ status: 400 } { status: 400 }
); );
} }
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: "文件超过允许的上传大小",
},
{ status: 413 }
);
}
if (payload.uploadId) { if (payload.uploadId) {
const completeMultipartStartedAt = Date.now();
const parts = [...(payload.parts || [])].sort( const parts = [...(payload.parts || [])].sort(
(a, b) => a.PartNumber - b.PartNumber (a, b) => a.PartNumber - b.PartNumber
); );
@ -557,77 +306,35 @@ export async function POST(request: Request) {
}, },
}) })
); );
logger.info("direct_upload_multipart_completed", {
elapsedMs: elapsedMs(completeMultipartStartedAt),
partCount: parts.length,
clientUploadId: payload.clientUploadId || null,
});
} }
const headStartedAt = Date.now();
const head = await client.send( const head = await client.send(
new HeadObjectCommand({ new HeadObjectCommand({
Bucket: bucket, Bucket: bucket,
Key: key, Key: key,
}) })
); );
logger.info("direct_upload_head_object_completed", {
elapsedMs: elapsedMs(headStartedAt),
uploadMode: payload.uploadId ? "multipart" : "single",
clientUploadId: payload.clientUploadId || null,
});
const actualSize = const actualSize =
typeof head.ContentLength === "number" ? head.ContentLength : null; typeof head.ContentLength === "number" ? head.ContentLength : null;
if (actualSize !== null && actualSize !== payload.size) { if (actualSize !== null && actualSize <= 0) {
throw new Error( throw new Error("Uploaded object is empty");
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
);
} }
const url = buildS3PublicUrl(target.config, key); const url = buildS3PublicUrl(target.config, key);
const resolvedContentType = const resolvedContentType =
head.ContentType || contentType || getContentType(payload.filename); head.ContentType || contentType || getContentType(payload.filename);
const assetStartedAt = Date.now();
const asset = await upsertMediaAsset({ const asset = await upsertMediaAsset({
userId: session.user.id, userId: session.user.id,
mediaType: mediaType: "image",
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE || sourceType: "upload",
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "file"
: resolvedContentType.startsWith("video/")
? "video"
: "image",
sourceType:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload"
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
? "openclaw_chat_file_upload"
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "openclaw_chat_folder_file_upload"
: "upload",
status: "ready", status: "ready",
originalUrl: url, originalUrl: url,
previewUrl: previewUrl: url,
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE || thumbnailUrl: url,
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? null
: url,
thumbnailUrl:
payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? null
: url,
mimeType: resolvedContentType, mimeType: resolvedContentType,
displayName: payload.filename, displayName: payload.filename,
sourceTable: sourceTable: "direct_upload",
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload"
: payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE
? "openclaw_chat_file_upload"
: payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? "openclaw_chat_folder_file_upload"
: "direct_upload",
metadata: { metadata: {
pathname: key, pathname: key,
size: actualSize ?? payload.size, size: actualSize ?? payload.size,
@ -637,13 +344,6 @@ export async function POST(request: Request) {
clientUploadId: payload.clientUploadId || null, clientUploadId: payload.clientUploadId || null,
}, },
}); });
logger.info("direct_upload_asset_registered", {
elapsedMs: elapsedMs(assetStartedAt),
totalElapsedMs: elapsedMs(requestStartedAt),
uploadMode: payload.uploadId ? "multipart" : "single",
assetId: asset?.id ?? null,
clientUploadId: payload.clientUploadId || null,
});
await maybeEnqueueStorageMigrationTask({ await maybeEnqueueStorageMigrationTask({
uploaded: { url, pathname: key }, uploaded: { url, pathname: key },
@ -674,13 +374,7 @@ export async function POST(request: Request) {
error: "Direct upload failed", error: "Direct upload failed",
details: error instanceof Error ? error.message : String(error), details: error instanceof Error ? error.message : String(error),
}, },
{ { status: 500 }
status:
error instanceof Error &&
error.name === "InvalidUploadObjectKeyError"
? 403
: 500,
}
); );
} }
} }

View File

@ -1,9 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import {
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
isImageUploadTooLarge,
} from "@/lib/image-upload-limits";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images"; import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
@ -11,7 +7,6 @@ import { upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin"; import { isSiteAdminType } from "@/lib/middleware/admin";
import { getContentType, uploadFile } from "@/lib/storage/service"; import { getContentType, uploadFile } from "@/lib/storage/service";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("upload-api"); const logger = createLogger("upload-api");
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
@ -19,32 +14,12 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/jpg", "image/jpg",
"image/png", "image/png",
"image/webp", "image/webp",
"image/avif",
"image/tiff",
"image/x-tiff",
"image/svg+xml", "image/svg+xml",
"image/heic", "image/heic",
"image/heif", "image/heif",
]); ]);
const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
]);
const ALLOWED_OPENCLAW_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]); const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
"image/avif",
"image/tiff",
"image/x-tiff",
]);
function normalizeUploadContentType(contentType: string, filename: string) { function normalizeUploadContentType(contentType: string, filename: string) {
const normalized = contentType.trim().toLowerCase(); const normalized = contentType.trim().toLowerCase();
@ -60,16 +35,8 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") { if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。"; return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
} }
if (
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
extension === "avif" ||
extension === "tif" ||
extension === "tiff"
) {
return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
}
return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。"; return "File type should be JPEG, PNG, WebP or SVG";
} }
function isHeicUpload(contentType: string, filename: string) { function isHeicUpload(contentType: string, filename: string) {
@ -81,20 +48,10 @@ function isHeicUpload(contentType: string, filename: string) {
); );
} }
function isSharpCompatibilityUpload(contentType: string, filename: string) {
const extension = filename.toLowerCase().split(".").pop() || "";
return (
SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
extension === "avif" ||
extension === "tif" ||
extension === "tiff"
);
}
function toJpegFilename(filename: string) { function toJpegFilename(filename: string) {
const trimmed = filename.trim() || `upload-${Date.now()}`; const trimmed = filename.trim() || `upload-${Date.now()}`;
if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) { if (/\.(heic|heif)$/i.test(trimmed)) {
return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg"); return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
} }
if (/\.[a-z0-9]+$/i.test(trimmed)) { if (/\.[a-z0-9]+$/i.test(trimmed)) {
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg"); return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
@ -114,21 +71,6 @@ async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
} }
return Buffer.from(output); return Buffer.from(output);
} }
async function convertSharpCompatibilityImageToJpeg(
buffer: Buffer
): Promise<Buffer> {
const sharp = (await import("sharp")).default;
return sharp(buffer, { animated: false, failOn: "none" })
.rotate()
.flatten({ background: "#ffffff" })
.jpeg({
quality: 92,
mozjpeg: true,
progressive: true,
})
.toBuffer();
}
/** /**
* 使用Sharp智能优化图片(保持原格式) * 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化 * 策略:避免双重压缩,只在需要时优化
@ -255,21 +197,9 @@ export async function POST(request: Request) {
let filename = originalFilename; let filename = originalFilename;
let contentType = originalContentType; let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || ""); const uploadPurpose = String(formData.get("purpose") || "");
const isAtlasCloudReferenceVideo =
uploadPurpose === "canvas-video-process" &&
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
const isOpenClawChatVideoUpload =
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
ALLOWED_OPENCLAW_VIDEO_TYPES.has(contentType);
const clientUploadId = const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null; String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml"; const isSvgUpload = contentType === "image/svg+xml";
if (contentType.startsWith("image/") && isImageUploadTooLarge(file.size)) {
return NextResponse.json(
{ error: IMAGE_UPLOAD_TOO_LARGE_MESSAGE },
{ status: 413 }
);
}
if ( if (
isSvgUpload && isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type)) (uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
@ -297,11 +227,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 }); return NextResponse.json({ error: errorMessage }, { status: 400 });
} }
if ( if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
!isAtlasCloudReferenceVideo &&
!isOpenClawChatVideoUpload
) {
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename); const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", { logger.warn("[Upload API] Unsupported upload content type", {
contentType, contentType,
@ -351,38 +277,6 @@ export async function POST(request: Request) {
{ status: 400 } { status: 400 }
); );
} }
} else if (isSharpCompatibilityUpload(contentType, filename)) {
try {
const convertedBuffer =
await convertSharpCompatibilityImageToJpeg(uploadBuffer);
logger.info("[Upload API] Compatibility image converted to JPEG", {
clientUploadId,
originalContentType: contentType,
originalSize: uploadBuffer.length,
convertedSize: convertedBuffer.length,
});
uploadBuffer = convertedBuffer;
filename = toJpegFilename(filename);
convertedFrom = contentType;
contentType = "image/jpeg";
} catch (conversionError) {
logger.warn("[Upload API] Compatibility image conversion failed", {
clientUploadId,
contentType,
size: uploadBuffer.length,
error:
conversionError instanceof Error
? conversionError.message
: String(conversionError),
});
return NextResponse.json(
{
error: createUnsupportedImageTypeMessage(contentType, filename),
clientUploadId,
},
{ status: 400 }
);
}
} }
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") { if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
@ -467,32 +361,18 @@ export async function POST(request: Request) {
try { try {
const asset = await upsertMediaAsset({ const asset = await upsertMediaAsset({
userId: session.user.id, userId: session.user.id,
mediaType: mediaType: "image",
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload sourceType: "upload",
? "video"
: "image",
sourceType: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: "upload",
status: "ready", status: "ready",
originalUrl: data.url, originalUrl: data.url,
previewUrl: previewUrl: data.url,
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload thumbnailUrl: data.url,
? null
: data.url,
thumbnailUrl:
isAtlasCloudReferenceVideo || isOpenClawChatVideoUpload
? null
: data.url,
mimeType: data.contentType || contentType || null, mimeType: data.contentType || contentType || null,
displayName: filename, displayName: filename,
sourceTable: isOpenClawChatVideoUpload sourceTable: "upload",
? "openclaw_chat_video_upload"
: "upload",
metadata: { metadata: {
pathname: data.pathname, pathname: data.pathname,
size: optimizedBuffer.length, size: optimizedBuffer.length,
purpose: uploadPurpose || null,
clientUploadId, clientUploadId,
convertedFrom, convertedFrom,
}, },

View File

@ -72,73 +72,6 @@ function isWorkflowImageGenerationModel(model: {
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限) // 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
export async function GET(request: Request) { export async function GET(request: Request) {
try { 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: "veo-3.1-fast",
displayName: "Veo 3.1 Fast(本地布局测试)",
avatarUrl: null,
description: "本地视频画布测试视频模型",
supportsVision: true,
supportsImageGen: false,
isMultimodal: true,
isDefault: false,
providerName: "Local E2E",
providerType: "vip98k",
isSubModel: false,
parentModelId: null,
capabilities: { kind: "video", supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"], supportedRatios:["16:9"], supportedResolutions:["720p","1080p"], durationOptions:[8], maxReferenceImages:3 },
videoToolCapability: {
supported: true,
durations: [8],
resolutions: ["720p", "1080p"],
},
imageGenConfig: {
creditCostPerVideo: 20,
videoDurationOptions: [8],
},
sortOrder: 1,
selectable: true,
},
];
return Response.json({
models,
defaultModelId: models[0].id,
agentWorkflowConfiguredDefaultModelId: null,
agentWorkflowDefaultModelId: null,
agentWorkflowConfiguredModels: [],
agentModel: null,
});
}
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {

View File

@ -3,5 +3,10 @@ export default function FlowBoardLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return <>{children}</>; return (
<>
<link href="/vendor/drawnix.css" rel="stylesheet" />
{children}
</>
);
} }

View File

@ -1,6 +1,5 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { ChatShell } from "@/components/chat-shell"; import { ChatShell } from "@/components/chat-shell";
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
import { DataStreamProvider } from "@/components/data-stream-provider"; import { DataStreamProvider } from "@/components/data-stream-provider";
import { auth } from "../(auth)/auth"; import { auth } from "../(auth)/auth";
import { import {
@ -9,14 +8,10 @@ import {
getTutorialVideos, getTutorialVideos,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
export const dynamic = "force-dynamic";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<DataStreamProvider> <DataStreamProvider>
<Suspense <Suspense fallback={<div className="flex h-dvh" />}>
fallback={<CanvasLogoLoadingView label="正在加载页面..." />}
>
<SidebarWrapper>{children}</SidebarWrapper> <SidebarWrapper>{children}</SidebarWrapper>
</Suspense> </Suspense>
</DataStreamProvider> </DataStreamProvider>

View File

@ -1,73 +0,0 @@
import { randomUUID } from "node:crypto";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm";
export const dynamic = "force-dynamic";
type Model3dRedirectPageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
function getSearchParam(
params: Record<string, string | string[] | undefined>,
key: string
) {
const value = params[key];
return Array.isArray(value) ? value[0] : value;
}
function buildModel3dUrl(
canvasId: string,
params: Record<string, string | string[] | undefined>
) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (key === "new" || value === undefined) {
continue;
}
if (Array.isArray(value)) {
value.forEach((item) => query.append(key, item));
continue;
}
query.set(key, value);
}
const queryString = query.toString();
return `/canvas/${canvasId}/model3d${queryString ? `?${queryString}` : ""}`;
}
async function getLatestCanvasId(userId: string) {
const [latestProject] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, userId),
eq(canvasProject.workspaceType, "canvas")
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(1);
return latestProject?.id || null;
}
export default async function Model3dRedirectPage({
searchParams,
}: Model3dRedirectPageProps) {
const params = (await searchParams) || {};
const session = await auth();
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fmodel3d");
}
const shouldCreateNew = getSearchParam(params, "new") === "1";
if (shouldCreateNew) {
redirect(buildModel3dUrl(randomUUID(), params));
}
const latestCanvasId = await getLatestCanvasId(session.user.id).catch(() => null);
redirect(buildModel3dUrl(latestCanvasId || randomUUID(), params));
}

View File

@ -1,95 +0,0 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { PascalEditorFrame } from "@/components/pascal-editor/pascal-editor-frame";
import { getSiteSettings } from "@/lib/db/queries";
const LOCAL_PASCAL_EDITOR_SRC = "/vendor/pascal-editor/index.html";
const DEV_SELF_HOSTED_PASCAL_EDITOR_SRC = "http://localhost:3002";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "建筑编辑",
};
export const runtime = "nodejs";
export default async function PascalEditorPage() {
const [session, siteSettings] = await Promise.all([auth(), getSiteSettings()]);
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fpascal-editor");
}
const configuredSrc = normalizePascalEditorUrl(
process.env.NEXT_PUBLIC_PASCAL_EDITOR_URL ||
process.env.PASCAL_EDITOR_URL ||
""
);
const hasLocalEditor = existsSync(
path.join(process.cwd(), "public/vendor/pascal-editor/index.html")
);
const shouldUseDevSelfHosted =
process.env.NODE_ENV === "development" && !configuredSrc && !hasLocalEditor;
const hasDevSelfHostedEditor =
shouldUseDevSelfHosted &&
(await isHttpUrlReachable(DEV_SELF_HOSTED_PASCAL_EDITOR_SRC));
const src =
configuredSrc ||
(hasLocalEditor ? LOCAL_PASCAL_EDITOR_SRC : "") ||
(hasDevSelfHostedEditor ? DEV_SELF_HOSTED_PASCAL_EDITOR_SRC : "") ||
LOCAL_PASCAL_EDITOR_SRC;
const isReady = Boolean(configuredSrc || hasLocalEditor || hasDevSelfHostedEditor);
return (
<PascalEditorFrame
fallbackSrc={LOCAL_PASCAL_EDITOR_SRC}
isReady={isReady}
sourceLabel={
hasDevSelfHostedEditor
? "本机 Pascal Editor"
: configuredSrc
? "Pascal Editor"
: "本地 Pascal Editor"
}
selfHostedSrc={DEV_SELF_HOSTED_PASCAL_EDITOR_SRC}
siteLogo={siteSettings.siteLogo}
siteLogoDark={siteSettings.siteLogoDark}
siteName={siteSettings.siteName}
src={src}
user={session.user}
/>
);
}
function normalizePascalEditorUrl(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (trimmed.startsWith("/")) {
return trimmed;
}
try {
const url = new URL(trimmed);
return url.protocol === "http:" || url.protocol === "https:"
? url.toString()
: "";
} catch {
return "";
}
}
async function isHttpUrlReachable(value: string) {
try {
const response = await fetch(value, {
cache: "no-store",
signal: AbortSignal.timeout(3000),
});
return response.ok;
} catch {
return false;
}
}

View File

@ -1,75 +1,79 @@
import { randomUUID } from "node:crypto";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { VideoCanvasHome, type VideoCanvasHomeProject } from "@/components/canvas/VideoCanvasHome";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getSiteSettings } from "@/lib/db/queries";
import { canvasProject } from "@/lib/db/schema"; import { canvasProject } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm";
type Props = { searchParams?: Promise<Record<string, string | string[] | undefined>> }; type VideoCanvasRedirectPageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
function first(value: string | string[] | undefined) { function getSearchParam(
params: Record<string, string | string[] | undefined>,
key: string
) {
const value = params[key];
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
function canvasHref(id: string, incoming: Record<string, string | string[] | undefined>) { function buildVideoCanvasUrl(
const params = new URLSearchParams({ mode: "video", workspaceType: "video" }); canvasId: string,
for (const [key, value] of Object.entries(incoming)) { params: Record<string, string | string[] | undefined>
if (["new", "mode", "workspaceType"].includes(key) || value === undefined) continue; ) {
for (const item of Array.isArray(value) ? value : [value]) params.append(key, item); const query = new URLSearchParams();
query.set("mode", "video");
query.set("workspaceType", "video");
for (const [key, value] of Object.entries(params)) {
if (
key === "new" ||
key === "mode" ||
key === "workspaceType" ||
value === undefined
) {
continue;
}
if (Array.isArray(value)) {
value.forEach((item) => query.append(key, item));
continue;
}
query.set(key, value);
} }
return `/canvas/${id}?${params.toString()}`;
return `/canvas/${canvasId}?${query.toString()}`;
} }
function projectPreview(thumbnail: string | null, canvasData: unknown) { async function getLatestVideoCanvasId(userId: string) {
const nodes = canvasData && typeof canvasData === "object" && "nodes" in canvasData const [latestProject] = await db
? (canvasData as { nodes?: unknown }).nodes : null; .select({ id: canvasProject.id })
const candidates = [thumbnail]; .from(canvasProject)
if (Array.isArray(nodes)) for (const node of [...nodes].reverse()) { .where(
if (!node || typeof node !== "object") continue; and(
const item = node as { inputType?: string; draftValues?: Record<string, unknown> }; eq(canvasProject.userId, userId),
const draft = item.draftValues || {}; eq(canvasProject.workspaceType, "video")
candidates.push( )
String(draft.poster || draft.previewImageUrl || draft.thumbnailUrl || draft.resultImage || (item.inputType === "image" ? draft.mediaUrl || (Array.isArray(draft.url) ? draft.url[0] : "") : "") || ""), )
); .orderBy(desc(canvasProject.updatedAt))
} .limit(1);
return candidates.find(value => typeof value === "string" && (value.startsWith("https://") || value.startsWith("/"))) || null;
return latestProject?.id || null;
} }
export default async function VideoCanvasHomePage({ searchParams }: Props) { export default async function VideoCanvasRedirectPage({
const session = await auth(); searchParams,
if (!session?.user?.id) redirect("/login?callbackUrl=%2Fvideo-canvas"); }: VideoCanvasRedirectPageProps) {
const params = (await searchParams) || {}; const params = (await searchParams) || {};
if (first(params.new) === "1") { const shouldCreateNew = getSearchParam(params, "new") === "1";
const [created] = await db.insert(canvasProject).values({
userId: session.user.id, if (shouldCreateNew) {
title: "未命名视频画布", redirect(buildVideoCanvasUrl(randomUUID(), params));
workspaceType: "video",
canvasData: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
}).returning({ id: canvasProject.id });
redirect(canvasHref(created.id, params));
} }
const [rows, siteSettings] = await Promise.all([db.select({ const session = await auth();
id: canvasProject.id, const latestCanvasId = session?.user?.id
title: canvasProject.title, ? await getLatestVideoCanvasId(session.user.id).catch(() => null)
thumbnail: canvasProject.thumbnail, : null;
canvasData: canvasProject.canvasData,
createdAt: canvasProject.createdAt,
updatedAt: canvasProject.updatedAt,
}).from(canvasProject).where(and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "video"),
)).orderBy(desc(canvasProject.updatedAt)).limit(25), getSiteSettings()]);
const projects: VideoCanvasHomeProject[] = rows.slice(0, 24).map(row => ({ redirect(buildVideoCanvasUrl(latestCanvasId || randomUUID(), params));
id: row.id,
title: row.title,
thumbnail: projectPreview(row.thumbnail, row.canvasData),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}));
return <VideoCanvasHome initialHasMore={rows.length > 24} initialProjects={projects} siteLogo={siteSettings.siteLogo} siteLogoDark={siteSettings.siteLogoDark} siteName={siteSettings.siteName} userName={session.user.name || ""} />;
} }

View File

@ -1,207 +0,0 @@
import { desc, eq, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { accessIpBlocklist, user } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import { requireAdmin } from "@/lib/middleware/admin";
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
import { normalizeManualBlockIp } from "@/lib/security/manual-ip-blocklist";
export const runtime = "nodejs";
export const maxDuration = 10;
const createSchema = z.object({
ip: z.string().trim().min(1).max(128),
reason: z.string().trim().max(500).optional().nullable(),
expiresAt: z.string().trim().optional().nullable(),
});
const patchSchema = z.object({
id: z.string().uuid(),
isActive: z.boolean(),
reason: z.string().trim().max(500).optional().nullable(),
expiresAt: z.string().trim().optional().nullable(),
});
function normalizeOptionalDate(value?: string | null) {
const normalized = String(value || "").trim();
if (!normalized) return null;
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
export async function GET() {
try {
await requireAdmin();
const entries = await db
.select({
id: accessIpBlocklist.id,
ip: accessIpBlocklist.ip,
reason: accessIpBlocklist.reason,
isActive: accessIpBlocklist.isActive,
expiresAt: accessIpBlocklist.expiresAt,
createdAt: accessIpBlocklist.createdAt,
updatedAt: accessIpBlocklist.updatedAt,
createdBy: accessIpBlocklist.createdBy,
createdByEmail: user.email,
})
.from(accessIpBlocklist)
.leftJoin(user, eq(accessIpBlocklist.createdBy, user.id))
.orderBy(desc(accessIpBlocklist.isActive), desc(accessIpBlocklist.updatedAt))
.limit(300);
return NextResponse.json({ entries });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
console.error("[AccessIpBlocklist] failed to list entries", error);
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function POST(request: Request) {
try {
const session = await requireAdmin();
const body = createSchema.parse(await request.json());
const normalizedIp = normalizeManualBlockIp(body.ip);
if (!normalizedIp) {
return new ChatSDKError(
"bad_request:api",
"请输入有效的 IP 地址或 IPv4 CIDR,例如 1.2.3.4 或 1.2.3.0/24"
).toResponse();
}
const now = new Date();
const expiresAt = normalizeOptionalDate(body.expiresAt);
const reason = body.reason?.trim() || null;
const [existing] = await db
.select({ id: accessIpBlocklist.id })
.from(accessIpBlocklist)
.where(sql`lower(${accessIpBlocklist.ip}) = lower(${normalizedIp})`)
.limit(1);
const [entry] = existing
? await db
.update(accessIpBlocklist)
.set({
reason,
isActive: true,
expiresAt,
updatedBy: session.user.id,
updatedAt: now,
})
.where(eq(accessIpBlocklist.id, existing.id))
.returning()
: await db
.insert(accessIpBlocklist)
.values({
ip: normalizedIp,
reason,
isActive: true,
expiresAt,
createdBy: session.user.id,
updatedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
.returning();
await recordAccessIpEventFromHeaders({
eventType: "manual_block_added",
userId: session.user.id,
userEmail: session.user.email || null,
userRole: session.user.type,
method: "POST",
path: "/api/admin/access-blocklist",
routeGroup: "admin-api",
statusCode: 200,
outcome: "manual_block_added",
severity: "high",
reasons: ["管理员手动拉黑 IP"],
metadata: {
targetIp: normalizedIp,
reason,
},
}).catch((error) => {
console.warn("[AccessIpBlocklist] failed to record block action", error);
});
return NextResponse.json({ entry });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
console.error("[AccessIpBlocklist] failed to create entry", error);
return new ChatSDKError("offline:admin").toResponse();
}
}
export async function PATCH(request: Request) {
try {
const session = await requireAdmin();
const body = patchSchema.parse(await request.json());
const now = new Date();
const expiresAt = normalizeOptionalDate(body.expiresAt);
const [existing] = await db
.select({ ip: accessIpBlocklist.ip })
.from(accessIpBlocklist)
.where(eq(accessIpBlocklist.id, body.id))
.limit(1);
if (!existing) {
return new ChatSDKError("bad_request:api", "IP 黑名单记录不存在").toResponse();
}
const [entry] = await db
.update(accessIpBlocklist)
.set({
isActive: body.isActive,
reason: body.reason?.trim() || undefined,
expiresAt,
updatedBy: session.user.id,
updatedAt: now,
})
.where(eq(accessIpBlocklist.id, body.id))
.returning();
await recordAccessIpEventFromHeaders({
eventType: body.isActive ? "manual_block_enabled" : "manual_block_released",
userId: session.user.id,
userEmail: session.user.email || null,
userRole: session.user.type,
method: "PATCH",
path: "/api/admin/access-blocklist",
routeGroup: "admin-api",
statusCode: 200,
outcome: body.isActive ? "manual_block_enabled" : "manual_block_released",
severity: body.isActive ? "high" : "watch",
reasons: [body.isActive ? "管理员启用 IP 拉黑" : "管理员解除 IP 拉黑"],
metadata: {
targetIp: existing.ip,
blocklistId: body.id,
},
}).catch((error) => {
console.warn("[AccessIpBlocklist] failed to record update action", error);
});
return NextResponse.json({ entry });
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
console.error("[AccessIpBlocklist] failed to update entry", error);
return new ChatSDKError("offline:admin").toResponse();
}
}

View File

@ -1,271 +0,0 @@
import { and, desc, gte, sql, type SQL } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { accessIpEvent } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import { requireAdmin } from "@/lib/middleware/admin";
export const runtime = "nodejs";
export const maxDuration = 10;
type Severity = "info" | "watch" | "high" | "critical";
const severityByRank: Record<number, Severity> = {
0: "info",
1: "watch",
2: "high",
3: "critical",
};
function clampInt(value: string | null, fallback: number, min: number, max: number) {
const parsed = Number.parseInt(value || "", 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function normalizeSearch(value: string | null) {
return String(value || "").trim().slice(0, 120);
}
function buildSeverityFromAggregate(params: {
eventSeverityRank: number;
total: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
suspiciousCount: number;
}) {
let severity = severityByRank[params.eventSeverityRank] || "info";
const failureRate =
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
if (params.rateLimitedCount > 0 || params.suspiciousCount > 0) {
severity = "high";
} else if (params.authFailureCount >= 5 || params.total >= 250) {
severity = severity === "critical" ? severity : "high";
} else if (
params.failureCount >= 20 ||
(params.total >= 10 && failureRate >= 0.45)
) {
severity = severity === "critical" ? severity : "high";
} else if (params.authFailureCount > 0 || params.failureCount > 0) {
severity = severity === "info" ? "watch" : severity;
}
return severity;
}
function buildAggregateReasons(params: {
total: number;
failureCount: number;
rateLimitedCount: number;
authFailureCount: number;
suspiciousCount: number;
loginSuccessCount: number;
}) {
const reasons: string[] = [];
const failureRate =
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
if (params.rateLimitedCount > 0) reasons.push("触发限流");
if (params.authFailureCount >= 5) reasons.push("认证失败偏多");
if (params.suspiciousCount > 0) reasons.push("命中异常规则");
if (params.total >= 250) reasons.push("请求量偏高");
if (params.failureCount >= 20 || (params.total >= 10 && failureRate >= 0.45)) {
reasons.push("失败率偏高");
}
if (params.loginSuccessCount > 0 && reasons.length === 0) {
reasons.push("有成功登录记录");
}
return reasons;
}
export async function GET(request: Request) {
try {
await requireAdmin();
const { searchParams } = new URL(request.url);
const hours = clampInt(searchParams.get("hours"), 24, 1, 168);
const limit = clampInt(searchParams.get("limit"), 120, 25, 500);
const severityFilter = searchParams.get("severity") || "all";
const search = normalizeSearch(searchParams.get("q"));
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
const whereParts: SQL[] = [gte(accessIpEvent.createdAt, since)];
if (severityFilter === "abnormal") {
whereParts.push(sql`${accessIpEvent.severity} <> 'info'`);
} else if (["info", "watch", "high", "critical"].includes(severityFilter)) {
whereParts.push(sql`${accessIpEvent.severity} = ${severityFilter}`);
}
if (search) {
const pattern = `%${search}%`;
whereParts.push(sql`(
${accessIpEvent.ip} ILIKE ${pattern}
OR ${accessIpEvent.path} ILIKE ${pattern}
OR ${accessIpEvent.userEmail} ILIKE ${pattern}
OR ${accessIpEvent.userAgent} ILIKE ${pattern}
)`);
}
const whereClause = and(...whereParts);
const [overview] = await db
.select({
totalEvents: sql<number>`count(*)::int`,
uniqueIps: sql<number>`count(distinct ${accessIpEvent.ip})::int`,
abnormalEvents: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
abnormalIps: sql<number>`count(distinct ${accessIpEvent.ip}) filter (where ${accessIpEvent.severity} <> 'info')::int`,
rateLimitedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
manualBlockedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'manual_blocked')::int`,
loginSuccessEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
unauthorizedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'unauthorized' or ${accessIpEvent.statusCode} in (401, 403))::int`,
})
.from(accessIpEvent)
.where(whereClause);
const recentEvents = await db
.select({
id: accessIpEvent.id,
eventType: accessIpEvent.eventType,
ip: accessIpEvent.ip,
userId: accessIpEvent.userId,
userEmail: accessIpEvent.userEmail,
userRole: accessIpEvent.userRole,
method: accessIpEvent.method,
path: accessIpEvent.path,
routeGroup: accessIpEvent.routeGroup,
statusCode: accessIpEvent.statusCode,
outcome: accessIpEvent.outcome,
severity: accessIpEvent.severity,
reasons: accessIpEvent.reasons,
userAgent: accessIpEvent.userAgent,
referer: accessIpEvent.referer,
occurredAt: accessIpEvent.occurredAt,
createdAt: accessIpEvent.createdAt,
})
.from(accessIpEvent)
.where(whereClause)
.orderBy(desc(accessIpEvent.createdAt))
.limit(limit);
const topIpRows = await db
.select({
ip: accessIpEvent.ip,
total: sql<number>`count(*)::int`,
uniqueUsers: sql<number>`count(distinct ${accessIpEvent.userId}) filter (where ${accessIpEvent.userId} is not null)::int`,
failureCount: sql<number>`count(*) filter (where ${accessIpEvent.statusCode} >= 400)::int`,
rateLimitedCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
authFailureCount: sql<number>`count(*) filter (where ${accessIpEvent.path} like '/api/auth%' and ${accessIpEvent.statusCode} >= 400)::int`,
loginSuccessCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
suspiciousCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical'))::int`,
firstSeen: sql<string>`min(${accessIpEvent.createdAt})::text`,
lastSeen: sql<string>`max(${accessIpEvent.createdAt})::text`,
eventSeverityRank: sql<number>`max(case ${accessIpEvent.severity}
when 'critical' then 3
when 'high' then 2
when 'watch' then 1
else 0
end)::int`,
})
.from(accessIpEvent)
.where(whereClause)
.groupBy(accessIpEvent.ip)
.orderBy(
desc(sql`(
count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical')) * 8
+ count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429) * 12
+ count(*) filter (where ${accessIpEvent.statusCode} >= 400) * 3
+ count(*)
)`)
)
.limit(24);
const topIps = topIpRows.map((row) => {
const severity = buildSeverityFromAggregate({
eventSeverityRank: Number(row.eventSeverityRank || 0),
total: Number(row.total || 0),
failureCount: Number(row.failureCount || 0),
rateLimitedCount: Number(row.rateLimitedCount || 0),
authFailureCount: Number(row.authFailureCount || 0),
suspiciousCount: Number(row.suspiciousCount || 0),
});
return {
...row,
severity,
failureRate:
Number(row.total || 0) > 0
? Number(row.failureCount || 0) / Number(row.total || 1)
: 0,
reasons: buildAggregateReasons({
total: Number(row.total || 0),
failureCount: Number(row.failureCount || 0),
rateLimitedCount: Number(row.rateLimitedCount || 0),
authFailureCount: Number(row.authFailureCount || 0),
suspiciousCount: Number(row.suspiciousCount || 0),
loginSuccessCount: Number(row.loginSuccessCount || 0),
}),
};
});
const hourlyStats = await db
.select({
hour: sql<string>`to_char(date_trunc('hour', ${accessIpEvent.createdAt}), 'MM-DD HH24:00')`,
count: sql<number>`count(*)::int`,
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
})
.from(accessIpEvent)
.where(whereClause)
.groupBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`)
.orderBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`);
const pathStats = await db
.select({
path: accessIpEvent.path,
routeGroup: accessIpEvent.routeGroup,
count: sql<number>`count(*)::int`,
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
})
.from(accessIpEvent)
.where(whereClause)
.groupBy(accessIpEvent.path, accessIpEvent.routeGroup)
.orderBy(desc(sql`count(*)`))
.limit(12);
return NextResponse.json({
window: {
hours,
since: since.toISOString(),
},
overview: {
totalEvents: overview?.totalEvents || 0,
uniqueIps: overview?.uniqueIps || 0,
abnormalEvents: overview?.abnormalEvents || 0,
abnormalIps: overview?.abnormalIps || 0,
rateLimitedEvents: overview?.rateLimitedEvents || 0,
manualBlockedEvents: overview?.manualBlockedEvents || 0,
loginSuccessEvents: overview?.loginSuccessEvents || 0,
unauthorizedEvents: overview?.unauthorizedEvents || 0,
},
topIps,
hourlyStats,
pathStats,
recentEvents,
rules: [
"短时间请求量偏高",
"4xx/5xx 或失败率偏高",
"认证失败偏多",
"触发限流",
"命中敏感路径或异常 User-Agent",
],
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
console.error("[AccessIpEvent] failed to load admin analytics", error);
return new ChatSDKError("offline:admin").toResponse();
}
}

View File

@ -33,14 +33,6 @@ const imageGenConfigSchema = z.object({
supportsNegativePrompt: z.boolean().optional(), supportsNegativePrompt: z.boolean().optional(),
supportsWatermark: z.boolean().optional(), supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(), supportsSeed: z.boolean().optional(),
supportsQuality: z.boolean().optional(),
supportedAspectRatios: z.array(z.string().min(1).max(32)).max(32).optional(),
supportedImageSizes: z.array(z.string().min(1).max(32)).max(32).optional(),
maxReferenceImages: z.number().min(0).max(64).optional(),
maxVideoReferences: z.number().min(0).max(16).optional(),
ruxaTaskInputMode: z
.enum(["size-image", "resolution-based"])
.optional(),
creditCostPerImage: z.number().min(0).max(100000).optional(), creditCostPerImage: z.number().min(0).max(100000).optional(),
creditCostMultiplierBySize: z.record(z.string(), z.number().min(0.01).max(100)).optional(), creditCostMultiplierBySize: z.record(z.string(), z.number().min(0.01).max(100)).optional(),
creditDiscountRate: z.number().min(0.1).max(10).optional(), creditDiscountRate: z.number().min(0.1).max(10).optional(),
@ -50,7 +42,6 @@ const imageGenConfigSchema = z.object({
creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(), creditCostPerVideoByResolution: z.record(z.string(), z.number().min(0).max(100000)).optional(),
enableLoadingPlaceholder: z.boolean().optional(), enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(), loadingColor: z.string().optional(),
vip98k: z.record(z.string(), z.unknown()).optional(),
}).optional(); }).optional();
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional(); const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();

View File

@ -9,8 +9,6 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models"; import { BLTCY_VIDEO_BASE_URL, isBltcyVideoBaseUrl } from "@/lib/ai/bltcy-video-models";
import { VIP98K_BASE_URL } from "@/lib/ai/vip98k-models";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const providerTypes = [ const providerTypes = [
"alibaba", "alibaba",
@ -31,12 +29,7 @@ const providerTypes = [
"bfl-image", "bfl-image",
"ruxa-image", "ruxa-image",
"fal-image", "fal-image",
"snapgen-image",
"openai-image", "openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image", "stepfun-image",
"midjourney", "midjourney",
"fal-video", "fal-video",
@ -76,17 +69,12 @@ const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai"; const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai"; const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1"; const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
const VISIONARY_BASE_URL = "https://api.visionary.beer";
const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
const VIP98K_PROVIDER_BASE_URL = VIP98K_BASE_URL;
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com"; const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai"; const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com"; const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi"; const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com"; const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev"; const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com"; const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const KREA_BASE_URL = "https://api.krea.ai"; const KREA_BASE_URL = "https://api.krea.ai";
@ -242,18 +230,8 @@ export async function POST(request: Request) {
? data.baseUrl || BFL_IMAGE_BASE_URL ? data.baseUrl || BFL_IMAGE_BASE_URL
: data.type === "ruxa-image" : data.type === "ruxa-image"
? data.baseUrl || RUXA_IMAGE_BASE_URL ? data.baseUrl || RUXA_IMAGE_BASE_URL
: data.type === "snapgen-image"
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
: data.type === "openai-image" : data.type === "openai-image"
? data.baseUrl || OPENAI_IMAGE_BASE_URL ? data.baseUrl || OPENAI_IMAGE_BASE_URL
: data.type === "bananaimage"
? data.baseUrl || BANANAIMAGE_BASE_URL
: data.type === "visionary"
? data.baseUrl || VISIONARY_BASE_URL
: data.type === "atlascloud"
? data.baseUrl || ATLASCLOUD_BASE_URL
: data.type === "vip98k"
? data.baseUrl || VIP98K_PROVIDER_BASE_URL
: data.type === "stepfun-image" : data.type === "stepfun-image"
? data.baseUrl || STEPFUN_IMAGE_BASE_URL ? data.baseUrl || STEPFUN_IMAGE_BASE_URL
: data.type === "midjourney" : data.type === "midjourney"
@ -277,7 +255,6 @@ export async function POST(request: Request) {
}); });
console.log("[Admin API] Provider created successfully:", provider[0].id); console.log("[Admin API] Provider created successfully:", provider[0].id);
clearScopedSiteSettingsCache();
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) }); return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
} catch (error) { } catch (error) {
console.error("[Admin API] Create provider error:", error); console.error("[Admin API] Create provider error:", error);
@ -316,12 +293,6 @@ export async function PUT(request: Request) {
...data, ...data,
apiKey: normalizedApiKey, apiKey: normalizedApiKey,
type: normalizeProviderTypeForStorage(data.type), type: normalizeProviderTypeForStorage(data.type),
baseUrl:
data.baseUrl !== undefined
? data.baseUrl
: data.type === "vip98k"
? VIP98K_PROVIDER_BASE_URL
: undefined,
updatedBy: session.user.id, updatedBy: session.user.id,
}); });
@ -329,7 +300,6 @@ export async function PUT(request: Request) {
return new ChatSDKError("bad_request:api", "Provider not found").toResponse(); return new ChatSDKError("bad_request:api", "Provider not found").toResponse();
} }
clearScopedSiteSettingsCache();
return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) }); return Response.json({ provider: normalizeProviderTypeForResponse(provider[0] as any) });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {
@ -355,7 +325,6 @@ export async function DELETE(request: Request) {
await deleteAiProvider({ id }); await deleteAiProvider({ id });
clearScopedSiteSettingsCache();
return Response.json({ success: true }); return Response.json({ success: true });
} catch (error) { } catch (error) {
if (error instanceof ChatSDKError) { if (error instanceof ChatSDKError) {

View File

@ -1,182 +0,0 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
fetchAtlasCloudModelPresets,
resolveAtlasCloudDisplayName,
shouldInsertMissingAtlasCloudPreset,
} from "@/lib/ai/atlascloud-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export const runtime = "nodejs";
export const maxDuration = 300;
export async function GET() {
try {
await requireAdmin();
const result = await fetchAtlasCloudModelPresets();
return NextResponse.json({
presets: result.presets,
errors: result.errors.length > 0 ? result.errors : undefined,
counts: {
total: result.catalogCount,
available: result.presets.length,
failed: result.errors.length,
},
});
} catch (error) {
console.error("[Atlas Cloud Models API] GET error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to fetch Atlas Cloud models",
},
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const requestedModelIds = Array.isArray(body?.modelIds)
? new Set(
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean)
)
: null;
if (!providerId) {
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "atlascloud") {
return NextResponse.json(
{ error: "Provider must be an Atlas Cloud provider" },
{ status: 400 }
);
}
const catalog = await fetchAtlasCloudModelPresets();
const presets = requestedModelIds
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
: catalog.presets;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors = [...catalog.errors];
const providerModels = await db.query.aiModel.findMany({
where: eq(aiModel.providerId, providerId),
});
const existingByModelId = new Map(
providerModels.map((model) => [model.modelId, model])
);
const existingMediaKinds = new Set(
providerModels.map((model) => {
const capabilities = model.capabilities as Record<string, unknown> | null;
return String(capabilities?.mediaType || "image");
})
);
let preservedDeletedCount = 0;
for (const preset of presets) {
try {
const existing = existingByModelId.get(preset.modelId);
const presetCapabilities = preset.capabilities as Record<string, unknown>;
const mediaKind = String(presetCapabilities.mediaType || "image");
if (
!existing &&
!shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
) {
preservedDeletedCount += 1;
continue;
}
const values = {
displayName: resolveAtlasCloudDisplayName(
existing?.displayName,
preset.displayName,
preset.modelId
),
avatarUrl: preset.avatarUrl || null,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
sortOrder: existing?.sortOrder ?? preset.sortOrder,
capabilities: preset.capabilities,
defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig,
updatedAt: new Date(),
};
if (existing) {
const [updated] = await db
.update(aiModel)
.set(values)
.where(
and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
)
)
.returning();
if (updated) updatedModels.push(updated);
} else {
const [created] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
...values,
isEnabled: true,
isHidden: false,
isDefault: false,
isSubModel: false,
parentModelId: null,
})
.returning();
if (created) addedModels.push(created);
}
} catch (error) {
console.error(
`[Atlas Cloud Models API] Failed to sync ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error instanceof Error ? error.message : "Sync failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presets.length,
total: catalog.catalogCount,
available: catalog.presets.length,
skippedUnsupported: catalog.skipped.length,
preservedDeleted: preservedDeletedCount,
},
});
} catch (error) {
console.error("[Atlas Cloud Models API] POST error:", error);
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to sync Atlas Cloud models",
},
{ status: 500 }
);
}
}

View File

@ -1,130 +0,0 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: BANANAIMAGE_MODEL_PRESETS,
counts: { total: BANANAIMAGE_MODEL_PRESETS.length },
});
} catch (error: any) {
console.error("[BananaImage Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch BananaImage model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? body.modelIds
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: null;
if (!providerId) {
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "bananaimage") {
return NextResponse.json(
{ error: "Provider must be a BananaImage provider" },
{ status: 400 }
);
}
const presets = modelIds
? BANANAIMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
: BANANAIMAGE_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
for (const preset of presets) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
const values = {
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
};
if (existing) {
const [updated] = await db
.update(aiModel)
.set(values)
.where(
and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
)
)
.returning();
if (updated) updatedModels.push(updated);
continue;
}
const [created] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
...values,
isEnabled: true,
isHidden: false,
isDefault: false,
isSubModel: false,
parentModelId: null,
})
.returning();
if (created) addedModels.push(created);
} catch (error: any) {
console.error(`[BananaImage Models API] Failed to sync ${preset.modelId}:`, error);
errors.push({
modelId: preset.modelId,
error: error.message || "Sync failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: { requested: presets.length, total: BANANAIMAGE_MODEL_PRESETS.length },
});
} catch (error: any) {
console.error("[BananaImage Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to sync BananaImage models" },
{ status: 500 }
);
}
}

View File

@ -1,8 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { import {
AGENT_PSD_TEMPLATES_KEY,
type AgentPsdTemplate,
type AgentPsdTemplateLayerSlot, type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateMutationResponse, type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config"; } from "@/lib/agents/psd-template-config";
@ -12,17 +15,6 @@ import {
} from "@/lib/agents/psd-template-parser"; } from "@/lib/agents/psd-template-parser";
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection"; import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill"; import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill";
import {
AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE,
AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE,
assertSupportedAgentPsdTemplateFile,
type AgentPsdTemplateFileInspection,
} from "@/lib/agents/psd-template-format";
import {
createAgentPsdTemplateParseHeartbeat,
updateAgentPsdTemplateById,
updateAgentPsdTemplateParseProgress,
} from "@/lib/agents/psd-template-progress";
import { import {
cacheAgentPsdSourceLocally, cacheAgentPsdSourceLocally,
hasAgentPsdLocalCache, hasAgentPsdLocalCache,
@ -33,7 +25,6 @@ import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 1800;
function jsonError(status: number, error: string) { function jsonError(status: number, error: string) {
const payload: AgentPsdTemplateMutationResponse = { const payload: AgentPsdTemplateMutationResponse = {
@ -57,22 +48,6 @@ function getErrorMessage(error: unknown) {
return "PSD 解析失败"; return "PSD 解析失败";
} }
function getPsdTemplateParseErrorMessage(params: {
error: unknown;
fileInspection?: AgentPsdTemplateFileInspection;
}) {
const message = getErrorMessage(params.error);
if (
params.fileInspection?.format === "psb" &&
/invalid signature/i.test(message)
) {
return AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE;
}
return message;
}
function pickPrimaryImageLayerId(params: { function pickPrimaryImageLayerId(params: {
parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"]; parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"];
canvasWidth: number; canvasWidth: number;
@ -182,15 +157,44 @@ function buildInitialTemplateLayers(params: {
}); });
} }
async function updateTemplate(
templateId: string,
updatedBy: string,
updater: (template: AgentPsdTemplate) => AgentPsdTemplate
) {
const templates = await readAgentPsdTemplates();
const currentTemplate = templates.find((entry) => entry.id === templateId);
if (!currentTemplate) {
return null;
}
const nextTemplate = updater(currentTemplate);
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates.map((entry) => (entry.id === templateId ? nextTemplate : entry)),
updatedBy,
});
return nextTemplate;
}
async function updateParseProgress(params: { async function updateParseProgress(params: {
templateId: string; templateId: string;
updatedBy: string; updatedBy: string;
progress: number; progress: number;
stage: string; stage: string;
error?: string; error?: string;
restart?: boolean;
}) { }) {
return updateAgentPsdTemplateParseProgress(params); const { templateId, updatedBy, progress, stage, error } = params;
return updateTemplate(templateId, updatedBy, (template) => ({
...template,
parseStatus: "processing",
parseProgress: progress,
parseStage: stage,
parseError: error,
updatedAt: new Date().toISOString(),
}));
} }
export async function POST( export async function POST(
@ -203,12 +207,6 @@ export async function POST(
} }
const { id } = await context.params; const { id } = await context.params;
let fileInspection: AgentPsdTemplateFileInspection | undefined;
let latestProgress = {
progress: 0,
stage: "准备解析 PSD",
error: undefined as string | undefined,
};
try { try {
const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({ const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({
@ -232,205 +230,155 @@ export async function POST(
return jsonError(409, "该模板正在解析中,请稍后刷新"); return jsonError(409, "该模板正在解析中,请稍后刷新");
} }
latestProgress = { await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 5, progress: 5,
stage: "准备解析 PSD", stage: "准备解析 PSD",
error: undefined, error: undefined,
}; });
const setParseProgress = async (progress: number, stage: string, error?: string) => {
latestProgress = { let fileBuffer: ArrayBuffer;
progress, if (
stage, await hasAgentPsdLocalCache({
error, templateId: id,
}; fileName: currentTemplate.fileName,
return updateParseProgress({ })
) {
await updateParseProgress({
templateId: id, templateId: id,
updatedBy: session.user.id, updatedBy: session.user.id,
progress, progress: 18,
stage, stage: "正在读取服务器本地 PSD 缓存",
error, error: undefined,
}); });
}; const localBuffer = await readAgentPsdSourceFromLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
});
fileBuffer = localBuffer.buffer.slice(
localBuffer.byteOffset,
localBuffer.byteOffset + localBuffer.byteLength
) as ArrayBuffer;
} else {
const response = await fetch(currentTemplate.fileUrl, {
cache: "no-store",
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
throw new Error(`PSD 文件下载失败 (${response.status})`);
}
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 18,
stage: "正在下载并写入服务器本地 PSD 缓存",
error: undefined,
});
fileBuffer = await response.arrayBuffer();
await cacheAgentPsdSourceLocally({
templateId: id,
fileName: currentTemplate.fileName,
buffer: fileBuffer,
}).catch((error) => {
console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", {
templateId: id,
error,
});
});
}
const parsedTemplate = await parsePsdTemplateFile(fileBuffer);
if (parsedTemplate.layers.length === 0) {
throw new Error("PSD 中未识别到可配置图层");
}
await updateParseProgress({ await updateParseProgress({
templateId: id, templateId: id,
updatedBy: session.user.id, updatedBy: session.user.id,
...latestProgress, progress: 42,
stage: "正在生成模板预览",
error: undefined, error: undefined,
restart: true,
}); });
const heartbeat = createAgentPsdTemplateParseHeartbeat({
const uploadedPreview = await uploadFile(
`agent-psd-preview-${id}-${Date.now()}.png`,
toArrayBuffer(parsedTemplate.previewBuffer)
);
await updateParseProgress({
templateId: id, templateId: id,
updatedBy: session.user.id, updatedBy: session.user.id,
getSnapshot: () => latestProgress, progress: 58,
stage: "正在匹配字体与初始化图层",
error: undefined,
}); });
try { const fonts = await readAgentPsdFonts().catch(() => []);
let fileBuffer: ArrayBuffer; const initialLayers = buildInitialTemplateLayers({
if ( parsedLayers: parsedTemplate.layers,
await hasAgentPsdLocalCache({ fonts,
templateId: id, canvasWidth: parsedTemplate.width,
fileName: currentTemplate.fileName, canvasHeight: parsedTemplate.height,
}) });
) {
await setParseProgress(18, "正在读取服务器本地 PSD 缓存");
const localBuffer = await readAgentPsdSourceFromLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
});
fileBuffer = localBuffer.buffer.slice(
localBuffer.byteOffset,
localBuffer.byteOffset + localBuffer.byteLength
) as ArrayBuffer;
} else {
const response = await fetch(currentTemplate.fileUrl, {
cache: "no-store",
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) { const updatedTemplate = await updateTemplate(id, session.user.id, (template) => ({
throw new Error(`PSD 文件下载失败 (${response.status})`); ...template,
} previewUrl: uploadedPreview.url,
previewPath: uploadedPreview.pathname,
width: parsedTemplate.width,
height: parsedTemplate.height,
layerCount: parsedTemplate.layers.length,
layers: initialLayers,
parseStatus: "processing",
parseProgress: 48,
parseStage: "快速解析完成,正在后台补全图层资源和模板底图",
parseError: undefined,
updatedAt: new Date().toISOString(),
}));
await setParseProgress(18, "正在下载并写入服务器本地 PSD 缓存"); if (!updatedTemplate) {
return jsonError(404, "未找到 PSD 模板");
fileBuffer = await response.arrayBuffer();
await cacheAgentPsdSourceLocally({
templateId: id,
fileName: currentTemplate.fileName,
buffer: fileBuffer,
}).catch((error) => {
console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", {
templateId: id,
error,
});
});
}
await setParseProgress(22, "正在校验 PSD 文件格式");
fileInspection = assertSupportedAgentPsdTemplateFile(fileBuffer);
await setParseProgress(24, "正在解析 PSD 图层结构与预览");
const parsedTemplate = await parsePsdTemplateFile(fileBuffer, {
onProgress: async (event) => {
await setParseProgress(event.progress, event.stage);
},
});
if (parsedTemplate.layers.length === 0) {
throw new Error("PSD 中未识别到可配置图层");
}
await setParseProgress(42, "正在上传模板预览");
const uploadedPreview = await uploadFile(
`agent-psd-preview-${id}-${Date.now()}.png`,
toArrayBuffer(parsedTemplate.previewBuffer)
);
await setParseProgress(58, "正在匹配字体与初始化图层");
const fonts = await readAgentPsdFonts().catch(() => []);
const initialLayers = buildInitialTemplateLayers({
parsedLayers: parsedTemplate.layers,
fonts,
canvasWidth: parsedTemplate.width,
canvasHeight: parsedTemplate.height,
});
heartbeat.stop();
const updatedTemplate = await updateAgentPsdTemplateById({
templateId: id,
updatedBy: session.user.id,
updater: (template) => {
const now = new Date().toISOString();
return {
...template,
previewUrl: uploadedPreview.url,
previewPath: uploadedPreview.pathname,
width: parsedTemplate.width,
height: parsedTemplate.height,
layerCount: parsedTemplate.layers.length,
layers: initialLayers,
parseStatus: "processing",
parseProgress: 48,
parseStage: "快速解析完成,正在后台补全图层资源和模板底图",
parseError: undefined,
parseHeartbeatAt: now,
updatedAt: now,
};
},
});
if (!updatedTemplate) {
return jsonError(404, "未找到 PSD 模板");
}
schedulePsdTemplateAssetBackfill({
templateId: id,
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
template: updatedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
heartbeat.stop();
throw error;
} }
} catch (error) {
const message = getPsdTemplateParseErrorMessage({ schedulePsdTemplateAssetBackfill({
error, templateId: id,
fileInspection, updatedBy: session.user.id,
}); });
return NextResponse.json({
success: true,
template: updatedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
const message = getErrorMessage(error);
const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id); const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id);
if (existingTemplate) { if (existingTemplate) {
const hasUsablePreviousParse = const fallbackStatus =
existingTemplate.layers.length > 0 && existingTemplate.parseStatus === "completed" && existingTemplate.layers.length > 0
existingTemplate.width > 1 && ? "completed"
existingTemplate.height > 1 && : "failed";
Boolean(existingTemplate.previewUrl);
const fallbackStatus = hasUsablePreviousParse ? "completed" : "failed";
await updateAgentPsdTemplateById({ await updateTemplate(id, session.user.id, (template) => ({
templateId: id, ...template,
updatedBy: session.user.id, parseStatus: fallbackStatus,
updater: (template) => ({ parseProgress: fallbackStatus === "completed" ? 100 : 0,
...template, parseStage:
parseStatus: fallbackStatus, fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined,
parseProgress: parseError:
fallbackStatus === "completed" fallbackStatus === "completed"
? 100 ? `重新解析失败:${message}`
: Math.max(0, Math.round(Number(latestProgress.progress || 0))), : message,
parseStage: updatedAt: new Date().toISOString(),
fallbackStatus === "completed" })).catch(() => undefined);
? "沿用上次成功解析结果"
: latestProgress.stage || "PSD 解析失败",
parseError:
fallbackStatus === "completed"
? `重新解析失败:${message}`
: message,
updatedAt: new Date().toISOString(),
}),
}).catch(() => undefined);
} }
console.error("[PSD Templates][Admin] parse failed:", { console.error("[PSD Templates][Admin] parse failed:", error);
templateId: id, return jsonError(502, message);
fileName: existingTemplate?.fileName,
progress: latestProgress.progress,
stage: latestProgress.stage,
fileFormat: fileInspection?.format,
error,
});
return jsonError(
message === AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE ||
message === AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE
? 400
: 502,
message
);
} }
} }

View File

@ -15,10 +15,6 @@ import {
type AgentPsdTemplateMutationResponse, type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config"; } from "@/lib/agents/psd-template-config";
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config"; import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
import {
AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE,
assertSupportedAgentPsdTemplateFile,
} from "@/lib/agents/psd-template-format";
import { cacheAgentPsdSourceLocally } from "@/lib/agents/psd-local-cache"; import { cacheAgentPsdSourceLocally } from "@/lib/agents/psd-local-cache";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout"; import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout";
@ -226,7 +222,7 @@ export async function POST(request: NextRequest) {
const extension = getFileExtension(file.name); const extension = getFileExtension(file.name);
if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) { if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) {
return jsonMutationError(400, "模板文件仅支持 Photoshop PSD / PSB"); return jsonMutationError(400, "模板文件仅支持 PSD / PSB");
} }
if (file.size <= 0) { if (file.size <= 0) {
@ -239,15 +235,11 @@ export async function POST(request: NextRequest) {
try { try {
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
const fileInspection = assertSupportedAgentPsdTemplateFile(fileBuffer);
const templateId = randomUUID(); const templateId = randomUUID();
console.log("[PSD Templates][Admin] received PSD template upload:", { console.log("[PSD Templates][Admin] received PSD template upload:", {
templateId, templateId,
fileName: file.name, fileName: file.name,
size: file.size, size: file.size,
fileFormat: fileInspection.format,
width: fileInspection.width,
height: fileInspection.height,
}); });
await cacheAgentPsdSourceLocally({ await cacheAgentPsdSourceLocally({
templateId, templateId,
@ -319,13 +311,6 @@ export async function POST(request: NextRequest) {
} satisfies AgentPsdTemplateMutationResponse); } satisfies AgentPsdTemplateMutationResponse);
} catch (error) { } catch (error) {
console.error("[PSD Templates][Admin] create failed:", error); console.error("[PSD Templates][Admin] create failed:", error);
if (error instanceof Error && error.message.trim()) {
if (
error.message === AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE
) {
return jsonMutationError(400, error.message);
}
}
return jsonMutationError(502, "PSD 模板上传或解析失败"); return jsonMutationError(502, "PSD 模板上传或解析失败");
} }
} }

View File

@ -1,157 +0,0 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { RUXA_IMAGE_MODEL_PRESETS } from "@/lib/ai/ruxa-image-models";
import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: RUXA_IMAGE_MODEL_PRESETS,
counts: {
total: RUXA_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[RUXA Image Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch RUXA image model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? body.modelIds
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: null;
if (!providerId) {
return NextResponse.json(
{ error: "Provider ID is required" },
{ status: 400 }
);
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "ruxa-image") {
return NextResponse.json(
{ error: "Provider must be a RUXA image provider" },
{ status: 400 }
);
}
const presetsToAdd = modelIds
? RUXA_IMAGE_MODEL_PRESETS.filter((preset) =>
modelIds.includes(preset.modelId)
)
: RUXA_IMAGE_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
const updatedModels: AiModel[] = [];
for (const preset of presetsToAdd) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
if (existing) {
const [updated] = await db
.update(aiModel)
.set({
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
})
.where(
and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
)
)
.returning();
if (updated) {
updatedModels.push(updated);
} else {
errors.push({ modelId: preset.modelId, error: "Model exists but failed to sync" });
}
continue;
}
const [newModel] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
isDefault: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
isSubModel: false,
parentModelId: null,
})
.returning();
addedModels.push(newModel);
} catch (error: any) {
console.error(
`[RUXA Image Models API] Failed to add ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error.message || "Insert failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presetsToAdd.length,
total: RUXA_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[RUXA Image Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to add RUXA image models" },
{ status: 500 }
);
}
}

View File

@ -18,8 +18,6 @@ import {
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME, DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID, DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY, DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
isDetailAgentLocalMoonshotBaseUrl,
normalizeDetailAgentLocalMoonshotBaseUrl,
} from "@/lib/ai/detail-agent-model-config"; } from "@/lib/ai/detail-agent-model-config";
import { import {
normalizeProductPlanningApiConfig, normalizeProductPlanningApiConfig,
@ -53,10 +51,6 @@ import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
normalizeCanvasImageTaskQueueConfig, normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config"; } from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import { import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
normalizeDingTalkRobotNotificationConfig, normalizeDingTalkRobotNotificationConfig,
@ -73,15 +67,6 @@ import {
EXTERNAL_IMAGE_API_CONFIG_KEY, EXTERNAL_IMAGE_API_CONFIG_KEY,
normalizeExternalImageApiConfig, normalizeExternalImageApiConfig,
} from "@/lib/external-image-api/config"; } from "@/lib/external-image-api/config";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
toAdminOpenClawChatConfig,
} from "@/lib/openclaw-chat/config";
import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache"; import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache";
const settingSchema = z.object({ const settingSchema = z.object({
@ -101,7 +86,7 @@ const moonshotDetailAgentConfigSchema = z.object({
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL; const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
function isMoonshotOfficialBaseUrl(value: string) { 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> { function isObjectLike(value: unknown): value is Record<string, unknown> {
@ -130,12 +115,6 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value }, setting: setting ? { ...setting, value } : { key, value },
}); });
} }
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
const value = normalizeCanvasLocalEditConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
const value = normalizeDingTalkRobotNotificationConfig(setting?.value); const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
return Response.json({ return Response.json({
@ -160,18 +139,6 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value }, setting: setting ? { ...setting, value } : { key, value },
}); });
} }
if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) {
const value = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === OPENCLAW_CHAT_CONFIG_KEY) {
const value = toAdminOpenClawChatConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
return Response.json({ setting }); return Response.json({ setting });
} }
@ -203,27 +170,19 @@ export async function POST(request: Request) {
? (existingSetting.value as Record<string, unknown>) ? (existingSetting.value as Record<string, unknown>)
: {}; : {};
const config = moonshotDetailAgentConfigSchema.parse(value || {}); 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 rawModelId = String(config.modelId || "").trim();
const rawDisplayName = String(config.displayName || "").trim(); const rawDisplayName = String(config.displayName || "").trim();
const normalizedModelId = const normalizedModelId =
!rawModelId || !rawModelId || rawModelId === "kimi-k2.5"
rawModelId === "kimi-k2.5" ||
rawModelId === "kimi-k2.6" ||
rawModelId === "kimi-k2.7-code" ||
rawModelId === "kimi-k2.7-code-highspeed"
? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID ? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID
: rawModelId; : rawModelId;
const normalizedDisplayName = const normalizedDisplayName =
!rawDisplayName || !rawDisplayName || rawDisplayName === "官方 Kimi K2.5"
rawDisplayName === "官方 Kimi K2.5" ||
rawDisplayName === "官方 Kimi K2.6" ||
rawDisplayName === "官方 Kimi K2.7 Code Highspeed" ||
rawDisplayName === "Kimi Code 编程套餐 K2.7 Highspeed"
? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME ? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME
: rawDisplayName; : rawDisplayName;
const normalizedProviderName = const normalizedProviderName =
String(config.providerName || "").trim() || "Kimi Code 编程套餐"; String(config.providerName || "").trim() || "Moonshot AI (Official)";
const normalizedApiKey = const normalizedApiKey =
String(config.apiKey || "").trim() || String(config.apiKey || "").trim() ||
String(existingValue.apiKey || "").trim(); String(existingValue.apiKey || "").trim();
@ -232,12 +191,12 @@ export async function POST(request: Request) {
if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) { if (!isMoonshotOfficialBaseUrl(normalizedBaseUrl)) {
return new ChatSDKError( return new ChatSDKError(
"bad_request:api", "bad_request:api",
`Kimi Code 编程套餐专用地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`, `Moonshot 官方地址必须为 ${MOONSHOT_OFFICIAL_BASE_URL}`,
).toResponse(); ).toResponse();
} }
if (!normalizedApiKey) { 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({ const saved = await upsertSystemSetting({
@ -319,58 +278,6 @@ export async function POST(request: Request) {
}); });
} }
if (key === AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY) {
const saved = await upsertSystemSetting({
key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
value: normalizeAgentWorkflowMaterialApiConfig(value),
updatedBy: session.user.id,
});
clearScopedSiteSettingsCache();
return Response.json({
setting: saved[0],
});
}
if (key === OPENCLAW_CHAT_CONFIG_KEY) {
const existingSetting = await getSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
}).catch(() => null);
const existingValue =
existingSetting?.value && typeof existingSetting.value === "object"
? (existingSetting.value as Record<string, unknown>)
: {};
const normalizedValue = normalizeOpenClawChatConfig(
value,
String(existingValue.token || "").trim()
);
if (normalizedValue.enabled) {
if (!normalizedValue.baseUrl) {
return new ChatSDKError("bad_request:api", "请填写大龙虾聊天接口地址").toResponse();
}
if (!normalizedValue.token) {
return new ChatSDKError("bad_request:api", "请填写大龙虾聊天接口 Token").toResponse();
}
}
const saved = await upsertSystemSetting({
key: OPENCLAW_CHAT_CONFIG_KEY,
value: normalizedValue,
updatedBy: session.user.id,
});
clearScopedSiteSettingsCache();
return Response.json({
setting: saved[0]
? {
...saved[0],
value: toAdminOpenClawChatConfig(saved[0].value),
}
: undefined,
});
}
if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) { if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) {
nextValue = normalizeVideoCanvasFunctionConfig(value); nextValue = normalizeVideoCanvasFunctionConfig(value);
} }
@ -379,10 +286,6 @@ export async function POST(request: Request) {
nextValue = normalizeCanvasImageTaskQueueConfig(value); nextValue = normalizeCanvasImageTaskQueueConfig(value);
} }
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
nextValue = normalizeCanvasLocalEditConfig(value);
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
nextValue = normalizeDingTalkRobotNotificationConfig(value); nextValue = normalizeDingTalkRobotNotificationConfig(value);
} }

View File

@ -1,153 +0,0 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { SNAPGEN_IMAGE_MODEL_PRESETS } from "@/lib/ai/snapgen-image-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: SNAPGEN_IMAGE_MODEL_PRESETS,
counts: {
total: SNAPGEN_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[SnapGen Image Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch SnapGen image model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? body.modelIds
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: null;
if (!providerId) {
return NextResponse.json(
{ error: "Provider ID is required" },
{ status: 400 }
);
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "snapgen-image") {
return NextResponse.json(
{ error: "Provider must be a SnapGen image provider" },
{ status: 400 }
);
}
const presetsToAdd = modelIds
? SNAPGEN_IMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
: SNAPGEN_IMAGE_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
for (const preset of presetsToAdd) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
if (existing) {
const [updated] = await db
.update(aiModel)
.set({
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
})
.where(
and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
)
)
.returning();
if (updated) {
updatedModels.push(updated);
} else {
errors.push({ modelId: preset.modelId, error: "Model exists but failed to sync" });
}
continue;
}
const [newModel] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
isDefault: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
isSubModel: false,
parentModelId: null,
})
.returning();
addedModels.push(newModel);
} catch (error: any) {
console.error(
`[SnapGen Image Models API] Failed to sync ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error.message || "Insert failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presetsToAdd.length,
total: SNAPGEN_IMAGE_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[SnapGen Image Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to add SnapGen image models" },
{ status: 500 }
);
}
}

View File

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

View File

@ -1,139 +0,0 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { VISIONARY_MODEL_PRESETS } from "@/lib/ai/visionary-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
import { requireAdmin } from "@/lib/middleware/admin";
export async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: VISIONARY_MODEL_PRESETS,
counts: { total: VISIONARY_MODEL_PRESETS.length },
});
} catch (error: any) {
console.error("[Visionary Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch Visionary model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? body.modelIds
.map((item: unknown) => String(item || "").trim())
.filter(Boolean)
: null;
if (!providerId) {
return NextResponse.json(
{ error: "Provider ID is required" },
{ status: 400 }
);
}
const provider = await getAiProviderById({ id: providerId });
if (!provider || provider.type !== "visionary") {
return NextResponse.json(
{ error: "Provider must be a Visionary provider" },
{ status: 400 }
);
}
const presets = modelIds
? VISIONARY_MODEL_PRESETS.filter((preset) =>
modelIds.includes(preset.modelId)
)
: VISIONARY_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
for (const preset of presets) {
try {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
),
});
const values = {
displayName: preset.displayName,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
};
if (existing) {
const [updated] = await db
.update(aiModel)
.set(values)
.where(
and(
eq(aiModel.providerId, providerId),
eq(aiModel.modelId, preset.modelId)
)
)
.returning();
if (updated) updatedModels.push(updated);
continue;
}
const [created] = await db
.insert(aiModel)
.values({
providerId,
modelId: preset.modelId,
...values,
isDefault: false,
isSubModel: false,
parentModelId: null,
})
.returning();
if (created) addedModels.push(created);
} catch (error: any) {
console.error(
`[Visionary Models API] Failed to sync ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
error: error.message || "Sync failed",
});
}
}
return NextResponse.json({
success: true,
addedCount: addedModels.length,
updatedCount: updatedModels.length,
addedModels,
updatedModels,
errors: errors.length > 0 ? errors : undefined,
counts: {
requested: presets.length,
total: VISIONARY_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[Visionary Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to sync Visionary models" },
{ status: 500 }
);
}
}

View File

@ -1,105 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 120;
const DEFAULT_WIDTH = 790;
const DEFAULT_HEIGHT = 1300;
const MAX_DIMENSION = 4096;
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
function normalizeDimension(value: unknown, fallback: number) {
const next = Math.round(Number(value));
if (!Number.isFinite(next) || next <= 0) return fallback;
return Math.min(MAX_DIMENSION, Math.max(64, next));
}
function normalizeFit(value: unknown) {
const fit = String(value || "").trim();
return fit === "contain" ? "contain" : "cover-top";
}
async function createThumbnailBuffer(buffer: Buffer) {
const sharp = (await import("sharp")).default;
return sharp(buffer, { failOn: "none" })
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 82, mozjpeg: true })
.toBuffer();
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const imageUrl = String(body.imageUrl || "").trim();
if (!/^https?:\/\//i.test(imageUrl)) {
return NextResponse.json({ success: false, error: "缺少有效图片地址" }, { status: 400 });
}
const width = normalizeDimension(body.width, DEFAULT_WIDTH);
const height = normalizeDimension(body.height, DEFAULT_HEIGHT);
const fit = normalizeFit(body.fit);
const sourceBuffer = await fetchImageBufferFromUrl(imageUrl);
const sharp = (await import("sharp")).default;
const pipeline = sharp(sourceBuffer, { failOn: "none" }).rotate();
const normalizedBuffer =
fit === "contain"
? await pipeline
.resize(width, height, {
fit: "contain",
position: "centre",
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false,
})
.png()
.toBuffer()
: await pipeline
.resize(width, height, {
fit: "cover",
position: "top",
withoutEnlargement: false,
})
.png()
.toBuffer();
const thumbnailBuffer = await createThumbnailBuffer(normalizedBuffer);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(`agent-detail-normalized-${suffix}.png`, bufferToArrayBuffer(normalizedBuffer)),
uploadFile(`agent-detail-normalized-thumb-${suffix}.jpg`, bufferToArrayBuffer(thumbnailBuffer)),
]);
return NextResponse.json({
success: true,
data: {
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
imageSize: `${width}x${height}`,
},
});
} catch (error) {
console.error("[Agent Detail Image Normalize] Failed:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "详情图尺寸归一化失败",
},
{ status: 500 },
);
}
}

View File

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs"; export const runtime = "nodejs";
@ -29,27 +28,14 @@ type StitchSourceSegment = {
}; };
const MAX_REFERENCE_COUNT = 120; const MAX_REFERENCE_COUNT = 120;
const FETCH_TIMEOUT_MS = 20_000; const FETCH_TIMEOUT_MS = 10000;
const FETCH_MAX_ATTEMPTS = 3; const FETCH_CONCURRENCY = 32;
const FETCH_RETRY_BASE_DELAY_MS = 350; const SLICE_UPLOAD_CONCURRENCY = 10;
const FETCH_CONCURRENCY = 12;
const SLICE_UPLOAD_CONCURRENCY = 4;
const PREVIEW_MAX_WIDTH = 720; const PREVIEW_MAX_WIDTH = 720;
const PREVIEW_MIN_WIDTH = 96; const PREVIEW_MIN_WIDTH = 96;
const PREVIEW_MAX_HEIGHT = 32000; const PREVIEW_MAX_HEIGHT = 32000;
const PREVIEW_MAX_PIXELS = 36_000_000; const PREVIEW_MAX_PIXELS = 36_000_000;
const SEGMENT_MATCH_TOLERANCE = 2; const SEGMENT_MATCH_TOLERANCE = 2;
const logger = createLogger("detail-stitch");
class ImageFetchError extends Error {
retryable: boolean;
constructor(message: string, retryable = false) {
super(message);
this.name = "ImageFetchError";
this.retryable = retryable;
}
}
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice( return buffer.buffer.slice(
@ -58,38 +44,6 @@ function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
) as ArrayBuffer; ) as ArrayBuffer;
} }
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error || "");
}
function isTimeoutLikeError(error: unknown) {
if (!(error instanceof Error)) return false;
return (
error.name === "TimeoutError" ||
error.name === "AbortError" ||
/timeout|aborted|operation was aborted/i.test(error.message)
);
}
function isRetryableFetchError(error: unknown) {
if (error instanceof ImageFetchError) {
return error.retryable;
}
return isTimeoutLikeError(error);
}
function toPublicErrorMessage(error: unknown) {
const message = getErrorMessage(error);
if (isTimeoutLikeError(error) || /timeout|aborted|operation was aborted/i.test(message)) {
return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。";
}
return message || "拼合切割失败";
}
async function runBoundedParallel<T, R>( async function runBoundedParallel<T, R>(
items: T[], items: T[],
workerCount: number, workerCount: number,
@ -194,54 +148,31 @@ function buildReferenceFetchUrl(url: string): string {
} }
async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise<Buffer> { async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise<Buffer> {
let lastError: unknown = null; const response = await fetch(url, {
cache: "no-store",
for (let attempt = 1; attempt <= FETCH_MAX_ATTEMPTS; attempt += 1) { redirect: "follow",
try { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
const response = await fetch(url, { headers: buildFetchHeaders(url, requestOrigin, cookie),
cache: "no-store", });
redirect: "follow", if (!response.ok) {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), throw new Error(`HTTP ${response.status}`);
headers: buildFetchHeaders(url, requestOrigin, cookie),
});
if (!response.ok) {
throw new ImageFetchError(
`图片下载失败:HTTP ${response.status}`,
response.status === 408 || response.status === 429 || response.status >= 500,
);
}
const contentType = response.headers.get("content-type") || "";
const normalizedContentType = contentType.toLowerCase();
if (
normalizedContentType &&
!normalizedContentType.startsWith("image/") &&
(normalizedContentType.startsWith("text/") ||
normalizedContentType.includes("json") ||
normalizedContentType.includes("xml"))
) {
throw new ImageFetchError("返回内容不是图片");
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new ImageFetchError("图片内容为空");
}
return buffer;
} catch (error) {
lastError = error;
if (attempt >= FETCH_MAX_ATTEMPTS || !isRetryableFetchError(error)) {
break;
}
logger.warn("image_fetch_retry", {
attempt,
maxAttempts: FETCH_MAX_ATTEMPTS,
timeoutMs: FETCH_TIMEOUT_MS,
reason: getErrorMessage(error),
});
await sleep(FETCH_RETRY_BASE_DELAY_MS * attempt);
}
} }
const contentType = response.headers.get("content-type") || "";
throw new ImageFetchError(toPublicErrorMessage(lastError), false); const normalizedContentType = contentType.toLowerCase();
if (
normalizedContentType &&
!normalizedContentType.startsWith("image/") &&
(normalizedContentType.startsWith("text/") ||
normalizedContentType.includes("json") ||
normalizedContentType.includes("xml"))
) {
throw new Error("返回内容不是图片");
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error("图片内容为空");
}
return buffer;
} }
function resolvePreviewWidth(items: Array<{ width: number; height: number }>) { function resolvePreviewWidth(items: Array<{ width: number; height: number }>) {
@ -425,9 +356,7 @@ async function applyStitch(params: {
return sharp(buffer, { failOn: "none" }) return sharp(buffer, { failOn: "none" })
.rotate() .rotate()
.resize(params.width, source.height, { .resize(params.width, source.height, {
fit: "contain", fit: "fill",
position: "centre",
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false, withoutEnlargement: false,
}) })
.jpeg({ .jpeg({
@ -546,8 +475,6 @@ async function applyStitch(params: {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const startedAt = Date.now();
let mode = "unknown";
try { try {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
@ -555,7 +482,7 @@ export async function POST(request: NextRequest) {
} }
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
mode = String(body.mode || "").trim(); const mode = String(body.mode || "").trim();
const requestOrigin = getRequestOrigin(request); const requestOrigin = getRequestOrigin(request);
const cookie = request.headers.get("cookie") || ""; const cookie = request.headers.get("cookie") || "";
@ -566,12 +493,6 @@ export async function POST(request: NextRequest) {
} }
const data = await prepareStitch({ references, requestOrigin, cookie }); const data = await prepareStitch({ references, requestOrigin, cookie });
logger.info("prepare_completed", {
referenceCount: references.length,
sourceSegmentCount: data.sourceSegments.length,
failedCount: data.failed.length,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ success: true, data }); return NextResponse.json({ success: true, data });
} }
@ -637,25 +558,16 @@ export async function POST(request: NextRequest) {
requestOrigin, requestOrigin,
cookie, cookie,
}); });
logger.info("apply_completed", {
sourceSegmentCount: sourceSegments.length,
cutLineCount: normalizeCutLines(body.cutLines, height).length,
outputCount: data.cards.length,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ success: true, data }); return NextResponse.json({ success: true, data });
} }
return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 }); return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 });
} catch (error) { } catch (error) {
logger.error("request_failed", error, { console.error("[DetailStitch API] Failed:", error);
mode,
durationMs: Date.now() - startedAt,
});
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: toPublicErrorMessage(error), error: error instanceof Error ? error.message : "拼合切割失败",
}, },
{ status: 500 }, { status: 500 },
); );

View File

@ -8,8 +8,6 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries"; import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
import { import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config"; } from "@/lib/agents/workflow-material-config";
import { import {
@ -99,14 +97,6 @@ function sanitizeGoodsInfoText(value: unknown, maxLength = 255) {
return String(value || "").trim().slice(0, maxLength); return String(value || "").trim().slice(0, maxLength);
} }
function deriveSpuFromSku(value: unknown) {
const normalized = String(value || "").trim().toUpperCase();
if (!/[0-9]{2}$/.test(normalized) || normalized.length <= 2) {
return "";
}
return normalized.slice(0, -2);
}
function createGoodsInfoSignature(timespan: string, secret: string) { function createGoodsInfoSignature(timespan: string, secret: string) {
const secretBuffer = Buffer.from(String(secret || "").trim(), "utf8"); const secretBuffer = Buffer.from(String(secret || "").trim(), "utf8");
if (secretBuffer.length !== 8) { if (secretBuffer.length !== 8) {
@ -679,12 +669,6 @@ export async function GET(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
if (!isAgentWorkflowMaterialResourceEnabled(config, "white-background")) {
return NextResponse.json(
{ error: "白底图素材接口未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) { if (!config.apiKey) {
return NextResponse.json( return NextResponse.json(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" }, { error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
@ -704,18 +688,15 @@ export async function GET(request: NextRequest) {
return null; return null;
}) })
: Promise.resolve(null); : Promise.resolve(null);
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, { const upstreamUrl = `${normalizeBaseUrl(
resourceType: "white-background", config.baseUrl
queryType, )}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`;
keyword: normalizedKeyword,
});
const upstreamResponse = await fetch(upstreamUrl, { const upstreamResponse = await fetch(upstreamUrl, {
method: "GET", method: "GET",
headers: { headers: {
"X-API-Key": config.apiKey, "X-API-Key": config.apiKey,
}, },
cache: "no-store", cache: "no-store",
signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000),
}); });
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse; const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse;
@ -836,7 +817,7 @@ export async function GET(request: NextRequest) {
query: normalizedKeyword, query: normalizedKeyword,
queryType: "sku", queryType: "sku",
matchedSku, matchedSku,
matchedSpu: goodsInfo?.matchedSpu || productModel || deriveSpuFromSku(matchedSku), matchedSpu: goodsInfo?.matchedSpu || productModel,
materialId: String(upstreamResult.data.materialId || "").trim() || null, materialId: String(upstreamResult.data.materialId || "").trim() || null,
productModel, productModel,
zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null, zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null,

View File

@ -6,8 +6,6 @@ import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries"; import { getSystemSetting } from "@/lib/db/queries";
import { import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config"; } from "@/lib/agents/workflow-material-config";
import { import {
@ -471,11 +469,13 @@ async function requestTaskDetailAttempt(params: {
verifiedGoodsBrandName?: string | null; verifiedGoodsBrandName?: string | null;
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>; config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
}) { }) {
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(params.config, { const upstreamParams = new URLSearchParams({
resourceType: "task-detail", resourceType: "task-detail",
queryType: params.queryType, [params.queryType]: params.keyword,
keyword: params.keyword,
}); });
const upstreamUrl = `${normalizeBaseUrl(
params.config.baseUrl
)}/standard-model-white-background?${upstreamParams.toString()}`;
const upstreamResponse = await fetch(upstreamUrl, { const upstreamResponse = await fetch(upstreamUrl, {
method: "GET", method: "GET",
@ -483,7 +483,6 @@ async function requestTaskDetailAttempt(params: {
"X-API-Key": params.config.apiKey, "X-API-Key": params.config.apiKey,
}, },
cache: "no-store", cache: "no-store",
signal: AbortSignal.timeout(params.config.requestTimeoutMs || 30_000),
}); });
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse; const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
const upstreamBrandName = getExternalBrandName(upstreamResult.data || {}); const upstreamBrandName = getExternalBrandName(upstreamResult.data || {});
@ -937,12 +936,6 @@ export async function GET(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
if (!isAgentWorkflowMaterialResourceEnabled(config, "task-detail")) {
return jsonNoStore(
{ error: "任务-详情整套素材接口未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) { if (!config.apiKey) {
return jsonNoStore( return jsonNoStore(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" }, { error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
@ -964,11 +957,11 @@ export async function GET(request: NextRequest) {
materialQueryType === "spu" && goodsLookup?.matchedSpu materialQueryType === "spu" && goodsLookup?.matchedSpu
? goodsLookup.matchedSpu.trim().toUpperCase() ? goodsLookup.matchedSpu.trim().toUpperCase()
: normalizedKeyword; : normalizedKeyword;
if (materialQueryType === "spu" && resolvedMaterialKeyword !== normalizedKeyword) { if (materialQueryType === "spu" && goodsLookup?.matchedSpu) {
console.info("[Agent Workflow Reference Search] Resolved SPU query keyword:", { console.info("[Agent Workflow Reference Search] Resolved SKU input to SPU:", {
keyword: normalizedKeyword, keyword: normalizedKeyword,
resolvedSpu: resolvedMaterialKeyword, resolvedSpu: resolvedMaterialKeyword,
matchedSku: goodsLookup?.matchedSku || null, matchedSku: goodsLookup.matchedSku || null,
}); });
} }
if ( if (

View File

@ -1,295 +0,0 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
ensureCanvasImageTaskActive,
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue";
import {
checkUserImageQuota,
getChatById,
getEnabledAiModelsWithProvider,
getPointsSystemConfig,
getUserById,
saveChat,
} from "@/lib/db/queries";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
import { resolveDirectReplacementModelRef } from "@/app/(chat)/api/chat/_lib/detail-replacement";
import {
buildAgentSlotPrompt,
normalizeAgentSupplementPrompt,
} from "@/app/(chat)/api/chat/_lib/agent-scene-workflow";
import {
detailReplacementSubmissionKey,
isDetailReplacementBatchRequest,
isDetailReplacementUuid,
type DetailReplacementBatchReference,
type DetailReplacementBatchRequest,
type DetailReplacementBatchResult,
} from "@/lib/agents/detail-replacement-batch";
export const maxDuration = 1200;
function scopedChatId(projectId: string) {
const normalized = projectId.toLowerCase().replace(/-/g, "");
const scoped = `${normalized.slice(0, 20)}a93e17a60001`;
return [scoped.slice(0, 8), scoped.slice(8, 12), scoped.slice(12, 16), scoped.slice(16, 20), scoped.slice(20, 32)].join("-");
}
function taskResponse(task: Awaited<ReturnType<typeof getCanvasImageTaskForUser>>) {
const result = task?.result as DetailReplacementBatchResult | null;
const request = task?.request as DetailReplacementBatchRequest | null;
return {
id: task?.id,
status: task?.status,
attempts: task?.attempts,
error: task?.error || null,
cards: Array.isArray(result?.cards) ? result.cards : [],
currentCardId: result?.currentCardId || null,
requestedCardIds: request?.references?.map((item) => item.id) || [],
createdAt: task?.createdAt || null,
queueStatus: task?.queueStatus || null,
};
}
async function findActiveBatchId(params: {
userId: string;
projectId: string;
submissionKey: string;
}) {
const [row] = await db.select({ id: canvasImageTask.id })
.from(canvasImageTask)
.where(and(
eq(canvasImageTask.userId, params.userId),
inArray(canvasImageTask.status, ["pending", "running"]),
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
sql`${canvasImageTask.request}->>'projectId' = ${params.projectId}`,
sql`${canvasImageTask.request}->>'submissionKey' = ${params.submissionKey}`,
))
.orderBy(desc(canvasImageTask.createdAt)).limit(1);
return row?.id || null;
}
function isUniqueViolation(error: unknown) {
for (let cursor: unknown = error; cursor && typeof cursor === "object"; ) {
if ((cursor as { code?: unknown }).code === "23505") return true;
cursor = (cursor as { cause?: unknown }).cause;
}
return false;
}
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const params = new URL(request.url).searchParams;
const projectId = String(params.get("projectId") || "").trim();
const taskId = String(params.get("taskId") || "").trim();
if (!isDetailReplacementUuid(projectId)) {
return NextResponse.json({ error: "项目编号无效" }, { status: 400 });
}
const [project] = await db.select({ id: canvasProject.id }).from(canvasProject).where(and(
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
)).limit(1);
if (!project) return NextResponse.json({ error: "项目不存在" }, { status: 404 });
const [candidate] = await db.select({ id: canvasImageTask.id, request: canvasImageTask.request })
.from(canvasImageTask)
.where(and(
eq(canvasImageTask.userId, session.user.id),
...(isDetailReplacementUuid(taskId) ? [eq(canvasImageTask.id, taskId)] : []),
sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`,
sql`${canvasImageTask.request}->>'projectId' = ${projectId}`,
))
// A batch that is still working matters more than a newer finished one, so the
// workspace keeps showing live progress after a rerun of a single card.
.orderBy(
sql`case when ${canvasImageTask.status} in ('pending', 'running') then 0 else 1 end`,
desc(canvasImageTask.createdAt),
).limit(1);
if (!candidate || !isDetailReplacementBatchRequest(candidate.request)) {
return NextResponse.json({ task: null });
}
let task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
if (task && (task.status === "failed" ||
(task.status === "running" && Date.now() - task.updatedAt.getTime() > 90_000))) {
await ensureCanvasImageTaskActive({ taskId: task.id, userId: session.user.id });
task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id });
}
return NextResponse.json({ task: taskResponse(task) });
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const body = await request.json();
const projectId = String(body.projectId || "").trim();
const taskId = String(body.requestId || "").trim();
if (!isDetailReplacementUuid(projectId) || !isDetailReplacementUuid(taskId)) {
return NextResponse.json({ error: "任务编号或项目编号无效" }, { status: 400 });
}
const [project] = await db.select().from(canvasProject).where(and(
eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id),
)).limit(1);
if (!project || !isAgentProjectRow(project)) {
return NextResponse.json({ error: "风格化项目不存在" }, { status: 404 });
}
const state = extractAgentProjectStateFromCanvasData(project.canvasData);
const knownReferences = new Map(
(state.workflowState.agentModeState?.detailReplacement?.references || [])
.map((item) => [String(item.id), String(item.imageUrl)]),
);
const requested = Array.isArray(body.references) ? body.references : [];
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
return {
id: String(item.id || "").slice(0, 128),
label: String(item.label || "详情参考图").slice(0, 255),
imageUrl: String(item.imageUrl || "").trim(),
candidateId: String(item.candidateId || "").slice(0, 128) || null,
passthrough: item.passthrough === true,
rerunReferenceImages: Array.isArray(item.rerunReferenceImages)
? item.rerunReferenceImages.slice(0, 6).map((entry: { url?: string }) => ({ url: String(entry?.url || "") }))
: [],
};
});
if (
references.length === 0 || references.length > 40 ||
new Set(references.map((item) => item.id)).size !== references.length ||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
) {
return NextResponse.json({ error: "详情参考图尚未保存或已发生变化,请刷新后重试" }, { status: 400 });
}
const productImageUrls = Array.isArray(body.productImageUrls)
? Array.from(new Set(body.productImageUrls.map((url: unknown) => String(url || "").trim())
.filter((url: string) => /^https?:\/\//i.test(url)))).slice(0, 16) as string[]
: [];
if (productImageUrls.length === 0 && !references.every((item) => item.passthrough)) {
return NextResponse.json({ error: "请先在输入图区选择至少一张产品图" }, { status: 400 });
}
const strategy = body.replacementStrategy === "product_only" ? "product_only" : "scene_replace";
const prompt = String(body.prompt || "").trim().slice(0, 24_000);
if (strategy !== "product_only" && !prompt) {
return NextResponse.json({ error: "请先配置风格化提示词" }, { status: 400 });
}
const models = await getEnabledAiModelsWithProvider();
const model = await resolveDirectReplacementModelRef({
selectedChatModel: String(body.model || "auto"),
finalModelId: String(body.model || "auto"),
models,
});
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
}
const quota = await checkUserImageQuota(session.user.id);
if (!quota.allowed) {
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });
}
const requestedImageCount = references.filter((item) => !item.passthrough).length;
if (quota.limit != null && quota.used + requestedImageCount > quota.limit) {
return NextResponse.json({ error: `本月剩余生图配额不足 ${requestedImageCount} 张` }, { status: 429 });
}
const pointsConfig = await getPointsSystemConfig();
if (pointsConfig.enabled && pointsConfig.blockOnInsufficient !== false) {
const selectedModel = models.find((entry) => entry.model.id === model)?.model;
const unitCost = getModelGenerationCreditCost(
selectedModel as Parameters<typeof getModelGenerationCreditCost>[0], "image", {
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
});
const user = await getUserById({ id: session.user.id });
const expectedCost = unitCost * requestedImageCount;
if (!user?.isHighVip && expectedCost > Number(user?.pointsBalance || 0)) {
return NextResponse.json({ error: `积分不足,本批次预计消耗 ${expectedCost} 积分` }, { status: 402 });
}
}
const chatId = scopedChatId(projectId);
const existingChat = await getChatById({ id: chatId });
if (existingChat && existingChat.userId !== session.user.id) {
return NextResponse.json({ error: "无权访问项目对话" }, { status: 403 });
}
if (!existingChat) await saveChat({
id: chatId, userId: session.user.id, title: project.title || "详情替换", visibility: "private",
});
const taskRequest: DetailReplacementBatchRequest = {
taskKind: "agent-detail-replacement-batch",
projectId,
chatId,
model,
prompt: strategy === "product_only"
? "仅替换参考详情图中的产品主体"
: buildAgentSlotPrompt({
workflowPrompt: prompt,
slotPrompt: "",
userSupplementPrompt: normalizeAgentSupplementPrompt(
String(body.userPrompt || ""), "详情参考图替换",
),
includeSlotTitle: false,
modeType: "detail_replacement",
}),
userPrompt: String(body.userPrompt || "").slice(0, 12_000),
imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K",
replacementStrategy: strategy,
productImageUrls,
modelImageUrl: /^https?:\/\//i.test(String(body.modelImageUrl || ""))
? String(body.modelImageUrl) : null,
references,
};
const submissionKey = detailReplacementSubmissionKey(taskRequest);
taskRequest.submissionKey = submissionKey;
const duplicateId = await findActiveBatchId({
userId: session.user.id, projectId, submissionKey,
});
if (duplicateId) {
await ensureCanvasImageTaskActive({ taskId: duplicateId, userId: session.user.id });
const duplicate = await getCanvasImageTaskForUser({ taskId: duplicateId, userId: session.user.id });
return NextResponse.json({ task: taskResponse(duplicate) });
}
let task: Awaited<ReturnType<typeof createCanvasImageTask>>;
try {
task = await createCanvasImageTask({
taskId, userId: session.user.id, request: taskRequest as unknown as Record<string, unknown>,
});
} catch (error) {
if (!isUniqueViolation(error)) throw error;
// Two identical submissions raced past the lookup above; the database
// unique index kept one of them, so answer with that batch.
const racedId = await findActiveBatchId({
userId: session.user.id, projectId, submissionKey,
});
if (!racedId) throw error;
const raced = await getCanvasImageTaskForUser({ taskId: racedId, userId: session.user.id });
return NextResponse.json({ task: taskResponse(raced) });
}
const existingTask = await getCanvasImageTaskForUser({ taskId: task.id, userId: session.user.id });
const storedRequest = existingTask?.request as DetailReplacementBatchRequest | null;
if (
!isDetailReplacementBatchRequest(storedRequest) ||
storedRequest.projectId !== projectId ||
(storedRequest.submissionKey && storedRequest.submissionKey !== submissionKey)
) {
return NextResponse.json({ error: "任务编号已被其他请求使用" }, { status: 409 });
}
await db.insert(message).values({
id: taskId,
chatId,
role: "user",
parts: [{ type: "text", text: taskRequest.userPrompt || `批量替换 ${references.length} 张详情图` }],
attachments: [],
createdAt: new Date(),
}).onConflictDoNothing({ target: message.id });
await enqueueCanvasImageTask(task.id);
const current = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
return NextResponse.json({ task: taskResponse(current) });
} catch (error) {
console.error("[Detail Replacement Batch] Failed to start", error);
return NextResponse.json({ error: error instanceof Error ? error.message : "提交详情替换任务失败" }, { status: 500 });
}
}

View File

@ -0,0 +1,122 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
getKittyCadModelingCommandError,
isKittyCadModelingCommandSuccessful,
sendKittyCadModelingCommand,
takeKittyCadModelingSnapshot,
} from "@/lib/kittycad/modeling-session";
export const runtime = "nodejs";
export const maxDuration = 60;
type ModelingCommandRequest = {
captureSnapshot?: boolean;
cmd?: Record<string, unknown>;
cmdId?: string;
sessionId?: string;
snapshotFormat?: "jpeg" | "png";
};
function readOptionalRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json()) as ModelingCommandRequest;
const sessionId = String(body.sessionId || "").trim();
if (!sessionId) {
return NextResponse.json(
{ success: false, error: "缺少官方建模会话 ID" },
{ status: 400 }
);
}
const cmd = readOptionalRecord(body.cmd);
const cmdType = String(cmd?.type || "").trim();
if (!cmd || !cmdType) {
return NextResponse.json(
{ success: false, error: "缺少有效的官方建模命令" },
{ status: 400 }
);
}
const cmdId = String(body.cmdId || "").trim() || randomUUID();
const response = await sendKittyCadModelingCommand({
cmd: {
...cmd,
type: cmdType,
},
cmdId,
sessionId,
});
if (!isKittyCadModelingCommandSuccessful(response)) {
return NextResponse.json(
{
success: false,
error: getKittyCadModelingCommandError(response),
payload: response.errors || null,
},
{ status: 400 }
);
}
let snapshotUrl: string | null = null;
let snapshotError: string | null = null;
const shouldCaptureSnapshot =
body.captureSnapshot === true && cmdType !== "take_snapshot";
if (cmdType === "take_snapshot") {
const contents = String(response.resp?.data?.contents || "").trim();
const snapshotFormat = body.snapshotFormat || "png";
snapshotUrl = contents
? `data:${snapshotFormat === "jpeg" ? "image/jpeg" : "image/png"};base64,${contents}`
: null;
} else if (shouldCaptureSnapshot) {
try {
const snapshot = await takeKittyCadModelingSnapshot({
format: body.snapshotFormat || "png",
sessionId,
});
snapshotUrl = snapshot.snapshotUrl;
} catch (error) {
snapshotError =
error instanceof Error ? error.message : "获取官方建模快照失败";
}
}
return NextResponse.json({
success: true,
data: {
raw: response,
requestId: response.request_id || cmdId,
responseData: response.resp?.data || null,
responseType: response.resp?.type || cmdType,
snapshotError,
snapshotUrl,
},
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "发送官方建模命令失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { closeKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
export const runtime = "nodejs";
export const maxDuration = 60;
type RouteParams = {
params: Promise<{
sessionId: string;
}>;
};
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { sessionId } = await params;
const normalizedSessionId = String(sessionId || "").trim();
if (!normalizedSessionId) {
return NextResponse.json(
{ success: false, error: "缺少 sessionId" },
{ status: 400 }
);
}
const result = await closeKittyCadModelingSession(normalizedSessionId);
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "关闭官方建模会话失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { createKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
export const runtime = "nodejs";
export const maxDuration = 60;
export async function POST() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const modelingSession = await createKittyCadModelingSession();
return NextResponse.json({
success: true,
data: modelingSession,
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建官方建模会话失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { findKittyCadProviderConfig } from "@/lib/kittycad/server";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const config = await findKittyCadProviderConfig();
return NextResponse.json({
success: true,
data: {
configured: Boolean(config),
source: config?.source || "none",
providerName: config?.providerName || null,
baseUrl: config?.baseUrl || null,
},
});
}

View File

@ -0,0 +1,391 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 60;
type KittyCadTaskResponse = {
id?: string;
status?: string;
outputs?: Record<string, string> | null;
error?: string | null;
code?: string | null;
prompt?: string | null;
conversation_id?: string | null;
model_version?: string | null;
output_format?: string | null;
};
type ArchivedFile = {
sourceHash: string;
archivedUrl: string;
};
type RouteParams = {
params: Promise<{ taskId: string }>;
};
function getArchiveCache() {
const globalState = globalThis as Record<string, unknown>;
if (!globalState.__kittyCadAssetArchiveCache) {
globalState.__kittyCadAssetArchiveCache = new Map<string, ArchivedFile>();
}
return globalState.__kittyCadAssetArchiveCache as Map<string, ArchivedFile>;
}
function getArchiveInflight() {
const globalState = globalThis as Record<string, unknown>;
if (!globalState.__kittyCadAssetArchiveInflight) {
globalState.__kittyCadAssetArchiveInflight = new Map<
string,
Promise<string | null>
>();
}
return globalState.__kittyCadAssetArchiveInflight as Map<
string,
Promise<string | null>
>;
}
function mapKittyCadStatus(status: unknown) {
const normalized = String(status || "")
.trim()
.toLowerCase();
if (!normalized) {
return "queued" as const;
}
if (
normalized === "queued" ||
normalized === "pending" ||
normalized === "created" ||
normalized === "submitted"
) {
return "queued" as const;
}
if (
normalized === "running" ||
normalized === "processing" ||
normalized === "in_progress" ||
normalized === "working" ||
normalized === "generating"
) {
return "running" as const;
}
if (
normalized === "completed" ||
normalized === "success" ||
normalized === "succeeded" ||
normalized === "done" ||
normalized === "finished"
) {
return "success" as const;
}
if (normalized === "cancelled" || normalized === "canceled") {
return "cancelled" as const;
}
if (normalized === "expired" || normalized === "timed_out") {
return "expired" as const;
}
if (normalized === "banned" || normalized === "blocked") {
return "banned" as const;
}
if (
normalized === "failed" ||
normalized === "error" ||
normalized === "errored"
) {
return "failed" as const;
}
return "unknown" as const;
}
function getFileExtension(filename: string) {
const normalized = String(filename || "")
.trim()
.toLowerCase();
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
const segments = cleanName.split(".");
return segments.length > 1 ? segments.at(-1) || "" : "";
}
function isImageExtension(extension: string) {
return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension);
}
function pickPreferredModelFile(files: Array<{ name: string; url: string }>) {
const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"];
for (const extension of preferredOrder) {
const matched = files.find(
(item) => getFileExtension(item.name) === extension
);
if (matched) {
return matched;
}
}
return null;
}
function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
return (
files.find((item) => isImageExtension(getFileExtension(item.name))) || null
);
}
async function archiveOutputIfNeeded(params: {
taskId: string;
filename: string;
base64: string;
}) {
const normalizedFilename = String(params.filename || "").trim();
const normalizedBase64 = String(params.base64 || "").trim();
if (!normalizedFilename || !normalizedBase64) {
return null;
}
const cacheKey = `${params.taskId}:${normalizedFilename}`;
const cache = getArchiveCache();
const cached = cache.get(cacheKey);
if (cached && cached.sourceHash === normalizedBase64) {
return cached.archivedUrl;
}
const inflight = getArchiveInflight();
const existingTask = inflight.get(cacheKey);
if (existingTask) {
return (await existingTask) || null;
}
const archiveTask = (async () => {
try {
const binary = Buffer.from(normalizedBase64, "base64");
if (binary.byteLength <= 0) {
throw new Error("KittyCAD 输出文件为空");
}
const arrayBuffer = Uint8Array.from(binary).buffer;
const safeFilename =
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin";
const uploaded = await uploadFile(
`kittycad-${params.taskId}-${safeFilename}`,
arrayBuffer
);
cache.set(cacheKey, {
sourceHash: normalizedBase64,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
console.error("[KittyCADArchive] failed", {
taskId: params.taskId,
filename: normalizedFilename,
error: error instanceof Error ? error.message : String(error),
});
return null;
} finally {
inflight.delete(cacheKey);
}
})();
inflight.set(cacheKey, archiveTask);
return await archiveTask;
}
async function archiveTextOutputIfNeeded(params: {
taskId: string;
filename: string;
content: string;
}) {
const normalizedFilename = String(params.filename || "").trim();
const normalizedContent = String(params.content || "");
if (!normalizedFilename || !normalizedContent.trim()) {
return null;
}
const cacheKey = `${params.taskId}:${normalizedFilename}`;
const cache = getArchiveCache();
const cached = cache.get(cacheKey);
if (cached && cached.sourceHash === normalizedContent) {
return cached.archivedUrl;
}
const inflight = getArchiveInflight();
const existingTask = inflight.get(cacheKey);
if (existingTask) {
return (await existingTask) || null;
}
const archiveTask = (async () => {
try {
const arrayBuffer = Uint8Array.from(
new TextEncoder().encode(normalizedContent)
).buffer;
const safeFilename =
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl";
const uploaded = await uploadFile(
`kittycad-${params.taskId}-${safeFilename}`,
arrayBuffer
);
cache.set(cacheKey, {
sourceHash: normalizedContent,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
console.error("[KittyCADArchive:text] failed", {
taskId: params.taskId,
filename: normalizedFilename,
error: error instanceof Error ? error.message : String(error),
});
return null;
} finally {
inflight.delete(cacheKey);
}
})();
inflight.set(cacheKey, archiveTask);
return await archiveTask;
}
export async function GET(_request: Request, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { taskId } = await params;
const normalizedTaskId = String(taskId || "").trim();
if (!normalizedTaskId) {
return NextResponse.json(
{ success: false, error: "缺少 taskId" },
{ status: 400 }
);
}
const config = await getKittyCadProviderConfig();
const result = await kittyCadRequest<KittyCadTaskResponse>(config, {
method: "GET",
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
});
const status = mapKittyCadStatus(result.status);
if (status !== "success") {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "cad",
status,
progress: status === "running" ? 65 : null,
errorMessage: result.error || null,
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat: result.output_format || null,
output: result,
},
});
}
const outputEntries = Object.entries(result.outputs || {}).filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" &&
entry[0].trim().length > 0 &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0
);
const archivedEntries = await Promise.all(
outputEntries.map(async ([name, base64]) => {
const archivedUrl = await archiveOutputIfNeeded({
taskId: normalizedTaskId,
filename: name,
base64,
});
return archivedUrl ? { name, url: archivedUrl } : null;
})
);
const archivedFiles = archivedEntries.filter(
(item): item is { name: string; url: string } =>
Boolean(item?.name && item?.url)
);
const hasArchivedKclFile = archivedFiles.some(
(item) => getFileExtension(item.name) === "kcl"
);
if (
!hasArchivedKclFile &&
typeof result.code === "string" &&
result.code.trim()
) {
const archivedCodeUrl = await archiveTextOutputIfNeeded({
taskId: normalizedTaskId,
filename: "main.kcl",
content: result.code,
});
if (archivedCodeUrl) {
archivedFiles.push({
name: "main.kcl",
url: archivedCodeUrl,
});
}
}
const modelFile = pickPreferredModelFile(archivedFiles);
const previewImageFile = pickPreviewImageFile(archivedFiles);
const downloadUrls = Object.fromEntries(
archivedFiles.map((item) => [item.name, item.url])
);
const codeFile =
archivedFiles.find((item) => getFileExtension(item.name) === "kcl") ||
null;
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "cad",
status: "success",
progress: 100,
modelUrl:
modelFile &&
["glb", "gltf"].includes(getFileExtension(modelFile.name))
? modelFile.url
: null,
previewImageUrl: previewImageFile?.url || null,
errorMessage: result.error || null,
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat: result.output_format || null,
codeUrl: codeFile?.url || null,
downloadUrls,
output: result,
},
});
} catch (error) {
if (error instanceof KittyCadApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,331 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
consumeUserPoints,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
export const runtime = "nodejs";
export const maxDuration = 60;
type CreateAiCadTaskRequest = {
prompt?: string;
outputFormat?: string;
projectName?: string;
iterationSourceTaskId?: string;
sourceCode?: string;
sourceRanges?: Record<string, unknown>[];
};
type KittyCadCreateResponse = {
id?: string;
status?: string;
conversation_id?: string | null;
model_version?: string | null;
output_format?: string | null;
};
type KittyCadSourceTaskResponse = {
code?: string | null;
outputs?: Record<string, string> | null;
};
type KittyCadIterationResponse = {
id?: string;
status?: string;
conversation_id?: string | null;
model_version?: string | null;
output_format?: string | null;
};
function getFileExtension(filename: string) {
const normalized = String(filename || "")
.trim()
.toLowerCase();
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
const segments = cleanName.split(".");
return segments.length > 1 ? segments.at(-1) || "" : "";
}
function decodeBase64Text(value: unknown) {
const normalized = String(value || "").trim();
if (!normalized) {
return null;
}
try {
const decoded = Buffer.from(normalized, "base64").toString("utf8").trim();
return decoded || null;
} catch (_error) {
return null;
}
}
function extractSourceCodeFromTask(task: KittyCadSourceTaskResponse) {
const directCode = String(task.code || "").trim();
if (directCode) {
return directCode;
}
const kclOutputEntry = Object.entries(task.outputs || {}).find(
(entry): entry is [string, string] =>
typeof entry[0] === "string" &&
entry[0].trim().length > 0 &&
getFileExtension(entry[0]) === "kcl" &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0
);
if (!kclOutputEntry) {
return null;
}
return decodeBase64Text(kclOutputEntry[1]);
}
function mapKittyCadStatus(status: unknown) {
const normalized = String(status || "")
.trim()
.toLowerCase();
if (!normalized) {
return "queued" as const;
}
if (
normalized === "queued" ||
normalized === "pending" ||
normalized === "created" ||
normalized === "submitted"
) {
return "queued" as const;
}
if (
normalized === "running" ||
normalized === "processing" ||
normalized === "in_progress" ||
normalized === "working" ||
normalized === "generating"
) {
return "running" as const;
}
if (
normalized === "completed" ||
normalized === "success" ||
normalized === "succeeded" ||
normalized === "done" ||
normalized === "finished"
) {
return "success" as const;
}
if (normalized === "cancelled" || normalized === "canceled") {
return "cancelled" as const;
}
if (normalized === "expired" || normalized === "timed_out") {
return "expired" as const;
}
if (normalized === "banned" || normalized === "blocked") {
return "banned" as const;
}
if (
normalized === "failed" ||
normalized === "error" ||
normalized === "errored"
) {
return "failed" as const;
}
return "unknown" as const;
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json()) as CreateAiCadTaskRequest;
const prompt = String(body.prompt || "").trim();
const pointsConfig = await getPointsSystemConfig();
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
: 0;
if (
pointsConfig.enabled &&
unitCost > 0 &&
pointsConfig.blockOnInsufficient !== false
) {
const userData = await getUserById({ id: session.user.id });
if (!userData?.isHighVip) {
const currentBalance = Math.max(
0,
Number(userData?.pointsBalance || 0)
);
if (currentBalance < unitCost) {
return NextResponse.json(
{
success: false,
error: "积分不足",
code: "insufficient_points",
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
balance: currentBalance,
expectedCost: unitCost,
unitCost,
},
{ status: 402 }
);
}
}
}
const outputFormat =
String(body.outputFormat || "glb")
.trim()
.toLowerCase() || "glb";
const normalizedProjectName = String(body.projectName || "").trim();
const iterationSourceTaskId = String(
body.iterationSourceTaskId || ""
).trim();
const sourceCode = String(body.sourceCode || "");
const normalizedSourceCode = sourceCode.trim();
const sourceRanges = Array.isArray(body.sourceRanges)
? body.sourceRanges.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === "object" && !Array.isArray(item)
)
: [];
const config = await getKittyCadProviderConfig();
const generationMode =
iterationSourceTaskId || normalizedSourceCode ? "iterate" : "create";
if (generationMode === "create" && !prompt) {
return NextResponse.json(
{ success: false, error: "请输入 CAD 描述" },
{ status: 400 }
);
}
const iterationPrompt =
prompt || "Apply the edited KCL source and rebuild the CAD model.";
const result =
generationMode === "iterate"
? await (async () => {
const originalSourceCode =
normalizedSourceCode ||
(await (async () => {
if (!iterationSourceTaskId) {
return null;
}
const sourceTask =
await kittyCadRequest<KittyCadSourceTaskResponse>(config, {
method: "GET",
path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`,
});
return extractSourceCodeFromTask(sourceTask);
})());
if (!originalSourceCode) {
throw new KittyCadApiError({
message:
"当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。",
httpStatus: 409,
});
}
return kittyCadRequest<KittyCadIterationResponse>(config, {
method: "POST",
path: "/ml/text-to-cad/iteration",
json: {
original_source_code: originalSourceCode,
prompt: iterationPrompt,
source_ranges: sourceRanges,
...(normalizedProjectName
? { project_name: normalizedProjectName }
: {}),
},
});
})()
: await kittyCadRequest<KittyCadCreateResponse>(config, {
method: "POST",
path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`,
json: {
prompt,
...(normalizedProjectName
? { project_name: normalizedProjectName }
: {}),
},
});
const taskId = String(result.id || "").trim();
if (!taskId) {
return NextResponse.json(
{
success: false,
error: "KittyCAD 返回缺少任务 ID",
raw: result,
},
{ status: 502 }
);
}
if (pointsConfig.enabled && unitCost > 0) {
try {
await consumeUserPoints({
userId: session.user.id,
amount: unitCost,
reason: "model3d_generation",
modelId: "kittycad-cad",
note:
generationMode === "iterate"
? "AI-CAD 连续迭代消耗积分"
: "AI-CAD 生成消耗积分",
});
} catch (error) {
console.error("[Points] Failed to consume AI-CAD points:", error);
}
}
return NextResponse.json({
success: true,
data: {
taskId,
status: mapKittyCadStatus(result.status),
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat:
generationMode === "iterate"
? result.output_format || null
: result.output_format || outputFormat,
generationMode,
sourceTaskId: iterationSourceTaskId || null,
raw: result,
},
});
} catch (error) {
if (error instanceof KittyCadApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建 AI-CAD 任务失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,195 @@
import type { AiCadWorkspace } from "@/lib/db/schema";
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
const FINAL_TASK_STATUS = new Set([
"success",
"failed",
"banned",
"expired",
"cancelled",
"unknown",
]);
const DEFAULT_VIEWER_SETTINGS = {
cameraPreset: "iso",
envPreset: "studio",
lightIntensity: 1.1,
autoRotate: true,
showGrid: true,
wireframe: false,
} as const;
type WorkspaceVersionLike = {
status?: unknown;
prompt?: unknown;
};
export type UpdateAiCadWorkspaceRequest = {
name?: string;
prompt?: string;
generationProvider?: string;
modelVersion?: string;
viewerSettings?: unknown;
messages?: unknown;
versions?: unknown;
activeVersionId?: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is WorkspaceVersionLike => isRecord(item));
}
export function normalizeViewerSettings(
value: unknown
): Record<string, unknown> {
if (!isRecord(value)) {
return { ...DEFAULT_VIEWER_SETTINGS };
}
return {
cameraPreset:
value.cameraPreset === "top" ||
value.cameraPreset === "front" ||
value.cameraPreset === "right" ||
value.cameraPreset === "iso"
? value.cameraPreset
: DEFAULT_VIEWER_SETTINGS.cameraPreset,
envPreset:
typeof value.envPreset === "string" && value.envPreset.trim()
? value.envPreset
: DEFAULT_VIEWER_SETTINGS.envPreset,
lightIntensity:
typeof value.lightIntensity === "number" &&
Number.isFinite(value.lightIntensity)
? value.lightIntensity
: DEFAULT_VIEWER_SETTINGS.lightIntensity,
autoRotate:
typeof value.autoRotate === "boolean"
? value.autoRotate
: DEFAULT_VIEWER_SETTINGS.autoRotate,
showGrid:
typeof value.showGrid === "boolean"
? value.showGrid
: DEFAULT_VIEWER_SETTINGS.showGrid,
wireframe:
typeof value.wireframe === "boolean"
? value.wireframe
: DEFAULT_VIEWER_SETTINGS.wireframe,
};
}
export function normalizeJsonRecordArray(
value: unknown
): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is Record<string, unknown> =>
isRecord(item)
);
}
export function normalizeWorkspaceName(
value: unknown,
fallbackName = "AI-CAD 工作台"
) {
return typeof value === "string" && value.trim()
? value.trim()
: fallbackName;
}
export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): {
name?: string;
prompt: string;
generationProvider: CadGenerationProvider;
modelVersion: string;
viewerSettings: Record<string, unknown>;
messages: Record<string, unknown>[];
versions: Record<string, unknown>[];
activeVersionId: string | null;
} {
return {
...(typeof body.name === "string" && body.name.trim()
? { name: body.name.trim() }
: {}),
prompt: String(body.prompt || ""),
generationProvider:
body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad",
modelVersion:
typeof body.modelVersion === "string" && body.modelVersion.trim()
? body.modelVersion
: "Turbo-v1.0-20250506",
viewerSettings: normalizeViewerSettings(body.viewerSettings),
messages: normalizeJsonRecordArray(body.messages),
versions: normalizeJsonRecordArray(body.versions),
activeVersionId:
typeof body.activeVersionId === "string" && body.activeVersionId.trim()
? body.activeVersionId
: null,
};
}
export function buildDefaultWorkspaceName(index: number) {
return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台";
}
export function createWorkspaceSummary(workspace: AiCadWorkspace) {
const versions = getWorkspaceVersions(workspace.versions);
const versionCount = versions.length;
const completedCount = versions.filter(
(version) => version.status === "success"
).length;
const hasPendingTasks = versions.some(
(version) =>
typeof version.status !== "string" ||
!FINAL_TASK_STATUS.has(version.status)
);
const latestPrompt =
typeof workspace.prompt === "string" && workspace.prompt.trim()
? workspace.prompt.trim()
: versions.find(
(version) =>
typeof version.prompt === "string" && version.prompt.trim()
)?.prompt || null;
return {
id: workspace.id,
name: workspace.name,
generationProvider: workspace.generationProvider,
versionCount,
completedCount,
hasPendingTasks,
latestPrompt:
typeof latestPrompt === "string" && latestPrompt.trim()
? latestPrompt.trim()
: null,
createdAt: workspace.createdAt?.toISOString() || null,
updatedAt: workspace.updatedAt?.toISOString() || null,
};
}
export function createWorkspaceDocument(workspace: AiCadWorkspace) {
return {
id: workspace.id,
name: workspace.name,
prompt: workspace.prompt,
generationProvider: workspace.generationProvider,
modelVersion: workspace.modelVersion,
viewerSettings: workspace.viewerSettings,
messages: workspace.messages,
versions: workspace.versions,
activeVersionId: workspace.activeVersionId,
createdAt: workspace.createdAt?.toISOString() || null,
updatedAt: workspace.updatedAt?.toISOString() || null,
};
}

View File

@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createWorkspaceDocument,
normalizeWorkspacePayload,
type UpdateAiCadWorkspaceRequest,
} from "@/app/api/ai-cad/workspace-utils";
import {
createAiCadWorkspace,
listAiCadWorkspacesByUserId,
updateAiCadWorkspace,
} from "@/lib/db/queries";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const workspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
const workspace = workspaces[0] ?? null;
if (!workspace) {
return NextResponse.json({
success: true,
data: null,
});
}
return NextResponse.json({
success: true,
data: createWorkspaceDocument(workspace),
});
}
export async function PUT(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
const existingWorkspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
const targetWorkspace =
existingWorkspaces[0] ??
(await createAiCadWorkspace({
userId: session.user.id,
name: "AI-CAD 工作台",
}));
if (!targetWorkspace) {
return NextResponse.json(
{ success: false, error: "初始化 AI-CAD 工作台失败" },
{ status: 500 }
);
}
const savedWorkspace = await updateAiCadWorkspace({
workspaceId: targetWorkspace.id,
userId: session.user.id,
...normalizeWorkspacePayload(body),
});
if (!savedWorkspace) {
return NextResponse.json(
{ success: false, error: "保存 AI-CAD 工作台失败" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
data: {
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
},
});
}

View File

@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createWorkspaceDocument,
createWorkspaceSummary,
normalizeWorkspacePayload,
type UpdateAiCadWorkspaceRequest,
} from "@/app/api/ai-cad/workspace-utils";
import {
deleteAiCadWorkspaceById,
getAiCadWorkspaceById,
updateAiCadWorkspace,
} from "@/lib/db/queries";
export const runtime = "nodejs";
type RouteContext = {
params: Promise<{
workspaceId: string;
}>;
};
export async function GET(_request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { workspaceId } = await context.params;
const workspace = await getAiCadWorkspaceById({
userId: session.user.id,
workspaceId,
});
if (!workspace) {
return NextResponse.json(
{ success: false, error: "AI-CAD 工作台不存在" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: createWorkspaceDocument(workspace),
});
}
export async function PUT(request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { workspaceId } = await context.params;
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
const savedWorkspace = await updateAiCadWorkspace({
workspaceId,
userId: session.user.id,
...normalizeWorkspacePayload(body),
});
if (!savedWorkspace) {
return NextResponse.json(
{ success: false, error: "AI-CAD 工作台不存在或保存失败" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
id: savedWorkspace.id,
name: savedWorkspace.name,
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
summary: createWorkspaceSummary(savedWorkspace),
},
});
}
export async function DELETE(_request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { workspaceId } = await context.params;
const deletedWorkspace = await deleteAiCadWorkspaceById({
workspaceId,
userId: session.user.id,
});
if (!deletedWorkspace) {
return NextResponse.json(
{ success: false, error: "AI-CAD 工作台不存在" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
id: deletedWorkspace.id,
},
});
}

View File

@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
buildDefaultWorkspaceName,
createWorkspaceSummary,
normalizeWorkspaceName,
} from "@/app/api/ai-cad/workspace-utils";
import {
createAiCadWorkspace,
listAiCadWorkspacesByUserId,
} from "@/lib/db/queries";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const workspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
return NextResponse.json({
success: true,
data: workspaces.map(createWorkspaceSummary),
});
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json().catch(() => ({}))) as { name?: unknown };
const existingWorkspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
const workspace = await createAiCadWorkspace({
userId: session.user.id,
name: normalizeWorkspaceName(
body.name,
buildDefaultWorkspaceName(existingWorkspaces.length + 1)
),
});
if (!workspace) {
return NextResponse.json(
{ success: false, error: "创建 AI-CAD 工作台失败" },
{ status: 500 }
);
}
return NextResponse.json(
{
success: true,
data: createWorkspaceSummary(workspace),
},
{ status: 201 }
);
}

View File

@ -1,66 +0,0 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const ALLOWED_WEBSOCKET_PATHS = new Set([
"/api/ai-cad/zoo-ws/ml/copilot",
"/api/ai-cad/zoo-ws/modeling/commands",
]);
function hasValidInternalSecret(request: Request) {
const expected = String(process.env.AI_CAD_WS_PROXY_SECRET || "").trim();
const received = String(
request.headers.get("x-hicad-ws-proxy-secret") || ""
).trim();
if (!expected || !received) {
return false;
}
const expectedBuffer = Buffer.from(expected);
const receivedBuffer = Buffer.from(received);
return (
expectedBuffer.length === receivedBuffer.length &&
timingSafeEqual(expectedBuffer, receivedBuffer)
);
}
export async function GET(request: Request) {
if (!hasValidInternalSecret(request)) {
return NextResponse.json({ error: "禁止访问" }, { status: 403 });
}
const originalUri = String(
request.headers.get("x-hicad-original-uri") || ""
).split("?", 1)[0];
if (!ALLOWED_WEBSOCKET_PATHS.has(originalUri)) {
return NextResponse.json({ error: "不支持的 Zoo WebSocket" }, { status: 403 });
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
try {
const config = await getKittyCadProviderConfig();
return new Response(null, {
headers: {
"Cache-Control": "private, no-store",
"X-Zoo-Authorization": `Bearer ${config.apiKey}`,
},
status: 204,
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Zoo WebSocket 鉴权失败",
},
{ status: 503 }
);
}
}

View File

@ -1,140 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export const runtime = "nodejs";
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"content-length",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
const BLOCKED_PATH_PREFIXES = [
"/admin",
"/internal",
"/logout",
"/oauth2",
"/org/api-tokens",
"/user/api-tokens",
"/users",
];
type RouteContext = {
params: Promise<{ path: string[] }>;
};
function isBlockedPath(pathname: string) {
return BLOCKED_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
}
function copyRequestHeaders(request: Request, apiKey: string) {
const headers = new Headers();
for (const [name, value] of request.headers.entries()) {
const normalizedName = name.toLowerCase();
if (
HOP_BY_HOP_HEADERS.has(normalizedName) ||
normalizedName === "authorization" ||
normalizedName === "cookie" ||
normalizedName === "origin" ||
normalizedName === "referer" ||
normalizedName.startsWith("x-forwarded-") ||
normalizedName === "x-hicad-ws-proxy-secret"
) {
continue;
}
headers.set(name, value);
}
headers.set("Authorization", `Bearer ${apiKey}`);
headers.set("Accept", request.headers.get("accept") || "application/json");
return headers;
}
function copyResponseHeaders(upstream: Response) {
const headers = new Headers();
for (const [name, value] of upstream.headers.entries()) {
const normalizedName = name.toLowerCase();
if (
HOP_BY_HOP_HEADERS.has(normalizedName) ||
normalizedName === "content-encoding" ||
normalizedName === "set-cookie"
) {
continue;
}
headers.set(name, value);
}
headers.set("Cache-Control", "private, no-store");
return headers;
}
async function proxyZooRequest(request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { path } = await context.params;
const pathname = `/${path.map(encodeURIComponent).join("/")}`;
if (isBlockedPath(pathname)) {
return NextResponse.json(
{ error: "该 Zoo API 不允许通过 HiCAD 代理访问" },
{ status: 403 }
);
}
try {
const config = await getKittyCadProviderConfig();
const incomingUrl = new URL(request.url);
const baseUrl = config.baseUrl.replace(/\/+$/, "");
const upstreamUrl = `${baseUrl}${pathname}${incomingUrl.search}`;
const method = request.method.toUpperCase();
const hasBody = method !== "GET" && method !== "HEAD";
const init: RequestInit & { duplex?: "half" } = {
body: hasBody ? request.body : undefined,
cache: "no-store",
duplex: hasBody ? "half" : undefined,
headers: copyRequestHeaders(request, config.apiKey),
method,
redirect: "manual",
signal: request.signal,
};
const upstream = await fetch(upstreamUrl, init);
return new Response(upstream.body, {
headers: copyResponseHeaders(upstream),
status: upstream.status,
statusText: upstream.statusText,
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Zoo API 代理请求失败",
},
{ status: 502 }
);
}
}
export const DELETE = proxyZooRequest;
export const GET = proxyZooRequest;
export const HEAD = proxyZooRequest;
export const OPTIONS = proxyZooRequest;
export const PATCH = proxyZooRequest;
export const POST = proxyZooRequest;
export const PUT = proxyZooRequest;

View File

@ -13,7 +13,6 @@ import {
findStyleProImageModelByRef, findStyleProImageModelByRef,
getStyleProImageModelEntries, getStyleProImageModelEntries,
} from "@/lib/style-pro/model-options"; } from "@/lib/style-pro/model-options";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
const requestSchema = z.object({ const requestSchema = z.object({
prompt: z.string().min(1).max(5000), prompt: z.string().min(1).max(5000),
@ -53,16 +52,6 @@ export async function POST(request: Request) {
{ status: 400 } { status: 400 }
); );
} }
const availability = getAiModelFrontendAvailability(selectedModel.model);
if (availability.selectable === false) {
return NextResponse.json(
{
success: false,
error: availability.disabledReason || "所选 AI洗图 生图模型当前不可用",
},
{ status: 400 }
);
}
const imageSize = body.imageSize?.trim() || "2K"; const imageSize = body.imageSize?.trim() || "2K";
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", { const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {

View File

@ -1,10 +1,356 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit"; import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("angle-edit-api");
// 角度编辑API配置类型
type AngleEditConfig = {
enabled?: boolean;
falApiKey?: string;
loraScale?: number;
inferenceSteps?: number;
guidanceScale?: number;
maxImageSize?: number;
timeout?: number;
};
// 设置API路由最大执行时间为3分钟(180秒)
export const maxDuration = 180; export const maxDuration = 180;
export async function POST(request: Request) {
const session = await auth(); /**
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 }); * 压缩图片生成缩略图
return executeAngleEditPOST(request, session.user.id); */
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
try {
const sharp = await import("sharp");
const thumbnailBuffer = await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 80 })
.toBuffer();
logger.debug("thumbnail_created", {
originalBytes: buffer.length,
thumbnailBytes: thumbnailBuffer.length,
});
return thumbnailBuffer;
} catch (error) {
logger.warn("thumbnail_creation_failed", error);
return buffer;
}
}
/**
* 将 base64 图片上传到 S3
*/
async function uploadBase64ToS3(base64Data: string, prefix: string = "angle-edit"): Promise<{ thumbnail: string; original: string }> {
try {
const buffer = Buffer.from(base64Data, "base64");
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.png`;
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.png`;
logger.debug("source_image_prepared", { originalBytes: buffer.length });
const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
logger.debug("storage_upload_started", {
hasOriginal: originalArrayBuffer.byteLength > 0,
hasThumbnail: thumbnailArrayBuffer.byteLength > 0,
prefix,
});
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
logger.info("storage_upload_completed", {
originalUrl: originalResult.url,
thumbnailUrl: thumbnailResult.url,
});
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
logger.error("storage_upload_failed", error);
throw new Error(`图片上传失败: ${error instanceof Error ? error.message : "未知错误"}`);
}
}
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
// 1. 验证用户登录
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
logger.info("request_received", { authenticated: true });
// 2. 从数据库获取配置
const angleEditConfig = await getSystemSetting({ key: "angle_edit_config" });
const config = (angleEditConfig?.value as AngleEditConfig) || {};
// 验证功能是否启用
if (!config.enabled) {
return NextResponse.json(
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
{ status: 400 }
);
}
// 验证 API Key
if (!config.falApiKey) {
return NextResponse.json(
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
{ status: 500 }
);
}
logger.debug("config_loaded", {
enabled: Boolean(config.enabled),
hasApiKey: Boolean(config.falApiKey),
loraScale: config.loraScale || 1,
});
// 3. 解析请求
const body = await request.json();
const {
image_url,
image_base64,
// 数值参数 (官方 API 格式)
horizontal_angle, // 0-360 度
vertical_angle, // -30 到 90 度
zoom, // 0-10
lora_scale,
} = body;
if (!image_url && !image_base64) {
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
}
// 使用请求中的参数或配置中的默认值
const finalLoraScale = lora_scale ?? config.loraScale ?? 1;
const finalHorizontalAngle = horizontal_angle ?? 0;
const finalVerticalAngle = vertical_angle ?? 0;
const finalZoom = zoom ?? 5;
logger.info("request_parameters_normalized", {
hasBase64Input: Boolean(image_base64),
hasUrlInput: Boolean(image_url),
horizontalAngle: finalHorizontalAngle,
loraScale: finalLoraScale,
verticalAngle: finalVerticalAngle,
zoom: finalZoom,
});
// 4. 准备图片输入
let imageInput: string;
if (image_url) {
imageInput = image_url;
} else {
const mimeType = image_base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
imageInput = `data:${mimeType};base64,${image_base64}`;
}
// 5. 动态导入 fal.ai SDK 并调用官方多角度 API
logger.info("provider_call_started", {
modelId: "fal-ai/qwen-image-edit-2511-multiple-angles",
});
const fal = await import("@fal-ai/serverless-client");
fal.config({
credentials: config.falApiKey,
});
// 使用官方多角度 API 端点
// 文档: https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles
const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", {
input: {
image_urls: [imageInput], // 官方使用 image_urls (数组)
horizontal_angle: finalHorizontalAngle, // 水平旋转角度 0-360
vertical_angle: finalVerticalAngle, // 垂直角度 -30 到 90
zoom: finalZoom, // 缩放 0-10
lora_scale: finalLoraScale, // LoRA 强度
num_inference_steps: config.inferenceSteps || 28,
guidance_scale: config.guidanceScale || 4.5, // 官方默认 4.5
enable_safety_checker: true,
output_format: "png",
},
logs: true,
onQueueUpdate: (update) => {
if (update.status === "IN_PROGRESS") {
logger.debug("provider_progress", { status: update.status });
}
},
});
// 6. 处理结果
const apiResult = result as { images?: Array<{ url?: string }>; prompt?: string };
const outputImage = apiResult.images?.[0];
if (!outputImage?.url) {
throw new Error("API 未返回有效图片");
}
const outputImageUrl = outputImage.url;
logger.info("provider_call_completed", {
hasGeneratedPrompt: Boolean(apiResult.prompt),
imageCount: apiResult.images?.length ?? 0,
outputImageUrl,
});
// 7. 下载图片并上传到我们的 S3
const imageResponse = await fetch(outputImageUrl);
if (!imageResponse.ok) {
throw new Error(`下载输出图片失败: ${imageResponse.status}`);
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = imageBuffer.toString("base64");
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
logger.info("result_image_persisted", {
originalUrl: s3Result.original,
thumbnailUrl: s3Result.thumbnail,
});
// 8. 保存到数据库
const processingTime = (Date.now() - startTime) / 1000;
const [savedImage] = await db
.insert(generatedImage)
.values({
userId: session.user.id,
thumbnailUrl: s3Result.thumbnail,
originalUrl: s3Result.original,
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
modelId: "qwen-image-edit-2511-multiple-angles",
createdAt: new Date(),
})
.returning();
try {
const sourceAssets =
typeof image_url === "string" && image_url.trim()
? await getMediaAssetsByUserAndUrls({
userId: session.user.id,
urls: [image_url],
})
: [];
const media = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: s3Result.original,
previewUrl: s3Result.original,
thumbnailUrl: s3Result.thumbnail,
displayName: `角度编辑 ${finalHorizontalAngle}/${finalVerticalAngle}`.slice(0, 80),
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
modelId: "qwen-image-edit-2511-multiple-angles",
sourceTable: "GeneratedImage",
sourceRowId: savedImage.id,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: {
horizontalAngle: finalHorizontalAngle,
verticalAngle: finalVerticalAngle,
zoom: finalZoom,
},
});
if (media) {
for (const sourceAsset of sourceAssets) {
await createMediaAssetRelation({
userId: session.user.id,
fromAssetId: sourceAsset.id,
toAssetId: media.id,
relationType: "edited_from",
metadata: {
horizontalAngle: finalHorizontalAngle,
verticalAngle: finalVerticalAngle,
zoom: finalZoom,
},
});
}
}
} catch (assetError) {
logger.warn("media_asset_registration_failed", assetError);
}
logger.info("request_completed", {
processingSeconds: Number(processingTime.toFixed(2)),
});
// 9. 返回结果
return NextResponse.json({
success: true,
image_url: s3Result.original,
thumbnail_url: s3Result.thumbnail,
image_base64: imageBase64Result,
processing_time: processingTime,
angles: {
horizontal_angle: finalHorizontalAngle,
vertical_angle: finalVerticalAngle,
zoom: finalZoom,
},
prompt: apiResult.prompt,
});
} catch (error) {
logger.error("request_failed", error);
// 输出详细的错误信息
if (error && typeof error === "object" && "status" in error) {
const apiError = error as { status?: number; body?: unknown; message?: string };
logger.warn("provider_error_summary", {
bodyType: typeof apiError.body,
hasBody: apiError.body != null,
message: apiError.message,
status: apiError.status,
});
if (apiError.status === 403) {
return NextResponse.json(
{
error: "API 访问被拒绝 (403 Forbidden)。请检查 API Key 是否正确,以及是否有权限访问该模型。",
details: apiError.body,
},
{ status: 403 }
);
}
}
return NextResponse.json(
{
error: error instanceof Error ? error.message : "角度编辑处理失败",
},
{ status: 500 }
);
}
} }

View File

@ -4,128 +4,23 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { systemSettings } from "@/lib/db/schema"; import { systemSettings } from "@/lib/db/schema";
import { import {
CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX,
CANVAS_SKILL_TAGS_CONFIG_VERSION,
CANVAS_SKILL_TAGS_SETTING_KEY, CANVAS_SKILL_TAGS_SETTING_KEY,
normalizeCanvasSkillTagSettings, normalizeCanvasSkillTagSettings,
} from "@/lib/canvas-skill-tags"; } 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}`;
}
function normalizeSkillTagOrder(value: unknown) {
const source =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>).tagIds
: value;
if (!Array.isArray(source)) return [];
const seen = new Set<string>();
return source
.map((item) => String(item || "").trim())
.filter((item) => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}
export async function GET() { export async function GET() {
try { try {
const session = await auth().catch(() => null);
const userId = session?.user?.id || null;
const [setting, userOrderSetting] = await Promise.all([
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
.limit(1)
.then((rows) => rows[0]),
userId
? db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, getUserSkillTagOrderKey(userId)))
.limit(1)
.then((rows) => rows[0])
: Promise.resolve(null),
]);
const settings = normalizeCanvasSkillTagSettings(setting?.value, {
requireCurrentVersion: true,
});
const userOrder = normalizeSkillTagOrder(userOrderSetting?.value);
return NextResponse.json({
...settings,
tags: applyCanvasSkillTagOrder(settings.tags, userOrder),
userOrder,
});
} catch (error) {
console.error("[CanvasSkillTags API] Get error:", error);
return NextResponse.json(normalizeCanvasSkillTagSettings(null));
}
}
export async function PATCH(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => null);
const requestedOrder = normalizeSkillTagOrder(
body && typeof body === "object" ? (body as Record<string, unknown>).tagIds : body
);
const [setting] = await db const [setting] = await db
.select() .select()
.from(systemSettings) .from(systemSettings)
.where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY)) .where(eq(systemSettings.key, CANVAS_SKILL_TAGS_SETTING_KEY))
.limit(1); .limit(1);
const settings = normalizeCanvasSkillTagSettings(setting?.value, {
requireCurrentVersion: true,
});
const knownIds = new Set(settings.tags.map((tag) => tag.id));
const orderedKnownIds = requestedOrder.filter((id) => knownIds.has(id));
const orderedSet = new Set(orderedKnownIds);
const tagIds = [
...orderedKnownIds,
...settings.tags.map((tag) => tag.id).filter((id) => !orderedSet.has(id)),
];
const value = {
version: 1,
tagIds,
};
const key = getUserSkillTagOrderKey(session.user.id);
await db const settings = normalizeCanvasSkillTagSettings(setting?.value);
.insert(systemSettings) return NextResponse.json(settings);
.values({
key,
value,
updatedBy: session.user.id,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value,
updatedBy: session.user.id,
updatedAt: new Date(),
},
});
return NextResponse.json({
success: true,
userOrder: tagIds,
tags: applyCanvasSkillTagOrder(settings.tags, tagIds),
});
} catch (error) { } catch (error) {
console.error("[CanvasSkillTags API] Save order error:", error); console.error("[CanvasSkillTags API] Get error:", error);
return NextResponse.json({ error: "Failed to save order" }, { status: 500 }); return NextResponse.json(normalizeCanvasSkillTagSettings(null));
} }
} }
@ -140,15 +35,8 @@ export async function PUT(request: NextRequest) {
const normalized = normalizeCanvasSkillTagSettings(body); const normalized = normalizeCanvasSkillTagSettings(body);
const enabled = normalized.enabled; const enabled = normalized.enabled;
const title = normalized.title; const title = normalized.title;
const categories = normalized.categories;
const tags = normalized.tags; const tags = normalized.tags;
const nextValue = { const nextValue = { enabled, title, tags };
enabled,
title,
categories,
tags,
version: CANVAS_SKILL_TAGS_CONFIG_VERSION,
};
const [existing] = await db const [existing] = await db
.select() .select()
@ -173,7 +61,7 @@ export async function PUT(request: NextRequest) {
}); });
} }
return NextResponse.json({ success: true, enabled, title, categories, tags }); return NextResponse.json({ success: true, enabled, title, tags });
} catch (error) { } catch (error) {
console.error("[CanvasSkillTags API] Update error:", error); console.error("[CanvasSkillTags API] Update error:", error);
return NextResponse.json({ error: "Failed to update" }, { status: 500 }); return NextResponse.json({ error: "Failed to update" }, { status: 500 });

View File

@ -14,7 +14,6 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"worldlabs.ai", "worldlabs.ai",
"s3.1080p.hk", "s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
"scqny.jiansun.vip",
"blob.vercel-storage.com", "blob.vercel-storage.com",
"aliyuncs.com", "aliyuncs.com",
"amazonaws.com", "amazonaws.com",

View File

@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
const { id } = await params;
const response = await handleCanvasAgentOperationRouteRequest({
userId: session?.user?.id,
projectId: id,
readBody: () => request.json(),
features: await getCanvasAgentFeatureFlagsFromSystemSettings(),
applyOperation: applyCanvasOperationPlan,
logger: console,
});
return NextResponse.json(response.body, { status: response.status });
}

View File

@ -1,91 +0,0 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { auth } from "@/app/(auth)/auth";
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const requestId = new URL(request.url).searchParams.get("requestId");
if (!requestId) {
return NextResponse.json({
success: true,
capabilities: getCanvasCapabilityManifest(),
});
}
const task = await getCanvasCapabilityTask({
requestId,
userId: session.user.id,
projectId: id,
});
if (!task) {
return NextResponse.json(
{ success: false, error: "能力任务不存在" },
{ status: 404 }
);
}
return NextResponse.json({ success: true, task });
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
try {
const { id } = await params;
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
const result = await executeCanvasCapabilityRuntime({
command,
projectId: id,
requestHeaders: request.headers,
requestUrl: request.url,
userId: session.user.id,
});
return NextResponse.json({ success: true, ...result });
} catch (error) {
if (
error instanceof CanvasCapabilityRuntimeError ||
error instanceof CanvasAgentMutationError
) {
return NextResponse.json(
{ success: false, error: error.message, code: error.code },
{ status: error.status }
);
}
if (error instanceof ZodError) {
return NextResponse.json(
{
success: false,
error: "画布能力参数无效",
issues: error.issues,
},
{ status: 400 }
);
}
const message = error instanceof Error ? error.message : "执行画布能力失败";
return NextResponse.json(
{ success: false, error: message },
{ status: 500 }
);
}
}

View File

@ -1,65 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { and, eq, inArray, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject, imageArchiveTask, generatedImage, canvasImageTask } from "@/lib/db/schema";
import { imageArchiveSourceKey } from "@/lib/storage/image-archive-db";
import { isGeneratedImageSource } from "@/lib/storage/image-archive-urls";
import { scheduleImageArchiveWorker } from "@/lib/storage/image-archive-worker";
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const { id } = await params;
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)))
.limit(1);
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const body = await request.json();
if (
!Array.isArray(body.urls) ||
body.urls.length > 100 ||
body.urls.some((url: unknown) => typeof url !== "string" || !isGeneratedImageSource(url))
) {
return NextResponse.json({ error: "图片地址格式错误" }, { status: 400 });
}
const urls: string[] = [...new Set<string>(body.urls)];
if (!urls.length) return NextResponse.json({ replacements: {}, pending: [] });
const source = imageArchiveTask.sourceUrl;
const rows = await db
.select({
sourceUrl: imageArchiveTask.sourceUrl,
targetUrl: imageArchiveTask.targetUrl,
status: imageArchiveTask.status,
})
.from(imageArchiveTask)
.where(
and(
inArray(imageArchiveTask.sourceUrl, urls.map(imageArchiveSourceKey)),
sql`(
exists (select 1 from ${generatedImage} where ${generatedImage.userId} = ${session.user.id}
and (${generatedImage.originalUrl} = ${source} or ${generatedImage.thumbnailUrl} = ${source}
or ${generatedImage.originalUrl} = ${imageArchiveTask.targetUrl} or ${generatedImage.thumbnailUrl} = ${imageArchiveTask.targetUrl}))
or exists (select 1 from ${canvasImageTask} where ${canvasImageTask.userId} = ${session.user.id}
and (position(${source} in ${canvasImageTask.result}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasImageTask.result}::text) > 0))
or exists (select 1 from ${canvasProject} where ${canvasProject.id} = ${id} and ${canvasProject.userId} = ${session.user.id}
and (position(${source} in ${canvasProject.canvasData}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasProject.canvasData}::text) > 0))
)`,
),
);
const byKey = new Map(rows.map((row) => [row.sourceUrl, row]));
const replacements: Record<string, string> = {};
const pending: string[] = [];
for (const url of urls) {
const row = byKey.get(imageArchiveSourceKey(url));
if (row?.targetUrl) replacements[url] = row.targetUrl;
else if (row && row.status !== "abandoned") pending.push(url);
}
if (rows.some((row) => row.status === "pending")) scheduleImageArchiveWorker(true);
return NextResponse.json(
{ replacements, pending },
{ headers: { "Cache-Control": "private, no-store" } },
);
}

View File

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

View File

@ -3,12 +3,7 @@ import { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema'; import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries'; import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import {
buildStaleCanvasSaveConflictPayload,
isStaleCanvasSaveBaseRevision,
} from '@/lib/canvas/canvas-save-conflict';
function resolveWorkspaceType(input: unknown) { function resolveWorkspaceType(input: unknown) {
return input === "agent" || input === "video" ? input : "canvas"; return input === "agent" || input === "video" ? input : "canvas";
@ -18,67 +13,19 @@ function getDefaultWorkspaceTitle(workspaceType: ReturnType<typeof resolveWorksp
return workspaceType === "video" ? "未命名视频画布" : "未命名画布"; 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( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params;
if (shouldUseLocalCanvasE2E(request)) {
return NextResponse.json({
success: true,
data: createLocalCanvasProject(id),
});
}
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }); return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
} }
const { id } = await params;
const [project] = await db const [project] = await db
.select() .select()
.from(canvasProject) .from(canvasProject)
@ -103,118 +50,85 @@ export async function GET(
// 更新画布(支持 upsert:如果不存在则创建) // 更新画布(支持 upsert:如果不存在则创建)
export async function PUT( export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> }
) { ) {
try { 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(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json( return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
{ success: false, error: "未登录" },
{ status: 401 },
);
} }
const { title, description, canvasData } = body; const { id } = await params;
const thumbnail = await resolveArchivedImageUrls(body.thumbnail); const body = await request.json();
const baseCanvasRevision = const { title, description, thumbnail, canvasData } = body;
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType = const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType") body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
? resolveWorkspaceType(body.workspaceType) ? resolveWorkspaceType(body.workspaceType)
: undefined; : undefined;
const syncedCanvasData =
return await db.transaction(async (tx) => { canvasData !== undefined
const [existingProject] = await tx ? await syncCanvasNodesForProject({
.select() projectId: id,
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
),
)
.for("update");
if (
existingProject &&
canvasData !== undefined &&
baseCanvasRevision &&
isStaleCanvasSaveBaseRevision({
baseCanvasRevision: body?.baseCanvasRevision,
baseCanvasUpdatedAt: body?.baseCanvasUpdatedAt,
currentUpdatedAt: existingProject.updatedAt,
})
) {
return NextResponse.json(
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
{ status: 409 },
);
}
if (!existingProject) {
// The parent must exist before node/asset foreign keys are synchronized.
const [created] = await tx
.insert(canvasProject)
.values({
id,
userId: session.user.id, userId: session.user.id,
createdAt: new Date(), canvasData,
updatedAt: new Date(),
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
workspaceType: workspaceType || "canvas",
canvasData: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
}) })
.onConflictDoNothing({ target: canvasProject.id }) : undefined;
.returning();
if (!created) // 先检查画布是否存在
return NextResponse.json( const [existingProject] = await db
{ success: false, error: "画布已被创建,请重新加载后保存" }, .select()
{ status: 409 }, .from(canvasProject)
); .where(
} and(
const syncedCanvasData = eq(canvasProject.id, id),
canvasData !== undefined eq(canvasProject.userId, session.user.id)
? await syncCanvasNodesForProject( )
{ projectId: id, userId: session.user.id, canvasData }, );
tx,
) if (existingProject) {
: undefined; // 更新现有画布
const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) }; const updateData: any = { updatedAt: new Date() };
if (title !== undefined) updateData.title = title; if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description; if (description !== undefined) updateData.description = description;
if (thumbnail !== undefined) updateData.thumbnail = thumbnail; if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
if (workspaceType !== undefined) updateData.workspaceType = workspaceType; if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
if (syncedCanvasData !== undefined) if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData;
updateData.canvasData = syncedCanvasData;
const [saved] = await tx const [updatedProject] = await db
.update(canvasProject) .update(canvasProject)
.set(updateData) .set(updateData)
.where( .where(
and( and(
eq(canvasProject.id, id), eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id), eq(canvasProject.userId, session.user.id)
), )
) )
.returning(); .returning();
return NextResponse.json({ success: true, data: saved });
}); return NextResponse.json({ success: true, data: updatedProject });
} else {
// 创建新画布(使用指定 ID)
const [newProject] = await db
.insert(canvasProject)
.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();
return NextResponse.json({ success: true, data: newProject });
}
} catch (error) { } catch (error) {
console.error("更新/创建画布失败:", error); console.error('更新/创建画布失败:', error);
return NextResponse.json( return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 });
{ success: false, error: "更新画布失败" },
{ status: 500 },
);
} }
} }

View File

@ -1,57 +0,0 @@
import { and, desc, eq, lte } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { tvstarSession } from "@/lib/db/schema";
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
import { tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
export const dynamic = "force-dynamic";
async function authorize(projectId: string) {
const session = await auth();
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
}
return { userId: session.user.id };
}
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const access = await authorize(id);
if (access.error) return access.error;
const rows = await db.select({ snapshot: tvstarSession.snapshot, shareToken: tvstarSession.shareToken })
.from(tvstarSession)
.where(and(eq(tvstarSession.userId, access.userId!), eq(tvstarSession.projectId, id)))
.orderBy(desc(tvstarSession.clientUpdatedAt)).limit(20);
return NextResponse.json({ sessions: rows.map(({ snapshot, shareToken }) => ({
...(snapshot as Record<string, unknown>), shareToken: shareToken || undefined,
})) }, { headers: { "Cache-Control": "no-store" } });
}
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const access = await authorize(id);
if (access.error) return access.error;
const raw = await request.text();
if (raw.length > 900_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
let body: unknown;
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
const parsed = z.object({ sessions: z.array(tvstarSessionSchema).max(20) }).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
for (const session of parsed.data.sessions) {
const clientUpdatedAt = new Date(Math.min(session.updatedAt, Date.now() + 60_000));
await db.insert(tvstarSession).values({
id: session.id, projectId: id, userId: access.userId!, title: session.title,
snapshot: session, clientUpdatedAt,
}).onConflictDoNothing({ target: tvstarSession.id });
await db.update(tvstarSession).set({ title: session.title, snapshot: session, clientUpdatedAt, updatedAt: new Date() })
.where(and(
eq(tvstarSession.id, session.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!),
lte(tvstarSession.clientUpdatedAt, clientUpdatedAt),
));
}
return NextResponse.json({ ok: true });
}

View File

@ -1,58 +0,0 @@
import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { tvstarSession } from "@/lib/db/schema";
import { ownsTaskProject } from "@/lib/video-canvas/task-store";
import { publicTVstarSnapshot, tvstarSessionSchema } from "@/lib/video-canvas/tvstar-session";
export const dynamic = "force-dynamic";
async function owner(projectId: string) {
const session = await auth();
if (!session?.user?.id) return { error: NextResponse.json({ error: "未登录" }, { status: 401 }) };
if (!z.string().uuid().safeParse(projectId).success || !await ownsTaskProject(session.user.id, projectId)) {
return { error: NextResponse.json({ error: "画布不存在" }, { status: 404 }) };
}
return { userId: session.user.id };
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const access = await owner(id);
if (access.error) return access.error;
const raw = await request.text();
if (raw.length > 300_000) return NextResponse.json({ error: "对话记录过大" }, { status: 413 });
let body: unknown;
try { body = JSON.parse(raw); } catch { return NextResponse.json({ error: "请求格式无效" }, { status: 400 }); }
const parsed = z.object({ session: tvstarSessionSchema }).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "对话记录格式无效" }, { status: 400 });
const current = parsed.data.session;
const condition = and(eq(tvstarSession.id, current.id), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!));
await db.insert(tvstarSession).values({
id: current.id, projectId: id, userId: access.userId!, title: current.title,
snapshot: current, clientUpdatedAt: new Date(Math.min(current.updatedAt, Date.now() + 60_000)),
}).onConflictDoNothing({ target: tvstarSession.id });
const existing = (await db.select({ shareToken: tvstarSession.shareToken }).from(tvstarSession).where(condition).limit(1))[0];
if (!existing) return NextResponse.json({ error: "对话不存在" }, { status: 404 });
const token = existing.shareToken || randomBytes(24).toString("base64url");
if (!existing.shareToken) {
await db.update(tvstarSession).set({
shareToken: token, sharedSnapshot: publicTVstarSnapshot(current), sharedAt: new Date(), updatedAt: new Date(),
}).where(condition);
}
return NextResponse.json({ url: `/tvstar/share/${token}`, token });
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const access = await owner(id);
if (access.error) return access.error;
const sessionId = new URL(request.url).searchParams.get("sessionId") || "";
if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "对话 ID 无效" }, { status: 400 });
await db.update(tvstarSession).set({ shareToken: null, sharedSnapshot: null, sharedAt: null, updatedAt: new Date() })
.where(and(eq(tvstarSession.id, sessionId), eq(tvstarSession.projectId, id), eq(tvstarSession.userId, access.userId!)));
return NextResponse.json({ ok: true });
}

View File

@ -1,40 +0,0 @@
import { PROJECT_AGENT_OPERATIONS, projectOperationAuthorized } from "@/lib/ai/canvas-agent-runtime/project-operation-catalog";
import { NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { getSystemSetting } from "@/lib/db/queries";
import {
normalizeVideoCanvasFunctionConfig,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
} from "@/lib/video-canvas/config";
import { getCanvasAgentAccessManifest } from "@/lib/ai/canvas-agent-runtime/capability-access";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const parsed = z.string().uuid().safeParse(new URL(request.url).searchParams.get("projectId"));
if (!parsed.success) return NextResponse.json({ error: "画布编号无效" }, { status: 400 });
const [project] = await db
.select({ id: canvasProject.id, workspace: canvasProject.workspaceType })
.from(canvasProject)
.where(and(eq(canvasProject.id, parsed.data), eq(canvasProject.userId, session.user.id)))
.limit(1);
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const config = normalizeVideoCanvasFunctionConfig(
(await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }))?.value,
);
return NextResponse.json(
{
workspace: project.workspace,
projectServices: PROJECT_AGENT_OPERATIONS.map(item => ({ ...item, authorized: projectOperationAuthorized(item) })),
utilityTools: ["getWeather", "createDocument", "updateDocument", "requestSuggestions", "loadProjectSkill"],
capabilities: getCanvasAgentAccessManifest(config).filter(
(item) => item.workspace === (project.workspace === "video" ? "video" : "canvas"),
),
},
{ headers: { "Cache-Control": "private, no-store" } },
);
}

View File

@ -1,491 +0,0 @@
import { agentModelCandidates } from "@/lib/ai/runtime/agent-model-failover";
import { agentModelCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
import { createAgentVisionExecutor } from "@/lib/ai/runtime/agent-vision-failover";
import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
import { registerProjectAgentTools } from "@/lib/ai/canvas-agent-runtime/project-tools";
import { isZcodeWorkspaceAuthorized } from "@/lib/ai/canvas-agent-runtime/authorization";
import {
createUIMessageStream,
createUIMessageStreamResponse,
generateText,
} from "ai";
import { after, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { siteSettings, canvasProject } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { postRequestBodySchema } from "@/app/(chat)/api/chat/schema";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
getAgentAiModelWithProvider,
getAccessibleAgentPresetById,
getEnabledAiModelsWithProvider,
getChatById,
getMessagesByChatId,
getMessageCountByUserId,
getSiteSettings,
saveChat,
saveMessages,
} from "@/lib/db/queries";
import { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task";
import { canvasAgentReplyText } from "@/lib/ai/zcode-agent/assistant-output";
import { createZcodeModelGateway } from "@/lib/ai/zcode-agent/model-gateway";
import { newAgentRunSnapshot, publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import {
acquireAgentRun,
agentRunView,
AgentRunError,
checkpointAgentRun,
consumeAgentRunInputs,
heartbeatAgentRun,
} from "@/lib/ai/canvas-agent-runtime/store";
import { readOwnedCanvas } from "@/lib/ai/canvas-agent-runtime/canvas";
import { buildCanvasRuntimeTools } from "@/lib/ai/canvas-agent-runtime/tools";
import { canvasRuntimeSystemPrompt } from "@/lib/ai/canvas-agent-runtime/prompt";
import { verifyCanvasAgentResults } from "@/lib/ai/canvas-agent-runtime/review";
import { inferCanvasTaskRequirements } from "@/lib/ai/canvas-agent-runtime/requirements";
import {
resolveRuntimeConversationSources,
persistRuntimeConversationResults,
} from "@/lib/ai/canvas-agent-runtime/conversation";
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
import { canApplyCanvasAgentMutations } from "@/lib/canvas/agent-feature-flags";
import {
videoAgentSettingsSchema,
inferVideoTaskRequirements,
videoNodeUrl,
videoRuntimeSystemPrompt,
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
import { readOwnedVideoCanvas } from "@/lib/ai/canvas-agent-runtime/video-store";
import {
buildVideoRuntimeTools,
createVideoRuntimePorts,
} from "@/lib/ai/canvas-agent-runtime/video-tools";
import { probeVideoMetadata } from "@/lib/ai/canvas-agent-runtime/video-probe";
import { verifyVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-review";
export const runtime = "nodejs";
export const maxDuration = 1200;
const requestSchema = postRequestBodySchema.extend({
resumeRunId: z.string().uuid().optional(),
selectedImageModel: z.string().min(1).max(180).optional(),
workspaceVariant: z.enum(["canvas", "video"]).default("canvas"),
videoAgentSettings: videoAgentSettingsSchema.optional(),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = requestSchema.parse(await request.json());
if (!shouldUseCanvasAgentRuntime(body))
throw new AgentRunError("此请求不属于画布 Agent 任务", 400);
const projectId = body.contextPayload!.projectId!;
const [ownedProject] = await db.select({ userId: canvasProject.userId, workspaceType: canvasProject.workspaceType })
.from(canvasProject).where(eq(canvasProject.id, projectId)).limit(1);
if (!ownedProject || ownedProject.userId !== session.user.id) throw new AgentRunError("画布不存在", 404);
if (!isZcodeWorkspaceAuthorized(ownedProject.workspaceType)) throw new AgentRunError("视频画布暂未授权给 ZCode Agent,请使用手工功能。", 403);
const isVideo = ownedProject.workspaceType === "video";
const preset = body.agentPresetId ? await getAccessibleAgentPresetById({ id: body.agentPresetId, userId: session.user.id }) : null;
if (preset && String(preset.type) === "video") throw new AgentRunError("视频技能暂未授权给 ZCode Agent", 403);
if (body.agentPresetId && (!preset || preset.status !== "active")) throw new AgentRunError("此 Agent 预设不可用", 403);
const objective = body.message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
if (!objective) throw new AgentRunError("请描述需要完成的创作任务", 400);
const settings = await getSiteSettings();
if (
!settings.agentEnabled ||
!canApplyCanvasAgentMutations(await getCanvasAgentFeatureFlagsFromSystemSettings())
) {
throw new AgentRunError("管理员尚未开启画布 Agent 操作", 403);
}
const [usageSettings] = await db.select().from(siteSettings).limit(1);
if (usageSettings?.usageTimeEnabled) {
const time = new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(new Date());
if (
time < (usageSettings.usageTimeStart || "08:00") ||
time > (usageSettings.usageTimeEnd || "20:00")
)
throw new AgentRunError(usageSettings.usageTimeMessage || "当前不在系统使用时间段内", 403);
}
const count = await getMessageCountByUserId({ id: session.user.id, differenceInHours: 24 });
if (count > entitlementsByUserType[session.user.type].maxMessagesPerDay)
throw new AgentRunError("今日对话次数已达到限额", 429);
const imageCanvas = isVideo ? null : await readOwnedCanvas(session.user.id, projectId);
if (imageCanvas?.project.workspaceType === "video")
throw new AgentRunError("请使用视频画布 Agent 入口", 400);
const videoCanvas = isVideo ? await readOwnedVideoCanvas(session.user.id, projectId) : null;
const nodes = imageCanvas?.nodes || [];
const availableIds = isVideo
? videoCanvas!.nodes.map((node) => node.id)
: nodes.map((node) => node.id);
const selectedNodeIds = body.contextPayload?.selectedNodeIds || [];
if (selectedNodeIds.some((id) => !availableIds.includes(id))) {
throw new AgentRunError("选中图片尚未保存到画布,请等待保存完成后重试。");
}
const existingChat = await getChatById({ id: body.id });
if (existingChat && existingChat.userId !== session.user.id)
throw new AgentRunError("对话不存在", 404);
if (!existingChat)
await saveChat({
id: body.id,
userId: session.user.id,
title: objective.slice(0, 80),
visibility: "private",
});
const conversation = isVideo
? {
sourceNodeIds: selectedNodeIds,
targetReason: "video-selected-nodes",
previousContext: null,
}
: await resolveRuntimeConversationSources({
userId: session.user.id,
chatId: body.id,
projectId,
text: objective,
parts: body.message.parts,
nodes,
selectedNodeIds,
});
const candidates = agentModelCandidates(await getAgentAiModelWithProvider(), await getEnabledAiModelsWithProvider());
const configured = candidates[0];
if (!configured) throw new AgentRunError("请先在后台配置可用的多模态 Agent 对话模型", 503);
let model = await getLanguageModelById(configured.model.id);
for (const candidate of candidates.slice(1)) {
if (model) break;
model = await getLanguageModelById(candidate.model.id);
}
if (!model) throw new AgentRunError("Agent 模型暂不可用", 503);
const visionModel = model;
const executeVisionModel = createAgentVisionExecutor(candidates, agentModelCircuitStore, getLanguageModelById);
// Validate the user's image choice independently of the Agent planning model.
if (body.selectedImageModel) {
const images = await getStyleProImageModelEntries();
if (!images.some(({ model }) => model.id === body.selectedImageModel && model.healthStatus !== "auto_disabled"))
throw new AgentRunError("你选择的生图模型当前不可用,请重新选择;不会自动换模型生成。", 400);
}
const assistantMessageId = crypto.randomUUID();
const previousVideoMessages =
existingChat
? (await getMessagesByChatId({ id: body.id })).slice(-6).map((message) => ({
role: message.role as "user" | "assistant",
content: (message.parts as Array<{ type: string; text?: string }>)
.filter((part) => part.type === "text")
.map((part) => part.text || "")
.join("\n")
.slice(0, 4000),
}))
: [];
const initialSnapshot = newAgentRunSnapshot(objective, [
...previousVideoMessages,
{ role: "user", content: objective },
]);
initialSnapshot.workspace = isVideo ? "video" : "canvas";
if (isVideo) {
initialSnapshot.videoRequirements = inferVideoTaskRequirements(objective);
initialSnapshot.videoSettings = videoAgentSettingsSchema.parse({
...body.videoAgentSettings,
...(initialSnapshot.videoRequirements.duration
? { duration: initialSnapshot.videoRequirements.duration }
: {}),
...(initialSnapshot.videoRequirements.ratio
? { ratio: initialSnapshot.videoRequirements.ratio }
: {}),
});
}
initialSnapshot.sourceNodeIds = conversation.sourceNodeIds;
initialSnapshot.requirements = {
...inferCanvasTaskRequirements(objective, initialSnapshot.sourceNodeIds.length),
sourceCount: initialSnapshot.sourceNodeIds.length,
};
const run = await acquireAgentRun({
id: body.message.id,
userId: session.user.id,
projectId,
chatId: body.id,
assistantMessageId,
resumeRunId: body.resumeRunId,
snapshot: initialSnapshot,
});
const snapshot = run.snapshot;
snapshot.imagePreferences = {
...snapshot.imagePreferences,
// Each submitted turn captures the current controls, including switching back to Auto.
modelId: body.selectedImageModel,
parameters: body.imageParams || snapshot.imagePreferences?.parameters,
};
const taskSourceNodeIds = snapshot.sourceNodeIds ?? selectedNodeIds;
const context = {
recentConversation: previousVideoMessages,
preset: preset ? { name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId, defaultParams: preset.defaultParams, slots: preset.slots.map(slot => ({ title: slot.title, promptTemplate: slot.promptTemplate, defaultParams: slot.defaultParams })) } : null,
videoSettings: snapshot.videoSettings,
selectedNodeIds: taskSourceNodeIds,
nodes: nodes
.filter((node) => taskSourceNodeIds.includes(node.id))
.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
size: node.size,
title: node.data.title,
})),
skillInstructions: body.hiddenPrompt || undefined,
imagePreferences: snapshot.imagePreferences,
targetReason: conversation.targetReason,
previousConversationContext: conversation.previousContext,
};
await saveMessages({
messages: [
{
id: body.message.id,
chatId: body.id,
role: "user",
parts: body.message.parts,
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
let archiveVideoResults: (() => Promise<void>) | undefined;
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const emit = (type: string, data: unknown) => writer.write({ type, data, ...(type === "data-canvasAgentRun" ? { transient: true } : {}) } as never);
let cancelled = false;
let leaseLost = false;
const activeModelController = new AbortController();
const withModel: typeof executeVisionModel = (execute, signal) => executeVisionModel(execute,
signal ? AbortSignal.any([signal, activeModelController.signal]) : activeModelController.signal);
const heartbeat = setInterval(() => {
void heartbeatAgentRun(run)
.then((current) => {
if (!current) leaseLost = true;
if (current?.cancelRequested) cancelled = true;
if (cancelled || leaseLost) activeModelController?.abort();
})
.catch(() => {
leaseLost = true;
activeModelController?.abort();
});
emit("data-keepalive", { label: "canvas-agent-runtime", ts: Date.now() });
}, 10_000);
const textId = crypto.randomUUID();
writer.write({ type: "start", messageId: assistantMessageId });
writer.write({ type: "text-start", id: textId });
let streamedText = "";
const emitAssistantText = (delta: string) => {
streamedText += delta;
writer.write({ type: "text-delta", id: textId, delta });
};
try {
const videoPorts = isVideo
? await createVideoRuntimePorts({
userId: session.user.id,
chatId: body.id,
projectId,
runId: run.id,
assistantMessageId,
request,
snapshot,
emit,
})
: null;
archiveVideoResults = videoPorts?.archiveResults;
const tools = isVideo
? buildVideoRuntimeTools({
runId: run.id,
projectId,
selectedNodeIds,
snapshot,
ports: videoPorts!,
})
: await buildCanvasRuntimeTools({
userId: session.user.id,
chatId: body.id,
projectId,
runId: run.id,
assistantMessageId,
selectedNodeIds: taskSourceNodeIds,
request,
model: visionModel || model,
supportsVision: Boolean(visionModel),
withModel,
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
imagePreferences: snapshot.imagePreferences,
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
operationSelectedNodeIds: selectedNodeIds,
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
"\n",
),
emit,
});
await registerProjectAgentTools({ session, projectId, request, objective: snapshot.objective,
definitions: tools.definitions, executable: tools.executable, emit,
imagePreferences: snapshot.imagePreferences });
const gateway = await createZcodeModelGateway(configured, undefined, candidates);
const status = await runZcodeCanvasTask({
runId: run.id, snapshot, definitions: tools.definitions,
instructions: (isVideo ? videoRuntimeSystemPrompt : canvasRuntimeSystemPrompt)(snapshot.objective, snapshot.summary, snapshot.inputs.map(input => input.text), context),
context, gateway, signal: activeModelController.signal,
onAssistantText: emitAssistantText,
tools: tools.executable,
checkpoint: async (state, stateStatus) => {
if (leaseLost) throw new AgentRunError("任务执行已中断");
const saved = await checkpointAgentRun(run, state, stateStatus);
cancelled ||= saved.cancelRequested;
emit("data-canvasAgentRun", agentRunView(saved));
},
consumeInputs: async (state) => {
if (leaseLost) throw new AgentRunError("任务执行已中断");
const control = await consumeAgentRunInputs(run, state);
cancelled ||= control.cancelled;
return { cancelled };
},
isCancelled: async () => {
const current = await heartbeatAgentRun(run);
if (!current) throw new AgentRunError("任务执行已中断");
cancelled ||= current.cancelRequested;
return cancelled;
},
verify: async (state) =>
isVideo
? verifyVideoAgentResults({
snapshot: state,
nodes: (await readOwnedVideoCanvas(session.user.id, projectId)).nodes,
inspect: async (node, requirements) => {
const metadata = await probeVideoMetadata(videoNodeUrl(node));
const report = await videoPorts!.analyze({
mode: "video-analysis",
prompt: `请基于真实抽帧画面检查主体、场景、镜头连贯性,说明不确定内容。用户要求:${requirements}`,
imageUrls: [],
videoUrls: [videoNodeUrl(node)],
});
const verdict = await generateText({
model,
system:
'你是视频验收员。依据实际抽帧分析判断是否满足用户要求;不能从文件名或提示词判断。若报告无法读取真实帧,必须不通过。抽帧只能检查画面,不能证明音频、精确时长或完整运动,涉及这些要求无法确认时说明需要人工检查。仅返回 JSON {"passed":boolean,"issues":string[]}。',
prompt: JSON.stringify({ requirements, report }),
maxOutputTokens: 1200,
maxRetries: 0,
abortSignal: AbortSignal.timeout(60_000),
});
state.inputTokens += verdict.usage.inputTokens || 0;
state.outputTokens += verdict.usage.outputTokens || 0;
const match = verdict.text.match(/\{[\s\S]*\}/);
if (!match) throw new Error("视频检查未返回有效结果");
return {
...z
.object({ passed: z.boolean(), issues: z.array(z.string()).max(12) })
.parse(JSON.parse(match[0])),
...metadata,
};
},
})
: verifyCanvasAgentResults({
snapshot: state,
userId: session.user.id,
projectId,
model: visionModel || model,
supportsVision: Boolean(visionModel),
withModel,
signal: activeModelController.signal,
}),
});
const finalText = canvasAgentReplyText(snapshot);
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot, status }));
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
if (!isVideo)
await persistRuntimeConversationResults({
userId: session.user.id,
chatId: body.id,
projectId,
assistantMessageId,
snapshot,
}).catch((error) => {
console.warn("[Canvas Agent] Conversation memory update failed", {
runId: run.id,
error: publicAgentError(error),
});
});
await saveMessages({
messages: [
{
id: assistantMessageId,
chatId: body.id,
role: "assistant",
parts: [{ type: "text", text: finalText }, { type: "data-canvasAgentProgress", data: progress }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
emitAssistantText(finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : `\n\n${snapshot.message}`);
} catch (error) {
const statusText = publicAgentError(error);
const text = canvasAgentReplyText({ ...snapshot, message: statusText });
await checkpointAgentRun(run, { ...snapshot, message: statusText }, "needs_review").catch(
() => undefined,
);
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot: { ...snapshot, message: statusText }, status: "needs_review" }));
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
await saveMessages({ messages: [{ id: assistantMessageId, chatId: body.id, role: "assistant",
parts: [{ type: "text", text }, { type: "data-canvasAgentProgress", data: progress }],
attachments: [], createdAt: new Date(), videoStorageStatus: null, temporaryVideoUrl: null,
permanentVideoUrl: null, archivedAt: null, videoMetadata: null }] }).catch(() => undefined);
emitAssistantText(text.startsWith(streamedText) ? text.slice(streamedText.length) : `\n\n${statusText}`);
} finally {
clearInterval(heartbeat);
writer.write({ type: "text-end", id: textId });
writer.write({ type: "finish" });
}
},
});
// Browser disconnection does not cancel paid image work. Keep consuming on the server.
const [client, background] = stream.tee();
after(async () => {
const reader = background.getReader();
try {
while (!(await reader.read()).done) {
/* Drain the server branch. */
}
} finally {
reader.releaseLock();
await archiveVideoResults?.();
}
});
return createUIMessageStreamResponse({
stream: client,
headers: { "Cache-Control": "no-store", "X-Accel-Buffering": "no" },
});
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error), message: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}

View File

@ -1,65 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
AgentRunError,
agentRunView,
assertAgentRunOwnership,
controlAgentRun,
getAgentRun,
} from "@/lib/ai/canvas-agent-runtime/store";
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
export const dynamic = "force-dynamic";
const scope = z.object({
chatId: z.string().uuid(),
projectId: z.string().uuid(),
runId: z.string().uuid().optional(),
});
const control = scope
.extend({
runId: z.string().uuid(),
action: z.enum(["steer", "cancel"]),
inputId: z.string().uuid().optional(),
text: z.string().trim().min(1).max(2000).optional(),
})
.refine(
(input) => input.action !== "steer" || Boolean(input.inputId && input.text),
"补充要求缺少内容或请求号",
);
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const input = scope.parse(Object.fromEntries(new URL(request.url).searchParams));
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
const run = await getAgentRun(session.user.id, input.chatId, input.projectId, input.runId);
return NextResponse.json(
{ run: run ? agentRunView(run) : null },
{ headers: { "Cache-Control": "private, no-store" } },
);
} catch (error) {
return failure(error);
}
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const input = control.parse(await request.json());
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
return NextResponse.json({ run: await controlAgentRun({ ...input, userId: session.user.id }) });
} catch (error) {
return failure(error);
}
}
function failure(error: unknown) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}

View File

@ -1,112 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { ffmpeg, mediaSafety, probeMedia } from "@/lib/video-canvas/smart-edit/local";
import {
remoteAudio,
remoteAudioConfig,
} from "@/lib/video-canvas/smart-edit/remote-audio";
export const runtime = "nodejs";
export const maxDuration = 600;
const running = new Set<string>();
const allowedStems = ["vocals", "background"] as const;
export async function POST(request: Request) {
const session = await auth();
const userId = session?.user?.id;
if (!userId)
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
const body = (await request.json().catch(() => null)) as {
url?: unknown;
stem?: unknown;
} | null;
const url = typeof body?.url === "string" ? body.url.trim() : "";
const stem = typeof body?.stem === "string" ? body.stem : "";
if (!/^https?:\/\//i.test(url) || !allowedStems.includes(stem as (typeof allowedStems)[number]))
return NextResponse.json({ success: false, error: "参数不完整" }, { status: 400 });
if (!remoteAudioConfig())
return NextResponse.json(
{ success: false, error: "音频分离服务未配置,请联系管理员" },
{ status: 503 }
);
const lockKey = `${userId}:${url}:${stem}`;
if (running.has(lockKey) || running.size >= 3)
return NextResponse.json(
{ success: false, error: "已有分离任务在进行,请稍候" },
{ status: 429 }
);
running.add(lockKey);
let dir = "";
try {
dir = await mkdtemp(path.join(tmpdir(), "canvas-separate-"));
const input = path.join(dir, "input");
const response = await fetch(url, {
signal: AbortSignal.timeout(180000),
cache: "no-store",
});
if (!response.ok) throw new Error("素材下载失败,请确认素材仍可访问");
const bytes = Buffer.from(await response.arrayBuffer());
if (!bytes.length || bytes.length > 150 * 1024 * 1024)
throw new Error("素材必须非空且不超过 125 MiB");
await writeFile(input, bytes);
const info = await probeMedia(input, 600);
if (!info.hasAudio) throw new Error("当前视频没有音轨");
const inputAudio = path.join(dir, "separation-input.wav");
const durationSeconds = Math.round(info.duration / 1e6);
await ffmpeg(
[
...mediaSafety,
"-i",
input,
"-vn",
"-t",
String(durationSeconds),
"-ac",
"2",
"-ar",
"44100",
"-c:a",
"pcm_f32le",
inputAudio,
],
undefined,
240000
);
const requestKey = createHash("sha256")
.update(`${url}:${stem}:${(await stat(inputAudio)).size}`)
.digest("hex");
await remoteAudio("separate", inputAudio, userId, requestKey, dir);
const stemFile = path.join(dir, `${stem}.wav`);
const wav = await readFile(stemFile);
if (wav.length < 1024) throw new Error("分离结果异常,请稍后重试");
const uploaded = await uploadFile(
`canvas-separate-${randomUUID()}.wav`,
await new Blob([wav]).arrayBuffer()
);
return NextResponse.json({
success: true,
data: {
url: uploaded.url,
duration: Math.round(info.duration / 1e6),
},
});
} catch (error) {
return NextResponse.json(
{
success: false,
error:
error instanceof Error ? error.message : "人声分离失败,原音轨已保留",
},
{ status: 500 }
);
} finally {
running.delete(lockKey);
if (dir) await rm(dir, { recursive: true, force: true });
}
}

View File

@ -1,24 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { listCanvasAudioModels } from "@/lib/video-canvas/audio-generation";
import { audioContract } from "@/lib/video-canvas/audio-contract";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const models = await listCanvasAudioModels();
return NextResponse.json(
{
models: models.map(({ model }) => ({
id: model.id,
modelId: model.modelId,
name: model.displayName,
...audioContract(model.modelId),
})),
},
{ headers: { "Cache-Control": "no-store" } },
);
} catch {
return NextResponse.json({ error: "音频模型读取失败,请稍后重试" }, { status: 503 });
}
}

View File

@ -1,266 +0,0 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getMessageById, getSystemSetting } from "@/lib/db/queries";
import {
normalizeOpenClawChatConfig,
OPENCLAW_CHAT_CONFIG_KEY,
} from "@/lib/openclaw-chat/config";
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 120;
const querySchema = z.object({
chatId: z.string().uuid(),
messageId: z.string().uuid(),
index: z.coerce.number().int().min(0).max(127),
download: z.enum(["0", "1"]).optional().default("0"),
});
const MAX_REDIRECTS = 4;
const RESPONSE_HEADERS = [
"accept-ranges",
"content-length",
"content-range",
"etag",
"last-modified",
] as const;
function isPrivateIpv4(address: string) {
const octets = address.split(".").map(Number);
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value))) {
return true;
}
const [a, b] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
a >= 224
);
}
function isPrivateIpAddress(address: string) {
if (isIP(address) === 4) return isPrivateIpv4(address);
if (isIP(address) !== 6) return true;
const normalized = address.toLowerCase();
if (normalized.startsWith("::ffff:")) {
return isPrivateIpv4(normalized.slice("::ffff:".length));
}
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb") ||
normalized.startsWith("ff")
);
}
async function validateTargetUrl(target: URL, trustedOrigin: string) {
if (target.username || target.password) return false;
if (target.origin === trustedOrigin) {
return target.protocol === "http:" || target.protocol === "https:";
}
if (target.protocol !== "https:") return false;
const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local")
) {
return false;
}
if (isIP(hostname)) return !isPrivateIpAddress(hostname);
const addresses = await lookup(hostname, { all: true, verbatim: true }).catch(
() => []
);
return (
addresses.length > 0 &&
addresses.every((entry) => !isPrivateIpAddress(entry.address))
);
}
function normalizeRangeHeader(value: string | null) {
const range = String(value || "").trim();
if (!range) return null;
const match = range.match(/^bytes=(\d*)-(\d*)$/);
if (!match || (!match[1] && !match[2])) return "";
if (match[1] && match[2] && Number(match[1]) > Number(match[2])) return "";
return range;
}
function inferContentType(filename: string, declaredType: string) {
if (declaredType) return declaredType;
const lower = filename.toLowerCase();
if (lower.endsWith(".glb")) return "model/gltf-binary";
if (lower.endsWith(".gltf")) return "model/gltf+json";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
return "application/octet-stream";
}
function safeDownloadName(value: string) {
const normalized = value.replace(/[\r\n"\\/]/g, "_").trim() || "artifact";
return normalized.slice(0, 180);
}
async function fetchArtifact(params: {
request: Request;
initialUrl: URL;
method: "GET" | "HEAD";
range: string | null;
token: string;
trustedOrigin: string;
}) {
let target = params.initialUrl;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
if (!(await validateTargetUrl(target, params.trustedOrigin))) {
throw new Error("不允许访问该文件地址");
}
const headers = new Headers({ Accept: "*/*" });
if (params.range) headers.set("Range", params.range);
if (target.origin === params.trustedOrigin && params.token) {
headers.set("Authorization", `Bearer ${params.token}`);
}
const response = await fetch(target, {
method: params.method,
headers,
cache: "no-store",
redirect: "manual",
signal: params.request.signal,
});
if (![301, 302, 303, 307, 308].includes(response.status)) {
return { response, target };
}
if (redirects === MAX_REDIRECTS) throw new Error("文件重定向次数过多");
const location = response.headers.get("location");
if (!location) throw new Error("文件重定向地址无效");
target = new URL(location, target);
}
throw new Error("文件地址无效");
}
async function handleRequest(request: Request, method: "GET" | "HEAD") {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const url = new URL(request.url);
const parsed = querySchema.safeParse({
chatId: url.searchParams.get("chatId"),
messageId: url.searchParams.get("messageId"),
index: url.searchParams.get("index"),
download: url.searchParams.get("download") || undefined,
});
if (!parsed.success) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
const chat = await getChatById({ id: parsed.data.chatId }).catch(() => null);
if (!chat || chat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const rows = await getMessageById({ id: parsed.data.messageId }).catch(
() => []
);
const message = rows.find((row) => row.chatId === parsed.data.chatId);
const metadata = normalizeClawdTaskMetadata(message?.videoMetadata);
const artifact = metadata?.artifacts?.[parsed.data.index];
if (!message || !artifact) {
return NextResponse.json({ error: "文件不存在" }, { status: 404 });
}
const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch(
() => null
);
const config = normalizeOpenClawChatConfig(setting?.value);
let target: URL;
let trustedOrigin: string;
try {
target = new URL(artifact.downloadUrl);
trustedOrigin = new URL(config.baseUrl).origin;
} catch {
return NextResponse.json({ error: "文件地址无效" }, { status: 400 });
}
const range = normalizeRangeHeader(request.headers.get("range"));
if (range === "") {
return NextResponse.json({ error: "Range 请求格式无效" }, { status: 400 });
}
try {
const { response, target: finalTarget } = await fetchArtifact({
request,
initialUrl: target,
method,
range,
token: config.token,
trustedOrigin,
});
if (!response.ok && response.status !== 206) {
return NextResponse.json(
{ error: `文件读取失败(HTTP ${response.status})` },
{ status: response.status >= 400 && response.status < 600 ? response.status : 502 }
);
}
const headers = new Headers();
for (const name of RESPONSE_HEADERS) {
const value = response.headers.get(name);
if (value) headers.set(name, value);
}
headers.set(
"content-type",
response.headers.get("content-type") ||
inferContentType(artifact.filename || finalTarget.pathname, artifact.mimeType)
);
headers.set("cache-control", "private, max-age=3600, must-revalidate");
headers.set("x-content-type-options", "nosniff");
if (parsed.data.download === "1") {
const filename = safeDownloadName(artifact.filename || artifact.displayName);
headers.set(
"content-disposition",
`attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
);
}
return new Response(method === "HEAD" ? null : response.body, {
status: response.status,
headers,
});
} catch (error) {
console.warn("[Clawd Artifact] Proxy failed:", {
chatId: parsed.data.chatId,
messageId: parsed.data.messageId,
artifactIndex: parsed.data.index,
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: error instanceof Error ? error.message : "文件读取失败" },
{ status: 502 }
);
}
}
export async function GET(request: Request) {
return handleRequest(request, "GET");
}
export async function HEAD(request: Request) {
return handleRequest(request, "HEAD");
}

View File

@ -1,92 +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,
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(),
userMessageId: z.string().uuid(),
});
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 }
);
}
const parsed = cancelSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
const existingChat = await getChatById({ id: parsed.data.id }).catch(() => null);
if (existingChat && existingChat.userId !== session.user.id) {
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 deleted = await deletePersistedClawdTask({
chatId: parsed.data.id,
userMessageId: parsed.data.userMessageId,
}).catch(() => []);
return NextResponse.json({
canceled: true,
active,
deleted: deleted.length > 0,
});
}

View File

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

View File

@ -1,52 +0,0 @@
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;
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 limit = Math.max(
1,
Math.min(500, Math.round(Number(url.searchParams.get("limit") || 100)))
);
const chats = await getClawdChatsByUserId({
userId: session.user.id,
limit,
});
return NextResponse.json(
{
chats: chats.map((chat) => ({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt?.toISOString?.() || chat.createdAt,
updatedAt: chat.updatedAt?.toISOString?.() || chat.updatedAt,
messageCount: Number(chat.messageCount || 0),
})),
},
{
headers: {
"Cache-Control": "no-store, max-age=0",
},
}
);
}

View File

@ -1,664 +0,0 @@
import { after, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
getChatById,
getSystemSetting,
getUserById,
saveChat,
} from "@/lib/db/queries";
import {
buildClawdCancelKey,
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 { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts";
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 requestSchema = z.object({
id: z.string().uuid(),
message: z.string().trim().max(8000).optional().default(""),
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[];
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 对话";
return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized;
}
function createConversationId(userId: string, chatId: string) {
return `site-user-${userId}-canvas-${chatId}`;
}
function getOpenClawUserName(user: {
id: string;
name?: string | null;
email?: string | null;
}) {
return String(user.name || user.email || user.id).trim();
}
function getMetadataString(
metadata: Record<string, unknown> | undefined,
key: string
) {
const value = metadata?.[key];
return typeof value === "string" ? value.trim() : "";
}
function isRequestFile(value: FormDataEntryValue): value is File {
return (
typeof value === "object" &&
value !== null &&
"arrayBuffer" in value &&
"name" in value &&
"size" in value
);
}
async function parseClawdChatRequest(
request: Request
): Promise<ParsedClawdChatRequest | null> {
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: [] };
}
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()) {
const parsedMetadata = JSON.parse(metadataValue);
if (!parsedMetadata || typeof parsedMetadata !== "object") return null;
metadata = parsedMetadata as Record<string, unknown>;
}
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({
id: formData.get("id"),
message: formData.get("message") || "",
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),
};
}
async function readOpenClawConfig() {
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 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;
}
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: !params.sensitiveInput,
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),
artifacts: extractOpenClawArtifacts(payload, {
baseUrl: params.config.baseUrl,
}),
});
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,
artifacts: outcome.artifacts,
});
} else if (outcome.status === "canceled") {
await markClawdTaskCanceled({
...target,
message: outcome.error,
});
} else if (outcome.status === "failed") {
await markClawdTaskFailed({
...target,
error: outcome.error,
});
}
clearClawdRequestCancellation(params.cancelKey);
} catch (error) {
if (isClawdRequestCanceled(params.cancelKey)) return;
if (error instanceof OpenClawTaskDetachedError) {
await appendPersistedClawdTaskProgress({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
progress: {
step_code: "task.detached",
step_state: "waiting",
message: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
display: "任务仍在 OpenClaw 后台执行,打开原对话后会自动续接结果",
},
}).catch(() => null);
return;
}
console.error("[ClawdChat API] Durable task failed:", error);
await markClawdTaskFailed({
chatId: params.chatId,
assistantMessageId: params.assistantMessageId,
error: getOpenClawTaskFailureMessage(error),
}).catch(() => null);
} finally {
if (params.sensitiveInput?.type === "taobao_credentials") {
params.sensitiveInput.username = "";
params.sensitiveInput.password = "";
} else if (params.sensitiveInput?.type === "taobao_verification") {
params.sensitiveInput.verification_code = "";
}
params.sensitiveInput = undefined;
unregisterActiveClawdRequest(params.cancelKey);
}
}
function buildTaskResponse(params: {
chatId: string;
userMessageId: string;
assistantMessageId: string;
status: string;
processingHint: string;
}) {
const statusUrl = `/api/canvas/clawd-chat/status?id=${encodeURIComponent(
params.chatId
)}&userMessageId=${encodeURIComponent(params.userMessageId)}`;
return {
accepted: true,
task: {
chatId: params.chatId,
userMessageId: params.userMessageId,
assistantMessageId: params.assistantMessageId,
status: params.status,
processingHint: params.processingHint,
statusUrl,
},
};
}
export async function POST(request: Request) {
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 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 }
);
}
const config = await readOpenClawConfig();
if (!config.enabled || !config.baseUrl || !config.token) {
return NextResponse.json(
{ error: OPENCLAW_UPGRADE_RETRY_MESSAGE },
{ status: 503 }
);
}
const existingChat = await getChatById({ id: parsed.id }).catch(() => null);
if (existingChat && existingChat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const userMessageId = parsed.userMessageId || generateUUID();
const existingTask = await getPersistedClawdTask({
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" } }
);
}
let spreadsheetMarkdownResult: Awaited<
ReturnType<typeof buildOpenClawSpreadsheetMarkdown>
> | null = null;
if (parsed.tableFiles.length > 0) {
try {
spreadsheetMarkdownResult = await buildOpenClawSpreadsheetMarkdown(
parsed.tableFiles
);
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "表格解析失败,请检查文件格式",
},
{ status: 400 }
);
}
}
const displayMessage = buildOpenClawDisplayMessage({
message: parsed.displayMessage || parsed.message,
files: spreadsheetMarkdownResult?.files || [],
});
const upstreamMessage = buildOpenClawMessageWithSpreadsheetMarkdown({
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 (!existingChat) {
await saveChat({
id: parsed.id,
userId: session.user.id,
title: buildChatTitle(displayMessage),
visibility: "private",
});
}
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"),
}));
const persistedTask = await persistPendingClawdTask({
chatId: parsed.id,
userMessageId,
assistantMessageId,
displayMessage,
attachments: messageAttachments,
spreadsheetAttachments,
skillTagId,
skillTagName,
processingHint,
});
if (
persistedTask &&
persistedTask.assistantMessageId !== assistantMessageId
) {
return NextResponse.json(
buildTaskResponse({
chatId: parsed.id,
userMessageId,
assistantMessageId: persistedTask.assistantMessageId,
status: persistedTask.status,
processingHint:
persistedTask.processingHint || DEFAULT_PROCESSING_HINT,
}),
{ status: 202, headers: { "Retry-After": "2" } }
);
}
const userProfile = await getUserById({ id: session.user.id }).catch(
() => null
);
const userName = getOpenClawUserName({
id: session.user.id,
name: userProfile?.name ?? session.user.name,
email: userProfile?.email ?? session.user.email,
});
const cancelKey = buildClawdCancelKey({
userId: session.user.id,
chatId: parsed.id,
userMessageId,
});
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,
});
});
const responseBody = buildTaskResponse({
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",
},
});
}

View File

@ -1,191 +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,
artifacts: outcome.artifacts,
});
} 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",
},
}
);
}

View File

@ -49,10 +49,6 @@ const DETAIL_PLAN_INLINE_RETRIES = 1;
const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000; const DETAIL_PLAN_MAIN_TIMEOUT_MS = 45000;
const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000; const DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS = 10000;
const logger = createLogger("canvas-detail-plan"); const logger = createLogger("canvas-detail-plan");
const DETAIL_MODEL_USAGE_HINT_PATTERN =
/模特|人物|人像|真人|人体|露脸|人脸|面部|发型|体型|肤色|穿搭|服装|试坐|试用|手持|拿着|自拍|合照|真人演示|人物场景|场景演示|使用者|顾客.{0,8}使用|model|person|people|human|face|hair|body|outfit|wearing|holding|selfie|lifestyle\s+demo/i;
const DETAIL_NO_MODEL_HINT_PATTERN =
/不要出现人物|不出现人物|不要有人物|无人|无人物|只展示产品|产品信息展示|不使用人物|不使用模特|禁止出现人物|no\s+(human|person|people)|without\s+(human|person|people)|product[-\s]?only/i;
function isAbortError(error: unknown): boolean { function isAbortError(error: unknown): boolean {
const target = error as { const target = error as {
@ -275,77 +271,6 @@ type DetailPlanResponse = {
}; };
}; };
function buildFixedModelIdentityInstruction(language: "zh" | "en") {
return language === "en"
? "Fixed model identity lock: if this frame includes a person, use only the same person from the uploaded model image. Keep face, hairstyle, body type, skin tone, and outfit style consistent; do not replace with another person. Pose and camera angle may adapt to the composition."
: "固定模特身份硬约束:如果本屏出现人物,只允许使用上传模特图中的同一人物;脸型五官、发型发色、体型肤色和穿搭风格必须保持一致,不得换成其他人。姿势、视线和机位可按构图调整。";
}
function screenSuggestsModelUsage(screen: DetailPlanScreen) {
const text = [
screen.objective,
screen.title,
screen.subtitle,
screen.copy,
screen.layoutNote,
screen.modelReason,
screen.visualPrompt,
screen.fabe?.b,
screen.fabe?.f,
screen.fabe?.a,
screen.fabe?.e,
]
.filter(Boolean)
.join(" ");
return (
DETAIL_MODEL_USAGE_HINT_PATTERN.test(text) &&
!DETAIL_NO_MODEL_HINT_PATTERN.test(text)
);
}
function normalizeDetailModelUsage(params: {
screens: DetailPlanScreen[];
hasModelImage: boolean;
language: "zh" | "en";
}) {
const { screens, hasModelImage, language } = params;
const identityInstruction = buildFixedModelIdentityInstruction(language);
return screens.map((screen) => {
if (!hasModelImage) {
return {
...screen,
useModel: false,
modelReason:
language === "en"
? "No model image was provided, so this frame does not use a fixed model."
: "未提供模特图,当前分镜不使用人物。",
};
}
const shouldUseModel = screen.useModel || screenSuggestsModelUsage(screen);
if (!shouldUseModel) {
return screen;
}
const modelReason =
screen.modelReason && !DETAIL_NO_MODEL_HINT_PATTERN.test(screen.modelReason)
? screen.modelReason
: language === "en"
? "This frame contains a human/model scenario, so the uploaded fixed model must be used."
: "本屏包含人物/模特展示语义,必须使用上传的固定模特图。";
const visualPrompt = screen.visualPrompt.includes(identityInstruction)
? screen.visualPrompt
: `${screen.visualPrompt} ${identityInstruction}`;
return {
...screen,
useModel: true,
modelReason,
visualPrompt,
};
});
}
type DetailPlanPreflight = { type DetailPlanPreflight = {
passed: boolean; passed: boolean;
designDirectorScore: number; designDirectorScore: number;
@ -1305,13 +1230,9 @@ function buildFallbackPlan(params: {
...params, ...params,
globalStyle, globalStyle,
}); });
const screens = normalizeDetailModelUsage({ const screens = enforceCoreFocusOnScreens({
screens: enforceCoreFocusOnScreens({ screens: fallbackScreens,
screens: fallbackScreens, coreFocus: params.coreFocus,
coreFocus: params.coreFocus,
language,
}),
hasModelImage: params.hasModelImage,
language, language,
}); });
const qualityChecks = evaluatePlanQuality({ const qualityChecks = evaluatePlanQuality({
@ -1956,7 +1877,7 @@ export async function POST(request: Request) {
(item) => (item) =>
!item.model.isSubModel && !item.model.isSubModel &&
item.model.isEnabled && item.model.isEnabled &&
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type), ["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
); );
const configuredDetailAgentConfig = const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId && currentSiteSettings?.canvasDetailAgentModelId &&
@ -2260,28 +2181,27 @@ export async function POST(request: Request) {
dslV2Screens?: unknown; dslV2Screens?: unknown;
}>(text || ""); }>(text || "");
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle); const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language); const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) =>
const normalizedScreensWithoutBundles = rawScreens.map((item) => hasModelImage
? item
: {
...item,
useModel: false,
modelReason: "未提供模特图,当前分镜不使用人物。",
}
);
const normalizedScreens = rawScreens.map((item) =>
hasBundleImages hasBundleImages
? item ? item
: { : {
...item, ...item,
bundleShowcase: false, bundleShowcase: false,
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
} }
); );
const normalizedScreens = normalizeDetailModelUsage({ const coreFocusedScreens = enforceCoreFocusOnScreens({
screens: normalizedScreensWithoutBundles, screens: normalizedScreens,
hasModelImage, coreFocus,
language,
});
const coreFocusedScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: normalizedScreens,
coreFocus,
language,
}),
hasModelImage,
language, language,
}); });
const qualityChecksInitial = evaluatePlanQuality({ const qualityChecksInitial = evaluatePlanQuality({
@ -2305,7 +2225,7 @@ export async function POST(request: Request) {
hasBundleImages, hasBundleImages,
}); });
const preflightScreensWithoutBundles = preflight.candidateScreens.map((item) => const preflightScreensRaw = preflight.candidateScreens.map((item) =>
hasBundleImages hasBundleImages
? item ? item
: { : {
@ -2314,18 +2234,9 @@ export async function POST(request: Request) {
bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。",
} }
); );
const preflightScreensRaw = normalizeDetailModelUsage({ const preflightScreens = enforceCoreFocusOnScreens({
screens: preflightScreensWithoutBundles, screens: preflightScreensRaw,
hasModelImage, coreFocus,
language,
});
const preflightScreens = normalizeDetailModelUsage({
screens: enforceCoreFocusOnScreens({
screens: preflightScreensRaw,
coreFocus,
language,
}),
hasModelImage,
language, language,
}); });
const qualityChecksAfterPreflight = evaluatePlanQuality({ const qualityChecksAfterPreflight = evaluatePlanQuality({

View File

@ -204,7 +204,7 @@ export async function POST(request: Request) {
(item) => (item) =>
!item.model.isSubModel && !item.model.isSubModel &&
item.model.isEnabled && item.model.isEnabled &&
["zhipu", "alibaba", "openai", "custom", "local", "vip98k"].includes(item.provider.type), ["zhipu", "alibaba", "openai", "custom", "local"].includes(item.provider.type),
); );
const configuredDetailAgentConfig = const configuredDetailAgentConfig =
currentSiteSettings?.canvasDetailAgentModelId && currentSiteSettings?.canvasDetailAgentModelId &&

View File

@ -1,45 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import {
DIRECTOR_MAX_GLB_BYTES,
validateDirectorGlb,
} from "@/lib/video-canvas/director-glb";
export const runtime = "nodejs";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const form = await request.formData();
const file = form.get("file");
if (
!(file instanceof File) ||
!/\.glb$/i.test(file.name) ||
file.size > DIRECTOR_MAX_GLB_BYTES
)
return NextResponse.json(
{ error: "请上传 25 MiB 以内的 GLB 模型" },
{ status: 400 },
);
const buffer = await file.arrayBuffer();
try {
validateDirectorGlb(buffer);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "无效 GLB" },
{ status: 400 },
);
}
const result = await uploadFile(
`director-${crypto.randomUUID()}.glb`,
buffer,
);
return NextResponse.json({ success: true, data: result });
} catch {
return NextResponse.json(
{ error: "模型上传失败,请重试" },
{ status: 500 },
);
}
}

View File

@ -1,160 +0,0 @@
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
import { after, NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
import {
buildAttachmentContentDisposition,
fetchCanvasDownloadAsset,
sanitizeCanvasDownloadFileName,
} from "@/lib/canvas/photoshop-safe-download";
export const runtime = "nodejs";
export const maxDuration = 120;
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
// A proxy candidate for the same original must not repeat the same slow
// upstream request through our own server (and create another HTTP timeout).
const sources = [...new Set(input.urls.map(url => {
const source = extractImageProxySourceUrl(url) || url;
return inferOriginalImageUrl(source) || source;
}))];
for (let attempt = 0; attempt < 3; attempt++) {
signal.throwIfAborted();
for (const url of sources) {
signal.throwIfAborted();
const result = await fetchCanvasDownloadAsset({
file: { url, name: "original" }, index: 0,
requestOrigin: input.requestOrigin, cookie: input.cookie,
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
});
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
}
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
// No AI/model request is involved and a completed source fetch is shared.
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
}
throw new Error("Original image sources unavailable");
}
function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
if (forwardedHost && forwardedProto) {
return `${forwardedProto}://${forwardedHost}`;
}
return new URL(request.url).origin;
}
function resolveDownloadUrl(rawUrl: unknown, origin: string): string {
const value = String(rawUrl || "").trim();
if (!value) return "";
try {
const parsed = new URL(value, origin);
if (!/^https?:$/i.test(parsed.protocol)) return "";
return parsed.toString();
} catch (_error) {
return "";
}
}
function resolveDownloadUrls(body: Record<string, unknown>, origin: string): string[] {
const rawCandidates = Array.isArray(body.urls) ? body.urls : [body.url];
const urls: string[] = [];
const seen = new Set<string>();
for (const rawUrl of rawCandidates) {
const url = resolveDownloadUrl(rawUrl, origin);
if (!url || seen.has(url)) continue;
seen.add(url);
urls.push(url);
}
return urls.slice(0, 6);
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request);
const urls = resolveDownloadUrls(body, requestOrigin);
const requestedUrls = [...urls];
if (urls.length === 0) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
}
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
if (body.preferDirect === true) {
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
if (task?.targetUrl) {
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
after(() => prepareImageArchiveDownload(task.id));
}
}
const cookie = request.headers.get("cookie") || "";
if (body.preparation === "async") {
const result = await getDownloadPreparationQueue(prepareDownload, after).request({
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
});
if (result.status === "preparing") {
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
message: "正在准备原图,完成后将自动下载" }, {
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
});
}
if (result.status === "failed") {
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
}
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
"Content-Type": result.asset.contentType,
"Content-Length": String(result.asset.buffer.length),
"Content-Disposition": buildAttachmentContentDisposition(`${name}.png`),
"Cache-Control": "no-store",
} });
}
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
const failures: string[] = [];
for (const url of urls) {
result = await fetchCanvasDownloadAsset({
file: { url, name },
index: 0,
requestOrigin,
cookie,
includeSequencePrefix: false,
});
if (result.ok) break;
failures.push(`${url}: ${result.error}`);
}
if (!result?.ok) {
if (failures.length > 0) {
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
}
return NextResponse.json(
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
{ status: 502 }
);
}
return new NextResponse(result.bytes, {
status: 200,
headers: {
"Content-Type": result.contentType,
"Content-Length": String(result.bytes.length),
"Content-Disposition": buildAttachmentContentDisposition(result.filename),
"Cache-Control": "no-store",
},
});
} catch (error) {
console.error("[CanvasDownloadImage] Failed:", error);
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
}
}

View File

@ -1,238 +0,0 @@
import { writeDownloadZip, readDownloadZip } from "@/lib/canvas/download-zip-stream";
import { claimZipTask, loadZipTask, saveZipTask, zipTaskPath, assertZipDiskSpace, cleanupZipTasks, assertZipTaskCapacity, type ZipTask } from "@/lib/canvas/download-zip-task-store";
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
import { createReadStream } from "node:fs";
import { mkdir, stat, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { after, NextRequest, NextResponse } from "next/server";
import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
export const runtime = "nodejs";
export const maxDuration = 300;
type DownloadFileItem = { url: string; name?: string };
const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>();
function sanitizeFolderName(value: string): string {
const cleaned = String(value || "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.trim();
return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`;
}
function buildContentDisposition(folderName: string): string {
const encodedName = encodeURIComponent(`${folderName}.zip`);
const asciiName =
folderName
.replace(/[^\x20-\x7E]+/g, "")
.replace(/"/g, "")
.trim() || "canvas-images";
return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`;
}
function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "")
.split(",")[0]
?.trim();
const forwardedProto = (request.headers.get("x-forwarded-proto") || "")
.split(",")[0]
?.trim();
if (forwardedHost && forwardedProto) {
return `${forwardedProto}://${forwardedHost}`;
}
return new URL(request.url).origin;
}
function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
if (!Array.isArray(input)) return [];
return input
.map((item) => {
const raw = (item || {}) as Record<string, unknown>;
const rawUrl = String(raw.url || "").trim();
if (!rawUrl) return null;
let resolvedUrl = "";
try {
const parsed = new URL(rawUrl, origin);
if (!/^https?:$/i.test(parsed.protocol)) return null;
resolvedUrl = parsed.toString();
} catch (_error) {
return null;
}
const name = String(raw.name || "").trim();
const nextItem: DownloadFileItem = { url: resolvedUrl };
if (name) {
nextItem.name = name;
}
return nextItem;
})
.filter((item): item is DownloadFileItem => item !== null)
.slice(0, MAX_DOWNLOAD_FILES);
}
function serializeTask(task: ZipTask) {
return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage,
totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount,
failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99,
Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)),
error: task.error || "", expiresAt: task.expiresAt,
downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" };
}
async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) {
if (active.has(id) || active.size >= 2) return;
active.add(id);
let release: (() => Promise<void>) | null = null;
let task: ZipTask | null = null;
try {
release = await claimZipTask(id);
if (!release) return;
task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace();
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
await saveZipTask(task);
// Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session.
let cursor = 0;
let writes = Promise.resolve();
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
while (cursor < task!.files.length) {
const index = cursor++; const file = task!.files[index];
const path = join(zipTaskPath(id), `${index}.image`);
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
delete file.prepared; delete file.filename;
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
if (result.ok) {
await assertZipDiskSpace(result.bytes.length);
await writeFile(path, result.bytes, { mode: 0o600 });
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
} else file.error = result.error;
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
task!.successCount = task!.files.filter(f => f.prepared).length;
task!.failedCount = task!.files.filter(f => f.error).length;
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
task!.updatedAt = Date.now(); await persist();
}
}));
const rejected = results.find(result => result.status === "rejected");
if (rejected?.status === "rejected") throw rejected.reason;
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
for (const file of task.files) if (file.prepared && file.filename)
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName);
task.stage = "正在准备下载链接"; await saveZipTask(task);
try {
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (cdn.url) task.cdnUrl = cdn.url;
}
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes.
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
} catch (error) {
if (task) {
task.status = "failed"; task.stage = "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
}
console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error });
} finally { await release?.().catch(() => undefined); active.delete(id); }
}
function schedule(task: ZipTask, request: NextRequest) {
const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
after(run);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = await request.json();
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
let task = await loadZipTask(id, session.user.id);
if (!task) {
await assertZipTaskCapacity(session.user.id);
if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
const files = toDownloadItems(body.files, getRequestOrigin(request));
if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length)
return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 });
// Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest.
await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
if (error.code !== "ENOENT") throw error;
await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
await mkdir(zipTaskPath(id), { mode: 0o700 });
});
const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
await saveZipTask(task);
} else if (task.status === "failed" && body.retry === true) {
task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id);
for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
await saveZipTask(task);
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
} catch (error) {
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });
return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 });
}
}
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const id = request.nextUrl.searchParams.get("taskId") || "";
const task = await loadZipTask(id, session.user.id);
if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 });
if (request.nextUrl.searchParams.get("download") === "1") {
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 });
if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1")
return NextResponse.redirect(task.cdnUrl, 307);
const info = await stat(task.zipFile.path).catch(() => null);
if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 });
const etag = `"${task.id}-${info.size}"`;
const headers: Record<string, string> = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!,
"Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store",
"X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) };
const ifRange = request.headers.get("if-range");
let range;
try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); }
catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); }
headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size);
if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`;
return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers });
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
} catch {
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
}
}

View File

@ -1,8 +1,6 @@
import { streamDownloadZip } from "@/lib/canvas/download-zip-stream";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import JSZip from "jszip"; import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 300; export const maxDuration = 300;
@ -12,8 +10,40 @@ type DownloadFileItem = {
name?: string; name?: string;
}; };
const FETCH_TIMEOUT_MS = 30000;
const FETCH_RETRY_COUNT = 3;
const MAX_CONCURRENCY = 8; const MAX_CONCURRENCY = 8;
const MAX_DOWNLOAD_FILES = 200; const MAX_DOWNLOAD_FILES = 200;
const RETRYABLE_STATUS_CODES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
function sanitizeFileName(value: string, fallback: string): string {
const cleaned = String(value || "")
.replace(/\.[a-zA-Z0-9]{2,6}$/g, "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.trim();
return cleaned || fallback;
}
function inferExt(url: string, contentType: string): string {
const type = String(contentType || "").toLowerCase();
if (type.includes("jpeg")) return "jpg";
if (type.includes("png")) return "png";
if (type.includes("webp")) return "webp";
if (type.includes("gif")) return "gif";
if (type.includes("bmp")) return "bmp";
if (type.includes("tiff")) return "tiff";
if (type.includes("mp4")) return "mp4";
if (type.includes("webm")) return "webm";
if (type.includes("quicktime")) return "mov";
const cleanUrl = String(url || "").split("?")[0] || "";
const matched = cleanUrl.match(/\.([a-zA-Z0-9]{2,6})$/);
if (matched?.[1]) return matched[1].toLowerCase();
return "png";
}
function sanitizeFolderName(value: string): string { function sanitizeFolderName(value: string): string {
const cleaned = String(value || "") const cleaned = String(value || "")
@ -35,6 +65,23 @@ function buildContentDisposition(folderName: string): string {
return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`; return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`;
} }
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
function getRequestOrigin(request: NextRequest): string { function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim(); const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
@ -69,6 +116,94 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
.slice(0, MAX_DOWNLOAD_FILES); .slice(0, MAX_DOWNLOAD_FILES);
} }
function buildRemoteFetchHeaders(fileUrl: string, requestOrigin: string, cookie: string): HeadersInit {
const headers: Record<string, string> = {
Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (compatible; BuyiCanvasDownloader/1.0; +https://buyi.ai)",
Referer: requestOrigin,
};
try {
const targetOrigin = new URL(fileUrl).origin;
if (targetOrigin === requestOrigin && cookie) {
headers.cookie = cookie;
}
} catch (_error) {
// URL has already been normalized before this point; keep this defensive.
}
return headers;
}
function normalizeDownloadError(error: unknown): string {
if (error instanceof Error) return error.message || "download failed";
if (typeof error === "string") return error;
return "download failed";
}
async function fetchDownloadFile(params: {
file: DownloadFileItem;
index: number;
requestOrigin: string;
cookie: string;
}): Promise<
| { ok: true; filename: string; bytes: Buffer }
| { ok: false; url: string; name?: string; error: string }
> {
let lastError = "";
for (let attempt = 1; attempt <= FETCH_RETRY_COUNT; attempt += 1) {
try {
const response = await fetchWithTimeout(
params.file.url,
{
method: "GET",
cache: "no-store",
redirect: "follow",
headers: buildRemoteFetchHeaders(params.file.url, params.requestOrigin, params.cookie),
},
FETCH_TIMEOUT_MS
);
if (!response.ok) {
lastError = `HTTP ${response.status}`;
if (attempt < FETCH_RETRY_COUNT && RETRYABLE_STATUS_CODES.has(response.status)) {
await sleep(350 * attempt);
continue;
}
break;
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length === 0) {
throw new Error("empty response");
}
const ext = inferExt(params.file.url, response.headers.get("content-type") || "");
const baseName = sanitizeFileName(
params.file.name || `canvas-image-${params.index + 1}`,
`canvas-image-${params.index + 1}`
);
const sequencePrefix = String(params.index + 1).padStart(3, "0");
const filename = `${sequencePrefix}-${baseName}.${ext}`;
return { ok: true, filename, bytes };
} catch (error) {
lastError = normalizeDownloadError(error);
if (attempt < FETCH_RETRY_COUNT) {
await sleep(350 * attempt);
}
}
}
return {
ok: false,
url: params.file.url,
name: params.file.name,
error: lastError || "图片下载失败",
};
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const session = await auth(); const session = await auth();
@ -103,7 +238,7 @@ export async function POST(request: NextRequest) {
const folder = zip.folder(folderName) || zip; const folder = zip.folder(folderName) || zip;
const cookie = request.headers.get("cookie") || ""; const cookie = request.headers.get("cookie") || "";
const results: Array< const results: Array<
| { ok: true; filename: string; bytes: Buffer; contentType: string } | { ok: true; filename: string; bytes: Buffer }
| { ok: false; url: string; name?: string; error: string } | { ok: false; url: string; name?: string; error: string }
| null | null
> = new Array(files.length).fill(null); > = new Array(files.length).fill(null);
@ -117,7 +252,7 @@ export async function POST(request: NextRequest) {
cursor += 1; cursor += 1;
if (index >= files.length) break; if (index >= files.length) break;
const file = files[index]!; const file = files[index]!;
results[index] = await fetchCanvasDownloadAsset({ results[index] = await fetchDownloadFile({
file, file,
index, index,
requestOrigin, requestOrigin,
@ -160,7 +295,13 @@ export async function POST(request: NextRequest) {
console.warn("[CanvasDownloadZip] Partial download failures:", failures); console.warn("[CanvasDownloadZip] Partial download failures:", failures);
} }
return new NextResponse(streamDownloadZip(zip), { const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
return new NextResponse(zipBuffer, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/zip", "Content-Type": "application/zip",

View File

@ -1,9 +1,61 @@
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue"; import {
createCanvasImageTask,
enqueueCanvasImageTask,
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue";
import {
generateCanvasImage,
type CanvasGenerateImageRequest,
} from "@/lib/canvas/generate-image";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import {
getImageToolCreditCost,
type ImageToolCreditFeature,
} from "@/lib/points/config";
export const maxDuration = 1200; export const maxDuration = 120;
interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean;
}
function normalizeBillingFeature(
value: unknown
): Extract<ImageToolCreditFeature, "multi-view" | "repaint"> | null {
if (value === "multi-view" || value === "repaint") return value;
return null;
}
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
return {
prompt: String(body.prompt || "").trim(),
negativePrompt:
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
model: typeof body.model === "string" ? body.model : undefined,
aspectRatio:
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
? body.aspectRatio.trim()
: "auto",
imageSize:
typeof body.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: undefined,
imageCount:
typeof body.imageCount === "number" && Number.isFinite(body.imageCount)
? body.imageCount
: 1,
promptExtend:
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
referenceImage:
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
referenceImages: Array.isArray(body.referenceImages)
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
: [],
};
}
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
@ -15,7 +67,10 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const taskId = String(searchParams.get("taskId") || "").trim(); const taskId = String(searchParams.get("taskId") || "").trim();
if (!taskId) { if (!taskId) {
return NextResponse.json({ success: false, error: "缺少 taskId 参数" }, { status: 400 }); return NextResponse.json(
{ success: false, error: "缺少 taskId 参数" },
{ status: 400 }
);
} }
const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id }); const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
@ -44,15 +99,105 @@ export async function GET(request: Request) {
success: false, success: false,
error: error instanceof Error ? error.message : "查询任务失败", error: error instanceof Error ? error.message : "查询任务失败",
}, },
{ status: 500 }, { status: 500 }
); );
} }
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await auth(); try {
if (!session?.user?.id) const session = await auth();
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); if (!session?.user?.id) {
const body = await request.json().catch(() => ({})); return NextResponse.json(
return executeCanvasImageResponse(body, session.user.id); { success: false, error: "未登录" },
{ status: 401 }
);
}
const body: GenerateImageRequest = await request.json();
const payload: CanvasGenerateImageRequest = {
...toGenerateImagePayload(body),
ownerKey: session.user.id,
};
if (!payload.prompt) {
return NextResponse.json(
{ success: false, error: "请输入提示词" },
{ status: 400 }
);
}
const billingFeature = normalizeBillingFeature(body.billingFeature);
const pointsCharge = billingFeature
? await (async () => {
const pointsConfig = await getPointsSystemConfig();
return prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, billingFeature),
reason:
billingFeature === "multi-view"
? "image_multi_view"
: "image_repaint",
modelId: "canvas-generate-image",
note:
billingFeature === "multi-view"
? "图片多视角生成消耗积分"
: "图片重绘消耗积分",
pointsConfig,
});
})()
: null;
if (pointsCharge && !pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const asyncMode = body.async === true;
if (asyncMode) {
const task = await createCanvasImageTask({
userId: session.user.id,
request: payload as unknown as Record<string, unknown>,
});
await enqueueCanvasImageTask(task.id);
const currentTask = await getCanvasImageTaskForUser({
taskId: task.id,
userId: session.user.id,
});
if (pointsCharge?.chargeable) {
try {
await pointsCharge.consume({
note:
billingFeature === "multi-view"
? `图片多视角生成消耗积分(${task.id})`
: `图片重绘消耗积分(${task.id})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume canvas image task points:", pointsError);
}
}
return NextResponse.json({
success: true,
taskId: task.id,
status: currentTask?.status || task.status,
queueStatus: currentTask?.queueStatus,
});
}
const result = await generateCanvasImage(payload);
if (pointsCharge?.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume canvas image points:", pointsError);
}
}
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
console.error("[Canvas Generate Image] Error:", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "生成失败" },
{ status: 500 }
);
}
} }

Some files were not shown because too many files have changed in this diff Show More