fix: harden canvas image generation flows

(cherry picked from commit 78b0da908c4a0c22bd7eb66b5a661c07fcecb4fc)
This commit is contained in:
134 2026-07-27 22:27:13 +08:00 committed by Codex Deploy
parent 31cc75823c
commit 1a9888d825
14 changed files with 350 additions and 28 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,
@ -329,6 +330,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;
@ -371,6 +374,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

@ -196,6 +196,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,
@ -1877,6 +1882,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,
@ -2570,6 +2582,9 @@ export function AdminSettingsClient() {
const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] =
useState("");
const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig());
const [canvasLocalEditConfig, setCanvasLocalEditConfig] = useState(
defaultCanvasLocalEditConfig()
);
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
@ -2811,6 +2826,13 @@ export function AdminSettingsClient() {
setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value));
}, [aiWashConfigData]);
useEffect(() => {
if (!canvasLocalEditConfigData) return;
setCanvasLocalEditConfig(
normalizeCanvasLocalEditConfig(canvasLocalEditConfigData.setting?.value)
);
}, [canvasLocalEditConfigData]);
const enhanceProApiKeysDraftCount = useMemo(
() =>
normalizeEnhanceProKreaConfig({
@ -5188,6 +5210,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,
@ -18364,6 +18414,7 @@ export function AdminSettingsClient() {
angleEditConfig={angleEditConfig}
aiWashConfig={aiWashConfig}
aiWashModeIconUploading={aiWashModeIconUploading}
canvasLocalEditConfig={canvasLocalEditConfig}
draftEnhanceProKeyRows={draftEnhanceProKeyRows}
draggingEnhanceProPresetId={draggingEnhanceProPresetId}
enhanceConfig={enhanceConfig}
@ -18384,6 +18435,7 @@ export function AdminSettingsClient() {
handleResetEnhanceProPresets={handleResetEnhanceProPresets}
handleSaveAngleEditConfig={handleSaveAngleEditConfig}
handleSaveAiWashConfig={handleSaveAiWashConfig}
handleSaveCanvasLocalEditConfig={handleSaveCanvasLocalEditConfig}
handleSaveEnhanceConfig={handleSaveEnhanceConfig}
handleSaveEnhanceProConfig={handleSaveEnhanceProConfig}
handleSaveExtendConfig={handleSaveExtendConfig}
@ -18405,6 +18457,7 @@ export function AdminSettingsClient() {
selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex}
setAngleEditConfig={setAngleEditConfig}
setAiWashConfig={setAiWashConfig}
setCanvasLocalEditConfig={setCanvasLocalEditConfig}
setEnhanceConfig={setEnhanceConfig}
setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft}
setEnhanceProConfig={setEnhanceProConfig}

View File

@ -53,6 +53,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,
@ -126,6 +130,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({
@ -369,6 +379,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

@ -21,7 +21,13 @@ import {
} from "@/lib/canvas/scale-adjust-semantic";
import {
getPointsSystemConfig,
getSiteSettings,
getSystemSetting,
} from "@/lib/db/queries";
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
} from "@/lib/canvas/local-edit-config";
import { getImageToolCreditCost } from "@/lib/points/config";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { uploadFile } from "@/lib/storage/service";
@ -618,7 +624,23 @@ export async function POST(request: NextRequest) {
);
}
const pointsConfig = await getPointsSystemConfig();
const [pointsConfig, siteSettingsForModel, localEditConfigSetting] = await Promise.all([
getPointsSystemConfig(),
getSiteSettings().catch(() => null),
getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(() => null),
]);
const localEditConfig = normalizeCanvasLocalEditConfig(
localEditConfigSetting?.value
);
const siteDetailDefaultModelId =
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
? siteSettingsForModel.canvasDetailDefaultModelId.trim()
: "";
const configuredLocalEditModelId = localEditConfig.modelId;
const configuredScaleAdjustModelId =
localEditConfig.scaleAdjustModelId ||
siteDetailDefaultModelId ||
SCALE_ADJUST_REPAIR_MODEL_REF;
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
@ -787,7 +809,7 @@ export async function POST(request: NextRequest) {
operation,
requestedModel: requestedModel || null,
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
resolvedModel: SCALE_ADJUST_REPAIR_MODEL_REF,
resolvedModel: configuredScaleAdjustModelId,
scaleAdjustProductLockMaskEditApplied: true,
scaleAdjustPersonExclusionApplied:
productLockAssets.personExclusionApplied,
@ -814,7 +836,7 @@ export async function POST(request: NextRequest) {
displayPrompt,
imageCount: 1,
imageSize: requestedImageSize,
model: SCALE_ADJUST_REPAIR_MODEL_REF,
model: configuredScaleAdjustModelId,
negativePrompt:
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
prompt,
@ -847,7 +869,7 @@ export async function POST(request: NextRequest) {
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
target: region.targetRect,
})),
relightModel: SCALE_ADJUST_REPAIR_MODEL_REF,
relightModel: configuredScaleAdjustModelId,
sourceImageUrl,
},
}
@ -876,7 +898,7 @@ export async function POST(request: NextRequest) {
...(hasPositionMove
? {
scaleAdjustModelRelight: {
model: SCALE_ADJUST_REPAIR_MODEL_REF,
model: configuredScaleAdjustModelId,
overlayImageUrl: overlayUpload.url,
quality: "medium" as const,
regions: scaleAdjustIntentRegions.map((region) => ({
@ -975,6 +997,7 @@ export async function POST(request: NextRequest) {
displayPrompt,
operation,
requestedModel: requestedModel || null,
resolvedModel: requestedModel || configuredLocalEditModelId || null,
resolvedAspectRatio,
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
regionReferenceImageCount: referenceImages.length,
@ -986,7 +1009,7 @@ export async function POST(request: NextRequest) {
displayPrompt,
imageCount: 1,
imageSize: requestedImageSize,
model: requestedModel || undefined,
model: requestedModel || configuredLocalEditModelId || undefined,
negativePrompt:
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
prompt,

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

@ -1799,7 +1799,8 @@ export function ImageNode({
: "",
[aiWashQuickLevels, selectedAiWashQuickPreset]
);
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef;
const shouldShowAiWashQuickModelSelector =
!aiWashQuickConfig.hideModelSelector && !aiWashQuickAdminModelRef;
const effectiveAiWashQuickModelRef =
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
const selectedAiWashQuickModel = useMemo(
@ -1809,6 +1810,11 @@ export function ImageNode({
null,
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
);
const aiWashQuickHasExecutableModel = Boolean(
effectiveAiWashQuickModelRef &&
selectedAiWashQuickModel &&
selectedAiWashQuickModel.selectable !== false
);
const aiWashQuickPrompt = useMemo(
() =>
selectedAiWashQuickPreset
@ -2272,7 +2278,7 @@ export function ImageNode({
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
});
const aiWashQuickCostShortLabel =
pointsEnabled && selectedAiWashQuickModel
pointsEnabled && !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel
? formatImageNodeFeatureCostShortLabel({
cost: selectedAiWashQuickModel.unitCost,
isHighVip,
@ -6420,8 +6426,11 @@ export function ImageNode({
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
return;
}
if (!effectiveAiWashQuickModelRef) {
toast.error("请先选择一个可用的生图模型");
if (!aiWashQuickHasExecutableModel || !selectedAiWashQuickModel) {
toast.error(
selectedAiWashQuickModel?.disabledReason ||
"当前 AI洗图模型不可用,请在后台重新配置模型"
);
return;
}
if (isProcessing || isSubmittingAiWashQuick) {
@ -6477,7 +6486,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,
@ -6574,6 +6583,7 @@ export function ImageNode({
}, [
addNode,
aiWashQuickConfig.enabled,
aiWashQuickConfig.hideModelSelector,
aiWashQuickConfig.imageSize,
aiWashQuickConfig.promptExtend,
aiWashQuickLevels,
@ -6581,6 +6591,7 @@ export function ImageNode({
aiWashQuickPrompt,
data.src,
displayTitle,
aiWashQuickHasExecutableModel,
effectiveAiWashQuickModelRef,
isProcessing,
isSubmittingAiWashQuick,
@ -6588,7 +6599,9 @@ export function ImageNode({
resetBoxCutoutState,
resetLocalEditState,
selectNode,
selectedAiWashQuickModel?.disabledReason,
selectedAiWashQuickModel?.displayName,
selectedAiWashQuickModel?.id,
selectedAiWashQuickModel?.modelId,
selectedAiWashQuickPreset,
updateNode,
@ -9960,13 +9973,6 @@ export function ImageNode({
: "没有可选模型"}
</div>
</div>
) : selectedAiWashQuickModel ? (
<div className="mb-3 rounded-[var(--ui-radius-control)] border border-slate-200 bg-slate-50 px-2.5 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-3 divide-y divide-slate-100 dark:divide-white/10">
@ -10018,7 +10024,7 @@ export function ImageNode({
disabled={
isSubmittingAiWashQuick ||
!selectedAiWashQuickPreset ||
!effectiveAiWashQuickModelRef
!aiWashQuickHasExecutableModel
}
onClick={() => void handleSubmitAiWashQuick()}
type="button"
@ -10031,9 +10037,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",
};
}
@ -181,7 +181,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
return {
eventType: "timeout",
severity: "warning",
counted: false,
counted: true,
reason: "timeout_without_provider_response",
};
}

View File

@ -56,12 +56,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 = 12 * 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;
@ -82,6 +84,7 @@ function getQueueState(): QueueState {
nextBatchEligibleAt: new Map<string, number>(),
nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(),
awaitingNextBatch: new Set<string>(),
lastStaleRunningCleanupAt: 0,
draining: false,
};
}
@ -394,6 +397,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
@ -485,6 +530,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),
};
}