feat: add external image generation api
This commit is contained in:
parent
040a1f514f
commit
b4698be089
@ -218,6 +218,7 @@ import {
|
||||
normalizeDulupayConfig,
|
||||
type DulupayConfig,
|
||||
} from "@/lib/payment/dulupay-config";
|
||||
import { ExternalImageApiSettingsCard } from "@/components/admin/external-image-api-settings-card";
|
||||
import {
|
||||
DEFAULT_CANVAS_SKILL_TAGS,
|
||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||
@ -17726,6 +17727,8 @@ export function AdminSettingsClient() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ExternalImageApiSettingsCard />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
74
app/api/admin/external-image-api/logs/route.ts
Normal file
74
app/api/admin/external-image-api/logs/route.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalImageApiRequestLog, user } from "@/lib/db/schema";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, Number.parseInt(searchParams.get("limit") || "50", 10)),
|
||||
);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: externalImageApiRequestLog.id,
|
||||
apiKeyId: externalImageApiRequestLog.apiKeyId,
|
||||
apiKeyName: externalImageApiRequestLog.apiKeyName,
|
||||
ownerUserId: externalImageApiRequestLog.ownerUserId,
|
||||
ownerUserEmail: user.email,
|
||||
ownerUserName: user.name,
|
||||
modelDbId: externalImageApiRequestLog.modelDbId,
|
||||
modelRuntimeId: externalImageApiRequestLog.modelRuntimeId,
|
||||
modelDisplayName: externalImageApiRequestLog.modelDisplayName,
|
||||
prompt: externalImageApiRequestLog.prompt,
|
||||
negativePrompt: externalImageApiRequestLog.negativePrompt,
|
||||
imageCount: externalImageApiRequestLog.imageCount,
|
||||
aspectRatio: externalImageApiRequestLog.aspectRatio,
|
||||
imageSize: externalImageApiRequestLog.imageSize,
|
||||
status: externalImageApiRequestLog.status,
|
||||
requesterIp: externalImageApiRequestLog.requesterIp,
|
||||
requesterUserAgent: externalImageApiRequestLog.requesterUserAgent,
|
||||
requestPayload: externalImageApiRequestLog.requestPayload,
|
||||
responsePayload: externalImageApiRequestLog.responsePayload,
|
||||
generatedImageIds: externalImageApiRequestLog.generatedImageIds,
|
||||
generatedAssetIds: externalImageApiRequestLog.generatedAssetIds,
|
||||
generatedImageUrls: externalImageApiRequestLog.generatedImageUrls,
|
||||
errorMessage: externalImageApiRequestLog.errorMessage,
|
||||
durationMs: externalImageApiRequestLog.durationMs,
|
||||
createdAt: externalImageApiRequestLog.createdAt,
|
||||
completedAt: externalImageApiRequestLog.completedAt,
|
||||
})
|
||||
.from(externalImageApiRequestLog)
|
||||
.leftJoin(user, eq(externalImageApiRequestLog.ownerUserId, user.id))
|
||||
.orderBy(desc(externalImageApiRequestLog.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
logs: rows,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
console.error("[Admin External Image API Logs] GET failed:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "获取日志失败",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -63,6 +63,10 @@ import {
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
normalizeStyleProConfig,
|
||||
} from "@/lib/style-pro/config";
|
||||
import {
|
||||
EXTERNAL_IMAGE_API_CONFIG_KEY,
|
||||
normalizeExternalImageApiConfig,
|
||||
} from "@/lib/external-image-api/config";
|
||||
|
||||
const settingSchema = z.object({
|
||||
key: z.string(),
|
||||
@ -128,6 +132,12 @@ export async function GET(request: Request) {
|
||||
setting: setting ? { ...setting, value } : { key, value },
|
||||
});
|
||||
}
|
||||
if (key === EXTERNAL_IMAGE_API_CONFIG_KEY) {
|
||||
const value = normalizeExternalImageApiConfig(setting?.value);
|
||||
return Response.json({
|
||||
setting: setting ? { ...setting, value } : { key, value },
|
||||
});
|
||||
}
|
||||
return Response.json({ setting });
|
||||
}
|
||||
|
||||
@ -254,6 +264,18 @@ export async function POST(request: Request) {
|
||||
nextValue = normalizedValue;
|
||||
}
|
||||
|
||||
if (key === EXTERNAL_IMAGE_API_CONFIG_KEY) {
|
||||
const saved = await upsertSystemSetting({
|
||||
key: EXTERNAL_IMAGE_API_CONFIG_KEY,
|
||||
value: normalizeExternalImageApiConfig(value),
|
||||
updatedBy: session.user.id,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
setting: saved[0],
|
||||
});
|
||||
}
|
||||
|
||||
if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) {
|
||||
nextValue = normalizeVideoCanvasFunctionConfig(value);
|
||||
}
|
||||
|
||||
184
app/api/external/image/generate/route.ts
vendored
Normal file
184
app/api/external/image/generate/route.ts
vendored
Normal file
@ -0,0 +1,184 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalImageApiRequestLog } from "@/lib/db/schema";
|
||||
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
|
||||
import {
|
||||
authenticateExternalImageApiRequest,
|
||||
buildExternalImageApiKeyFingerprint,
|
||||
ensureHostedImageMarkdown,
|
||||
extractGeneratedImagesFromMarkdown,
|
||||
generateImagesWithModel,
|
||||
getRequesterIp,
|
||||
resolveExternalImageApiModel,
|
||||
resolveExternalImageApiOwnerUserId,
|
||||
serializeExternalImageApiModel,
|
||||
} from "@/lib/external-image-api/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
|
||||
const requestSchema = z.object({
|
||||
model: z.string().trim().min(1).optional(),
|
||||
prompt: z.string().trim().min(1, "prompt 不能为空"),
|
||||
negativePrompt: z.string().trim().optional(),
|
||||
imageCount: z.coerce.number().int().min(1).max(8).optional(),
|
||||
aspectRatio: z.string().trim().max(32).optional(),
|
||||
imageSize: z.string().trim().max(32).optional(),
|
||||
metadata: z.record(z.string(), z.any()).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authResult = await authenticateExternalImageApiRequest(request);
|
||||
if (!authResult.accessKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error,
|
||||
},
|
||||
{ status: authResult.status },
|
||||
);
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
let logId: string | null = null;
|
||||
|
||||
try {
|
||||
const json = await request.json();
|
||||
const parsed = requestSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: parsed.error.errors[0]?.message || "参数错误",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = parsed.data;
|
||||
const selectedModel = await resolveExternalImageApiModel(payload.model);
|
||||
const ownerUserId = await resolveExternalImageApiOwnerUserId(
|
||||
authResult.config.defaultOwnerEmail,
|
||||
);
|
||||
const requesterIp = getRequesterIp(request);
|
||||
const requesterUserAgent = request.headers.get("user-agent") || null;
|
||||
|
||||
const [createdLog] = await db
|
||||
.insert(externalImageApiRequestLog)
|
||||
.values({
|
||||
apiKeyId: authResult.accessKey.id,
|
||||
apiKeyName: authResult.accessKey.name,
|
||||
ownerUserId,
|
||||
modelDbId: selectedModel.model.id,
|
||||
modelRuntimeId: selectedModel.model.modelId,
|
||||
modelDisplayName: selectedModel.model.displayName,
|
||||
prompt: payload.prompt,
|
||||
negativePrompt: payload.negativePrompt || null,
|
||||
imageCount: payload.imageCount || 1,
|
||||
aspectRatio: payload.aspectRatio || null,
|
||||
imageSize: payload.imageSize || null,
|
||||
status: "pending",
|
||||
requesterIp,
|
||||
requesterUserAgent,
|
||||
requestPayload: {
|
||||
...payload,
|
||||
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
|
||||
authResult.accessKey,
|
||||
),
|
||||
},
|
||||
})
|
||||
.returning({ id: externalImageApiRequestLog.id });
|
||||
|
||||
logId = createdLog?.id || null;
|
||||
|
||||
const rawMarkdown = await generateImagesWithModel({
|
||||
modelDbId: selectedModel.model.id,
|
||||
prompt: payload.prompt,
|
||||
negativePrompt: payload.negativePrompt || null,
|
||||
imageCount: payload.imageCount || 1,
|
||||
aspectRatio: payload.aspectRatio || null,
|
||||
imageSize: payload.imageSize || null,
|
||||
});
|
||||
|
||||
const hostedMarkdown = await ensureHostedImageMarkdown(
|
||||
rawMarkdown,
|
||||
payload.imageSize || "2K",
|
||||
);
|
||||
const images = extractGeneratedImagesFromMarkdown(hostedMarkdown);
|
||||
if (!images.length) {
|
||||
throw new Error("模型返回结果里未解析到图片");
|
||||
}
|
||||
|
||||
const persistResult = await saveGeneratedImages(
|
||||
hostedMarkdown,
|
||||
ownerUserId,
|
||||
null,
|
||||
null,
|
||||
payload.prompt,
|
||||
selectedModel.model.id,
|
||||
{
|
||||
sourceType: "external_api_generated",
|
||||
userPrompt: payload.prompt,
|
||||
optimizedPrompt: payload.prompt,
|
||||
requestedImageCount: payload.imageCount || images.length,
|
||||
aspectRatio: payload.aspectRatio,
|
||||
imageSize: payload.imageSize,
|
||||
skipBilling: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (logId) {
|
||||
await db
|
||||
.update(externalImageApiRequestLog)
|
||||
.set({
|
||||
status: "succeeded",
|
||||
responsePayload: {
|
||||
model: serializeExternalImageApiModel(selectedModel),
|
||||
markdown: hostedMarkdown,
|
||||
images,
|
||||
},
|
||||
generatedImageIds: persistResult.savedImageIds,
|
||||
generatedAssetIds: persistResult.savedAssetIds,
|
||||
generatedImageUrls: persistResult.savedOriginalUrls,
|
||||
durationMs: Date.now() - startedAt,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(externalImageApiRequestLog.id, logId));
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
requestId: logId,
|
||||
model: serializeExternalImageApiModel(selectedModel),
|
||||
images,
|
||||
generatedImageIds: persistResult.savedImageIds,
|
||||
generatedAssetIds: persistResult.savedAssetIds,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[External Image API] Generate failed:", error);
|
||||
|
||||
if (logId) {
|
||||
await db
|
||||
.update(externalImageApiRequestLog)
|
||||
.set({
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : "图片生成失败",
|
||||
durationMs: Date.now() - startedAt,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(externalImageApiRequestLog.id, logId));
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "图片生成失败",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/api/external/image/models/route.ts
vendored
Normal file
42
app/api/external/image/models/route.ts
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
authenticateExternalImageApiRequest,
|
||||
getExternalImageApiModels,
|
||||
serializeExternalImageApiModel,
|
||||
} from "@/lib/external-image-api/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const authResult = await authenticateExternalImageApiRequest(request);
|
||||
if (!authResult.accessKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error,
|
||||
},
|
||||
{ status: authResult.status },
|
||||
);
|
||||
}
|
||||
|
||||
const models = await getExternalImageApiModels();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
models: models.map(serializeExternalImageApiModel),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[External Image API] Failed to load models:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "获取模型列表失败",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
458
components/admin/external-image-api-settings-card.tsx
Normal file
458
components/admin/external-image-api-settings-card.tsx
Normal file
@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { toast } from "sonner";
|
||||
import { KeyRound, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { formatBeijingTime } from "@/lib/utils";
|
||||
import {
|
||||
defaultExternalImageApiConfig,
|
||||
EXTERNAL_IMAGE_API_CONFIG_KEY,
|
||||
maskExternalImageApiKey,
|
||||
normalizeExternalImageApiConfig,
|
||||
type ExternalImageApiConfig,
|
||||
} from "@/lib/external-image-api/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload?.error || payload?.message || "请求失败");
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
type AdminExternalImageApiLog = {
|
||||
id: string;
|
||||
apiKeyId: string | null;
|
||||
apiKeyName: string | null;
|
||||
ownerUserEmail: string | null;
|
||||
modelDisplayName: string | null;
|
||||
modelRuntimeId: string | null;
|
||||
prompt: string | null;
|
||||
negativePrompt: string | null;
|
||||
imageCount: number;
|
||||
aspectRatio: string | null;
|
||||
imageSize: string | null;
|
||||
status: string;
|
||||
generatedImageUrls: string[] | null;
|
||||
errorMessage: string | null;
|
||||
durationMs: number | null;
|
||||
requesterIp: string | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
function buildKeyDraft() {
|
||||
return {
|
||||
id: `external-key-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: "",
|
||||
apiKey: "",
|
||||
enabled: true,
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function ExternalImageApiSettingsCard() {
|
||||
const { data: configData, mutate: mutateConfig } = useSWR<{
|
||||
setting?: { value?: unknown };
|
||||
}>(`/api/admin/settings?key=${EXTERNAL_IMAGE_API_CONFIG_KEY}`, fetcher);
|
||||
const { data: logsData, mutate: mutateLogs } = useSWR<{
|
||||
success: boolean;
|
||||
data: { logs: AdminExternalImageApiLog[] };
|
||||
}>("/api/admin/external-image-api/logs?limit=50", fetcher);
|
||||
|
||||
const [config, setConfig] = useState<ExternalImageApiConfig>(
|
||||
defaultExternalImageApiConfig,
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setConfig(
|
||||
normalizeExternalImageApiConfig(configData?.setting?.value),
|
||||
);
|
||||
}, [configData]);
|
||||
|
||||
const enabledKeyCount = useMemo(
|
||||
() => config.apiKeys.filter((item) => item.enabled).length,
|
||||
[config.apiKeys],
|
||||
);
|
||||
|
||||
const handleAddKey = () => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
apiKeys: [...current.apiKeys, buildKeyDraft()],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveKey = (id: string) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
apiKeys: current.apiKeys.filter((item) => item.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key: EXTERNAL_IMAGE_API_CONFIG_KEY,
|
||||
value: normalizeExternalImageApiConfig(config),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
String(payload?.message || payload?.error || "保存失败"),
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("第三方生图 API 配置已保存");
|
||||
await Promise.all([mutateConfig(), mutateLogs()]);
|
||||
} catch (error) {
|
||||
console.error("[External Image API Settings] Save failed:", error);
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-primary/10 p-2 text-primary">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl">第三方生图 API</CardTitle>
|
||||
<CardDescription>
|
||||
对外开放当前系统里已启用的生图模型。第三方先调
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
|
||||
/api/external/image/models
|
||||
</code>
|
||||
获取模型名称,再调用
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
|
||||
/api/external/image/generate
|
||||
</code>
|
||||
生图。结果会继续走我们的储存桶并落到数据库。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
<div className="space-y-4 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">启用第三方生图 API</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
关闭后第三方 API Key 会全部失效。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig((current) => ({ ...current, enabled: checked }))
|
||||
}
|
||||
/>
|
||||
<span className="min-w-[3rem] text-sm font-medium text-muted-foreground">
|
||||
{config.enabled ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="externalImageApiOwnerEmail">
|
||||
生成结果归属邮箱
|
||||
</Label>
|
||||
<Input
|
||||
id="externalImageApiOwnerEmail"
|
||||
value={config.defaultOwnerEmail}
|
||||
onChange={(event) =>
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
defaultOwnerEmail: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="external-image-api@system.local"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
外部 API 生成图片会统一挂到这个用户下,方便我们保留生成记录、媒体资产和后续分析数据。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
当前共 {config.apiKeys.length} 个 Key,启用中 {enabledKeyCount} 个。
|
||||
外部调用支持
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
|
||||
Authorization: Bearer <apiKey>
|
||||
</code>
|
||||
和
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
|
||||
x-api-key
|
||||
</code>
|
||||
两种方式。
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{config.apiKeys.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="space-y-3 rounded-xl border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">
|
||||
Key #{index + 1}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveKey(item.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
value={item.name}
|
||||
onChange={(event) =>
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
apiKeys: current.apiKeys.map((record) =>
|
||||
record.id === item.id
|
||||
? { ...record, name: event.target.value }
|
||||
: record,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="渠道 A / 合作方 B"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Key ID</Label>
|
||||
<Input
|
||||
value={item.id}
|
||||
onChange={(event) =>
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
apiKeys: current.apiKeys.map((record) =>
|
||||
record.id === item.id
|
||||
? { ...record, id: event.target.value }
|
||||
: record,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="partner-a"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<Input
|
||||
value={item.apiKey}
|
||||
onChange={(event) =>
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
apiKeys: current.apiKeys.map((record) =>
|
||||
record.id === item.id
|
||||
? { ...record, apiKey: event.target.value }
|
||||
: record,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="请输入调用第三方生图 API 的密钥"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
当前显示:{maskExternalImageApiKey(item.apiKey)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>备注</Label>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={item.notes}
|
||||
onChange={(event) =>
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
apiKeys: current.apiKeys.map((record) =>
|
||||
record.id === item.id
|
||||
? { ...record, notes: event.target.value }
|
||||
: record,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="可以记录对接方、用途、流量限制等"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
单独停用后,这个 Key 会立即失效,但不会影响其他 Key。
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
apiKeys: current.apiKeys.map((record) =>
|
||||
record.id === item.id
|
||||
? { ...record, enabled: checked }
|
||||
: record,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.enabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleAddKey}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增 API Key
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-base font-medium">调用说明</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
这条第三方接口直接复用我们系统里启用的生图模型、模型名称和存储链路。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dashed bg-muted/20 p-4 text-sm">
|
||||
<div className="font-medium">接口 1:获取模型</div>
|
||||
<code className="mt-2 block rounded bg-background px-3 py-2 text-xs">
|
||||
GET /api/external/image/models
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dashed bg-muted/20 p-4 text-sm">
|
||||
<div className="font-medium">接口 2:发起生图</div>
|
||||
<code className="mt-2 block whitespace-pre-wrap rounded bg-background px-3 py-2 text-xs">
|
||||
{`POST /api/external/image/generate
|
||||
{
|
||||
"model": "模型 UUID / 模型ID / 显示名称",
|
||||
"prompt": "请生成一张暖色家居客厅图",
|
||||
"negativePrompt": "不要出现人物",
|
||||
"imageCount": 2,
|
||||
"aspectRatio": "16:9",
|
||||
"imageSize": "2K"
|
||||
}`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dashed bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
返回图片地址会优先上传到我们的储存桶,然后再写入
|
||||
<code className="mx-1 rounded bg-background px-1 py-0.5 text-[11px]">
|
||||
GeneratedImage
|
||||
</code>
|
||||
和媒体资产表。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-base font-medium">最近 50 条请求日志</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
这里会记录 API Key、模型、提示词、生成张数、耗时和结果地址,方便排查对接问题。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{logsData?.data?.logs?.length ? (
|
||||
logsData.data.logs.map((log) => (
|
||||
<div key={log.id} className="rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium">
|
||||
{log.modelDisplayName || log.modelRuntimeId || "未记录模型"}
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
log.status === "succeeded"
|
||||
? "rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-600"
|
||||
: log.status === "failed"
|
||||
? "rounded-full bg-rose-500/10 px-2 py-0.5 text-xs text-rose-600"
|
||||
: "rounded-full bg-slate-500/10 px-2 py-0.5 text-xs text-slate-600"
|
||||
}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{log.apiKeyName || log.apiKeyId || "未命名 Key"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{log.prompt || "无提示词"}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>归属用户:{log.ownerUserEmail || "-"}</div>
|
||||
<div>生成张数:{log.imageCount}</div>
|
||||
<div>
|
||||
规格:{log.aspectRatio || "-"} / {log.imageSize || "-"}
|
||||
</div>
|
||||
<div>
|
||||
耗时:{typeof log.durationMs === "number" ? `${log.durationMs}ms` : "-"}
|
||||
</div>
|
||||
<div>请求 IP:{log.requesterIp || "-"}</div>
|
||||
<div>生成图片:{log.generatedImageUrls?.length || 0} 张</div>
|
||||
<div>开始时间:{formatBeijingTime(log.createdAt)}</div>
|
||||
<div>
|
||||
完成时间:
|
||||
{log.completedAt ? formatBeijingTime(log.completedAt) : "-"}
|
||||
</div>
|
||||
</div>
|
||||
{log.errorMessage ? (
|
||||
<div className="mt-3 rounded-lg bg-rose-500/5 px-3 py-2 text-sm text-rose-600">
|
||||
{log.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
还没有第三方生图 API 请求记录。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
207
docs/第三方生图API调用说明.md
Normal file
207
docs/第三方生图API调用说明.md
Normal file
@ -0,0 +1,207 @@
|
||||
# 第三方生图 API 调用说明
|
||||
|
||||
本文档用于提供给第三方调用方,说明如何对接我们系统开放的生图能力。
|
||||
|
||||
## 1. 接口概览
|
||||
|
||||
当前开放两条接口:
|
||||
|
||||
1. 获取可用模型列表
|
||||
`GET /api/external/image/models`
|
||||
|
||||
2. 发起生图
|
||||
`POST /api/external/image/generate`
|
||||
|
||||
这套接口直接复用我们系统后台已启用的生图模型与模型名称。
|
||||
生成结果图片会先上传到我们的储存桶,再写入我们的数据库与媒体资产表。
|
||||
|
||||
## 2. 鉴权方式
|
||||
|
||||
支持以下两种方式,任选其一:
|
||||
|
||||
### 方式 A:Authorization Bearer
|
||||
|
||||
```http
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
### 方式 B:x-api-key
|
||||
|
||||
```http
|
||||
x-api-key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
如果 API Key 无效、已停用,或后台未启用第三方生图 API,接口会返回 `401` 或 `403`。
|
||||
|
||||
## 3. 获取模型列表
|
||||
|
||||
### 请求
|
||||
|
||||
```bash
|
||||
curl -X GET "https://你的域名/api/external/image/models" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"models": [
|
||||
{
|
||||
"id": "9b8f7c10-1111-4d22-9abc-1234567890ab",
|
||||
"modelId": "seedream-5.0-lite",
|
||||
"displayName": "Seedream 5.0 Lite",
|
||||
"description": "轻量版商品生图模型",
|
||||
"providerType": "volcano-vision",
|
||||
"providerName": "火山视觉",
|
||||
"avatarUrl": "https://你的域名/uploads/xxx.png",
|
||||
"imageGenConfig": {
|
||||
"creditCost": 20
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
- `id`:系统内部模型 UUID,推荐第三方优先使用这个值调用生成接口
|
||||
- `modelId`:上游模型 ID
|
||||
- `displayName`:系统中展示给用户的模型名称
|
||||
- `providerType`:模型服务商类型
|
||||
- `providerName`:模型服务商显示名称
|
||||
- `imageGenConfig`:当前模型的生图配置,包含积分等后台配置
|
||||
|
||||
## 4. 发起生图
|
||||
|
||||
### 请求地址
|
||||
|
||||
```http
|
||||
POST /api/external/image/generate
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
### 请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "9b8f7c10-1111-4d22-9abc-1234567890ab",
|
||||
"prompt": "请生成一张高端现代客厅空间图,暖光,真实摄影感,适合商业首页展示",
|
||||
"negativePrompt": "不要人物,不要文字水印,不要畸变",
|
||||
"imageCount": 2,
|
||||
"aspectRatio": "16:9",
|
||||
"imageSize": "2K",
|
||||
"metadata": {
|
||||
"bizId": "partner-order-20260518-001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
- `model`:可传以下任意一种
|
||||
- 模型 UUID `id`
|
||||
- 模型运行 ID `modelId`
|
||||
- 模型显示名 `displayName`
|
||||
- `prompt`:正向提示词,必填
|
||||
- `negativePrompt`:反向提示词,可选
|
||||
- `imageCount`:生成张数,可选,默认 `1`,最大 `8`
|
||||
- `aspectRatio`:宽高比,可选,例如 `1:1`、`16:9`
|
||||
- `imageSize`:图片规格,可选,例如 `1K`、`2K`
|
||||
- `metadata`:业务扩展字段,可选,会原样记录到请求日志里
|
||||
|
||||
## 5. 生图成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"requestId": "5e8a63f6-1111-4d6c-8bb4-1234567890ab",
|
||||
"model": {
|
||||
"id": "9b8f7c10-1111-4d22-9abc-1234567890ab",
|
||||
"modelId": "seedream-5.0-lite",
|
||||
"displayName": "Seedream 5.0 Lite",
|
||||
"description": "轻量版商品生图模型",
|
||||
"providerType": "volcano-vision",
|
||||
"providerName": "火山视觉",
|
||||
"avatarUrl": null,
|
||||
"imageGenConfig": {
|
||||
"creditCost": 20
|
||||
}
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"id": 1,
|
||||
"originalUrl": "https://你的域名/uploads/2026xxxx-orig.jpg",
|
||||
"thumbnailUrl": "https://你的域名/uploads/2026xxxx-orig.jpg",
|
||||
"imageSize": "2K"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"originalUrl": "https://你的域名/uploads/2026yyyy-orig.jpg",
|
||||
"thumbnailUrl": "https://你的域名/uploads/2026yyyy-orig.jpg",
|
||||
"imageSize": "2K"
|
||||
}
|
||||
],
|
||||
"generatedImageIds": [
|
||||
"3d3a0f24-1111-4c0c-8d9d-1234567890ab"
|
||||
],
|
||||
"generatedAssetIds": [
|
||||
"b7b19e10-1111-4db8-aeb2-1234567890ab"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 返回字段说明
|
||||
|
||||
- `requestId`:本次请求日志 ID,可用于后台排查
|
||||
- `images`:最终生成结果
|
||||
- `originalUrl`:原图地址
|
||||
- `thumbnailUrl`:缩略图地址
|
||||
- `imageSize`:记录的图片规格
|
||||
- `generatedImageIds`:写入 `GeneratedImage` 表后的记录 ID
|
||||
- `generatedAssetIds`:写入媒体资产表后的记录 ID
|
||||
|
||||
## 6. 失败响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "未找到匹配的生图模型"
|
||||
}
|
||||
```
|
||||
|
||||
常见失败原因:
|
||||
|
||||
- API Key 缺失或无效
|
||||
- 第三方生图 API 后台未开启
|
||||
- 请求的模型不存在或未启用
|
||||
- 上游模型生成失败
|
||||
- 上游返回内容中未解析出图片
|
||||
|
||||
## 7. 调用建议
|
||||
|
||||
1. 先调用模型列表接口,缓存可用模型
|
||||
2. 生成时优先传模型 UUID `id`,最稳定
|
||||
3. 将返回的 `requestId` 保存到业务日志,便于双方排查问题
|
||||
4. 返回的图片地址已经是我们系统储存桶地址,可以直接展示或下载
|
||||
|
||||
## 8. 后台能力
|
||||
|
||||
后台已支持以下管理能力:
|
||||
|
||||
- 启用/关闭第三方生图 API
|
||||
- 配置默认归属用户邮箱
|
||||
- 配置多个 API Key
|
||||
- 单独启用/停用某个 Key
|
||||
- 查看最近请求日志、生图明细、耗时、失败原因、结果地址
|
||||
|
||||
## 9. 对接备注
|
||||
|
||||
- 这套接口使用的是我们系统当前启用的生图模型,模型列表会随后台配置变化而变化
|
||||
- 如果需要增加调用频控、签名校验、白名单 IP、异步回调等能力,可以继续在这套 API 上扩展
|
||||
@ -40,8 +40,8 @@ interface DirectAgentImageParamsSnapshot {
|
||||
type SaveGeneratedImages = (
|
||||
imageMarkdown: string,
|
||||
userId: string,
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
chatId: string | null,
|
||||
messageId: string | null,
|
||||
prompt: string,
|
||||
modelId: string,
|
||||
options?: {
|
||||
@ -55,8 +55,14 @@ type SaveGeneratedImages = (
|
||||
requestedImageCount?: number;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
skipBilling?: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
) => Promise<{
|
||||
savedImageCount: number;
|
||||
savedImageIds: string[];
|
||||
savedAssetIds: string[];
|
||||
savedOriginalUrls: string[];
|
||||
}>;
|
||||
|
||||
export interface DirectAgentImageEditExecutionResult {
|
||||
originalUrl: string;
|
||||
|
||||
@ -88,8 +88,8 @@ async function resolveBillingModelByReference(modelRef: string): Promise<any | u
|
||||
export async function saveGeneratedImages(
|
||||
imageMarkdown: string,
|
||||
userId: string,
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
chatId: string | null,
|
||||
messageId: string | null,
|
||||
prompt: string,
|
||||
modelId: string,
|
||||
options?: {
|
||||
@ -103,12 +103,19 @@ export async function saveGeneratedImages(
|
||||
requestedImageCount?: number;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
skipBilling?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<{
|
||||
savedImageCount: number;
|
||||
savedImageIds: string[];
|
||||
savedAssetIds: string[];
|
||||
savedOriginalUrls: string[];
|
||||
}> {
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const matches = [...imageMarkdown.matchAll(imageRegex)];
|
||||
|
||||
let savedImageCount = 0;
|
||||
const savedImageIds: string[] = [];
|
||||
const savedAssetIds: string[] = [];
|
||||
const savedOriginalUrls: string[] = [];
|
||||
let relationSourceAssetIds = Array.isArray(options?.sourceAssetIds)
|
||||
@ -141,8 +148,8 @@ export async function saveGeneratedImages(
|
||||
.insert(generatedImage)
|
||||
.values({
|
||||
userId,
|
||||
chatId,
|
||||
messageId,
|
||||
chatId: chatId ?? null,
|
||||
messageId: messageId ?? null,
|
||||
thumbnailUrl,
|
||||
originalUrl,
|
||||
imageSize,
|
||||
@ -152,6 +159,9 @@ export async function saveGeneratedImages(
|
||||
.returning();
|
||||
|
||||
savedImageCount += 1;
|
||||
if (savedImage?.id) {
|
||||
savedImageIds.push(savedImage.id);
|
||||
}
|
||||
if (!savedOriginalUrls.includes(originalUrl)) {
|
||||
savedOriginalUrls.push(originalUrl);
|
||||
}
|
||||
@ -208,7 +218,7 @@ export async function saveGeneratedImages(
|
||||
}
|
||||
}
|
||||
|
||||
if (savedOriginalUrls.length > 0) {
|
||||
if (savedOriginalUrls.length > 0 && chatId && messageId) {
|
||||
try {
|
||||
const resultAssetIds = savedAssetIds.length > 0 ? savedAssetIds : null;
|
||||
await persistImageWorkflowTurn({
|
||||
@ -241,21 +251,56 @@ export async function saveGeneratedImages(
|
||||
}
|
||||
}
|
||||
|
||||
if (savedImageCount <= 0) return;
|
||||
if (savedImageCount <= 0) {
|
||||
return {
|
||||
savedImageCount,
|
||||
savedImageIds,
|
||||
savedAssetIds,
|
||||
savedOriginalUrls,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (options?.skipBilling) {
|
||||
return {
|
||||
savedImageCount,
|
||||
savedImageIds,
|
||||
savedAssetIds,
|
||||
savedOriginalUrls,
|
||||
};
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
if (!pointsConfig.enabled) return;
|
||||
if (!pointsConfig.enabled) {
|
||||
return {
|
||||
savedImageCount,
|
||||
savedImageIds,
|
||||
savedAssetIds,
|
||||
savedOriginalUrls,
|
||||
};
|
||||
}
|
||||
|
||||
const userData = await getUserById({ id: userId }).catch(() => undefined);
|
||||
if (userData?.isHighVip) {
|
||||
console.log(`[Points] Skip image points for VIP user=${userId} model=${modelId}`);
|
||||
return;
|
||||
return {
|
||||
savedImageCount,
|
||||
savedImageIds,
|
||||
savedAssetIds,
|
||||
savedOriginalUrls,
|
||||
};
|
||||
}
|
||||
|
||||
const modelConfig = await resolveBillingModelByReference(modelId);
|
||||
const unitCost = getModelCreditCost(modelConfig, "image");
|
||||
if (unitCost <= 0) return;
|
||||
if (unitCost <= 0) {
|
||||
return {
|
||||
savedImageCount,
|
||||
savedImageIds,
|
||||
savedAssetIds,
|
||||
savedOriginalUrls,
|
||||
};
|
||||
}
|
||||
|
||||
const totalCost = unitCost * savedImageCount;
|
||||
const consumeResult = await consumeUserPoints({
|
||||
@ -263,8 +308,8 @@ export async function saveGeneratedImages(
|
||||
amount: totalCost,
|
||||
reason: "image_generation",
|
||||
modelId: modelConfig?.id || modelId,
|
||||
chatId,
|
||||
messageId,
|
||||
chatId: chatId ?? undefined,
|
||||
messageId: messageId ?? undefined,
|
||||
note: `模型(${modelConfig?.displayName || modelId}) 生图 ${savedImageCount} 张`,
|
||||
});
|
||||
|
||||
@ -284,4 +329,11 @@ export async function saveGeneratedImages(
|
||||
} catch (error) {
|
||||
console.error("[Points] Failed to consume points after image generation:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
savedImageCount,
|
||||
savedImageIds,
|
||||
savedAssetIds,
|
||||
savedOriginalUrls,
|
||||
};
|
||||
}
|
||||
|
||||
52
lib/db/migrations/0081_add_external_image_api_logs.sql
Normal file
52
lib/db/migrations/0081_add_external_image_api_logs.sql
Normal file
@ -0,0 +1,52 @@
|
||||
CREATE TABLE IF NOT EXISTS "ExternalImageApiRequestLog" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"apiKeyId" varchar(64),
|
||||
"apiKeyName" varchar(128),
|
||||
"ownerUserId" uuid,
|
||||
"modelDbId" uuid,
|
||||
"modelRuntimeId" varchar(128),
|
||||
"modelDisplayName" varchar(128),
|
||||
"prompt" text,
|
||||
"negativePrompt" text,
|
||||
"imageCount" integer NOT NULL DEFAULT 1,
|
||||
"aspectRatio" varchar(32),
|
||||
"imageSize" varchar(32),
|
||||
"status" varchar(32) NOT NULL DEFAULT 'pending',
|
||||
"requesterIp" varchar(64),
|
||||
"requesterUserAgent" text,
|
||||
"requestPayload" jsonb,
|
||||
"responsePayload" jsonb,
|
||||
"generatedImageIds" jsonb,
|
||||
"generatedAssetIds" jsonb,
|
||||
"generatedImageUrls" jsonb,
|
||||
"errorMessage" text,
|
||||
"durationMs" integer,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"completedAt" timestamp
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "ExternalImageApiRequestLog"
|
||||
ADD CONSTRAINT "ExternalImageApiRequestLog_ownerUserId_User_id_fk"
|
||||
FOREIGN KEY ("ownerUserId")
|
||||
REFERENCES "public"."User"("id")
|
||||
ON DELETE NO ACTION
|
||||
ON UPDATE NO ACTION;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "ExternalImageApiRequestLog"
|
||||
ADD CONSTRAINT "ExternalImageApiRequestLog_modelDbId_AiModel_id_fk"
|
||||
FOREIGN KEY ("modelDbId")
|
||||
REFERENCES "public"."AiModel"("id")
|
||||
ON DELETE NO ACTION
|
||||
ON UPDATE NO ACTION;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END
|
||||
$$;
|
||||
@ -470,6 +470,13 @@
|
||||
"when": 1778461200000,
|
||||
"tag": "0080_add_ai_model_health_recovery",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "7",
|
||||
"when": 1779069600000,
|
||||
"tag": "0081_add_external_image_api_logs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -582,6 +582,37 @@ export const generatedImage = pgTable("GeneratedImage", {
|
||||
|
||||
export type GeneratedImage = InferSelectModel<typeof generatedImage>;
|
||||
|
||||
export const externalImageApiRequestLog = pgTable("ExternalImageApiRequestLog", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
apiKeyId: varchar("apiKeyId", { length: 64 }),
|
||||
apiKeyName: varchar("apiKeyName", { length: 128 }),
|
||||
ownerUserId: uuid("ownerUserId").references(() => user.id),
|
||||
modelDbId: uuid("modelDbId").references(() => aiModel.id),
|
||||
modelRuntimeId: varchar("modelRuntimeId", { length: 128 }),
|
||||
modelDisplayName: varchar("modelDisplayName", { length: 128 }),
|
||||
prompt: text("prompt"),
|
||||
negativePrompt: text("negativePrompt"),
|
||||
imageCount: integer("imageCount").notNull().default(1),
|
||||
aspectRatio: varchar("aspectRatio", { length: 32 }),
|
||||
imageSize: varchar("imageSize", { length: 32 }),
|
||||
status: varchar("status", { length: 32 }).notNull().default("pending"),
|
||||
requesterIp: varchar("requesterIp", { length: 64 }),
|
||||
requesterUserAgent: text("requesterUserAgent"),
|
||||
requestPayload: jsonb("requestPayload"),
|
||||
responsePayload: jsonb("responsePayload"),
|
||||
generatedImageIds: jsonb("generatedImageIds"),
|
||||
generatedAssetIds: jsonb("generatedAssetIds"),
|
||||
generatedImageUrls: jsonb("generatedImageUrls"),
|
||||
errorMessage: text("errorMessage"),
|
||||
durationMs: integer("durationMs"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
completedAt: timestamp("completedAt"),
|
||||
});
|
||||
|
||||
export type ExternalImageApiRequestLog = InferSelectModel<
|
||||
typeof externalImageApiRequestLog
|
||||
>;
|
||||
|
||||
// 风格化分类表
|
||||
export const agentPresetCategory = pgTable("AgentPresetCategory", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
|
||||
104
lib/external-image-api/config.ts
Normal file
104
lib/external-image-api/config.ts
Normal file
@ -0,0 +1,104 @@
|
||||
export const EXTERNAL_IMAGE_API_CONFIG_KEY = "external_image_api_config";
|
||||
|
||||
export type ExternalImageApiAccessKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type ExternalImageApiConfig = {
|
||||
enabled: boolean;
|
||||
defaultOwnerEmail: string;
|
||||
apiKeys: ExternalImageApiAccessKey[];
|
||||
};
|
||||
|
||||
export const DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL =
|
||||
"external-image-api@system.local";
|
||||
|
||||
export const defaultExternalImageApiConfig: ExternalImageApiConfig = {
|
||||
enabled: false,
|
||||
defaultOwnerEmail: DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL,
|
||||
apiKeys: [],
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value || "").replace(/[\u0000-\u001F\u007F]/g, "").trim();
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
const normalized = normalizeText(value).toLowerCase();
|
||||
if (!normalized) return DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeKeyId(value: unknown, fallbackIndex: number) {
|
||||
const normalized = normalizeText(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
return normalized || `external-key-${fallbackIndex + 1}`;
|
||||
}
|
||||
|
||||
function normalizeAccessKey(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): ExternalImageApiAccessKey | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
|
||||
const raw = value as Partial<ExternalImageApiAccessKey>;
|
||||
const apiKey = normalizeText(raw.apiKey);
|
||||
if (!apiKey) return null;
|
||||
|
||||
return {
|
||||
id: normalizeKeyId(raw.id, index),
|
||||
name: normalizeText(raw.name) || `第三方调用 Key ${index + 1}`,
|
||||
apiKey,
|
||||
enabled: raw.enabled !== false,
|
||||
notes: normalizeText(raw.notes),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExternalImageApiConfig(
|
||||
value: unknown,
|
||||
): ExternalImageApiConfig {
|
||||
if (!value || typeof value !== "object") {
|
||||
return defaultExternalImageApiConfig;
|
||||
}
|
||||
|
||||
const raw = value as Partial<ExternalImageApiConfig> & {
|
||||
apiKeys?: unknown[];
|
||||
};
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const apiKeys = Array.isArray(raw.apiKeys)
|
||||
? raw.apiKeys
|
||||
.map((item, index) => normalizeAccessKey(item, index))
|
||||
.filter((item): item is ExternalImageApiAccessKey => Boolean(item))
|
||||
.map((item, index) => {
|
||||
let nextId = item.id || `external-key-${index + 1}`;
|
||||
while (seenIds.has(nextId)) {
|
||||
nextId = `${nextId}-${seenIds.size + 1}`;
|
||||
}
|
||||
seenIds.add(nextId);
|
||||
return {
|
||||
...item,
|
||||
id: nextId,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
defaultOwnerEmail: normalizeEmail(raw.defaultOwnerEmail),
|
||||
apiKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export function maskExternalImageApiKey(apiKey: string) {
|
||||
const trimmed = normalizeText(apiKey);
|
||||
if (trimmed.length <= 8) return trimmed ? "********" : "";
|
||||
return `${trimmed.slice(0, 4)}********${trimmed.slice(-4)}`;
|
||||
}
|
||||
405
lib/external-image-api/service.ts
Normal file
405
lib/external-image-api/service.ts
Normal file
@ -0,0 +1,405 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
||||
import { db } from "@/lib/db";
|
||||
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
|
||||
import { user } from "@/lib/db/schema";
|
||||
import { generateDummyPassword } from "@/lib/db/utils";
|
||||
import {
|
||||
DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL,
|
||||
EXTERNAL_IMAGE_API_CONFIG_KEY,
|
||||
normalizeExternalImageApiConfig,
|
||||
type ExternalImageApiAccessKey,
|
||||
} from "@/lib/external-image-api/config";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
type ImageModelEntry = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function mimeTypeToExtension(mimeType: string) {
|
||||
const normalized = mimeType.toLowerCase();
|
||||
if (normalized.includes("png")) return "png";
|
||||
if (normalized.includes("webp")) return "webp";
|
||||
if (normalized.includes("gif")) return "gif";
|
||||
if (normalized.includes("bmp")) return "bmp";
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
function getExtensionFromUrl(url: string): string {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const ext = pathname.split(".").pop()?.toLowerCase();
|
||||
if (!ext) return "jpg";
|
||||
if (["jpg", "jpeg", "png", "webp", "gif", "bmp"].includes(ext)) {
|
||||
return ext;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
function safeTokenCompare(left: string, right: string) {
|
||||
const a = Buffer.from(left);
|
||||
const b = Buffer.from(right);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function extractBearerToken(request: Request) {
|
||||
const authorization = normalizeText(request.headers.get("authorization"));
|
||||
if (/^bearer\s+/i.test(authorization)) {
|
||||
return authorization.replace(/^bearer\s+/i, "").trim();
|
||||
}
|
||||
return normalizeText(request.headers.get("x-api-key"));
|
||||
}
|
||||
|
||||
async function uploadRemoteImageToStorage(imageUrl: string): Promise<string> {
|
||||
const response = await fetch(imageUrl, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Remote image fetch failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
||||
if (contentType && !contentType.startsWith("image/")) {
|
||||
throw new Error(`Remote url is not an image: ${contentType}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
if (!arrayBuffer.byteLength) {
|
||||
throw new Error("Remote image content is empty");
|
||||
}
|
||||
|
||||
const ext = contentType
|
||||
? mimeTypeToExtension(contentType)
|
||||
: getExtensionFromUrl(imageUrl);
|
||||
const fileName = `external-image-api-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.${ext}`;
|
||||
const uploaded = await uploadFile(fileName, arrayBuffer);
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
async function uploadDataUrlImage(dataUrl: string): Promise<string> {
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,([\s\S]+)$/i);
|
||||
if (!match) {
|
||||
throw new Error("Invalid data URL image format");
|
||||
}
|
||||
|
||||
const mimeType = match[1] || "image/jpeg";
|
||||
const base64Data = normalizeText(match[2]).replace(/\s+/g, "");
|
||||
if (!base64Data) {
|
||||
throw new Error("Empty base64 image content");
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const fileName = `external-inline-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}.${mimeTypeToExtension(mimeType)}`;
|
||||
const arrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
) as ArrayBuffer;
|
||||
const uploaded = await uploadFile(fileName, arrayBuffer);
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
async function ensureHostedImageUrl(imageUrl: string): Promise<string> {
|
||||
const normalizedUrl = normalizeText(imageUrl);
|
||||
if (!normalizedUrl) {
|
||||
throw new Error("Image URL is empty");
|
||||
}
|
||||
|
||||
if (normalizedUrl.startsWith("data:image")) {
|
||||
return uploadDataUrlImage(normalizedUrl);
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
||||
throw new Error(`Unsupported image URL: ${normalizedUrl}`);
|
||||
}
|
||||
|
||||
if (/\/uploads\//i.test(normalizedUrl)) {
|
||||
return normalizedUrl;
|
||||
}
|
||||
|
||||
return uploadRemoteImageToStorage(normalizedUrl);
|
||||
}
|
||||
|
||||
export async function ensureHostedImageMarkdown(
|
||||
markdown: string,
|
||||
imageSize = "2K",
|
||||
) {
|
||||
const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim;
|
||||
const matches = [...markdown.matchAll(pattern)];
|
||||
|
||||
if (!matches.length) {
|
||||
const plainUrls = extractImageUrlsFromText(markdown);
|
||||
if (!plainUrls.length) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
const hostedUrls = await Promise.all(
|
||||
plainUrls.map((url) => ensureHostedImageUrl(url)),
|
||||
);
|
||||
return hostedUrls
|
||||
.map((url) => ``)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
let rebuilt = "";
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of matches) {
|
||||
const raw = match[0];
|
||||
const sourceUrl = match[2] || "";
|
||||
const start = match.index ?? -1;
|
||||
if (start < 0) continue;
|
||||
|
||||
rebuilt += markdown.slice(cursor, start);
|
||||
const hostedUrl = await ensureHostedImageUrl(sourceUrl);
|
||||
rebuilt += ``;
|
||||
cursor = start + raw.length;
|
||||
}
|
||||
|
||||
rebuilt += markdown.slice(cursor);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
export function extractGeneratedImagesFromMarkdown(markdown: string) {
|
||||
const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim;
|
||||
const matches = [...markdown.matchAll(pattern)];
|
||||
|
||||
return matches.map((match, index) => {
|
||||
const alt = match[1] || "";
|
||||
const thumbnailUrl = match[2] || "";
|
||||
let originalUrl = thumbnailUrl;
|
||||
let imageSize = "2K";
|
||||
const metaMatch = alt.match(/thumb\|\|\|([^|]+)\|\|\|(.+)$/i);
|
||||
if (metaMatch) {
|
||||
originalUrl = normalizeText(metaMatch[1]) || thumbnailUrl;
|
||||
imageSize = normalizeText(metaMatch[2]) || imageSize;
|
||||
}
|
||||
return {
|
||||
id: index + 1,
|
||||
originalUrl,
|
||||
thumbnailUrl,
|
||||
imageSize,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getExternalImageApiConfig() {
|
||||
const setting = await getSystemSetting({
|
||||
key: EXTERNAL_IMAGE_API_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
return normalizeExternalImageApiConfig(setting?.value);
|
||||
}
|
||||
|
||||
export async function authenticateExternalImageApiRequest(request: Request) {
|
||||
const config = await getExternalImageApiConfig();
|
||||
const token = extractBearerToken(request);
|
||||
|
||||
if (!config.enabled) {
|
||||
return {
|
||||
config,
|
||||
accessKey: null,
|
||||
token,
|
||||
error: "第三方生图 API 未启用",
|
||||
status: 403 as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
config,
|
||||
accessKey: null,
|
||||
token,
|
||||
error: "缺少 API Key",
|
||||
status: 401 as const,
|
||||
};
|
||||
}
|
||||
|
||||
const accessKey =
|
||||
config.apiKeys.find(
|
||||
(item) => item.enabled && safeTokenCompare(item.apiKey, token),
|
||||
) || null;
|
||||
|
||||
if (!accessKey) {
|
||||
return {
|
||||
config,
|
||||
accessKey,
|
||||
token,
|
||||
error: "API Key 无效或已禁用",
|
||||
status: 401 as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
accessKey,
|
||||
token,
|
||||
error: null,
|
||||
status: 200 as const,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getExternalImageApiModels() {
|
||||
const models = await getEnabledAiModelsWithProvider(false, false);
|
||||
|
||||
return models.filter((entry) =>
|
||||
isImageGenerationModelDescriptor({
|
||||
providerType: entry.provider.type,
|
||||
modelId: entry.model.modelId,
|
||||
capabilities: entry.model.capabilities,
|
||||
supportsImageGen: entry.model.supportsImageGen,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function serializeExternalImageApiModel(entry: ImageModelEntry) {
|
||||
return {
|
||||
id: entry.model.id,
|
||||
modelId: entry.model.modelId,
|
||||
displayName: entry.model.displayName,
|
||||
description: entry.model.description,
|
||||
providerType: entry.provider.type,
|
||||
providerName: entry.provider.name,
|
||||
avatarUrl: entry.model.avatarUrl,
|
||||
imageGenConfig: entry.model.imageGenConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveExternalImageApiModel(modelRef?: string | null) {
|
||||
const models = await getExternalImageApiModels();
|
||||
if (!models.length) {
|
||||
throw new Error("当前没有启用可用于生图的模型");
|
||||
}
|
||||
|
||||
const normalizedRef = normalizeText(modelRef).toLowerCase();
|
||||
if (!normalizedRef) {
|
||||
return models[0];
|
||||
}
|
||||
|
||||
const matched = models.find((entry) => {
|
||||
const candidates = [
|
||||
entry.model.id,
|
||||
entry.model.modelId,
|
||||
entry.model.displayName,
|
||||
]
|
||||
.map((item) => normalizeText(item).toLowerCase())
|
||||
.filter(Boolean);
|
||||
return candidates.includes(normalizedRef);
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
throw new Error("未找到匹配的生图模型");
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
export async function resolveExternalImageApiOwnerUserId(ownerEmail?: string | null) {
|
||||
const normalizedEmail = normalizeText(ownerEmail).toLowerCase() ||
|
||||
DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL;
|
||||
|
||||
const existing = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(sql`lower(${user.email}) = ${normalizedEmail}`)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]?.id) {
|
||||
return existing[0].id;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(user)
|
||||
.values({
|
||||
email: normalizedEmail,
|
||||
password: generateDummyPassword(),
|
||||
role: "guest",
|
||||
name: "第三方生图 API",
|
||||
isActive: true,
|
||||
})
|
||||
.returning({ id: user.id });
|
||||
|
||||
if (!created?.id) {
|
||||
throw new Error("创建第三方 API 挂载用户失败");
|
||||
}
|
||||
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function generateImagesWithModel(params: {
|
||||
modelDbId: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string | null;
|
||||
imageCount: number;
|
||||
aspectRatio?: string | null;
|
||||
imageSize?: string | null;
|
||||
}) {
|
||||
const languageModel = await getLanguageModelById(params.modelDbId);
|
||||
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
|
||||
throw new Error("当前模型暂不支持第三方生图调用");
|
||||
}
|
||||
|
||||
const composedPrompt = [
|
||||
normalizeText(params.prompt),
|
||||
normalizeText(params.negativePrompt)
|
||||
? `Negative prompt:\n${normalizeText(params.negativePrompt)}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const result = await (languageModel as any).doGenerate({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: composedPrompt }],
|
||||
},
|
||||
],
|
||||
imageParams: {
|
||||
imageCount: params.imageCount,
|
||||
aspectRatio: normalizeText(params.aspectRatio) || undefined,
|
||||
imageSize: normalizeText(params.imageSize) || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.finishReason === "error") {
|
||||
throw new Error(result.text || "图片生成失败");
|
||||
}
|
||||
|
||||
const text = normalizeText(result?.text);
|
||||
if (!text) {
|
||||
throw new Error("模型未返回生成图片");
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function buildExternalImageApiKeyFingerprint(
|
||||
accessKey: ExternalImageApiAccessKey | null,
|
||||
) {
|
||||
if (!accessKey?.apiKey) return null;
|
||||
return createHash("sha256").update(accessKey.apiKey).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function getRequesterIp(request: Request) {
|
||||
const forwarded = normalizeText(request.headers.get("x-forwarded-for"));
|
||||
if (forwarded) {
|
||||
return forwarded.split(",")[0]?.trim() || null;
|
||||
}
|
||||
return normalizeText(request.headers.get("x-real-ip")) || null;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user