fix: polish style pro model visibility and billing
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-20 14:26:24 +08:00
parent 71837cc547
commit d72ca0b344
14 changed files with 1265 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">
视觉

View File

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

View File

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

View 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,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
poweredByHeader: false,
// 禁用 cacheComponents 以支持 dynamic = "force-dynamic"
// cacheComponents: true,
typescript: {