@assistant-ui/react-generative-ui 0.0.10 → 0.0.12

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,282 @@
1
+ import {
2
+ A2UI_SURFACE_ID,
3
+ type A2uiOperationResult,
4
+ type A2uiState,
5
+ type A2uiSurfaceState,
6
+ } from "./types";
7
+
8
+ const OPERATION_KEYS = new Set([
9
+ "createSurface",
10
+ "updateComponents",
11
+ "updateDataModel",
12
+ "deleteSurface",
13
+ ]);
14
+
15
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
16
+ typeof value === "object" && value !== null && !Array.isArray(value);
17
+
18
+ const isVersion = (value: unknown): value is "v0.9" | "v1.0" =>
19
+ value === "v0.9" || value === "v1.0";
20
+
21
+ const surfaceIdOf = (payload: Record<string, unknown>): string | undefined => {
22
+ const surfaceId = payload["surfaceId"];
23
+ return typeof surfaceId === "string" && surfaceId.length > 0
24
+ ? surfaceId
25
+ : undefined;
26
+ };
27
+
28
+ const withSurfaceId = (
29
+ surface: A2uiSurfaceState,
30
+ surfaceId: string,
31
+ ): A2uiSurfaceState => {
32
+ Object.defineProperty(surface, A2UI_SURFACE_ID, {
33
+ value: surfaceId,
34
+ enumerable: false,
35
+ });
36
+ return surface;
37
+ };
38
+
39
+ const getSurfaceId = (surface: A2uiSurfaceState): string | undefined =>
40
+ (surface as A2uiSurfaceState & { [A2UI_SURFACE_ID]?: string })[
41
+ A2UI_SURFACE_ID
42
+ ];
43
+
44
+ const cloneSurface = (
45
+ surface: A2uiSurfaceState,
46
+ surfaceId: string,
47
+ ): A2uiSurfaceState =>
48
+ withSurfaceId(
49
+ {
50
+ components: new Map(surface.components),
51
+ dataModel: surface.dataModel,
52
+ },
53
+ getSurfaceId(surface) ?? surfaceId,
54
+ );
55
+
56
+ const upsertComponents = (
57
+ surface: A2uiSurfaceState,
58
+ components: unknown,
59
+ warnings: string[],
60
+ operationIndex: number,
61
+ ): void => {
62
+ if (!Array.isArray(components)) {
63
+ warnings.push(
64
+ `Operation at index ${operationIndex} has malformed components.`,
65
+ );
66
+ return;
67
+ }
68
+
69
+ for (
70
+ let componentIndex = 0;
71
+ componentIndex < components.length;
72
+ componentIndex++
73
+ ) {
74
+ const component = components[componentIndex];
75
+ if (
76
+ !isRecord(component) ||
77
+ typeof component["id"] !== "string" ||
78
+ component["id"].length === 0 ||
79
+ typeof component["component"] !== "string" ||
80
+ component["component"].length === 0
81
+ ) {
82
+ warnings.push(
83
+ `Component at index ${componentIndex} in operation ${operationIndex} is malformed.`,
84
+ );
85
+ continue;
86
+ }
87
+ surface.components.set(component["id"], { ...component });
88
+ }
89
+ };
90
+
91
+ const decodePointer = (path: string): string[] | undefined => {
92
+ if (path === "" || path === "/") return [];
93
+ if (!path.startsWith("/")) return undefined;
94
+ return path
95
+ .slice(1)
96
+ .split("/")
97
+ .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
98
+ };
99
+
100
+ const isArrayIndex = (segment: string): boolean =>
101
+ segment === "0" || /^[1-9]\d*$/.test(segment);
102
+
103
+ const setAtPointer = (
104
+ model: unknown,
105
+ path: string,
106
+ value: unknown,
107
+ ): { readonly ok: boolean; readonly value: unknown } => {
108
+ const segments = decodePointer(path);
109
+ if (!segments) return { ok: false, value: model };
110
+ if (segments.length === 0) return { ok: true, value };
111
+
112
+ const update = (current: unknown, index: number): unknown => {
113
+ const segment = segments[index]!;
114
+ const isLast = index === segments.length - 1;
115
+
116
+ if (Array.isArray(current)) {
117
+ if (segment !== "-" && !isArrayIndex(segment)) return current;
118
+ const clone = current.slice();
119
+ const targetIndex = segment === "-" ? clone.length : Number(segment);
120
+ clone[targetIndex] = isLast
121
+ ? value
122
+ : update(clone[targetIndex], index + 1);
123
+ return clone;
124
+ }
125
+
126
+ const clone: Record<string, unknown> = isRecord(current)
127
+ ? { ...current }
128
+ : {};
129
+ const child = clone[segment];
130
+ clone[segment] = isLast
131
+ ? value
132
+ : update(
133
+ child ??
134
+ (isArrayIndex(segments[index + 1] ?? "") ||
135
+ segments[index + 1] === "-"
136
+ ? []
137
+ : {}),
138
+ index + 1,
139
+ );
140
+ return clone;
141
+ };
142
+
143
+ return { ok: true, value: update(model, 0) };
144
+ };
145
+
146
+ const dataModelValue = (
147
+ payload: Record<string, unknown>,
148
+ ): { readonly found: boolean; readonly value: unknown } => {
149
+ for (const key of ["contents", "value", "data"] as const) {
150
+ if (payload[key] !== undefined) {
151
+ return { found: true, value: payload[key] };
152
+ }
153
+ }
154
+ return { found: false, value: undefined };
155
+ };
156
+
157
+ export function applyA2uiOperations(
158
+ state: A2uiState,
159
+ operations: unknown,
160
+ ): A2uiOperationResult {
161
+ const nextState = new Map(state);
162
+ const warnings: string[] = [];
163
+
164
+ if (!Array.isArray(operations)) {
165
+ return {
166
+ state: nextState,
167
+ warnings: ["A2UI operations must be an array."],
168
+ };
169
+ }
170
+
171
+ for (let index = 0; index < operations.length; index++) {
172
+ try {
173
+ const entry = operations[index];
174
+ if (!isRecord(entry)) {
175
+ warnings.push(`Operation at index ${index} is malformed.`);
176
+ continue;
177
+ }
178
+
179
+ const version = entry["version"];
180
+ if (!isVersion(version)) {
181
+ warnings.push(
182
+ `Operation at index ${index} has an unsupported version.`,
183
+ );
184
+ continue;
185
+ }
186
+
187
+ const operationKeys = Object.keys(entry).filter(
188
+ (key) => key !== "version",
189
+ );
190
+ if (operationKeys.length !== 1) {
191
+ warnings.push(
192
+ `Operation at index ${index} must contain exactly one operation key.`,
193
+ );
194
+ continue;
195
+ }
196
+
197
+ const operationKey = operationKeys[0]!;
198
+ if (!OPERATION_KEYS.has(operationKey)) {
199
+ warnings.push(
200
+ `Unknown A2UI operation "${operationKey}" at index ${index}.`,
201
+ );
202
+ continue;
203
+ }
204
+
205
+ const payload = entry[operationKey];
206
+ if (!isRecord(payload)) {
207
+ warnings.push(`Operation at index ${index} has a malformed payload.`);
208
+ continue;
209
+ }
210
+
211
+ const surfaceId = surfaceIdOf(payload);
212
+ if (!surfaceId) {
213
+ warnings.push(`Operation at index ${index} has no valid surfaceId.`);
214
+ continue;
215
+ }
216
+
217
+ if (operationKey === "createSurface") {
218
+ const surface = withSurfaceId(
219
+ {
220
+ components: new Map(),
221
+ dataModel:
222
+ version === "v1.0" && payload["dataModel"] !== undefined
223
+ ? payload["dataModel"]
224
+ : undefined,
225
+ },
226
+ surfaceId,
227
+ );
228
+ if (version === "v1.0" && payload["components"] !== undefined) {
229
+ upsertComponents(surface, payload["components"], warnings, index);
230
+ }
231
+ nextState.set(surfaceId, surface);
232
+ continue;
233
+ }
234
+
235
+ if (operationKey === "deleteSurface") {
236
+ nextState.delete(surfaceId);
237
+ continue;
238
+ }
239
+
240
+ const currentSurface = nextState.get(surfaceId);
241
+ if (!currentSurface) {
242
+ warnings.push(
243
+ `Operation at index ${index} references missing surface "${surfaceId}".`,
244
+ );
245
+ continue;
246
+ }
247
+ const surface = cloneSurface(currentSurface, surfaceId);
248
+
249
+ if (operationKey === "updateComponents") {
250
+ upsertComponents(surface, payload["components"], warnings, index);
251
+ nextState.set(surfaceId, surface);
252
+ continue;
253
+ }
254
+
255
+ const update = dataModelValue(payload);
256
+ if (!update.found) {
257
+ warnings.push(`Operation at index ${index} has no data model value.`);
258
+ continue;
259
+ }
260
+ const path = payload["path"] ?? "/";
261
+ if (typeof path !== "string") {
262
+ warnings.push(
263
+ `Operation at index ${index} has a malformed data model path.`,
264
+ );
265
+ continue;
266
+ }
267
+ const result = setAtPointer(surface.dataModel, path, update.value);
268
+ if (!result.ok) {
269
+ warnings.push(
270
+ `Operation at index ${index} has an invalid JSON Pointer path.`,
271
+ );
272
+ continue;
273
+ }
274
+ surface.dataModel = result.value;
275
+ nextState.set(surfaceId, surface);
276
+ } catch {
277
+ warnings.push(`Operation at index ${index} is malformed.`);
278
+ }
279
+ }
280
+
281
+ return { state: nextState, warnings };
282
+ }
@@ -0,0 +1,91 @@
1
+ export const A2UI_SURFACE_ID = Symbol("a2uiSurfaceId");
2
+
3
+ export type A2uiVersion = "v0.9" | "v1.0";
4
+
5
+ export interface A2uiTemplateChildren {
6
+ readonly template: {
7
+ readonly componentId: string;
8
+ readonly path: string;
9
+ };
10
+ }
11
+
12
+ export interface ComponentNode extends Record<string, unknown> {
13
+ readonly id: string;
14
+ readonly component: string;
15
+ readonly children?: readonly string[] | A2uiTemplateChildren;
16
+ }
17
+
18
+ export interface A2uiCreateSurfaceV09Payload {
19
+ readonly surfaceId: string;
20
+ readonly catalogId?: string;
21
+ readonly theme?: unknown;
22
+ readonly attachDataModel?: unknown;
23
+ }
24
+
25
+ export interface A2uiCreateSurfaceV10Payload {
26
+ readonly surfaceId: string;
27
+ readonly surfaceProperties?: unknown;
28
+ readonly sendDataModel?: unknown;
29
+ readonly components?: readonly ComponentNode[];
30
+ readonly dataModel?: unknown;
31
+ }
32
+
33
+ export interface A2uiUpdateComponentsPayload {
34
+ readonly surfaceId: string;
35
+ readonly components: readonly ComponentNode[];
36
+ }
37
+
38
+ export interface A2uiUpdateDataModelPayload {
39
+ readonly surfaceId: string;
40
+ readonly path?: string;
41
+ readonly contents?: unknown;
42
+ readonly value?: unknown;
43
+ readonly data?: unknown;
44
+ }
45
+
46
+ export interface A2uiDeleteSurfacePayload {
47
+ readonly surfaceId: string;
48
+ }
49
+
50
+ export type A2uiCreateSurfaceOperation =
51
+ | {
52
+ readonly version: "v0.9";
53
+ readonly createSurface: A2uiCreateSurfaceV09Payload;
54
+ }
55
+ | {
56
+ readonly version: "v1.0";
57
+ readonly createSurface: A2uiCreateSurfaceV10Payload;
58
+ };
59
+
60
+ export interface A2uiUpdateComponentsOperation {
61
+ readonly version: A2uiVersion;
62
+ readonly updateComponents: A2uiUpdateComponentsPayload;
63
+ }
64
+
65
+ export interface A2uiUpdateDataModelOperation {
66
+ readonly version: A2uiVersion;
67
+ readonly updateDataModel: A2uiUpdateDataModelPayload;
68
+ }
69
+
70
+ export interface A2uiDeleteSurfaceOperation {
71
+ readonly version: A2uiVersion;
72
+ readonly deleteSurface: A2uiDeleteSurfacePayload;
73
+ }
74
+
75
+ export type A2uiOperation =
76
+ | A2uiCreateSurfaceOperation
77
+ | A2uiUpdateComponentsOperation
78
+ | A2uiUpdateDataModelOperation
79
+ | A2uiDeleteSurfaceOperation;
80
+
81
+ export type A2uiSurfaceState = {
82
+ components: Map<string, Record<string, unknown>>;
83
+ dataModel: unknown;
84
+ };
85
+
86
+ export type A2uiState = ReadonlyMap<string, A2uiSurfaceState>;
87
+
88
+ export interface A2uiOperationResult {
89
+ readonly state: A2uiState;
90
+ readonly warnings: string[];
91
+ }
package/src/a2ui.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { applyA2uiOperations } from "./a2ui/reducer";
2
+ export { convertSurfaceToUISpec } from "./a2ui/convert";
3
+ export type {
4
+ A2uiCreateSurfaceOperation,
5
+ A2uiCreateSurfaceV09Payload,
6
+ A2uiCreateSurfaceV10Payload,
7
+ A2uiDeleteSurfaceOperation,
8
+ A2uiDeleteSurfacePayload,
9
+ A2uiOperation,
10
+ A2uiOperationResult,
11
+ A2uiState,
12
+ A2uiSurfaceState,
13
+ A2uiTemplateChildren,
14
+ A2uiUpdateComponentsOperation,
15
+ A2uiUpdateComponentsPayload,
16
+ A2uiUpdateDataModelOperation,
17
+ A2uiUpdateDataModelPayload,
18
+ A2uiVersion,
19
+ ComponentNode,
20
+ } from "./a2ui/types";
@@ -333,7 +333,7 @@ describe("dataVocabulary Chart stacking", () => {
333
333
  });
