feat: add local AI key pools
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-11 16:25:10 +08:00
parent 99c2edb1d4
commit f69cca1da6
12 changed files with 535 additions and 71 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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