fix: harden canvas image generation flows

This commit is contained in:
134 2026-07-27 22:27:13 +08:00
parent f7fc3561f5
commit 78b0da908c
14 changed files with 357 additions and 30 deletions

View File

@ -75,6 +75,8 @@ export function LocalAiImagePipelineSections({
aiWashConfig,
setAiWashConfig,
aiWashModeIconUploading,
canvasLocalEditConfig,
setCanvasLocalEditConfig,
extendConfig,
setExtendConfig,
watermarkRemoveConfig,
@ -103,6 +105,7 @@ export function LocalAiImagePipelineSections({
handleMoveEnhanceProPreset,
handleAiWashModeIconUpload,
handleSaveAiWashConfig,
handleSaveCanvasLocalEditConfig,
handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig,
@ -1421,6 +1424,112 @@ export function LocalAiImagePipelineSections({
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
</>
}
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
id="local-ai-section-canvas-local-edit"
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
summary={
<LocalAiSectionSummary
items={[
{
label: canvasLocalEditConfig.modelId
? "普通局部编辑已指定"
: "普通局部编辑继承默认",
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
},
{
label: canvasLocalEditConfig.scaleAdjustModelId
? "比例调整已指定"
: "比例调整兼容旧默认",
tone: canvasLocalEditConfig.scaleAdjustModelId
? "enabled"
: "neutral",
},
]}
/>
}
title="局部编辑模型配置"
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
modelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.modelId || "__none__"}
>
<SelectTrigger id="canvasLocalEditModel">
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于手动画框并填写提示词的局部编辑。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
<Select
onValueChange={(value) =>
setCanvasLocalEditConfig((prev) => ({
...prev,
scaleAdjustModelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
>
<SelectTrigger id="canvasScaleAdjustModel">
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
</div>
</div>
</div>
<Button onClick={handleSaveCanvasLocalEditConfig}>
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>

View File

@ -12,6 +12,7 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
rmbg: false,
enhance: false,
"enhance-pro": false,
"canvas-local-edit": false,
"ai-wash": false,
extend: false,
"watermark-remove": false,

View File

@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { CanvasLocalEditConfig } from "@/lib/canvas/local-edit-config";
import type { AiWashConfig } from "@/lib/ai-wash/config";
import type {
EnhanceProKreaConfig,
@ -330,6 +331,8 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
aiWashConfig: AiWashConfig;
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
aiWashModeIconUploading: boolean;
canvasLocalEditConfig: CanvasLocalEditConfig;
setCanvasLocalEditConfig: Dispatch<SetStateAction<CanvasLocalEditConfig>>;
extendConfig: ExtendConfig;
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
watermarkRemoveConfig: WatermarkRemoveConfig;
@ -372,6 +375,7 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
handleAiWashModeIconUpload: (file: File) => Promise<void>;
handleSaveAiWashConfig: AsyncAction;
handleSaveCanvasLocalEditConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction;

View File

@ -192,6 +192,11 @@ import {
MIN_CANVAS_IMAGE_TASK_CONCURRENCY,
normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
defaultCanvasLocalEditConfig,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import {
type CanvasSkillTag,
DEFAULT_CANVAS_SKILL_TAGS,
@ -1857,6 +1862,13 @@ export function AdminSettingsClient() {
const { data: aiWashConfigData, mutate: mutateAiWashConfig } = useSWR<{
setting?: { value?: unknown };
}>(`/api/admin/settings?key=${AI_WASH_CONFIG_KEY}`, fetcher);
const {
data: canvasLocalEditConfigData,
mutate: mutateCanvasLocalEditConfig,
} = useSWR<{ setting?: { value?: unknown } }>(
`/api/admin/settings?key=${CANVAS_LOCAL_EDIT_CONFIG_KEY}`,
fetcher
);
const {
data: canvasImageTaskQueueConfigData,
mutate: mutateCanvasImageTaskQueueConfig,
@ -2563,6 +2575,9 @@ export function AdminSettingsClient() {
const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] =
useState("");
const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig());
const [canvasLocalEditConfig, setCanvasLocalEditConfig] = useState(
defaultCanvasLocalEditConfig()
);
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
@ -2804,6 +2819,13 @@ export function AdminSettingsClient() {
setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value));
}, [aiWashConfigData]);
useEffect(() => {
if (!canvasLocalEditConfigData) return;
setCanvasLocalEditConfig(
normalizeCanvasLocalEditConfig(canvasLocalEditConfigData.setting?.value)
);
}, [canvasLocalEditConfigData]);
const enhanceProApiKeysDraftCount = useMemo(
() =>
normalizeEnhanceProKreaConfig({
@ -5189,6 +5211,34 @@ export function AdminSettingsClient() {
}
};
const handleSaveCanvasLocalEditConfig = async () => {
try {
const normalizedConfig =
normalizeCanvasLocalEditConfig(canvasLocalEditConfig);
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: CANVAS_LOCAL_EDIT_CONFIG_KEY,
value: normalizedConfig,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(
String(payload?.message || payload?.error || "保存失败")
);
}
mutateCanvasLocalEditConfig();
toast.success("局部编辑模型配置保存成功");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存局部编辑模型配置失败"
);
}
};
const handleDeleteEnhanceProPoolKey = (index: number) => {
setEnhanceProConfig((prev) => ({
...prev,
@ -18429,6 +18479,7 @@ export function AdminSettingsClient() {
angleEditConfig={angleEditConfig}
aiWashConfig={aiWashConfig}
aiWashModeIconUploading={aiWashModeIconUploading}
canvasLocalEditConfig={canvasLocalEditConfig}
draftEnhanceProKeyRows={draftEnhanceProKeyRows}
draggingEnhanceProPresetId={draggingEnhanceProPresetId}
enhanceConfig={enhanceConfig}
@ -18449,6 +18500,7 @@ export function AdminSettingsClient() {
handleResetEnhanceProPresets={handleResetEnhanceProPresets}
handleSaveAngleEditConfig={handleSaveAngleEditConfig}
handleSaveAiWashConfig={handleSaveAiWashConfig}
handleSaveCanvasLocalEditConfig={handleSaveCanvasLocalEditConfig}
handleSaveEnhanceConfig={handleSaveEnhanceConfig}
handleSaveEnhanceProConfig={handleSaveEnhanceProConfig}
handleSaveExtendConfig={handleSaveExtendConfig}
@ -18470,6 +18522,7 @@ export function AdminSettingsClient() {
selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex}
setAngleEditConfig={setAngleEditConfig}
setAiWashConfig={setAiWashConfig}
setCanvasLocalEditConfig={setCanvasLocalEditConfig}
setEnhanceConfig={setEnhanceConfig}
setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft}
setEnhanceProConfig={setEnhanceProConfig}

View File

@ -51,6 +51,10 @@ import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
normalizeCanvasImageTaskQueueConfig,
} from "@/lib/canvas/image-task-config";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
normalizeDingTalkRobotNotificationConfig,
@ -124,6 +128,12 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
const value = normalizeCanvasLocalEditConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
const value = normalizeDingTalkRobotNotificationConfig(setting?.value);
return Response.json({
@ -359,6 +369,10 @@ export async function POST(request: Request) {
nextValue = normalizeCanvasImageTaskQueueConfig(value);
}
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
nextValue = normalizeCanvasLocalEditConfig(value);
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {
nextValue = normalizeDingTalkRobotNotificationConfig(value);
}

View File

@ -13,6 +13,7 @@ import {
findStyleProImageModelByRef,
getStyleProImageModelEntries,
} from "@/lib/style-pro/model-options";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
const requestSchema = z.object({
prompt: z.string().min(1).max(5000),
@ -52,6 +53,16 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
const availability = getAiModelFrontendAvailability(selectedModel.model);
if (availability.selectable === false) {
return NextResponse.json(
{
success: false,
error: availability.disabledReason || "所选 AI洗图 生图模型当前不可用",
},
{ status: 400 }
);
}
const imageSize = body.imageSize?.trim() || "2K";
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {

View File

@ -16,7 +16,12 @@ import {
getEnabledAiModelsWithProvider,
getPointsSystemConfig,
getSiteSettings,
getSystemSetting,
} from "@/lib/db/queries";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import {
modelLacksReferenceImageInputSupport,
} from "@/lib/ai/reference-image-capability";
@ -4466,10 +4471,17 @@ export async function POST(request: NextRequest) {
const shouldUseScaleAdjustDetailModel =
operation === SCALE_ADJUST_OPERATION &&
(hasScaleAdjustTargetRects || scalePercent !== null);
const [pointsConfig, siteSettingsForModel, scaleAdjustModelCandidates] =
await Promise.all([
const [
pointsConfig,
siteSettingsForModel,
localEditConfigSetting,
scaleAdjustModelCandidates,
] = await Promise.all([
getPointsSystemConfig(),
getSiteSettings().catch(() => null),
getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(
() => null
),
shouldUseScaleAdjustDetailModel
? getEnabledAiModelsWithProvider(false, true).catch((error) => {
console.warn(
@ -4483,10 +4495,18 @@ export async function POST(request: NextRequest) {
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
reason: operation === SCALE_ADJUST_OPERATION ? "image_scale_adjust" : "image_repaint",
reason:
operation === SCALE_ADJUST_OPERATION
? "image_scale_adjust"
: "image_repaint",
modelId:
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
note: operation === SCALE_ADJUST_OPERATION ? "比例调整消耗积分" : "局部编辑消耗积分",
operation === SCALE_ADJUST_OPERATION
? "canvas-scale-adjust"
: "canvas-local-edit",
note:
operation === SCALE_ADJUST_OPERATION
? "比例调整消耗积分"
: "局部编辑消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
@ -4518,13 +4538,19 @@ export async function POST(request: NextRequest) {
typeof body.model === "string" && body.model.trim()
? body.model.trim()
: undefined;
const localEditConfig = normalizeCanvasLocalEditConfig(
localEditConfigSetting?.value
);
const configuredDetailModel =
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
: "";
const configuredLocalEditModel = localEditConfig.modelId;
const configuredScaleAdjustModel =
localEditConfig.scaleAdjustModelId || configuredDetailModel;
const detailFixedModel = isScaleAdjustOperation
? resolveScaleAdjustPreferredModelId({
configuredModelId: configuredDetailModel,
configuredModelId: configuredScaleAdjustModel,
honorRequestedModelBeforeGpt: hasScaleAdjustEnlarge,
preferFalNanoBananaEdit: preferScaleAdjustFalNanoBananaEdit,
preferMaskCapableGpt: preferScaleAdjustMaskCapableModel,
@ -4536,15 +4562,17 @@ export async function POST(request: NextRequest) {
if (
isScaleAdjustOperation &&
detailFixedModel &&
detailFixedModel !== configuredDetailModel
detailFixedModel !== configuredScaleAdjustModel
) {
console.info("[Canvas Local Edit] Override scale adjust model to detail line:", {
configuredDetailModel: configuredDetailModel || null,
configuredLocalEditScaleModel: localEditConfig.scaleAdjustModelId || null,
requestedModel: requestedModel || null,
resolvedModel: detailFixedModel,
});
}
const resolvedModel = detailFixedModel || requestedModel;
const resolvedModel =
detailFixedModel || requestedModel || configuredLocalEditModel;
const resolvedScaleAdjustModelEntry = isScaleAdjustOperation
? findModelSelectionEntry(scaleAdjustModelCandidates, resolvedModel || "")
: null;

View File

@ -610,7 +610,9 @@ export function AiWashWorkspace({
[presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId]
);
const selectedPresetHasExecutableModel = Boolean(
selectedExecutionState?.effectiveModelRef
selectedExecutionState?.effectiveModelRef &&
selectedExecutionState?.resolvedModel &&
selectedExecutionState.resolvedModel.selectable !== false
);
const shouldShowUserModelSelector = Boolean(
!config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel
@ -757,7 +759,10 @@ export function AiWashWorkspace({
return;
}
if (!selectedPresetHasExecutableModel) {
toast.error("当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型");
toast.error(
selectedExecutionState?.resolvedModel?.disabledReason ||
"当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"
);
return;
}
@ -818,6 +823,15 @@ export function AiWashWorkspace({
if (!executionState.effectiveModelRef) {
throw new Error(`${preset.title} 未配置可用模型`);
}
if (
!executionState.resolvedModel ||
executionState.resolvedModel.selectable === false
) {
throw new Error(
executionState.resolvedModel?.disabledReason ||
`${preset.title} 配置的模型不可用`
);
}
const submitResponse = await fetch("/api/ai-wash/generate", {
method: "POST",
@ -825,7 +839,9 @@ export function AiWashWorkspace({
body: JSON.stringify({
prompt: executionState.resolvedPrompt,
referenceImage: requestReferenceImage,
selectedModelId: executionState.effectiveModelRef,
selectedModelId:
executionState.resolvedModel.id ||
executionState.effectiveModelRef,
imageSize: config.imageSize,
promptExtend: config.promptExtend,
title: preset.title,

View File

@ -278,6 +278,10 @@ function configureMaterialTextures(
geometry.setAttribute("uv2", geometry.attributes.uv);
}
if (geometry?.attributes.color && "vertexColors" in materialRecord) {
materialRecord.vertexColors = true;
}
for (const key of MATERIAL_TEXTURE_KEYS) {
const texture = materialRecord[key];
if (!texture?.isTexture) continue;
@ -287,6 +291,7 @@ function configureMaterialTextures(
? THREE.SRGBColorSpace
: THREE.NoColorSpace;
}
texture.anisotropy = Math.max(Number(texture.anisotropy || 1), 8);
texture.needsUpdate = true;
}
@ -652,7 +657,7 @@ export function Model3DViewer({
const resolvedModelUrl = useMemo(() => {
return buildThreeAssetUrl(modelUrl);
}, [modelUrl]);
const gridY = Number.isFinite(groundY) ? groundY - 0.01 : -0.01;
const gridY = Number.isFinite(groundY) ? groundY - 0.08 : -0.08;
const handleGroundYChange = React.useCallback((nextGroundY: number) => {
setGroundY((previousGroundY) =>
Math.abs(previousGroundY - nextGroundY) < 0.001

View File

@ -1801,7 +1801,8 @@ export function ImageNode({
: "",
[aiWashQuickLevels, selectedAiWashQuickPreset]
);
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef;
const shouldShowAiWashQuickModelSelector =
!aiWashQuickConfig.hideModelSelector && !aiWashQuickAdminModelRef;
const effectiveAiWashQuickModelRef =
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
const selectedAiWashQuickModel = useMemo(
@ -1811,6 +1812,11 @@ export function ImageNode({
null,
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
);
const aiWashQuickHasExecutableModel = Boolean(
effectiveAiWashQuickModelRef &&
selectedAiWashQuickModel &&
selectedAiWashQuickModel.selectable !== false
);
const aiWashQuickPrompt = useMemo(
() =>
selectedAiWashQuickPreset
@ -2271,7 +2277,7 @@ export function ImageNode({
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
});
const aiWashQuickCostShortLabel =
pointsEnabled && selectedAiWashQuickModel
pointsEnabled && !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel
? formatImageNodeFeatureCostShortLabel({
cost: selectedAiWashQuickModel.unitCost,
isHighVip,
@ -6471,8 +6477,11 @@ export function ImageNode({
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
return;
}
if (!effectiveAiWashQuickModelRef) {
toast.error("请先选择一个可用的生图模型");
if (!aiWashQuickHasExecutableModel || !selectedAiWashQuickModel) {
toast.error(
selectedAiWashQuickModel?.disabledReason ||
"当前 AI洗图模型不可用,请在后台重新配置模型"
);
return;
}
if (isProcessing || isSubmittingAiWashQuick) {
@ -6528,7 +6537,7 @@ export function ImageNode({
body: JSON.stringify({
prompt: aiWashQuickPrompt,
referenceImage: data.src,
selectedModelId: effectiveAiWashQuickModelRef,
selectedModelId: selectedAiWashQuickModel.id || effectiveAiWashQuickModelRef,
imageSize: aiWashQuickConfig.imageSize,
promptExtend: aiWashQuickConfig.promptExtend,
title: selectedAiWashQuickPreset.title,
@ -6625,6 +6634,7 @@ export function ImageNode({
}, [
addNode,
aiWashQuickConfig.enabled,
aiWashQuickConfig.hideModelSelector,
aiWashQuickConfig.imageSize,
aiWashQuickConfig.promptExtend,
aiWashQuickLevels,
@ -6632,6 +6642,7 @@ export function ImageNode({
aiWashQuickPrompt,
data.src,
displayTitle,
aiWashQuickHasExecutableModel,
effectiveAiWashQuickModelRef,
isProcessing,
isSubmittingAiWashQuick,
@ -6639,7 +6650,9 @@ export function ImageNode({
resetBoxCutoutState,
resetLocalEditState,
selectNode,
selectedAiWashQuickModel?.disabledReason,
selectedAiWashQuickModel?.displayName,
selectedAiWashQuickModel?.id,
selectedAiWashQuickModel?.modelId,
selectedAiWashQuickPreset,
updateNode,
@ -10096,13 +10109,6 @@ export function ImageNode({
: "没有可选模型"}
</div>
</div>
) : selectedAiWashQuickModel ? (
<div className="mb-3 rounded-[10px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
{selectedAiWashQuickModel.displayName}
{selectedAiWashQuickModel.unitCost > 0
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
: ""}
</div>
) : null}
<div className="-mx-4 divide-y divide-slate-100 dark:divide-white/10">
@ -10154,7 +10160,7 @@ export function ImageNode({
disabled={
isSubmittingAiWashQuick ||
!selectedAiWashQuickPreset ||
!effectiveAiWashQuickModelRef
!aiWashQuickHasExecutableModel
}
onClick={() => void handleSubmitAiWashQuick()}
type="button"
@ -10167,9 +10173,10 @@ export function ImageNode({
{isSubmittingAiWashQuick
? "生成中"
: `${aiWashQuickModeLabel}${
!aiWashQuickConfig.hideModelSelector &&
selectedAiWashQuickModel?.unitCost
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
: ""
: ""
}`}
</Button>
</div>

View File

@ -117,7 +117,7 @@ const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
const WORLDLABS_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0];
const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1);
const WORLDLABS_DISPLAY_GROUND_BIAS_RATIO = 0.08;
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.22;
const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.45;
const EMPTY_MOVEMENT_STATE: MovementState = {
forward: false,
@ -1494,6 +1494,7 @@ function WorldSplatScene({
semanticsMetadata?.ground_plane_offset
);
const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset);
const shouldShowGroundHelper = groundHelperOffset !== null && !firstPerson;
const handleOrbitControlsRef = React.useCallback((controls: any | null) => {
orbitControlsRef.current = controls;
setControlsMountVersion((version) => version + 1);
@ -1779,7 +1780,7 @@ function WorldSplatScene({
<>
<ambientLight intensity={1.6} />
<directionalLight intensity={1.25} position={[6, 10, 4]} />
{groundHelperOffset !== null ? (
{shouldShowGroundHelper ? (
<gridHelper
args={[18, 18, "#334155", "#0f172a"]}
position={[0, groundHelperOffset, 0]}

View File

@ -129,7 +129,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
return {
eventType: "unavailable",
severity: "warning",
counted: false,
counted: true,
reason: "transport_failure_no_provider_response",
};
}
@ -184,7 +184,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
return {
eventType: "timeout",
severity: "warning",
counted: false,
counted: true,
reason: "timeout_without_provider_response",
};
}

View File

@ -55,12 +55,14 @@ interface QueueState {
nextBatchEligibleAt: Map<string, number>;
nextBatchTimers: Map<string, ReturnType<typeof setTimeout>>;
awaitingNextBatch: Set<string>;
lastStaleRunningCleanupAt: number;
draining: boolean;
}
const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000;
const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 15 * 60 * 1000;
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000;
const PENDING_LOOKAHEAD_LIMIT = 100;
const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120;
@ -81,6 +83,7 @@ function getQueueState(): QueueState {
nextBatchEligibleAt: new Map<string, number>(),
nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(),
awaitingNextBatch: new Set<string>(),
lastStaleRunningCleanupAt: 0,
draining: false,
};
}
@ -393,6 +396,48 @@ async function claimNextPendingTask(
return null;
}
async function markStaleDatabaseRunningTasksFailed() {
const state = getQueueState();
const now = Date.now();
if (now - state.lastStaleRunningCleanupAt < STALE_RUNNING_CLEANUP_INTERVAL_MS) {
return;
}
state.lastStaleRunningCleanupAt = now;
const localRunningIds = Array.from(state.running.keys());
const staleBefore = new Date(now - LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS);
const staleWhere =
localRunningIds.length > 0
? and(
eq(canvasImageTask.status, "running"),
lt(canvasImageTask.startedAt, staleBefore),
notInArray(canvasImageTask.id, localRunningIds)
)
: and(
eq(canvasImageTask.status, "running"),
lt(canvasImageTask.startedAt, staleBefore)
);
const completedAt = new Date(now);
const staleTasks = await db
.update(canvasImageTask)
.set({
status: "failed",
error: "图片生成任务已超时,请重新发起生成",
completedAt,
updatedAt: completedAt,
})
.where(staleWhere)
.returning({ id: canvasImageTask.id });
if (staleTasks.length > 0) {
console.warn("[Canvas Image Task Queue] Marked stale running tasks failed:", {
count: staleTasks.length,
taskIds: staleTasks.slice(0, 10).map((task) => task.id),
});
}
}
async function runSingleTask(entry: PendingTaskEntry) {
try {
const [task] = await db
@ -474,6 +519,7 @@ export async function drainCanvasImageTaskQueue() {
state.draining = true;
try {
await markStaleDatabaseRunningTasksFailed();
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
while (true) {
const nextTask = await claimNextPendingTask(maxConcurrentPerModel);

View File

@ -0,0 +1,32 @@
export const CANVAS_LOCAL_EDIT_CONFIG_KEY = "canvas_local_edit_config";
export type CanvasLocalEditConfig = {
modelId: string;
scaleAdjustModelId: string;
};
export function defaultCanvasLocalEditConfig(): CanvasLocalEditConfig {
return {
modelId: "",
scaleAdjustModelId: "",
};
}
function normalizeModelRef(value: unknown) {
return String(value || "").trim().slice(0, 128);
}
export function normalizeCanvasLocalEditConfig(
value: unknown
): CanvasLocalEditConfig {
const defaults = defaultCanvasLocalEditConfig();
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaults;
}
const raw = value as Record<string, unknown>;
return {
modelId: normalizeModelRef(raw.modelId),
scaleAdjustModelId: normalizeModelRef(raw.scaleAdjustModelId),
};
}