@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,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas UI 本地视图偏好(aigc-canvas · Req 3.2 / 3.3 / 3.5 / 6.4 / 10.3)。
|
|
3
|
+
*
|
|
4
|
+
* 这些偏好是**客户端 state**(localStorage),**不进权威快照**(对齐 AAS「UI 本地偏好走客户端」):
|
|
5
|
+
* - `density`:概览 / 瀑布流 / 聚焦;
|
|
6
|
+
* - `page`:客户端分页页码;
|
|
7
|
+
* - `group`:时间 / 血缘 / 无 分组;
|
|
8
|
+
* - `selected`:选中资产 id(A-B 对比 / 工作台目标);
|
|
9
|
+
* - `chain`:当前工作图链(沿 derivedFrom 的一条 UI 本地路径,前进 / 回退)。
|
|
10
|
+
*
|
|
11
|
+
* 另暴露一个**跨 slot 共享**的「画廊开合」store(`canvasOpenStore`):launcherRail 的入口按钮与
|
|
12
|
+
* panelRight 的画廊面板是不同 slot 子树,经此 module-level 可订阅 store(+ localStorage)联动开合,
|
|
13
|
+
* 刷新后从本地恢复(配合上游粘性快照,画廊仍在)。
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
16
|
+
|
|
17
|
+
export type CanvasDensity = "overview" | "waterfall" | "focus";
|
|
18
|
+
export type CanvasGroupMode = "time" | "lineage" | "none";
|
|
19
|
+
|
|
20
|
+
export interface CanvasViewState {
|
|
21
|
+
density: CanvasDensity;
|
|
22
|
+
page: number;
|
|
23
|
+
group: CanvasGroupMode;
|
|
24
|
+
/** 选中资产 id 列表(A-B 对比取前两项)。 */
|
|
25
|
+
selected: string[];
|
|
26
|
+
/** 当前工作图链(att_id 序列;末项为当前工作图)。 */
|
|
27
|
+
chain: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function defaultViewState(): CanvasViewState {
|
|
31
|
+
return { density: "overview", page: 0, group: "time", selected: [], chain: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const VIEW_KEY = "pi-web:canvas:view";
|
|
35
|
+
const OPEN_KEY = "pi-web:canvas:open";
|
|
36
|
+
|
|
37
|
+
function readLocal<T>(key: string, fallback: T): T {
|
|
38
|
+
if (typeof window === "undefined") return fallback;
|
|
39
|
+
try {
|
|
40
|
+
const raw = window.localStorage.getItem(key);
|
|
41
|
+
if (raw === null) return fallback;
|
|
42
|
+
return JSON.parse(raw) as T;
|
|
43
|
+
} catch {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeLocal(key: string, value: unknown): void {
|
|
49
|
+
if (typeof window === "undefined") return;
|
|
50
|
+
try {
|
|
51
|
+
window.localStorage.setItem(key, JSON.stringify(value));
|
|
52
|
+
} catch {
|
|
53
|
+
/* 配额 / 隐私模式:忽略。 */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 跨 slot 共享的「画廊开合」store(module singleton)────────────────────────────
|
|
58
|
+
|
|
59
|
+
interface OpenStore {
|
|
60
|
+
getSnapshot(): boolean;
|
|
61
|
+
subscribe(listener: () => void): () => void;
|
|
62
|
+
set(open: boolean): void;
|
|
63
|
+
toggle(): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createOpenStore(): OpenStore {
|
|
67
|
+
let open = readLocal<boolean>(OPEN_KEY, false);
|
|
68
|
+
const listeners = new Set<() => void>();
|
|
69
|
+
const emit = (): void => {
|
|
70
|
+
for (const l of listeners) l();
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
getSnapshot: () => open,
|
|
74
|
+
subscribe: (listener) => {
|
|
75
|
+
listeners.add(listener);
|
|
76
|
+
return () => listeners.delete(listener);
|
|
77
|
+
},
|
|
78
|
+
set: (next) => {
|
|
79
|
+
if (open === next) return;
|
|
80
|
+
open = next;
|
|
81
|
+
writeLocal(OPEN_KEY, open);
|
|
82
|
+
emit();
|
|
83
|
+
},
|
|
84
|
+
toggle: () => {
|
|
85
|
+
open = !open;
|
|
86
|
+
writeLocal(OPEN_KEY, open);
|
|
87
|
+
emit();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** module-level 单例(同一 app bundle 内 launcher / panel 共享)。 */
|
|
93
|
+
export const canvasOpenStore: OpenStore = createOpenStore();
|
|
94
|
+
|
|
95
|
+
const SERVER_OPEN = (): boolean => false;
|
|
96
|
+
|
|
97
|
+
/** 订阅跨 slot 的画廊开合态。 */
|
|
98
|
+
export function useCanvasOpen(): {
|
|
99
|
+
open: boolean;
|
|
100
|
+
setOpen: (open: boolean) => void;
|
|
101
|
+
toggle: () => void;
|
|
102
|
+
} {
|
|
103
|
+
const open = useSyncExternalStore(
|
|
104
|
+
canvasOpenStore.subscribe,
|
|
105
|
+
canvasOpenStore.getSnapshot,
|
|
106
|
+
SERVER_OPEN,
|
|
107
|
+
);
|
|
108
|
+
return {
|
|
109
|
+
open,
|
|
110
|
+
setOpen: useCallback((next: boolean) => canvasOpenStore.set(next), []),
|
|
111
|
+
toggle: useCallback(() => canvasOpenStore.toggle(), []),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── 视图偏好 store(localStorage;单例,便于跨 slot 一致)────────────────────────
|
|
116
|
+
|
|
117
|
+
interface ViewStore {
|
|
118
|
+
getSnapshot(): CanvasViewState;
|
|
119
|
+
subscribe(listener: () => void): () => void;
|
|
120
|
+
update(patch: (prev: CanvasViewState) => CanvasViewState): void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createViewStore(): ViewStore {
|
|
124
|
+
let state: CanvasViewState = { ...defaultViewState(), ...readLocal(VIEW_KEY, {}) };
|
|
125
|
+
const listeners = new Set<() => void>();
|
|
126
|
+
return {
|
|
127
|
+
getSnapshot: () => state,
|
|
128
|
+
subscribe: (listener) => {
|
|
129
|
+
listeners.add(listener);
|
|
130
|
+
return () => listeners.delete(listener);
|
|
131
|
+
},
|
|
132
|
+
update: (patch) => {
|
|
133
|
+
state = patch(state);
|
|
134
|
+
writeLocal(VIEW_KEY, state);
|
|
135
|
+
for (const l of listeners) l();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const canvasViewStore: ViewStore = createViewStore();
|
|
141
|
+
|
|
142
|
+
const SERVER_VIEW = (): CanvasViewState => defaultViewState();
|
|
143
|
+
|
|
144
|
+
export interface UseCanvasViewResult extends CanvasViewState {
|
|
145
|
+
setDensity(density: CanvasDensity): void;
|
|
146
|
+
setPage(page: number): void;
|
|
147
|
+
setGroup(group: CanvasGroupMode): void;
|
|
148
|
+
toggleSelected(id: string): void;
|
|
149
|
+
clearSelected(): void;
|
|
150
|
+
/** 把资产推进当前工作图链(末项)。 */
|
|
151
|
+
pushChain(id: string): void;
|
|
152
|
+
/** 沿工作图链回退一步。 */
|
|
153
|
+
popChain(): void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** UI 本地视图偏好(localStorage 持久;刷新后从本地恢复)。 */
|
|
157
|
+
export function useCanvasView(): UseCanvasViewResult {
|
|
158
|
+
const state = useSyncExternalStore(
|
|
159
|
+
canvasViewStore.subscribe,
|
|
160
|
+
canvasViewStore.getSnapshot,
|
|
161
|
+
SERVER_VIEW,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const setDensity = useCallback((density: CanvasDensity) => {
|
|
165
|
+
canvasViewStore.update((s) => ({ ...s, density, page: 0 }));
|
|
166
|
+
}, []);
|
|
167
|
+
const setPage = useCallback((page: number) => {
|
|
168
|
+
canvasViewStore.update((s) => ({ ...s, page: Math.max(0, page) }));
|
|
169
|
+
}, []);
|
|
170
|
+
const setGroup = useCallback((group: CanvasGroupMode) => {
|
|
171
|
+
canvasViewStore.update((s) => ({ ...s, group }));
|
|
172
|
+
}, []);
|
|
173
|
+
const toggleSelected = useCallback((id: string) => {
|
|
174
|
+
canvasViewStore.update((s) => ({
|
|
175
|
+
...s,
|
|
176
|
+
selected: s.selected.includes(id)
|
|
177
|
+
? s.selected.filter((x) => x !== id)
|
|
178
|
+
: [...s.selected, id],
|
|
179
|
+
}));
|
|
180
|
+
}, []);
|
|
181
|
+
const clearSelected = useCallback(() => {
|
|
182
|
+
canvasViewStore.update((s) => ({ ...s, selected: [] }));
|
|
183
|
+
}, []);
|
|
184
|
+
const pushChain = useCallback((id: string) => {
|
|
185
|
+
canvasViewStore.update((s) => ({
|
|
186
|
+
...s,
|
|
187
|
+
chain: [...s.chain.filter((x) => x !== id), id],
|
|
188
|
+
}));
|
|
189
|
+
}, []);
|
|
190
|
+
const popChain = useCallback(() => {
|
|
191
|
+
canvasViewStore.update((s) => ({ ...s, chain: s.chain.slice(0, -1) }));
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
return useMemo(
|
|
195
|
+
() => ({
|
|
196
|
+
...state,
|
|
197
|
+
setDensity,
|
|
198
|
+
setPage,
|
|
199
|
+
setGroup,
|
|
200
|
+
toggleSelected,
|
|
201
|
+
clearSelected,
|
|
202
|
+
pushChain,
|
|
203
|
+
popChain,
|
|
204
|
+
}),
|
|
205
|
+
[state, setDensity, setPage, setGroup, toggleSelected, clearSelected, pushChain, popChain],
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 每页容量(over 轻量快照列表;9 宫格默认视图)。 */
|
|
210
|
+
export const CANVAS_PAGE_SIZE: Record<CanvasDensity, number> = {
|
|
211
|
+
overview: 9,
|
|
212
|
+
waterfall: 12,
|
|
213
|
+
focus: 1,
|
|
214
|
+
};
|
package/src/vision-op.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vision-op — Canvas「解读」按钮的对话通道载荷构造器(spec canvas-vision-readout)。
|
|
3
|
+
*
|
|
4
|
+
* 把「当前工作图 + 问题 + 可选视觉模型」组装成一个 `tool: image_vision` 的 {@link SurfaceOp},
|
|
5
|
+
* 由 `bridge.submitOp` 经 `renderSurfaceOp` 渲染为**用户消息**发进对话流,LLM 据此调用
|
|
6
|
+
* 已实现的 `image_vision` 工具(spec image-vision-tool)。结论因此天然回流对话记录:
|
|
7
|
+
* 可回放、可追问、进 LLM 上下文。
|
|
8
|
+
*
|
|
9
|
+
* 设计要点:
|
|
10
|
+
* - **刻意不复用 `buildSurfaceOp`**:二者 tool 行语义、参数集合、可选项规则完全不同;
|
|
11
|
+
* 强行抽象会把 `generate-actions.test.ts` 的决策守恒线(逐字节断言生成载荷)拖下水。
|
|
12
|
+
* - **tool 行必须内嵌中文指令**:agent 的 systemPrompt **没有**教 LLM 解析 `canvas-op` 围栏,
|
|
13
|
+
* 理解完全依赖 tool 行里的「请直接按下列参数调用,勿追问」(与 `buildSurfaceOp:314` 同形态)。
|
|
14
|
+
* 去掉它,LLM 很可能复述参数而不调用工具。
|
|
15
|
+
* - **`model` 为空时省略该参数行**:`renderSurfaceOp` 跳过空值(surface-op.ts:62),
|
|
16
|
+
* 工具收不到 model 即弹选择层(image-vision-tool Req 3.1);收到则直接用(Req 3.2)。
|
|
17
|
+
* - `model` 的取值是 **`provider/modelId`**(与工具 `model` 参数、`modelKey()` 对齐),
|
|
18
|
+
* ⚠ 与提示词栏既有「生成模型」选择器的**裸 id** 格式不同,不可混用。
|
|
19
|
+
*
|
|
20
|
+
* 纯函数:零 React、零 I/O、同输入恒同输出。
|
|
21
|
+
*/
|
|
22
|
+
import type { SurfaceOp } from "@blksails/pi-web-kit";
|
|
23
|
+
|
|
24
|
+
/** 输入框为空时使用的默认提问(Req 1.3)。 */
|
|
25
|
+
export const DEFAULT_READOUT_QUESTION = "描述这张图片的内容。";
|
|
26
|
+
|
|
27
|
+
/** 标题中意图摘要的最大长度(与 `buildSurfaceOp` 的 48 字截断同规)。 */
|
|
28
|
+
const INTENT_MAX = 48;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 视觉模型选项。
|
|
32
|
+
*
|
|
33
|
+
* `value` 是 **`provider/modelId`**(工具 `model` 参数的格式);`label` 供展示。
|
|
34
|
+
*/
|
|
35
|
+
export interface VisionModelOption {
|
|
36
|
+
readonly value: string;
|
|
37
|
+
readonly label: string;
|
|
38
|
+
readonly provider: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BuildVisionOpInput {
|
|
42
|
+
/** 当前工作图的附件 id(`att_…`)。 */
|
|
43
|
+
readonly imageId: string;
|
|
44
|
+
/** 用户问题;空串 / 全空白 → 使用 {@link DEFAULT_READOUT_QUESTION}。 */
|
|
45
|
+
readonly question: string;
|
|
46
|
+
/** `provider/modelId`;省略 / 空串 → 载荷不带 `model` 行,由工具弹层选择。 */
|
|
47
|
+
readonly model?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 标题的意图摘要:超长截断,空则不附。 */
|
|
51
|
+
function summarizeIntent(question: string): string {
|
|
52
|
+
const q = question.trim();
|
|
53
|
+
if (q === "") return "";
|
|
54
|
+
return q.length > INTENT_MAX ? `${q.slice(0, INTENT_MAX)}…` : q;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 构造 `image_vision` 的对话通道载荷。
|
|
59
|
+
*
|
|
60
|
+
* 后置:`params` 顺序恒为 `image → question → model?`;`fence` 恒为 `"canvas-op"`;
|
|
61
|
+
* `model` 为空时结果中**不出现** `model` 项。
|
|
62
|
+
*/
|
|
63
|
+
export function buildVisionOp(input: BuildVisionOpInput): SurfaceOp {
|
|
64
|
+
const question = input.question.trim() === "" ? DEFAULT_READOUT_QUESTION : input.question;
|
|
65
|
+
|
|
66
|
+
const params: Array<readonly [string, string]> = [
|
|
67
|
+
["image", input.imageId],
|
|
68
|
+
["question", question],
|
|
69
|
+
];
|
|
70
|
+
// 空 model 不产生参数行 —— 把「是否弹层」的决策权完整交回工具。
|
|
71
|
+
if (typeof input.model === "string" && input.model.trim() !== "") {
|
|
72
|
+
params.push(["model", input.model]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const intent = summarizeIntent(question);
|
|
76
|
+
return {
|
|
77
|
+
title: intent !== "" ? `👁 解读 · ${intent}` : "👁 解读",
|
|
78
|
+
tool: "image_vision(请直接按下列参数调用,勿追问、勿复述参数)",
|
|
79
|
+
params,
|
|
80
|
+
fence: "canvas-op",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 拉取可用视觉模型清单(Req 3.1/3.6)。
|
|
86
|
+
*
|
|
87
|
+
* **任何失败(无 baseUrl / 网络 / 非 2xx / 解析异常 / 形状不符)都返回空数组**,
|
|
88
|
+
* 既不抛也不阻断解读 —— 空清单时载荷不带 `model`,由 `image_vision` 工具弹层兜底。
|
|
89
|
+
*
|
|
90
|
+
* 抽成纯异步函数(而非埋在 hook 里)使其可直接单测 fetch 的四条分支。
|
|
91
|
+
*/
|
|
92
|
+
export async function fetchVisionModels(
|
|
93
|
+
baseUrl: string | undefined,
|
|
94
|
+
fetchImpl: typeof fetch = fetch,
|
|
95
|
+
): Promise<readonly VisionModelOption[]> {
|
|
96
|
+
if (baseUrl === undefined || baseUrl === "") return [];
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetchImpl(`${baseUrl}/vision/models`);
|
|
99
|
+
if (!res.ok) return [];
|
|
100
|
+
const body = (await res.json()) as { models?: unknown };
|
|
101
|
+
if (!Array.isArray(body.models)) return [];
|
|
102
|
+
return body.models.filter(
|
|
103
|
+
(m): m is VisionModelOption =>
|
|
104
|
+
typeof m === "object" &&
|
|
105
|
+
m !== null &&
|
|
106
|
+
typeof (m as VisionModelOption).value === "string" &&
|
|
107
|
+
typeof (m as VisionModelOption).label === "string",
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|