Compare commits
26 Commits
main
...
codex/yedi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6937217634 | ||
|
|
84f105d4c8 | ||
|
|
824806406a | ||
|
|
6b72771701 | ||
|
|
97cc9e4a0a | ||
|
|
154ec44a4c | ||
|
|
2f0c533b87 | ||
|
|
3ac209d149 | ||
|
|
97d7604e7c | ||
|
|
641cd800b2 | ||
|
|
f5ee1fe592 | ||
|
|
674a8d143e | ||
|
|
b189ca1b58 | ||
|
|
6e647021cd | ||
|
|
6d60d53910 | ||
|
|
f050c807a4 | ||
|
|
c15f6d435f | ||
|
|
42adff8545 | ||
|
|
a701ddde50 | ||
|
|
d14fc0c188 | ||
|
|
f455b433ec | ||
|
|
6e0e4a5454 | ||
|
|
5761fa77b1 | ||
|
|
fab8826123 | ||
|
|
c83a16545c | ||
|
|
f7cae037d5 |
25
.env.example
25
.env.example
@ -9,13 +9,6 @@
|
|||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
AUTH_SECRET=replace-with-random-secret
|
AUTH_SECRET=replace-with-random-secret
|
||||||
|
|
||||||
# Stable pepper for user Access Key hashes. Generate independently from AUTH_SECRET.
|
|
||||||
ACCESS_KEY_PEPPER=replace-with-random-secret
|
|
||||||
|
|
||||||
# Authorizes trusted third-party services to provision user-scoped MCP keys.
|
|
||||||
# Must contain at least 32 characters. Never expose it to end users or browsers.
|
|
||||||
MCP_KEY_PROVISIONING_SECRET=replace-with-random-secret
|
|
||||||
|
|
||||||
# Public application URL. Required for production and self-hosted Auth.js.
|
# Public application URL. Required for production and self-hosted Auth.js.
|
||||||
AUTH_URL=https://your-domain.com
|
AUTH_URL=https://your-domain.com
|
||||||
NEXTAUTH_URL=https://your-domain.com
|
NEXTAUTH_URL=https://your-domain.com
|
||||||
@ -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
20
.gitattributes
vendored
@ -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
49
.gitignore
vendored
@ -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
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
# 检查日志是否包含完整错误堆栈
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,706 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
Ban,
|
|
||||||
Clock3,
|
|
||||||
Globe2,
|
|
||||||
LogIn,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
ShieldAlert,
|
|
||||||
Unlock,
|
|
||||||
UserCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Severity = "info" | "watch" | "high" | "critical";
|
|
||||||
|
|
||||||
type AccessLogEvent = {
|
|
||||||
id: string;
|
|
||||||
eventType: string;
|
|
||||||
ip: string;
|
|
||||||
userEmail: string | null;
|
|
||||||
userRole: string | null;
|
|
||||||
method: string | null;
|
|
||||||
path: string | null;
|
|
||||||
routeGroup: string | null;
|
|
||||||
statusCode: number | null;
|
|
||||||
outcome: string;
|
|
||||||
severity: Severity;
|
|
||||||
reasons: string[] | null;
|
|
||||||
userAgent: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccessLogTopIp = {
|
|
||||||
ip: string;
|
|
||||||
total: number;
|
|
||||||
uniqueUsers: number;
|
|
||||||
failureCount: number;
|
|
||||||
rateLimitedCount: number;
|
|
||||||
authFailureCount: number;
|
|
||||||
loginSuccessCount: number;
|
|
||||||
suspiciousCount: number;
|
|
||||||
firstSeen: string;
|
|
||||||
lastSeen: string;
|
|
||||||
severity: Severity;
|
|
||||||
failureRate: number;
|
|
||||||
reasons: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccessLogResponse = {
|
|
||||||
overview: {
|
|
||||||
totalEvents: number;
|
|
||||||
uniqueIps: number;
|
|
||||||
abnormalEvents: number;
|
|
||||||
abnormalIps: number;
|
|
||||||
rateLimitedEvents: number;
|
|
||||||
manualBlockedEvents: number;
|
|
||||||
loginSuccessEvents: number;
|
|
||||||
unauthorizedEvents: number;
|
|
||||||
};
|
|
||||||
topIps: AccessLogTopIp[];
|
|
||||||
hourlyStats: Array<{
|
|
||||||
hour: string;
|
|
||||||
count: number;
|
|
||||||
abnormalCount: number;
|
|
||||||
}>;
|
|
||||||
pathStats: Array<{
|
|
||||||
path: string | null;
|
|
||||||
routeGroup: string | null;
|
|
||||||
count: number;
|
|
||||||
abnormalCount: number;
|
|
||||||
}>;
|
|
||||||
recentEvents: AccessLogEvent[];
|
|
||||||
rules: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccessBlocklistEntry = {
|
|
||||||
id: string;
|
|
||||||
ip: string;
|
|
||||||
reason: string | null;
|
|
||||||
isActive: boolean;
|
|
||||||
expiresAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
createdByEmail: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityLabels: Record<Severity, string> = {
|
|
||||||
info: "正常",
|
|
||||||
watch: "关注",
|
|
||||||
high: "异常",
|
|
||||||
critical: "高危",
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityClassNames: Record<Severity, string> = {
|
|
||||||
info:
|
|
||||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-300",
|
|
||||||
watch:
|
|
||||||
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/45 dark:text-amber-300",
|
|
||||||
high:
|
|
||||||
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-300",
|
|
||||||
critical:
|
|
||||||
"border-red-200 bg-red-50 text-red-700 dark:border-red-800/70 dark:bg-red-950/45 dark:text-red-300",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined) {
|
|
||||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPercent(value: number | null | undefined) {
|
|
||||||
return `${Math.round(Number(value || 0) * 100)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(value: string | null | undefined) {
|
|
||||||
if (!value) return "--";
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return "--";
|
|
||||||
return new Intl.DateTimeFormat("zh-CN", {
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusClassName(statusCode: number | null) {
|
|
||||||
if (!statusCode) return "text-muted-foreground";
|
|
||||||
if (statusCode >= 500) return "text-red-700 dark:text-red-300";
|
|
||||||
if (statusCode >= 400) return "text-orange-700 dark:text-orange-300";
|
|
||||||
if (statusCode >= 300) return "text-sky-700 dark:text-sky-300";
|
|
||||||
return "text-emerald-700 dark:text-emerald-300";
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccessSeverityBadge({ severity }: { severity: Severity }) {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className={cn("shrink-0 border px-2 py-0.5", severityClassNames[severity])}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{severityLabels[severity]}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AccessSecurityDashboard() {
|
|
||||||
const [hours, setHours] = useState("24");
|
|
||||||
const [severity, setSeverity] = useState("all");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [blockIp, setBlockIp] = useState("");
|
|
||||||
const [blockReason, setBlockReason] = useState("");
|
|
||||||
const [isSavingBlock, setIsSavingBlock] = useState(false);
|
|
||||||
|
|
||||||
const accessLogUrl = useMemo(() => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
hours,
|
|
||||||
severity,
|
|
||||||
limit: "160",
|
|
||||||
});
|
|
||||||
if (query.trim()) params.set("q", query.trim());
|
|
||||||
return `/api/admin/access-logs?${params.toString()}`;
|
|
||||||
}, [hours, query, severity]);
|
|
||||||
|
|
||||||
const { data, isLoading, mutate } = useSWR<AccessLogResponse>(
|
|
||||||
accessLogUrl,
|
|
||||||
fetcher,
|
|
||||||
{ refreshInterval: 30_000 }
|
|
||||||
);
|
|
||||||
const { data: blocklistData, mutate: mutateBlocklist } = useSWR<{
|
|
||||||
entries: AccessBlocklistEntry[];
|
|
||||||
}>("/api/admin/access-blocklist", fetcher);
|
|
||||||
|
|
||||||
const activeBlocklistEntries = useMemo(
|
|
||||||
() => (blocklistData?.entries || []).filter((entry) => entry.isActive),
|
|
||||||
[blocklistData?.entries]
|
|
||||||
);
|
|
||||||
const blockedRules = useMemo(
|
|
||||||
() => new Set(activeBlocklistEntries.map((entry) => entry.ip.toLowerCase())),
|
|
||||||
[activeBlocklistEntries]
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveBlockEntry = async (targetIp?: string, targetReason?: string) => {
|
|
||||||
const ip = String(targetIp || blockIp).trim();
|
|
||||||
const reason = String(targetReason ?? blockReason).trim();
|
|
||||||
if (!ip) {
|
|
||||||
toast.error("请输入要拉黑的 IP");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSavingBlock(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/access-blocklist", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ ip, reason }),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(payload?.message || payload?.error || "拉黑失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(`${ip} 已加入手动黑名单`);
|
|
||||||
setBlockIp("");
|
|
||||||
setBlockReason("");
|
|
||||||
await Promise.all([mutateBlocklist(), mutate()]);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "拉黑失败");
|
|
||||||
} finally {
|
|
||||||
setIsSavingBlock(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateBlockEntry = async (entry: AccessBlocklistEntry, isActive: boolean) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/access-blocklist", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: entry.id,
|
|
||||||
isActive,
|
|
||||||
reason: entry.reason || "",
|
|
||||||
expiresAt: entry.expiresAt || null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(payload?.message || payload?.error || "更新失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(isActive ? `${entry.ip} 已重新拉黑` : `${entry.ip} 已解除拉黑`);
|
|
||||||
await Promise.all([mutateBlocklist(), mutate()]);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "更新失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const overviewCards = [
|
|
||||||
{
|
|
||||||
key: "events",
|
|
||||||
label: "访问记录",
|
|
||||||
value: formatNumber(data?.overview.totalEvents),
|
|
||||||
note: `${hours} 小时窗口`,
|
|
||||||
icon: Activity,
|
|
||||||
className:
|
|
||||||
"border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-700/70 dark:bg-slate-900/70 dark:text-slate-100",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "ips",
|
|
||||||
label: "独立 IP",
|
|
||||||
value: formatNumber(data?.overview.uniqueIps),
|
|
||||||
note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`,
|
|
||||||
icon: Globe2,
|
|
||||||
className:
|
|
||||||
"border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800/70 dark:bg-sky-950/45 dark:text-sky-100",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "abnormal",
|
|
||||||
label: "异常事件",
|
|
||||||
value: formatNumber(data?.overview.abnormalEvents),
|
|
||||||
note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`,
|
|
||||||
icon: ShieldAlert,
|
|
||||||
className:
|
|
||||||
"border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-100",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "login",
|
|
||||||
label: "成功登录",
|
|
||||||
value: formatNumber(data?.overview.loginSuccessEvents),
|
|
||||||
note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`,
|
|
||||||
icon: LogIn,
|
|
||||||
className:
|
|
||||||
"border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-100",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const maxHourlyCount = Math.max(
|
|
||||||
1,
|
|
||||||
...(data?.hourlyStats || []).map((item) => item.count)
|
|
||||||
);
|
|
||||||
const maxPathCount = Math.max(
|
|
||||||
1,
|
|
||||||
...(data?.pathStats || []).map((item) => item.count)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-xl">访问安全分析</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
IP 访问记录、登录来源、异常标注与请求趋势
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative sm:w-[260px]">
|
|
||||||
<Search className="-translate-y-1/2 absolute left-3 top-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="h-9 pl-9"
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="搜索 IP、路径、用户"
|
|
||||||
value={query}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select onValueChange={setHours} value={hours}>
|
|
||||||
<SelectTrigger className="h-9 sm:w-[132px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">近 1 小时</SelectItem>
|
|
||||||
<SelectItem value="6">近 6 小时</SelectItem>
|
|
||||||
<SelectItem value="24">近 24 小时</SelectItem>
|
|
||||||
<SelectItem value="72">近 3 天</SelectItem>
|
|
||||||
<SelectItem value="168">近 7 天</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select onValueChange={setSeverity} value={severity}>
|
|
||||||
<SelectTrigger className="h-9 sm:w-[132px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">全部级别</SelectItem>
|
|
||||||
<SelectItem value="abnormal">仅异常</SelectItem>
|
|
||||||
<SelectItem value="watch">关注</SelectItem>
|
|
||||||
<SelectItem value="high">异常</SelectItem>
|
|
||||||
<SelectItem value="critical">高危</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
className="h-9 gap-2"
|
|
||||||
onClick={() => mutate()}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{overviewCards.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("rounded-lg border p-4", item.className)}
|
|
||||||
key={item.key}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="text-sm text-current/70">{item.label}</div>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 font-semibold text-3xl tracking-tight">
|
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-current/65 text-xs">{item.note}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-background p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2 font-medium text-sm">
|
|
||||||
<Clock3 className="h-4 w-4" />
|
|
||||||
小时趋势
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
橙色表示异常事件占比
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-[180px] items-end gap-1">
|
|
||||||
{(data?.hourlyStats || []).map((item) => {
|
|
||||||
const height = Math.max(4, (item.count / maxHourlyCount) * 100);
|
|
||||||
const abnormalHeight =
|
|
||||||
item.count > 0
|
|
||||||
? Math.max(0, (item.abnormalCount / item.count) * height)
|
|
||||||
: 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200 dark:bg-slate-800"
|
|
||||||
key={item.hour}
|
|
||||||
style={{ height: `${height}%` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full bg-orange-400"
|
|
||||||
style={{ height: `${abnormalHeight}%` }}
|
|
||||||
/>
|
|
||||||
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-white text-xs shadow-lg group-hover:block">
|
|
||||||
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "}
|
|
||||||
{formatNumber(item.abnormalCount)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(!data?.hourlyStats || data.hourlyStats.length === 0) && (
|
|
||||||
<div className="flex h-full w-full items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
|
|
||||||
暂无访问记录
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<CardTitle className="text-lg">手动 IP 黑名单</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
管理员手动加入后,命中 IP 会被阻断并写入访问日志
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(240px,1fr)_auto]">
|
|
||||||
<Input
|
|
||||||
className="h-9"
|
|
||||||
onChange={(event) => setBlockIp(event.target.value)}
|
|
||||||
placeholder="IP 或 IPv4 CIDR"
|
|
||||||
value={blockIp}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
className="h-9"
|
|
||||||
onChange={(event) => setBlockReason(event.target.value)}
|
|
||||||
placeholder="拉黑原因"
|
|
||||||
value={blockReason}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="h-9 gap-2"
|
|
||||||
disabled={isSavingBlock}
|
|
||||||
onClick={() => saveBlockEntry()}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
加入黑名单
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{activeBlocklistEntries.map((entry) => (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3 dark:border-red-900/60 dark:bg-red-950/30"
|
|
||||||
key={entry.id}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Ban className="h-4 w-4 text-red-600 dark:text-red-400" />
|
|
||||||
<span className="truncate font-mono font-semibold text-sm text-red-900 dark:text-red-200">
|
|
||||||
{entry.ip}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs dark:text-red-300/75">
|
|
||||||
{entry.reason || "未填写原因"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-red-700/70 text-xs dark:text-red-400/70">
|
|
||||||
{formatTime(entry.updatedAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-950/60"
|
|
||||||
onClick={() => updateBlockEntry(entry, false)}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Unlock className="h-3.5 w-3.5" />
|
|
||||||
解除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{activeBlocklistEntries.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-dashed py-8 text-center text-muted-foreground text-sm md:col-span-2 xl:col-span-3">
|
|
||||||
暂无手动拉黑 IP
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<CardTitle className="text-lg">异常 IP 标注</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
按限流、失败率、认证异常、敏感路径和请求量综合排序
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(data?.topIps || []).map((item) => (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border bg-background px-4 py-3"
|
|
||||||
key={item.ip}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-mono font-semibold text-sm">
|
|
||||||
{item.ip}
|
|
||||||
</span>
|
|
||||||
<AccessSeverityBadge severity={item.severity} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-muted-foreground text-xs">
|
|
||||||
<span>{formatNumber(item.total)} 次访问</span>
|
|
||||||
<span>{formatNumber(item.uniqueUsers)} 个用户</span>
|
|
||||||
<span>失败率 {formatPercent(item.failureRate)}</span>
|
|
||||||
<span>最近 {formatTime(item.lastSeen)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-1 md:justify-end">
|
|
||||||
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
|
|
||||||
(reason) => (
|
|
||||||
<Badge
|
|
||||||
className="border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
|
||||||
key={reason}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{reason}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
className="h-7 gap-1 px-2 text-xs"
|
|
||||||
disabled={blockedRules.has(item.ip.toLowerCase()) || isSavingBlock}
|
|
||||||
onClick={() =>
|
|
||||||
saveBlockEntry(
|
|
||||||
item.ip,
|
|
||||||
item.reasons.join("、") || "管理员从异常 IP 排行手动拉黑"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Ban className="h-3.5 w-3.5" />
|
|
||||||
{blockedRules.has(item.ip.toLowerCase()) ? "已拉黑" : "拉黑"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(!data?.topIps || data.topIps.length === 0) && (
|
|
||||||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
|
||||||
暂无 IP 聚合数据
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<CardTitle className="text-lg">访问路径</CardTitle>
|
|
||||||
<CardDescription>按请求数量排序</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{(data?.pathStats || []).map((item) => {
|
|
||||||
const abnormalRate =
|
|
||||||
item.count > 0 ? item.abnormalCount / item.count : 0;
|
|
||||||
return (
|
|
||||||
<div key={`${item.routeGroup || "page"}-${item.path || "/"}`}>
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="min-w-0 truncate font-medium">
|
|
||||||
{item.path || "/"}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-muted-foreground text-xs">
|
|
||||||
{formatNumber(item.count)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-zinc-900 dark:bg-zinc-200"
|
|
||||||
style={{
|
|
||||||
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="-mt-2 h-full rounded-full bg-orange-400"
|
|
||||||
style={{
|
|
||||||
width: `${Math.round(abnormalRate * 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(!data?.pathStats || data.pathStats.length === 0) && (
|
|
||||||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
|
||||||
暂无路径统计
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-lg">访问日志列表</CardTitle>
|
|
||||||
<CardDescription>最近 {formatNumber(data?.recentEvents.length)} 条</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(data?.rules || []).map((rule) => (
|
|
||||||
<Badge
|
|
||||||
className="border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
|
||||||
key={rule}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{rule}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto rounded-lg border">
|
|
||||||
<div className="min-w-[980px]">
|
|
||||||
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs dark:bg-zinc-900/70">
|
|
||||||
<div>时间</div>
|
|
||||||
<div>IP</div>
|
|
||||||
<div>状态</div>
|
|
||||||
<div>路径</div>
|
|
||||||
<div>用户</div>
|
|
||||||
<div>标注</div>
|
|
||||||
</div>
|
|
||||||
{(data?.recentEvents || []).map((event) => (
|
|
||||||
<div
|
|
||||||
className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] items-center border-b px-4 py-3 text-sm last:border-b-0"
|
|
||||||
key={event.id}
|
|
||||||
>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{formatTime(event.createdAt)}
|
|
||||||
</div>
|
|
||||||
<div className="font-mono text-xs">{event.ip}</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"font-semibold text-xs",
|
|
||||||
getStatusClassName(event.statusCode)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{event.statusCode || "--"}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium">
|
|
||||||
{event.method || "GET"} {event.path || "/"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 truncate text-muted-foreground text-xs">
|
|
||||||
{event.reasons?.join("、") || event.outcome}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-xs">
|
|
||||||
{event.userEmail || "匿名"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
|
|
||||||
<UserCheck className="h-3 w-3" />
|
|
||||||
{event.userRole || "unknown"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{event.severity === "high" || event.severity === "critical" ? (
|
|
||||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
|
||||||
) : null}
|
|
||||||
<AccessSeverityBadge severity={event.severity} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(!data?.recentEvents || data.recentEvents.length === 0) && (
|
|
||||||
<div className="py-10 text-center text-muted-foreground text-sm">
|
|
||||||
暂无访问日志
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -11,9 +11,11 @@ import {
|
|||||||
SelectTrigger,
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
{/* 批量处理配置 */}
|
{/* 批量处理配置 */}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
|
||||||
);
|
|
||||||
|
|||||||
@ -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
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 }) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
@ -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") ||
|
||||||
|
|||||||
@ -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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
@ -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
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 || ""} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,207 +0,0 @@
|
|||||||
import { desc, eq, sql } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { accessIpBlocklist, user } from "@/lib/db/schema";
|
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
import { recordAccessIpEventFromHeaders } from "@/lib/security/access-log";
|
|
||||||
import { normalizeManualBlockIp } from "@/lib/security/manual-ip-blocklist";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 10;
|
|
||||||
|
|
||||||
const createSchema = z.object({
|
|
||||||
ip: z.string().trim().min(1).max(128),
|
|
||||||
reason: z.string().trim().max(500).optional().nullable(),
|
|
||||||
expiresAt: z.string().trim().optional().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const patchSchema = z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
reason: z.string().trim().max(500).optional().nullable(),
|
|
||||||
expiresAt: z.string().trim().optional().nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function normalizeOptionalDate(value?: string | null) {
|
|
||||||
const normalized = String(value || "").trim();
|
|
||||||
if (!normalized) return null;
|
|
||||||
const date = new Date(normalized);
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
|
|
||||||
const entries = await db
|
|
||||||
.select({
|
|
||||||
id: accessIpBlocklist.id,
|
|
||||||
ip: accessIpBlocklist.ip,
|
|
||||||
reason: accessIpBlocklist.reason,
|
|
||||||
isActive: accessIpBlocklist.isActive,
|
|
||||||
expiresAt: accessIpBlocklist.expiresAt,
|
|
||||||
createdAt: accessIpBlocklist.createdAt,
|
|
||||||
updatedAt: accessIpBlocklist.updatedAt,
|
|
||||||
createdBy: accessIpBlocklist.createdBy,
|
|
||||||
createdByEmail: user.email,
|
|
||||||
})
|
|
||||||
.from(accessIpBlocklist)
|
|
||||||
.leftJoin(user, eq(accessIpBlocklist.createdBy, user.id))
|
|
||||||
.orderBy(desc(accessIpBlocklist.isActive), desc(accessIpBlocklist.updatedAt))
|
|
||||||
.limit(300);
|
|
||||||
|
|
||||||
return NextResponse.json({ entries });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ChatSDKError) {
|
|
||||||
return error.toResponse();
|
|
||||||
}
|
|
||||||
console.error("[AccessIpBlocklist] failed to list entries", error);
|
|
||||||
return new ChatSDKError("offline:admin").toResponse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await requireAdmin();
|
|
||||||
const body = createSchema.parse(await request.json());
|
|
||||||
const normalizedIp = normalizeManualBlockIp(body.ip);
|
|
||||||
|
|
||||||
if (!normalizedIp) {
|
|
||||||
return new ChatSDKError(
|
|
||||||
"bad_request:api",
|
|
||||||
"请输入有效的 IP 地址或 IPv4 CIDR,例如 1.2.3.4 或 1.2.3.0/24"
|
|
||||||
).toResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const expiresAt = normalizeOptionalDate(body.expiresAt);
|
|
||||||
const reason = body.reason?.trim() || null;
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ id: accessIpBlocklist.id })
|
|
||||||
.from(accessIpBlocklist)
|
|
||||||
.where(sql`lower(${accessIpBlocklist.ip}) = lower(${normalizedIp})`)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [entry] = existing
|
|
||||||
? await db
|
|
||||||
.update(accessIpBlocklist)
|
|
||||||
.set({
|
|
||||||
reason,
|
|
||||||
isActive: true,
|
|
||||||
expiresAt,
|
|
||||||
updatedBy: session.user.id,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq(accessIpBlocklist.id, existing.id))
|
|
||||||
.returning()
|
|
||||||
: await db
|
|
||||||
.insert(accessIpBlocklist)
|
|
||||||
.values({
|
|
||||||
ip: normalizedIp,
|
|
||||||
reason,
|
|
||||||
isActive: true,
|
|
||||||
expiresAt,
|
|
||||||
createdBy: session.user.id,
|
|
||||||
updatedBy: session.user.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await recordAccessIpEventFromHeaders({
|
|
||||||
eventType: "manual_block_added",
|
|
||||||
userId: session.user.id,
|
|
||||||
userEmail: session.user.email || null,
|
|
||||||
userRole: session.user.type,
|
|
||||||
method: "POST",
|
|
||||||
path: "/api/admin/access-blocklist",
|
|
||||||
routeGroup: "admin-api",
|
|
||||||
statusCode: 200,
|
|
||||||
outcome: "manual_block_added",
|
|
||||||
severity: "high",
|
|
||||||
reasons: ["管理员手动拉黑 IP"],
|
|
||||||
metadata: {
|
|
||||||
targetIp: normalizedIp,
|
|
||||||
reason,
|
|
||||||
},
|
|
||||||
}).catch((error) => {
|
|
||||||
console.warn("[AccessIpBlocklist] failed to record block action", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ entry });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ChatSDKError) {
|
|
||||||
return error.toResponse();
|
|
||||||
}
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
|
||||||
}
|
|
||||||
console.error("[AccessIpBlocklist] failed to create entry", error);
|
|
||||||
return new ChatSDKError("offline:admin").toResponse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await requireAdmin();
|
|
||||||
const body = patchSchema.parse(await request.json());
|
|
||||||
const now = new Date();
|
|
||||||
const expiresAt = normalizeOptionalDate(body.expiresAt);
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ ip: accessIpBlocklist.ip })
|
|
||||||
.from(accessIpBlocklist)
|
|
||||||
.where(eq(accessIpBlocklist.id, body.id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return new ChatSDKError("bad_request:api", "IP 黑名单记录不存在").toResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [entry] = await db
|
|
||||||
.update(accessIpBlocklist)
|
|
||||||
.set({
|
|
||||||
isActive: body.isActive,
|
|
||||||
reason: body.reason?.trim() || undefined,
|
|
||||||
expiresAt,
|
|
||||||
updatedBy: session.user.id,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq(accessIpBlocklist.id, body.id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await recordAccessIpEventFromHeaders({
|
|
||||||
eventType: body.isActive ? "manual_block_enabled" : "manual_block_released",
|
|
||||||
userId: session.user.id,
|
|
||||||
userEmail: session.user.email || null,
|
|
||||||
userRole: session.user.type,
|
|
||||||
method: "PATCH",
|
|
||||||
path: "/api/admin/access-blocklist",
|
|
||||||
routeGroup: "admin-api",
|
|
||||||
statusCode: 200,
|
|
||||||
outcome: body.isActive ? "manual_block_enabled" : "manual_block_released",
|
|
||||||
severity: body.isActive ? "high" : "watch",
|
|
||||||
reasons: [body.isActive ? "管理员启用 IP 拉黑" : "管理员解除 IP 拉黑"],
|
|
||||||
metadata: {
|
|
||||||
targetIp: existing.ip,
|
|
||||||
blocklistId: body.id,
|
|
||||||
},
|
|
||||||
}).catch((error) => {
|
|
||||||
console.warn("[AccessIpBlocklist] failed to record update action", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ entry });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ChatSDKError) {
|
|
||||||
return error.toResponse();
|
|
||||||
}
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return new ChatSDKError("bad_request:api", error.message).toResponse();
|
|
||||||
}
|
|
||||||
console.error("[AccessIpBlocklist] failed to update entry", error);
|
|
||||||
return new ChatSDKError("offline:admin").toResponse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,271 +0,0 @@
|
|||||||
import { and, desc, gte, sql, type SQL } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { accessIpEvent } from "@/lib/db/schema";
|
|
||||||
import { ChatSDKError } from "@/lib/errors";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 10;
|
|
||||||
|
|
||||||
type Severity = "info" | "watch" | "high" | "critical";
|
|
||||||
|
|
||||||
const severityByRank: Record<number, Severity> = {
|
|
||||||
0: "info",
|
|
||||||
1: "watch",
|
|
||||||
2: "high",
|
|
||||||
3: "critical",
|
|
||||||
};
|
|
||||||
|
|
||||||
function clampInt(value: string | null, fallback: number, min: number, max: number) {
|
|
||||||
const parsed = Number.parseInt(value || "", 10);
|
|
||||||
if (!Number.isFinite(parsed)) return fallback;
|
|
||||||
return Math.min(Math.max(parsed, min), max);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSearch(value: string | null) {
|
|
||||||
return String(value || "").trim().slice(0, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSeverityFromAggregate(params: {
|
|
||||||
eventSeverityRank: number;
|
|
||||||
total: number;
|
|
||||||
failureCount: number;
|
|
||||||
rateLimitedCount: number;
|
|
||||||
authFailureCount: number;
|
|
||||||
suspiciousCount: number;
|
|
||||||
}) {
|
|
||||||
let severity = severityByRank[params.eventSeverityRank] || "info";
|
|
||||||
const failureRate =
|
|
||||||
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
|
|
||||||
|
|
||||||
if (params.rateLimitedCount > 0 || params.suspiciousCount > 0) {
|
|
||||||
severity = "high";
|
|
||||||
} else if (params.authFailureCount >= 5 || params.total >= 250) {
|
|
||||||
severity = severity === "critical" ? severity : "high";
|
|
||||||
} else if (
|
|
||||||
params.failureCount >= 20 ||
|
|
||||||
(params.total >= 10 && failureRate >= 0.45)
|
|
||||||
) {
|
|
||||||
severity = severity === "critical" ? severity : "high";
|
|
||||||
} else if (params.authFailureCount > 0 || params.failureCount > 0) {
|
|
||||||
severity = severity === "info" ? "watch" : severity;
|
|
||||||
}
|
|
||||||
|
|
||||||
return severity;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAggregateReasons(params: {
|
|
||||||
total: number;
|
|
||||||
failureCount: number;
|
|
||||||
rateLimitedCount: number;
|
|
||||||
authFailureCount: number;
|
|
||||||
suspiciousCount: number;
|
|
||||||
loginSuccessCount: number;
|
|
||||||
}) {
|
|
||||||
const reasons: string[] = [];
|
|
||||||
const failureRate =
|
|
||||||
params.total > 0 ? params.failureCount / Math.max(params.total, 1) : 0;
|
|
||||||
|
|
||||||
if (params.rateLimitedCount > 0) reasons.push("触发限流");
|
|
||||||
if (params.authFailureCount >= 5) reasons.push("认证失败偏多");
|
|
||||||
if (params.suspiciousCount > 0) reasons.push("命中异常规则");
|
|
||||||
if (params.total >= 250) reasons.push("请求量偏高");
|
|
||||||
if (params.failureCount >= 20 || (params.total >= 10 && failureRate >= 0.45)) {
|
|
||||||
reasons.push("失败率偏高");
|
|
||||||
}
|
|
||||||
if (params.loginSuccessCount > 0 && reasons.length === 0) {
|
|
||||||
reasons.push("有成功登录记录");
|
|
||||||
}
|
|
||||||
|
|
||||||
return reasons;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const hours = clampInt(searchParams.get("hours"), 24, 1, 168);
|
|
||||||
const limit = clampInt(searchParams.get("limit"), 120, 25, 500);
|
|
||||||
const severityFilter = searchParams.get("severity") || "all";
|
|
||||||
const search = normalizeSearch(searchParams.get("q"));
|
|
||||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
|
||||||
const whereParts: SQL[] = [gte(accessIpEvent.createdAt, since)];
|
|
||||||
|
|
||||||
if (severityFilter === "abnormal") {
|
|
||||||
whereParts.push(sql`${accessIpEvent.severity} <> 'info'`);
|
|
||||||
} else if (["info", "watch", "high", "critical"].includes(severityFilter)) {
|
|
||||||
whereParts.push(sql`${accessIpEvent.severity} = ${severityFilter}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
const pattern = `%${search}%`;
|
|
||||||
whereParts.push(sql`(
|
|
||||||
${accessIpEvent.ip} ILIKE ${pattern}
|
|
||||||
OR ${accessIpEvent.path} ILIKE ${pattern}
|
|
||||||
OR ${accessIpEvent.userEmail} ILIKE ${pattern}
|
|
||||||
OR ${accessIpEvent.userAgent} ILIKE ${pattern}
|
|
||||||
)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = and(...whereParts);
|
|
||||||
|
|
||||||
const [overview] = await db
|
|
||||||
.select({
|
|
||||||
totalEvents: sql<number>`count(*)::int`,
|
|
||||||
uniqueIps: sql<number>`count(distinct ${accessIpEvent.ip})::int`,
|
|
||||||
abnormalEvents: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
|
||||||
abnormalIps: sql<number>`count(distinct ${accessIpEvent.ip}) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
|
||||||
rateLimitedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
|
|
||||||
manualBlockedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'manual_blocked')::int`,
|
|
||||||
loginSuccessEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
|
|
||||||
unauthorizedEvents: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'unauthorized' or ${accessIpEvent.statusCode} in (401, 403))::int`,
|
|
||||||
})
|
|
||||||
.from(accessIpEvent)
|
|
||||||
.where(whereClause);
|
|
||||||
|
|
||||||
const recentEvents = await db
|
|
||||||
.select({
|
|
||||||
id: accessIpEvent.id,
|
|
||||||
eventType: accessIpEvent.eventType,
|
|
||||||
ip: accessIpEvent.ip,
|
|
||||||
userId: accessIpEvent.userId,
|
|
||||||
userEmail: accessIpEvent.userEmail,
|
|
||||||
userRole: accessIpEvent.userRole,
|
|
||||||
method: accessIpEvent.method,
|
|
||||||
path: accessIpEvent.path,
|
|
||||||
routeGroup: accessIpEvent.routeGroup,
|
|
||||||
statusCode: accessIpEvent.statusCode,
|
|
||||||
outcome: accessIpEvent.outcome,
|
|
||||||
severity: accessIpEvent.severity,
|
|
||||||
reasons: accessIpEvent.reasons,
|
|
||||||
userAgent: accessIpEvent.userAgent,
|
|
||||||
referer: accessIpEvent.referer,
|
|
||||||
occurredAt: accessIpEvent.occurredAt,
|
|
||||||
createdAt: accessIpEvent.createdAt,
|
|
||||||
})
|
|
||||||
.from(accessIpEvent)
|
|
||||||
.where(whereClause)
|
|
||||||
.orderBy(desc(accessIpEvent.createdAt))
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
const topIpRows = await db
|
|
||||||
.select({
|
|
||||||
ip: accessIpEvent.ip,
|
|
||||||
total: sql<number>`count(*)::int`,
|
|
||||||
uniqueUsers: sql<number>`count(distinct ${accessIpEvent.userId}) filter (where ${accessIpEvent.userId} is not null)::int`,
|
|
||||||
failureCount: sql<number>`count(*) filter (where ${accessIpEvent.statusCode} >= 400)::int`,
|
|
||||||
rateLimitedCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429)::int`,
|
|
||||||
authFailureCount: sql<number>`count(*) filter (where ${accessIpEvent.path} like '/api/auth%' and ${accessIpEvent.statusCode} >= 400)::int`,
|
|
||||||
loginSuccessCount: sql<number>`count(*) filter (where ${accessIpEvent.outcome} = 'login_success')::int`,
|
|
||||||
suspiciousCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical'))::int`,
|
|
||||||
firstSeen: sql<string>`min(${accessIpEvent.createdAt})::text`,
|
|
||||||
lastSeen: sql<string>`max(${accessIpEvent.createdAt})::text`,
|
|
||||||
eventSeverityRank: sql<number>`max(case ${accessIpEvent.severity}
|
|
||||||
when 'critical' then 3
|
|
||||||
when 'high' then 2
|
|
||||||
when 'watch' then 1
|
|
||||||
else 0
|
|
||||||
end)::int`,
|
|
||||||
})
|
|
||||||
.from(accessIpEvent)
|
|
||||||
.where(whereClause)
|
|
||||||
.groupBy(accessIpEvent.ip)
|
|
||||||
.orderBy(
|
|
||||||
desc(sql`(
|
|
||||||
count(*) filter (where ${accessIpEvent.severity} in ('watch', 'high', 'critical')) * 8
|
|
||||||
+ count(*) filter (where ${accessIpEvent.outcome} = 'rate_limited' or ${accessIpEvent.statusCode} = 429) * 12
|
|
||||||
+ count(*) filter (where ${accessIpEvent.statusCode} >= 400) * 3
|
|
||||||
+ count(*)
|
|
||||||
)`)
|
|
||||||
)
|
|
||||||
.limit(24);
|
|
||||||
|
|
||||||
const topIps = topIpRows.map((row) => {
|
|
||||||
const severity = buildSeverityFromAggregate({
|
|
||||||
eventSeverityRank: Number(row.eventSeverityRank || 0),
|
|
||||||
total: Number(row.total || 0),
|
|
||||||
failureCount: Number(row.failureCount || 0),
|
|
||||||
rateLimitedCount: Number(row.rateLimitedCount || 0),
|
|
||||||
authFailureCount: Number(row.authFailureCount || 0),
|
|
||||||
suspiciousCount: Number(row.suspiciousCount || 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
severity,
|
|
||||||
failureRate:
|
|
||||||
Number(row.total || 0) > 0
|
|
||||||
? Number(row.failureCount || 0) / Number(row.total || 1)
|
|
||||||
: 0,
|
|
||||||
reasons: buildAggregateReasons({
|
|
||||||
total: Number(row.total || 0),
|
|
||||||
failureCount: Number(row.failureCount || 0),
|
|
||||||
rateLimitedCount: Number(row.rateLimitedCount || 0),
|
|
||||||
authFailureCount: Number(row.authFailureCount || 0),
|
|
||||||
suspiciousCount: Number(row.suspiciousCount || 0),
|
|
||||||
loginSuccessCount: Number(row.loginSuccessCount || 0),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const hourlyStats = await db
|
|
||||||
.select({
|
|
||||||
hour: sql<string>`to_char(date_trunc('hour', ${accessIpEvent.createdAt}), 'MM-DD HH24:00')`,
|
|
||||||
count: sql<number>`count(*)::int`,
|
|
||||||
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
|
||||||
})
|
|
||||||
.from(accessIpEvent)
|
|
||||||
.where(whereClause)
|
|
||||||
.groupBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`)
|
|
||||||
.orderBy(sql`date_trunc('hour', ${accessIpEvent.createdAt})`);
|
|
||||||
|
|
||||||
const pathStats = await db
|
|
||||||
.select({
|
|
||||||
path: accessIpEvent.path,
|
|
||||||
routeGroup: accessIpEvent.routeGroup,
|
|
||||||
count: sql<number>`count(*)::int`,
|
|
||||||
abnormalCount: sql<number>`count(*) filter (where ${accessIpEvent.severity} <> 'info')::int`,
|
|
||||||
})
|
|
||||||
.from(accessIpEvent)
|
|
||||||
.where(whereClause)
|
|
||||||
.groupBy(accessIpEvent.path, accessIpEvent.routeGroup)
|
|
||||||
.orderBy(desc(sql`count(*)`))
|
|
||||||
.limit(12);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
window: {
|
|
||||||
hours,
|
|
||||||
since: since.toISOString(),
|
|
||||||
},
|
|
||||||
overview: {
|
|
||||||
totalEvents: overview?.totalEvents || 0,
|
|
||||||
uniqueIps: overview?.uniqueIps || 0,
|
|
||||||
abnormalEvents: overview?.abnormalEvents || 0,
|
|
||||||
abnormalIps: overview?.abnormalIps || 0,
|
|
||||||
rateLimitedEvents: overview?.rateLimitedEvents || 0,
|
|
||||||
manualBlockedEvents: overview?.manualBlockedEvents || 0,
|
|
||||||
loginSuccessEvents: overview?.loginSuccessEvents || 0,
|
|
||||||
unauthorizedEvents: overview?.unauthorizedEvents || 0,
|
|
||||||
},
|
|
||||||
topIps,
|
|
||||||
hourlyStats,
|
|
||||||
pathStats,
|
|
||||||
recentEvents,
|
|
||||||
rules: [
|
|
||||||
"短时间请求量偏高",
|
|
||||||
"4xx/5xx 或失败率偏高",
|
|
||||||
"认证失败偏多",
|
|
||||||
"触发限流",
|
|
||||||
"命中敏感路径或异常 User-Agent",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ChatSDKError) {
|
|
||||||
return error.toResponse();
|
|
||||||
}
|
|
||||||
console.error("[AccessIpEvent] failed to load admin analytics", error);
|
|
||||||
return new ChatSDKError("offline:admin").toResponse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -33,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();
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -1,182 +0,0 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
fetchAtlasCloudModelPresets,
|
|
||||||
resolveAtlasCloudDisplayName,
|
|
||||||
shouldInsertMissingAtlasCloudPreset,
|
|
||||||
} from "@/lib/ai/atlascloud-models";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { getAiProviderById } from "@/lib/db/queries";
|
|
||||||
import { type AiModel, aiModel } from "@/lib/db/schema";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 300;
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
const result = await fetchAtlasCloudModelPresets();
|
|
||||||
return NextResponse.json({
|
|
||||||
presets: result.presets,
|
|
||||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
|
||||||
counts: {
|
|
||||||
total: result.catalogCount,
|
|
||||||
available: result.presets.length,
|
|
||||||
failed: result.errors.length,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Atlas Cloud Models API] GET error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to fetch Atlas Cloud models",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
const body = await request.json();
|
|
||||||
const providerId = String(body?.providerId || "").trim();
|
|
||||||
const requestedModelIds = Array.isArray(body?.modelIds)
|
|
||||||
? new Set(
|
|
||||||
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
if (!providerId) {
|
|
||||||
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = await getAiProviderById({ id: providerId });
|
|
||||||
if (!provider || provider.type !== "atlascloud") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Provider must be an Atlas Cloud provider" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalog = await fetchAtlasCloudModelPresets();
|
|
||||||
const presets = requestedModelIds
|
|
||||||
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
|
|
||||||
: catalog.presets;
|
|
||||||
const addedModels: AiModel[] = [];
|
|
||||||
const updatedModels: AiModel[] = [];
|
|
||||||
const errors = [...catalog.errors];
|
|
||||||
const providerModels = await db.query.aiModel.findMany({
|
|
||||||
where: eq(aiModel.providerId, providerId),
|
|
||||||
});
|
|
||||||
const existingByModelId = new Map(
|
|
||||||
providerModels.map((model) => [model.modelId, model])
|
|
||||||
);
|
|
||||||
const existingMediaKinds = new Set(
|
|
||||||
providerModels.map((model) => {
|
|
||||||
const capabilities = model.capabilities as Record<string, unknown> | null;
|
|
||||||
return String(capabilities?.mediaType || "image");
|
|
||||||
})
|
|
||||||
);
|
|
||||||
let preservedDeletedCount = 0;
|
|
||||||
|
|
||||||
for (const preset of presets) {
|
|
||||||
try {
|
|
||||||
const existing = existingByModelId.get(preset.modelId);
|
|
||||||
const presetCapabilities = preset.capabilities as Record<string, unknown>;
|
|
||||||
const mediaKind = String(presetCapabilities.mediaType || "image");
|
|
||||||
if (
|
|
||||||
!existing &&
|
|
||||||
!shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
|
|
||||||
) {
|
|
||||||
preservedDeletedCount += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const values = {
|
|
||||||
displayName: resolveAtlasCloudDisplayName(
|
|
||||||
existing?.displayName,
|
|
||||||
preset.displayName,
|
|
||||||
preset.modelId
|
|
||||||
),
|
|
||||||
avatarUrl: preset.avatarUrl || null,
|
|
||||||
description: preset.description,
|
|
||||||
supportsVision: preset.supportsVision,
|
|
||||||
supportsImageGen: preset.supportsImageGen,
|
|
||||||
sortOrder: existing?.sortOrder ?? preset.sortOrder,
|
|
||||||
capabilities: preset.capabilities,
|
|
||||||
defaultParams: preset.defaultParams,
|
|
||||||
imageGenConfig: preset.imageGenConfig,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const [updated] = await db
|
|
||||||
.update(aiModel)
|
|
||||||
.set(values)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(aiModel.providerId, providerId),
|
|
||||||
eq(aiModel.modelId, preset.modelId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
if (updated) updatedModels.push(updated);
|
|
||||||
} else {
|
|
||||||
const [created] = await db
|
|
||||||
.insert(aiModel)
|
|
||||||
.values({
|
|
||||||
providerId,
|
|
||||||
modelId: preset.modelId,
|
|
||||||
...values,
|
|
||||||
isEnabled: true,
|
|
||||||
isHidden: false,
|
|
||||||
isDefault: false,
|
|
||||||
isSubModel: false,
|
|
||||||
parentModelId: null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
if (created) addedModels.push(created);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`[Atlas Cloud Models API] Failed to sync ${preset.modelId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
errors.push({
|
|
||||||
modelId: preset.modelId,
|
|
||||||
error: error instanceof Error ? error.message : "Sync failed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
addedCount: addedModels.length,
|
|
||||||
updatedCount: updatedModels.length,
|
|
||||||
addedModels,
|
|
||||||
updatedModels,
|
|
||||||
errors: errors.length > 0 ? errors : undefined,
|
|
||||||
counts: {
|
|
||||||
requested: presets.length,
|
|
||||||
total: catalog.catalogCount,
|
|
||||||
available: catalog.presets.length,
|
|
||||||
skippedUnsupported: catalog.skipped.length,
|
|
||||||
preservedDeleted: preservedDeletedCount,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Atlas Cloud Models API] POST error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to sync Atlas Cloud models",
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 模板上传或解析失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,213 +0,0 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
fetchVip98kModelPresets,
|
|
||||||
type Vip98kModelPreset,
|
|
||||||
} from "@/lib/ai/vip98k-models";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { getAiProviderById } from "@/lib/db/queries";
|
|
||||||
import { aiModel, type AiModel } from "@/lib/db/schema";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 300;
|
|
||||||
|
|
||||||
function summarizeByType(presets: Vip98kModelPreset[]) {
|
|
||||||
return presets.reduce<Record<string, number>>((acc, preset) => {
|
|
||||||
const type = String(preset.type || "model");
|
|
||||||
acc[type] = (acc[type] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const providerId = String(searchParams.get("providerId") || "").trim();
|
|
||||||
if (!providerId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Provider ID is required to fetch VIP98K catalog" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = await getAiProviderById({ id: providerId });
|
|
||||||
if (!provider || provider.type !== "vip98k") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Provider must be a VIP98K provider" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const presets = await fetchVip98kModelPresets({
|
|
||||||
baseURL: provider.baseUrl,
|
|
||||||
apiKey: provider.apiKey,
|
|
||||||
});
|
|
||||||
return NextResponse.json({
|
|
||||||
presets,
|
|
||||||
counts: {
|
|
||||||
total: presets.length,
|
|
||||||
byType: summarizeByType(presets),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[VIP98K Models API] GET error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to fetch VIP98K models",
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
const body = await request.json();
|
|
||||||
const providerId = String(body?.providerId || "").trim();
|
|
||||||
const requestedModelIds = Array.isArray(body?.modelIds)
|
|
||||||
? new Set(
|
|
||||||
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const preserveDeletedModels = body?.preserveDeletedModels !== false;
|
|
||||||
if (!providerId) {
|
|
||||||
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = await getAiProviderById({ id: providerId });
|
|
||||||
if (!provider || provider.type !== "vip98k") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Provider must be a VIP98K provider" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalogPresets = await fetchVip98kModelPresets({
|
|
||||||
baseURL: provider.baseUrl,
|
|
||||||
apiKey: provider.apiKey,
|
|
||||||
});
|
|
||||||
const existingModels = await db.query.aiModel.findMany({
|
|
||||||
where: eq(aiModel.providerId, providerId),
|
|
||||||
});
|
|
||||||
const existingModelIds = new Set(existingModels.map((model) => model.modelId));
|
|
||||||
const preserveExistingCatalog =
|
|
||||||
requestedModelIds === null &&
|
|
||||||
preserveDeletedModels &&
|
|
||||||
existingModels.length > 0;
|
|
||||||
const presets = requestedModelIds
|
|
||||||
? catalogPresets.filter((preset) => requestedModelIds.has(preset.modelId))
|
|
||||||
: preserveExistingCatalog
|
|
||||||
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
|
||||||
: catalogPresets;
|
|
||||||
|
|
||||||
const addedModels: AiModel[] = [];
|
|
||||||
const updatedModels: AiModel[] = [];
|
|
||||||
const disabledModels: AiModel[] = [];
|
|
||||||
const errors: Array<{ modelId: string; error: string }> = [];
|
|
||||||
|
|
||||||
if (requestedModelIds === null) {
|
|
||||||
const catalogModelIds = new Set(catalogPresets.map((preset) => preset.modelId));
|
|
||||||
for (const existing of existingModels) {
|
|
||||||
if (catalogModelIds.has(existing.modelId)) continue;
|
|
||||||
const [disabled] = await db
|
|
||||||
.update(aiModel)
|
|
||||||
.set({ isEnabled: false, isHidden: true, updatedAt: new Date() })
|
|
||||||
.where(eq(aiModel.id, existing.id))
|
|
||||||
.returning();
|
|
||||||
if (disabled) disabledModels.push(disabled);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const preset of presets) {
|
|
||||||
try {
|
|
||||||
const existing = await db.query.aiModel.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(aiModel.providerId, providerId),
|
|
||||||
eq(aiModel.modelId, preset.modelId),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const values = {
|
|
||||||
displayName: preset.displayName,
|
|
||||||
avatarUrl: null,
|
|
||||||
description: preset.description,
|
|
||||||
supportsVision: preset.supportsVision,
|
|
||||||
supportsImageGen: preset.supportsImageGen,
|
|
||||||
isEnabled:
|
|
||||||
requestedModelIds !== null ? true : (existing?.isEnabled ?? true),
|
|
||||||
isHidden: preset.isHidden,
|
|
||||||
sortOrder: preset.sortOrder,
|
|
||||||
capabilities: preset.capabilities,
|
|
||||||
defaultParams: preset.defaultParams,
|
|
||||||
imageGenConfig: preset.imageGenConfig,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const [updated] = await db
|
|
||||||
.update(aiModel)
|
|
||||||
.set(values)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(aiModel.providerId, providerId),
|
|
||||||
eq(aiModel.modelId, preset.modelId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
if (updated) updatedModels.push(updated);
|
|
||||||
} else {
|
|
||||||
const [created] = await db
|
|
||||||
.insert(aiModel)
|
|
||||||
.values({
|
|
||||||
providerId,
|
|
||||||
modelId: preset.modelId,
|
|
||||||
...values,
|
|
||||||
isDefault: false,
|
|
||||||
isSubModel: false,
|
|
||||||
parentModelId: null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
if (created) addedModels.push(created);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[VIP98K Models API] Failed to sync ${preset.modelId}:`, error);
|
|
||||||
errors.push({
|
|
||||||
modelId: preset.modelId,
|
|
||||||
error: error instanceof Error ? error.message : "Sync failed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
addedCount: addedModels.length,
|
|
||||||
updatedCount: updatedModels.length,
|
|
||||||
disabledCount: disabledModels.length,
|
|
||||||
addedModels,
|
|
||||||
updatedModels,
|
|
||||||
disabledModelIds: disabledModels.map((model) => model.modelId),
|
|
||||||
errors: errors.length > 0 ? errors : undefined,
|
|
||||||
counts: {
|
|
||||||
requested: presets.length,
|
|
||||||
total: catalogPresets.length,
|
|
||||||
byType: summarizeByType(catalogPresets),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[VIP98K Models API] POST error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to sync VIP98K models",
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { VISIONARY_MODEL_PRESETS } from "@/lib/ai/visionary-models";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { getAiProviderById } from "@/lib/db/queries";
|
|
||||||
import { type AiModel, aiModel } from "@/lib/db/schema";
|
|
||||||
import { requireAdmin } from "@/lib/middleware/admin";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
return NextResponse.json({
|
|
||||||
presets: VISIONARY_MODEL_PRESETS,
|
|
||||||
counts: { total: VISIONARY_MODEL_PRESETS.length },
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("[Visionary Models API] GET error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || "Failed to fetch Visionary model presets" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
const body = await request.json();
|
|
||||||
const providerId = String(body?.providerId || "").trim();
|
|
||||||
const modelIds = Array.isArray(body?.modelIds)
|
|
||||||
? body.modelIds
|
|
||||||
.map((item: unknown) => String(item || "").trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!providerId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Provider ID is required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = await getAiProviderById({ id: providerId });
|
|
||||||
if (!provider || provider.type !== "visionary") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Provider must be a Visionary provider" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const presets = modelIds
|
|
||||||
? VISIONARY_MODEL_PRESETS.filter((preset) =>
|
|
||||||
modelIds.includes(preset.modelId)
|
|
||||||
)
|
|
||||||
: VISIONARY_MODEL_PRESETS;
|
|
||||||
const addedModels: AiModel[] = [];
|
|
||||||
const updatedModels: AiModel[] = [];
|
|
||||||
const errors: { modelId: string; error: string }[] = [];
|
|
||||||
|
|
||||||
for (const preset of presets) {
|
|
||||||
try {
|
|
||||||
const existing = await db.query.aiModel.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(aiModel.providerId, providerId),
|
|
||||||
eq(aiModel.modelId, preset.modelId)
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const values = {
|
|
||||||
displayName: preset.displayName,
|
|
||||||
description: preset.description,
|
|
||||||
supportsVision: preset.supportsVision,
|
|
||||||
supportsImageGen: preset.supportsImageGen,
|
|
||||||
isEnabled: true,
|
|
||||||
isHidden: false,
|
|
||||||
sortOrder: preset.sortOrder,
|
|
||||||
capabilities: preset.capabilities || null,
|
|
||||||
defaultParams: preset.defaultParams || null,
|
|
||||||
imageGenConfig: preset.imageGenConfig || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const [updated] = await db
|
|
||||||
.update(aiModel)
|
|
||||||
.set(values)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(aiModel.providerId, providerId),
|
|
||||||
eq(aiModel.modelId, preset.modelId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
if (updated) updatedModels.push(updated);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [created] = await db
|
|
||||||
.insert(aiModel)
|
|
||||||
.values({
|
|
||||||
providerId,
|
|
||||||
modelId: preset.modelId,
|
|
||||||
...values,
|
|
||||||
isDefault: false,
|
|
||||||
isSubModel: false,
|
|
||||||
parentModelId: null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
if (created) addedModels.push(created);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(
|
|
||||||
`[Visionary Models API] Failed to sync ${preset.modelId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
errors.push({
|
|
||||||
modelId: preset.modelId,
|
|
||||||
error: error.message || "Sync failed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
addedCount: addedModels.length,
|
|
||||||
updatedCount: updatedModels.length,
|
|
||||||
addedModels,
|
|
||||||
updatedModels,
|
|
||||||
errors: errors.length > 0 ? errors : undefined,
|
|
||||||
counts: {
|
|
||||||
requested: presets.length,
|
|
||||||
total: VISIONARY_MODEL_PRESETS.length,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("[Visionary Models API] POST error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || "Failed to sync Visionary models" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
app/api/ai-cad/modeling/command/route.ts
Normal file
122
app/api/ai-cad/modeling/command/route.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
getKittyCadModelingCommandError,
|
||||||
|
isKittyCadModelingCommandSuccessful,
|
||||||
|
sendKittyCadModelingCommand,
|
||||||
|
takeKittyCadModelingSnapshot,
|
||||||
|
} from "@/lib/kittycad/modeling-session";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
type ModelingCommandRequest = {
|
||||||
|
captureSnapshot?: boolean;
|
||||||
|
cmd?: Record<string, unknown>;
|
||||||
|
cmdId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
snapshotFormat?: "jpeg" | "png";
|
||||||
|
};
|
||||||
|
|
||||||
|
function readOptionalRecord(value: unknown) {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as ModelingCommandRequest;
|
||||||
|
const sessionId = String(body.sessionId || "").trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少官方建模会话 ID" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = readOptionalRecord(body.cmd);
|
||||||
|
const cmdType = String(cmd?.type || "").trim();
|
||||||
|
if (!cmd || !cmdType) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少有效的官方建模命令" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmdId = String(body.cmdId || "").trim() || randomUUID();
|
||||||
|
const response = await sendKittyCadModelingCommand({
|
||||||
|
cmd: {
|
||||||
|
...cmd,
|
||||||
|
type: cmdType,
|
||||||
|
},
|
||||||
|
cmdId,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isKittyCadModelingCommandSuccessful(response)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: getKittyCadModelingCommandError(response),
|
||||||
|
payload: response.errors || null,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshotUrl: string | null = null;
|
||||||
|
let snapshotError: string | null = null;
|
||||||
|
|
||||||
|
const shouldCaptureSnapshot =
|
||||||
|
body.captureSnapshot === true && cmdType !== "take_snapshot";
|
||||||
|
|
||||||
|
if (cmdType === "take_snapshot") {
|
||||||
|
const contents = String(response.resp?.data?.contents || "").trim();
|
||||||
|
const snapshotFormat = body.snapshotFormat || "png";
|
||||||
|
snapshotUrl = contents
|
||||||
|
? `data:${snapshotFormat === "jpeg" ? "image/jpeg" : "image/png"};base64,${contents}`
|
||||||
|
: null;
|
||||||
|
} else if (shouldCaptureSnapshot) {
|
||||||
|
try {
|
||||||
|
const snapshot = await takeKittyCadModelingSnapshot({
|
||||||
|
format: body.snapshotFormat || "png",
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
snapshotUrl = snapshot.snapshotUrl;
|
||||||
|
} catch (error) {
|
||||||
|
snapshotError =
|
||||||
|
error instanceof Error ? error.message : "获取官方建模快照失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
raw: response,
|
||||||
|
requestId: response.request_id || cmdId,
|
||||||
|
responseData: response.resp?.data || null,
|
||||||
|
responseType: response.resp?.type || cmdType,
|
||||||
|
snapshotError,
|
||||||
|
snapshotUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "发送官方建模命令失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/api/ai-cad/modeling/session/[sessionId]/route.ts
Normal file
48
app/api/ai-cad/modeling/session/[sessionId]/route.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { closeKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
type RouteParams = {
|
||||||
|
params: Promise<{
|
||||||
|
sessionId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await params;
|
||||||
|
const normalizedSessionId = String(sessionId || "").trim();
|
||||||
|
if (!normalizedSessionId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少 sessionId" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await closeKittyCadModelingSession(normalizedSessionId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "关闭官方建模会话失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/api/ai-cad/modeling/session/route.ts
Normal file
33
app/api/ai-cad/modeling/session/route.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { createKittyCadModelingSession } from "@/lib/kittycad/modeling-session";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelingSession = await createKittyCadModelingSession();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: modelingSession,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "创建官方建模会话失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/api/ai-cad/provider/route.ts
Normal file
27
app/api/ai-cad/provider/route.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { findKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await findKittyCadProviderConfig();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
configured: Boolean(config),
|
||||||
|
source: config?.source || "none",
|
||||||
|
providerName: config?.providerName || null,
|
||||||
|
baseUrl: config?.baseUrl || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
391
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
391
app/api/ai-cad/tasks/[taskId]/route.ts
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
type KittyCadTaskResponse = {
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
outputs?: Record<string, string> | null;
|
||||||
|
error?: string | null;
|
||||||
|
code?: string | null;
|
||||||
|
prompt?: string | null;
|
||||||
|
conversation_id?: string | null;
|
||||||
|
model_version?: string | null;
|
||||||
|
output_format?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArchivedFile = {
|
||||||
|
sourceHash: string;
|
||||||
|
archivedUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteParams = {
|
||||||
|
params: Promise<{ taskId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getArchiveCache() {
|
||||||
|
const globalState = globalThis as Record<string, unknown>;
|
||||||
|
if (!globalState.__kittyCadAssetArchiveCache) {
|
||||||
|
globalState.__kittyCadAssetArchiveCache = new Map<string, ArchivedFile>();
|
||||||
|
}
|
||||||
|
return globalState.__kittyCadAssetArchiveCache as Map<string, ArchivedFile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArchiveInflight() {
|
||||||
|
const globalState = globalThis as Record<string, unknown>;
|
||||||
|
if (!globalState.__kittyCadAssetArchiveInflight) {
|
||||||
|
globalState.__kittyCadAssetArchiveInflight = new Map<
|
||||||
|
string,
|
||||||
|
Promise<string | null>
|
||||||
|
>();
|
||||||
|
}
|
||||||
|
return globalState.__kittyCadAssetArchiveInflight as Map<
|
||||||
|
string,
|
||||||
|
Promise<string | null>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapKittyCadStatus(status: unknown) {
|
||||||
|
const normalized = String(status || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return "queued" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "queued" ||
|
||||||
|
normalized === "pending" ||
|
||||||
|
normalized === "created" ||
|
||||||
|
normalized === "submitted"
|
||||||
|
) {
|
||||||
|
return "queued" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "running" ||
|
||||||
|
normalized === "processing" ||
|
||||||
|
normalized === "in_progress" ||
|
||||||
|
normalized === "working" ||
|
||||||
|
normalized === "generating"
|
||||||
|
) {
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "completed" ||
|
||||||
|
normalized === "success" ||
|
||||||
|
normalized === "succeeded" ||
|
||||||
|
normalized === "done" ||
|
||||||
|
normalized === "finished"
|
||||||
|
) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "cancelled" || normalized === "canceled") {
|
||||||
|
return "cancelled" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "expired" || normalized === "timed_out") {
|
||||||
|
return "expired" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "banned" || normalized === "blocked") {
|
||||||
|
return "banned" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "failed" ||
|
||||||
|
normalized === "error" ||
|
||||||
|
normalized === "errored"
|
||||||
|
) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "unknown" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileExtension(filename: string) {
|
||||||
|
const normalized = String(filename || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
||||||
|
const segments = cleanName.split(".");
|
||||||
|
return segments.length > 1 ? segments.at(-1) || "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageExtension(extension: string) {
|
||||||
|
return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPreferredModelFile(files: Array<{ name: string; url: string }>) {
|
||||||
|
const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"];
|
||||||
|
for (const extension of preferredOrder) {
|
||||||
|
const matched = files.find(
|
||||||
|
(item) => getFileExtension(item.name) === extension
|
||||||
|
);
|
||||||
|
if (matched) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
|
||||||
|
return (
|
||||||
|
files.find((item) => isImageExtension(getFileExtension(item.name))) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveOutputIfNeeded(params: {
|
||||||
|
taskId: string;
|
||||||
|
filename: string;
|
||||||
|
base64: string;
|
||||||
|
}) {
|
||||||
|
const normalizedFilename = String(params.filename || "").trim();
|
||||||
|
const normalizedBase64 = String(params.base64 || "").trim();
|
||||||
|
if (!normalizedFilename || !normalizedBase64) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
||||||
|
const cache = getArchiveCache();
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached && cached.sourceHash === normalizedBase64) {
|
||||||
|
return cached.archivedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = getArchiveInflight();
|
||||||
|
const existingTask = inflight.get(cacheKey);
|
||||||
|
if (existingTask) {
|
||||||
|
return (await existingTask) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveTask = (async () => {
|
||||||
|
try {
|
||||||
|
const binary = Buffer.from(normalizedBase64, "base64");
|
||||||
|
if (binary.byteLength <= 0) {
|
||||||
|
throw new Error("KittyCAD 输出文件为空");
|
||||||
|
}
|
||||||
|
const arrayBuffer = Uint8Array.from(binary).buffer;
|
||||||
|
|
||||||
|
const safeFilename =
|
||||||
|
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin";
|
||||||
|
const uploaded = await uploadFile(
|
||||||
|
`kittycad-${params.taskId}-${safeFilename}`,
|
||||||
|
arrayBuffer
|
||||||
|
);
|
||||||
|
cache.set(cacheKey, {
|
||||||
|
sourceHash: normalizedBase64,
|
||||||
|
archivedUrl: uploaded.url,
|
||||||
|
});
|
||||||
|
return uploaded.url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[KittyCADArchive] failed", {
|
||||||
|
taskId: params.taskId,
|
||||||
|
filename: normalizedFilename,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
inflight.delete(cacheKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflight.set(cacheKey, archiveTask);
|
||||||
|
return await archiveTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveTextOutputIfNeeded(params: {
|
||||||
|
taskId: string;
|
||||||
|
filename: string;
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
const normalizedFilename = String(params.filename || "").trim();
|
||||||
|
const normalizedContent = String(params.content || "");
|
||||||
|
if (!normalizedFilename || !normalizedContent.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${params.taskId}:${normalizedFilename}`;
|
||||||
|
const cache = getArchiveCache();
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached && cached.sourceHash === normalizedContent) {
|
||||||
|
return cached.archivedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = getArchiveInflight();
|
||||||
|
const existingTask = inflight.get(cacheKey);
|
||||||
|
if (existingTask) {
|
||||||
|
return (await existingTask) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveTask = (async () => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = Uint8Array.from(
|
||||||
|
new TextEncoder().encode(normalizedContent)
|
||||||
|
).buffer;
|
||||||
|
const safeFilename =
|
||||||
|
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl";
|
||||||
|
const uploaded = await uploadFile(
|
||||||
|
`kittycad-${params.taskId}-${safeFilename}`,
|
||||||
|
arrayBuffer
|
||||||
|
);
|
||||||
|
cache.set(cacheKey, {
|
||||||
|
sourceHash: normalizedContent,
|
||||||
|
archivedUrl: uploaded.url,
|
||||||
|
});
|
||||||
|
return uploaded.url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[KittyCADArchive:text] failed", {
|
||||||
|
taskId: params.taskId,
|
||||||
|
filename: normalizedFilename,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
inflight.delete(cacheKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflight.set(cacheKey, archiveTask);
|
||||||
|
return await archiveTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { taskId } = await params;
|
||||||
|
const normalizedTaskId = String(taskId || "").trim();
|
||||||
|
if (!normalizedTaskId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "缺少 taskId" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
const result = await kittyCadRequest<KittyCadTaskResponse>(config, {
|
||||||
|
method: "GET",
|
||||||
|
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = mapKittyCadStatus(result.status);
|
||||||
|
if (status !== "success") {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "cad",
|
||||||
|
status,
|
||||||
|
progress: status === "running" ? 65 : null,
|
||||||
|
errorMessage: result.error || null,
|
||||||
|
conversationId: result.conversation_id || null,
|
||||||
|
modelVersion: result.model_version || null,
|
||||||
|
outputFormat: result.output_format || null,
|
||||||
|
output: result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputEntries = Object.entries(result.outputs || {}).filter(
|
||||||
|
(entry): entry is [string, string] =>
|
||||||
|
typeof entry[0] === "string" &&
|
||||||
|
entry[0].trim().length > 0 &&
|
||||||
|
typeof entry[1] === "string" &&
|
||||||
|
entry[1].trim().length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const archivedEntries = await Promise.all(
|
||||||
|
outputEntries.map(async ([name, base64]) => {
|
||||||
|
const archivedUrl = await archiveOutputIfNeeded({
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
filename: name,
|
||||||
|
base64,
|
||||||
|
});
|
||||||
|
return archivedUrl ? { name, url: archivedUrl } : null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const archivedFiles = archivedEntries.filter(
|
||||||
|
(item): item is { name: string; url: string } =>
|
||||||
|
Boolean(item?.name && item?.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasArchivedKclFile = archivedFiles.some(
|
||||||
|
(item) => getFileExtension(item.name) === "kcl"
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!hasArchivedKclFile &&
|
||||||
|
typeof result.code === "string" &&
|
||||||
|
result.code.trim()
|
||||||
|
) {
|
||||||
|
const archivedCodeUrl = await archiveTextOutputIfNeeded({
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
filename: "main.kcl",
|
||||||
|
content: result.code,
|
||||||
|
});
|
||||||
|
if (archivedCodeUrl) {
|
||||||
|
archivedFiles.push({
|
||||||
|
name: "main.kcl",
|
||||||
|
url: archivedCodeUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelFile = pickPreferredModelFile(archivedFiles);
|
||||||
|
const previewImageFile = pickPreviewImageFile(archivedFiles);
|
||||||
|
const downloadUrls = Object.fromEntries(
|
||||||
|
archivedFiles.map((item) => [item.name, item.url])
|
||||||
|
);
|
||||||
|
const codeFile =
|
||||||
|
archivedFiles.find((item) => getFileExtension(item.name) === "kcl") ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "cad",
|
||||||
|
status: "success",
|
||||||
|
progress: 100,
|
||||||
|
modelUrl:
|
||||||
|
modelFile &&
|
||||||
|
["glb", "gltf"].includes(getFileExtension(modelFile.name))
|
||||||
|
? modelFile.url
|
||||||
|
: null,
|
||||||
|
previewImageUrl: previewImageFile?.url || null,
|
||||||
|
errorMessage: result.error || null,
|
||||||
|
conversationId: result.conversation_id || null,
|
||||||
|
modelVersion: result.model_version || null,
|
||||||
|
outputFormat: result.output_format || null,
|
||||||
|
codeUrl: codeFile?.url || null,
|
||||||
|
downloadUrls,
|
||||||
|
output: result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof KittyCadApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
payload: error.payload,
|
||||||
|
},
|
||||||
|
{ status: error.httpStatus || 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
331
app/api/ai-cad/tasks/route.ts
Normal file
331
app/api/ai-cad/tasks/route.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
consumeUserPoints,
|
||||||
|
getPointsSystemConfig,
|
||||||
|
getUserById,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
|
||||||
|
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
type CreateAiCadTaskRequest = {
|
||||||
|
prompt?: string;
|
||||||
|
outputFormat?: string;
|
||||||
|
projectName?: string;
|
||||||
|
iterationSourceTaskId?: string;
|
||||||
|
sourceCode?: string;
|
||||||
|
sourceRanges?: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type KittyCadCreateResponse = {
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
conversation_id?: string | null;
|
||||||
|
model_version?: string | null;
|
||||||
|
output_format?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KittyCadSourceTaskResponse = {
|
||||||
|
code?: string | null;
|
||||||
|
outputs?: Record<string, string> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KittyCadIterationResponse = {
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
conversation_id?: string | null;
|
||||||
|
model_version?: string | null;
|
||||||
|
output_format?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFileExtension(filename: string) {
|
||||||
|
const normalized = String(filename || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
|
||||||
|
const segments = cleanName.split(".");
|
||||||
|
return segments.length > 1 ? segments.at(-1) || "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Text(value: unknown) {
|
||||||
|
const normalized = String(value || "").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(normalized, "base64").toString("utf8").trim();
|
||||||
|
return decoded || null;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSourceCodeFromTask(task: KittyCadSourceTaskResponse) {
|
||||||
|
const directCode = String(task.code || "").trim();
|
||||||
|
if (directCode) {
|
||||||
|
return directCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kclOutputEntry = Object.entries(task.outputs || {}).find(
|
||||||
|
(entry): entry is [string, string] =>
|
||||||
|
typeof entry[0] === "string" &&
|
||||||
|
entry[0].trim().length > 0 &&
|
||||||
|
getFileExtension(entry[0]) === "kcl" &&
|
||||||
|
typeof entry[1] === "string" &&
|
||||||
|
entry[1].trim().length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!kclOutputEntry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeBase64Text(kclOutputEntry[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapKittyCadStatus(status: unknown) {
|
||||||
|
const normalized = String(status || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return "queued" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "queued" ||
|
||||||
|
normalized === "pending" ||
|
||||||
|
normalized === "created" ||
|
||||||
|
normalized === "submitted"
|
||||||
|
) {
|
||||||
|
return "queued" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "running" ||
|
||||||
|
normalized === "processing" ||
|
||||||
|
normalized === "in_progress" ||
|
||||||
|
normalized === "working" ||
|
||||||
|
normalized === "generating"
|
||||||
|
) {
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "completed" ||
|
||||||
|
normalized === "success" ||
|
||||||
|
normalized === "succeeded" ||
|
||||||
|
normalized === "done" ||
|
||||||
|
normalized === "finished"
|
||||||
|
) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "cancelled" || normalized === "canceled") {
|
||||||
|
return "cancelled" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "expired" || normalized === "timed_out") {
|
||||||
|
return "expired" as const;
|
||||||
|
}
|
||||||
|
if (normalized === "banned" || normalized === "blocked") {
|
||||||
|
return "banned" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalized === "failed" ||
|
||||||
|
normalized === "error" ||
|
||||||
|
normalized === "errored"
|
||||||
|
) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "unknown" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as CreateAiCadTaskRequest;
|
||||||
|
const prompt = String(body.prompt || "").trim();
|
||||||
|
|
||||||
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
|
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
|
||||||
|
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
pointsConfig.enabled &&
|
||||||
|
unitCost > 0 &&
|
||||||
|
pointsConfig.blockOnInsufficient !== false
|
||||||
|
) {
|
||||||
|
const userData = await getUserById({ id: session.user.id });
|
||||||
|
if (!userData?.isHighVip) {
|
||||||
|
const currentBalance = Math.max(
|
||||||
|
0,
|
||||||
|
Number(userData?.pointsBalance || 0)
|
||||||
|
);
|
||||||
|
if (currentBalance < unitCost) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "积分不足",
|
||||||
|
code: "insufficient_points",
|
||||||
|
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
|
||||||
|
balance: currentBalance,
|
||||||
|
expectedCost: unitCost,
|
||||||
|
unitCost,
|
||||||
|
},
|
||||||
|
{ status: 402 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputFormat =
|
||||||
|
String(body.outputFormat || "glb")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() || "glb";
|
||||||
|
const normalizedProjectName = String(body.projectName || "").trim();
|
||||||
|
const iterationSourceTaskId = String(
|
||||||
|
body.iterationSourceTaskId || ""
|
||||||
|
).trim();
|
||||||
|
const sourceCode = String(body.sourceCode || "");
|
||||||
|
const normalizedSourceCode = sourceCode.trim();
|
||||||
|
const sourceRanges = Array.isArray(body.sourceRanges)
|
||||||
|
? body.sourceRanges.filter(
|
||||||
|
(item): item is Record<string, unknown> =>
|
||||||
|
Boolean(item) && typeof item === "object" && !Array.isArray(item)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const config = await getKittyCadProviderConfig();
|
||||||
|
const generationMode =
|
||||||
|
iterationSourceTaskId || normalizedSourceCode ? "iterate" : "create";
|
||||||
|
|
||||||
|
if (generationMode === "create" && !prompt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "请输入 CAD 描述" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterationPrompt =
|
||||||
|
prompt || "Apply the edited KCL source and rebuild the CAD model.";
|
||||||
|
const result =
|
||||||
|
generationMode === "iterate"
|
||||||
|
? await (async () => {
|
||||||
|
const originalSourceCode =
|
||||||
|
normalizedSourceCode ||
|
||||||
|
(await (async () => {
|
||||||
|
if (!iterationSourceTaskId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceTask =
|
||||||
|
await kittyCadRequest<KittyCadSourceTaskResponse>(config, {
|
||||||
|
method: "GET",
|
||||||
|
path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`,
|
||||||
|
});
|
||||||
|
return extractSourceCodeFromTask(sourceTask);
|
||||||
|
})());
|
||||||
|
|
||||||
|
if (!originalSourceCode) {
|
||||||
|
throw new KittyCadApiError({
|
||||||
|
message:
|
||||||
|
"当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。",
|
||||||
|
httpStatus: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return kittyCadRequest<KittyCadIterationResponse>(config, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/ml/text-to-cad/iteration",
|
||||||
|
json: {
|
||||||
|
original_source_code: originalSourceCode,
|
||||||
|
prompt: iterationPrompt,
|
||||||
|
source_ranges: sourceRanges,
|
||||||
|
...(normalizedProjectName
|
||||||
|
? { project_name: normalizedProjectName }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
: await kittyCadRequest<KittyCadCreateResponse>(config, {
|
||||||
|
method: "POST",
|
||||||
|
path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`,
|
||||||
|
json: {
|
||||||
|
prompt,
|
||||||
|
...(normalizedProjectName
|
||||||
|
? { project_name: normalizedProjectName }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskId = String(result.id || "").trim();
|
||||||
|
if (!taskId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "KittyCAD 返回缺少任务 ID",
|
||||||
|
raw: result,
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointsConfig.enabled && unitCost > 0) {
|
||||||
|
try {
|
||||||
|
await consumeUserPoints({
|
||||||
|
userId: session.user.id,
|
||||||
|
amount: unitCost,
|
||||||
|
reason: "model3d_generation",
|
||||||
|
modelId: "kittycad-cad",
|
||||||
|
note:
|
||||||
|
generationMode === "iterate"
|
||||||
|
? "AI-CAD 连续迭代消耗积分"
|
||||||
|
: "AI-CAD 生成消耗积分",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Points] Failed to consume AI-CAD points:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId,
|
||||||
|
status: mapKittyCadStatus(result.status),
|
||||||
|
conversationId: result.conversation_id || null,
|
||||||
|
modelVersion: result.model_version || null,
|
||||||
|
outputFormat:
|
||||||
|
generationMode === "iterate"
|
||||||
|
? result.output_format || null
|
||||||
|
: result.output_format || outputFormat,
|
||||||
|
generationMode,
|
||||||
|
sourceTaskId: iterationSourceTaskId || null,
|
||||||
|
raw: result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof KittyCadApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
payload: error.payload,
|
||||||
|
},
|
||||||
|
{ status: error.httpStatus || 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "创建 AI-CAD 任务失败",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
195
app/api/ai-cad/workspace-utils.ts
Normal file
195
app/api/ai-cad/workspace-utils.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import type { AiCadWorkspace } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
|
||||||
|
|
||||||
|
const FINAL_TASK_STATUS = new Set([
|
||||||
|
"success",
|
||||||
|
"failed",
|
||||||
|
"banned",
|
||||||
|
"expired",
|
||||||
|
"cancelled",
|
||||||
|
"unknown",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_VIEWER_SETTINGS = {
|
||||||
|
cameraPreset: "iso",
|
||||||
|
envPreset: "studio",
|
||||||
|
lightIntensity: 1.1,
|
||||||
|
autoRotate: true,
|
||||||
|
showGrid: true,
|
||||||
|
wireframe: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type WorkspaceVersionLike = {
|
||||||
|
status?: unknown;
|
||||||
|
prompt?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateAiCadWorkspaceRequest = {
|
||||||
|
name?: string;
|
||||||
|
prompt?: string;
|
||||||
|
generationProvider?: string;
|
||||||
|
modelVersion?: string;
|
||||||
|
viewerSettings?: unknown;
|
||||||
|
messages?: unknown;
|
||||||
|
versions?: unknown;
|
||||||
|
activeVersionId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.filter((item): item is WorkspaceVersionLike => isRecord(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeViewerSettings(
|
||||||
|
value: unknown
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return { ...DEFAULT_VIEWER_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cameraPreset:
|
||||||
|
value.cameraPreset === "top" ||
|
||||||
|
value.cameraPreset === "front" ||
|
||||||
|
value.cameraPreset === "right" ||
|
||||||
|
value.cameraPreset === "iso"
|
||||||
|
? value.cameraPreset
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.cameraPreset,
|
||||||
|
envPreset:
|
||||||
|
typeof value.envPreset === "string" && value.envPreset.trim()
|
||||||
|
? value.envPreset
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.envPreset,
|
||||||
|
lightIntensity:
|
||||||
|
typeof value.lightIntensity === "number" &&
|
||||||
|
Number.isFinite(value.lightIntensity)
|
||||||
|
? value.lightIntensity
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.lightIntensity,
|
||||||
|
autoRotate:
|
||||||
|
typeof value.autoRotate === "boolean"
|
||||||
|
? value.autoRotate
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.autoRotate,
|
||||||
|
showGrid:
|
||||||
|
typeof value.showGrid === "boolean"
|
||||||
|
? value.showGrid
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.showGrid,
|
||||||
|
wireframe:
|
||||||
|
typeof value.wireframe === "boolean"
|
||||||
|
? value.wireframe
|
||||||
|
: DEFAULT_VIEWER_SETTINGS.wireframe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeJsonRecordArray(
|
||||||
|
value: unknown
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.filter((item): item is Record<string, unknown> =>
|
||||||
|
isRecord(item)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkspaceName(
|
||||||
|
value: unknown,
|
||||||
|
fallbackName = "AI-CAD 工作台"
|
||||||
|
) {
|
||||||
|
return typeof value === "string" && value.trim()
|
||||||
|
? value.trim()
|
||||||
|
: fallbackName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): {
|
||||||
|
name?: string;
|
||||||
|
prompt: string;
|
||||||
|
generationProvider: CadGenerationProvider;
|
||||||
|
modelVersion: string;
|
||||||
|
viewerSettings: Record<string, unknown>;
|
||||||
|
messages: Record<string, unknown>[];
|
||||||
|
versions: Record<string, unknown>[];
|
||||||
|
activeVersionId: string | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
...(typeof body.name === "string" && body.name.trim()
|
||||||
|
? { name: body.name.trim() }
|
||||||
|
: {}),
|
||||||
|
prompt: String(body.prompt || ""),
|
||||||
|
generationProvider:
|
||||||
|
body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad",
|
||||||
|
modelVersion:
|
||||||
|
typeof body.modelVersion === "string" && body.modelVersion.trim()
|
||||||
|
? body.modelVersion
|
||||||
|
: "Turbo-v1.0-20250506",
|
||||||
|
viewerSettings: normalizeViewerSettings(body.viewerSettings),
|
||||||
|
messages: normalizeJsonRecordArray(body.messages),
|
||||||
|
versions: normalizeJsonRecordArray(body.versions),
|
||||||
|
activeVersionId:
|
||||||
|
typeof body.activeVersionId === "string" && body.activeVersionId.trim()
|
||||||
|
? body.activeVersionId
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultWorkspaceName(index: number) {
|
||||||
|
return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceSummary(workspace: AiCadWorkspace) {
|
||||||
|
const versions = getWorkspaceVersions(workspace.versions);
|
||||||
|
const versionCount = versions.length;
|
||||||
|
const completedCount = versions.filter(
|
||||||
|
(version) => version.status === "success"
|
||||||
|
).length;
|
||||||
|
const hasPendingTasks = versions.some(
|
||||||
|
(version) =>
|
||||||
|
typeof version.status !== "string" ||
|
||||||
|
!FINAL_TASK_STATUS.has(version.status)
|
||||||
|
);
|
||||||
|
const latestPrompt =
|
||||||
|
typeof workspace.prompt === "string" && workspace.prompt.trim()
|
||||||
|
? workspace.prompt.trim()
|
||||||
|
: versions.find(
|
||||||
|
(version) =>
|
||||||
|
typeof version.prompt === "string" && version.prompt.trim()
|
||||||
|
)?.prompt || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: workspace.id,
|
||||||
|
name: workspace.name,
|
||||||
|
generationProvider: workspace.generationProvider,
|
||||||
|
versionCount,
|
||||||
|
completedCount,
|
||||||
|
hasPendingTasks,
|
||||||
|
latestPrompt:
|
||||||
|
typeof latestPrompt === "string" && latestPrompt.trim()
|
||||||
|
? latestPrompt.trim()
|
||||||
|
: null,
|
||||||
|
createdAt: workspace.createdAt?.toISOString() || null,
|
||||||
|
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceDocument(workspace: AiCadWorkspace) {
|
||||||
|
return {
|
||||||
|
id: workspace.id,
|
||||||
|
name: workspace.name,
|
||||||
|
prompt: workspace.prompt,
|
||||||
|
generationProvider: workspace.generationProvider,
|
||||||
|
modelVersion: workspace.modelVersion,
|
||||||
|
viewerSettings: workspace.viewerSettings,
|
||||||
|
messages: workspace.messages,
|
||||||
|
versions: workspace.versions,
|
||||||
|
activeVersionId: workspace.activeVersionId,
|
||||||
|
createdAt: workspace.createdAt?.toISOString() || null,
|
||||||
|
updatedAt: workspace.updatedAt?.toISOString() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
89
app/api/ai-cad/workspace/route.ts
Normal file
89
app/api/ai-cad/workspace/route.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
createWorkspaceDocument,
|
||||||
|
normalizeWorkspacePayload,
|
||||||
|
type UpdateAiCadWorkspaceRequest,
|
||||||
|
} from "@/app/api/ai-cad/workspace-utils";
|
||||||
|
import {
|
||||||
|
createAiCadWorkspace,
|
||||||
|
listAiCadWorkspacesByUserId,
|
||||||
|
updateAiCadWorkspace,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaces = await listAiCadWorkspacesByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const workspace = workspaces[0] ?? null;
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: createWorkspaceDocument(workspace),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
||||||
|
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const targetWorkspace =
|
||||||
|
existingWorkspaces[0] ??
|
||||||
|
(await createAiCadWorkspace({
|
||||||
|
userId: session.user.id,
|
||||||
|
name: "AI-CAD 工作台",
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!targetWorkspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "初始化 AI-CAD 工作台失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedWorkspace = await updateAiCadWorkspace({
|
||||||
|
workspaceId: targetWorkspace.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
...normalizeWorkspacePayload(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!savedWorkspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "保存 AI-CAD 工作台失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
114
app/api/ai-cad/workspaces/[workspaceId]/route.ts
Normal file
114
app/api/ai-cad/workspaces/[workspaceId]/route.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
createWorkspaceDocument,
|
||||||
|
createWorkspaceSummary,
|
||||||
|
normalizeWorkspacePayload,
|
||||||
|
type UpdateAiCadWorkspaceRequest,
|
||||||
|
} from "@/app/api/ai-cad/workspace-utils";
|
||||||
|
import {
|
||||||
|
deleteAiCadWorkspaceById,
|
||||||
|
getAiCadWorkspaceById,
|
||||||
|
updateAiCadWorkspace,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{
|
||||||
|
workspaceId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: Request, context: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspaceId } = await context.params;
|
||||||
|
const workspace = await getAiCadWorkspaceById({
|
||||||
|
userId: session.user.id,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: createWorkspaceDocument(workspace),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request, context: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspaceId } = await context.params;
|
||||||
|
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
|
||||||
|
const savedWorkspace = await updateAiCadWorkspace({
|
||||||
|
workspaceId,
|
||||||
|
userId: session.user.id,
|
||||||
|
...normalizeWorkspacePayload(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!savedWorkspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "AI-CAD 工作台不存在或保存失败" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: savedWorkspace.id,
|
||||||
|
name: savedWorkspace.name,
|
||||||
|
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
|
||||||
|
summary: createWorkspaceSummary(savedWorkspace),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, context: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspaceId } = await context.params;
|
||||||
|
const deletedWorkspace = await deleteAiCadWorkspaceById({
|
||||||
|
workspaceId,
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deletedWorkspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "AI-CAD 工作台不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: deletedWorkspace.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
69
app/api/ai-cad/workspaces/route.ts
Normal file
69
app/api/ai-cad/workspaces/route.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
buildDefaultWorkspaceName,
|
||||||
|
createWorkspaceSummary,
|
||||||
|
normalizeWorkspaceName,
|
||||||
|
} from "@/app/api/ai-cad/workspace-utils";
|
||||||
|
import {
|
||||||
|
createAiCadWorkspace,
|
||||||
|
listAiCadWorkspacesByUserId,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaces = await listAiCadWorkspacesByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: workspaces.map(createWorkspaceSummary),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => ({}))) as { name?: unknown };
|
||||||
|
const existingWorkspaces = await listAiCadWorkspacesByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const workspace = await createAiCadWorkspace({
|
||||||
|
userId: session.user.id,
|
||||||
|
name: normalizeWorkspaceName(
|
||||||
|
body.name,
|
||||||
|
buildDefaultWorkspaceName(existingWorkspaces.length + 1)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "创建 AI-CAD 工作台失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: createWorkspaceSummary(workspace),
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const ALLOWED_WEBSOCKET_PATHS = new Set([
|
|
||||||
"/api/ai-cad/zoo-ws/ml/copilot",
|
|
||||||
"/api/ai-cad/zoo-ws/modeling/commands",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function hasValidInternalSecret(request: Request) {
|
|
||||||
const expected = String(process.env.AI_CAD_WS_PROXY_SECRET || "").trim();
|
|
||||||
const received = String(
|
|
||||||
request.headers.get("x-hicad-ws-proxy-secret") || ""
|
|
||||||
).trim();
|
|
||||||
if (!expected || !received) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedBuffer = Buffer.from(expected);
|
|
||||||
const receivedBuffer = Buffer.from(received);
|
|
||||||
return (
|
|
||||||
expectedBuffer.length === receivedBuffer.length &&
|
|
||||||
timingSafeEqual(expectedBuffer, receivedBuffer)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
if (!hasValidInternalSecret(request)) {
|
|
||||||
return NextResponse.json({ error: "禁止访问" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalUri = String(
|
|
||||||
request.headers.get("x-hicad-original-uri") || ""
|
|
||||||
).split("?", 1)[0];
|
|
||||||
if (!ALLOWED_WEBSOCKET_PATHS.has(originalUri)) {
|
|
||||||
return NextResponse.json({ error: "不支持的 Zoo WebSocket" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = await getKittyCadProviderConfig();
|
|
||||||
return new Response(null, {
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "private, no-store",
|
|
||||||
"X-Zoo-Authorization": `Bearer ${config.apiKey}`,
|
|
||||||
},
|
|
||||||
status: 204,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "Zoo WebSocket 鉴权失败",
|
|
||||||
},
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const maxDuration = 60;
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
const HOP_BY_HOP_HEADERS = new Set([
|
|
||||||
"connection",
|
|
||||||
"content-length",
|
|
||||||
"host",
|
|
||||||
"keep-alive",
|
|
||||||
"proxy-authenticate",
|
|
||||||
"proxy-authorization",
|
|
||||||
"te",
|
|
||||||
"trailer",
|
|
||||||
"transfer-encoding",
|
|
||||||
"upgrade",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const BLOCKED_PATH_PREFIXES = [
|
|
||||||
"/admin",
|
|
||||||
"/internal",
|
|
||||||
"/logout",
|
|
||||||
"/oauth2",
|
|
||||||
"/org/api-tokens",
|
|
||||||
"/user/api-tokens",
|
|
||||||
"/users",
|
|
||||||
];
|
|
||||||
|
|
||||||
type RouteContext = {
|
|
||||||
params: Promise<{ path: string[] }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isBlockedPath(pathname: string) {
|
|
||||||
return BLOCKED_PATH_PREFIXES.some(
|
|
||||||
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyRequestHeaders(request: Request, apiKey: string) {
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
for (const [name, value] of request.headers.entries()) {
|
|
||||||
const normalizedName = name.toLowerCase();
|
|
||||||
if (
|
|
||||||
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
|
||||||
normalizedName === "authorization" ||
|
|
||||||
normalizedName === "cookie" ||
|
|
||||||
normalizedName === "origin" ||
|
|
||||||
normalizedName === "referer" ||
|
|
||||||
normalizedName.startsWith("x-forwarded-") ||
|
|
||||||
normalizedName === "x-hicad-ws-proxy-secret"
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
headers.set(name, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
||||||
headers.set("Accept", request.headers.get("accept") || "application/json");
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyResponseHeaders(upstream: Response) {
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
for (const [name, value] of upstream.headers.entries()) {
|
|
||||||
const normalizedName = name.toLowerCase();
|
|
||||||
if (
|
|
||||||
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
|
||||||
normalizedName === "content-encoding" ||
|
|
||||||
normalizedName === "set-cookie"
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
headers.set(name, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.set("Cache-Control", "private, no-store");
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function proxyZooRequest(request: Request, context: RouteContext) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { path } = await context.params;
|
|
||||||
const pathname = `/${path.map(encodeURIComponent).join("/")}`;
|
|
||||||
if (isBlockedPath(pathname)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "该 Zoo API 不允许通过 HiCAD 代理访问" },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = await getKittyCadProviderConfig();
|
|
||||||
const incomingUrl = new URL(request.url);
|
|
||||||
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
||||||
const upstreamUrl = `${baseUrl}${pathname}${incomingUrl.search}`;
|
|
||||||
const method = request.method.toUpperCase();
|
|
||||||
const hasBody = method !== "GET" && method !== "HEAD";
|
|
||||||
const init: RequestInit & { duplex?: "half" } = {
|
|
||||||
body: hasBody ? request.body : undefined,
|
|
||||||
cache: "no-store",
|
|
||||||
duplex: hasBody ? "half" : undefined,
|
|
||||||
headers: copyRequestHeaders(request, config.apiKey),
|
|
||||||
method,
|
|
||||||
redirect: "manual",
|
|
||||||
signal: request.signal,
|
|
||||||
};
|
|
||||||
const upstream = await fetch(upstreamUrl, init);
|
|
||||||
|
|
||||||
return new Response(upstream.body, {
|
|
||||||
headers: copyResponseHeaders(upstream),
|
|
||||||
status: upstream.status,
|
|
||||||
statusText: upstream.statusText,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "Zoo API 代理请求失败",
|
|
||||||
},
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DELETE = proxyZooRequest;
|
|
||||||
export const GET = proxyZooRequest;
|
|
||||||
export const HEAD = proxyZooRequest;
|
|
||||||
export const OPTIONS = proxyZooRequest;
|
|
||||||
export const PATCH = proxyZooRequest;
|
|
||||||
export const POST = proxyZooRequest;
|
|
||||||
export const PUT = proxyZooRequest;
|
|
||||||
@ -13,7 +13,6 @@ import {
|
|||||||
findStyleProImageModelByRef,
|
findStyleProImageModelByRef,
|
||||||
getStyleProImageModelEntries,
|
getStyleProImageModelEntries,
|
||||||
} from "@/lib/style-pro/model-options";
|
} from "@/lib/style-pro/model-options";
|
||||||
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
prompt: z.string().min(1).max(5000),
|
prompt: z.string().min(1).max(5000),
|
||||||
@ -53,16 +52,6 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const availability = getAiModelFrontendAvailability(selectedModel.model);
|
|
||||||
if (availability.selectable === false) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: availability.disabledReason || "所选 AI洗图 生图模型当前不可用",
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageSize = body.imageSize?.trim() || "2K";
|
const imageSize = body.imageSize?.trim() || "2K";
|
||||||
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
|
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
|
||||||
|
|||||||
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { ZodError } from "zod";
|
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
|
||||||
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
|
||||||
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
|
||||||
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
|
||||||
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
|
||||||
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const maxDuration = 600;
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
const { id } = await params;
|
|
||||||
const requestId = new URL(request.url).searchParams.get("requestId");
|
|
||||||
if (!requestId) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
capabilities: getCanvasCapabilityManifest(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const task = await getCanvasCapabilityTask({
|
|
||||||
requestId,
|
|
||||||
userId: session.user.id,
|
|
||||||
projectId: id,
|
|
||||||
});
|
|
||||||
if (!task) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "能力任务不存在" },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.json({ success: true, task });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
|
||||||
const result = await executeCanvasCapabilityRuntime({
|
|
||||||
command,
|
|
||||||
projectId: id,
|
|
||||||
requestHeaders: request.headers,
|
|
||||||
requestUrl: request.url,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ success: true, ...result });
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof CanvasCapabilityRuntimeError ||
|
|
||||||
error instanceof CanvasAgentMutationError
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: error.message, code: error.code },
|
|
||||||
{ status: error.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error instanceof ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: "画布能力参数无效",
|
|
||||||
issues: error.issues,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: message },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,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" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
@ -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" } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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");
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
import JSZip from "jszip";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { getAllStorageConfigs } from "@/lib/db/queries";
|
|
||||||
import {
|
|
||||||
countOpenClawArchiveRootFolders,
|
|
||||||
sanitizeOpenClawArchiveEntryPath,
|
|
||||||
sanitizeOpenClawArchiveName,
|
|
||||||
} from "@/lib/openclaw-chat/folder-archive";
|
|
||||||
import {
|
|
||||||
OPENCLAW_CHAT_FOLDER_MAX_FILES,
|
|
||||||
} from "@/lib/openclaw-chat/media-attachments";
|
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
|
||||||
import {
|
|
||||||
toDbS3UploadConfig,
|
|
||||||
uploadFileWithS3Config,
|
|
||||||
} from "@/lib/storage/service";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 600;
|
|
||||||
|
|
||||||
const logger = createLogger("openclaw-folder-upload");
|
|
||||||
const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
|
|
||||||
|
|
||||||
function isUploadedFile(value: FormDataEntryValue): value is File {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"arrayBuffer" in value &&
|
|
||||||
"name" in value &&
|
|
||||||
"size" in value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toArrayBuffer(buffer: Buffer) {
|
|
||||||
return buffer.buffer.slice(
|
|
||||||
buffer.byteOffset,
|
|
||||||
buffer.byteOffset + buffer.byteLength
|
|
||||||
) as ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const files = formData
|
|
||||||
.getAll("files")
|
|
||||||
.filter((entry): entry is File => isUploadedFile(entry));
|
|
||||||
const relativePaths = formData
|
|
||||||
.getAll("relativePaths")
|
|
||||||
.map((entry) => (typeof entry === "string" ? entry : ""));
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "所选文件夹中没有可上传文件" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `单次最多打包 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件` },
|
|
||||||
{ status: 413 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalSize = files.reduce((sum, file) => sum + file.size, 0);
|
|
||||||
if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" },
|
|
||||||
{ status: 413 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const archivePaths = files.map((file, index) =>
|
|
||||||
sanitizeOpenClawArchiveEntryPath(
|
|
||||||
relativePaths[index] || file.name,
|
|
||||||
file.name,
|
|
||||||
index
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const zip = new JSZip();
|
|
||||||
for (const [index, file] of files.entries()) {
|
|
||||||
zip.file(archivePaths[index], await file.arrayBuffer(), {
|
|
||||||
date: new Date(file.lastModified || Date.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveBaseName = sanitizeOpenClawArchiveName(
|
|
||||||
String(formData.get("archiveName") || archivePaths[0]?.split("/")[0] || "")
|
|
||||||
);
|
|
||||||
const archiveName = `${archiveBaseName.replace(/\.zip$/i, "")}.zip`;
|
|
||||||
const archiveBuffer = await zip.generateAsync({
|
|
||||||
type: "nodebuffer",
|
|
||||||
compression: "DEFLATE",
|
|
||||||
compressionOptions: { level: 6 },
|
|
||||||
platform: "UNIX",
|
|
||||||
});
|
|
||||||
|
|
||||||
const storageConfigs = await getAllStorageConfigs();
|
|
||||||
const qiniuStorage = storageConfigs
|
|
||||||
.filter((config) => config.type === "qiniu-kodo" && config.isEnabled)
|
|
||||||
.sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
|
|
||||||
.find((config) => toDbS3UploadConfig(config));
|
|
||||||
const qiniuConfig = toDbS3UploadConfig(qiniuStorage);
|
|
||||||
if (!qiniuConfig) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "系统尚未配置可用的七牛云储存桶" },
|
|
||||||
{ status: 503 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploaded = await uploadFileWithS3Config(
|
|
||||||
archiveName,
|
|
||||||
toArrayBuffer(archiveBuffer),
|
|
||||||
qiniuConfig
|
|
||||||
);
|
|
||||||
if (!/^https?:\/\//i.test(uploaded.url)) {
|
|
||||||
throw new Error("七牛云未返回可访问的公网地址");
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
url: uploaded.url,
|
|
||||||
name: archiveName,
|
|
||||||
contentType: "application/zip",
|
|
||||||
size: archiveBuffer.byteLength,
|
|
||||||
originalSize,
|
|
||||||
fileCount: files.length,
|
|
||||||
folderCount: countOpenClawArchiveRootFolders(archivePaths),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to package and upload Claw folders", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error instanceof Error ? error.message : "文件夹上传失败" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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({
|
||||||
|
|||||||
@ -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 &&
|
||||||
|
|||||||
@ -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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user