;
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 6802c4fa7..bab8ac724 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -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}
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts
index f25f19ee6..ec0665427 100644
--- a/app/api/admin/settings/route.ts
+++ b/app/api/admin/settings/route.ts
@@ -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);
}
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 868acc9a7..f453c25d0 100644
--- a/app/api/canvas/local-edit/route.ts
+++ b/app/api/canvas/local-edit/route.ts
@@ -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;
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 f1340ec0b..be122410e 100644
--- a/components/canvas/nodes/ImageNode.tsx
+++ b/components/canvas/nodes/ImageNode.tsx
@@ -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({
: "没有可选模型"}
- ) : selectedAiWashQuickModel ? (
-
- {selectedAiWashQuickModel.displayName}
- {selectedAiWashQuickModel.unitCost > 0
- ? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
- : ""}
-
) : null}
@@ -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} 积分`
- : ""
+ : ""
}`}
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 = 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(),
nextBatchTimers: new Map>(),
awaitingNextBatch: new Set(),
+ 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);
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),
+ };
+}