@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 ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@blksails/pi-web-canvas-ui",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./styles.css": "./src/styles.css"
10
+ },
11
+ "engines": {
12
+ "node": ">=22.19.0"
13
+ },
14
+ "dependencies": {
15
+ "lucide-react": "^0.468.0",
16
+ "@blksails/pi-web-canvas-kit": "0.2.0",
17
+ "@blksails/pi-web-kit": "0.2.0",
18
+ "@blksails/pi-web-react": "0.2.0",
19
+ "@blksails/pi-web-primitives": "0.2.0",
20
+ "@blksails/pi-web-tool-kit": "0.2.0"
21
+ },
22
+ "peerDependencies": {
23
+ "react": "^19.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.10.0",
27
+ "@types/react": "^19.0.0",
28
+ "@types/react-dom": "^19.0.0",
29
+ "jsdom": "^25.0.1",
30
+ "react": "^19.0.0",
31
+ "react-dom": "^19.0.0",
32
+ "typescript": "^5.6.3",
33
+ "vitest": "^2.1.8"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "files": [
39
+ "src"
40
+ ],
41
+ "scripts": {
42
+ "test": "vitest run",
43
+ "typecheck": "tsc -p tsconfig.json --noEmit"
44
+ }
45
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * aigc-model-meta — AIGC 模型选择器/设置面板共享的 provider 徽章与显示名逻辑。
3
+ *
4
+ * 单一事实源:`AigcQuickSettings`(选择器)与 `AigcModelTogglesField`(/settings 模型开关)复用同一套
5
+ * provider 字母徽章(O/N/S/D)与 ` · <provider 名>` 后缀剥离规则,避免两处 PROVIDER_META 漂移。
6
+ */
7
+ import * as React from "react";
8
+
9
+ /**
10
+ * provider → 字母徽章元数据(无图标资源,取首字母表示)。首字母互不冲突(O/N/S/D)。
11
+ * `name` 用于去掉 label 里冗余的 ` · <name>` 后缀 + 徽章 hover 提示。
12
+ */
13
+ export const PROVIDER_META: Readonly<
14
+ Record<string, { readonly letter: string; readonly name: string; readonly bg: string }>
15
+ > = {
16
+ openrouter: { letter: "O", name: "OpenRouter", bg: "#6366f1" },
17
+ newapi: { letter: "N", name: "NewAPI", bg: "#10b981" },
18
+ sufy: { letter: "S", name: "Sufy", bg: "#f59e0b" },
19
+ dashscope: { letter: "D", name: "DashScope", bg: "#0ea5e9" },
20
+ };
21
+
22
+ /**
23
+ * 去掉 label 末尾冗余的 ` · <provider 名>` 后缀 —— 仅当后缀与该 model 的 provider 名匹配
24
+ * (大小写不敏感)时移除,由徽章代表;保留如 ` · token plan` 这类非 provider 名的有意义区分。
25
+ */
26
+ export function displayNameOf(label: string, providerId: string | undefined): string {
27
+ const meta = providerId !== undefined ? PROVIDER_META[providerId] : undefined;
28
+ if (meta === undefined) return label;
29
+ const idx = label.lastIndexOf(" · ");
30
+ if (idx < 0) return label;
31
+ const suffix = label.slice(idx + 3).trim();
32
+ return suffix.toLowerCase() === meta.name.toLowerCase() ? label.slice(0, idx).trim() : label;
33
+ }
34
+
35
+ /** provider 字母徽章(无图标资源时的字母表示);未知 provider → 不渲染。 */
36
+ export function ProviderBadge({
37
+ providerId,
38
+ }: {
39
+ readonly providerId: string | undefined;
40
+ }): React.JSX.Element | null {
41
+ const meta = providerId !== undefined ? PROVIDER_META[providerId] : undefined;
42
+ if (meta === undefined) return null;
43
+ return (
44
+ <span
45
+ aria-hidden
46
+ title={meta.name}
47
+ className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px] text-[9px] font-semibold leading-none text-white"
48
+ style={{ backgroundColor: meta.bg }}
49
+ >
50
+ {meta.letter}
51
+ </span>
52
+ );
53
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * AigcQuickSettings — 输入区工具排的 AIGC 快捷设置(aigc-prompt-toolbar Req 2/3/5.2/6/7.2)。
3
+ *
4
+ * 由 aigc-canvas-agent 的 `.pi/web` 挂载到 `promptToolbar` 槽(内核控件后、发送键前):
5
+ * 图像模型 + 输出尺寸两个紧凑选择器。
6
+ *
7
+ * - **偏好通道**:经 props 注入的 `state`(WebExtStateAccess)读写会话偏好键
8
+ * `aigc.model` / `aigc.size`(权威 KV 在 agent 子进程;图像工具执行时直接读同键,
9
+ * 见 tool-kit run-image-tool 偏好级)。工具交互追问写回 → 下行帧 → 本组件订阅回显(5.2)。
10
+ * - **清单**:读 `aigc.models` / `aigc.sizes`(aigcExtension 装配期下发;单一事实源 = 工具
11
+ * routes),未就绪回退内置常量。
12
+ * - **跨会话**:变更双写 localStorage(`pi-web.aigc.*`);挂载时会话偏好为空而本地记忆存在
13
+ * 则回填(seed)——新会话初始即生效(6.1/6.3)。
14
+ * - **退化**:`state === undefined`(宿主未接状态桥)→ 返回 null,不呈现不报错(7.2)。
15
+ *
16
+ * slot 组件是独立 bundle:一切依赖经 props 注入(不依赖 React context)。
17
+ */
18
+ import * as React from "react";
19
+ import type { WebExtStateAccess } from "@blksails/pi-web-kit";
20
+ import {
21
+ Select,
22
+ SelectContent,
23
+ SelectItem,
24
+ SelectTrigger,
25
+ SelectValue,
26
+ } from "@blksails/pi-web-primitives";
27
+ import { ProviderBadge, displayNameOf } from "./aigc-model-meta.js";
28
+
29
+ /** 清单未就绪时的回退常量(与 aigcExtension 下发值同源语义;KV 到达后即被覆盖)。 */
30
+ const FALLBACK_MODELS: readonly string[] = [
31
+ "gpt-image-2",
32
+ "wan2.7-image-pro",
33
+ "qwen-image-edit-max",
34
+ "qwen-image-2.0",
35
+ "wan2.6-t2i",
36
+ "wanx2.1-t2i-turbo",
37
+ ];
38
+ const FALLBACK_SIZES: readonly string[] = ["1024x1024", "1536x1024", "1024x1536", "auto"];
39
+
40
+ /** Radix Select 不接受空字符串 item value;以哨兵表示「默认」(= 不设偏好)。 */
41
+ const DEFAULT_SENTINEL = "__default__";
42
+
43
+ /** localStorage 键(同一浏览器跨会话记忆;读写包 try/catch 兼容隐私模式)。 */
44
+ const LS_PREFIX = "pi-web.aigc.";
45
+
46
+ function lsGet(key: string): string | undefined {
47
+ try {
48
+ const v = window.localStorage.getItem(LS_PREFIX + key);
49
+ return v === null || v === "" ? undefined : v;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ function lsSet(key: string, value: string | undefined): void {
56
+ try {
57
+ if (value === undefined) window.localStorage.removeItem(LS_PREFIX + key);
58
+ else window.localStorage.setItem(LS_PREFIX + key, value);
59
+ } catch {
60
+ /* 隐私模式等:跨会话记忆静默不可用,会话内偏好不受影响 */
61
+ }
62
+ }
63
+
64
+ /** 订阅一个偏好键:当前值回显 + 外部写回(如工具追问)推送更新。 */
65
+ function useStateKey(
66
+ state: WebExtStateAccess,
67
+ key: string,
68
+ ): string | undefined {
69
+ const subscribe = React.useCallback(
70
+ (onChange: () => void) => state.subscribe(key, onChange),
71
+ [state, key],
72
+ );
73
+ const getSnapshot = React.useCallback(() => {
74
+ const v = state.get<string>(key);
75
+ return typeof v === "string" && v !== "" ? v : undefined;
76
+ }, [state, key]);
77
+ return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
78
+ }
79
+
80
+ /** 订阅清单键(string[]);无效或未就绪返回 fallback。 */
81
+ function useCatalogKey(
82
+ state: WebExtStateAccess,
83
+ key: string,
84
+ fallback: readonly string[],
85
+ ): readonly string[] {
86
+ const subscribe = React.useCallback(
87
+ (onChange: () => void) => state.subscribe(key, onChange),
88
+ [state, key],
89
+ );
90
+ const getSnapshot = React.useCallback(() => state.get<unknown>(key), [state, key]);
91
+ const raw = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
92
+ return React.useMemo(() => {
93
+ if (Array.isArray(raw) && raw.length > 0 && raw.every((x) => typeof x === "string")) {
94
+ return raw as readonly string[];
95
+ }
96
+ return fallback;
97
+ }, [raw, fallback]);
98
+ }
99
+
100
+ /** 最大公约数(约分屏幕比例用)。 */
101
+ function gcd(a: number, b: number): number {
102
+ return b === 0 ? a : gcd(b, a % b);
103
+ }
104
+
105
+ /**
106
+ * 尺寸 "W×H" → **方向文本 + 屏幕比例**副标(比裸比例数字更直观):
107
+ * 方形("1024x1024"→"方形 1:1")、横向("1280x720"→"宽屏 16:9")、纵向("720x1280"→"竖屏 9:16");
108
+ * "auto" → "自适应";非法/无法解析 → undefined(不显示副标)。
109
+ */
110
+ export function sizeHint(size: string): string | undefined {
111
+ if (size === "auto") return "自适应";
112
+ const m = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(size);
113
+ if (m === null) return undefined;
114
+ const w = Number(m[1]);
115
+ const h = Number(m[2]);
116
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return undefined;
117
+ const g = gcd(w, h);
118
+ const ratio = `${w / g}:${h / g}`;
119
+ const orientation = w === h ? "方形" : w > h ? "宽屏" : "竖屏";
120
+ return `${orientation} ${ratio}`;
121
+ }
122
+
123
+ /**
124
+ * 尺寸 "W×H" → **像素尺寸**弱化副标(方向/比例作主项突出时,像素退居右侧灰字)。
125
+ * "1024x1024"→"1024×1024";"auto"/非法/无法解析 → undefined(不显示副标)。
126
+ */
127
+ export function sizePixels(size: string): string | undefined {
128
+ if (size === "auto") return undefined;
129
+ const m = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(size);
130
+ if (m === null) return undefined;
131
+ const w = Number(m[1]);
132
+ const h = Number(m[2]);
133
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return undefined;
134
+ return `${w}×${h}`;
135
+ }
136
+
137
+ interface PrefSelectProps {
138
+ readonly state: WebExtStateAccess;
139
+ readonly prefKey: "model" | "size";
140
+ readonly options: readonly string[];
141
+ readonly placeholder: string;
142
+ readonly ariaLabel: string;
143
+ readonly dataAttr: string;
144
+ readonly widthClass: string;
145
+ /** value(id)→ 显示 label 映射;缺失键回退显示 id。hover title 恒为 id。 */
146
+ readonly labels?: Readonly<Record<string, string>>;
147
+ /** value(id)→ provider 标识;有则渲染字母徽章并去掉冗余 provider 名后缀。 */
148
+ readonly providers?: Readonly<Record<string, string>>;
149
+ /** value → 右侧副标文本(如尺寸的像素);返回 undefined 则不显示。 */
150
+ readonly hint?: (value: string) => string | undefined;
151
+ /**
152
+ * value → **主项突出文本**(如尺寸的方向+比例);返回 undefined 则回退 label/id。
153
+ * 与 hint 配合可对调突出关系:主项显方向描述、副标弱化像素尺寸。
154
+ */
155
+ readonly primary?: (value: string) => string | undefined;
156
+ }
157
+
158
+ /** 单个偏好选择器:回显 KV 当前值,变更写 KV + localStorage。 */
159
+ function PrefSelect({
160
+ state,
161
+ prefKey,
162
+ options,
163
+ placeholder,
164
+ ariaLabel,
165
+ dataAttr,
166
+ widthClass,
167
+ labels,
168
+ providers,
169
+ hint,
170
+ primary,
171
+ }: PrefSelectProps): React.JSX.Element {
172
+ const current = useStateKey(state, `aigc.${prefKey}`);
173
+ // 当前值不在清单里(如追问写回了清单外模型)仍需可回显:并入 items。
174
+ const items = React.useMemo(
175
+ () => (current !== undefined && !options.includes(current) ? [current, ...options] : options),
176
+ [current, options],
177
+ );
178
+ const onChange = (v: string): void => {
179
+ const next = v === DEFAULT_SENTINEL ? undefined : v;
180
+ if (next === undefined) void state.delete(`aigc.${prefKey}`);
181
+ else void state.set(`aigc.${prefKey}`, next);
182
+ lsSet(prefKey, next);
183
+ };
184
+ return (
185
+ <Select value={current ?? DEFAULT_SENTINEL} onValueChange={onChange}>
186
+ <SelectTrigger
187
+ {...{ [dataAttr]: "" }}
188
+ aria-label={ariaLabel}
189
+ // 收起态也让 hover 可见模型 id(仅 label≠id 的模型选择器需要)。
190
+ {...(labels !== undefined && current !== undefined ? { title: current } : {})}
191
+ className={`h-8 rounded-full border-[hsl(var(--border))] bg-transparent text-xs ${widthClass}`}
192
+ >
193
+ <SelectValue placeholder={placeholder} />
194
+ </SelectTrigger>
195
+ <SelectContent>
196
+ <SelectItem value={DEFAULT_SENTINEL}>{placeholder}</SelectItem>
197
+ {items.map((m) => {
198
+ const providerId = providers?.[m];
199
+ const label = labels?.[m] ?? m;
200
+ const h = hint?.(m);
201
+ const primaryLabel = primary?.(m) ?? displayNameOf(label, providerId);
202
+ return (
203
+ // 可见=provider 字母徽章 + 主项(方向描述/去后缀 label,缺失回退 id)+ 可选右侧弱化副标
204
+ //(如像素尺寸);hover title 恒为模型 id / 原始 value。
205
+ <SelectItem key={m} value={m} title={m}>
206
+ <span className="flex items-center gap-1.5">
207
+ <ProviderBadge providerId={providerId} />
208
+ <span className="truncate">{primaryLabel}</span>
209
+ {h !== undefined ? (
210
+ <span className="ml-auto shrink-0 pl-2 text-[10px] text-[hsl(var(--muted-foreground))]">
211
+ {h}
212
+ </span>
213
+ ) : null}
214
+ </span>
215
+ </SelectItem>
216
+ );
217
+ })}
218
+ </SelectContent>
219
+ </Select>
220
+ );
221
+ }
222
+
223
+ export interface AigcQuickSettingsProps {
224
+ /** 会话共享状态接入(宿主经 SlotHost 注入);缺失 → 不呈现(Req 7.2)。 */
225
+ readonly state?: WebExtStateAccess;
226
+ /** seed 前的判空延迟(毫秒;测试可传 0)。见组件内竞态注释。 */
227
+ readonly seedDelayMs?: number;
228
+ }
229
+
230
+ export function AigcQuickSettings({
231
+ state,
232
+ seedDelayMs = 800,
233
+ }: AigcQuickSettingsProps): React.JSX.Element | null {
234
+ // seed(Req 6.1/6.3):会话偏好为空且本地记忆存在 → 回填会话 KV,使新会话的图像工具
235
+ // 直接采用历史选择。
236
+ // ⚠ 竞态防护:KV 镜像经 SSE 粘性帧**异步**回放——mount 瞬间判空会在「重开既有会话」时
237
+ // 误用本地旧值覆盖会话内真值。故延迟 seedDelayMs 后再判空(粘性回放在连接后毫秒级完成,
238
+ // 延迟后窗口实际归零);期间键已回放出值则自然跳过。
239
+ React.useEffect(() => {
240
+ if (state === undefined) return;
241
+ const t = setTimeout(() => {
242
+ for (const key of ["model", "size"] as const) {
243
+ const inSession = state.get<string>(`aigc.${key}`);
244
+ const remembered = lsGet(key);
245
+ if ((inSession === undefined || inSession === "") && remembered !== undefined) {
246
+ void state.set(`aigc.${key}`, remembered);
247
+ }
248
+ }
249
+ }, seedDelayMs);
250
+ return () => clearTimeout(t);
251
+ }, [state, seedDelayMs]);
252
+
253
+ // 所有 hooks 之后再早退(Rules of Hooks):退化态不呈现。
254
+ const models = useCatalogKeySafe(state, "aigc.models", FALLBACK_MODELS);
255
+ const sizes = useCatalogKeySafe(state, "aigc.sizes", FALLBACK_SIZES);
256
+ const modelLabels = useLabelMapSafe(state, "aigc.modelLabels");
257
+ const modelProviders = useLabelMapSafe(state, "aigc.modelProviders");
258
+ if (state === undefined) return null;
259
+
260
+ return (
261
+ <span data-aigc-quick-settings className="flex items-center gap-1">
262
+ <PrefSelect
263
+ state={state}
264
+ prefKey="model"
265
+ options={models}
266
+ labels={modelLabels}
267
+ providers={modelProviders}
268
+ placeholder="图像模型"
269
+ ariaLabel="图像生成模型"
270
+ dataAttr="data-aigc-model-select"
271
+ widthClass="w-32"
272
+ />
273
+ <PrefSelect
274
+ state={state}
275
+ prefKey="size"
276
+ options={sizes}
277
+ placeholder="尺寸"
278
+ ariaLabel="输出尺寸"
279
+ dataAttr="data-aigc-size-select"
280
+ widthClass="w-36"
281
+ primary={sizeHint}
282
+ hint={sizePixels}
283
+ />
284
+ </span>
285
+ );
286
+ }
287
+
288
+ /** 订阅一个 label 映射键(Record<string,string>);无效或未就绪返回空对象。 */
289
+ function useLabelMap(
290
+ state: WebExtStateAccess,
291
+ key: string,
292
+ ): Readonly<Record<string, string>> {
293
+ const subscribe = React.useCallback(
294
+ (onChange: () => void) => state.subscribe(key, onChange),
295
+ [state, key],
296
+ );
297
+ const getSnapshot = React.useCallback(() => state.get<unknown>(key), [state, key]);
298
+ const raw = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
299
+ return React.useMemo(() => {
300
+ if (
301
+ raw !== null &&
302
+ typeof raw === "object" &&
303
+ !Array.isArray(raw) &&
304
+ Object.values(raw as Record<string, unknown>).every((v) => typeof v === "string")
305
+ ) {
306
+ return raw as Record<string, string>;
307
+ }
308
+ return {};
309
+ }, [raw]);
310
+ }
311
+
312
+ /** useLabelMap 的 state 可缺失版(缺失时恒空对象,保持 hooks 顺序稳定)。 */
313
+ function useLabelMapSafe(
314
+ state: WebExtStateAccess | undefined,
315
+ key: string,
316
+ ): Readonly<Record<string, string>> {
317
+ const noopState = React.useMemo<WebExtStateAccess>(
318
+ () => ({
319
+ get: () => undefined,
320
+ subscribe: () => () => {},
321
+ set: async () => {},
322
+ delete: async () => {},
323
+ }),
324
+ [],
325
+ );
326
+ return useLabelMap(state ?? noopState, key);
327
+ }
328
+
329
+ /** useCatalogKey 的 state 可缺失版(缺失时恒 fallback,保持 hooks 顺序稳定)。 */
330
+ function useCatalogKeySafe(
331
+ state: WebExtStateAccess | undefined,
332
+ key: string,
333
+ fallback: readonly string[],
334
+ ): readonly string[] {
335
+ const noopState = React.useMemo<WebExtStateAccess>(
336
+ () => ({
337
+ get: () => undefined,
338
+ subscribe: () => () => {},
339
+ set: async () => {},
340
+ delete: async () => {},
341
+ }),
342
+ [],
343
+ );
344
+ return useCatalogKey(state ?? noopState, key, fallback);
345
+ }