fix: polish style pro model visibility and billing
This commit is contained in:
parent
71837cc547
commit
d72ca0b344
@ -31,6 +31,8 @@ import {
|
||||
normalizeStyleProConfig,
|
||||
type StyleProCategory,
|
||||
type StyleProConfig,
|
||||
type StyleProCustomField,
|
||||
type StyleProCustomFieldType,
|
||||
type StyleProOption,
|
||||
} from "@/lib/style-pro/config";
|
||||
|
||||
@ -66,6 +68,7 @@ function reindexCategories(categories: StyleProCategory[]) {
|
||||
return categories.map((category, index) => ({
|
||||
...category,
|
||||
order: (index + 1) * 10,
|
||||
customFields: reindexCustomFields(category.customFields || []),
|
||||
options: category.options.map((option, optionIndex) => ({
|
||||
...option,
|
||||
order: (optionIndex + 1) * 10,
|
||||
@ -73,6 +76,13 @@ function reindexCategories(categories: StyleProCategory[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
function reindexCustomFields(fields: StyleProCustomField[]) {
|
||||
return fields.map((field, index) => ({
|
||||
...field,
|
||||
order: (index + 1) * 10,
|
||||
}));
|
||||
}
|
||||
|
||||
function createCategory(): StyleProCategory {
|
||||
const id = getId("category");
|
||||
return {
|
||||
@ -84,10 +94,27 @@ function createCategory(): StyleProCategory {
|
||||
maxSelections: 1,
|
||||
enabled: true,
|
||||
order: 999,
|
||||
customFields: [],
|
||||
options: [createOption(id)],
|
||||
};
|
||||
}
|
||||
|
||||
function createCustomField(prefix = "custom"): StyleProCustomField {
|
||||
return {
|
||||
id: getId(`${prefix}-field`),
|
||||
label: "自定义要求",
|
||||
description: "",
|
||||
type: "text",
|
||||
placeholder: "请输入自定义要求...",
|
||||
defaultValue: "",
|
||||
promptTemplate: "{{fieldLabel}}:{{value}}",
|
||||
required: false,
|
||||
enabled: true,
|
||||
order: 999,
|
||||
maxLength: 160,
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(categoryId: string): StyleProOption {
|
||||
return {
|
||||
id: getId("option"),
|
||||
@ -147,6 +174,153 @@ function ColorSettingField({
|
||||
);
|
||||
}
|
||||
|
||||
function CustomFieldEditor({
|
||||
field,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
field: StyleProCustomField;
|
||||
onChange: (updates: Partial<StyleProCustomField>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const fieldTypeOptions: Array<{ value: StyleProCustomFieldType; label: string }> = [
|
||||
{ value: "text", label: "单行文本" },
|
||||
{ value: "textarea", label: "多行文本" },
|
||||
{ value: "color", label: "颜色选择" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{field.label || "未命名自定义项"}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{field.type === "color"
|
||||
? "颜色选择"
|
||||
: field.type === "textarea"
|
||||
? "多行文本"
|
||||
: "单行文本"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={field.enabled}
|
||||
onCheckedChange={(enabled) => onChange({ enabled })}
|
||||
/>
|
||||
<Button onClick={onRemove} size="sm" variant="ghost">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>前台名称</Label>
|
||||
<Input
|
||||
onChange={(event) => onChange({ label: event.target.value })}
|
||||
value={field.label}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>字段 ID</Label>
|
||||
<Input
|
||||
onChange={(event) => onChange({ id: event.target.value })}
|
||||
value={field.id}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>字段类型</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
onChange({ type: value as StyleProCustomFieldType })
|
||||
}
|
||||
value={field.type}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>最大长度</Label>
|
||||
<Input
|
||||
min={1}
|
||||
onChange={(event) =>
|
||||
onChange({ maxLength: Number(event.target.value) || 160 })
|
||||
}
|
||||
type="number"
|
||||
value={field.maxLength || (field.type === "textarea" ? 800 : 160)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>默认值</Label>
|
||||
<div className="flex gap-2">
|
||||
{field.type === "color" ? (
|
||||
<Input
|
||||
className="h-10 w-14 shrink-0 cursor-pointer p-1"
|
||||
onChange={(event) => onChange({ defaultValue: event.target.value })}
|
||||
type="color"
|
||||
value={
|
||||
/^#[0-9a-fA-F]{6}$/.test(field.defaultValue || "")
|
||||
? field.defaultValue
|
||||
: "#ffffff"
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Input
|
||||
className={field.type === "color" ? "font-mono" : undefined}
|
||||
maxLength={field.type === "color" ? 7 : undefined}
|
||||
onChange={(event) => onChange({ defaultValue: event.target.value })}
|
||||
placeholder={field.type === "color" ? "#FFFFFF" : "可留空"}
|
||||
value={field.defaultValue || ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>占位提示</Label>
|
||||
<Input
|
||||
onChange={(event) => onChange({ placeholder: event.target.value })}
|
||||
placeholder="请输入自定义要求..."
|
||||
value={field.placeholder || ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>说明</Label>
|
||||
<Input
|
||||
onChange={(event) => onChange({ description: event.target.value })}
|
||||
value={field.description || ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>隐藏提示词模板</Label>
|
||||
<Textarea
|
||||
className="min-h-[86px]"
|
||||
onChange={(event) => onChange({ promptTemplate: event.target.value })}
|
||||
placeholder="{{fieldLabel}}:{{value}}"
|
||||
value={field.promptTemplate || ""}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
可用变量:{"{{value}}"}、{"{{fieldLabel}}"}、{"{{categoryTitle}}"}。
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center justify-between rounded-lg border bg-background p-3 md:col-span-2">
|
||||
<span className="text-sm">前台必填</span>
|
||||
<Switch
|
||||
checked={field.required}
|
||||
onCheckedChange={(required) => onChange({ required })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StyleProSettingsClient() {
|
||||
const { data, mutate } = useSWR<SettingsResponse>(
|
||||
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
|
||||
@ -212,6 +386,90 @@ export function StyleProSettingsClient() {
|
||||
}));
|
||||
};
|
||||
|
||||
const addGlobalCustomField = () => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
customFields: reindexCustomFields([
|
||||
...(current.customFields || []),
|
||||
createCustomField("global"),
|
||||
]),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateGlobalCustomField = (
|
||||
fieldId: string,
|
||||
updates: Partial<StyleProCustomField>
|
||||
) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
customFields: (current.customFields || []).map((field) =>
|
||||
field.id === fieldId ? { ...field, ...updates } : field
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeGlobalCustomField = (fieldId: string) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
customFields: reindexCustomFields(
|
||||
(current.customFields || []).filter((field) => field.id !== fieldId)
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addCategoryCustomField = (categoryId: string) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
categories: current.categories.map((category) =>
|
||||
category.id === categoryId
|
||||
? {
|
||||
...category,
|
||||
customFields: reindexCustomFields([
|
||||
...(category.customFields || []),
|
||||
createCustomField(category.id),
|
||||
]),
|
||||
}
|
||||
: category
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateCategoryCustomField = (
|
||||
categoryId: string,
|
||||
fieldId: string,
|
||||
updates: Partial<StyleProCustomField>
|
||||
) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
categories: current.categories.map((category) =>
|
||||
category.id === categoryId
|
||||
? {
|
||||
...category,
|
||||
customFields: (category.customFields || []).map((field) =>
|
||||
field.id === fieldId ? { ...field, ...updates } : field
|
||||
),
|
||||
}
|
||||
: category
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeCategoryCustomField = (categoryId: string, fieldId: string) => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
categories: current.categories.map((category) =>
|
||||
category.id === categoryId
|
||||
? {
|
||||
...category,
|
||||
customFields: reindexCustomFields(
|
||||
(category.customFields || []).filter((field) => field.id !== fieldId)
|
||||
),
|
||||
}
|
||||
: category
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
setConfig((current) => ({
|
||||
...current,
|
||||
@ -603,6 +861,39 @@ export function StyleProSettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border p-4 md:col-span-2">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<Label>全局自定义输入项</Label>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
适合墙面颜色、顶面颜色、材质说明等全局参数。前台会自动渲染,后台模板决定如何写入隐藏提示词。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addGlobalCustomField} size="sm" variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加自定义项
|
||||
</Button>
|
||||
</div>
|
||||
{(config.customFields || []).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(config.customFields || []).map((field) => (
|
||||
<CustomFieldEditor
|
||||
field={field}
|
||||
key={field.id}
|
||||
onChange={(updates) =>
|
||||
updateGlobalCustomField(field.id, updates)
|
||||
}
|
||||
onRemove={() => removeGlobalCustomField(field.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
|
||||
暂未配置全局自定义项。需要墙面/页面调色时,可添加“颜色选择”字段。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>默认系统提示词</Label>
|
||||
<Textarea
|
||||
@ -762,6 +1053,45 @@ export function StyleProSettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3 rounded-lg border border-dashed p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">分类自定义输入项</h3>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
用户可在这个分类下补充自定义要求,例如颜色、材质、光感或特殊说明。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => addCategoryCustomField(category.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加自定义项
|
||||
</Button>
|
||||
</div>
|
||||
{(category.customFields || []).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(category.customFields || []).map((field) => (
|
||||
<CustomFieldEditor
|
||||
field={field}
|
||||
key={field.id}
|
||||
onChange={(updates) =>
|
||||
updateCategoryCustomField(category.id, field.id, updates)
|
||||
}
|
||||
onRemove={() =>
|
||||
removeCategoryCustomField(category.id, field.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-muted/40 p-3 text-muted-foreground text-sm">
|
||||
这个分类暂未配置自定义输入项。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm">按钮配置</h3>
|
||||
|
||||
@ -3,10 +3,12 @@ import {
|
||||
getAgentAiModelWithProvider,
|
||||
getDefaultAiModel,
|
||||
getEnabledAiModelsWithProvider,
|
||||
getFrontendDisplayAiModelsWithProvider,
|
||||
getSiteSettings,
|
||||
getSystemSetting,
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||||
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
@ -68,7 +70,7 @@ function isWorkflowImageGenerationModel(model: {
|
||||
}
|
||||
|
||||
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
@ -79,9 +81,13 @@ export async function GET() {
|
||||
const currentUser = await getUserById({ id: session.user.id });
|
||||
const allowHiddenModels =
|
||||
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
|
||||
const includeUnavailable =
|
||||
new URL(request.url).searchParams.get("includeUnavailable") === "1";
|
||||
|
||||
// 只获取主模型(不包括子模型),子模型由后端根据 videoParams.mode 自动选择
|
||||
const modelsWithProvider = await getEnabledAiModelsWithProvider(false, true);
|
||||
const modelsWithProvider = includeUnavailable
|
||||
? await getFrontendDisplayAiModelsWithProvider(false, true)
|
||||
: await getEnabledAiModelsWithProvider(false, true);
|
||||
const agentModelWithProvider = await getAgentAiModelWithProvider();
|
||||
const defaultModel = await getDefaultAiModel();
|
||||
const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
|
||||
@ -114,6 +120,7 @@ export async function GET() {
|
||||
}),
|
||||
imageGenConfig: model.imageGenConfig,
|
||||
sortOrder: model.sortOrder ?? 0,
|
||||
...getAiModelFrontendAvailability(model),
|
||||
});
|
||||
const workflowImageModelById = new Map<string, ModelWithProvider>();
|
||||
modelsWithProvider.forEach((entry) => {
|
||||
@ -150,8 +157,10 @@ export async function GET() {
|
||||
|
||||
// 转换为前端需要的格式
|
||||
const models = visibleModelsWithProviderSorted.map(serializeModel);
|
||||
const selectableModels = models.filter((model) => model.selectable !== false);
|
||||
const preferredNanoBananaModel = models.find(
|
||||
(model) =>
|
||||
model.selectable !== false &&
|
||||
isImageGenerationModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
@ -168,6 +177,7 @@ export async function GET() {
|
||||
);
|
||||
const preferredImageModel = models.find(
|
||||
(model) =>
|
||||
model.selectable !== false &&
|
||||
isImageGenerationModelDescriptor({
|
||||
providerType: model.providerType,
|
||||
modelId: model.modelId,
|
||||
@ -187,10 +197,10 @@ export async function GET() {
|
||||
preferredImageModel?.id ??
|
||||
(defaultModel &&
|
||||
!isAiModelAgentAuto(defaultModel) &&
|
||||
models.some((model) => model.id === defaultModel.id)
|
||||
selectableModels.some((model) => model.id === defaultModel.id)
|
||||
? defaultModel.id
|
||||
: models.find((model) => model.isDefault)?.id)) ??
|
||||
models[0]?.id ??
|
||||
: selectableModels.find((model) => model.isDefault)?.id)) ??
|
||||
selectableModels[0]?.id ??
|
||||
null;
|
||||
const configuredAgentWorkflowModelId = String(
|
||||
siteSettings?.agentWorkflowDefaultModelId || ""
|
||||
|
||||
@ -12,6 +12,8 @@ import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import {
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
type StyleProCategory,
|
||||
type StyleProCustomField,
|
||||
type StyleProCustomInputMap,
|
||||
type StyleProGenerationMode,
|
||||
type StyleProOption,
|
||||
type StyleProSelectionMap,
|
||||
@ -37,6 +39,12 @@ const generateRequestSchema = z.object({
|
||||
generationMode: z.enum(["style", "realistic"]).optional(),
|
||||
modelId: z.string().trim().min(1).max(160).optional(),
|
||||
selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)),
|
||||
customInputs: z
|
||||
.object({
|
||||
global: z.record(z.string().max(2000)).optional(),
|
||||
categories: z.record(z.record(z.string().max(2000))).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const STYLE_PRO_REQUEST_LOCK_TTL_MS = 10 * 60 * 1000;
|
||||
@ -87,6 +95,101 @@ function buildSelectionPayload(selectedOptions: SelectedOption[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeCustomInputValue(field: StyleProCustomField, value: unknown) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
if (field.type === "color") {
|
||||
return /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(raw) ? raw.toUpperCase() : "";
|
||||
}
|
||||
const maxLength = Math.max(
|
||||
1,
|
||||
Math.min(2000, Number(field.maxLength || (field.type === "textarea" ? 800 : 160)))
|
||||
);
|
||||
return raw.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function renderCustomInputPrompt(params: {
|
||||
field: StyleProCustomField;
|
||||
value: string;
|
||||
category?: StyleProCategory;
|
||||
}) {
|
||||
const template =
|
||||
params.field.promptTemplate?.trim() || "{{fieldLabel}}:{{value}}";
|
||||
return template
|
||||
.replaceAll("{{value}}", params.value)
|
||||
.replaceAll("{{fieldLabel}}", params.field.label)
|
||||
.replaceAll("{{fieldId}}", params.field.id)
|
||||
.replaceAll("{{categoryTitle}}", params.category?.title || "全局自定义")
|
||||
.replaceAll("{{categoryId}}", params.category?.id || "global");
|
||||
}
|
||||
|
||||
function validateCustomInputs(params: {
|
||||
config: ReturnType<typeof normalizeStyleProConfig>;
|
||||
enabledCategories: StyleProCategory[];
|
||||
rawCustomInputs?: StyleProCustomInputMap;
|
||||
}) {
|
||||
const normalizedInputs: StyleProCustomInputMap = {
|
||||
global: {},
|
||||
categories: {},
|
||||
};
|
||||
const selectedCustomInputs: NonNullable<StyleProTaskRequest["selectedCustomInputs"]> = [];
|
||||
const rawGlobal = params.rawCustomInputs?.global || {};
|
||||
const rawCategories = params.rawCustomInputs?.categories || {};
|
||||
|
||||
for (const field of params.config.customFields.filter((item) => item.enabled)) {
|
||||
const value = normalizeCustomInputValue(field, rawGlobal[field.id]);
|
||||
if (field.required && !value) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `请填写:${field.label}`,
|
||||
};
|
||||
}
|
||||
if (!value) continue;
|
||||
normalizedInputs.global![field.id] = value;
|
||||
selectedCustomInputs.push({
|
||||
scope: "global",
|
||||
fieldId: field.id,
|
||||
fieldLabel: field.label,
|
||||
fieldType: field.type,
|
||||
value,
|
||||
prompt: renderCustomInputPrompt({ field, value }),
|
||||
});
|
||||
}
|
||||
|
||||
for (const category of params.enabledCategories) {
|
||||
const categoryInput = rawCategories[category.id] || {};
|
||||
for (const field of (category.customFields || []).filter((item) => item.enabled)) {
|
||||
const value = normalizeCustomInputValue(field, categoryInput[field.id]);
|
||||
if (field.required && !value) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `请填写:${category.title} / ${field.label}`,
|
||||
};
|
||||
}
|
||||
if (!value) continue;
|
||||
normalizedInputs.categories![category.id] =
|
||||
normalizedInputs.categories![category.id] || {};
|
||||
normalizedInputs.categories![category.id][field.id] = value;
|
||||
selectedCustomInputs.push({
|
||||
scope: "category",
|
||||
categoryId: category.id,
|
||||
categoryTitle: category.title,
|
||||
fieldId: field.id,
|
||||
fieldLabel: field.label,
|
||||
fieldType: field.type,
|
||||
value,
|
||||
prompt: renderCustomInputPrompt({ field, value, category }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
normalizedInputs,
|
||||
selectedCustomInputs,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSelections(
|
||||
categories: StyleProCategory[],
|
||||
rawSelections: Record<string, string[]>
|
||||
@ -158,7 +261,9 @@ function buildStyleProTaskRequest(params: {
|
||||
body: z.infer<typeof generateRequestSchema>;
|
||||
generationMode: StyleProGenerationMode;
|
||||
normalizedSelections: StyleProSelectionMap;
|
||||
customInputs: StyleProCustomInputMap;
|
||||
selectedOptions: SelectedOption[];
|
||||
selectedCustomInputs: NonNullable<StyleProTaskRequest["selectedCustomInputs"]>;
|
||||
selectedModels: Array<{
|
||||
model: {
|
||||
id: string;
|
||||
@ -180,7 +285,9 @@ function buildStyleProTaskRequest(params: {
|
||||
sourceImageUrl: params.body.sourceImageUrl,
|
||||
generationMode: params.generationMode,
|
||||
normalizedSelections: params.normalizedSelections,
|
||||
customInputs: params.customInputs,
|
||||
selectedOptions: buildSelectionPayload(params.selectedOptions),
|
||||
selectedCustomInputs: params.selectedCustomInputs,
|
||||
selectedModels: params.selectedModels.map((entry) => ({
|
||||
id: entry.model.id,
|
||||
displayName: entry.model.displayName,
|
||||
@ -321,6 +428,17 @@ export async function POST(request: Request) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const customInputResult = validateCustomInputs({
|
||||
config,
|
||||
enabledCategories,
|
||||
rawCustomInputs: body.customInputs,
|
||||
});
|
||||
if (!customInputResult.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: customInputResult.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const imageModelEntries = await getStyleProImageModelEntries({
|
||||
includeHidden: generationMode === "realistic",
|
||||
@ -422,7 +540,9 @@ export async function POST(request: Request) {
|
||||
body,
|
||||
generationMode,
|
||||
normalizedSelections: selectionResult.normalizedSelections,
|
||||
customInputs: customInputResult.normalizedInputs,
|
||||
selectedOptions: selectionResult.selectedOptions,
|
||||
selectedCustomInputs: customInputResult.selectedCustomInputs,
|
||||
selectedModels,
|
||||
config,
|
||||
billing,
|
||||
|
||||
@ -4,6 +4,7 @@ import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelDisplayEntries,
|
||||
getStyleProImageModelEntries,
|
||||
toPublicStyleProModelOption,
|
||||
} from "@/lib/style-pro/model-options";
|
||||
@ -24,11 +25,13 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
|
||||
const [entries, realisticModelEntries, setting] = await Promise.all([
|
||||
getStyleProImageModelEntries({ includeHidden: false }),
|
||||
getStyleProImageModelEntries({ includeHidden: true }),
|
||||
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
const [displayEntries, entries, realisticModelEntries, setting] =
|
||||
await Promise.all([
|
||||
getStyleProImageModelDisplayEntries({ includeHidden: false }),
|
||||
getStyleProImageModelEntries({ includeHidden: false }),
|
||||
getStyleProImageModelEntries({ includeHidden: true }),
|
||||
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
const config = normalizeStyleProConfig(setting?.value);
|
||||
const configuredDefault = findStyleProImageModelByRef(
|
||||
entries,
|
||||
@ -57,7 +60,7 @@ export async function GET() {
|
||||
success: true,
|
||||
defaultModelId: defaultEntry?.model.id || null,
|
||||
realisticUnitCost,
|
||||
models: entries.map(toPublicStyleProModelOption),
|
||||
models: displayEntries.map(toPublicStyleProModelOption),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Style Pro Model Options] Failed:", error);
|
||||
|
||||
@ -3154,6 +3154,10 @@ export function CanvasChatPanel({
|
||||
supportsVision?: boolean;
|
||||
supportsImageGen?: boolean;
|
||||
isMultimodal?: boolean;
|
||||
availabilityStatus?: "available" | "maintenance" | "testing";
|
||||
availabilityLabel?: string | null;
|
||||
selectable?: boolean;
|
||||
disabledReason?: string | null;
|
||||
capabilities?: any;
|
||||
videoToolCapability?: VideoToolCapabilityContract | null;
|
||||
imageGenConfig?: {
|
||||
@ -3201,7 +3205,7 @@ export function CanvasChatPanel({
|
||||
creditCostPerVideoByResolution?: Record<string, number>;
|
||||
} | null;
|
||||
} | null;
|
||||
}>("/api/models", fetcher);
|
||||
}>("/api/models?includeUnavailable=1", fetcher);
|
||||
|
||||
// 获取网站设置(包含广告横幅配置和画布设置)
|
||||
const { data: siteSettings } = useSWR<{
|
||||
@ -3478,6 +3482,10 @@ export function CanvasChatPanel({
|
||||
supportsImageGen: true,
|
||||
capabilities: null,
|
||||
isMultimodal: false,
|
||||
selectable: true,
|
||||
availabilityStatus: "available" as const,
|
||||
availabilityLabel: null,
|
||||
disabledReason: null,
|
||||
videoToolCapability: null,
|
||||
imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined,
|
||||
};
|
||||
@ -3497,6 +3505,10 @@ export function CanvasChatPanel({
|
||||
supportsImageGen: modelsData.agentModel.supportsImageGen,
|
||||
capabilities: null,
|
||||
isMultimodal: true,
|
||||
selectable: true,
|
||||
availabilityStatus: "available" as const,
|
||||
availabilityLabel: null,
|
||||
disabledReason: null,
|
||||
videoToolCapability: null,
|
||||
imageGenConfig: modelsData.agentModel.imageGenConfig || undefined,
|
||||
};
|
||||
@ -3659,9 +3671,13 @@ export function CanvasChatPanel({
|
||||
imageOnlyModels,
|
||||
videoOnlyModels,
|
||||
]);
|
||||
const modelSelectValue = selectableModels.some((model) => model.id === selectedModelId)
|
||||
const enabledSelectableModels = useMemo(
|
||||
() => selectableModels.filter((model) => model.selectable !== false),
|
||||
[selectableModels]
|
||||
);
|
||||
const modelSelectValue = enabledSelectableModels.some((model) => model.id === selectedModelId)
|
||||
? selectedModelId
|
||||
: selectableModels[0]?.id || selectedModelId;
|
||||
: enabledSelectableModels[0]?.id || selectedModelId;
|
||||
const selectDisplayModel =
|
||||
selectableModels.find((model) => model.id === modelSelectValue) ||
|
||||
currentModel ||
|
||||
@ -3962,11 +3978,11 @@ export function CanvasChatPanel({
|
||||
]);
|
||||
|
||||
const firstImageModelId = useMemo(
|
||||
() => imageOnlyModels[0]?.id || null,
|
||||
() => imageOnlyModels.find((model) => model.selectable !== false)?.id || null,
|
||||
[imageOnlyModels]
|
||||
);
|
||||
const firstVideoModelId = useMemo(
|
||||
() => videoOnlyModels[0]?.id || null,
|
||||
() => videoOnlyModels.find((model) => model.selectable !== false)?.id || null,
|
||||
[videoOnlyModels]
|
||||
);
|
||||
const activeModeLabel =
|
||||
@ -9403,11 +9419,13 @@ export function CanvasChatPanel({
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
disabled={model.selectable === false}
|
||||
className={cn(
|
||||
"h-[34px] rounded-xl px-2.5 [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
|
||||
isVideoWorkspace
|
||||
? "text-foreground focus:bg-background focus:text-foreground data-[state=checked]:bg-background data-[state=checked]:text-foreground"
|
||||
: "text-[#2f3640] focus:bg-[#e7ebf0] focus:text-[#1f2937] data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937] dark:text-zinc-100 dark:focus:bg-white/10 dark:focus:text-white dark:data-[state=checked]:bg-white/10 dark:data-[state=checked]:text-white"
|
||||
: "text-[#2f3640] focus:bg-[#e7ebf0] focus:text-[#1f2937] data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937] dark:text-zinc-100 dark:focus:bg-white/10 dark:focus:text-white dark:data-[state=checked]:bg-white/10 dark:data-[state=checked]:text-white",
|
||||
model.selectable === false && "opacity-55"
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
@ -9459,6 +9477,22 @@ export function CanvasChatPanel({
|
||||
{discountLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{model.availabilityLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border px-1.5 py-0.5 text-[10px] font-semibold leading-none",
|
||||
model.availabilityStatus === "maintenance"
|
||||
? isVideoWorkspace
|
||||
? "border-amber-400/30 bg-amber-500/10 text-amber-300"
|
||||
: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-200"
|
||||
: isVideoWorkspace
|
||||
? "border-sky-400/30 bg-sky-500/10 text-sky-300"
|
||||
: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-400/30 dark:bg-sky-500/10 dark:text-sky-200"
|
||||
)}
|
||||
>
|
||||
{model.availabilityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{modelSelectValue === model.id ? (
|
||||
<span
|
||||
className={cn(
|
||||
@ -12105,6 +12139,10 @@ export function CanvasChatPanel({
|
||||
value={modelSelectValue}
|
||||
onValueChange={(value) => {
|
||||
const nextModel = allModels.find((model) => model.id === value);
|
||||
if (nextModel?.selectable === false) {
|
||||
toast.info(nextModel.disabledReason || "模型维护中,请稍后再试");
|
||||
return;
|
||||
}
|
||||
const nextIsVideoModel = isCanvasVideoModel(nextModel);
|
||||
const nextIsConversationModel = isCanvasConversationOnlyModel(nextModel);
|
||||
currentModelIdRef.current = value;
|
||||
|
||||
@ -36,6 +36,10 @@ type DynamicModel = {
|
||||
imageGenConfig?: {
|
||||
creditDiscountRate?: number | null;
|
||||
} | null;
|
||||
availabilityStatus?: "available" | "maintenance" | "testing";
|
||||
availabilityLabel?: string | null;
|
||||
selectable?: boolean;
|
||||
disabledReason?: string | null;
|
||||
};
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
@ -56,9 +60,16 @@ export function ModelSelector({
|
||||
const { data: dynamicModelsData, error: modelsError } = useSWR<{
|
||||
models: DynamicModel[];
|
||||
defaultModelId: string | null;
|
||||
}>("/api/models", fetcher);
|
||||
}>("/api/models?includeUnavailable=1", fetcher);
|
||||
|
||||
const dynamicModels = dynamicModelsData?.models ?? [];
|
||||
const dynamicModels = useMemo(
|
||||
() => dynamicModelsData?.models ?? [],
|
||||
[dynamicModelsData?.models]
|
||||
);
|
||||
const selectableDynamicModels = useMemo(
|
||||
() => dynamicModels.filter((model) => model.selectable !== false),
|
||||
[dynamicModels]
|
||||
);
|
||||
const defaultDynamicModelId = dynamicModelsData?.defaultModelId;
|
||||
|
||||
// 新增:手动添加 Auto 选项
|
||||
@ -72,6 +83,7 @@ export function ModelSelector({
|
||||
isDefault: false,
|
||||
providerName: "System",
|
||||
providerType: "system",
|
||||
selectable: true,
|
||||
};
|
||||
|
||||
// 将 Auto 放在列表最前面
|
||||
@ -90,20 +102,27 @@ export function ModelSelector({
|
||||
|
||||
// 如果当前选中的模型 ID 不存在,且有动态默认模型,则切换到默认模型
|
||||
useEffect(() => {
|
||||
if (dynamicModels.length > 0 && defaultDynamicModelId) {
|
||||
const currentExists = dynamicModels.some((m) => m.id === optimisticModelId);
|
||||
if (selectableDynamicModels.length > 0) {
|
||||
const currentExists = selectableDynamicModels.some(
|
||||
(m) => m.id === optimisticModelId
|
||||
);
|
||||
const fallbackModelId = selectableDynamicModels.some(
|
||||
(m) => m.id === defaultDynamicModelId
|
||||
)
|
||||
? defaultDynamicModelId
|
||||
: selectableDynamicModels[0]?.id;
|
||||
|
||||
if (!currentExists) {
|
||||
if (!currentExists && fallbackModelId) {
|
||||
startTransition(() => {
|
||||
setOptimisticModelId(defaultDynamicModelId);
|
||||
saveChatModelAsCookie(defaultDynamicModelId);
|
||||
setOptimisticModelId(fallbackModelId);
|
||||
saveChatModelAsCookie(fallbackModelId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
dynamicModels,
|
||||
defaultDynamicModelId,
|
||||
optimisticModelId,
|
||||
selectableDynamicModels,
|
||||
setOptimisticModelId,
|
||||
]);
|
||||
|
||||
@ -188,8 +207,13 @@ export function ModelSelector({
|
||||
asChild
|
||||
data-active={model.id === optimisticModelId}
|
||||
data-testid={`model-selector-item-${model.id}`}
|
||||
disabled={model.selectable === false}
|
||||
key={model.id}
|
||||
onSelect={() => {
|
||||
onSelect={(event) => {
|
||||
if (model.selectable === false) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
|
||||
startTransition(() => {
|
||||
@ -199,12 +223,28 @@ export function ModelSelector({
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="group/item flex w-full flex-row items-center justify-between gap-2 sm:gap-4"
|
||||
className={cn(
|
||||
"group/item flex w-full flex-row items-center justify-between gap-2 sm:gap-4",
|
||||
model.selectable === false && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
disabled={model.selectable === false}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex items-center gap-2 text-sm sm:text-base">
|
||||
{model.displayName}
|
||||
{model.availabilityLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px]",
|
||||
model.availabilityStatus === "maintenance"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-sky-100 text-sky-700"
|
||||
)}
|
||||
>
|
||||
{model.availabilityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{model.supportsVision && (
|
||||
<span className="text-[10px] bg-green-100 text-green-700 px-1.5 py-0.5 rounded">
|
||||
视觉
|
||||
|
||||
@ -47,6 +47,10 @@ type DynamicModel = {
|
||||
creditCostPerVideoByDuration?: Record<string, number>;
|
||||
creditCostPerVideoByResolution?: Record<string, number>;
|
||||
} | null;
|
||||
availabilityStatus?: "available" | "maintenance" | "testing";
|
||||
availabilityLabel?: string | null;
|
||||
selectable?: boolean;
|
||||
disabledReason?: string | null;
|
||||
};
|
||||
|
||||
type PointsSummaryResponse = {
|
||||
@ -243,7 +247,7 @@ function PureMultimodalInput({
|
||||
const { data: siteSettings } = useSWR("/api/site-settings?scope=chat", fetcher);
|
||||
const { data: modelCatalog } = useSWR<{
|
||||
models: DynamicModel[];
|
||||
}>("/api/models", fetcher);
|
||||
}>("/api/models?includeUnavailable=1", fetcher);
|
||||
const { data: membershipSummary } = useSWR<PointsSummaryResponse>(
|
||||
"/api/user/membership-summary",
|
||||
async (url: string) => {
|
||||
@ -271,7 +275,9 @@ function PureMultimodalInput({
|
||||
);
|
||||
|
||||
const quickSwitchTargets = useMemo(() => {
|
||||
const models = modelCatalog?.models || [];
|
||||
const models = (modelCatalog?.models || []).filter(
|
||||
(model) => model.selectable !== false
|
||||
);
|
||||
const nanoBananaImage = models.find(
|
||||
(model) =>
|
||||
isImageGenerationModel(model) &&
|
||||
@ -1563,7 +1569,7 @@ function PureModelSelectorCompact({
|
||||
avatarUrl?: string | null;
|
||||
providerType: string;
|
||||
} | null;
|
||||
}>("/api/models", fetcher);
|
||||
}>("/api/models?includeUnavailable=1", fetcher);
|
||||
|
||||
// 获取站点设置
|
||||
const { data: siteSettings } = useSWR<{
|
||||
@ -1621,6 +1627,7 @@ function PureModelSelectorCompact({
|
||||
supportsVision: true,
|
||||
isDefault: false,
|
||||
providerName: "System",
|
||||
selectable: true,
|
||||
};
|
||||
|
||||
const modeFilteredModels = useMemo(() => {
|
||||
@ -1632,6 +1639,10 @@ function PureModelSelectorCompact({
|
||||
}
|
||||
return dynamicModels;
|
||||
}, [dynamicModels, modeFilter]);
|
||||
const selectableModeFilteredModels = useMemo(
|
||||
() => modeFilteredModels.filter((model) => model.selectable !== false),
|
||||
[modeFilteredModels]
|
||||
);
|
||||
|
||||
// 合并模型列表(根据设置决定是否包含自动模型)
|
||||
const showAutoModel = siteSettings?.showAutoModel ?? true;
|
||||
@ -1645,18 +1656,22 @@ function PureModelSelectorCompact({
|
||||
useEffect(() => {
|
||||
if (displayModels.length === 0) return;
|
||||
|
||||
const currentExistsInDisplay = displayModels.some((m) => m.id === selectedModelId);
|
||||
const currentExistsInDisplay = displayModels.some(
|
||||
(m) => m.id === selectedModelId && m.selectable !== false
|
||||
);
|
||||
if (currentExistsInDisplay) return;
|
||||
|
||||
const currentExistsInAll = allModels.some((m) => m.id === selectedModelId);
|
||||
const currentExistsInAll = allModels.some(
|
||||
(m) => m.id === selectedModelId && m.selectable !== false
|
||||
);
|
||||
if (currentExistsInAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackImageModel =
|
||||
modeFilteredModels.find((model) => isNanoBananaProModel(model)) ||
|
||||
modeFilteredModels.find((model) => model.isDefault) ||
|
||||
modeFilteredModels[0];
|
||||
selectableModeFilteredModels.find((model) => isNanoBananaProModel(model)) ||
|
||||
selectableModeFilteredModels.find((model) => model.isDefault) ||
|
||||
selectableModeFilteredModels[0];
|
||||
const fallbackModelId =
|
||||
fallbackImageModel?.id ?? (canShowAutoModel ? "auto" : "");
|
||||
|
||||
@ -1667,7 +1682,14 @@ function PureModelSelectorCompact({
|
||||
startTransition(() => {
|
||||
saveChatModelAsCookie(fallbackModelId);
|
||||
});
|
||||
}, [allModels, canShowAutoModel, displayModels, modeFilteredModels, onModelChange, selectedModelId]);
|
||||
}, [
|
||||
allModels,
|
||||
canShowAutoModel,
|
||||
displayModels,
|
||||
onModelChange,
|
||||
selectableModeFilteredModels,
|
||||
selectedModelId,
|
||||
]);
|
||||
|
||||
// 自动选择模型的逻辑 - 必须在所有条件 return 之前定义
|
||||
const selectBestModel = useCallback(() => {
|
||||
@ -1675,16 +1697,20 @@ function PureModelSelectorCompact({
|
||||
|
||||
// 优先选择支持视觉的模型(如果有附件)
|
||||
if (hasAttachments) {
|
||||
const visionModel = dynamicModels.find((m) => m.supportsVision || m.isMultimodal);
|
||||
const visionModel = dynamicModels.find(
|
||||
(m) => m.selectable !== false && (m.supportsVision || m.isMultimodal)
|
||||
);
|
||||
if (visionModel) return visionModel;
|
||||
}
|
||||
|
||||
// 否则选择默认模型
|
||||
const defaultModel = dynamicModels.find((m) => m.isDefault);
|
||||
const defaultModel = dynamicModels.find(
|
||||
(m) => m.selectable !== false && m.isDefault
|
||||
);
|
||||
if (defaultModel) return defaultModel;
|
||||
|
||||
// 最后选择第一个可用模型
|
||||
return dynamicModels[0];
|
||||
return dynamicModels.find((m) => m.selectable !== false);
|
||||
}, [dynamicModels, attachments]);
|
||||
|
||||
// 从动态模型中查找当前选中的模型
|
||||
@ -1784,6 +1810,10 @@ function PureModelSelectorCompact({
|
||||
onValueChange={(modelId) => {
|
||||
const model = displayModels.find((m) => m.id === modelId);
|
||||
if (model) {
|
||||
if (model.selectable === false) {
|
||||
toast.info(model.disabledReason || "模型维护中,请稍后再试");
|
||||
return;
|
||||
}
|
||||
// 🆕 如果选择的是"自动"模式,直接传递 "auto" 给后端
|
||||
// 后端会使用智能分析器进行意图识别和模型选择
|
||||
if (model.id === "auto") {
|
||||
@ -1837,11 +1867,13 @@ function PureModelSelectorCompact({
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
disabled={model.selectable === false}
|
||||
className={cn(
|
||||
"h-11 rounded-[14px] px-3 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
|
||||
"focus:bg-[#e7ebf0] focus:text-[#1f2937]",
|
||||
"data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]",
|
||||
model.isSubModel ? "pl-8" : ""
|
||||
model.isSubModel ? "pl-8" : "",
|
||||
model.selectable === false && "opacity-55"
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
@ -1880,6 +1912,18 @@ function PureModelSelectorCompact({
|
||||
{getImageModelCreditDiscountLabel(model.imageGenConfig)}
|
||||
</span>
|
||||
) : null}
|
||||
{model.availabilityLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-[10px] border px-2 py-0.5 text-[11px] font-semibold leading-none",
|
||||
model.availabilityStatus === "maintenance"
|
||||
? "border-amber-200 bg-amber-50 text-amber-700"
|
||||
: "border-sky-200 bg-sky-50 text-sky-700"
|
||||
)}
|
||||
>
|
||||
{model.availabilityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{optimisticModelId === model.id ? (
|
||||
<span className="text-[18px] font-semibold text-[#1f2937]">✓</span>
|
||||
) : null}
|
||||
|
||||
@ -26,7 +26,9 @@ import { imageGenerationService } from "@/services/canvas";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import type {
|
||||
PublicStyleProCategory,
|
||||
PublicStyleProCustomField,
|
||||
PublicStyleProConfig,
|
||||
StyleProCustomInputMap,
|
||||
StyleProGenerationMode,
|
||||
StyleProHistoryItem,
|
||||
StyleProPersistedState,
|
||||
@ -55,6 +57,10 @@ type StyleProModelOption = {
|
||||
isDefault: boolean;
|
||||
unitCost: number;
|
||||
creditDiscountLabel?: string | null;
|
||||
availabilityStatus?: "available" | "maintenance" | "testing";
|
||||
availabilityLabel?: string | null;
|
||||
selectable?: boolean;
|
||||
disabledReason?: string | null;
|
||||
};
|
||||
|
||||
type StyleProModelsResponse = {
|
||||
@ -119,6 +125,7 @@ type StyleProGenerateResponse = {
|
||||
generationMode?: StyleProGenerationMode;
|
||||
chargedPoints?: number;
|
||||
selections: StyleProSelectionMap;
|
||||
customInputs?: StyleProCustomInputMap;
|
||||
optionLabels: string[];
|
||||
} | null;
|
||||
};
|
||||
@ -271,6 +278,149 @@ function mergeStyleProState(
|
||||
};
|
||||
}
|
||||
|
||||
function cloneCustomInputs(
|
||||
inputs?: StyleProCustomInputMap | null
|
||||
): StyleProCustomInputMap {
|
||||
return {
|
||||
global: { ...(inputs?.global || {}) },
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(inputs?.categories || {}).map(([categoryId, values]) => [
|
||||
categoryId,
|
||||
{ ...(values || {}) },
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultCustomInputs(
|
||||
config?: PublicStyleProConfig | null,
|
||||
current?: StyleProCustomInputMap | null
|
||||
): StyleProCustomInputMap {
|
||||
const next = cloneCustomInputs(current);
|
||||
for (const field of config?.customFields || []) {
|
||||
if (next.global?.[field.id] === undefined && field.defaultValue) {
|
||||
next.global![field.id] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
for (const category of config?.categories || []) {
|
||||
for (const field of category.customFields || []) {
|
||||
if (!field.defaultValue) continue;
|
||||
next.categories![category.id] = next.categories![category.id] || {};
|
||||
if (next.categories![category.id][field.id] === undefined) {
|
||||
next.categories![category.id][field.id] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function hasCustomFields(config?: PublicStyleProConfig | null) {
|
||||
return Boolean(
|
||||
(config?.customFields || []).length ||
|
||||
(config?.categories || []).some((category) => category.customFields?.length)
|
||||
);
|
||||
}
|
||||
|
||||
function getCustomFieldColorValue(value?: string | null) {
|
||||
const trimmed = String(value || "").trim();
|
||||
const shortHex = trimmed.match(/^#([0-9a-fA-F]{3})$/);
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (shortHex) {
|
||||
return `#${shortHex[1]
|
||||
.split("")
|
||||
.map((char) => `${char}${char}`)
|
||||
.join("")}`;
|
||||
}
|
||||
return "#ffffff";
|
||||
}
|
||||
|
||||
function StyleProCustomFieldInput({
|
||||
field,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
field: PublicStyleProCustomField;
|
||||
onChange: (value: string) => void;
|
||||
value?: string;
|
||||
}) {
|
||||
const inputValue = value || "";
|
||||
const label = (
|
||||
<label
|
||||
className="mb-1.5 block text-[12px] font-semibold text-black/62"
|
||||
htmlFor={`style-pro-custom-${field.id}`}
|
||||
>
|
||||
{field.label}
|
||||
{field.required ? <span className="ml-0.5 text-red-500">*</span> : null}
|
||||
</label>
|
||||
);
|
||||
|
||||
if (field.type === "color") {
|
||||
return (
|
||||
<div className="min-w-[230px] flex-1">
|
||||
{label}
|
||||
<div className="flex h-10 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 shadow-sm">
|
||||
<input
|
||||
aria-label={`${field.label}颜色`}
|
||||
className="h-7 w-8 shrink-0 cursor-pointer rounded-md border-0 bg-transparent p-0"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
type="color"
|
||||
value={getCustomFieldColorValue(inputValue || field.defaultValue)}
|
||||
/>
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent font-mono text-[13px] font-semibold text-black/72 outline-none"
|
||||
id={`style-pro-custom-${field.id}`}
|
||||
maxLength={7}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder || "#FFFFFF"}
|
||||
value={inputValue}
|
||||
/>
|
||||
</div>
|
||||
{field.description ? (
|
||||
<div className="mt-1 text-[11px] text-black/40">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<div className="min-w-[260px] flex-1 md:col-span-2">
|
||||
{label}
|
||||
<textarea
|
||||
className="min-h-[76px] w-full resize-y rounded-[14px] border border-black/10 bg-white px-3 py-2 text-[13px] text-black/72 shadow-sm outline-none transition placeholder:text-black/35 focus:border-black/25"
|
||||
id={`style-pro-custom-${field.id}`}
|
||||
maxLength={field.maxLength || 800}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder || "请输入自定义要求..."}
|
||||
value={inputValue}
|
||||
/>
|
||||
{field.description ? (
|
||||
<div className="mt-1 text-[11px] text-black/40">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[230px] flex-1">
|
||||
{label}
|
||||
<input
|
||||
className="h-10 w-full rounded-[14px] border border-black/10 bg-white px-3 text-[13px] text-black/72 shadow-sm outline-none transition placeholder:text-black/35 focus:border-black/25"
|
||||
id={`style-pro-custom-${field.id}`}
|
||||
maxLength={field.maxLength || 160}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder || "请输入自定义要求..."}
|
||||
value={inputValue}
|
||||
/>
|
||||
{field.description ? (
|
||||
<div className="mt-1 text-[11px] text-black/40">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyleProUserInitials(name?: string | null, email?: string | null) {
|
||||
const source = String(name || email || "mu").trim();
|
||||
return (source || "mu").slice(0, 2).toLowerCase();
|
||||
@ -804,6 +954,10 @@ export function StyleProWorkspace({
|
||||
() => (modelsData?.models || []).filter((model) => Boolean(model.id)),
|
||||
[modelsData?.models]
|
||||
);
|
||||
const selectableModelOptions = useMemo(
|
||||
() => modelOptions.filter((model) => model.selectable !== false),
|
||||
[modelOptions]
|
||||
);
|
||||
const enabledCategories = useMemo(
|
||||
() =>
|
||||
(config?.categories || [])
|
||||
@ -840,6 +994,10 @@ export function StyleProWorkspace({
|
||||
.history
|
||||
);
|
||||
const [selections, setSelections] = useState<StyleProSelectionMap>({});
|
||||
const [customInputs, setCustomInputs] = useState<StyleProCustomInputMap>({
|
||||
global: {},
|
||||
categories: {},
|
||||
});
|
||||
const [generationMode, setGenerationMode] =
|
||||
useState<StyleProGenerationMode>("style");
|
||||
const [selectedModelId, setSelectedModelId] = useState("");
|
||||
@ -851,17 +1009,27 @@ export function StyleProWorkspace({
|
||||
generationTaskState?.status === "running";
|
||||
|
||||
useEffect(() => {
|
||||
if (modelOptions.length === 0) {
|
||||
if (selectableModelOptions.length === 0) {
|
||||
setSelectedModelId("");
|
||||
return;
|
||||
}
|
||||
setSelectedModelId((current) => {
|
||||
if (current && modelOptions.some((model) => model.id === current)) {
|
||||
if (
|
||||
current &&
|
||||
selectableModelOptions.some((model) => model.id === current)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return modelsData?.defaultModelId || modelOptions[0]?.id || "";
|
||||
const defaultModel = selectableModelOptions.find(
|
||||
(model) => model.id === modelsData?.defaultModelId
|
||||
);
|
||||
return defaultModel?.id || selectableModelOptions[0]?.id || "";
|
||||
});
|
||||
}, [modelOptions, modelsData?.defaultModelId]);
|
||||
}, [modelsData?.defaultModelId, selectableModelOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomInputs((current) => buildDefaultCustomInputs(config, current));
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
const localState = readLocalStyleProState(canvasId);
|
||||
@ -910,10 +1078,10 @@ export function StyleProWorkspace({
|
||||
|
||||
const selectedModel = useMemo(
|
||||
() =>
|
||||
modelOptions.find((model) => model.id === selectedModelId) ||
|
||||
modelOptions[0] ||
|
||||
selectableModelOptions.find((model) => model.id === selectedModelId) ||
|
||||
selectableModelOptions[0] ||
|
||||
null,
|
||||
[modelOptions, selectedModelId]
|
||||
[selectableModelOptions, selectedModelId]
|
||||
);
|
||||
|
||||
const selectedModelCostLabel = selectedModel
|
||||
@ -948,6 +1116,24 @@ export function StyleProWorkspace({
|
||||
[enabledCategories, selections]
|
||||
);
|
||||
|
||||
const missingRequiredCustomField = useMemo(() => {
|
||||
for (const field of config?.customFields || []) {
|
||||
if (!field.required) continue;
|
||||
const value = String(customInputs.global?.[field.id] || "").trim();
|
||||
if (!value) return field.label;
|
||||
}
|
||||
for (const category of enabledCategories) {
|
||||
for (const field of category.customFields || []) {
|
||||
if (!field.required) continue;
|
||||
const value = String(
|
||||
customInputs.categories?.[category.id]?.[field.id] || ""
|
||||
).trim();
|
||||
if (!value) return `${category.title} / ${field.label}`;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}, [config?.customFields, customInputs, enabledCategories]);
|
||||
|
||||
const persistState = useCallback(
|
||||
async (nextState: StyleProPersistedState) => {
|
||||
const normalizedState = {
|
||||
@ -1018,6 +1204,41 @@ export function StyleProWorkspace({
|
||||
[]
|
||||
);
|
||||
|
||||
const updateCustomInput = useCallback(
|
||||
({
|
||||
categoryId,
|
||||
fieldId,
|
||||
scope,
|
||||
value,
|
||||
}: {
|
||||
categoryId?: string;
|
||||
fieldId: string;
|
||||
scope: "global" | "category";
|
||||
value: string;
|
||||
}) => {
|
||||
setCustomInputs((current) => {
|
||||
const next = cloneCustomInputs(current);
|
||||
if (scope === "global") {
|
||||
next.global = {
|
||||
...(next.global || {}),
|
||||
[fieldId]: value,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
if (!categoryId) return next;
|
||||
next.categories = {
|
||||
...(next.categories || {}),
|
||||
[categoryId]: {
|
||||
...(next.categories?.[categoryId] || {}),
|
||||
[fieldId]: value,
|
||||
},
|
||||
};
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (activeGenerateRequestRef.current || generating) {
|
||||
toast.info("当前图片正在生成中,请稍候");
|
||||
@ -1035,6 +1256,10 @@ export function StyleProWorkspace({
|
||||
toast.error(`请完成必选项:${missingRequiredCategories[0].title}`);
|
||||
return;
|
||||
}
|
||||
if (missingRequiredCustomField) {
|
||||
toast.error(`请填写:${missingRequiredCustomField}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = createStyleProRequestId();
|
||||
const localRequestKey = requestId || `local-${Date.now()}`;
|
||||
@ -1060,6 +1285,7 @@ export function StyleProWorkspace({
|
||||
: undefined,
|
||||
sourceImageUrl,
|
||||
selections,
|
||||
customInputs,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as
|
||||
@ -1131,6 +1357,7 @@ export function StyleProWorkspace({
|
||||
resultImageUrl: taskResult.imageUrl,
|
||||
selections: taskResult.selections || selections,
|
||||
optionLabels: taskResult.optionLabels || selectedOptionLabels,
|
||||
customInputs: taskResult.customInputs || customInputs,
|
||||
createdAt: new Date().toISOString(),
|
||||
generationMode: taskResult.generationMode || generationMode,
|
||||
model: taskResult.model,
|
||||
@ -1167,7 +1394,9 @@ export function StyleProWorkspace({
|
||||
generationMode,
|
||||
generating,
|
||||
history,
|
||||
customInputs,
|
||||
missingRequiredCategories,
|
||||
missingRequiredCustomField,
|
||||
persistState,
|
||||
selectedOptionLabels,
|
||||
selectedModel?.id,
|
||||
@ -1176,13 +1405,17 @@ export function StyleProWorkspace({
|
||||
sourceImageUrl,
|
||||
]);
|
||||
|
||||
const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => {
|
||||
setSourceImageUrl(item.sourceImageUrl);
|
||||
setSelections(item.selections || {});
|
||||
setGenerationMode(item.generationMode || "style");
|
||||
setCurrentResult(item);
|
||||
setGenerationTaskState(null);
|
||||
}, []);
|
||||
const restoreHistoryItem = useCallback(
|
||||
(item: StyleProHistoryItem) => {
|
||||
setSourceImageUrl(item.sourceImageUrl);
|
||||
setSelections(item.selections || {});
|
||||
setCustomInputs(buildDefaultCustomInputs(config, item.customInputs));
|
||||
setGenerationMode(item.generationMode || "style");
|
||||
setCurrentResult(item);
|
||||
setGenerationTaskState(null);
|
||||
},
|
||||
[config]
|
||||
);
|
||||
|
||||
const disabledReason = !sourceImageUrl
|
||||
? "请先上传图片"
|
||||
@ -1190,10 +1423,12 @@ export function StyleProWorkspace({
|
||||
? "请先配置实景化模型"
|
||||
: generationMode === "style" &&
|
||||
modelsData?.success === true &&
|
||||
modelOptions.length === 0
|
||||
selectableModelOptions.length === 0
|
||||
? "暂无可用生图模型"
|
||||
: missingRequiredCategories.length > 0
|
||||
? `还差:${missingRequiredCategories[0].title}`
|
||||
: missingRequiredCustomField
|
||||
? `还差:${missingRequiredCustomField}`
|
||||
: "";
|
||||
const promptDisplayMode = config?.promptDisplayMode || "stacked";
|
||||
const themeStyle = {
|
||||
@ -1210,6 +1445,7 @@ export function StyleProWorkspace({
|
||||
layout: "stacked" | "carousel" = "stacked"
|
||||
) => {
|
||||
const selectedIds = selections[category.id] || [];
|
||||
const categoryCustomFields = category.customFields || [];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -1243,6 +1479,30 @@ export function StyleProWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{categoryCustomFields.length > 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 grid gap-3 border-black/5 border-t pt-3",
|
||||
layout === "carousel" ? "grid-cols-1" : "md:grid-cols-2"
|
||||
)}
|
||||
>
|
||||
{categoryCustomFields.map((field) => (
|
||||
<StyleProCustomFieldInput
|
||||
field={field}
|
||||
key={field.id}
|
||||
onChange={(value) =>
|
||||
updateCustomInput({
|
||||
categoryId: category.id,
|
||||
fieldId: field.id,
|
||||
scope: "category",
|
||||
value,
|
||||
})
|
||||
}
|
||||
value={customInputs.categories?.[category.id]?.[field.id] || ""}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1387,6 +1647,37 @@ export function StyleProWorkspace({
|
||||
))
|
||||
)}
|
||||
|
||||
{hasCustomFields(config) && (config?.customFields || []).length > 0 ? (
|
||||
<section className="section-card p-6">
|
||||
<div className="rounded-[20px] bg-black/[0.025] p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<span className="subsection-title">自定义参数</span>
|
||||
<p className="mt-1 text-[13px] text-black/45">
|
||||
这里的内容会作为隐藏要求加入生成提示词。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(config?.customFields || []).map((field) => (
|
||||
<StyleProCustomFieldInput
|
||||
field={field}
|
||||
key={field.id}
|
||||
onChange={(value) =>
|
||||
updateCustomInput({
|
||||
fieldId: field.id,
|
||||
scope: "global",
|
||||
value,
|
||||
})
|
||||
}
|
||||
value={customInputs.global?.[field.id] || ""}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section-card p-5">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_300px] 2xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="rounded-[20px] bg-black/[0.025] p-4">
|
||||
@ -1425,7 +1716,7 @@ export function StyleProWorkspace({
|
||||
className="h-6 max-w-[210px] bg-transparent font-semibold text-black/75 outline-none"
|
||||
disabled={
|
||||
modelsLoading ||
|
||||
modelOptions.length === 0 ||
|
||||
selectableModelOptions.length === 0 ||
|
||||
generating
|
||||
}
|
||||
onChange={(event) =>
|
||||
@ -1435,11 +1726,15 @@ export function StyleProWorkspace({
|
||||
>
|
||||
{modelOptions.length > 0 ? (
|
||||
modelOptions.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.displayName} ·{" "}
|
||||
{model.unitCost > 0
|
||||
? `${model.unitCost}积分/张`
|
||||
: "未设置积分"}
|
||||
<option
|
||||
disabled={model.selectable === false}
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
>
|
||||
{model.displayName}
|
||||
{model.availabilityLabel
|
||||
? `(${model.availabilityLabel})`
|
||||
: ""}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
@ -1456,6 +1751,11 @@ export function StyleProWorkspace({
|
||||
{selectedModel.creditDiscountLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{selectedModel?.availabilityLabel ? (
|
||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 font-semibold text-amber-600">
|
||||
{selectedModel.availabilityLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{generationMode === "realistic" ? (
|
||||
|
||||
55
lib/ai/model-frontend-availability.ts
Normal file
55
lib/ai/model-frontend-availability.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { AiModel } from "@/lib/db/schema";
|
||||
|
||||
export type ModelFrontendAvailabilityStatus =
|
||||
| "available"
|
||||
| "maintenance"
|
||||
| "testing";
|
||||
|
||||
export type ModelFrontendAvailability = {
|
||||
availabilityStatus: ModelFrontendAvailabilityStatus;
|
||||
availabilityLabel: string | null;
|
||||
selectable: boolean;
|
||||
disabledReason: string | null;
|
||||
};
|
||||
|
||||
type AvailabilityModel = Pick<
|
||||
AiModel,
|
||||
| "isEnabled"
|
||||
| "healthStatus"
|
||||
| "healthAutoDisabledAt"
|
||||
| "healthObservationStartedAt"
|
||||
>;
|
||||
|
||||
export function getAiModelFrontendAvailability(
|
||||
model: AvailabilityModel
|
||||
): ModelFrontendAvailability {
|
||||
const healthStatus = model.healthStatus || "healthy";
|
||||
|
||||
if (
|
||||
healthStatus === "auto_disabled" ||
|
||||
(model.isEnabled === false && Boolean(model.healthAutoDisabledAt))
|
||||
) {
|
||||
return {
|
||||
availabilityStatus: "maintenance",
|
||||
availabilityLabel: "维护中",
|
||||
selectable: false,
|
||||
disabledReason: "模型维护中,请稍后再试",
|
||||
};
|
||||
}
|
||||
|
||||
if (healthStatus === "degraded" && model.healthObservationStartedAt) {
|
||||
return {
|
||||
availabilityStatus: "testing",
|
||||
availabilityLabel: "测试中",
|
||||
selectable: true,
|
||||
disabledReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
availabilityStatus: "available",
|
||||
availabilityLabel: null,
|
||||
selectable: true,
|
||||
disabledReason: null,
|
||||
};
|
||||
}
|
||||
@ -9,6 +9,7 @@ import {
|
||||
gt,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
like,
|
||||
lt,
|
||||
notInArray,
|
||||
@ -4577,6 +4578,70 @@ export async function getEnabledAiModelsWithProvider(
|
||||
}
|
||||
}
|
||||
|
||||
// 获取前端展示用模型列表:包含可用模型,以及被健康检查临时封禁的维护中模型。
|
||||
export async function getFrontendDisplayAiModelsWithProvider(
|
||||
includeSubModels: boolean = false,
|
||||
includeHidden: boolean = true
|
||||
) {
|
||||
try {
|
||||
await reviveAiModelsFromCooldown();
|
||||
const visibilityCondition = or(
|
||||
eq(aiModel.isEnabled, true),
|
||||
and(
|
||||
eq(aiModel.isEnabled, false),
|
||||
eq(aiModel.healthStatus, "auto_disabled"),
|
||||
isNotNull(aiModel.healthAutoDisabledAt)
|
||||
)
|
||||
);
|
||||
const conditions: SQL[] = [
|
||||
eq(aiProvider.isEnabled, true),
|
||||
];
|
||||
|
||||
if (visibilityCondition) {
|
||||
conditions.push(visibilityCondition);
|
||||
}
|
||||
|
||||
if (!includeSubModels) {
|
||||
conditions.push(eq(aiModel.isSubModel, false));
|
||||
}
|
||||
if (!includeHidden) {
|
||||
conditions.push(eq(aiModel.isHidden, false));
|
||||
}
|
||||
|
||||
const models = await db
|
||||
.select({
|
||||
model: aiModel,
|
||||
provider: aiProvider,
|
||||
})
|
||||
.from(aiModel)
|
||||
.innerJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
|
||||
.where(and(...conditions));
|
||||
|
||||
const healthPriority = (status?: string | null) => {
|
||||
if (status === "healthy") return 0;
|
||||
if (status === "degraded") return 1;
|
||||
if (status === "auto_disabled") return 2;
|
||||
return 3;
|
||||
};
|
||||
|
||||
return [...models].sort((left, right) => {
|
||||
const byHealth =
|
||||
healthPriority(left.model.healthStatus) -
|
||||
healthPriority(right.model.healthStatus);
|
||||
if (byHealth !== 0) return byHealth;
|
||||
if (left.model.isDefault !== right.model.isDefault) {
|
||||
return Number(right.model.isDefault) - Number(left.model.isDefault);
|
||||
}
|
||||
return (left.model.sortOrder ?? 0) - (right.model.sortOrder ?? 0);
|
||||
});
|
||||
} catch (_error) {
|
||||
throw new ChatSDKError(
|
||||
"bad_request:database",
|
||||
"Failed to get frontend display AI models with provider"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentAiModelWithProvider() {
|
||||
const models = await getEnabledAiModelsWithProvider(false);
|
||||
const candidates = models
|
||||
|
||||
@ -10,6 +10,22 @@ export type StyleProOption = {
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type StyleProCustomFieldType = "text" | "textarea" | "color";
|
||||
|
||||
export type StyleProCustomField = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type: StyleProCustomFieldType;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
promptTemplate?: string;
|
||||
required: boolean;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export type StyleProCategory = {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -19,6 +35,7 @@ export type StyleProCategory = {
|
||||
maxSelections: number;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
customFields: StyleProCustomField[];
|
||||
options: StyleProOption[];
|
||||
};
|
||||
|
||||
@ -42,12 +59,21 @@ export type StyleProConfig = {
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
promptDisplayMode: StyleProPromptDisplayMode;
|
||||
customFields: StyleProCustomField[];
|
||||
categories: StyleProCategory[];
|
||||
};
|
||||
|
||||
export type PublicStyleProOption = Omit<StyleProOption, "prompt">;
|
||||
export type PublicStyleProCustomField = Omit<
|
||||
StyleProCustomField,
|
||||
"promptTemplate"
|
||||
>;
|
||||
|
||||
export type PublicStyleProCategory = Omit<StyleProCategory, "options"> & {
|
||||
export type PublicStyleProCategory = Omit<
|
||||
StyleProCategory,
|
||||
"customFields" | "options"
|
||||
> & {
|
||||
customFields: PublicStyleProCustomField[];
|
||||
options: PublicStyleProOption[];
|
||||
};
|
||||
|
||||
@ -60,13 +86,19 @@ export type PublicStyleProConfig = Omit<
|
||||
| "realisticSecondPrompt"
|
||||
| "defaultPrompt"
|
||||
| "negativePrompt"
|
||||
| "customFields"
|
||||
| "categories"
|
||||
> & {
|
||||
realisticModeAvailable: boolean;
|
||||
customFields: PublicStyleProCustomField[];
|
||||
categories: PublicStyleProCategory[];
|
||||
};
|
||||
|
||||
export type StyleProSelectionMap = Record<string, string[]>;
|
||||
export type StyleProCustomInputMap = {
|
||||
global?: Record<string, string>;
|
||||
categories?: Record<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
export type StyleProHistoryItem = {
|
||||
id: string;
|
||||
@ -74,6 +106,7 @@ export type StyleProHistoryItem = {
|
||||
resultImageUrl: string;
|
||||
selections: StyleProSelectionMap;
|
||||
optionLabels: string[];
|
||||
customInputs?: StyleProCustomInputMap;
|
||||
createdAt: string;
|
||||
generationMode?: StyleProGenerationMode;
|
||||
model?: string;
|
||||
@ -150,6 +183,7 @@ function presetCategory(params: {
|
||||
maxSelections: params.maxSelections ?? 1,
|
||||
enabled: true,
|
||||
order: params.order,
|
||||
customFields: [],
|
||||
options: params.options.map((option, index) =>
|
||||
presetOption(option.id, option.label, option.prompt, params.jsonKey, (index + 1) * 10)
|
||||
),
|
||||
@ -451,6 +485,7 @@ export const defaultStyleProConfig: StyleProConfig = {
|
||||
aspectRatio: "auto",
|
||||
imageSize: "2K",
|
||||
promptDisplayMode: "stacked",
|
||||
customFields: [],
|
||||
categories: INDEX_25_STYLE_PRO_CATEGORIES,
|
||||
};
|
||||
|
||||
@ -488,6 +523,58 @@ function cleanPromptDisplayMode(value: unknown): StyleProPromptDisplayMode {
|
||||
return value === "carousel" ? "carousel" : "stacked";
|
||||
}
|
||||
|
||||
function cleanCustomFieldType(value: unknown): StyleProCustomFieldType {
|
||||
return value === "color" || value === "textarea" ? value : "text";
|
||||
}
|
||||
|
||||
function cleanCustomField(
|
||||
value: unknown,
|
||||
index: number,
|
||||
fallbackIdPrefix: string
|
||||
): StyleProCustomField | null {
|
||||
const raw = asRecord(value);
|
||||
const type = cleanCustomFieldType(raw.type);
|
||||
const id = cleanId(raw.id, `${fallbackIdPrefix}-field-${index + 1}`);
|
||||
const label = cleanString(raw.label, `自定义项 ${index + 1}`, 80);
|
||||
if (!label) return null;
|
||||
const fallbackMaxLength = type === "textarea" ? 800 : 160;
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
description: cleanString(raw.description, "", 240) || undefined,
|
||||
type,
|
||||
placeholder: cleanString(raw.placeholder, "", 160) || undefined,
|
||||
defaultValue:
|
||||
(type === "color"
|
||||
? cleanHexColor(raw.defaultValue, "")
|
||||
: cleanString(raw.defaultValue, "", type === "textarea" ? 800 : 160)) ||
|
||||
undefined,
|
||||
promptTemplate:
|
||||
cleanString(
|
||||
raw.promptTemplate,
|
||||
"{{fieldLabel}}:{{value}}",
|
||||
1000
|
||||
) || undefined,
|
||||
required: typeof raw.required === "boolean" ? raw.required : false,
|
||||
enabled: typeof raw.enabled === "boolean" ? raw.enabled : true,
|
||||
order: cleanNumber(raw.order, (index + 1) * 10, -9999, 9999),
|
||||
maxLength: cleanNumber(raw.maxLength, fallbackMaxLength, 1, 2000),
|
||||
};
|
||||
}
|
||||
|
||||
function cleanCustomFields(
|
||||
value: unknown,
|
||||
fallbackIdPrefix: string
|
||||
): StyleProCustomField[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return sortByOrder(
|
||||
value
|
||||
.map((entry, index) => cleanCustomField(entry, index, fallbackIdPrefix))
|
||||
.filter((field): field is StyleProCustomField => Boolean(field))
|
||||
);
|
||||
}
|
||||
|
||||
function sortByOrder<T extends { order: number; id: string }>(items: T[]) {
|
||||
return [...items].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
||||
}
|
||||
@ -546,6 +633,7 @@ export function normalizeStyleProConfig(value: unknown): StyleProConfig {
|
||||
enabled:
|
||||
typeof categoryRaw.enabled === "boolean" ? categoryRaw.enabled : true,
|
||||
order: cleanNumber(categoryRaw.order, (categoryIndex + 1) * 10, -9999, 9999),
|
||||
customFields: cleanCustomFields(categoryRaw.customFields, id),
|
||||
options: sortByOrder(options),
|
||||
} satisfies StyleProCategory;
|
||||
})
|
||||
@ -600,6 +688,7 @@ export function normalizeStyleProConfig(value: unknown): StyleProConfig {
|
||||
aspectRatio: cleanString(raw.aspectRatio, fallback.aspectRatio, 32),
|
||||
imageSize: cleanString(raw.imageSize, fallback.imageSize, 32),
|
||||
promptDisplayMode: cleanPromptDisplayMode(raw.promptDisplayMode),
|
||||
customFields: cleanCustomFields(raw.customFields, "global"),
|
||||
categories: isLegacyDefaultCategories(sortedCategories)
|
||||
? fallback.categories
|
||||
: sortedCategories,
|
||||
@ -626,6 +715,33 @@ export function normalizeStyleProPersistedState(
|
||||
: [],
|
||||
])
|
||||
);
|
||||
const customInputsRaw = asRecord(itemRaw.customInputs);
|
||||
const customInputCategoriesRaw = asRecord(customInputsRaw.categories);
|
||||
const customInputs: StyleProCustomInputMap = {
|
||||
global: Object.fromEntries(
|
||||
Object.entries(asRecord(customInputsRaw.global))
|
||||
.map(([key, value]) => [
|
||||
cleanId(key, key),
|
||||
cleanString(value, "", 2000),
|
||||
])
|
||||
.filter(([, value]) => Boolean(value))
|
||||
),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(customInputCategoriesRaw)
|
||||
.map(([categoryId, value]) => [
|
||||
cleanId(categoryId, categoryId),
|
||||
Object.fromEntries(
|
||||
Object.entries(asRecord(value))
|
||||
.map(([key, entryValue]) => [
|
||||
cleanId(key, key),
|
||||
cleanString(entryValue, "", 2000),
|
||||
])
|
||||
.filter(([, entryValue]) => Boolean(entryValue))
|
||||
),
|
||||
])
|
||||
.filter(([, value]) => Object.keys(value).length > 0)
|
||||
),
|
||||
};
|
||||
const normalizedItem: StyleProHistoryItem = {
|
||||
id: cleanString(itemRaw.id, `style-pro-history-${index + 1}`, 120),
|
||||
sourceImageUrl,
|
||||
@ -636,6 +752,7 @@ export function normalizeStyleProPersistedState(
|
||||
.map((entry) => cleanString(entry, "", 80))
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
customInputs,
|
||||
createdAt:
|
||||
cleanString(itemRaw.createdAt, "", 80) || new Date(0).toISOString(),
|
||||
};
|
||||
@ -664,6 +781,11 @@ export function normalizeStyleProPersistedState(
|
||||
export function toPublicStyleProConfig(
|
||||
config: StyleProConfig
|
||||
): PublicStyleProConfig {
|
||||
const toPublicCustomField = ({
|
||||
promptTemplate: _promptTemplate,
|
||||
...field
|
||||
}: StyleProCustomField): PublicStyleProCustomField => field;
|
||||
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
aspectRatio: config.aspectRatio,
|
||||
@ -677,10 +799,16 @@ export function toPublicStyleProConfig(
|
||||
realisticModeAvailable: Boolean(
|
||||
config.realisticFirstModelId && config.realisticSecondModelId
|
||||
),
|
||||
customFields: config.customFields
|
||||
.filter((field) => field.enabled)
|
||||
.map(toPublicCustomField),
|
||||
categories: config.categories
|
||||
.filter((category) => category.enabled)
|
||||
.map((category) => ({
|
||||
...category,
|
||||
customFields: category.customFields
|
||||
.filter((field) => field.enabled)
|
||||
.map(toPublicCustomField),
|
||||
options: category.options
|
||||
.filter((option) => option.enabled)
|
||||
.map(({ prompt: _prompt, ...option }) => option),
|
||||
|
||||
@ -5,11 +5,21 @@ import {
|
||||
getImageCreditCostFromConfig,
|
||||
getImageModelCreditDiscountLabel,
|
||||
} from "@/lib/ai/image-model-pricing";
|
||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import {
|
||||
getAiModelFrontendAvailability,
|
||||
type ModelFrontendAvailabilityStatus,
|
||||
} from "@/lib/ai/model-frontend-availability";
|
||||
import {
|
||||
getEnabledAiModelsWithProvider,
|
||||
getFrontendDisplayAiModelsWithProvider,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export type StyleProModelEntry = Awaited<
|
||||
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||
>[number];
|
||||
export type StyleProDisplayModelEntry = Awaited<
|
||||
ReturnType<typeof getFrontendDisplayAiModelsWithProvider>
|
||||
>[number];
|
||||
|
||||
export type PublicStyleProModelOption = {
|
||||
id: string;
|
||||
@ -21,6 +31,10 @@ export type PublicStyleProModelOption = {
|
||||
isDefault: boolean;
|
||||
unitCost: number;
|
||||
creditDiscountLabel: string | null;
|
||||
availabilityStatus: ModelFrontendAvailabilityStatus;
|
||||
availabilityLabel: string | null;
|
||||
selectable: boolean;
|
||||
disabledReason: string | null;
|
||||
};
|
||||
|
||||
const VIDEO_PROVIDER_TYPES = new Set([
|
||||
@ -70,7 +84,9 @@ function hasVideoCapabilities(value: unknown) {
|
||||
return serialized.includes("text-to-video") || serialized.includes("image-to-video");
|
||||
}
|
||||
|
||||
function isVideoLikeModel(entry: StyleProModelEntry) {
|
||||
function isVideoLikeModel(
|
||||
entry: StyleProModelEntry | StyleProDisplayModelEntry
|
||||
) {
|
||||
if (VIDEO_PROVIDER_TYPES.has(entry.provider.type)) return true;
|
||||
if (
|
||||
hasVideoGenerationConfig(entry.model.imageGenConfig) ||
|
||||
@ -92,7 +108,9 @@ function isVideoLikeModel(entry: StyleProModelEntry) {
|
||||
return VIDEO_MODEL_KEYWORDS.some((keyword) => searchable.includes(keyword));
|
||||
}
|
||||
|
||||
export function isStyleProImageModel(entry: StyleProModelEntry) {
|
||||
export function isStyleProImageModel(
|
||||
entry: StyleProModelEntry | StyleProDisplayModelEntry
|
||||
) {
|
||||
return Boolean(entry.model.supportsImageGen) && !isVideoLikeModel(entry);
|
||||
}
|
||||
|
||||
@ -106,8 +124,18 @@ export async function getStyleProImageModelEntries(options?: {
|
||||
return models.filter(isStyleProImageModel);
|
||||
}
|
||||
|
||||
export async function getStyleProImageModelDisplayEntries(options?: {
|
||||
includeHidden?: boolean;
|
||||
}) {
|
||||
const models = await getFrontendDisplayAiModelsWithProvider(
|
||||
false,
|
||||
options?.includeHidden === true
|
||||
);
|
||||
return models.filter(isStyleProImageModel);
|
||||
}
|
||||
|
||||
export function findStyleProImageModelByRef(
|
||||
entries: StyleProModelEntry[],
|
||||
entries: Array<StyleProModelEntry | StyleProDisplayModelEntry>,
|
||||
modelRef?: string | null
|
||||
) {
|
||||
const normalized = String(modelRef || "").trim();
|
||||
@ -120,10 +148,11 @@ export function findStyleProImageModelByRef(
|
||||
}
|
||||
|
||||
export function toPublicStyleProModelOption(
|
||||
entry: StyleProModelEntry
|
||||
entry: StyleProModelEntry | StyleProDisplayModelEntry
|
||||
): PublicStyleProModelOption {
|
||||
const imageGenConfig = (entry.model.imageGenConfig ||
|
||||
null) as DiscountableImageGenConfigLike | null;
|
||||
const availability = getAiModelFrontendAvailability(entry.model);
|
||||
return {
|
||||
id: entry.model.id,
|
||||
modelId: entry.model.modelId,
|
||||
@ -134,5 +163,6 @@ export function toPublicStyleProModelOption(
|
||||
isDefault: entry.model.isDefault,
|
||||
unitCost: getImageCreditCostFromConfig(imageGenConfig),
|
||||
creditDiscountLabel: getImageModelCreditDiscountLabel(imageGenConfig),
|
||||
...availability,
|
||||
};
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from "@/lib/canvas/generate-image";
|
||||
import type {
|
||||
StyleProGenerationMode,
|
||||
StyleProCustomInputMap,
|
||||
StyleProSelectionMap,
|
||||
} from "./config";
|
||||
|
||||
@ -19,6 +20,17 @@ export type StyleProTaskSelectedOption = {
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type StyleProTaskCustomInput = {
|
||||
scope: "global" | "category";
|
||||
fieldId: string;
|
||||
fieldLabel: string;
|
||||
fieldType: string;
|
||||
value: string;
|
||||
prompt: string;
|
||||
categoryId?: string;
|
||||
categoryTitle?: string;
|
||||
};
|
||||
|
||||
export type StyleProTaskModelRef = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@ -38,7 +50,9 @@ export type StyleProTaskRequest = {
|
||||
sourceImageUrl: string;
|
||||
generationMode: StyleProGenerationMode;
|
||||
normalizedSelections: StyleProSelectionMap;
|
||||
customInputs?: StyleProCustomInputMap;
|
||||
selectedOptions: StyleProTaskSelectedOption[];
|
||||
selectedCustomInputs?: StyleProTaskCustomInput[];
|
||||
selectedModels: StyleProTaskModelRef[];
|
||||
optionLabels: string[];
|
||||
prompts: {
|
||||
@ -61,16 +75,19 @@ export type StyleProTaskResult = {
|
||||
generationMode: StyleProGenerationMode;
|
||||
chargedPoints: number;
|
||||
selections: StyleProSelectionMap;
|
||||
customInputs?: StyleProCustomInputMap;
|
||||
optionLabels: string[];
|
||||
};
|
||||
|
||||
function composeStyleProPrompt({
|
||||
defaultPrompt,
|
||||
selectedOptions,
|
||||
selectedCustomInputs = [],
|
||||
mode = "style-pro-final",
|
||||
}: {
|
||||
defaultPrompt: string;
|
||||
selectedOptions: StyleProTaskSelectedOption[];
|
||||
selectedCustomInputs?: StyleProTaskCustomInput[];
|
||||
mode?: string;
|
||||
}) {
|
||||
const groupedPrompt = selectedOptions.reduce<Record<string, string[]>>(
|
||||
@ -82,6 +99,18 @@ function composeStyleProPrompt({
|
||||
},
|
||||
{}
|
||||
);
|
||||
const customPrompt = selectedCustomInputs.reduce<Record<string, string[]>>(
|
||||
(acc, item) => {
|
||||
const key =
|
||||
item.scope === "category" && item.categoryTitle
|
||||
? item.categoryTitle
|
||||
: "全局自定义";
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(item.prompt || `${item.fieldLabel}:${item.value}`);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return [
|
||||
defaultPrompt,
|
||||
@ -91,6 +120,8 @@ function composeStyleProPrompt({
|
||||
mode,
|
||||
selections: selectedOptions,
|
||||
promptByKey: groupedPrompt,
|
||||
customInputs: selectedCustomInputs,
|
||||
customPromptByScope: customPrompt,
|
||||
},
|
||||
null,
|
||||
2
|
||||
@ -201,6 +232,7 @@ export async function executeStyleProTask(params: {
|
||||
prompt: composeStyleProPrompt({
|
||||
defaultPrompt: request.prompts.realisticSecondPrompt || "",
|
||||
selectedOptions: request.selectedOptions,
|
||||
selectedCustomInputs: request.selectedCustomInputs || [],
|
||||
mode: "style-pro-realistic-final",
|
||||
}),
|
||||
negativePrompt,
|
||||
@ -222,6 +254,7 @@ export async function executeStyleProTask(params: {
|
||||
prompt: composeStyleProPrompt({
|
||||
defaultPrompt: request.prompts.defaultPrompt,
|
||||
selectedOptions: request.selectedOptions,
|
||||
selectedCustomInputs: request.selectedCustomInputs || [],
|
||||
}),
|
||||
negativePrompt,
|
||||
model: getPrimaryModelId(request),
|
||||
@ -249,6 +282,7 @@ export async function executeStyleProTask(params: {
|
||||
generationMode: request.generationMode,
|
||||
chargedPoints,
|
||||
selections: request.normalizedSelections,
|
||||
customInputs: request.customInputs,
|
||||
optionLabels: request.optionLabels,
|
||||
} satisfies StyleProTaskResult;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
poweredByHeader: false,
|
||||
// 禁用 cacheComponents 以支持 dynamic = "force-dynamic"
|
||||
// cacheComponents: true,
|
||||
typescript: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user