@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.
- package/package.json +45 -0
- package/src/aigc-model-meta.tsx +53 -0
- package/src/aigc-quick-settings.tsx +345 -0
- package/src/canvas-gallery.tsx +292 -0
- package/src/canvas-launcher.tsx +218 -0
- package/src/canvas-workbench.tsx +2156 -0
- package/src/client-image-ops.ts +50 -0
- package/src/generate-actions.ts +155 -0
- package/src/index.ts +122 -0
- package/src/lineage-view.tsx +170 -0
- package/src/plugin-aggregation.ts +51 -0
- package/src/styles.css +24 -0
- package/src/use-canvas-view.ts +214 -0
- package/src/vision-op.ts +112 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CanvasGallery — 画廊镜像视图(aigc-canvas · Req 3.x / 8.2 / 9.x)。
|
|
3
|
+
*
|
|
4
|
+
* 经宿主注入的 `surface`(`WebExtSurfaceAccess`,slot 侧等价 `useSurface("canvas")`)镜像
|
|
5
|
+
* `surface:canvas` 快照:
|
|
6
|
+
* - `available === true`(source 注册了 `surface:canvas` 探针)→ 9 宫格默认 + 密度切换
|
|
7
|
+
* (概览 / 瀑布流 / 聚焦)+ 客户端分页 + 血缘 / 时间分组;缩略用签名 `displayUrl`(二进制旁路);
|
|
8
|
+
* 轮末 idle 边沿(`syncSignal` 变化)→ `run("sync")` reconcile。
|
|
9
|
+
* - `available === false`(非 AIGC source)→ 退化只读图库(来源 = 宿主注入的消息历史图片 `historyImages`),
|
|
10
|
+
* A 档禁用、不发命令、不报错(B 档在工作台侧仍本地可用)。
|
|
11
|
+
*
|
|
12
|
+
* slot 组件是独立 bundle,经 prop 注入 surface(领域无关搬运);domain 对宿主不透明。
|
|
13
|
+
*/
|
|
14
|
+
import * as React from "react";
|
|
15
|
+
import { Loader2 } from "lucide-react";
|
|
16
|
+
import type { WebExtSurfaceAccess } from "@blksails/pi-web-kit";
|
|
17
|
+
import { useConversationBridge } from "@blksails/pi-web-react";
|
|
18
|
+
import type { GalleryAsset, GalleryState } from "@blksails/pi-web-tool-kit/aigc-canvas-schema";
|
|
19
|
+
import {
|
|
20
|
+
useCanvasView,
|
|
21
|
+
CANVAS_PAGE_SIZE,
|
|
22
|
+
type CanvasDensity,
|
|
23
|
+
} from "./use-canvas-view.js";
|
|
24
|
+
|
|
25
|
+
const DOMAIN = "canvas";
|
|
26
|
+
const STATE_KEY = `surface:${DOMAIN}`;
|
|
27
|
+
const PROBE = `surface:${DOMAIN}`;
|
|
28
|
+
|
|
29
|
+
const DENSITY_LABEL: Record<CanvasDensity, string> = {
|
|
30
|
+
overview: "概览",
|
|
31
|
+
waterfall: "瀑布流",
|
|
32
|
+
focus: "聚焦",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface CanvasGalleryProps {
|
|
36
|
+
/** 宿主注入的权威 surface 接入(panelRight slot);缺失 / 探针缺失 → 退化。 */
|
|
37
|
+
readonly surface?: WebExtSurfaceAccess;
|
|
38
|
+
/** 退化态图库来源:当前消息历史中的图片附件(宿主已有,无 surface)。 */
|
|
39
|
+
readonly historyImages?: readonly GalleryAsset[];
|
|
40
|
+
/** 轮末 idle 边沿信号:值变化时触发 `run("sync")`(宿主在 onTurnEnd bump)。 */
|
|
41
|
+
readonly syncSignal?: unknown;
|
|
42
|
+
/** 点击格子:展开工作台(由父面板处理)。 */
|
|
43
|
+
readonly onOpenAsset?: (assetId: string) => void;
|
|
44
|
+
/** 宿主转发的当前轮流式图像预览(由糊变清);配合 surface `livePreview` 显示渐进图。 */
|
|
45
|
+
readonly livePreviewImage?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 时间分组 KEY(YYYY-MM-DD;locale 无关,保证分组稳定)。 */
|
|
49
|
+
function dayOf(iso: string): string {
|
|
50
|
+
return iso.slice(0, 10);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 分组标签**显示**格式(经 Intl.DateTimeFormat 本地化;运行时 locale)。 */
|
|
54
|
+
const DAY_LABEL_FMT = new Intl.DateTimeFormat(undefined, {
|
|
55
|
+
year: "numeric",
|
|
56
|
+
month: "long",
|
|
57
|
+
day: "numeric",
|
|
58
|
+
});
|
|
59
|
+
function formatDayLabel(ymd: string): string {
|
|
60
|
+
const d = new Date(`${ymd}T00:00:00`);
|
|
61
|
+
return Number.isNaN(d.getTime()) ? ymd : DAY_LABEL_FMT.format(d);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 血缘分组标签(root att_ = 无 derivedFrom 的祖先;此处以直接 derivedFrom 归组,UI 本地派生)。 */
|
|
65
|
+
function lineageGroupOf(a: GalleryAsset): string {
|
|
66
|
+
return a.derivedFrom ?? a.attachmentId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function CanvasGallery({
|
|
70
|
+
surface,
|
|
71
|
+
historyImages,
|
|
72
|
+
syncSignal,
|
|
73
|
+
onOpenAsset,
|
|
74
|
+
livePreviewImage,
|
|
75
|
+
}: CanvasGalleryProps): React.JSX.Element {
|
|
76
|
+
const view = useCanvasView();
|
|
77
|
+
const available = surface !== undefined && surface.hasCommand(PROBE);
|
|
78
|
+
// 对话桥门面(契约 §4.5):轮末 idle 边沿经 bridge.onTurnEnd 订阅(封装 syncSignal 首见不触发、
|
|
79
|
+
// 仅变化触发的边沿语义);画廊只用 onTurnEnd 一维,无提交/别名。
|
|
80
|
+
const bridge = useConversationBridge({
|
|
81
|
+
...(surface !== undefined ? { surface } : {}),
|
|
82
|
+
syncSignal,
|
|
83
|
+
domain: DOMAIN,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// 镜像快照(available 时);退化时用 historyImages。
|
|
87
|
+
const [snap, setSnap] = React.useState<GalleryState | undefined>(() =>
|
|
88
|
+
surface?.getState<GalleryState>(STATE_KEY),
|
|
89
|
+
);
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
if (surface === undefined) return;
|
|
92
|
+
setSnap(surface.getState<GalleryState>(STATE_KEY));
|
|
93
|
+
return surface.subscribe(STATE_KEY, (v) => setSnap(v as GalleryState | undefined));
|
|
94
|
+
}, [surface]);
|
|
95
|
+
|
|
96
|
+
// 轮末 idle 边沿 → run("sync")(仅 available;bridge.onTurnEnd 封装 syncSignal 边沿)。
|
|
97
|
+
React.useEffect(
|
|
98
|
+
() =>
|
|
99
|
+
bridge.onTurnEnd(() => {
|
|
100
|
+
if (available && surface !== undefined) {
|
|
101
|
+
void surface.run(DOMAIN, "sync");
|
|
102
|
+
}
|
|
103
|
+
}),
|
|
104
|
+
[bridge, available, surface],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const assets: readonly GalleryAsset[] = available
|
|
108
|
+
? (snap?.assets ?? [])
|
|
109
|
+
: (historyImages ?? []);
|
|
110
|
+
|
|
111
|
+
// 客户端分页(over 轻量快照列表)。
|
|
112
|
+
const pageSize = CANVAS_PAGE_SIZE[view.density];
|
|
113
|
+
const pageCount = Math.max(1, Math.ceil(assets.length / pageSize));
|
|
114
|
+
const page = Math.min(view.page, pageCount - 1);
|
|
115
|
+
const pageAssets = assets.slice(page * pageSize, page * pageSize + pageSize);
|
|
116
|
+
|
|
117
|
+
// 分组(UI 本地派生;不发命令)。
|
|
118
|
+
const groups = React.useMemo(() => {
|
|
119
|
+
if (view.group === "none") return [{ key: "", items: pageAssets }];
|
|
120
|
+
const map = new Map<string, GalleryAsset[]>();
|
|
121
|
+
for (const a of pageAssets) {
|
|
122
|
+
const key = view.group === "time" ? dayOf(a.createdAt) : lineageGroupOf(a);
|
|
123
|
+
const arr = map.get(key);
|
|
124
|
+
if (arr === undefined) map.set(key, [a]);
|
|
125
|
+
else arr.push(a);
|
|
126
|
+
}
|
|
127
|
+
return Array.from(map.entries()).map(([key, items]) => ({ key, items }));
|
|
128
|
+
}, [pageAssets, view.group]);
|
|
129
|
+
|
|
130
|
+
const gridClass =
|
|
131
|
+
view.density === "overview"
|
|
132
|
+
? "grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-2"
|
|
133
|
+
: view.density === "waterfall"
|
|
134
|
+
? "columns-[160px] gap-2 [&>*]:mb-2"
|
|
135
|
+
: "mx-auto grid w-full max-w-2xl grid-cols-1 gap-2";
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
data-canvas-gallery
|
|
140
|
+
data-canvas-available={String(available)}
|
|
141
|
+
data-canvas-density={view.density}
|
|
142
|
+
data-canvas-page={String(page)}
|
|
143
|
+
className="flex flex-col gap-2 p-3"
|
|
144
|
+
>
|
|
145
|
+
{/* 密度切换 + 分组(UI 本地)。 */}
|
|
146
|
+
<div className="flex items-center gap-1 text-xs">
|
|
147
|
+
{(Object.keys(DENSITY_LABEL) as CanvasDensity[]).map((d) => (
|
|
148
|
+
<button
|
|
149
|
+
key={d}
|
|
150
|
+
type="button"
|
|
151
|
+
data-canvas-density-btn={d}
|
|
152
|
+
aria-pressed={view.density === d}
|
|
153
|
+
onClick={() => view.setDensity(d)}
|
|
154
|
+
className={`rounded px-1.5 py-0.5 transition-colors ${
|
|
155
|
+
view.density === d
|
|
156
|
+
? "bg-[hsl(var(--accent))] font-medium"
|
|
157
|
+
: "text-[hsl(var(--muted-foreground))]"
|
|
158
|
+
}`}
|
|
159
|
+
>
|
|
160
|
+
{DENSITY_LABEL[d]}
|
|
161
|
+
</button>
|
|
162
|
+
))}
|
|
163
|
+
<span className="mx-1 opacity-30">·</span>
|
|
164
|
+
{(["time", "lineage", "none"] as const).map((g) => (
|
|
165
|
+
<button
|
|
166
|
+
key={g}
|
|
167
|
+
type="button"
|
|
168
|
+
data-canvas-group-btn={g}
|
|
169
|
+
aria-pressed={view.group === g}
|
|
170
|
+
onClick={() => view.setGroup(g)}
|
|
171
|
+
className={`rounded px-1.5 py-0.5 transition-colors ${
|
|
172
|
+
view.group === g
|
|
173
|
+
? "bg-[hsl(var(--accent))] font-medium"
|
|
174
|
+
: "text-[hsl(var(--muted-foreground))]"
|
|
175
|
+
}`}
|
|
176
|
+
>
|
|
177
|
+
{g === "time" ? "时间" : g === "lineage" ? "血缘" : "全部"}
|
|
178
|
+
</button>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{!available ? (
|
|
183
|
+
<div
|
|
184
|
+
data-canvas-degraded
|
|
185
|
+
className="rounded bg-[hsl(var(--muted)/0.4)] px-2 py-1 text-xs text-[hsl(var(--muted-foreground))]"
|
|
186
|
+
>
|
|
187
|
+
只读图库(该 source 未提供 canvas surface;A 档禁用)
|
|
188
|
+
</div>
|
|
189
|
+
) : null}
|
|
190
|
+
|
|
191
|
+
{/* 流式渐进预览指示(由糊变清):生成中在画廊顶部显示;有小预览显图,否则显占位指示。
|
|
192
|
+
完整渐进图由对话流工具卡承载(4:6 布局下与 Canvas 并列可见);出终图即清。 */}
|
|
193
|
+
{snap?.livePreview != null ? (
|
|
194
|
+
<div
|
|
195
|
+
data-canvas-live-preview
|
|
196
|
+
data-canvas-live-preview-stage={snap.livePreview.stage}
|
|
197
|
+
className="relative flex aspect-square w-full max-w-[200px] items-center justify-center gap-2 overflow-hidden rounded-md border border-dashed border-[hsl(var(--primary))] bg-[hsl(var(--muted)/0.3)]"
|
|
198
|
+
>
|
|
199
|
+
{(livePreviewImage ?? snap.livePreview.displayUrl) !== undefined ? (
|
|
200
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
201
|
+
<img
|
|
202
|
+
src={livePreviewImage ?? snap.livePreview.displayUrl}
|
|
203
|
+
alt="生成中预览"
|
|
204
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
205
|
+
/>
|
|
206
|
+
) : (
|
|
207
|
+
<Loader2 className="h-6 w-6 animate-spin text-[hsl(var(--primary))]" aria-hidden="true" />
|
|
208
|
+
)}
|
|
209
|
+
<div
|
|
210
|
+
role="status"
|
|
211
|
+
aria-live="polite"
|
|
212
|
+
className="pointer-events-none absolute bottom-1 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full bg-[hsl(var(--background))]/90 px-2 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))] shadow"
|
|
213
|
+
>
|
|
214
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
|
215
|
+
{snap.livePreview.stage === "finalizing" ? "正在保存…" : "生成中 · 由糊变清"}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
) : null}
|
|
219
|
+
|
|
220
|
+
{assets.length === 0 && snap?.livePreview == null ? (
|
|
221
|
+
<div data-canvas-empty className="px-2 py-6 text-center text-xs text-[hsl(var(--muted-foreground))]">
|
|
222
|
+
暂无图片
|
|
223
|
+
</div>
|
|
224
|
+
) : (
|
|
225
|
+
groups.map((group) => (
|
|
226
|
+
<div key={group.key || "_"} data-canvas-group={group.key}>
|
|
227
|
+
{view.group !== "none" && group.key !== "" ? (
|
|
228
|
+
<div className="mb-1 text-[11px] font-medium text-[hsl(var(--muted-foreground))]">
|
|
229
|
+
{view.group === "time" ? formatDayLabel(group.key) : group.key}
|
|
230
|
+
</div>
|
|
231
|
+
) : null}
|
|
232
|
+
<div className={gridClass}>
|
|
233
|
+
{group.items.map((a) => (
|
|
234
|
+
<button
|
|
235
|
+
key={a.attachmentId}
|
|
236
|
+
type="button"
|
|
237
|
+
data-canvas-cell
|
|
238
|
+
data-att-id={a.attachmentId}
|
|
239
|
+
onClick={() => onOpenAsset?.(a.attachmentId)}
|
|
240
|
+
className="group relative block animate-in fade-in-0 overflow-hidden rounded-md transition-opacity duration-300 hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-[hsl(var(--ring))]"
|
|
241
|
+
>
|
|
242
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
243
|
+
<img
|
|
244
|
+
src={a.displayUrl}
|
|
245
|
+
alt={a.name}
|
|
246
|
+
className="aspect-square w-full object-cover"
|
|
247
|
+
loading="lazy"
|
|
248
|
+
/>
|
|
249
|
+
{a.derivedFrom !== undefined ? (
|
|
250
|
+
<span
|
|
251
|
+
data-canvas-cell-derived
|
|
252
|
+
className="absolute left-1 top-1 rounded bg-black/50 px-1 text-[9px] text-white"
|
|
253
|
+
>
|
|
254
|
+
派生
|
|
255
|
+
</span>
|
|
256
|
+
) : null}
|
|
257
|
+
</button>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
))
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{/* 客户端分页。 */}
|
|
265
|
+
{pageCount > 1 ? (
|
|
266
|
+
<div data-canvas-pager className="flex items-center justify-center gap-2 text-xs">
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
data-canvas-prev
|
|
270
|
+
disabled={page <= 0}
|
|
271
|
+
onClick={() => view.setPage(page - 1)}
|
|
272
|
+
className="rounded px-2 py-0.5 disabled:opacity-40"
|
|
273
|
+
>
|
|
274
|
+
上一页
|
|
275
|
+
</button>
|
|
276
|
+
<span data-canvas-page-indicator>
|
|
277
|
+
{page + 1} / {pageCount}
|
|
278
|
+
</span>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
data-canvas-next
|
|
282
|
+
disabled={page >= pageCount - 1}
|
|
283
|
+
onClick={() => view.setPage(page + 1)}
|
|
284
|
+
className="rounded px-2 py-0.5 disabled:opacity-40"
|
|
285
|
+
>
|
|
286
|
+
下一页
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
) : null}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CanvasLauncher / CanvasPanel — 入口 + 面板挂载(aigc-canvas · Req 10.x)。
|
|
3
|
+
*
|
|
4
|
+
* **由 source 声明驱动,免全局门控**:agent source 在 `.pi/web` 把这两个组件挂到
|
|
5
|
+
* launcherRail / panelRight 槽,即视为"要用 Canvas"——组件被挂载即显示(`enabled` 默认 true)。
|
|
6
|
+
* 非 AIGC source 不声明这些槽 → 自然不挂载(独立性由声明缺席保证,而非 env 开关)。
|
|
7
|
+
*
|
|
8
|
+
* - **CanvasLauncher**(launcherRail 具名槽):渲染入口按钮,点击经跨 slot 共享的
|
|
9
|
+
* `canvasOpenStore` 开合画廊面板(激活/关闭回收视图)。
|
|
10
|
+
* - **CanvasPanel**(panelRight 具名槽):宿主经 prop 注入 `surface`(launcherRail slot 拿不到 surface,
|
|
11
|
+
* 故交互画廊 / 工作台落在有 surface 的 panelRight 区);读 `canvasOpen` 开合,展开 CanvasGallery,
|
|
12
|
+
* 点格子展开 CanvasWorkbench,关闭回画廊。
|
|
13
|
+
*
|
|
14
|
+
* 两个 slot 是不同子树,经 module-level `canvasOpenStore` 联动(同一 app bundle 内共享)。
|
|
15
|
+
*/
|
|
16
|
+
import * as React from "react";
|
|
17
|
+
import type { WebExtSurfaceAccess, ConversationAccess, WebExtension } from "@blksails/pi-web-kit";
|
|
18
|
+
import type { GalleryAsset, GalleryState } from "@blksails/pi-web-tool-kit/aigc-canvas-schema";
|
|
19
|
+
import { CanvasGallery } from "./canvas-gallery.js";
|
|
20
|
+
import { CanvasWorkbench } from "./canvas-workbench.js";
|
|
21
|
+
import { fetchVisionModels, type VisionModelOption } from "./vision-op.js";
|
|
22
|
+
import { collectCanvasPluginBundles } from "./plugin-aggregation.js";
|
|
23
|
+
import { useCanvasOpen } from "./use-canvas-view.js";
|
|
24
|
+
import type { UploadFn } from "@blksails/pi-web-canvas-kit";
|
|
25
|
+
|
|
26
|
+
const DOMAIN = "canvas";
|
|
27
|
+
const STATE_KEY = `surface:${DOMAIN}`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @deprecated Canvas 显示已改为 source 声明驱动(挂 slot 即显示),不再依赖此 env 门控。
|
|
31
|
+
* 保留仅为向后兼容 / 可选的强制覆盖读取:`NEXT_PUBLIC_PI_WEB_CANVAS === "true" || "1"`。
|
|
32
|
+
*/
|
|
33
|
+
export function isCanvasEnabled(): boolean {
|
|
34
|
+
if (typeof process === "undefined") return false;
|
|
35
|
+
const v = process.env?.NEXT_PUBLIC_PI_WEB_CANVAS;
|
|
36
|
+
return v === "true" || v === "1";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CanvasLauncherProps {
|
|
40
|
+
readonly extId?: string;
|
|
41
|
+
readonly surface?: WebExtSurfaceAccess;
|
|
42
|
+
/** 测试 / 强制覆盖门控(缺省读 env)。 */
|
|
43
|
+
readonly enabled?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** launcherRail 入口按钮(被 source 声明挂载即显示;`enabled` 显式传可覆盖,如强制关)。 */
|
|
47
|
+
export function CanvasLauncher({ enabled }: CanvasLauncherProps): React.JSX.Element | null {
|
|
48
|
+
const on = enabled ?? true;
|
|
49
|
+
const { open, toggle } = useCanvasOpen();
|
|
50
|
+
if (!on) return null;
|
|
51
|
+
return (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
data-canvas-launcher
|
|
55
|
+
aria-expanded={open}
|
|
56
|
+
onClick={toggle}
|
|
57
|
+
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-medium transition-colors hover:bg-[hsl(var(--accent))]"
|
|
58
|
+
>
|
|
59
|
+
<span aria-hidden>🖼️</span>
|
|
60
|
+
<span>Canvas 画廊</span>
|
|
61
|
+
</button>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 拉取可用视觉模型清单(spec canvas-vision-readout,Req 3.1/3.6)。
|
|
67
|
+
*
|
|
68
|
+
* 面板打开时拉一次,不轮询。**任何失败(网络 / 非 2xx / 解析)都记为空清单**,
|
|
69
|
+
* 既不抛也不阻断解读 —— 空清单时解读载荷不带 model,由 `image_vision` 工具弹层兜底。
|
|
70
|
+
*
|
|
71
|
+
* `override` 供测试与宿主直接注入,注入时跳过 fetch。
|
|
72
|
+
*/
|
|
73
|
+
function useVisionModels(
|
|
74
|
+
baseUrl: string | undefined,
|
|
75
|
+
override?: readonly VisionModelOption[],
|
|
76
|
+
): readonly VisionModelOption[] {
|
|
77
|
+
const [models, setModels] = React.useState<readonly VisionModelOption[]>([]);
|
|
78
|
+
|
|
79
|
+
React.useEffect(() => {
|
|
80
|
+
if (override !== undefined) return;
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
void fetchVisionModels(baseUrl).then((list) => {
|
|
83
|
+
// fetchVisionModels 内部已把一切失败折成空数组 —— 此处不需要 catch。
|
|
84
|
+
if (!cancelled && list.length > 0) setModels(list);
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
};
|
|
89
|
+
}, [baseUrl, override]);
|
|
90
|
+
|
|
91
|
+
return override ?? models;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CanvasPanelProps {
|
|
95
|
+
readonly extId?: string;
|
|
96
|
+
readonly surface?: WebExtSurfaceAccess;
|
|
97
|
+
readonly enabled?: boolean;
|
|
98
|
+
/** 轮末 idle 边沿信号(透传 CanvasGallery → run("sync"))。 */
|
|
99
|
+
readonly syncSignal?: unknown;
|
|
100
|
+
/** 退化态图库来源(消息历史图片)。 */
|
|
101
|
+
readonly historyImages?: readonly GalleryAsset[];
|
|
102
|
+
/** 带入对话(Prompt 注入 att_id)。 */
|
|
103
|
+
readonly onBringToConversation?: (attachmentId: string) => void;
|
|
104
|
+
// ── B 档上传接缝(可注入)────────────────────────────────────────────────────
|
|
105
|
+
readonly upload?: UploadFn;
|
|
106
|
+
readonly baseUrl?: string;
|
|
107
|
+
/** 视觉模型清单覆盖(测试 / 宿主直注);缺省时经 `GET {baseUrl}/vision/models` 拉取。 */
|
|
108
|
+
readonly visionModelOptions?: readonly VisionModelOption[];
|
|
109
|
+
readonly sessionId?: string;
|
|
110
|
+
/** 会话能力对象(契约 §4.2;canvas 生成走对话流经此提交,取代 onSubmitPrompt)。 */
|
|
111
|
+
readonly conversation?: ConversationAccess;
|
|
112
|
+
/**
|
|
113
|
+
* 经宿主 Prompt 通道发用户消息(canvas 生成走对话流,LLM 调工具执行)。
|
|
114
|
+
* @deprecated 使用 `conversation`(过渡别名,行为等价)。
|
|
115
|
+
*/
|
|
116
|
+
readonly onSubmitPrompt?: (text: string) => void;
|
|
117
|
+
/** 宿主转发的当前轮流式图像预览(由糊变清);配合 surface `livePreview.stage` 显示渐进图。 */
|
|
118
|
+
readonly livePreviewImage?: string;
|
|
119
|
+
/**
|
|
120
|
+
* 宿主中立注入的全部已装载扩展描述符(SlotHost 领域中立搬运;task 3.1,Req 4.1/4.2/5.1)。
|
|
121
|
+
* CanvasPanel 经 collectCanvasPluginBundles 领域聚合出 canvas 插件捆(附 manifestId 命名空间)
|
|
122
|
+
* 透传工作台。缺省/无声明 canvasPlugins → 零插件路径,行为与现状逐点一致(Req 4.3/5.3)。
|
|
123
|
+
*/
|
|
124
|
+
readonly extensions?: readonly WebExtension[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** panelRight 画廊 / 工作台面板(门控关或未开 → null)。 */
|
|
128
|
+
export function CanvasPanel({
|
|
129
|
+
surface,
|
|
130
|
+
enabled,
|
|
131
|
+
syncSignal,
|
|
132
|
+
historyImages,
|
|
133
|
+
onBringToConversation,
|
|
134
|
+
upload,
|
|
135
|
+
baseUrl,
|
|
136
|
+
visionModelOptions,
|
|
137
|
+
sessionId,
|
|
138
|
+
conversation,
|
|
139
|
+
onSubmitPrompt,
|
|
140
|
+
livePreviewImage,
|
|
141
|
+
extensions,
|
|
142
|
+
}: CanvasPanelProps): React.JSX.Element | null {
|
|
143
|
+
const on = enabled ?? true;
|
|
144
|
+
const { open, setOpen } = useCanvasOpen();
|
|
145
|
+
const [openId, setOpenId] = React.useState<string | null>(null);
|
|
146
|
+
// 领域聚合:已装载扩展 → canvas 插件捆(附 manifestId 命名空间)。useMemo 稳定引用,使
|
|
147
|
+
// 下游工作台 kernel 装配(registerPluginBundles)不因每次渲染的新数组重建(per-mount 契约)。
|
|
148
|
+
const plugins = React.useMemo(() => collectCanvasPluginBundles(extensions), [extensions]);
|
|
149
|
+
// 视觉模型清单(canvas-vision-readout):失败/未就绪 → 空数组,解读仍可用。
|
|
150
|
+
// ⚠ 必须在下方 `if (!on || !open) return null` **之前**调用 —— hooks 不得置于 early return 之后
|
|
151
|
+
// (面板开合会改变 hook 数量,React 报「Rendered more hooks than during the previous render」)。
|
|
152
|
+
const visionModels = useVisionModels(baseUrl, visionModelOptions);
|
|
153
|
+
|
|
154
|
+
// 「点 chat 生成图 → 进 Canvas 编辑」:对话流工具卡的图带通用 `data-att-id`(见 pi-tool-part),
|
|
155
|
+
// 这里挂 document 级**委托监听**——命中即开面板并把工作台切到该 att_id(此时面板可能仍关闭,故
|
|
156
|
+
// 监听须在早返回之前、恒常挂载)。asset 生成后经 idle 边沿 sync 已进画廊 `assets`;openId 具粘性,
|
|
157
|
+
// 万一点击早于 sync,待 asset 到达即自动展开工作台。canvas 关闭态(!on)不接管。
|
|
158
|
+
React.useEffect(() => {
|
|
159
|
+
if (!on) return undefined;
|
|
160
|
+
const onDocClick = (e: MouseEvent): void => {
|
|
161
|
+
const target = e.target as HTMLElement | null;
|
|
162
|
+
const img = target?.closest?.("img[data-att-id]") as HTMLElement | null;
|
|
163
|
+
if (img === null || img === undefined) return;
|
|
164
|
+
if (img.closest("[data-pi-tool-images]") === null) return; // 仅工具卡图片
|
|
165
|
+
const id = img.getAttribute("data-att-id");
|
|
166
|
+
if (id === null || id === "") return;
|
|
167
|
+
setOpenId(id);
|
|
168
|
+
setOpen(true);
|
|
169
|
+
};
|
|
170
|
+
document.addEventListener("click", onDocClick);
|
|
171
|
+
// canvas 活跃期给 body 打标记 → 工具图悬浮态显「可点」affordance(见 styles.css)。
|
|
172
|
+
document.body.setAttribute("data-canvas-tool-image-clickable", "true");
|
|
173
|
+
return () => {
|
|
174
|
+
document.removeEventListener("click", onDocClick);
|
|
175
|
+
document.body.removeAttribute("data-canvas-tool-image-clickable");
|
|
176
|
+
};
|
|
177
|
+
}, [on, setOpen]);
|
|
178
|
+
|
|
179
|
+
if (!on || !open) return null;
|
|
180
|
+
|
|
181
|
+
const snap = surface?.getState<GalleryState>(STATE_KEY);
|
|
182
|
+
const assets: readonly GalleryAsset[] = snap?.assets ?? historyImages ?? [];
|
|
183
|
+
const workbenchAsset = openId !== null ? assets.find((a) => a.attachmentId === openId) : undefined;
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
data-canvas-panel
|
|
188
|
+
className="pi-scrollbar-ghost flex h-full min-h-0 flex-col overflow-y-auto animate-in fade-in-0 duration-200"
|
|
189
|
+
>
|
|
190
|
+
{workbenchAsset !== undefined ? (
|
|
191
|
+
<CanvasWorkbench
|
|
192
|
+
{...(surface !== undefined ? { surface } : {})}
|
|
193
|
+
asset={workbenchAsset}
|
|
194
|
+
assets={assets}
|
|
195
|
+
onClose={() => setOpenId(null)}
|
|
196
|
+
{...(onBringToConversation !== undefined ? { onBringToConversation } : {})}
|
|
197
|
+
{...(upload !== undefined ? { upload } : {})}
|
|
198
|
+
{...(baseUrl !== undefined ? { baseUrl } : {})}
|
|
199
|
+
{...(sessionId !== undefined ? { sessionId } : {})}
|
|
200
|
+
{...(conversation !== undefined ? { conversation } : {})}
|
|
201
|
+
{...(onSubmitPrompt !== undefined ? { onSubmitPrompt } : {})}
|
|
202
|
+
{...(livePreviewImage !== undefined ? { livePreviewImage } : {})}
|
|
203
|
+
{...(syncSignal !== undefined ? { syncSignal } : {})}
|
|
204
|
+
{...(plugins.length > 0 ? { plugins } : {})}
|
|
205
|
+
visionModelOptions={visionModels}
|
|
206
|
+
/>
|
|
207
|
+
) : (
|
|
208
|
+
<CanvasGallery
|
|
209
|
+
{...(surface !== undefined ? { surface } : {})}
|
|
210
|
+
{...(historyImages !== undefined ? { historyImages } : {})}
|
|
211
|
+
{...(syncSignal !== undefined ? { syncSignal } : {})}
|
|
212
|
+
{...(livePreviewImage !== undefined ? { livePreviewImage } : {})}
|
|
213
|
+
onOpenAsset={(id) => setOpenId(id)}
|
|
214
|
+
/>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|