;
handleSaveAiWashConfig: AsyncAction;
+ handleSaveCanvasLocalEditConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction;
diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index d26512e1f..b4fbbf6d7 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -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}
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts
index 86dc8606a..a03af8d26 100644
--- a/app/api/admin/settings/route.ts
+++ b/app/api/admin/settings/route.ts
@@ -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);
}
diff --git a/app/api/ai-wash/generate/route.ts b/app/api/ai-wash/generate/route.ts
index e7d6f13e7..eab20dd6a 100644
--- a/app/api/ai-wash/generate/route.ts
+++ b/app/api/ai-wash/generate/route.ts
@@ -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", {
diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts
index bf72b9387..48ecd8b3c 100644
--- a/app/api/canvas/local-edit/route.ts
+++ b/app/api/canvas/local-edit/route.ts
@@ -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,
diff --git a/components/canvas/AiWashWorkspace.tsx b/components/canvas/AiWashWorkspace.tsx
index 10690b970..07526d58a 100644
--- a/components/canvas/AiWashWorkspace.tsx
+++ b/components/canvas/AiWashWorkspace.tsx
@@ -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,
diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx
index 73fb8960a..bb5465065 100644
--- a/components/canvas/model3d/Model3DViewer.tsx
+++ b/components/canvas/model3d/Model3DViewer.tsx
@@ -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
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx
index 0f58b6b9c..57a5cbc53 100644
--- a/components/canvas/nodes/ImageNode.tsx
+++ b/components/canvas/nodes/ImageNode.tsx
@@ -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({
: "没有可选模型"}
- ) : selectedAiWashQuickModel ? (
-
- {selectedAiWashQuickModel.displayName}
- {selectedAiWashQuickModel.unitCost > 0
- ? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
- : ""}
-
) : null}
@@ -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} 积分`
- : ""
+ : ""
}`}
diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx
index d099421fc..062794e6c 100644
--- a/components/canvas/world3d/World3DViewer.tsx
+++ b/components/canvas/world3d/World3DViewer.tsx
@@ -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({
<>
- {groundHelperOffset !== null ? (
+ {shouldShowGroundHelper ? (
;
nextBatchTimers: Map>;
awaitingNextBatch: Set;
+ 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(),
nextBatchTimers: new Map>(),
awaitingNextBatch: new Set(),
+ 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);
diff --git a/lib/canvas/local-edit-config.ts b/lib/canvas/local-edit-config.ts
new file mode 100644
index 000000000..bcdf9cb27
--- /dev/null
+++ b/lib/canvas/local-edit-config.ts
@@ -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;
+ return {
+ modelId: normalizeModelRef(raw.modelId),
+ scaleAdjustModelId: normalizeModelRef(raw.scaleAdjustModelId),
+ };
+}