feat: add local AI key pools
This commit is contained in:
parent
99c2edb1d4
commit
f69cca1da6
@ -1337,6 +1337,7 @@ export function AdminSettingsClient() {
|
||||
const [enhanceProConfig, setEnhanceProConfig] = useState(
|
||||
defaultEnhanceProKreaConfig()
|
||||
);
|
||||
const [enhanceProApiKeysDraft, setEnhanceProApiKeysDraft] = useState("");
|
||||
const [enhanceProPresetEditorMode, setEnhanceProPresetEditorMode] = useState<
|
||||
"simple" | "advanced"
|
||||
>("simple");
|
||||
@ -1449,6 +1450,10 @@ export function AdminSettingsClient() {
|
||||
const [threeDModelAliasConfig, setThreeDModelAliasConfig] = useState(
|
||||
defaultThreeDModelAliasConfig()
|
||||
);
|
||||
const [tripoProviderSearch, setTripoProviderSearch] = useState("");
|
||||
const [tripoProviderBusyId, setTripoProviderBusyId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [worldlabsProviderSearch, setWorldlabsProviderSearch] = useState("");
|
||||
const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState<
|
||||
string | null
|
||||
@ -1564,8 +1569,17 @@ export function AdminSettingsClient() {
|
||||
setEnhanceProConfig(
|
||||
normalizeEnhanceProKreaConfig(enhanceProConfigData.setting?.value)
|
||||
);
|
||||
setEnhanceProApiKeysDraft("");
|
||||
}, [enhanceProConfigData]);
|
||||
|
||||
const enhanceProApiKeysDraftCount = useMemo(
|
||||
() =>
|
||||
normalizeEnhanceProKreaConfig({
|
||||
apiKeys: enhanceProApiKeysDraft,
|
||||
}).apiKeys.length,
|
||||
[enhanceProApiKeysDraft]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (enhanceProConfig.presets.length === 0) {
|
||||
if (selectedEnhanceProPresetId) {
|
||||
@ -1976,6 +1990,28 @@ export function AdminSettingsClient() {
|
||||
);
|
||||
}, [threeDModelAliasConfigData]);
|
||||
|
||||
const tripoProviders = useMemo(
|
||||
() =>
|
||||
(providersData?.providers || []).filter(
|
||||
(provider) => provider.type === "tripo-3d"
|
||||
),
|
||||
[providersData?.providers]
|
||||
);
|
||||
const filteredTripoProviders = useMemo(() => {
|
||||
const keyword = tripoProviderSearch.trim().toLowerCase();
|
||||
if (!keyword) return tripoProviders;
|
||||
|
||||
return tripoProviders.filter((provider) =>
|
||||
[provider.name, provider.apiKey, provider.baseUrl]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
);
|
||||
}, [tripoProviderSearch, tripoProviders]);
|
||||
const enabledTripoProviderCount = useMemo(
|
||||
() => tripoProviders.filter((provider) => provider.isEnabled).length,
|
||||
[tripoProviders]
|
||||
);
|
||||
|
||||
const worldlabsProviders = useMemo(
|
||||
() =>
|
||||
(providersData?.providers || []).filter(
|
||||
@ -3138,13 +3174,28 @@ export function AdminSettingsClient() {
|
||||
|
||||
const handleSaveEnhanceProConfig = async () => {
|
||||
try {
|
||||
const hasEnhanceProApiKeysDraft =
|
||||
enhanceProApiKeysDraft.trim().length > 0;
|
||||
const normalizedConfig = normalizeEnhanceProKreaConfig({
|
||||
...enhanceProConfig,
|
||||
providerName:
|
||||
enhanceProConfig.providerName.trim() || "Krea Topaz Generative",
|
||||
baseUrl:
|
||||
enhanceProConfig.baseUrl.trim() ||
|
||||
ENHANCE_PRO_KREA_DEFAULT_BASE_URL,
|
||||
apiKey: enhanceProConfig.apiKey.trim(),
|
||||
apiKeys: hasEnhanceProApiKeysDraft
|
||||
? enhanceProApiKeysDraft
|
||||
: enhanceProConfig.apiKeys,
|
||||
});
|
||||
|
||||
if (enhanceProConfig.enabled) {
|
||||
if (!enhanceProConfig.baseUrl.trim()) {
|
||||
if (!normalizedConfig.baseUrl) {
|
||||
toast.error("请输入 Krea API 地址");
|
||||
return;
|
||||
}
|
||||
if (!enhanceProConfig.apiKey.trim()) {
|
||||
toast.error("请输入 Krea API Key");
|
||||
if (!normalizedConfig.apiKey && normalizedConfig.apiKeys.length === 0) {
|
||||
toast.error("请输入 Krea API Key 或 Key 池");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -3154,22 +3205,7 @@ export function AdminSettingsClient() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: ENHANCE_PRO_KREA_SETTING_KEY,
|
||||
value: {
|
||||
enabled: enhanceProConfig.enabled,
|
||||
providerName:
|
||||
enhanceProConfig.providerName.trim() || "Krea Topaz Generative",
|
||||
baseUrl:
|
||||
enhanceProConfig.baseUrl.trim() ||
|
||||
ENHANCE_PRO_KREA_DEFAULT_BASE_URL,
|
||||
apiKey: enhanceProConfig.apiKey.trim(),
|
||||
defaultModel: enhanceProConfig.defaultModel,
|
||||
defaultOutputFormat: enhanceProConfig.defaultOutputFormat,
|
||||
allowBatchSize: enhanceProConfig.allowBatchSize,
|
||||
maxBatchSize: enhanceProConfig.maxBatchSize,
|
||||
maxOutputEdge: enhanceProConfig.maxOutputEdge,
|
||||
enableAdvancedControls: enhanceProConfig.enableAdvancedControls,
|
||||
presets: enhanceProConfig.presets,
|
||||
},
|
||||
value: normalizedConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -4322,6 +4358,82 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTripo3dPoolKey = async () => {
|
||||
try {
|
||||
const trimmedName = tripo3dConfig.name.trim() || "Tripo3D Key";
|
||||
const trimmedBaseUrl =
|
||||
tripo3dConfig.baseUrl.trim() || getProviderDefaultBaseUrl("tripo-3d");
|
||||
const trimmedApiKey = tripo3dConfig.apiKey.trim();
|
||||
|
||||
if (!trimmedApiKey) {
|
||||
toast.error("请填写要加入 Key 池的 Tripo3D API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/admin/ai-providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: trimmedName,
|
||||
type: "tripo-3d",
|
||||
baseUrl: trimmedBaseUrl,
|
||||
apiKey: trimmedApiKey,
|
||||
isEnabled: tripo3dConfig.isEnabled,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("添加失败");
|
||||
}
|
||||
|
||||
toast.success("Tripo3D Key 已加入池");
|
||||
setTripo3dConfig((prev) => ({ ...prev, apiKey: "" }));
|
||||
mutateProviders();
|
||||
} catch (error) {
|
||||
toast.error("添加 Tripo3D Key 失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTripoPoolProvider = (provider: AiProvider) => {
|
||||
setTripo3dConfig({
|
||||
providerId: provider.id,
|
||||
name: provider.name || "Tripo3D",
|
||||
baseUrl: provider.baseUrl || getProviderDefaultBaseUrl("tripo-3d"),
|
||||
apiKey: "",
|
||||
isEnabled: provider.isEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleTripoPoolProvider = async (
|
||||
provider: AiProvider,
|
||||
isEnabled: boolean
|
||||
) => {
|
||||
setTripoProviderBusyId(provider.id);
|
||||
try {
|
||||
const response = await fetch("/api/admin/ai-providers", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: provider.id,
|
||||
isEnabled,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("更新状态失败");
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`已${isEnabled ? "启用" : "停用"} ${provider.name || "Tripo3D Key"}`
|
||||
);
|
||||
mutateProviders();
|
||||
} catch (error) {
|
||||
toast.error("更新 Tripo3D Key 状态失败");
|
||||
} finally {
|
||||
setTripoProviderBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveWorldlabs3dConfig = async () => {
|
||||
try {
|
||||
const trimmedName = worldlabs3dConfig.name.trim() || "World Labs";
|
||||
@ -13244,6 +13356,101 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-xl border p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Tripo3D Key 池</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
当前共 {tripoProviders.length} 个 Key,已启用{" "}
|
||||
{enabledTripoProviderCount} 个。生成任务会轮询使用可用 Key。
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full md:max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="搜索名称 / Key / API 地址"
|
||||
value={tripoProviderSearch}
|
||||
onChange={(e) => setTripoProviderSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredTripoProviders.length ? (
|
||||
filteredTripoProviders.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium">{provider.name}</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs",
|
||||
provider.isEnabled
|
||||
? "bg-emerald-500/10 text-emerald-600"
|
||||
: "bg-zinc-200 text-zinc-600"
|
||||
)}
|
||||
>
|
||||
{provider.isEnabled ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
Key: {provider.apiKey}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
API: {provider.baseUrl}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
创建时间: {formatBeijingTime(provider.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditTripoPoolProvider(provider)}
|
||||
>
|
||||
<Edit className="mr-1 h-4 w-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Switch
|
||||
checked={provider.isEnabled}
|
||||
disabled={tripoProviderBusyId === provider.id}
|
||||
onCheckedChange={(checked) =>
|
||||
void handleToggleTripoPoolProvider(
|
||||
provider,
|
||||
checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.isEnabled ? "启用中" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteProvider(provider.id)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{tripoProviders.length
|
||||
? "没有匹配到符合筛选条件的 Tripo3D Key。"
|
||||
: "当前还没有导入任何 Tripo3D Key。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">启用 Tripo3D</Label>
|
||||
@ -13263,9 +13470,14 @@ export function AdminSettingsClient() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSaveTripo3dConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存 Tripo3D 配置
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSaveTripo3dConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存 Tripo3D 配置
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleAddTripo3dPoolKey}>
|
||||
<Plus className="mr-2 h-4 w-4" /> 添加为新 Key
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -14595,6 +14807,52 @@ export function AdminSettingsClient() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enhanceProApiKeys">API Key 池</Label>
|
||||
<Textarea
|
||||
id="enhanceProApiKeys"
|
||||
value={enhanceProApiKeysDraft}
|
||||
onChange={(e) => setEnhanceProApiKeysDraft(e.target.value)}
|
||||
placeholder="为安全起见不回显已保存 Key;每行一个新 Krea Bearer Token,保存后会替换当前 Key 池。留空保存则保持当前池不变。"
|
||||
rows={4}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-xs text-muted-foreground md:flex-row md:items-center md:justify-between">
|
||||
<span>
|
||||
已保存 {enhanceProConfig.apiKeys.length} 个 Key
|
||||
{enhanceProApiKeysDraft.trim()
|
||||
? `,本次保存会替换为 ${enhanceProApiKeysDraftCount} 个 Key`
|
||||
: ";留空不会覆盖已保存池"}
|
||||
。生成任务会轮询使用,异步查询会绑定回创建任务时使用的 Key。
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEnhanceProApiKeysDraft("")}
|
||||
disabled={!enhanceProApiKeysDraft.trim()}
|
||||
>
|
||||
清空输入
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEnhanceProConfig((prev) => ({
|
||||
...prev,
|
||||
apiKeys: [],
|
||||
}));
|
||||
setEnhanceProApiKeysDraft("");
|
||||
}}
|
||||
disabled={enhanceProConfig.apiKeys.length === 0}
|
||||
>
|
||||
清空已保存 Key 池
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enhanceProDefaultModel">默认模型</Label>
|
||||
|
||||
@ -252,8 +252,8 @@ export async function POST(request: Request) {
|
||||
if (!normalizedValue.baseUrl) {
|
||||
return new ChatSDKError("bad_request:api", "请填写 Krea API 地址").toResponse();
|
||||
}
|
||||
if (!normalizedValue.apiKey) {
|
||||
return new ChatSDKError("bad_request:api", "请填写 Krea API Key").toResponse();
|
||||
if (!normalizedValue.apiKey && normalizedValue.apiKeys.length === 0) {
|
||||
return new ChatSDKError("bad_request:api", "请填写 Krea API Key 或 Key 池").toResponse();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -140,7 +140,7 @@ interface RouteParams {
|
||||
params: Promise<{ taskId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: RouteParams) {
|
||||
export async function GET(request: Request, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
@ -153,7 +153,8 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
||||
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getTripoProviderConfig();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const config = await getTripoProviderConfig(searchParams.get("providerId"));
|
||||
const result = await tripoRequest<TripoTaskData>(config, {
|
||||
method: "GET",
|
||||
path: `/task/${encodeURIComponent(normalizedTaskId)}`,
|
||||
|
||||
@ -36,6 +36,7 @@ type CreateTaskRequest = {
|
||||
files?: Array<Record<string, unknown>>;
|
||||
imageTokens?: string[];
|
||||
imageUrls?: string[];
|
||||
providerId?: string;
|
||||
originalModelTaskId?: string;
|
||||
draftModelTaskId?: string;
|
||||
format?: "GLTF" | "USDZ" | "FBX" | "OBJ" | "STL" | "3MF";
|
||||
@ -390,7 +391,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const config = await getTripoProviderConfig();
|
||||
const config = await getTripoProviderConfig(
|
||||
readOptionalTrimmedText(body.providerId)
|
||||
);
|
||||
const result = await tripoRequest<Record<string, unknown>>(config, {
|
||||
method: "POST",
|
||||
path: "/task",
|
||||
@ -443,6 +446,8 @@ export async function POST(request: Request) {
|
||||
data: {
|
||||
taskId,
|
||||
status: String(result.data?.status || "queued"),
|
||||
providerId: config.providerId || null,
|
||||
providerName: config.providerName || null,
|
||||
raw: result.data,
|
||||
},
|
||||
traceId: result.traceId,
|
||||
|
||||
@ -32,6 +32,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const providerId = String(formData.get("providerId") || "").trim();
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ success: false, error: "请上传图片文件" }, { status: 400 });
|
||||
}
|
||||
@ -50,7 +51,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getTripoProviderConfig();
|
||||
const config = await getTripoProviderConfig(providerId);
|
||||
|
||||
const uploadPaths = ["/upload/sts", "/upload"];
|
||||
let lastError: unknown = null;
|
||||
@ -70,6 +71,8 @@ export async function POST(request: Request) {
|
||||
data: {
|
||||
imageToken,
|
||||
file: result.data,
|
||||
providerId: config.providerId || null,
|
||||
providerName: config.providerName || null,
|
||||
},
|
||||
traceId: result.traceId,
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
@ -27,6 +28,7 @@ const ENHANCE_PRO_SYNC_WAIT_TIMEOUT_MS = 20_000;
|
||||
const ENHANCE_PRO_TASK_POLL_INTERVAL_MS = 1_500;
|
||||
const ENHANCE_PRO_MODEL_ID = "krea-topaz-generative";
|
||||
const ENHANCE_PRO_MAX_RESULT_IMAGES = 4;
|
||||
let enhanceProKeyPoolCursor = 0;
|
||||
|
||||
type EnhanceProRequestBody = {
|
||||
imageUrl?: string;
|
||||
@ -83,13 +85,55 @@ function normalizeJobStatus(value: unknown) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildEnhanceProTaskPendingResponse(taskId: string) {
|
||||
function getEnhanceProKeyFingerprint(apiKey: string) {
|
||||
return createHash("sha256")
|
||||
.update(String(apiKey || "").trim())
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
function getEnhanceProEffectiveApiKeys(config: EnhanceProKreaConfig) {
|
||||
const pooledKeys = Array.isArray(config.apiKeys) ? config.apiKeys : [];
|
||||
if (pooledKeys.length > 0) return pooledKeys;
|
||||
return config.apiKey ? [config.apiKey] : [];
|
||||
}
|
||||
|
||||
function resolveEnhanceProApiKey(
|
||||
config: EnhanceProKreaConfig,
|
||||
preferredKeyId?: string | null,
|
||||
) {
|
||||
const keys = getEnhanceProEffectiveApiKeys(config);
|
||||
if (keys.length === 0) return null;
|
||||
|
||||
const normalizedPreferredKeyId = String(preferredKeyId || "").trim();
|
||||
if (normalizedPreferredKeyId) {
|
||||
const matchedKey = keys.find(
|
||||
(apiKey) => getEnhanceProKeyFingerprint(apiKey) === normalizedPreferredKeyId,
|
||||
);
|
||||
if (matchedKey) {
|
||||
return {
|
||||
apiKey: matchedKey,
|
||||
keyId: normalizedPreferredKeyId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const selectedKey = keys[enhanceProKeyPoolCursor % keys.length];
|
||||
enhanceProKeyPoolCursor = (enhanceProKeyPoolCursor + 1) % keys.length;
|
||||
return {
|
||||
apiKey: selectedKey,
|
||||
keyId: getEnhanceProKeyFingerprint(selectedKey),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEnhanceProTaskPendingResponse(taskId: string, taskKeyId?: string | null) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
processing: true,
|
||||
status: "running",
|
||||
taskId,
|
||||
taskKeyId,
|
||||
pollAfterMs: ENHANCE_PRO_TASK_POLL_INTERVAL_MS,
|
||||
},
|
||||
{ status: 202 }
|
||||
@ -513,16 +557,20 @@ export async function GET(request: NextRequest) {
|
||||
if (!config.enabled) {
|
||||
return NextResponse.json({ error: "图像增强 Pro 功能未开启" }, { status: 403 });
|
||||
}
|
||||
if (!config.apiKey || !config.baseUrl) {
|
||||
const keySelection = resolveEnhanceProApiKey(config, searchParams.get("taskKeyId"));
|
||||
if (!keySelection || !config.baseUrl) {
|
||||
return NextResponse.json({ error: "图像增强 Pro API 尚未配置" }, { status: 400 });
|
||||
}
|
||||
|
||||
let taskResult: any;
|
||||
try {
|
||||
taskResult = await pollEnhanceProJob(config, taskId);
|
||||
taskResult = await pollEnhanceProJob(
|
||||
{ ...config, apiKey: keySelection.apiKey },
|
||||
taskId
|
||||
);
|
||||
} catch (pollError) {
|
||||
if (isEnhanceProTaskStillProcessing(pollError)) {
|
||||
return buildEnhanceProTaskPendingResponse(taskId);
|
||||
return buildEnhanceProTaskPendingResponse(taskId, keySelection.keyId);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
@ -569,9 +617,11 @@ export async function POST(request: NextRequest) {
|
||||
if (!config.enabled) {
|
||||
return NextResponse.json({ error: "图像增强 Pro 功能未开启" }, { status: 403 });
|
||||
}
|
||||
if (!config.apiKey || !config.baseUrl) {
|
||||
const keySelection = resolveEnhanceProApiKey(config);
|
||||
if (!keySelection || !config.baseUrl) {
|
||||
return NextResponse.json({ error: "图像增强 Pro API 尚未配置" }, { status: 400 });
|
||||
}
|
||||
const requestConfig = { ...config, apiKey: keySelection.apiKey };
|
||||
|
||||
const body = (await request.json()) as EnhanceProRequestBody;
|
||||
const prompt = String(body.prompt || "").trim();
|
||||
@ -628,7 +678,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const startTime = Date.now();
|
||||
const createdJob = await createEnhanceProJob({
|
||||
config,
|
||||
config: requestConfig,
|
||||
requestBody: {
|
||||
...body,
|
||||
batchSize,
|
||||
@ -683,7 +733,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const taskResult = await pollEnhanceProJob(config, taskId);
|
||||
const taskResult = await pollEnhanceProJob(requestConfig, taskId);
|
||||
const responsePayload = await finalizeEnhanceProTaskResult({
|
||||
userId: session.user.id,
|
||||
taskResult,
|
||||
@ -701,7 +751,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
} catch (pollError) {
|
||||
if (isEnhanceProTaskStillProcessing(pollError)) {
|
||||
return buildEnhanceProTaskPendingResponse(taskId);
|
||||
return buildEnhanceProTaskPendingResponse(taskId, keySelection.keyId);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
@ -110,6 +110,10 @@ type GenerationMode = "text" | "image" | "multiview" | "batch";
|
||||
type WorkspaceMode = "model" | "world";
|
||||
type WorldGenerationMode = "text" | "image" | "multi-image" | "video";
|
||||
type WorldViewportMode = "world" | "imagery" | "mesh" | "splat";
|
||||
type Model3DTaskAssetPayload = WorldLabsTaskAssetPayload & {
|
||||
providerId?: string | null;
|
||||
providerName?: string | null;
|
||||
};
|
||||
type TaskProvider = "tripo-3d" | "worldlabs-3d";
|
||||
type TaskMode =
|
||||
| GenerationMode
|
||||
@ -142,7 +146,7 @@ type TaskRecord = {
|
||||
error?: string | null;
|
||||
sourceTaskId?: string | null;
|
||||
exportFormat?: string | null;
|
||||
assetPayload?: WorldLabsTaskAssetPayload | null;
|
||||
assetPayload?: Model3DTaskAssetPayload | null;
|
||||
shareEnabled?: boolean;
|
||||
shareToken?: string | null;
|
||||
sharedAt?: number | null;
|
||||
@ -403,9 +407,9 @@ function normalizeTaskProvider(provider: unknown): TaskProvider {
|
||||
|
||||
function normalizeAssetPayload(
|
||||
payload: unknown
|
||||
): WorldLabsTaskAssetPayload | null {
|
||||
): Model3DTaskAssetPayload | null {
|
||||
return payload && typeof payload === "object" && !Array.isArray(payload)
|
||||
? (payload as WorldLabsTaskAssetPayload)
|
||||
? (payload as Model3DTaskAssetPayload)
|
||||
: null;
|
||||
}
|
||||
|
||||
@ -1086,7 +1090,9 @@ export default function CanvasModel3DPage() {
|
||||
? await worldLabsGenerationService.getTask(currentTask.taskId, {
|
||||
providerId: currentTask.assetPayload?.providerId,
|
||||
})
|
||||
: await model3DGenerationService.getTask(currentTask.taskId);
|
||||
: await model3DGenerationService.getTask(currentTask.taskId, {
|
||||
providerId: currentTask.assetPayload?.providerId,
|
||||
});
|
||||
if (!result.success || !result.data) {
|
||||
const failMessage = result.error || "任务查询失败";
|
||||
const failedRecord: TaskRecord = {
|
||||
@ -1295,7 +1301,7 @@ export default function CanvasModel3DPage() {
|
||||
modelLabel: string;
|
||||
sourceTaskId?: string | null;
|
||||
exportFormat?: string | null;
|
||||
assetPayload?: WorldLabsTaskAssetPayload | null;
|
||||
assetPayload?: Model3DTaskAssetPayload | null;
|
||||
}) => {
|
||||
const createResult =
|
||||
params.provider === "worldlabs-3d"
|
||||
@ -1309,6 +1315,27 @@ export default function CanvasModel3DPage() {
|
||||
"assetPayload" in createResult.data
|
||||
? createResult.data
|
||||
: null;
|
||||
const tripoCreateResultData =
|
||||
params.provider === "tripo-3d"
|
||||
? (createResult.data as {
|
||||
providerId?: string | null;
|
||||
providerName?: string | null;
|
||||
})
|
||||
: null;
|
||||
const tripoAssetPayload =
|
||||
params.provider === "tripo-3d"
|
||||
? {
|
||||
...(params.assetPayload || {}),
|
||||
providerId:
|
||||
tripoCreateResultData?.providerId ||
|
||||
params.assetPayload?.providerId ||
|
||||
null,
|
||||
providerName:
|
||||
tripoCreateResultData?.providerName ||
|
||||
params.assetPayload?.providerName ||
|
||||
null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const newTask: TaskRecord = {
|
||||
taskId: createResult.data.taskId,
|
||||
@ -1323,7 +1350,7 @@ export default function CanvasModel3DPage() {
|
||||
assetPayload:
|
||||
params.provider === "worldlabs-3d"
|
||||
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
|
||||
: params.assetPayload ?? null,
|
||||
: tripoAssetPayload,
|
||||
};
|
||||
setCurrentTask(newTask);
|
||||
upsertTaskHistory(newTask);
|
||||
@ -1419,12 +1446,19 @@ export default function CanvasModel3DPage() {
|
||||
} else {
|
||||
throw new Error("参考图上传未返回可用 token");
|
||||
}
|
||||
if (uploadResult.data.providerId) {
|
||||
payload.providerId = uploadResult.data.providerId;
|
||||
}
|
||||
await submitTask({
|
||||
provider: "tripo-3d",
|
||||
payload,
|
||||
mode: "image",
|
||||
promptText: normalizedPrompt || "图生3D",
|
||||
modelLabel: modelVersion,
|
||||
assetPayload: {
|
||||
providerId: uploadResult.data.providerId || null,
|
||||
providerName: uploadResult.data.providerName || null,
|
||||
},
|
||||
});
|
||||
toast.success("已提交图生3D任务");
|
||||
return;
|
||||
@ -1446,12 +1480,19 @@ export default function CanvasModel3DPage() {
|
||||
} else {
|
||||
throw new Error(`图片未返回可用 token: ${file.name}`);
|
||||
}
|
||||
if (uploadResult.data.providerId) {
|
||||
payload.providerId = uploadResult.data.providerId;
|
||||
}
|
||||
await submitTask({
|
||||
provider: "tripo-3d",
|
||||
payload,
|
||||
mode: "batch",
|
||||
promptText: `${normalizedPrompt || "批量图生3D"} · ${file.name}`,
|
||||
modelLabel: modelVersion,
|
||||
assetPayload: {
|
||||
providerId: uploadResult.data.providerId || null,
|
||||
providerName: uploadResult.data.providerName || null,
|
||||
},
|
||||
});
|
||||
successCount += 1;
|
||||
}
|
||||
@ -1460,11 +1501,21 @@ export default function CanvasModel3DPage() {
|
||||
}
|
||||
|
||||
setUploadingImage(true);
|
||||
const uploadResults = await Promise.all(
|
||||
selectedMultiFiles.map(async (file) =>
|
||||
model3DGenerationService.uploadImage(file)
|
||||
)
|
||||
);
|
||||
const uploadResults: Array<
|
||||
Awaited<ReturnType<typeof model3DGenerationService.uploadImage>>
|
||||
> = [];
|
||||
let boundTripoProviderId: string | null = null;
|
||||
let boundTripoProviderName: string | null = null;
|
||||
for (const file of selectedMultiFiles) {
|
||||
const result = await model3DGenerationService.uploadImage(file, {
|
||||
providerId: boundTripoProviderId,
|
||||
});
|
||||
if (result.success && result.data?.providerId && !boundTripoProviderId) {
|
||||
boundTripoProviderId = result.data.providerId;
|
||||
boundTripoProviderName = result.data.providerName || null;
|
||||
}
|
||||
uploadResults.push(result);
|
||||
}
|
||||
const filesPayload: Array<Record<string, any>> = [];
|
||||
for (const result of uploadResults) {
|
||||
if (!result.success || !result.data) {
|
||||
@ -1484,12 +1535,19 @@ export default function CanvasModel3DPage() {
|
||||
}
|
||||
const payload = buildGenerationPayload("multiview_to_model");
|
||||
payload.files = filesPayload;
|
||||
if (boundTripoProviderId) {
|
||||
payload.providerId = boundTripoProviderId;
|
||||
}
|
||||
await submitTask({
|
||||
provider: "tripo-3d",
|
||||
payload,
|
||||
mode: "multiview",
|
||||
promptText: normalizedPrompt || "多视图3D",
|
||||
modelLabel: modelVersion,
|
||||
assetPayload: {
|
||||
providerId: boundTripoProviderId,
|
||||
providerName: boundTripoProviderName,
|
||||
},
|
||||
});
|
||||
toast.success("已提交多视图3D任务");
|
||||
} catch (error) {
|
||||
|
||||
@ -1415,7 +1415,7 @@ export function LovartHome({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside className="fixed bottom-6 left-6 top-[92px] z-30 hidden flex-col items-center gap-2 lg:flex">
|
||||
<aside className="fixed left-6 top-[92px] z-30 hidden flex-col items-center gap-2 lg:flex">
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@ -1431,7 +1431,7 @@ export function LovartHome({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">新建项目</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex min-h-0 w-[56px] flex-1 flex-col items-center gap-1 overflow-y-auto rounded-[28px] border border-black/10 bg-white px-1.5 py-2.5 shadow">
|
||||
<div className="flex max-h-[calc(100vh-172px)] w-[56px] flex-col items-center gap-1 overflow-y-auto rounded-[28px] border border-black/10 bg-white px-1.5 py-2.5 shadow">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@ -102,6 +102,10 @@ export async function requestLongTaskResult(params: {
|
||||
|
||||
const pollingParams = new URLSearchParams();
|
||||
pollingParams.set("taskId", taskId);
|
||||
const taskKeyId = String(result?.taskKeyId || "").trim();
|
||||
if (taskKeyId) {
|
||||
pollingParams.set("taskKeyId", taskKeyId);
|
||||
}
|
||||
for (const [key, value] of Object.entries(params.pollingParams || {})) {
|
||||
if (value === null || value === undefined || value === "") continue;
|
||||
pollingParams.set(key, String(value));
|
||||
|
||||
@ -44,6 +44,7 @@ export type EnhanceProKreaConfig = {
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
apiKeys: string[];
|
||||
defaultModel: EnhanceProKreaModel;
|
||||
defaultOutputFormat: EnhanceProOutputFormat;
|
||||
allowBatchSize: boolean;
|
||||
@ -88,6 +89,30 @@ function createEnhanceProPresetId(name: string, index: number) {
|
||||
return normalized || `enhance-pro-preset-${index + 1}`;
|
||||
}
|
||||
|
||||
function normalizeEnhanceProApiKey(value: unknown) {
|
||||
return String(value || "")
|
||||
.replace(/[\u2028\u2029]/g, "")
|
||||
.replace(/[\u0000-\u001F\u007F]/g, "")
|
||||
.replace(/^bearer\s+/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeEnhanceProApiKeys(value: unknown) {
|
||||
const candidates = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? value.split(/\r?\n|,/)
|
||||
: [];
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
candidates
|
||||
.map((item) => normalizeEnhanceProApiKey(item))
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function defaultEnhanceProPresets(): EnhanceProPreset[] {
|
||||
return [
|
||||
{
|
||||
@ -263,6 +288,7 @@ export function defaultEnhanceProKreaConfig(): EnhanceProKreaConfig {
|
||||
providerName: ENHANCE_PRO_KREA_DEFAULT_PROVIDER_NAME,
|
||||
baseUrl: ENHANCE_PRO_KREA_DEFAULT_BASE_URL,
|
||||
apiKey: "",
|
||||
apiKeys: [],
|
||||
defaultModel: ENHANCE_PRO_KREA_DEFAULT_MODEL,
|
||||
defaultOutputFormat: ENHANCE_PRO_KREA_DEFAULT_OUTPUT_FORMAT,
|
||||
allowBatchSize: true,
|
||||
@ -309,7 +335,10 @@ export function normalizeEnhanceProKreaConfig(
|
||||
String(raw.baseUrl || "")
|
||||
.trim()
|
||||
.replace(TRAILING_SLASHES_REGEX, "") || defaults.baseUrl,
|
||||
apiKey: String(raw.apiKey || "").trim() || apiKeyFallback,
|
||||
apiKey:
|
||||
normalizeEnhanceProApiKey(raw.apiKey) ||
|
||||
normalizeEnhanceProApiKey(apiKeyFallback),
|
||||
apiKeys: normalizeEnhanceProApiKeys(raw.apiKeys || raw.apiKeyPool),
|
||||
defaultModel:
|
||||
defaultModel === "Recovery" ||
|
||||
defaultModel === "Recovery V2" ||
|
||||
@ -342,6 +371,7 @@ export function sanitizeEnhanceProKreaConfigForClient(
|
||||
enabled: config.enabled,
|
||||
providerName: config.providerName,
|
||||
baseUrl: config.baseUrl,
|
||||
keyPoolSize: config.apiKeys.length,
|
||||
defaultModel: config.defaultModel,
|
||||
defaultOutputFormat: config.defaultOutputFormat,
|
||||
allowBatchSize: config.allowBatchSize,
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import "server-only";
|
||||
|
||||
import { getEnabledAiProviders } from "@/lib/db/queries";
|
||||
import { getAiProvidersByType } from "@/lib/db/queries";
|
||||
import type { TripoProviderConfig } from "./types";
|
||||
|
||||
const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||
let tripoProviderCursor = 0;
|
||||
|
||||
function sanitizeTripoApiKey(value: string): string {
|
||||
return String(value || "")
|
||||
@ -20,27 +21,68 @@ function sanitizeTripoBaseUrl(value: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function getTripoProviderConfig(): Promise<TripoProviderConfig> {
|
||||
const providers = await getEnabledAiProviders();
|
||||
const tripoProvider = providers.find((item) => item.type === "tripo-3d");
|
||||
export async function getTripoProviderConfigs(): Promise<TripoProviderConfig[]> {
|
||||
const providers = await getAiProvidersByType({
|
||||
type: "tripo-3d",
|
||||
enabledOnly: true,
|
||||
});
|
||||
|
||||
const providerApiKey = sanitizeTripoApiKey(String(tripoProvider?.apiKey || ""));
|
||||
if (tripoProvider && providerApiKey) {
|
||||
return {
|
||||
apiKey: providerApiKey,
|
||||
baseUrl: sanitizeTripoBaseUrl(String(tripoProvider?.baseUrl || "")) || DEFAULT_TRIPO_BASE_URL,
|
||||
providerId: tripoProvider.id,
|
||||
providerName: tripoProvider.name,
|
||||
};
|
||||
const normalizedProviders = providers
|
||||
.map((provider): TripoProviderConfig | null => {
|
||||
const providerApiKey = sanitizeTripoApiKey(String(provider.apiKey || ""));
|
||||
if (!providerApiKey) return null;
|
||||
return {
|
||||
apiKey: providerApiKey,
|
||||
baseUrl:
|
||||
sanitizeTripoBaseUrl(String(provider.baseUrl || "")) ||
|
||||
DEFAULT_TRIPO_BASE_URL,
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
};
|
||||
})
|
||||
.filter((item): item is TripoProviderConfig => Boolean(item));
|
||||
|
||||
if (normalizedProviders.length > 0) {
|
||||
return normalizedProviders;
|
||||
}
|
||||
|
||||
const envApiKey = sanitizeTripoApiKey(String(process.env.TRIPO_API_KEY || ""));
|
||||
if (!envApiKey) {
|
||||
throw new Error("未配置 Tripo3D API,请在系统设置-本地AI新增 Tripo3D 提供商");
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: envApiKey,
|
||||
baseUrl: sanitizeTripoBaseUrl(String(process.env.TRIPO_BASE_URL || "")) || DEFAULT_TRIPO_BASE_URL,
|
||||
};
|
||||
return [
|
||||
{
|
||||
apiKey: envApiKey,
|
||||
baseUrl:
|
||||
sanitizeTripoBaseUrl(String(process.env.TRIPO_BASE_URL || "")) ||
|
||||
DEFAULT_TRIPO_BASE_URL,
|
||||
providerName: "TRIPO_API_KEY",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function getTripoProviderConfig(
|
||||
preferredProviderId?: string | null,
|
||||
): Promise<TripoProviderConfig> {
|
||||
const providers = await getTripoProviderConfigs();
|
||||
const normalizedPreferredProviderId = String(preferredProviderId || "").trim();
|
||||
|
||||
if (normalizedPreferredProviderId) {
|
||||
const matchedProvider = providers.find(
|
||||
(provider) => provider.providerId === normalizedPreferredProviderId,
|
||||
);
|
||||
if (matchedProvider) return matchedProvider;
|
||||
}
|
||||
|
||||
const tripoProvider =
|
||||
providers.length > 0
|
||||
? providers[tripoProviderCursor % providers.length]
|
||||
: null;
|
||||
if (providers.length > 0) {
|
||||
tripoProviderCursor = (tripoProviderCursor + 1) % providers.length;
|
||||
}
|
||||
if (tripoProvider) return tripoProvider;
|
||||
|
||||
throw new Error("未配置 Tripo3D API,请在系统设置-本地AI新增 Tripo3D 提供商");
|
||||
}
|
||||
|
||||
@ -762,10 +762,16 @@ export type Model3DTaskStatus =
|
||||
| 'unknown';
|
||||
|
||||
export const model3DGenerationService = {
|
||||
async uploadImage(file: File): Promise<ApiResponse<{ imageToken?: string; file?: Record<string, any> }>> {
|
||||
async uploadImage(
|
||||
file: File,
|
||||
options?: { providerId?: string | null },
|
||||
): Promise<ApiResponse<{ imageToken?: string; file?: Record<string, any>; providerId?: string | null; providerName?: string | null }>> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (options?.providerId) {
|
||||
formData.append('providerId', options.providerId);
|
||||
}
|
||||
const response = await fetch('/api/canvas/3d/upload-image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@ -777,7 +783,7 @@ export const model3DGenerationService = {
|
||||
}
|
||||
},
|
||||
|
||||
async createTask(params: Record<string, any>): Promise<ApiResponse<{ taskId: string; status: Model3DTaskStatus; raw?: any }>> {
|
||||
async createTask(params: Record<string, any>): Promise<ApiResponse<{ taskId: string; status: Model3DTaskStatus; providerId?: string | null; providerName?: string | null; raw?: any }>> {
|
||||
try {
|
||||
const response = await fetch('/api/canvas/3d/tasks', {
|
||||
method: 'POST',
|
||||
@ -791,7 +797,7 @@ export const model3DGenerationService = {
|
||||
}
|
||||
},
|
||||
|
||||
async getTask(taskId: string): Promise<ApiResponse<{
|
||||
async getTask(taskId: string, options?: { providerId?: string | null }): Promise<ApiResponse<{
|
||||
taskId: string;
|
||||
type: string;
|
||||
status: Model3DTaskStatus;
|
||||
@ -803,7 +809,14 @@ export const model3DGenerationService = {
|
||||
errorMessage?: string | null;
|
||||
}>> {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/3d/tasks/${taskId}`);
|
||||
const params = new URLSearchParams();
|
||||
if (options?.providerId) {
|
||||
params.set('providerId', options.providerId);
|
||||
}
|
||||
const query = params.toString();
|
||||
const response = await fetch(
|
||||
`/api/canvas/3d/tasks/${taskId}${query ? `?${query}` : ''}`
|
||||
);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user