diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1ccfc7398..df71d5b83 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6107,3 +6107,14 @@ - 复查中核过、确认不是问题的两点:其一,网关放宽的作用域正确——`/api/*` 走 `is_generic_api_proxy_path`,读超时默认 `3600` 秒,远高于服务端 90 秒预算与客户端 120 秒,次序是 `90 < 120 < 3600`,网关不会在服务端合法工作期间截断,它合成 502/504 只可能是连接失败或进程不可用,确属未知结果;唯一变数是有人把 `GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API_READ_TIMEOUT_SECONDS` 调到 90 秒以下。其二,`projectHasUnresolvedGenerationDialog` 由 `some(...)` 改为「取首个匹配再判」存在极低风险的语义收窄,同 id 多 dialog 时行为不同,但 id 唯一,实际不可达。 - 验证:`vitest src/components/image-editor` 908 通过 / 72 文件,typecheck、eslint、check:encoding 通过。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 inline 占位补到期清理,收掉存活窗口引入的回归 + +- 缺陷:存活窗口把「立即刷新也能清掉孤儿占位」这条旧行为换掉了。剥离只在项目首次加载执行一次,窗口内被保留的占位再没有任何东西会重新判定——页面保持打开就一直转,必须等到用户下一次加载且距创建已满窗口才收口。这是引入 TTL 时的已知取舍,本次补上。 +- 复查中发现的关键事实:**手动收口入口本来就存在**——`requestRemoveCanvasGenerationDialog` 已接入键盘快捷键,对 `generating` 占位会先弹确认再删。所以缺的从来不是删除手段,而是「它已经死了」这个信号;占位看起来和正在干活一模一样。 +- 决策:加一次性到期定时器,到点走与加载期**完全一致**的处置——移除 + 同一条文案(抽成 `DEAD_INLINE_PLACEHOLDER_NOTICE` 共用)。未采纳「标记 failed 而不移除」:`failed` 会被自动保存持久化,而剥离只处理 `generating`,那张卡片会跨刷新长期存在,与当初选「刷新后占位消失」的用意相反,把一次性噪音变成永久残留。 +- 三条必须守住的实现约束,都写进了 hook 的文档注释:其一,到期回调只推进 tick 让 effect 重跑,判定始终在 effect 体里用当前 dialogs 和当前时间做——挂上定时器之后占位可能已被拥有者会话正常收口,按闭包旧值行动会清掉一个已完成的占位;其二,清理用底层 `removeCanvasGenerationDialogById` 而不是 View 的 `removeCanvasGenerationDialog`,后者是用户主动删除的语义(写 `delete-generation-result` 历史、清空选中、切回选择工具),自动清理记用户没做过的历史、抢走当前选中态都是错的,加载期剥离同样不做这些;其三,本会话自己在途的占位不会被误清(客户端 120 秒就 abort,catch 会把它推离 `generating`),但不依赖该推理,靠第一条的重新判定兜住。 +- 作用面划分:`dropDeadInlineGenerationPlaceholders` 跑在**快照**上、只在加载时执行;`collectExpiredInlineGenerationDialogIds` / `resolveNextInlineGenerationDialogExpiryAt` 跑在**内存 dialog** 上、供页面打开期间使用。同一条规则、两种数据形状,边界(到期时刻含等号不算过期)与缺时间戳的兜底方向都保持一致。 +- 到期时刻可精确计算,所以挂一次性定时器而不是轮询;多给 50ms 余量,避免贴着到期时刻醒来判定为未到期、白白多挂一轮。 +- 验证:纯函数四条用例覆盖边界、队列型与已终态不到期、缺时间戳立即到期、最早到期时刻;hook 四条覆盖到期清理、醒来重新判定(占位期间被收口则不清)、队列型永不挂定时器、已超窗立即清理。去掉定时器重挂逻辑后第一条变红。`vitest src/components/image-editor` 916 通过 / 73 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 1d98db6ff..8134d054c 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -8,6 +8,7 @@ import { CANVAS_WORLD_ORIGIN, canvasDisplayScaleToViewportScale, canvasDisplayViewportToViewport, + collectExpiredInlineGenerationDialogIds, createLayerFromAsset, DEFAULT_CANVAS_BACKGROUND_COLOR, dropDeadInlineGenerationPlaceholders, @@ -16,6 +17,7 @@ import { hydrateLayer, normalizeAssetLibrary, normalizeCanvasBackgroundHex, + resolveNextInlineGenerationDialogExpiryAt, resolveSnappedLayerPosition, serializeCanvasLayout, serializeLayer, @@ -999,6 +1001,73 @@ describe('ImageCanvasEditorModel', () => { }); }); + describe('inline placeholder expiry helpers', () => { + const dialog = ( + overrides: Partial, + ): CanvasGenerationDialogState => + ({ + id: 'dialog-1', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + ...overrides, + }) as CanvasGenerationDialogState; + + it('reports an inline placeholder as expired only after the window elapses', () => { + // 中文注释:与 dropDeadInlineGenerationPlaceholders 是同一条规则的两个作用面,边界 + // 必须一致——正好到期不算过期,超过一毫秒才算。 + const now = 1_700_000_000_000; + const live = [dialog({ requiresLiveSession: true, generationStartedAt: now - 180_000 })]; + const stale = [dialog({ requiresLiveSession: true, generationStartedAt: now - 180_001 })]; + + expect(collectExpiredInlineGenerationDialogIds(live, now)).toEqual([]); + expect(collectExpiredInlineGenerationDialogIds(stale, now)).toEqual([ + 'dialog-1', + ]); + }); + + it('never expires queue-backed or settled placeholders', () => { + // 中文注释:队列型占位的 job 在服务端继续跑,误清会让用户以为没发生而重复提交; + // 已终态的占位也不该被自动清掉,那是用户要自己处置的失败卡片。 + const now = 1_700_000_000_000; + const dialogs = [ + dialog({ id: 'queued', generationStartedAt: now - 999_999 }), + dialog({ + id: 'settled', + requiresLiveSession: true, + status: 'failed', + generationStartedAt: now - 999_999, + }), + ]; + + expect(collectExpiredInlineGenerationDialogIds(dialogs, now)).toEqual([]); + expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBeNull(); + }); + + it('expires a live-session placeholder that carries no usable timestamp', () => { + // 中文注释:兜底方向与快照侧一致——按保留会让这类占位永久转下去。 + expect( + collectExpiredInlineGenerationDialogIds( + [dialog({ requiresLiveSession: true })], + 1_700_000_000_000, + ), + ).toEqual(['dialog-1']); + }); + + it('resolves the earliest expiry so callers can arm a single timer', () => { + // 中文注释:到期时刻可以精确算出来,调用方据此挂一次性定时器而不是轮询。 + const now = 1_700_000_000_000; + const dialogs = [ + dialog({ id: 'later', requiresLiveSession: true, generationStartedAt: now - 10_000 }), + dialog({ id: 'sooner', requiresLiveSession: true, generationStartedAt: now - 60_000 }), + ]; + + expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBe( + now - 60_000 + 180_000, + ); + }); + }); + describe('dropDeadInlineGenerationPlaceholders', () => { const buildProject = ( layers: EditorProjectLayerSnapshot[], diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 3d4fc307b..08bbe1367 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -563,6 +563,61 @@ export function isUnresolvedCanvasGenerationDialogRecord( */ const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 180_000; +function inlineGenerationPlaceholderExpiryAt( + dialog: CanvasGenerationDialogState, +): number | null { + if (dialog.requiresLiveSession !== true || dialog.status !== 'generating') { + return null; + } + const startedAt = dialog.generationStartedAt; + // 中文注释:缺时间戳按「立即到期」处理,与 dropDeadInlineGenerationPlaceholders 的兜底 + // 方向一致——按保留会让这类占位永久转下去。 + if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) { + return Number.NEGATIVE_INFINITY; + } + return startedAt + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS; +} + +/** + * 中文注释:内存态里已经越过存活窗口的 inline 占位 id。 + * + * 与 `dropDeadInlineGenerationPlaceholders` 是同一条规则的两个作用面:那个跑在**快照**上、 + * 只在项目加载时执行一次;这个跑在**内存 dialog** 上,供页面打开期间的到期清理使用。 + * 少了后者,加载时因未到期而被保留的孤儿占位就再没有任何东西会重新判定,只能转到用户 + * 下一次加载——那正是引入存活窗口带来的回归。 + */ +export function collectExpiredInlineGenerationDialogIds( + dialogs: readonly CanvasGenerationDialogState[], + now: number = Date.now(), +): string[] { + return dialogs + .filter((dialog) => { + const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog); + return expiryAt !== null && now > expiryAt; + }) + .map((dialog) => dialog.id); +} + +/** + * 中文注释:下一个 inline 占位到期的绝对时刻,没有则返回 null。调用方据此挂一次性定时器, + * 而不是轮询——到期时刻是可以精确算出来的。 + */ +export function resolveNextInlineGenerationDialogExpiryAt( + dialogs: readonly CanvasGenerationDialogState[], +): number | null { + let earliest: number | null = null; + for (const dialog of dialogs) { + const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog); + if (expiryAt === null || !Number.isFinite(expiryAt)) { + continue; + } + if (earliest === null || expiryAt < earliest) { + earliest = expiryAt; + } + } + return earliest; +} + /** * 中文注释:剥离「只能由已死会话收口」的 generating 占位。 * diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 45d31c45f..686399ef9 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -97,6 +97,7 @@ import { DEFAULT_IMAGE_CANVAS_VIEWPORT, useImageCanvasViewportControls, } from './useImageCanvasViewportControls'; +import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry'; const TASK_FOCUS_HORIZONTAL_INSET = 28; const TASK_FOCUS_TOP_INSET = 82; @@ -295,6 +296,13 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer { }; } +// 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset +// → 画布收口,非事务。看到孤儿 generating 占位只能说明最后一步没做完,前面几步可能已经 +// 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。 +// 加载期剥离与页面打开期的到期清理共用这一条,两条路径对用户完全一致。 +const DEAD_INLINE_PLACEHOLDER_NOTICE = + '上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。'; + export function ImageCanvasEditorView({ onProjectAccessLost, }: ImageCanvasEditorViewProps = {}) { @@ -1508,13 +1516,18 @@ export function ImageCanvasEditorView({ if (deadInlinePlaceholderDropCount === 0) { return; } - // 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset - // → 画布收口,非事务。加载时还看到 generating 只能说明最后一步没做完,前面几步可能已经 - // 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。 - showGenerationWarning( - '上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。', - ); + showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE); }, [deadInlinePlaceholderDropCount, showGenerationWarning]); + const handleInlinePlaceholdersExpired = useCallback(() => { + showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE); + }, [showGenerationWarning]); + // 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理, + // 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。 + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs, + removeCanvasGenerationDialogById, + onPlaceholdersExpired: handleInlinePlaceholdersExpired, + }); const handleExternalGenerationTasksCompleted = useCallback( (tasks: ExternalGenerationTaskRecord[]) => { if (!projectId || tasks.length === 0) { diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx new file mode 100644 index 000000000..04aae2db3 --- /dev/null +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx @@ -0,0 +1,154 @@ +/* @vitest-environment jsdom */ + +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; +import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry'; + +const WINDOW_MS = 180_000; + +function dialog( + overrides: Partial, +): CanvasGenerationDialogState { + return { + id: 'dialog-1', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + ...overrides, + } as CanvasGenerationDialogState; +} + +function Harness({ + dialogs, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, +}: { + dialogs: CanvasGenerationDialogState[]; + removeCanvasGenerationDialogById: (dialogId: string) => void; + onPlaceholdersExpired: (expiredCount: number) => void; +}) { + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs: dialogs, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, + }); + return null; +} + +describe('useInlineGenerationPlaceholderExpiry', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-03T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('clears an inline placeholder once its live window elapses', () => { + // 中文注释:加载期剥离只跑一次,当时未到期而被保留的孤儿占位靠这里补收口,否则会一直 + // 转到用户下一次加载——那正是引入存活窗口带来的回归。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + render( + , + ); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + + // 中文注释:定时器触发的是 setState,必须包 act 才会同步刷新出重新判定那一轮。 + act(() => { + vi.advanceTimersByTime(2_000); + }); + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(onPlaceholdersExpired).toHaveBeenCalledWith(1); + }); + + it('re-evaluates on wake instead of acting on the armed snapshot', () => { + // 中文注释:挂上定时器之后占位可能已被拥有者会话正常收口。到期回调若按闭包里的旧值行动, + // 就会清掉一个已经完成的占位——判定必须在醒来那一刻用当前 dialogs 重做。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + const startedAt = Date.now() - WINDOW_MS + 1_000; + const { rerender } = render( + , + ); + + rerender( + , + ); + + act(() => { + vi.advanceTimersByTime(10_000); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + expect(onPlaceholdersExpired).not.toHaveBeenCalled(); + }); + + it('never arms a timer for queue-backed placeholders', () => { + // 中文注释:队列型占位的 job 在服务端继续跑,worker 会替换占位。自动清理会让用户以为 + // 操作没发生而重复提交。 + const removeCanvasGenerationDialogById = vi.fn(); + render( + , + ); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 20); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + }); + + it('clears an already-expired placeholder without waiting for a timer', () => { + // 中文注释:加载时就已超窗的情形由快照侧剥离处理,但内存态也可能直接拿到超窗占位 + // (例如会话内切换项目),此时不该再等一轮定时器。 + const removeCanvasGenerationDialogById = vi.fn(); + render( + , + ); + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + }); +}); diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts new file mode 100644 index 000000000..7ac3f8631 --- /dev/null +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; + +import { + collectExpiredInlineGenerationDialogIds, + resolveNextInlineGenerationDialogExpiryAt, +} from './ImageCanvasEditorModel'; +import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; + +type InlineGenerationPlaceholderExpiryOptions = { + canvasGenerationDialogs: CanvasGenerationDialogState[]; + removeCanvasGenerationDialogById: (dialogId: string) => void; + onPlaceholdersExpired: (expiredCount: number) => void; +}; + +/** + * 中文注释:页面打开期间的 inline 占位到期清理。 + * + * 加载期的 `dropDeadInlineGenerationPlaceholders` 只在项目首次加载执行一次,所以当时还没 + * 越过存活窗口、被保留下来的孤儿占位再没有任何东西会重新判定——它会一直转到用户下一次 + * 加载为止。本 hook 补上这一段:算出下一个到期时刻挂一次性定时器,到点按同一条规则处置。 + * + * 三个必须守住的点: + * + * 一、到期回调**不做判定**,只推进 tick 让 effect 重跑,判定始终在 effect 体里用当前的 + * dialogs 和当前时间做。挂上定时器之后占位可能已被拥有者会话正常收口、或被用户删掉, + * 按闭包里的旧值行动会清掉一个已经完成的占位。 + * + * 二、清理用底层的 `removeCanvasGenerationDialogById`,不是 View 的 + * `removeCanvasGenerationDialog`。后者是用户主动删除的语义:写一条 + * `delete-generation-result` 历史、清空选中、切回选择工具。自动清理记用户没做过的历史、 + * 抢走用户当前的选中态和工具,都是错的;加载期剥离同样不做这些。 + * + * 三、本会话自己在途的占位不会被误清。客户端 120 秒就会 abort,catch 会把它推离 + * `generating`,180 秒时它不可能还处于 `generating`。但这里不依赖该推理——第一点的 + * 「到期重新判定」本身就兜住了:条件不成立就什么都不做。 + */ +export function useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, +}: InlineGenerationPlaceholderExpiryOptions) { + const [expiryTick, setExpiryTick] = useState(0); + + useEffect(() => { + const expiredIds = collectExpiredInlineGenerationDialogIds( + canvasGenerationDialogs, + ); + if (expiredIds.length > 0) { + for (const dialogId of expiredIds) { + removeCanvasGenerationDialogById(dialogId); + } + onPlaceholdersExpired(expiredIds.length); + return undefined; + } + const nextExpiryAt = resolveNextInlineGenerationDialogExpiryAt( + canvasGenerationDialogs, + ); + if (nextExpiryAt === null) { + return undefined; + } + // 中文注释:多给 50ms 余量。定时器只保证「不早于」,贴着到期时刻醒来会让 effect 重跑一次 + // 却判定为未到期,白白多挂一轮。 + const delayMs = Math.max(0, nextExpiryAt - Date.now()) + 50; + const timer = window.setTimeout(() => { + setExpiryTick((currentTick) => currentTick + 1); + }, delayMs); + return () => { + window.clearTimeout(timer); + }; + }, [ + canvasGenerationDialogs, + expiryTick, + onPlaceholdersExpired, + removeCanvasGenerationDialogById, + ]); +}