@blksails/pi-web-canvas-ui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2156 @@
1
+ /**
2
+ * CanvasWorkbench — 格子展开工作台(aigc-canvas · Req 4.1 / 4.6 / 5.2 / 5.4 / 6.x / 10.3)。
3
+ *
4
+ * M2 画布编辑器:舞台(滚轮缩放/移动工具平移)+ 右侧工具轨(移动/画线/箭头/文本/掩码刷/擦除/
5
+ * 撤销重做)+ overlay 画布(掩码粉红 + 标注红)+ 底部居中提示词栏(@多图引用 + 比例/变体参数簇)
6
+ * + 左侧垂直版本条。全部控件为舞台上的浮动层,画板满幅。
7
+ *
8
+ * - **A 档**:底部「生成」按舞台状态决策动作(见 {@link decideGenerate}):
9
+ * 掩码笔迹 → `inpaint`(笔迹经 `strokesToMask` 光栅化为 **alpha mask PNG**,OpenAI 标准:
10
+ * 透明洞=编辑区)> @引用/标注 → `reference`(标注经 `annotationsToImage` 拍平为批注参考图,
11
+ * 标注即指令)> 变体数≥2 → `variants` > 仅比例 → `reframe` > `edit`。
12
+ * `args` 仅 `att_` 引用 + 文本,无二进制。
13
+ * - **B 档**:掩码/标注/旋转/回贴合成全在本地 Canvas 2D;产物经既有上传接缝落新 `att_` →
14
+ * `run("register", ...)`(Req 5.2);`available===false` 时仅本地呈现、**不 register**(Req 9.3)。
15
+ * - **带入对话**:显式动作经 Prompt 通道注入 `att_id`(默认不注入,Req 4.6)。
16
+ *
17
+ * slot 组件经 prop 注入 surface(领域无关搬运)。B 档上传接缝与 canvas 工厂经 props 注入(可测)。
18
+ */
19
+ import * as React from "react";
20
+ import {
21
+ renderSurfaceOp,
22
+ type SurfaceOp,
23
+ type WebExtSurfaceAccess,
24
+ type ConversationAccess,
25
+ } from "@blksails/pi-web-kit";
26
+ import { useConversationBridge } from "@blksails/pi-web-react";
27
+ // 解读载荷构造器(spec canvas-vision-readout):与 buildSurfaceOp 平行、互不 import。
28
+ import { buildVisionOp, type VisionModelOption } from "./vision-op.js";
29
+ import type { GalleryAsset, GalleryState } from "@blksails/pi-web-tool-kit/aigc-canvas-schema";
30
+ import {
31
+ ArrowLeft,
32
+ AtSign,
33
+ Loader2,
34
+ Maximize2,
35
+ MessageSquarePlus,
36
+ Minus,
37
+ Eye,
38
+ Plus,
39
+ Redo2,
40
+ RotateCw,
41
+ Sparkles,
42
+ Undo2,
43
+ X,
44
+ } from "lucide-react";
45
+ import { Button } from "@blksails/pi-web-primitives";
46
+ import { Card } from "@blksails/pi-web-primitives";
47
+ import { Input } from "@blksails/pi-web-primitives";
48
+ import { Popover, PopoverContent, PopoverAnchor } from "@blksails/pi-web-primitives";
49
+ import {
50
+ Select,
51
+ SelectContent,
52
+ SelectItem,
53
+ SelectTrigger,
54
+ SelectValue,
55
+ } from "@blksails/pi-web-primitives";
56
+ import { Textarea } from "@blksails/pi-web-primitives";
57
+ import { cn } from "@blksails/pi-web-primitives";
58
+ import {
59
+ ANNOTATION_COLOR,
60
+ BRUSH_RATIOS,
61
+ PREF_ANNO_COLOR,
62
+ PREF_BRUSH_RATIO,
63
+ PREF_EXPAND_EDGES,
64
+ annotationsToImage,
65
+ compositeByMask,
66
+ createCanvasKernel,
67
+ expandedSize,
68
+ flattenLayers,
69
+ hasExpand,
70
+ hasMaskContent,
71
+ outpaintImage,
72
+ outpaintMask,
73
+ registerBuiltinTools,
74
+ registerPluginBundles,
75
+ resolveAction,
76
+ rotateImage,
77
+ strokesToMask,
78
+ uploadDataUri,
79
+ type Annotation,
80
+ type CanvasCapability,
81
+ type CanvasFactory,
82
+ type CanvasLike,
83
+ type Ctx2DLike,
84
+ type ExpandEdges,
85
+ type ImageSourceLike,
86
+ type LoadedImage,
87
+ type MaskStroke,
88
+ type RouterPointerEvent,
89
+ type ToolDiagnostic,
90
+ type UploadFn,
91
+ type WorkLayer,
92
+ } from "@blksails/pi-web-canvas-kit";
93
+ import {
94
+ BUILTIN_GENERATE_ACTIONS,
95
+ registerBuiltinGenerateActions,
96
+ toGenerateDecision,
97
+ } from "./generate-actions.js";
98
+ import type { NamespacedPluginBundles } from "./plugin-aggregation.js";
99
+
100
+ const DOMAIN = "canvas";
101
+ const PROBE = `surface:${DOMAIN}`;
102
+ const STATE_KEY = `surface:${DOMAIN}`;
103
+
104
+ /**
105
+ * busy 解锁窗:`surface.run` 结果在 prod 毫秒级配对回包;但 **next dev(StrictMode)** 双跑空闲
106
+ * 控制流 effect 的竞争会令 ui-rpc 回包帧丢失,run 只能等满 ui-rpc bus 15s 超时结算。命令效果
107
+ * 本就经权威快照回流呈现(与 run resolve 无关),故 busy 以短窗 race 解锁,不吊死交互。
108
+ */
109
+ const RUN_SETTLE_MS = 4000;
110
+ function settleWindow<T>(p: Promise<T>, ms = RUN_SETTLE_MS): Promise<unknown> {
111
+ return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
112
+ }
113
+
114
+ // ── 舞台工具装配(4.2 注册表驱动:工具本体在 canvas-kit builtin/,此处只留装配策略)──
115
+
116
+ /** 移动工具 id(领域策略锚:双击复位视图/舞台 grab 光标归属)。 */
117
+ const MOVE_TOOL_ID = "builtin:move";
118
+
119
+ /** 扩图工具 id(领域策略锚:手柄 DOM 留 workbench(design 裁定)+ 扩图态 fitPad)。 */
120
+ const EXPAND_TOOL_ID = "builtin:expand";
121
+
122
+ /** 工具轨长 title(3.1 留账①:不在 CanvasTool 声明面,装配侧另行保持;缺省用 label)。 */
123
+ const TOOL_RAIL_TITLES: Readonly<Record<string, string>> = {
124
+ [EXPAND_TOOL_ID]: "扩图(拖动边框向外扩,生成填充新区域)",
125
+ "builtin:draw": "画笔(标注即指令)",
126
+ "builtin:line": "画线(标注即指令)",
127
+ "builtin:arrow": "箭头(标注即指令)",
128
+ "builtin:text": "文本(标注即指令)",
129
+ };
130
+
131
+ /** 不受「A 档 + 上传接缝」禁用门约束的工具(纯本地视口手势;其余同旧 maskToolsDisabled)。 */
132
+ const BACKEND_FREE_TOOLS: ReadonlySet<string> = new Set([MOVE_TOOL_ID]);
133
+
134
+ /** 工具 data-* 锚点值:剥 `builtin:` 前缀(既有锚点 `data-canvas-tool="move"` 等零变)。 */
135
+ const toolAnchor = (id: string): string => id.replace(/^builtin:/, "");
136
+
137
+ /**
138
+ * 工具轨按钮 title 组装(Req 6.3/6.4;裁定 B 插件缺依赖)。两条禁用来源:
139
+ * ① **插件缺依赖**(task 3.1;`pluginReason` 非空 = 该工具在 registry.disabledPluginTools):
140
+ * 直接以缺依赖原因串(含具体缺失项)拼 title —— 拓扑校验诊断按**捆 id** 归属,resolveToolRailTitle
141
+ * 按工具 id 匹配 diagnostics 取不到,故经 registry.disabledPluginToolReason 显式取回原因;
142
+ * ② **runtime 回调抛错禁用**(在 `disabledTools` 中且 `diagnostics` 存在该工具**语义**条目——
143
+ * `kind` 缺省或 `"tool"`;`"action"` 条目属动作面不入工具轨):拼首条诊断原因。
144
+ * 否则逐字节维持原 title——门控禁用(上传接缝/A 档未就绪)与无诊断均零变。纯函数,可独立单测。
145
+ */
146
+ export function resolveToolRailTitle(
147
+ baseTitle: string,
148
+ toolId: string,
149
+ disabledTools: readonly string[],
150
+ diagnostics: readonly ToolDiagnostic[],
151
+ pluginReason?: string,
152
+ ): string {
153
+ // ① 插件缺依赖恒禁用:原因来自禁用集读面(裁定 B;先于 runtime 诊断 —— 缺依赖工具从不激活,
154
+ // 不会另有回调抛错诊断,二者不并存)。
155
+ if (pluginReason !== undefined) return `${baseTitle}(已禁用:${pluginReason})`;
156
+ if (!disabledTools.includes(toolId)) return baseTitle;
157
+ const entry = diagnostics.find((d) => d.toolId === toolId && d.kind !== "action");
158
+ return entry === undefined ? baseTitle : `${baseTitle}(已禁用:${entry.error})`;
159
+ }
160
+
161
+ /** 四边零扩展(扩图复位/初值;prefs 键 PREF_EXPAND_EDGES 的缺省值)。 */
162
+ const NO_EXPAND: ExpandEdges = { top: 0, right: 0, bottom: 0, left: 0 };
163
+
164
+ /**
165
+ * 模型下拉的内置常用清单(AIGC 图像模型;运行时可用性取决于 provider 配置,宿主可经
166
+ * `modelOptions` prop 覆盖为动态清单)。空值 = 交给工具默认模型。
167
+ */
168
+ const DEFAULT_MODEL_OPTIONS: readonly string[] = [
169
+ "gpt-image-2",
170
+ "wan2.7-image-pro",
171
+ "qwen-image-edit-max",
172
+ "qwen-image-2.0",
173
+ "wan2.6-t2i",
174
+ "wanx2.1-t2i-turbo",
175
+ ];
176
+
177
+ /** Radix Select 不接受空字符串 item value;以哨兵表示「默认模型」。 */
178
+ const MODEL_DEFAULT_SENTINEL = "__default__";
179
+
180
+ /**
181
+ * 视觉模型偏好的本地存储键(spec canvas-vision-readout,Req 3.2)。
182
+ *
183
+ * 与 `AigcQuickSettings` 的 `pi-web.aigc.*` 同前缀风格。刻意用 localStorage 而非 state 桥 KV:
184
+ * 提示词栏的**生成**模型选择器就是纯本地 state,引入 KV 会让同栏两个下拉的持久化机制不一致。
185
+ */
186
+ const VISION_MODEL_LS_KEY = "pi-web.vision.model";
187
+
188
+ /** 读视觉模型偏好;隐私模式 / SSR 下静默退化为「本次会话内有效」。 */
189
+ function readVisionModelPref(): string {
190
+ try {
191
+ return window.localStorage.getItem(VISION_MODEL_LS_KEY) ?? "";
192
+ } catch {
193
+ return "";
194
+ }
195
+ }
196
+
197
+ /** 写视觉模型偏好;失败静默忽略。 */
198
+ function writeVisionModelPref(value: string): void {
199
+ try {
200
+ if (value === "") window.localStorage.removeItem(VISION_MODEL_LS_KEY);
201
+ else window.localStorage.setItem(VISION_MODEL_LS_KEY, value);
202
+ } catch {
203
+ /* 隐私模式等:跨会话记忆静默不可用,会话内偏好不受影响 */
204
+ }
205
+ }
206
+
207
+ /**
208
+ * 比例参数簇(仅 1:1 / 16:9 / 9:16 三档)。初始 ratioSize="" 时 trigger 显示「跟随原图」,选比例即带 size。
209
+ * ⚠️ 16:9 / 9:16(1280×720 / 720×1280)是 wan/dashscope 系尺寸;gpt-image(默认 NewAPI/sufy)只支持
210
+ * 1:1(1024²)/1536×1024/1024×1536 —— 选 16:9/9:16 走 gpt-image 会被网关拒绝,须配 wan 模型。
211
+ */
212
+ const RATIO_OPTIONS: readonly { label: string; size: string }[] = [
213
+ { label: "1:1", size: "1024x1024" },
214
+ { label: "16:9", size: "1280x720" },
215
+ { label: "9:16", size: "720x1280" },
216
+ ];
217
+
218
+ /** 浮动层公共观感(舞台上的悬浮控件)。 */
219
+ const FLOAT_LAYER =
220
+ "rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))]/90 shadow-md backdrop-blur";
221
+
222
+ /**
223
+ * 插件图层 Inspector 编辑图层 data 的统一历史 op(task 3.2,Req 1.6)。进 undo 栈,经 1.2
224
+ * op 行为钩子 revert=写回 prev、apply=写回 next(裁定 C 的 data 变体;放置类 op 归 3.3)。
225
+ */
226
+ const LAYER_DATA_OP = "builtin:layer-data";
227
+
228
+ // ── 生成决策(纯函数,export 供单测)────────────────────────────────────────────
229
+
230
+ export interface GenerateDecisionInput {
231
+ readonly imageId: string;
232
+ readonly prompt: string;
233
+ readonly model: string;
234
+ /** 扩图就绪(四边扩展量任一 >0 且上传接缝可用;优先级最高——改变画布本身)。 */
235
+ readonly hasExpand?: boolean;
236
+ /** 掩码就绪(有 paint 笔迹且上传接缝可用)。 */
237
+ readonly hasMask: boolean;
238
+ /** 参考图 att_id 序列(@引用 + 已拍平上传的批注图)。 */
239
+ readonly referenceIds: readonly string[];
240
+ /** 变体数(1 = 不走 variants)。 */
241
+ readonly variants: number;
242
+ /** 输出尺寸("" = 默认)。 */
243
+ readonly size: string;
244
+ }
245
+
246
+ export interface GenerateDecision {
247
+ readonly action: "outpaint" | "inpaint" | "reference" | "variants" | "reframe" | "edit";
248
+ /** 命令 args(inpaint 的 `mask` 由调用方在掩码上传后补充)。 */
249
+ readonly args: Record<string, unknown>;
250
+ }
251
+
252
+ /** 空能力清单常量(退化路径喂入;内置六动作 via:"prompt" 不受 command 白名单门控,Req 2.6)。 */
253
+ const EMPTY_CAPABILITY: CanvasCapability = { models: [], sizes: [], actions: [] };
254
+
255
+ /**
256
+ * resolveAction 空候选(理论不可达:edit 恒 `match`→10)的防御性回退,逐字复刻旧 if 链的
257
+ * edit 兜底:base = image + prompt(+ 非空 model/size)。
258
+ */
259
+ function fallbackEditDecision(i: {
260
+ readonly imageId: string;
261
+ readonly prompt: string;
262
+ readonly model: string;
263
+ readonly size: string;
264
+ }): GenerateDecision {
265
+ const base: Record<string, unknown> = { image: i.imageId, prompt: i.prompt };
266
+ if (i.model !== "") base.model = i.model;
267
+ if (i.size !== "") base.size = i.size;
268
+ return { action: "edit", args: base };
269
+ }
270
+
271
+ /**
272
+ * 生成动作决策:掩码 > 引用/标注 > 变体 > 仅比例(空 prompt)→ reframe > edit。
273
+ * size/model 作为参数随主动作附带(schema 各动作均可选支持)。
274
+ *
275
+ * 实现自 task 3.2 起委托评分制动作插件链:组装 {@link ActionInput}(capability 喂空清单常量,
276
+ * 内置六动作 via:"prompt" 不受白名单门控)→ `resolveAction(BUILTIN_GENERATE_ACTIONS)` →
277
+ * {@link toGenerateDecision}。签名 / 导出 / 语义与旧封闭 if 链逐项等价(generate-actions.test.ts
278
+ * 决策守恒线锁定)。
279
+ *
280
+ * @deprecated 兼容一个大版本;新代码直连 `resolveAction` + `BUILTIN_GENERATE_ACTIONS`
281
+ * (canvas-kit),按需要以 {@link toGenerateDecision} 映射胜者插件 id。
282
+ */
283
+ export function decideGenerate(i: GenerateDecisionInput): GenerateDecision {
284
+ const resolved = resolveAction(BUILTIN_GENERATE_ACTIONS, {
285
+ imageId: i.imageId,
286
+ prompt: i.prompt,
287
+ model: i.model,
288
+ size: i.size,
289
+ variants: i.variants,
290
+ hasMask: i.hasMask,
291
+ hasExpand: i.hasExpand === true,
292
+ referenceIds: i.referenceIds,
293
+ capability: EMPTY_CAPABILITY,
294
+ });
295
+ if (resolved === null) return fallbackEditDecision(i);
296
+ return toGenerateDecision(resolved.plugin.id, resolved.args);
297
+ }
298
+
299
+ const ACTION_LABEL: Record<GenerateDecision["action"], string> = {
300
+ outpaint: "扩图",
301
+ inpaint: "局部重绘",
302
+ reference: "融合生成",
303
+ variants: "生成变体",
304
+ reframe: "重构比例",
305
+ edit: "生成",
306
+ };
307
+
308
+ /**
309
+ * 把生成决策析出为与通道无关的 {@link SurfaceOp}:领域参数组装(工具行执行注解、mask/
310
+ * reference_images 值内注解、reframe 默认提示词、省略规则、标题行意图 ≤48 截断)原样迁移,
311
+ * fence 固定 `canvas-op`。**不声明 fallback**(canvas 生成无控制面等价,command 态不可提交)。
312
+ * export 供门面/单测。参数按 tool→image→mask→reference_images→prompt→size→n→model 有序组装。
313
+ */
314
+ export function buildSurfaceOp(d: GenerateDecision, opts?: { maskId?: string }): SurfaceOp {
315
+ const a = d.args;
316
+ const params: Array<readonly [string, string]> = [["image", String(a.image)]];
317
+ if (opts?.maskId !== undefined) {
318
+ params.push(["mask", `${opts.maskId}(alpha mask,透明区=需要重绘的区域)`]);
319
+ }
320
+ const refs = a.reference_images;
321
+ if (Array.isArray(refs) && refs.length > 0) {
322
+ params.push([
323
+ "reference_images",
324
+ `${refs.map(String).join(", ")}(首张若为批注图,按其箭头/文字指示修改)`,
325
+ ]);
326
+ }
327
+ if (typeof a.prompt === "string" && a.prompt.trim() !== "") {
328
+ params.push(["prompt", a.prompt]);
329
+ } else if (d.action === "reframe") {
330
+ params.push(["prompt", "保持画面内容,仅按目标尺寸重构比例"]);
331
+ }
332
+ if (typeof a.size === "string") params.push(["size", a.size]);
333
+ if (typeof a.n === "number") params.push(["n", `${a.n}`]);
334
+ if (typeof a.model === "string") params.push(["model", a.model]);
335
+ const intent =
336
+ typeof a.prompt === "string" && a.prompt.trim() !== ""
337
+ ? a.prompt.trim().length > 48
338
+ ? `${a.prompt.trim().slice(0, 48)}…`
339
+ : a.prompt.trim()
340
+ : "";
341
+ const title = intent !== "" ? `🎨 ${ACTION_LABEL[d.action]} · ${intent}` : `🎨 ${ACTION_LABEL[d.action]}`;
342
+ return {
343
+ title,
344
+ tool: "image_edit(请直接按下列参数调用,勿追问、勿复述参数)",
345
+ params,
346
+ fence: "canvas-op",
347
+ };
348
+ }
349
+
350
+ /**
351
+ * 把生成决策组装为**经对话流**的用户消息(LLM 据此调 `image_edit` 工具;参数用 `att_` 引用,
352
+ * attachment-bridge 在工具侧解析)。操作因此天然回流对话历史:用户消息 + 工具卡片 + 结果图
353
+ * 全部可见、可回放、进 LLM 上下文(后续"刚才那张再调亮"能接上)。薄包装于 {@link buildSurfaceOp}
354
+ * + {@link renderSurfaceOp};export 与签名不变。
355
+ *
356
+ * @deprecated 兼容一个大版本;新代码经动作插件 `execution.buildOp` + {@link renderSurfaceOp}
357
+ * 取对话流载荷(BUILTIN_GENERATE_ACTIONS 内置六动作 via:"prompt" 即用此通道)。
358
+ */
359
+ export function buildToolPrompt(d: GenerateDecision, opts?: { maskId?: string }): string {
360
+ return renderSurfaceOp(buildSurfaceOp(d, opts));
361
+ }
362
+
363
+ /** 从 asset 派生只读元信息摘要片段(缺项跳过)。 */
364
+ function summarizeGenParams(asset: GalleryAsset): string[] {
365
+ const parts: string[] = [];
366
+ const gp = asset.genParams as Record<string, unknown> | undefined;
367
+ const size = gp?.size;
368
+ if (typeof size === "string" && size !== "") parts.push(size);
369
+ parts.push(asset.origin === "upload" ? "上传" : "工具生成");
370
+ if (asset.derivedFrom !== undefined && asset.derivedFrom !== "") {
371
+ parts.push(`派生自 …${asset.derivedFrom.slice(-6)}`);
372
+ }
373
+ const modelName = gp?.model;
374
+ if (typeof modelName === "string" && modelName !== "") parts.push(modelName);
375
+ return parts;
376
+ }
377
+
378
+ // LoadedImage 已收编进 canvas-kit 类型 canonical 家;此处转发保持 workbench 既有导出面。
379
+ export type { LoadedImage } from "@blksails/pi-web-canvas-kit";
380
+
381
+ /** 图像加载器签名(浏览器默认 new Image;测试注入 fake)。 */
382
+ export type ImageLoader = (url: string) => Promise<LoadedImage>;
383
+
384
+ function defaultImageLoader(url: string): Promise<LoadedImage> {
385
+ return new Promise((resolve, reject) => {
386
+ const img = new Image();
387
+ img.crossOrigin = "anonymous";
388
+ img.onload = () =>
389
+ resolve({ source: img, width: img.naturalWidth, height: img.naturalHeight });
390
+ img.onerror = () => reject(new Error(`图像加载失败: ${url}`));
391
+ img.src = url;
392
+ });
393
+ }
394
+
395
+ /**
396
+ * 等待快照中出现「派生自 baseId 的新资产」(inpaint 模型结果回流;排除已知 id 与合成品自身)。
397
+ * 超时回 null(生图失败/过慢 → 放弃回贴,模型原始结果仍在画廊,仅降级)。
398
+ */
399
+ function waitForNewDerivedAsset(
400
+ surface: WebExtSurfaceAccess,
401
+ baseId: string,
402
+ knownIds: ReadonlySet<string>,
403
+ timeoutMs: number,
404
+ ): Promise<GalleryAsset | null> {
405
+ const check = (): GalleryAsset | undefined =>
406
+ (surface.getState<GalleryState>(STATE_KEY)?.assets ?? []).find((a) => {
407
+ if (knownIds.has(a.attachmentId) || a.derivedFrom !== baseId) return false;
408
+ const op = (a.genParams as Record<string, unknown> | undefined)?.op;
409
+ return op !== "inpaint-composite";
410
+ });
411
+ const hit = check();
412
+ if (hit !== undefined) return Promise.resolve(hit);
413
+ return new Promise((resolve) => {
414
+ const timer = setTimeout(() => {
415
+ unsub();
416
+ resolve(null);
417
+ }, timeoutMs);
418
+ const unsub = surface.subscribe(STATE_KEY, () => {
419
+ const h = check();
420
+ if (h !== undefined) {
421
+ clearTimeout(timer);
422
+ unsub();
423
+ resolve(h);
424
+ }
425
+ });
426
+ });
427
+ }
428
+
429
+ /** inpaint 回流后的掩码回贴任务超时(生图可能要数十秒)。 */
430
+ const COMPOSE_BACK_TIMEOUT_MS = 120_000;
431
+
432
+ /**
433
+ * 掩码回贴(后台任务):等 inpaint 模型结果回流 → 掩码外贴回原图/掩码内取新图 →
434
+ * 上传合成图 → `register`(derivedFrom=原图,op:"inpaint-composite")。
435
+ * gpt-image 系 edits 是整图重生成,掩码外漂移只能靠本地回贴根治;失败静默降级
436
+ * (模型原始结果已在画廊)。导出仅供单测(UI 画笔路径由浏览器 e2e 覆盖)。
437
+ */
438
+ export async function composeInpaintBack(args: {
439
+ surface: WebExtSurfaceAccess;
440
+ baseId: string;
441
+ baseDisplayUrl: string;
442
+ baseName: string;
443
+ strokes: readonly MaskStroke[];
444
+ prompt: string;
445
+ /** 发 inpaint **之前**的资产 id 集合(此后新出现的 derivedFrom=baseId 即模型结果;发后收集会漏判秒回结果)。 */
446
+ knownIds: ReadonlySet<string>;
447
+ upload: UploadFn;
448
+ uploadBaseUrl: string;
449
+ sessionId: string;
450
+ canvasFactory?: CanvasFactory;
451
+ imageLoader: ImageLoader;
452
+ timeoutMs?: number;
453
+ }): Promise<void> {
454
+ try {
455
+ const patchAsset = await waitForNewDerivedAsset(
456
+ args.surface,
457
+ args.baseId,
458
+ args.knownIds,
459
+ args.timeoutMs ?? COMPOSE_BACK_TIMEOUT_MS,
460
+ );
461
+ if (patchAsset === null) return;
462
+ const [base, patch] = await Promise.all([
463
+ args.imageLoader(args.baseDisplayUrl),
464
+ args.imageLoader(patchAsset.displayUrl),
465
+ ]);
466
+ const opts = args.canvasFactory !== undefined ? { canvasFactory: args.canvasFactory } : {};
467
+ const uri = compositeByMask(
468
+ { width: base.width, height: base.height, source: base.source },
469
+ patch.source,
470
+ args.strokes,
471
+ opts,
472
+ );
473
+ const { attachmentId } = await uploadDataUri({
474
+ dataUri: uri,
475
+ name: `inpaint-${args.baseName}`,
476
+ baseUrl: args.uploadBaseUrl,
477
+ sessionId: args.sessionId,
478
+ upload: args.upload,
479
+ });
480
+ await args.surface.run(DOMAIN, "register", {
481
+ attachmentId,
482
+ derivedFrom: args.baseId,
483
+ genParams: { op: "inpaint-composite", prompt: args.prompt, from: patchAsset.attachmentId },
484
+ });
485
+ } catch {
486
+ // 回贴失败:静默降级(模型原始结果已在画廊)。
487
+ }
488
+ }
489
+
490
+ export interface CanvasWorkbenchProps {
491
+ readonly surface?: WebExtSurfaceAccess;
492
+ /** 当前工作图(初始;可经左侧版本条切换)。 */
493
+ readonly asset: GalleryAsset;
494
+ /** 全部资产(供版本条 / @引用选择)。 */
495
+ readonly assets: readonly GalleryAsset[];
496
+ readonly onClose: () => void;
497
+ /** 带入对话(显式 Prompt 注入 att_id);缺失则不提供该动作。 */
498
+ readonly onBringToConversation?: (attachmentId: string) => void;
499
+ /** 复用历史参数(C 档;预填表单)。 */
500
+ readonly onReuseParams?: (asset: GalleryAsset) => void;
501
+ // ── B 档上传接缝(可注入,测试用)──────────────────────────────────────────────
502
+ readonly upload?: UploadFn;
503
+ readonly baseUrl?: string;
504
+ readonly sessionId?: string;
505
+ readonly canvasFactory?: CanvasFactory;
506
+ /** 模型下拉候选(缺省用内置常用清单)。 */
507
+ readonly modelOptions?: readonly string[];
508
+ /**
509
+ * 可选视觉模型清单(spec canvas-vision-readout,Req 3.1)。
510
+ * 由宿主(CanvasLauncher)从 `GET /api/vision/models` 拉取注入;缺省 `[]`
511
+ * (选择器空态,解读仍可用 —— 此时载荷不带 model,由工具弹层兜底)。
512
+ */
513
+ readonly visionModelOptions?: readonly VisionModelOption[];
514
+ /** 图像加载器(掩码回贴合成用;缺省浏览器 Image,测试注入 fake)。 */
515
+ readonly imageLoader?: ImageLoader;
516
+ /**
517
+ * 会话能力对象(契约 §4.2;经宿主 Prompt 通道提交用户消息)。与 `onSubmitPrompt` 同族,
518
+ * 二者在场时 conversation 优先(见 {@link useConversationBridge});承载「生成走对话流」能力。
519
+ */
520
+ readonly conversation?: ConversationAccess;
521
+ /**
522
+ * 经宿主 Prompt 通道发用户消息:提供时,「生成」组装 image_edit 指令**走对话流**
523
+ * (LLM 调工具执行,操作回流对话历史);缺失时回退旁路 surface 命令(不过 LLM,兼容旧宿主)。
524
+ *
525
+ * @deprecated 使用 `conversation`;此裸回调为过渡别名,行为与之等价(契约 §4.2)。
526
+ */
527
+ readonly onSubmitPrompt?: (text: string) => void;
528
+ /** 宿主转发的当前轮流式图像预览(由糊变清);配合 surface `livePreview` 显示渐进图。 */
529
+ readonly livePreviewImage?: string;
530
+ /** 轮末 idle 边沿信号(宿主每轮结束 bump);作 livePreview 卡死自愈锚点。 */
531
+ readonly syncSignal?: unknown;
532
+ /**
533
+ * 外部插件捆(按来源命名空间分组;CanvasPanel 经 collectCanvasPluginBundles 从已装载扩展
534
+ * 聚合而来,task 3.1)。kernel 装配期在内置工具/动作注册后逐来源 registerPluginBundles
535
+ * (`<namespace>:` 前缀化 + requires 拓扑校验);缺依赖捆内工具进轨但恒禁用(裁定 B)。
536
+ * 缺省(无外部插件)→ 工具轨仅内置,行为逐点与现状一致(Req 4.3)。
537
+ */
538
+ readonly plugins?: readonly NamespacedPluginBundles[];
539
+ }
540
+
541
+ export function CanvasWorkbench({
542
+ surface,
543
+ asset,
544
+ assets,
545
+ onClose,
546
+ onBringToConversation,
547
+ onReuseParams,
548
+ upload,
549
+ baseUrl,
550
+ sessionId,
551
+ canvasFactory,
552
+ modelOptions,
553
+ visionModelOptions,
554
+ imageLoader,
555
+ conversation,
556
+ onSubmitPrompt,
557
+ livePreviewImage,
558
+ syncSignal,
559
+ plugins,
560
+ }: CanvasWorkbenchProps): React.JSX.Element {
561
+ const available = surface !== undefined && surface.hasCommand(PROBE);
562
+ // 对话桥门面(契约 §4.5):三处提交点经 bridge.submitOp 分道(prompt 优先经会话能力/别名),
563
+ // 轮末 livePreview 自愈经 bridge.onTurnEnd 订阅 syncSignal 边沿。
564
+ const bridge = useConversationBridge({
565
+ ...(conversation !== undefined ? { conversation } : {}),
566
+ ...(onSubmitPrompt !== undefined ? { onSubmitPrompt } : {}),
567
+ ...(surface !== undefined ? { surface } : {}),
568
+ syncSignal,
569
+ domain: DOMAIN,
570
+ });
571
+ const [prompt, setPrompt] = React.useState("");
572
+ const [model, setModel] = React.useState<string>("");
573
+ // 视觉模型偏好(Req 3.2):`""` = 未设定 → 解读载荷不带 model → 工具弹层选择(Req 3.4)。
574
+ // ⚠ 取值是 `provider/modelId`,与上面**生成**模型选择器的裸 id 格式不同,不可混用。
575
+ const [visionModel, setVisionModel] = React.useState<string>("");
576
+ React.useEffect(() => {
577
+ const pref = readVisionModelPref();
578
+ if (pref !== "") setVisionModel(pref);
579
+ }, []);
580
+ const [busy, setBusy] = React.useState(false);
581
+ const [currentId, setCurrentId] = React.useState<string>(asset.attachmentId);
582
+ // 生成中的临时渐进预览(流式 partial_images 由糊变清):订阅权威快照 livePreview,渲染为舞台叠层。
583
+ const [livePreview, setLivePreview] = React.useState<GalleryState["livePreview"]>(
584
+ () => surface?.getState<GalleryState>(STATE_KEY)?.livePreview ?? null,
585
+ );
586
+ // 能力清单(agent 权威;task 3.3):纳入同一订阅态,agent 侧晚到的 capability 帧刷新
587
+ // 模型/尺寸选项与决策门控输入。缺失(旧快照/退化)= undefined,消费侧 ?? 链回退硬编码。
588
+ const [capabilities, setCapabilities] = React.useState<GalleryState["capabilities"]>(
589
+ () => surface?.getState<GalleryState>(STATE_KEY)?.capabilities,
590
+ );
591
+ React.useEffect(() => {
592
+ if (surface === undefined) return;
593
+ const read = (): void => {
594
+ const s = surface.getState<GalleryState>(STATE_KEY);
595
+ setLivePreview(s?.livePreview ?? null);
596
+ setCapabilities(s?.capabilities);
597
+ };
598
+ read();
599
+ return surface.subscribe(STATE_KEY, read);
600
+ }, [surface]);
601
+ // 轮末兜底自愈:清除帧(livePreview:null)在 dev 帧投递不稳/长流式高频窗口下可能丢失,
602
+ // 叠层会卡死在「生成中」。轮末 idle 边沿(bridge.onTurnEnd,首见不触发仅变化触发)时生成必已
603
+ // 结束 → 无条件清叠层。
604
+ React.useEffect(() => bridge.onTurnEnd(() => setLivePreview(null)), [bridge]);
605
+ // ── M2 状态:@引用 / 参数簇 ──────────────────────────────────────────────────
606
+ const [refs, setRefs] = React.useState<readonly string[]>([]);
607
+ const [refOpen, setRefOpen] = React.useState(false);
608
+ const [ratioSize, setRatioSize] = React.useState<string>("");
609
+ const [variantsN, setVariantsN] = React.useState(1);
610
+ const [sizeOpen, setSizeOpen] = React.useState(false);
611
+ const [customW, setCustomW] = React.useState("");
612
+ const [customH, setCustomH] = React.useState("");
613
+ const imgRef = React.useRef<HTMLImageElement | null>(null);
614
+ const stageRef = React.useRef<HTMLDivElement | null>(null);
615
+ const overlayRef = React.useRef<HTMLCanvasElement | null>(null);
616
+ /** natural 的 ref 镜像(kernel env 访问器在事件回调期读取;经 effect 与 state 同步)。 */
617
+ const naturalRef = React.useRef<{ w: number; h: number } | null>(null);
618
+
619
+ // ── 交互内核(4.1 状态搬家 + 4.2 注册表驱动):per mount,8 内置工具自举 ─────────
620
+ // StrictMode 双跑 useMemo 会各建一份(纯状态容器零副作用,弃置其一安全);DOM 量取
621
+ // 与 pointer capture 经 env 接缝延迟到调用时,kernel 本身零 DOM 依赖。
622
+ // prefs 初值注入(硬账③,同键契约):annoColor/brushRatio/expandEdges。
623
+ const kernel = React.useMemo(() => {
624
+ const k = createCanvasKernel({
625
+ getRect: () => overlayRef.current?.getBoundingClientRect() ?? null,
626
+ getNaturalSize: () => naturalRef.current,
627
+ capturePointer: (target, pointerId) => {
628
+ (target as unknown as Element).setPointerCapture?.(pointerId);
629
+ },
630
+ initialPrefs: {
631
+ [PREF_ANNO_COLOR]: ANNOTATION_COLOR,
632
+ [PREF_BRUSH_RATIO]: BRUSH_RATIOS[1],
633
+ [PREF_EXPAND_EDGES]: NO_EXPAND,
634
+ },
635
+ });
636
+ registerBuiltinTools(k.registry);
637
+ // 六内置生成动作同位注册进同一 per-instance 注册表(task 3.2);退订随 kernel(per-mount,
638
+ // 卸载即弃)生命周期,与 registerBuiltinTools 的既有装配形态一致,无需捕获 unregister。
639
+ registerBuiltinGenerateActions(k.registry);
640
+ // 外部插件捆(task 3.1):内置注册后逐来源命名空间 registerPluginBundles(前缀化 + requires
641
+ // 拓扑校验;缺依赖捆内工具进轨恒禁用,裁定 B)。退订随 kernel 生命周期,同内置装配形态。
642
+ // 装配同步于首帧前(与内置同位),故工具轨首渲即含插件工具(禁用态一并就位)。
643
+ for (const { namespace, bundles } of plugins ?? []) {
644
+ registerPluginBundles(k.registry, bundles, { namespace });
645
+ }
646
+ // 插件图层 Inspector data 编辑的 undo/redo 行为(task 3.2,Req 1.6;裁定 C data 变体):
647
+ // op.item 携 {layerId, prev, next},撤销写回 prev、重做写回 next。装配期一次注册(未注册
648
+ // 该 kind 时纯栈语义零变);内置图像图层不产此 op,零影响。
649
+ k.opBehaviors.registerOpBehavior(LAYER_DATA_OP, {
650
+ revert: (op) => {
651
+ const it = op.item as { layerId: string; prev: unknown };
652
+ k.layers.updateData(it.layerId, it.prev);
653
+ },
654
+ apply: (op) => {
655
+ const it = op.item as { layerId: string; next: unknown };
656
+ k.layers.updateData(it.layerId, it.next);
657
+ },
658
+ });
659
+ k.tools.setActiveTool(MOVE_TOOL_ID);
660
+ return k;
661
+ // plugins 来自 source 声明,mount 时即就位且经 CanvasPanel useMemo 稳定引用;身份变化
662
+ // (罕见:扩展集变更)才重建 kernel(新画布)——per-mount 契约由稳定引用维持。
663
+ }, [plugins]);
664
+
665
+ // 工具通道快照(激活工具/draft/禁用)与 prefs 快照(选项条双向绑定/扩图边)。
666
+ const toolsSnap = React.useSyncExternalStore(
667
+ kernel.tools.subscribe,
668
+ kernel.tools.getSnapshot,
669
+ kernel.tools.getSnapshot,
670
+ );
671
+ const prefsSnap = React.useSyncExternalStore(
672
+ kernel.prefs.subscribe,
673
+ kernel.prefs.getSnapshot,
674
+ kernel.prefs.getSnapshot,
675
+ );
676
+ const activeToolId = toolsSnap.activeToolId;
677
+ const activeCanvasTool = kernel.registry.tools.find((t) => t.id === activeToolId) ?? null;
678
+ /** overlay 命中门控(硬账②):工具声明 overlayInteractive 才开 pointer-events。 */
679
+ const overlayInteractive = activeCanvasTool?.overlayInteractive === true;
680
+ // ── 扩图:四边扩展量(源图像素;>0 即扩图意图,生成走 outpaint)—— prefs KV(4.2)──
681
+ const expand = (prefsSnap[PREF_EXPAND_EDGES] as ExpandEdges | undefined) ?? NO_EXPAND;
682
+
683
+ // ── M3 状态:图层(树状态/选中/id 序列归 layers 内核,4.1)──────────────────────
684
+ const layersSnap = React.useSyncExternalStore(
685
+ kernel.layers.subscribe,
686
+ kernel.layers.getSnapshot,
687
+ kernel.layers.getSnapshot,
688
+ );
689
+ const layers = layersSnap.layers;
690
+ const selectedLayer = layersSnap.selectedId;
691
+
692
+ // 当前工作图:优先内部选择,回退到 prop。prop 变化(父切换)时同步。
693
+ const current = React.useMemo(
694
+ () => assets.find((a) => a.attachmentId === currentId) ?? asset,
695
+ [assets, currentId, asset],
696
+ );
697
+ React.useEffect(() => setCurrentId(asset.attachmentId), [asset.attachmentId]);
698
+
699
+ // ── 舞台缩放 / 平移(移动工具)—— 视口状态归 stage 内核(4.1)──────────────────
700
+ const viewport = React.useSyncExternalStore(
701
+ kernel.stage.subscribe,
702
+ kernel.stage.getViewport,
703
+ kernel.stage.getViewport,
704
+ );
705
+ const scale = viewport.scale;
706
+ const offset = viewport.offset;
707
+
708
+ // ── 编辑历史(掩码笔迹 + 标注共栈;撤销/重做按操作顺序)—— 双栈归 history 内核(4.1)
709
+ const history = React.useSyncExternalStore(
710
+ kernel.history.subscribe,
711
+ kernel.history.getSnapshot,
712
+ kernel.history.getSnapshot,
713
+ );
714
+ const ops = history.ops;
715
+ const strokes = React.useMemo(
716
+ () => ops.filter((o) => o.kind === "stroke").map((o) => o.item as MaskStroke),
717
+ [ops],
718
+ );
719
+ const annotations = React.useMemo(
720
+ () => ops.filter((o) => o.kind === "anno").map((o) => o.item as Annotation),
721
+ [ops],
722
+ );
723
+ // 源图自然尺寸(overlay 坐标系)与舞台尺寸(contain-fit 计算)。
724
+ const [natural, setNatural] = React.useState<{ w: number; h: number } | null>(null);
725
+ const [stageSize, setStageSize] = React.useState<{ w: number; h: number } | null>(null);
726
+
727
+ // naturalRef 镜像:kernel env 访问器在指针回调期读取(事件必发生在 effect 提交后)。
728
+ React.useEffect(() => {
729
+ naturalRef.current = natural;
730
+ }, [natural]);
731
+
732
+ const resetView = React.useCallback(() => {
733
+ kernel.stage.reset();
734
+ }, [kernel]);
735
+
736
+ // 切换工作图 → 复位视图 + 清空编辑历史/图层/进行中 draft(含文本编辑器)+ 扩图边
737
+ // (prefs 同键复位)+ 重新量自然尺寸。
738
+ React.useEffect(() => {
739
+ resetView();
740
+ kernel.history.clear();
741
+ kernel.tools.context.draft.set(null);
742
+ kernel.layers.clear();
743
+ kernel.prefs.set(PREF_EXPAND_EDGES, NO_EXPAND);
744
+ setNatural(null);
745
+ }, [current.attachmentId, resetView, kernel]);
746
+
747
+ // 自然尺寸兜底:图片命中缓存时(挂载前已 complete)React onLoad 不触发,同步量取;
748
+ // 否则 natural 恒 null → overlay 不渲染 → 掩码刷画不上。置于清空 effect 之后(同 deps 顺序执行)。
749
+ React.useEffect(() => {
750
+ const el = imgRef.current;
751
+ if (el !== null && el.complete && el.naturalWidth > 0 && el.naturalHeight > 0) {
752
+ setNatural({ w: el.naturalWidth, h: el.naturalHeight });
753
+ }
754
+ }, [current.attachmentId]);
755
+
756
+ // 舞台尺寸(ResizeObserver;jsdom 缺失时跳过,布局退化为 CSS contain)。
757
+ React.useEffect(() => {
758
+ const el = stageRef.current;
759
+ if (el === null || typeof ResizeObserver === "undefined") return;
760
+ const measure = (): void => setStageSize({ w: el.offsetWidth, h: el.offsetHeight });
761
+ const ro = new ResizeObserver(measure);
762
+ ro.observe(el);
763
+ measure();
764
+ return () => ro.disconnect();
765
+ }, []);
766
+
767
+ // 滚轮缩放(native 非 passive,才能 preventDefault 阻止页面滚动)。
768
+ React.useEffect(() => {
769
+ const el = stageRef.current;
770
+ if (el === null) return;
771
+ const onWheel = (e: WheelEvent): void => {
772
+ e.preventDefault();
773
+ kernel.stage.zoomBy(e.deltaY < 0 ? 1.12 : 0.89);
774
+ };
775
+ el.addEventListener("wheel", onWheel, { passive: false });
776
+ return () => el.removeEventListener("wheel", onWheel);
777
+ }, [kernel]);
778
+
779
+ // overlay 预览重绘(4.2 注册表驱动):已提交 ops 按 opKinds 注册表回放(提交序)
780
+ // + 激活工具 rasterizeDraft(进行中手势预览)。清屏留装配层(canvas 元素属 DOM)。
781
+ React.useEffect(() => {
782
+ const cv = overlayRef.current;
783
+ if (cv === null || natural === null) return;
784
+ const ctx = cv.getContext("2d");
785
+ if (ctx === null) return;
786
+ ctx.clearRect(0, 0, cv.width, cv.height);
787
+ // 真实 CanvasRenderingContext2D 是 Ctx2DLike 的超集(fillStyle 为 union),收窄安全。
788
+ kernel.renderOverlay(ctx as unknown as Ctx2DLike, { w: natural.w, h: natural.h });
789
+ }, [ops, toolsSnap.draft, natural, kernel]);
790
+
791
+ /** 当前工作图的像素尺寸(用于 B 档坐标对齐);未加载时退化占位。 */
792
+ const sourceSize = (): { width: number; height: number; source?: CanvasImageSource } => {
793
+ const el = imgRef.current;
794
+ const width = el?.naturalWidth && el.naturalWidth > 0 ? el.naturalWidth : 1024;
795
+ const height = el?.naturalHeight && el.naturalHeight > 0 ? el.naturalHeight : 1024;
796
+ return { width, height, ...(el !== null ? { source: el } : {}) };
797
+ };
798
+
799
+ /** B 档:本地旋转 90° → 上传 att_ → register(乐观回流画廊)。 */
800
+ const rotateAndRegister = React.useCallback(async (): Promise<void> => {
801
+ if (upload === undefined) return;
802
+ const src: ImageSourceLike = sourceSize();
803
+ const opts = canvasFactory !== undefined ? { canvasFactory } : {};
804
+ const dataUri = rotateImage(src, 90, opts);
805
+ setBusy(true);
806
+ try {
807
+ const { attachmentId } = await uploadDataUri({
808
+ dataUri,
809
+ name: `rotate-${current.name}`,
810
+ baseUrl: baseUrl ?? "",
811
+ sessionId: sessionId ?? "",
812
+ upload,
813
+ });
814
+ // 退化态(无 surface)→ 仅本地呈现,不 register(Req 9.3)。
815
+ if (available && surface !== undefined) {
816
+ await settleWindow(
817
+ surface.run(DOMAIN, "register", {
818
+ attachmentId,
819
+ derivedFrom: current.attachmentId,
820
+ genParams: { op: "rotate", degrees: 90 },
821
+ }),
822
+ );
823
+ }
824
+ } finally {
825
+ setBusy(false);
826
+ }
827
+ }, [upload, canvasFactory, current, baseUrl, sessionId, available, surface]);
828
+
829
+ // 扩展后画布(hasExpand 时 wrapper 按 extended 布局,原图/overlay/图层带偏移定位)。
830
+ const ext = natural !== null ? expandedSize({ width: natural.w, height: natural.h }, expand) : null;
831
+ const expanding = hasExpand(expand);
832
+
833
+ const maskReady = hasMaskContent(strokes) && upload !== undefined;
834
+ const expandReady = expanding && upload !== undefined;
835
+ // 切换模型令已选尺寸落在新模型受支持集外 → 复位为 ""(跟随原图),不静默发不支持组合(4.3)。
836
+ // 仅在 model 真正切换时触发(prevModelRef 守卫):同模型下的自定义 W×H 输入/尺寸选择不受影响。
837
+ const prevModelRef = React.useRef(model);
838
+ React.useEffect(() => {
839
+ if (model === prevModelRef.current) return;
840
+ prevModelRef.current = model;
841
+ if (ratioSize === "") return;
842
+ const supported = capabilities?.models.find((m) => m.id === model)?.sizes;
843
+ if (supported !== undefined && !supported.includes(ratioSize)) setRatioSize("");
844
+ }, [model, capabilities, ratioSize]);
845
+
846
+ // 快照能力清单(agent 权威,4.4/4.5 门控用;缺失→空清单 = command 动作全排除)。经响应式
847
+ // capabilities 态派生,决策输入与模型/尺寸选项消费(见底部提示词栏)同源(task 3.3)。
848
+ const snapshotCapability = capabilities ?? EMPTY_CAPABILITY;
849
+ // 决策预览(生成按钮的动作/文案;inpaint 的 mask 与标注拍平图在 generate 时才上传)。经注册表
850
+ // 评分制决策(kernel.registry.actions),胜者插件 id 经 toGenerateDecision 映射回 GenerateDecision
851
+ // ——data-canvas-action 与 ACTION_LABEL 消费面零变(内置六动作 label 逐字等同 ACTION_LABEL)。
852
+ const previewResolved = resolveAction(kernel.registry.actions, {
853
+ imageId: current.attachmentId,
854
+ prompt,
855
+ model,
856
+ size: ratioSize,
857
+ variants: variantsN,
858
+ hasMask: maskReady,
859
+ hasExpand: expandReady,
860
+ referenceIds: annotations.length > 0 && upload !== undefined ? ["__anno__", ...refs] : refs,
861
+ capability: snapshotCapability,
862
+ });
863
+ const decisionPreview: GenerateDecision =
864
+ previewResolved !== null
865
+ ? toGenerateDecision(previewResolved.plugin.id, previewResolved.args)
866
+ : fallbackEditDecision({ imageId: current.attachmentId, prompt, model, size: ratioSize });
867
+
868
+ /**
869
+ * 解读:对当前工作图提问,经**与生成相同的对话通道**发出(spec canvas-vision-readout)。
870
+ *
871
+ * 与 {@link generate} 刻意保持平行且互不干扰(Req 4.2/4.3/4.4):
872
+ * - **不进入** `resolveAction` 评分制 —— 解读由显式按钮触发,不与生成动作竞争;
873
+ * - **不调用** `consumeSent` —— 掩码 / 参考图 / 标注只服务生成,解读只看当前工作图,
874
+ * 点击后它们原样保留,供后续生成使用;
875
+ * - **不上传**标注拍平图;
876
+ * - 发出后**保留**输入框文字(与生成按钮既有行为一致,Req 1.4)。
877
+ *
878
+ * 结论由 `image_vision` 工具产出并回流对话记录(工具卡 + 助手回复),
879
+ * Canvas 侧不另建结论展示区(Req 2.4),也不解释失败(Req 5.1 交给工具)。
880
+ */
881
+ const readout = React.useCallback(async (): Promise<void> => {
882
+ if (bridge.opChannel !== "prompt") return;
883
+ setBusy(true);
884
+ try {
885
+ await bridge.submitOp(
886
+ buildVisionOp({
887
+ imageId: current.attachmentId,
888
+ question: prompt,
889
+ // 空偏好 → 不带 model 参数 → 工具弹层选择(3.4);非空 → 直接用(3.3)。
890
+ ...(visionModel !== "" ? { model: visionModel } : {}),
891
+ }),
892
+ );
893
+ } finally {
894
+ setBusy(false);
895
+ }
896
+ }, [bridge, current.attachmentId, prompt, visionModel]);
897
+
898
+ /** 主生成:按决策发对应 A 档动作;掩码/标注产物先经上传接缝落 att_。 */
899
+ const generate = React.useCallback(async (): Promise<void> => {
900
+ if (!available || surface === undefined) return;
901
+ setBusy(true);
902
+ try {
903
+ // 标注拍平 → 批注参考图 att_(标注即指令,并入 reference_images 首位)。
904
+ const referenceIds: string[] = [...refs];
905
+ if (annotations.length > 0 && upload !== undefined) {
906
+ const src = sourceSize();
907
+ const opts = canvasFactory !== undefined ? { canvasFactory } : {};
908
+ const annoUri = annotationsToImage(src, annotations, opts);
909
+ const { attachmentId: annoId } = await uploadDataUri({
910
+ dataUri: annoUri,
911
+ name: `anno-${current.name}`,
912
+ baseUrl: baseUrl ?? "",
913
+ sessionId: sessionId ?? "",
914
+ upload,
915
+ });
916
+ referenceIds.unshift(annoId);
917
+ }
918
+ // 注册表评分制决策(task 3.2):actionInput.capability = 快照能力(缺失→空清单,command
919
+ // 动作全排除,内置六 prompt 动作不受门控);胜者插件 id 经 toGenerateDecision 映射回
920
+ // decision——下游通道选择/资产编排/consumeSent/settleWindow 结构逐行零变。
921
+ const resolved = resolveAction(kernel.registry.actions, {
922
+ imageId: current.attachmentId,
923
+ prompt,
924
+ model,
925
+ size: ratioSize,
926
+ variants: variantsN,
927
+ hasMask: hasMaskContent(strokes) && upload !== undefined,
928
+ hasExpand: hasExpand(expand) && upload !== undefined,
929
+ referenceIds,
930
+ capability: surface.getState<GalleryState>(STATE_KEY)?.capabilities ?? EMPTY_CAPABILITY,
931
+ });
932
+ const decision: GenerateDecision =
933
+ resolved !== null
934
+ ? toGenerateDecision(resolved.plugin.id, resolved.args)
935
+ : fallbackEditDecision({ imageId: current.attachmentId, prompt, model, size: ratioSize });
936
+
937
+ // 消费快照:只清「本次发送时存在的」引用/标注/笔迹 —— 飞行期间用户可能已新加,
938
+ // 全量清空会把新输入吞掉(竞争)。
939
+ const sentRefs = new Set(refs);
940
+ const sentAnnos = new Set(annotations);
941
+ const sentStrokes = new Set(strokes);
942
+ const consumeSent = (withStrokes: boolean): void => {
943
+ setRefs((prev) => prev.filter((r) => !sentRefs.has(r)));
944
+ // history.prune = 按谓词过滤 ops + 清空重做栈(原 setOps(filter)+setRedoOps([]))。
945
+ kernel.history.prune((o) => {
946
+ if (o.kind === "anno") return !sentAnnos.has(o.item as Annotation);
947
+ return withStrokes ? !sentStrokes.has(o.item as MaskStroke) : true;
948
+ });
949
+ };
950
+
951
+ // 插件 command 动作(task 3.4 remediation;design System Flows「按胜者 via 分道」):
952
+ // 胜出插件声明 via:"command" → 直接经命令通道派发其声明命令(capability.actions 门控已在
953
+ // resolveAction 内保证白名单命中),不塌缩为 GenerateDecision(六内置 id 之外映射会回退
954
+ // edit,曾致插件命令动作永远走 image_edit 对话流)。内置六动作全 via:"prompt",不进此分支
955
+ // ——下游既有编排逐行零变。引用消费与 reference 同构(参考图已随命令发出)。
956
+ if (resolved !== null && resolved.plugin.execution.via === "command") {
957
+ await settleWindow(
958
+ surface.run(DOMAIN, resolved.plugin.execution.command, resolved.args),
959
+ );
960
+ consumeSent(false);
961
+ return;
962
+ }
963
+
964
+ if (decision.action === "outpaint" && upload !== undefined) {
965
+ // 扩图:本地合成「大画布+原图居位」输入图 + alpha mask(扩展区透明=生成)。
966
+ const src = sourceSize();
967
+ const opts = canvasFactory !== undefined ? { canvasFactory } : {};
968
+ const bigUri = outpaintImage(src, expand, opts);
969
+ const maskUri = outpaintMask({ width: src.width, height: src.height }, expand, opts);
970
+ const [big, maskUp] = await Promise.all([
971
+ uploadDataUri({
972
+ dataUri: bigUri,
973
+ name: `outpaint-${current.name}`,
974
+ baseUrl: baseUrl ?? "",
975
+ sessionId: sessionId ?? "",
976
+ upload,
977
+ }),
978
+ uploadDataUri({
979
+ dataUri: maskUri,
980
+ name: `outpaint-mask-${current.name}`,
981
+ baseUrl: baseUrl ?? "",
982
+ sessionId: sessionId ?? "",
983
+ upload,
984
+ }),
985
+ ]);
986
+ const args: Record<string, unknown> = {
987
+ ...decision.args,
988
+ image: big.attachmentId,
989
+ prompt:
990
+ prompt.trim() !== ""
991
+ ? prompt
992
+ : "向外自然延展画面内容,与原图风格/光影无缝衔接",
993
+ };
994
+ if (bridge.opChannel === "prompt") {
995
+ void bridge.submitOp(
996
+ buildSurfaceOp({ action: "outpaint", args }, { maskId: maskUp.attachmentId }),
997
+ );
998
+ } else {
999
+ await settleWindow(
1000
+ surface.run(DOMAIN, "outpaint", { ...args, mask: maskUp.attachmentId }),
1001
+ );
1002
+ }
1003
+ kernel.prefs.set(PREF_EXPAND_EDGES, NO_EXPAND);
1004
+ return;
1005
+ }
1006
+
1007
+ if (decision.action === "inpaint" && upload !== undefined) {
1008
+ const src = sourceSize();
1009
+ const opts = canvasFactory !== undefined ? { canvasFactory } : {};
1010
+ const maskUri = strokesToMask({ width: src.width, height: src.height }, strokes, opts);
1011
+ // 回贴任务的输入快照:笔迹 + 基图 + 发命令**前**的资产 id 集(见 composeInpaintBack)。
1012
+ const strokesSnapshot = strokes;
1013
+ const baseSnapshot = current;
1014
+ const knownIds = new Set(
1015
+ (surface.getState<GalleryState>(STATE_KEY)?.assets ?? []).map((a) => a.attachmentId),
1016
+ );
1017
+ const { attachmentId: maskId } = await uploadDataUri({
1018
+ dataUri: maskUri,
1019
+ name: `mask-${current.name}`,
1020
+ baseUrl: baseUrl ?? "",
1021
+ sessionId: sessionId ?? "",
1022
+ upload,
1023
+ });
1024
+ if (bridge.opChannel === "prompt") {
1025
+ // 走对话流(A 方案):LLM 调 image_edit,操作回流对话历史。
1026
+ // ⚠回贴暂不随此路径启动:工具产物经轮末 sync 收编,快照资产无 derivedFrom=基图
1027
+ // 锚点,waitForNewDerivedAsset 匹配不到(误配风险大于收益);恢复待工具结果 meta 带血缘。
1028
+ void bridge.submitOp(buildSurfaceOp(decision, { maskId }));
1029
+ consumeSent(true);
1030
+ return;
1031
+ }
1032
+ await settleWindow(
1033
+ surface.run(DOMAIN, "inpaint", { ...decision.args, mask: maskId }),
1034
+ );
1035
+ // 掩码已消费:清空;像素级局部化交给后台回贴(不阻塞 busy)。
1036
+ consumeSent(true);
1037
+ void composeInpaintBack({
1038
+ surface,
1039
+ baseId: baseSnapshot.attachmentId,
1040
+ baseDisplayUrl: baseSnapshot.displayUrl,
1041
+ baseName: baseSnapshot.name,
1042
+ strokes: strokesSnapshot,
1043
+ prompt,
1044
+ knownIds,
1045
+ upload,
1046
+ uploadBaseUrl: baseUrl ?? "",
1047
+ sessionId: sessionId ?? "",
1048
+ ...(canvasFactory !== undefined ? { canvasFactory } : {}),
1049
+ imageLoader: imageLoader ?? defaultImageLoader,
1050
+ });
1051
+ return;
1052
+ }
1053
+
1054
+ if (bridge.opChannel === "prompt") {
1055
+ // 走对话流(A 方案默认):组装 image_edit 指令为用户消息,LLM 调工具执行。
1056
+ void bridge.submitOp(buildSurfaceOp(decision));
1057
+ if (decision.action === "reference") consumeSent(false);
1058
+ return;
1059
+ }
1060
+ // 回退:旁路 surface 命令(不过 LLM;兼容未接 Prompt 通道的宿主/测试)。
1061
+ await settleWindow(surface.run(DOMAIN, decision.action, decision.args));
1062
+ if (decision.action === "reference") consumeSent(false);
1063
+ } finally {
1064
+ setBusy(false);
1065
+ }
1066
+ }, [available, surface, refs, annotations, strokes, expand, upload, canvasFactory, current, baseUrl, sessionId, prompt, model, variantsN, ratioSize, imageLoader, bridge, kernel]);
1067
+
1068
+ /** 版本条选中 → 切工作图 + 复用其参数(预填表单 + 通知宿主)。 */
1069
+ const selectAsset = React.useCallback(
1070
+ (a: GalleryAsset): void => {
1071
+ setCurrentId(a.attachmentId);
1072
+ const gp = a.genParams as Record<string, unknown> | undefined;
1073
+ if (gp !== undefined) {
1074
+ if (typeof gp.prompt === "string") setPrompt(gp.prompt);
1075
+ if (typeof gp.model === "string") setModel(gp.model);
1076
+ onReuseParams?.(a);
1077
+ }
1078
+ },
1079
+ [onReuseParams],
1080
+ );
1081
+
1082
+ // ── 指针唯一入口(4.2:PointerRouter;命中判定经 DOM data-* 标记,散点分支拆除)──
1083
+ // 容器级 pointer 事件全量喂路由:层/手柄/overlay/stage 命中互斥会话,双事件守卫内建
1084
+ // (onMouseDown stopPropagation 补丁族结构性根治);capture 经 env 接缝在 down 目标上
1085
+ // 设置,后续事件钉住目标冒泡回容器,扩图手柄小目标拖动续流(硬账⑥)。
1086
+ const routerEvent = (e: React.PointerEvent): RouterPointerEvent => ({
1087
+ pointerId: e.pointerId,
1088
+ clientX: e.clientX,
1089
+ clientY: e.clientY,
1090
+ target: e.target as Element | null,
1091
+ });
1092
+
1093
+ // ── M3:图层(加/拖放/变换/拍平;全 B 档本地)──────────────────────────────────
1094
+ const loader = imageLoader ?? defaultImageLoader;
1095
+
1096
+ /** 加一层:初始宽 = 底图宽 40%,高按图像纵横比(加载后修正);落点居中(缺省底图中心)。 */
1097
+ const addLayer = React.useCallback(
1098
+ (
1099
+ att: { attachmentId: string; displayUrl: string },
1100
+ at?: { x: number; y: number },
1101
+ meta?: { kind: string; data?: unknown },
1102
+ ): void => {
1103
+ // 初始几何/占位策略/id 序列归 layers 内核(natural 未量到由 store 退化 1024 占位)。
1104
+ const id = kernel.layers.add(att, at ?? null, natural, meta ?? null);
1105
+ // 插件图层(kind 声明)以 Render 呈现,无位图源;不走图像 loader(displayUrl 为空)。
1106
+ if (meta !== undefined) return;
1107
+ void loader(att.displayUrl)
1108
+ .then((img) => kernel.layers.markLoaded(id, img))
1109
+ .catch(() => {
1110
+ // 加载失败:层保留占位(拍平时跳过;markLoaded 不调用)。
1111
+ });
1112
+ },
1113
+ [kernel, natural, loader],
1114
+ );
1115
+
1116
+ /**
1117
+ * 放置插件图层(task 3.2「点击置层」seam):贴纸类工具激活期舞台按下 → 据工具 createLayer
1118
+ * 声明在按下处新建一个插件图层(工具上下文 layers 只读,写路径归装配层)。坐标经 overlay
1119
+ * rect 换算底图像素(rect 不可得 → 落点居中,由 store 退化处理)。
1120
+ */
1121
+ const placePluginLayer = React.useCallback(
1122
+ (create: { kind: string; data?: unknown }, e: React.PointerEvent): void => {
1123
+ const cv = overlayRef.current;
1124
+ let at: { x: number; y: number } | undefined;
1125
+ if (cv !== null && natural !== null) {
1126
+ const rect = cv.getBoundingClientRect();
1127
+ if (rect.width > 0) {
1128
+ at = {
1129
+ x: ((e.clientX - rect.left) / rect.width) * natural.w,
1130
+ y: ((e.clientY - rect.top) / rect.height) * natural.h,
1131
+ };
1132
+ }
1133
+ }
1134
+ addLayer({ attachmentId: "", displayUrl: "" }, at, { kind: create.kind, data: create.data });
1135
+ },
1136
+ [addLayer, natural],
1137
+ );
1138
+
1139
+ /**
1140
+ * 插件图层 Inspector 编辑回写(task 3.2,Req 1.3/1.6):更新该层 data 并把「旧值→新值」
1141
+ * 提交为 LAYER_DATA_OP 进统一 undo 栈(装配期已注册 revert/apply 写回)。
1142
+ */
1143
+ const onInspectorUpdate = React.useCallback(
1144
+ (id: string, data: unknown): void => {
1145
+ const prev = kernel.layers.get(id)?.data;
1146
+ kernel.layers.updateData(id, data);
1147
+ kernel.history.commit({ kind: LAYER_DATA_OP, item: { layerId: id, prev, next: data } });
1148
+ },
1149
+ [kernel],
1150
+ );
1151
+
1152
+ /** OS 图片文件 → 上传接缝落 att_ → register 进画廊(origin=upload)→ 成层。拖放与粘贴共用。 */
1153
+ const importFile = React.useCallback(
1154
+ (file: File, at?: { x: number; y: number }): void => {
1155
+ if (upload === undefined) return;
1156
+ void upload(baseUrl ?? "", sessionId ?? "", file).then(async (res) => {
1157
+ if (available && surface !== undefined) {
1158
+ await settleWindow(
1159
+ surface.run(DOMAIN, "register", { attachmentId: res.attachment.id }),
1160
+ );
1161
+ }
1162
+ addLayer({ attachmentId: res.attachment.id, displayUrl: res.displayUrl }, at);
1163
+ });
1164
+ },
1165
+ [upload, baseUrl, sessionId, available, surface, addLayer],
1166
+ );
1167
+
1168
+ /** stage drop:画廊资产(text/att-id)或 OS 文件(经上传接缝落 att_ 并 register)。 */
1169
+ const onStageDrop = (e: React.DragEvent): void => {
1170
+ e.preventDefault();
1171
+ const cv = overlayRef.current;
1172
+ if (cv === null || natural === null) return;
1173
+ const rect = cv.getBoundingClientRect();
1174
+ const at =
1175
+ rect.width > 0
1176
+ ? {
1177
+ x: ((e.clientX - rect.left) / rect.width) * natural.w,
1178
+ y: ((e.clientY - rect.top) / rect.height) * natural.h,
1179
+ }
1180
+ : undefined;
1181
+ const attId = e.dataTransfer.getData("text/att-id");
1182
+ if (attId !== "") {
1183
+ const a = assets.find((x) => x.attachmentId === attId);
1184
+ if (a !== undefined) addLayer(a, at);
1185
+ return;
1186
+ }
1187
+ // OS 文件:落 att_ → 成层。
1188
+ const file = e.dataTransfer.files?.[0];
1189
+ if (file !== undefined) importFile(file, at);
1190
+ };
1191
+
1192
+ /**
1193
+ * 粘贴图片(Cmd/Ctrl+V):剪贴板含图片文件 → 复用 importFile 落图层(居中)。
1194
+ * document 级监听(舞台 div 不可聚焦难收 paste 事件),仅在剪贴板**含图片**时接管——
1195
+ * 纯文本粘贴(如往提示词框)不受影响;无上传接缝时静默降级。
1196
+ */
1197
+ React.useEffect(() => {
1198
+ if (upload === undefined) return undefined;
1199
+ const onPaste = (e: ClipboardEvent): void => {
1200
+ const items = e.clipboardData?.items;
1201
+ if (items === undefined) return;
1202
+ for (const it of Array.from(items)) {
1203
+ if (it.kind === "file" && it.type.startsWith("image/")) {
1204
+ const file = it.getAsFile();
1205
+ if (file !== null) {
1206
+ e.preventDefault();
1207
+ importFile(file); // 无坐标 → 舞台居中
1208
+ return;
1209
+ }
1210
+ }
1211
+ }
1212
+ };
1213
+ document.addEventListener("paste", onPaste);
1214
+ return () => document.removeEventListener("paste", onPaste);
1215
+ }, [upload, importFile]);
1216
+
1217
+ /** 拍平:底图 + 依序各层 → 上传 att_ → register(derivedFrom=底图)→ 清层。 */
1218
+ const flatten = React.useCallback(async (): Promise<void> => {
1219
+ if (upload === undefined || layers.length === 0) return;
1220
+ setBusy(true);
1221
+ try {
1222
+ // 确保所有图像层已加载(占位未完成的现场补载;失败层跳过)。插件图层(kind 声明)无位图源,
1223
+ // 不走 loader —— 拍平时经插件 bake 烤入(下)。
1224
+ const resolved = await Promise.all(
1225
+ layers.map(async (l) => {
1226
+ if (l.kind !== undefined || l.loaded !== undefined) return l;
1227
+ try {
1228
+ const img = await loader(l.displayUrl);
1229
+ return { ...l, loaded: img } as WorkLayer;
1230
+ } catch {
1231
+ return l;
1232
+ }
1233
+ }),
1234
+ );
1235
+ const src = sourceSize();
1236
+ const opts = canvasFactory !== undefined ? { canvasFactory } : {};
1237
+ // 合成输入:依序各层 → 插件图层(kind 命中 registry.layers)经 bake 烤进 per-layer 画布
1238
+ // (bake 抛错跳过该层 + 诊断,不阻塞产物,Req 1.4);图像图层保持 loaded.source(既有
1239
+ // drawImage 逐字节零变)。per-layer 画布经注入 canvasFactory(jsdom fake / 生产 DOM)。
1240
+ const mkCanvas =
1241
+ canvasFactory ?? (() => document.createElement("canvas") as unknown as CanvasLike);
1242
+ const flattenInputs: Array<{
1243
+ source: CanvasImageSource | CanvasLike;
1244
+ x: number;
1245
+ y: number;
1246
+ w: number;
1247
+ h: number;
1248
+ }> = [];
1249
+ for (const l of resolved) {
1250
+ const plugin =
1251
+ l.kind !== undefined
1252
+ ? kernel.registry.layers.find((p) => p.type === l.kind)
1253
+ : undefined;
1254
+ if (plugin !== undefined) {
1255
+ const bw = Math.max(1, Math.round(l.w));
1256
+ const bh = Math.max(1, Math.round(l.h));
1257
+ const cv = mkCanvas();
1258
+ cv.width = bw;
1259
+ cv.height = bh;
1260
+ const bctx = cv.getContext("2d");
1261
+ if (bctx === null) continue;
1262
+ try {
1263
+ await plugin.bake(bctx, l, { w: bw, h: bh });
1264
+ flattenInputs.push({ source: cv, x: l.x, y: l.y, w: l.w, h: l.h });
1265
+ } catch (err) {
1266
+ // bake 抛错:跳过该层 + 诊断(kind:"plugin"),产物不阻塞(与 hydrate 退化同哲学)。
1267
+ kernel.registry.recordPluginDiagnostic(l.kind!, `图层拍平 bake 抛错: ${String(err)}`);
1268
+ }
1269
+ } else if (l.loaded !== undefined) {
1270
+ flattenInputs.push({ source: l.loaded.source, x: l.x, y: l.y, w: l.w, h: l.h });
1271
+ }
1272
+ }
1273
+ const uri = flattenLayers(src, flattenInputs, opts);
1274
+ const { attachmentId } = await uploadDataUri({
1275
+ dataUri: uri,
1276
+ name: `flatten-${current.name}`,
1277
+ baseUrl: baseUrl ?? "",
1278
+ sessionId: sessionId ?? "",
1279
+ upload,
1280
+ });
1281
+ if (available && surface !== undefined) {
1282
+ await settleWindow(
1283
+ surface.run(DOMAIN, "register", {
1284
+ attachmentId,
1285
+ derivedFrom: current.attachmentId,
1286
+ genParams: { op: "flatten", layers: layers.map((l) => l.attachmentId) },
1287
+ }),
1288
+ );
1289
+ }
1290
+ kernel.layers.clear();
1291
+ } finally {
1292
+ setBusy(false);
1293
+ }
1294
+ }, [upload, layers, loader, canvasFactory, current, baseUrl, sessionId, available, surface, kernel]);
1295
+
1296
+ // 嗅探:真实像素尺寸(natural);扩展时显示扩展后画布。
1297
+ const sniff =
1298
+ natural !== null
1299
+ ? expanding && ext !== null
1300
+ ? `${natural.w}×${natural.h} → ${ext.width}×${ext.height}`
1301
+ : `${natural.w}×${natural.h}`
1302
+ : undefined;
1303
+ const summary = [...(sniff !== undefined ? [sniff] : []), ...summarizeGenParams(current)].join(" · ");
1304
+ const genDisabled = !available || busy;
1305
+
1306
+ // ── 区块:Header ────────────────────────────────────────────────────────────
1307
+ const header = (
1308
+ <Card className="flex items-center gap-2 p-2">
1309
+ <Button variant="ghost" size="icon" data-canvas-workbench-close onClick={onClose} aria-label="返回画廊">
1310
+ <ArrowLeft className="h-4 w-4" />
1311
+ </Button>
1312
+ <div className="min-w-0 flex-1">
1313
+ <div className="truncate text-sm font-medium">{current.name}</div>
1314
+ {summary !== "" ? (
1315
+ <div className="truncate text-xs text-[hsl(var(--muted-foreground))]">{summary}</div>
1316
+ ) : null}
1317
+ </div>
1318
+ {onBringToConversation !== undefined ? (
1319
+ <Button
1320
+ variant="ghost"
1321
+ size="sm"
1322
+ data-canvas-bring-to-conversation
1323
+ onClick={() => onBringToConversation(current.attachmentId)}
1324
+ >
1325
+ <MessageSquarePlus className="mr-1 h-4 w-4" />
1326
+ 带入对话
1327
+ </Button>
1328
+ ) : null}
1329
+ </Card>
1330
+ );
1331
+
1332
+ // ── 区块:垂直版本条(左沿浮动层;点缩略切换工作图)────────────────────────────
1333
+ const versionRail = (
1334
+ <div
1335
+ data-canvas-version-rail
1336
+ className={cn(
1337
+ "pi-scrollbar-thin absolute left-2 top-2 z-10 flex max-h-[calc(100%-4.5rem)] w-[68px] flex-col gap-2 overflow-y-auto p-1",
1338
+ FLOAT_LAYER,
1339
+ )}
1340
+ >
1341
+ <div className="px-0.5 text-[10px] font-medium text-[hsl(var(--muted-foreground))]">版本</div>
1342
+ {assets.map((a) => (
1343
+ <div
1344
+ key={a.attachmentId}
1345
+ className="group relative"
1346
+ draggable
1347
+ onDragStart={(e) => {
1348
+ e.dataTransfer.setData("text/att-id", a.attachmentId);
1349
+ e.dataTransfer.effectAllowed = "copy";
1350
+ }}
1351
+ >
1352
+ <button
1353
+ type="button"
1354
+ data-canvas-version-item
1355
+ data-att-id={a.attachmentId}
1356
+ aria-pressed={a.attachmentId === current.attachmentId}
1357
+ onClick={() => selectAsset(a)}
1358
+ className={cn(
1359
+ "relative block aspect-square w-full shrink-0 overflow-hidden rounded-md border transition-[opacity,border-color,box-shadow]",
1360
+ a.attachmentId === current.attachmentId
1361
+ ? "border-[hsl(var(--primary))] ring-2 ring-[hsl(var(--primary))]"
1362
+ : "border-[hsl(var(--border))] opacity-70 hover:opacity-100",
1363
+ )}
1364
+ >
1365
+ {/* eslint-disable-next-line @next/next/no-img-element */}
1366
+ <img src={a.displayUrl} alt={a.name} className="h-full w-full object-cover" loading="lazy" />
1367
+ {a.derivedFrom !== undefined ? (
1368
+ <span className="absolute left-0.5 top-0.5 rounded bg-black/50 px-1 text-[8px] text-white">派生</span>
1369
+ ) : null}
1370
+ </button>
1371
+ {/* ⊕ 加为图层(hover 显;拖拽到舞台等效)。当前工作图不自叠。 */}
1372
+ {a.attachmentId !== current.attachmentId ? (
1373
+ <button
1374
+ type="button"
1375
+ data-canvas-layer-add
1376
+ data-att-id={a.attachmentId}
1377
+ aria-label="加为图层"
1378
+ title="加为图层(或直接拖到画布)"
1379
+ onClick={() => addLayer(a)}
1380
+ className="absolute bottom-0.5 right-0.5 hidden h-4 w-4 items-center justify-center rounded-full bg-black/60 text-[10px] leading-none text-white group-hover:flex"
1381
+ >
1382
+ +
1383
+ </button>
1384
+ ) : null}
1385
+ </div>
1386
+ ))}
1387
+ </div>
1388
+ );
1389
+
1390
+ // ── 区块:右侧工具轨(4.2:map registry.tools 注册表驱动;新工具注册自动纳入)────
1391
+ // 掩码工具需要 A 档 inpaint(available)+ 上传接缝;标注同理(拍平图需上传)。缺一禁用。
1392
+ const maskToolsDisabled = !available || upload === undefined || busy;
1393
+ const toolRail = (
1394
+ <div
1395
+ data-canvas-tool-rail
1396
+ className={cn(
1397
+ "absolute right-2 top-1/2 z-10 flex w-10 -translate-y-1/2 flex-col items-center gap-1 p-1",
1398
+ FLOAT_LAYER,
1399
+ )}
1400
+ >
1401
+ {kernel.registry.tools.map((t) => (
1402
+ <Button
1403
+ key={t.id}
1404
+ variant={activeToolId === t.id ? "default" : "ghost"}
1405
+ size="icon"
1406
+ className="h-8 w-8"
1407
+ aria-pressed={activeToolId === t.id}
1408
+ aria-label={t.label}
1409
+ title={resolveToolRailTitle(
1410
+ TOOL_RAIL_TITLES[t.id] ?? t.label,
1411
+ t.id,
1412
+ toolsSnap.disabledTools,
1413
+ kernel.registry.diagnostics,
1414
+ // 插件缺依赖恒禁用时取回原因(裁定 B;非插件工具 → undefined,title 逐字节零变)。
1415
+ kernel.registry.disabledPluginToolReason(t.id),
1416
+ )}
1417
+ data-canvas-tool={toolAnchor(t.id)}
1418
+ disabled={
1419
+ (!BACKEND_FREE_TOOLS.has(t.id) && maskToolsDisabled) ||
1420
+ toolsSnap.disabledTools.includes(t.id) ||
1421
+ // 插件缺依赖 → 恒禁用(裁定 B;工具进轨但置灰,tooltip 显缺失项)。
1422
+ kernel.registry.disabledPluginTools.has(t.id)
1423
+ }
1424
+ onClick={() => kernel.tools.setActiveTool(t.id)}
1425
+ >
1426
+ {t.icon}
1427
+ </Button>
1428
+ ))}
1429
+
1430
+ {/* 选项条 = 激活工具贡献(4.2;data-canvas-anno-colors/brush-sizes 锚点由内置实现保持)。 */}
1431
+ {activeCanvasTool?.optionsBar !== undefined
1432
+ ? activeCanvasTool.optionsBar(kernel.tools.context)
1433
+ : null}
1434
+
1435
+ <div className="my-0.5 h-px w-6 bg-[hsl(var(--border))]" />
1436
+ <Button variant="ghost" size="icon" className="h-8 w-8" aria-label="撤销" data-canvas-undo disabled={!history.canUndo} onClick={() => kernel.history.undo()}>
1437
+ <Undo2 className="h-4 w-4" />
1438
+ </Button>
1439
+ <Button variant="ghost" size="icon" className="h-8 w-8" aria-label="重做" data-canvas-redo disabled={!history.canRedo} onClick={() => kernel.history.redo()}>
1440
+ <Redo2 className="h-4 w-4" />
1441
+ </Button>
1442
+ <div className="my-0.5 h-px w-6 bg-[hsl(var(--border))]" />
1443
+ <Button
1444
+ variant="ghost"
1445
+ size="icon"
1446
+ className="h-8 w-8"
1447
+ aria-label="旋转 90°"
1448
+ title="旋转 90°(本地)"
1449
+ data-canvas-b-rotate
1450
+ disabled={busy || upload === undefined}
1451
+ onClick={() => void rotateAndRegister()}
1452
+ >
1453
+ {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCw className="h-4 w-4" />}
1454
+ </Button>
1455
+ </div>
1456
+ );
1457
+
1458
+ // ── 区块:Stage(contain-fit wrapper + overlay 画布 + 缩放胶囊)────────────────
1459
+ // 扩图态四周预留手柄操作区:右侧工具轨/左侧版本条是更高 z 的浮层(wrapper 因 transform
1460
+ // 自成 stacking context,内部手柄 z 压不过它们),贴边时手柄会被盖住不可点。
1461
+ const fitPad = activeToolId === EXPAND_TOOL_ID ? 56 : 0;
1462
+ const fit =
1463
+ ext !== null && stageSize !== null
1464
+ ? Math.min(
1465
+ Math.max(64, stageSize.w - fitPad * 2) / ext.width,
1466
+ Math.max(64, stageSize.h - fitPad * 2) / ext.height,
1467
+ )
1468
+ : null;
1469
+ const wrapperStyle: React.CSSProperties =
1470
+ ext !== null && fit !== null
1471
+ ? {
1472
+ width: ext.width * fit,
1473
+ height: ext.height * fit,
1474
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
1475
+ }
1476
+ : { width: "100%", height: "100%", transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` };
1477
+ // eslint-disable-next-line no-lone-blocks -- (占位:原重复 expanding 定义已上移)
1478
+ /** 原图区在扩展画布内的百分比定位(无扩展时 = 满幅)。 */
1479
+ const baseRect =
1480
+ natural !== null && ext !== null
1481
+ ? {
1482
+ left: `${(expand.left / ext.width) * 100}%`,
1483
+ top: `${(expand.top / ext.height) * 100}%`,
1484
+ width: `${(natural.w / ext.width) * 100}%`,
1485
+ height: `${(natural.h / ext.height) * 100}%`,
1486
+ }
1487
+ : { left: "0%", top: "0%", width: "100%", height: "100%" };
1488
+
1489
+ const zoomPct = Math.round(scale * 100);
1490
+ // 舞台光标:overlay 手势工具的 cursor 施加在 overlay 画布上(见下);舞台级工具
1491
+ // (move)的 cursor 施加在舞台容器上,grab 类在手势中(draft 在场)切 grabbing。
1492
+ const stageCursor =
1493
+ activeCanvasTool !== null && !overlayInteractive && activeCanvasTool.cursor !== undefined
1494
+ ? activeCanvasTool.cursor === "grab" && toolsSnap.draft !== null
1495
+ ? "grabbing"
1496
+ : activeCanvasTool.cursor
1497
+ : undefined;
1498
+ const stage = (
1499
+ <Card
1500
+ ref={stageRef}
1501
+ data-canvas-stage
1502
+ data-canvas-active-tool={activeToolId !== null ? toolAnchor(activeToolId) : undefined}
1503
+ className="canvas-checkerboard relative flex h-full w-full items-center justify-center overflow-hidden p-0"
1504
+ onPointerDown={(e) => {
1505
+ // 插件图层「点击置层」(task 3.2):贴纸类工具声明 createLayer 时,舞台按下先放置一层
1506
+ // (装配层写路径);随后仍交指针路由(插件工具无 onDown,路由无副作用)。
1507
+ if (activeCanvasTool?.createLayer !== undefined) {
1508
+ placePluginLayer(activeCanvasTool.createLayer, e);
1509
+ }
1510
+ kernel.pointer.onPointerDown(routerEvent(e));
1511
+ }}
1512
+ onPointerMove={(e) => kernel.pointer.onPointerMove(routerEvent(e))}
1513
+ onPointerUp={(e) => kernel.pointer.onPointerUp(routerEvent(e))}
1514
+ onPointerCancel={(e) => kernel.pointer.onPointerCancel(routerEvent(e))}
1515
+ onDoubleClick={activeToolId === MOVE_TOOL_ID ? resetView : undefined}
1516
+ onDragOver={(e) => e.preventDefault()}
1517
+ onDrop={onStageDrop}
1518
+ style={{ cursor: stageCursor }}
1519
+ >
1520
+ {/* 流式渐进预览叠层(由糊变清):生成中盖住舞台。有小尺寸预览则显图,否则显指示;
1521
+ 完整渐进图由对话流工具卡承载(4:6 布局下与 Canvas 并列可见)。出终图即清。 */}
1522
+ {livePreview != null ? (
1523
+ <div
1524
+ data-canvas-live-preview
1525
+ data-canvas-live-preview-stage={livePreview.stage}
1526
+ className="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-3 bg-[hsl(var(--background))]/75 backdrop-blur-sm"
1527
+ >
1528
+ {(livePreviewImage ?? livePreview.displayUrl) !== undefined ? (
1529
+ // eslint-disable-next-line @next/next/no-img-element
1530
+ <img
1531
+ src={livePreviewImage ?? livePreview.displayUrl}
1532
+ alt="生成中预览"
1533
+ className="max-h-[80%] max-w-[80%] rounded-md object-contain shadow-lg"
1534
+ />
1535
+ ) : (
1536
+ <Loader2 className="h-8 w-8 animate-spin text-[hsl(var(--primary))]" aria-hidden="true" />
1537
+ )}
1538
+ <div
1539
+ role="status"
1540
+ aria-live="polite"
1541
+ className="flex items-center gap-2 rounded-full bg-[hsl(var(--background))]/90 px-3 py-1 text-xs text-[hsl(var(--muted-foreground))] shadow"
1542
+ >
1543
+ <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
1544
+ {livePreview.stage === "finalizing" ? "正在保存…" : "生成中 · 由糊变清"}
1545
+ </div>
1546
+ </div>
1547
+ ) : null}
1548
+ <div
1549
+ className={cn(
1550
+ "relative shrink-0 will-change-transform",
1551
+ expanding && "outline-dashed outline-1 outline-[hsl(var(--primary))]",
1552
+ )}
1553
+ style={wrapperStyle}
1554
+ >
1555
+ {/* eslint-disable-next-line @next/next/no-img-element */}
1556
+ <img
1557
+ ref={imgRef}
1558
+ data-canvas-workbench-image
1559
+ src={current.displayUrl}
1560
+ alt={current.name}
1561
+ draggable={false}
1562
+ onLoad={() => {
1563
+ const el = imgRef.current;
1564
+ if (el !== null && el.naturalWidth > 0 && el.naturalHeight > 0) {
1565
+ setNatural({ w: el.naturalWidth, h: el.naturalHeight });
1566
+ }
1567
+ }}
1568
+ className="absolute select-none object-fill"
1569
+ style={baseRect}
1570
+ crossOrigin="anonymous"
1571
+ />
1572
+ {/* 图层(底图之上、掩码/标注 overlay 之下;百分比定位随 wrapper 缩放)。 */}
1573
+ {natural !== null
1574
+ ? layers.map((l) => {
1575
+ // 插件图层(task 3.2):kind 命中 registry.layers → 定位容器内渲染插件 Render(替换
1576
+ // img,随视口 scale 缩放);无 kind(或 kind 未注册)走既有 img,逐字节零变(Req 1.5)。
1577
+ const layerPlugin =
1578
+ l.kind !== undefined
1579
+ ? kernel.registry.layers.find((p) => p.type === l.kind)
1580
+ : undefined;
1581
+ const PluginRender = layerPlugin?.Render;
1582
+ return (
1583
+ <div
1584
+ key={l.id}
1585
+ data-canvas-layer
1586
+ data-layer-id={l.id}
1587
+ data-att-id={l.attachmentId}
1588
+ className={cn(
1589
+ "absolute",
1590
+ selectedLayer === l.id
1591
+ ? "z-[2] ring-2 ring-[hsl(var(--primary))]"
1592
+ : "z-[1]",
1593
+ )}
1594
+ style={{
1595
+ left: `${((expand.left + l.x) / (ext?.width ?? natural.w)) * 100}%`,
1596
+ top: `${((expand.top + l.y) / (ext?.height ?? natural.h)) * 100}%`,
1597
+ width: `${(l.w / (ext?.width ?? natural.w)) * 100}%`,
1598
+ height: `${(l.h / (ext?.height ?? natural.h)) * 100}%`,
1599
+ cursor: "move",
1600
+ }}
1601
+ // 层拖拽/缩放 = 工具无关内核手势:pointer 事件冒泡到舞台容器经路由
1602
+ // 命中 data-canvas-layer 标记分派(独占会话,双事件补丁族不再需要)。
1603
+ >
1604
+ {PluginRender !== undefined ? (
1605
+ <div data-canvas-plugin-layer className="h-full w-full">
1606
+ <PluginRender layer={l} scale={scale} />
1607
+ </div>
1608
+ ) : (
1609
+ // eslint-disable-next-line @next/next/no-img-element
1610
+ <img
1611
+ src={l.displayUrl}
1612
+ alt=""
1613
+ draggable={false}
1614
+ className="h-full w-full select-none object-fill"
1615
+ crossOrigin="anonymous"
1616
+ />
1617
+ )}
1618
+ {selectedLayer === l.id ? (
1619
+ <span
1620
+ data-canvas-layer-resize
1621
+ aria-label="缩放图层"
1622
+ className="absolute -bottom-1.5 -right-1.5 h-3 w-3 cursor-nwse-resize rounded-sm border border-[hsl(var(--background))] bg-[hsl(var(--primary))]"
1623
+ />
1624
+ ) : null}
1625
+ </div>
1626
+ );
1627
+ })
1628
+ : null}
1629
+ {natural !== null ? (
1630
+ <canvas
1631
+ ref={overlayRef}
1632
+ data-canvas-mask-overlay
1633
+ width={natural.w}
1634
+ height={natural.h}
1635
+ style={{
1636
+ ...baseRect,
1637
+ ...(overlayInteractive && activeCanvasTool?.cursor !== undefined
1638
+ ? { cursor: activeCanvasTool.cursor }
1639
+ : {}),
1640
+ }}
1641
+ className={cn(
1642
+ "absolute z-[3]",
1643
+ // overlayInteractive 门控保持(硬账②):非 overlay 手势工具下 overlay 不夺
1644
+ // 命中(pointer-events-none),move 的舞台平移/层拖拽照常穿透。
1645
+ !overlayInteractive && "pointer-events-none",
1646
+ )}
1647
+ />
1648
+ ) : null}
1649
+ {/* 扩图手柄:四边中点,拖动向外扩(源图像素);仅扩图工具激活时显示。手柄 DOM 留
1650
+ workbench(design 裁定),事件经 data-canvas-expand-handle 标记走路由 —— down 时
1651
+ capture 钉住手柄,后续事件冒泡回容器,小目标拖动续流(硬账⑥)。 */}
1652
+ {activeToolId === EXPAND_TOOL_ID && natural !== null
1653
+ ? (
1654
+ [
1655
+ { edge: "top" as const, cls: "left-1/2 -top-1.5 -translate-x-1/2 cursor-ns-resize" },
1656
+ { edge: "right" as const, cls: "top-1/2 -right-1.5 -translate-y-1/2 cursor-ew-resize" },
1657
+ { edge: "bottom" as const, cls: "left-1/2 -bottom-1.5 -translate-x-1/2 cursor-ns-resize" },
1658
+ { edge: "left" as const, cls: "top-1/2 -left-1.5 -translate-y-1/2 cursor-ew-resize" },
1659
+ ].map((h) => (
1660
+ <span
1661
+ key={h.edge}
1662
+ data-canvas-expand-handle={h.edge}
1663
+ aria-label={`向${h.edge === "top" ? "上" : h.edge === "right" ? "右" : h.edge === "bottom" ? "下" : "左"}扩展`}
1664
+ className={cn(
1665
+ "absolute z-[4] h-3 w-3 rounded-sm border border-[hsl(var(--background))] bg-[hsl(var(--primary))] shadow",
1666
+ h.cls,
1667
+ )}
1668
+ />
1669
+ ))
1670
+ )
1671
+ : null}
1672
+ {/* 激活工具 DOM 叠层贡献(text 编辑器等):挂进与 overlay 画布重合的定位容器
1673
+ (硬账①:natural 百分比定位的等价性前提);容器本身不夺命中,贡献内容自带
1674
+ pointer-events-auto。 */}
1675
+ {natural !== null && activeCanvasTool?.overlayReact !== undefined
1676
+ ? (() => {
1677
+ const contributed = activeCanvasTool.overlayReact(kernel.tools.context);
1678
+ return contributed !== null && contributed !== undefined ? (
1679
+ <div
1680
+ data-canvas-tool-overlay
1681
+ className="pointer-events-none absolute z-20"
1682
+ style={baseRect}
1683
+ >
1684
+ {contributed}
1685
+ </div>
1686
+ ) : null;
1687
+ })()
1688
+ : null}
1689
+ </div>
1690
+ {/* 扩展信息条(扩图态顶部中央;与层浮条并存时下移)。 */}
1691
+ {expanding && ext !== null ? (
1692
+ <div
1693
+ data-canvas-expand-bar
1694
+ className={cn(
1695
+ "absolute left-1/2 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full border border-[hsl(var(--border))] bg-[hsl(var(--background))]/90 px-2.5 py-0.5 text-xs shadow-sm backdrop-blur",
1696
+ layers.length > 0 ? "top-11" : "top-2",
1697
+ )}
1698
+ >
1699
+ <span className="text-[hsl(var(--muted-foreground))]">
1700
+ 扩图 → {ext.width}×{ext.height}
1701
+ </span>
1702
+ <button
1703
+ type="button"
1704
+ data-canvas-expand-reset
1705
+ onClick={() => kernel.prefs.set(PREF_EXPAND_EDGES, NO_EXPAND)}
1706
+ className="text-[hsl(var(--muted-foreground))] underline-offset-2 hover:underline"
1707
+ >
1708
+ 复位
1709
+ </button>
1710
+ </div>
1711
+ ) : null}
1712
+ {/* 图层浮条(有层时顶部中央):拍平/删除选中/清空。 */}
1713
+ {layers.length > 0 ? (
1714
+ <div
1715
+ data-canvas-layer-bar
1716
+ className="absolute left-1/2 top-2 z-20 flex -translate-x-1/2 items-center gap-1 rounded-full border border-[hsl(var(--border))] bg-[hsl(var(--background))]/90 px-2 py-0.5 text-xs shadow-sm backdrop-blur"
1717
+ >
1718
+ <span className="text-[hsl(var(--muted-foreground))]">图层 {layers.length}</span>
1719
+ {selectedLayer !== null ? (
1720
+ <Button
1721
+ variant="ghost"
1722
+ size="sm"
1723
+ className="h-6 px-1.5 text-xs"
1724
+ data-canvas-layer-remove
1725
+ onClick={() => {
1726
+ // remove(选中层)含清选中(:1694 语义在 store 内)。
1727
+ if (selectedLayer !== null) kernel.layers.remove(selectedLayer);
1728
+ }}
1729
+ >
1730
+ 删除选中
1731
+ </Button>
1732
+ ) : null}
1733
+ <Button
1734
+ variant="ghost"
1735
+ size="sm"
1736
+ className="h-6 px-1.5 text-xs"
1737
+ data-canvas-layer-clear
1738
+ onClick={() => kernel.layers.clear()}
1739
+ >
1740
+ 清空
1741
+ </Button>
1742
+ <Button
1743
+ variant="default"
1744
+ size="sm"
1745
+ className="h-6 px-2 text-xs"
1746
+ data-canvas-layer-flatten
1747
+ disabled={busy || upload === undefined}
1748
+ onClick={() => void flatten()}
1749
+ >
1750
+ {busy ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : null}
1751
+ 拍平
1752
+ </Button>
1753
+ </div>
1754
+ ) : null}
1755
+ {/* 插件图层 Inspector 浮层(task 3.2,Req 1.3):选中的插件图层其 kind 命中 registry.layers
1756
+ 且插件有 Inspector 时,贴右上角浮层渲染;编辑经 onInspectorUpdate 回写 data + 进 undo 栈。
1757
+ 非插件图层/无 Inspector → 不渲染(既有图像图层无检查器,零影响)。 */}
1758
+ {(() => {
1759
+ if (selectedLayer === null) return null;
1760
+ const sel = layers.find((l) => l.id === selectedLayer);
1761
+ if (sel === undefined || sel.kind === undefined) return null;
1762
+ const Inspector = kernel.registry.layers.find((p) => p.type === sel.kind)?.Inspector;
1763
+ if (Inspector === undefined) return null;
1764
+ return (
1765
+ <div data-canvas-inspector className={cn("absolute right-2 top-2 z-30 p-2", FLOAT_LAYER)}>
1766
+ <Inspector layer={sel} update={(data) => onInspectorUpdate(sel.id, data)} />
1767
+ </div>
1768
+ );
1769
+ })()}
1770
+ {/* 缩放胶囊:左下角(中央底部让位给浮动提示词栏,右下角避开宿主比例切换器)。 */}
1771
+ <div className="absolute bottom-2 left-2 flex items-center gap-1 rounded-full border border-[hsl(var(--border))] bg-[hsl(var(--background))]/85 px-1 py-0.5 shadow-sm backdrop-blur">
1772
+ <Button variant="ghost" size="icon" className="h-7 w-7" aria-label="缩小" onClick={() => kernel.stage.zoomBy(0.83)}>
1773
+ <Minus className="h-4 w-4" />
1774
+ </Button>
1775
+ <span className="min-w-[3rem] text-center text-xs tabular-nums text-[hsl(var(--muted-foreground))]">{zoomPct}%</span>
1776
+ <Button variant="ghost" size="icon" className="h-7 w-7" aria-label="放大" onClick={() => kernel.stage.zoomBy(1.2)}>
1777
+ <Plus className="h-4 w-4" />
1778
+ </Button>
1779
+ <Button variant="ghost" size="icon" className="h-7 w-7" aria-label="适应" onClick={resetView}>
1780
+ <Maximize2 className="h-4 w-4" />
1781
+ </Button>
1782
+ </div>
1783
+ </Card>
1784
+ );
1785
+
1786
+ // ── 区块:底部提示词栏(舞台上的居中浮动层;@引用 + 参数簇)────────────────────
1787
+ // z-50:宿主右下角面板比例切换器(z-40 绝对定位浮层)与栏尾「生成」按钮在常见视口
1788
+ // 重叠并拦截点击;内容交互优先,提升本栏层级(反向仅遮浮层一角)。
1789
+ // 模型选项优先级(task 3.3,design 裁定):capability > modelOptions prop > DEFAULT。真实 agent
1790
+ // 下发的清单应胜出;prop 是测试/宿主覆盖接缝,退到 capability 之后;二者皆缺才回内置常量。
1791
+ const knownModels = capabilities?.models.map((m) => m.id) ?? modelOptions ?? DEFAULT_MODEL_OPTIONS;
1792
+ // 视觉模型清单:纯由宿主注入(agent 装配期拿不到 modelRegistry,无法经 capabilities 下发)。
1793
+ // 缺省空数组 → 选择器空态,解读仍可用(Req 3.5/3.6/5.4)。
1794
+ const visionModelItems: readonly VisionModelOption[] = visionModelOptions ?? [];
1795
+ // 复用历史参数可能带来清单外的 model:并入候选兜底(否则 Select 显示为空)。
1796
+ const modelItems =
1797
+ model !== "" && !knownModels.includes(model) ? [model, ...knownModels] : knownModels;
1798
+ // 尺寸选项:全局档位取 capability.sizes(缺失回退 RATIO_OPTIONS);当前选中模型在 capability 中
1799
+ // 声明了受支持尺寸集 → 收窄为交集(顺序按全局列表,4.3);未选模型 = 不收窄(守恒)。
1800
+ const globalSizes = capabilities?.sizes ?? RATIO_OPTIONS;
1801
+ const selectedModelSizes =
1802
+ model !== "" ? capabilities?.models.find((m) => m.id === model)?.sizes : undefined;
1803
+ const ratioOptions =
1804
+ selectedModelSizes !== undefined
1805
+ ? globalSizes.filter((r) => selectedModelSizes.includes(r.size))
1806
+ : globalSizes;
1807
+ const refCandidates = assets.filter((a) => a.attachmentId !== current.attachmentId);
1808
+ const editSummaryBits: string[] = [];
1809
+ if (strokes.length > 0) editSummaryBits.push(`掩码 ${strokes.length}`);
1810
+ if (annotations.length > 0) editSummaryBits.push(`标注 ${annotations.length}`);
1811
+
1812
+ const promptBar = (
1813
+ <div className="pointer-events-none absolute inset-x-0 bottom-2 z-50 flex justify-center px-14">
1814
+ <Card
1815
+ data-canvas-prompt-bar
1816
+ className={cn("pointer-events-auto flex w-full max-w-xl flex-col gap-2 p-2", FLOAT_LAYER)}
1817
+ >
1818
+ {/* 降级三态呈现(Req 8.4–8.6,按 bridge.opChannel):prompt 无提示;command 呈现
1819
+ 「操作不进入对话、LLM 不在环」可感知降级(生成仍经控制面旁路 surface.run 执行,
1820
+ 仅不入对话流,故按钮不禁用——见 generate 回退分支与 Req 8.3 既有单测零改动);
1821
+ unavailable 沿用「surface 不可用」横幅。 */}
1822
+ {bridge.opChannel === "unavailable" ? (
1823
+ <div data-canvas-degrade="unavailable" className="text-xs text-[hsl(var(--muted-foreground))]">
1824
+ surface 不可用,仅本地工具可用
1825
+ </div>
1826
+ ) : bridge.opChannel === "command" ? (
1827
+ <div data-canvas-degrade="command" className="text-xs text-[hsl(var(--muted-foreground))]">
1828
+ 操作不进入对话(LLM 不在环)
1829
+ </div>
1830
+ ) : null}
1831
+ <Popover open={refOpen} onOpenChange={setRefOpen}>
1832
+ <PopoverAnchor asChild>
1833
+ <Textarea
1834
+ data-canvas-prompt
1835
+ aria-label="修改描述提示词"
1836
+ value={prompt}
1837
+ onChange={(e) => {
1838
+ const v = e.target.value;
1839
+ setPrompt(v);
1840
+ // 输入 @ 触发引用选择(选中后去掉末尾 @)。
1841
+ if (v.endsWith("@") && refCandidates.length > 0) setRefOpen(true);
1842
+ }}
1843
+ placeholder="描述修改…(@ 引用画廊图;掩码刷圈选后为局部重绘)"
1844
+ rows={2}
1845
+ className="pi-scrollbar-thin resize-none"
1846
+ />
1847
+ </PopoverAnchor>
1848
+ <PopoverContent align="start" side="top" className="w-72 p-2">
1849
+ <div className="mb-1 text-[10px] font-medium text-[hsl(var(--muted-foreground))]">
1850
+ 引用画廊图(并入 reference_images)
1851
+ </div>
1852
+ <div className="grid max-h-56 grid-cols-4 gap-1 overflow-y-auto">
1853
+ {refCandidates.map((a) => (
1854
+ <button
1855
+ key={a.attachmentId}
1856
+ type="button"
1857
+ data-canvas-ref-option
1858
+ data-att-id={a.attachmentId}
1859
+ aria-pressed={refs.includes(a.attachmentId)}
1860
+ onClick={() => {
1861
+ setRefs(
1862
+ refs.includes(a.attachmentId)
1863
+ ? refs.filter((r) => r !== a.attachmentId)
1864
+ : [...refs, a.attachmentId],
1865
+ );
1866
+ if (prompt.endsWith("@")) setPrompt(prompt.slice(0, -1));
1867
+ }}
1868
+ className={cn(
1869
+ "relative aspect-square overflow-hidden rounded border transition-[opacity,border-color,box-shadow]",
1870
+ refs.includes(a.attachmentId)
1871
+ ? "border-[hsl(var(--primary))] ring-2 ring-[hsl(var(--primary))]"
1872
+ : "border-[hsl(var(--border))] opacity-80 hover:opacity-100",
1873
+ )}
1874
+ >
1875
+ {/* eslint-disable-next-line @next/next/no-img-element */}
1876
+ <img src={a.displayUrl} alt={a.name} className="h-full w-full object-cover" loading="lazy" />
1877
+ </button>
1878
+ ))}
1879
+ </div>
1880
+ </PopoverContent>
1881
+ </Popover>
1882
+
1883
+ {refs.length > 0 || editSummaryBits.length > 0 ? (
1884
+ <div className="flex flex-wrap items-center gap-1">
1885
+ {refs.map((id) => {
1886
+ const a = assets.find((x) => x.attachmentId === id);
1887
+ return (
1888
+ <span
1889
+ key={id}
1890
+ data-canvas-ref-chip
1891
+ data-att-id={id}
1892
+ className="flex items-center gap-1 rounded-full border border-[hsl(var(--border))] bg-[hsl(var(--muted)/0.5)] py-0.5 pl-0.5 pr-1.5 text-[10px]"
1893
+ >
1894
+ {a !== undefined ? (
1895
+ // eslint-disable-next-line @next/next/no-img-element
1896
+ <img src={a.displayUrl} alt="" width={16} height={16} className="h-4 w-4 rounded-full object-cover" />
1897
+ ) : null}
1898
+ @…{id.slice(-6)}
1899
+ <button
1900
+ type="button"
1901
+ aria-label="移除引用"
1902
+ onClick={() => setRefs(refs.filter((r) => r !== id))}
1903
+ className="opacity-60 hover:opacity-100"
1904
+ >
1905
+ <X className="h-3 w-3" />
1906
+ </button>
1907
+ </span>
1908
+ );
1909
+ })}
1910
+ {editSummaryBits.length > 0 ? (
1911
+ <button
1912
+ type="button"
1913
+ data-canvas-mask-clear
1914
+ onClick={() => kernel.history.clear()}
1915
+ className="text-[10px] text-[hsl(var(--muted-foreground))] underline-offset-2 hover:underline"
1916
+ >
1917
+ {editSummaryBits.join(" · ")} · 清除
1918
+ </button>
1919
+ ) : null}
1920
+ </div>
1921
+ ) : null}
1922
+
1923
+ <div className="flex items-center gap-1.5">
1924
+ <Select
1925
+ value={model === "" ? MODEL_DEFAULT_SENTINEL : model}
1926
+ onValueChange={(v) => setModel(v === MODEL_DEFAULT_SENTINEL ? "" : v)}
1927
+ >
1928
+ <SelectTrigger data-canvas-model aria-label="生成模型" className="h-8 w-36 text-xs">
1929
+ <SelectValue placeholder="默认模型" />
1930
+ </SelectTrigger>
1931
+ <SelectContent>
1932
+ <SelectItem value={MODEL_DEFAULT_SENTINEL}>默认模型</SelectItem>
1933
+ {modelItems.map((m) => (
1934
+ <SelectItem key={m} value={m}>
1935
+ {m}
1936
+ </SelectItem>
1937
+ ))}
1938
+ </SelectContent>
1939
+ </Select>
1940
+
1941
+ {/* 视觉模型选择器(spec canvas-vision-readout,Req 3.1/3.2/3.5)。
1942
+ ⚠ 取值是 `provider/modelId`(工具 model 参数格式),与上面**生成**模型的裸 id 不同。
1943
+ 清单为空 → 展示空态说明;解读按钮**不受影响**仍可用(载荷不带 model,工具弹层兜底)。 */}
1944
+ <Select
1945
+ value={visionModel === "" ? MODEL_DEFAULT_SENTINEL : visionModel}
1946
+ onValueChange={(v) => {
1947
+ const next = v === MODEL_DEFAULT_SENTINEL ? "" : v;
1948
+ setVisionModel(next);
1949
+ writeVisionModelPref(next);
1950
+ }}
1951
+ >
1952
+ <SelectTrigger
1953
+ data-canvas-vision-model
1954
+ aria-label="视觉模型"
1955
+ className="h-8 w-40 text-xs"
1956
+ >
1957
+ <SelectValue placeholder="视觉模型" />
1958
+ </SelectTrigger>
1959
+ <SelectContent>
1960
+ {visionModelItems.length === 0 ? (
1961
+ <SelectItem value={MODEL_DEFAULT_SENTINEL} disabled>
1962
+ 没有可用的视觉模型
1963
+ </SelectItem>
1964
+ ) : (
1965
+ <>
1966
+ <SelectItem value={MODEL_DEFAULT_SENTINEL}>每次询问</SelectItem>
1967
+ {visionModelItems.map((m) => (
1968
+ <SelectItem key={m.value} value={m.value}>
1969
+ {m.label}
1970
+ </SelectItem>
1971
+ ))}
1972
+ </>
1973
+ )}
1974
+ </SelectContent>
1975
+ </Select>
1976
+
1977
+ {/* 输出尺寸(嗅探+预设比例+自定义)。 */}
1978
+ <Popover open={sizeOpen} onOpenChange={setSizeOpen}>
1979
+ <PopoverAnchor asChild>
1980
+ <button
1981
+ type="button"
1982
+ data-canvas-size-trigger
1983
+ onClick={() => setSizeOpen(true)}
1984
+ className="rounded border border-[hsl(var(--border))] px-1.5 py-1 text-[10px] text-[hsl(var(--muted-foreground))] transition-colors hover:bg-[hsl(var(--muted))]"
1985
+ >
1986
+ 尺寸·
1987
+ {ratioSize === ""
1988
+ ? natural !== null
1989
+ ? `跟随 ${natural.w}×${natural.h}`
1990
+ : "跟随原图"
1991
+ : (globalSizes.find((r) => r.size === ratioSize)?.label ?? ratioSize)}
1992
+ </button>
1993
+ </PopoverAnchor>
1994
+ <PopoverContent align="start" side="top" className="w-56 p-2 text-xs">
1995
+ <div className="mb-1 text-[10px] font-medium text-[hsl(var(--muted-foreground))]">
1996
+ 输出尺寸
1997
+ </div>
1998
+ <div className="flex flex-col gap-0.5">
1999
+ {ratioOptions.map((r) => (
2000
+ <button
2001
+ key={r.label}
2002
+ type="button"
2003
+ data-canvas-ratio={r.label}
2004
+ aria-pressed={ratioSize === r.size}
2005
+ onClick={() => {
2006
+ setRatioSize(r.size);
2007
+ setSizeOpen(false);
2008
+ }}
2009
+ className={cn(
2010
+ "rounded px-1.5 py-1 text-left transition-colors",
2011
+ ratioSize === r.size
2012
+ ? "bg-[hsl(var(--accent))] font-medium"
2013
+ : "hover:bg-[hsl(var(--muted))]",
2014
+ )}
2015
+ >
2016
+ {r.size === ""
2017
+ ? `跟随原图${natural !== null ? `(${natural.w}×${natural.h})` : ""}`
2018
+ : `${r.label}(${r.size})`}
2019
+ </button>
2020
+ ))}
2021
+ <div className="mt-1 flex items-center gap-1 border-t border-[hsl(var(--border))] pt-1.5">
2022
+ <span className="text-[hsl(var(--muted-foreground))]">自定义</span>
2023
+ <Input
2024
+ data-canvas-size-custom-w
2025
+ aria-label="自定义宽度(像素)"
2026
+ inputMode="numeric"
2027
+ value={customW}
2028
+ onChange={(e) => setCustomW(e.target.value.replace(/\D/g, ""))}
2029
+ placeholder="宽"
2030
+ className="h-6 w-14 px-1 text-xs"
2031
+ />
2032
+ ×
2033
+ <Input
2034
+ data-canvas-size-custom-h
2035
+ aria-label="自定义高度(像素)"
2036
+ inputMode="numeric"
2037
+ value={customH}
2038
+ onChange={(e) => setCustomH(e.target.value.replace(/\D/g, ""))}
2039
+ placeholder="高"
2040
+ className="h-6 w-14 px-1 text-xs"
2041
+ />
2042
+ <Button
2043
+ variant="outline"
2044
+ size="sm"
2045
+ className="h-6 px-1.5 text-[10px]"
2046
+ data-canvas-size-apply
2047
+ disabled={customW === "" || customH === ""}
2048
+ onClick={() => {
2049
+ setRatioSize(`${customW}x${customH}`);
2050
+ setSizeOpen(false);
2051
+ }}
2052
+ >
2053
+ 应用
2054
+ </Button>
2055
+ </div>
2056
+ </div>
2057
+ </PopoverContent>
2058
+ </Popover>
2059
+
2060
+ {/* 变体数 stepper。 */}
2061
+ <div className="flex items-center gap-0.5 text-[10px]" aria-label="变体数">
2062
+ <button
2063
+ type="button"
2064
+ data-canvas-variants-dec
2065
+ aria-label="减少变体"
2066
+ disabled={variantsN <= 1}
2067
+ onClick={() => setVariantsN((n) => Math.max(1, n - 1))}
2068
+ className="rounded px-1 py-1 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))] disabled:opacity-40"
2069
+ >
2070
+
2071
+ </button>
2072
+ <span data-canvas-variants-n className="min-w-[1.75rem] text-center tabular-nums">
2073
+ ×{variantsN}
2074
+ </span>
2075
+ <button
2076
+ type="button"
2077
+ data-canvas-variants-inc
2078
+ aria-label="增加变体"
2079
+ disabled={variantsN >= 4}
2080
+ onClick={() => setVariantsN((n) => Math.min(4, n + 1))}
2081
+ className="rounded px-1 py-1 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))] disabled:opacity-40"
2082
+ >
2083
+
2084
+ </button>
2085
+ </div>
2086
+
2087
+ <div className="flex-1" />
2088
+ <Button
2089
+ variant="ghost"
2090
+ size="icon"
2091
+ className="h-8 w-8"
2092
+ aria-label="引用画廊图"
2093
+ data-canvas-ref-trigger
2094
+ disabled={refCandidates.length === 0}
2095
+ onClick={() => setRefOpen(true)}
2096
+ >
2097
+ <AtSign className="h-4 w-4" />
2098
+ </Button>
2099
+ {/* 解读按钮(spec canvas-vision-readout,Req 1.1):与生成按钮并列。
2100
+ 走 prompt 通道即可,**不需要** surface(生成才需 A 档命令探针),
2101
+ 故清单未就绪 / 拉取失败时它仍然可用(Req 3.6/5.4)。 */}
2102
+ <Button
2103
+ variant="outline"
2104
+ size="sm"
2105
+ data-canvas-readout
2106
+ aria-label="解读"
2107
+ title="解读这张图(把输入框文字当作问题)"
2108
+ disabled={bridge.opChannel !== "prompt" || busy}
2109
+ onClick={() => void readout()}
2110
+ >
2111
+ <Eye className="mr-1 h-4 w-4" />
2112
+ 解读
2113
+ </Button>
2114
+ <Button
2115
+ variant="default"
2116
+ size="sm"
2117
+ data-canvas-generate
2118
+ data-canvas-action={
2119
+ // 插件 command 胜者(task 3.4 remediation):锚值=去命名空间的插件 id、标签=插件
2120
+ // label(内置六动作走既有 decision.action/ACTION_LABEL,逐字节零变——builtin 恒
2121
+ // via:"prompt" 不进此分支)。
2122
+ previewResolved !== null && previewResolved.plugin.execution.via === "command"
2123
+ ? (previewResolved.plugin.id.split(":").pop() ?? previewResolved.plugin.id)
2124
+ : decisionPreview.action
2125
+ }
2126
+ disabled={genDisabled}
2127
+ onClick={() => void generate()}
2128
+ >
2129
+ {busy ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Sparkles className="mr-1 h-4 w-4" />}
2130
+ {previewResolved !== null && previewResolved.plugin.execution.via === "command"
2131
+ ? previewResolved.plugin.label
2132
+ : ACTION_LABEL[decisionPreview.action]}
2133
+ </Button>
2134
+ </div>
2135
+ </Card>
2136
+ </div>
2137
+ );
2138
+
2139
+ // 画板最大化:舞台独占 header 下全幅;版本条/工具轨/提示词栏均为舞台上的浮动层。
2140
+ return (
2141
+ <div
2142
+ data-canvas-workbench
2143
+ data-att-id={current.attachmentId}
2144
+ data-canvas-op-channel={bridge.opChannel}
2145
+ className="flex h-full min-h-0 flex-col gap-2 p-2"
2146
+ >
2147
+ {header}
2148
+ <div className="relative min-h-0 flex-1">
2149
+ {stage}
2150
+ {versionRail}
2151
+ {toolRail}
2152
+ {promptBar}
2153
+ </div>
2154
+ </div>
2155
+ );
2156
+ }