334
334
 
335
335
  describe("dataVocabulary Chart area variant", () => {
336
- it("renders a closed filled polygon with fill-opacity and a stroke on top", () => {
336
+ it("renders an unstroked filled polygon plus a top-edge polyline", () => {
337
337
  const html = render({
338
338
  $type: "Chart",
339
339
  variant: "area",
@@ -342,9 +342,14 @@ describe("dataVocabulary Chart area variant", () => {
342
342
  expect(html).toContain("<polygon");
343
343
  expect(html).toContain('fill="currentColor"');
344
344
  expect(html).toContain('fill-opacity="0.25"');
345
- expect(html).toContain('stroke="currentColor"');
346
- // top line 0,40 -> 100,0 then closed back down to the baseline in reverse.
345
+ expect(html).not.toMatch(/<polygon[^>]*stroke=/);
346
+ // fill polygon: top line 0,40 -> 100,0 then closed back down to the baseline in reverse.
347
347
  expect(html).toContain('points="0,40 100,0 100,40 0,40"');
348
+ // the visible edge is a separate polyline over the top points only.
349
+ expect(html).toMatch(
350
+ /<polyline[^>]*points="0,40 100,0"[^>]*stroke="currentColor"/,
351
+ );
352
+ expect(html).toMatch(/<polyline[^>]*vector-effect="non-scaling-stroke"/);
348
353
  });
349
354
 
350
355
  it("renders a circle instead of a degenerate polygon for a single point", () => {
@@ -483,7 +488,9 @@ describe("dataVocabulary Chart legend and x-labels", () => {
483
488
  { data: [{ value: 30 }, { value: 40 }] },
484
489
  ],
485
490
  });
486
- const points = [...html.matchAll(/points="([^"]+)"/g)].map((m) => m[1]!);
491
+ const points = [...html.matchAll(/<polygon[^>]*points="([^"]+)"/g)].map(
492
+ (m) => m[1]!,
493
+ );
487
494
  expect(points).toHaveLength(2);
488
495
  for (const polygon of points) {
489
496
  expect(polygon).toContain("100,40");
@@ -502,7 +509,9 @@ describe("dataVocabulary Chart legend and x-labels", () => {
502
509
  { data: [{ value: 30 }, { value: 40 }] },
503
510
  ],
504
511
  });
505
- const points = [...html.matchAll(/points="([^"]+)"/g)].map((m) => m[1]!);
512
+ const points = [...html.matchAll(/<polygon[^>]*points="([^"]+)"/g)].map(
513
+ (m) => m[1]!,
514
+ );
506
515
  expect(points).toHaveLength(2);
507
516
  expect(points[1]).toContain("100,0");
508
517
  expect(points[1]).not.toContain("100,40");
@@ -231,7 +231,12 @@ function renderSeriesMarks(
231
231
  points={[...topPoints, ...bottomPoints].join(" ")}
232
232
  fill="currentColor"
233
233
  fillOpacity="0.25"
234
+ />
235
+ <polyline
236
+ points={topPoints.join(" ")}
237
+ fill="none"
234
238
  stroke="currentColor"
239
+ vectorEffect="non-scaling-stroke"
235
240
  />
236
241
  </g>
237
242
  );
@@ -262,6 +267,7 @@ function renderSeriesMarks(
262
267
  .join(" ")}
263
268
  fill="none"
264
269
  stroke="currentColor"
270
+ vectorEffect="non-scaling-stroke"
265
271
  />
266
272
  ) : null}
267
273
  </g>