@adecore/intelligent-ui 0.0.0 → 0.21.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/README.md +2 -2
- package/dist/compiler.d.ts +17 -0
- package/dist/compiler.js +130 -34
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/links.d.ts +47 -0
- package/dist/links.js +39 -0
- package/dist/protocol.js +2 -1
- package/dist/query.d.ts +6 -0
- package/dist/query.js +115 -0
- package/dist/runtime.d.ts +8 -0
- package/dist/runtime.js +105 -0
- package/dist/stream.d.ts +28 -0
- package/dist/stream.js +56 -0
- package/dist/text.js +15 -4
- package/package.json +91 -76
- package/src/compiler.ts +148 -33
- package/src/index.ts +27 -2
- package/src/links.ts +49 -0
- package/src/protocol.ts +2 -1
- package/src/query.ts +121 -0
- package/src/runtime.ts +119 -0
- package/src/stream.ts +78 -0
- package/src/text.ts +16 -4
package/src/query.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { UI_CATALOG_VERSION } from './catalog.ts';
|
|
2
|
+
import { UiBudget, UiFailure } from './budget.ts';
|
|
3
|
+
import type { UiBlock, UiNode } from './compiler.ts';
|
|
4
|
+
import { copyUiValue, evaluateUiExpression, type UiValue } from './expression.ts';
|
|
5
|
+
import { evaluateUiBlock, uiInputValues, UiState, type UiViewNode } from './runtime.ts';
|
|
6
|
+
|
|
7
|
+
function findBinding(nodes: readonly UiNode[], id: string, prop: string): string | undefined {
|
|
8
|
+
for (const node of nodes) {
|
|
9
|
+
if (node.id === id) {
|
|
10
|
+
return node.bindings[prop];
|
|
11
|
+
}
|
|
12
|
+
const found = findBinding(node.children, id, prop);
|
|
13
|
+
if (found) {
|
|
14
|
+
return found;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function uiValidatedState(block: UiBlock, input: Readonly<Record<string, unknown>> = {}, queries: Readonly<Record<string, unknown>> = {}): UiState {
|
|
21
|
+
if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
|
|
22
|
+
throw new UiFailure('invalid_block', 'Only completed supported blocks can resolve input.');
|
|
23
|
+
}
|
|
24
|
+
const values = copyUiValue(input, new UiBudget()) as Record<string, UiValue>;
|
|
25
|
+
const state = new UiState(block);
|
|
26
|
+
for (const [name, value] of Object.entries(queries)) {
|
|
27
|
+
state.setQuery(name, value, block);
|
|
28
|
+
}
|
|
29
|
+
const allowed = uiInputValues(block, state);
|
|
30
|
+
for (const [key, value] of Object.entries(values)) {
|
|
31
|
+
if (!Object.hasOwn(allowed, key)) {
|
|
32
|
+
throw new UiFailure('refused_binding', 'Only declared input bindings can be submitted.');
|
|
33
|
+
}
|
|
34
|
+
state.set(key, value);
|
|
35
|
+
}
|
|
36
|
+
const visible = new Set<string>();
|
|
37
|
+
const visit = (nodes: readonly UiViewNode[]) => {
|
|
38
|
+
for (const node of nodes) {
|
|
39
|
+
if (node.error || !node.complete) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
let valid = true;
|
|
43
|
+
if (node.type === 'Segmented') {
|
|
44
|
+
valid = node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
|
|
45
|
+
}
|
|
46
|
+
if (node.type === 'Checklist') {
|
|
47
|
+
const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
|
|
48
|
+
valid = (node.props.value as unknown[]).every((value) => options.includes(value));
|
|
49
|
+
}
|
|
50
|
+
if (valid) {
|
|
51
|
+
for (const [key, binding] of Object.entries(node.bindings)) {
|
|
52
|
+
const original = findBinding(block.nodes, node.sourceId ?? node.id, key);
|
|
53
|
+
if (original && binding) {
|
|
54
|
+
visible.add(original);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
visit(node.children);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const evaluated = evaluateUiBlock(block, state);
|
|
62
|
+
if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
|
|
63
|
+
throw new UiFailure('budget_exceeded', 'The query inputs exceeded their evaluation budget.');
|
|
64
|
+
}
|
|
65
|
+
visit(evaluated.nodes);
|
|
66
|
+
for (const [key, value] of Object.entries(values)) {
|
|
67
|
+
if (JSON.stringify(value) !== JSON.stringify(allowed[key]) && !visible.has(key)) {
|
|
68
|
+
throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function uiQueryArguments(block: UiBlock, name: string, input: Readonly<Record<string, unknown>> = {}): Record<string, UiValue> {
|
|
75
|
+
if (!Object.hasOwn(block.queries, name)) {
|
|
76
|
+
throw new UiFailure('invalid_query', 'This block does not declare that query.');
|
|
77
|
+
}
|
|
78
|
+
const state = uiValidatedState(block, input);
|
|
79
|
+
const query = block.queries[name];
|
|
80
|
+
return query.expression
|
|
81
|
+
? (evaluateUiExpression(query.expression, state.scope(), new UiBudget()) as Record<string, UiValue>)
|
|
82
|
+
: (copyUiValue(query.args, new UiBudget()) as Record<string, UiValue>);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function uiQueryFallback(block: UiBlock, queries: Readonly<Record<string, unknown>>): string {
|
|
86
|
+
const state = new UiState(block);
|
|
87
|
+
for (const [name, value] of Object.entries(queries)) {
|
|
88
|
+
state.setQuery(name, value, block);
|
|
89
|
+
}
|
|
90
|
+
const plain = (value: unknown) => (value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
91
|
+
const text = (nodes: readonly UiViewNode[]): string =>
|
|
92
|
+
nodes
|
|
93
|
+
.map((node) => {
|
|
94
|
+
if (node.error) {
|
|
95
|
+
return node.fallback;
|
|
96
|
+
}
|
|
97
|
+
if (node.type === '$text') {
|
|
98
|
+
return plain(node.props.text);
|
|
99
|
+
}
|
|
100
|
+
const props = node.props;
|
|
101
|
+
const children = text(node.children);
|
|
102
|
+
if (node.type === 'Stat') {
|
|
103
|
+
return `${plain(props.label)}: ${plain(props.value)}${props.unit ? ` ${plain(props.unit)}` : ''}\n`;
|
|
104
|
+
}
|
|
105
|
+
if (node.type === 'Table') {
|
|
106
|
+
return `${plain(props.rows)}\n`;
|
|
107
|
+
}
|
|
108
|
+
if (node.type === 'Chart') {
|
|
109
|
+
return `${plain(props.data)}\n`;
|
|
110
|
+
}
|
|
111
|
+
if (node.type === 'Source') {
|
|
112
|
+
return `${plain(props.label)}: ${plain(props.url)}\n`;
|
|
113
|
+
}
|
|
114
|
+
if (node.type === 'Image') {
|
|
115
|
+
return node.fallback;
|
|
116
|
+
}
|
|
117
|
+
return `${(props.label ?? props.title) ? `${plain(props.label ?? props.title)}: ` : ''}${plain(props.path ?? props.sha ?? (node.type === 'Node' ? props.id : null))}${children}${props.context ? `\n${plain(props.context)}` : ''}${node.type === 'Tag' ? '' : '\n'}`;
|
|
118
|
+
})
|
|
119
|
+
.join('');
|
|
120
|
+
return text(evaluateUiBlock(block, state).nodes).trim() || block.fallback;
|
|
121
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface UiBinding<Value> {
|
|
|
11
11
|
|
|
12
12
|
export interface UiViewNode<Props = Record<string, unknown>> {
|
|
13
13
|
id: string;
|
|
14
|
+
sourceId?: string;
|
|
14
15
|
type: string;
|
|
15
16
|
props: Props;
|
|
16
17
|
bindings: Readonly<Record<string, UiBinding<unknown>>>;
|
|
@@ -149,6 +150,7 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
|
|
|
149
150
|
const visit = (node: UiNode, variables: Readonly<Record<string, UiValue>>, depth: number, suffix = ''): UiViewNode[] => {
|
|
150
151
|
const view: UiViewNode = {
|
|
151
152
|
id: `${node.id}${suffix}`,
|
|
153
|
+
sourceId: node.id,
|
|
152
154
|
type: node.type,
|
|
153
155
|
props: {},
|
|
154
156
|
bindings: {},
|
|
@@ -240,3 +242,120 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
|
|
|
240
242
|
}
|
|
241
243
|
return result;
|
|
242
244
|
}
|
|
245
|
+
|
|
246
|
+
export interface UiChoiceSelection {
|
|
247
|
+
label: string;
|
|
248
|
+
context: string;
|
|
249
|
+
values: Record<string, UiValue>;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function uiInputValues(block: UiBlock, state: UiState): Record<string, UiValue> {
|
|
253
|
+
const scope = state.scope();
|
|
254
|
+
const values: Record<string, UiValue> = Object.create(null);
|
|
255
|
+
const visit = (nodes: readonly UiNode[]) => {
|
|
256
|
+
for (const node of nodes) {
|
|
257
|
+
if (isUiComponent(node.type) && !node.error && node.complete) {
|
|
258
|
+
const entry = UI_CATALOG[node.type];
|
|
259
|
+
for (const [prop, name] of Object.entries(node.bindings)) {
|
|
260
|
+
if ('binding' in entry && entry.binding === prop && Object.hasOwn(block.defaults, name) && !Object.hasOwn(block.queries, name)) {
|
|
261
|
+
values[name] = scope[name];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
visit(node.children);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
visit(block.nodes);
|
|
269
|
+
return values;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* The host supplies its stored block; client data can change only declared input bindings. */
|
|
273
|
+
export function resolveUiChoice(
|
|
274
|
+
block: UiBlock,
|
|
275
|
+
choiceId: string,
|
|
276
|
+
input: Readonly<Record<string, unknown>> = {},
|
|
277
|
+
queries: Readonly<Record<string, unknown>> = {}
|
|
278
|
+
): UiChoiceSelection {
|
|
279
|
+
if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
|
|
280
|
+
throw new UiFailure('refused_choice', 'Only a completed supported block can send a choice.');
|
|
281
|
+
}
|
|
282
|
+
const values = copyUiValue(input, new UiBudget()) as Record<string, UiValue>;
|
|
283
|
+
const state = new UiState(block);
|
|
284
|
+
for (const [name, value] of Object.entries(queries)) {
|
|
285
|
+
state.setQuery(name, value, block);
|
|
286
|
+
}
|
|
287
|
+
const allowed = uiInputValues(block, state);
|
|
288
|
+
const changed = new Set<string>();
|
|
289
|
+
for (const [name, value] of Object.entries(values)) {
|
|
290
|
+
if (!Object.hasOwn(allowed, name)) {
|
|
291
|
+
throw new UiFailure('refused_binding', 'Only declared input bindings can be submitted.');
|
|
292
|
+
}
|
|
293
|
+
if (JSON.stringify(value) !== JSON.stringify(allowed[name])) {
|
|
294
|
+
state.set(name, value);
|
|
295
|
+
changed.add(name);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const evaluated = evaluateUiBlock(block, state);
|
|
299
|
+
if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
|
|
300
|
+
throw new UiFailure('budget_exceeded', 'The choice exceeded its evaluation budget.');
|
|
301
|
+
}
|
|
302
|
+
const validInputs = new Set<string>();
|
|
303
|
+
let choice: UiViewNode | undefined;
|
|
304
|
+
const written = new Map<string, UiNode>();
|
|
305
|
+
const remember = (nodes: readonly UiNode[]) => {
|
|
306
|
+
for (const node of nodes) {
|
|
307
|
+
written.set(node.id, node);
|
|
308
|
+
remember(node.children);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
remember(block.nodes);
|
|
312
|
+
const visit = (nodes: readonly UiViewNode[], parent?: string) => {
|
|
313
|
+
for (const node of nodes) {
|
|
314
|
+
const source = written.get(node.sourceId ?? node.id);
|
|
315
|
+
if (node.error && source && Object.values(source.bindings).some((name) => changed.has(name))) {
|
|
316
|
+
throw new UiFailure('invalid_value', 'An input value does not match its control.');
|
|
317
|
+
}
|
|
318
|
+
if (node.error || !node.complete) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
let valid = true;
|
|
322
|
+
if (node.type === 'Segmented') {
|
|
323
|
+
valid = node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
|
|
324
|
+
} else if (node.type === 'Checklist') {
|
|
325
|
+
const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
|
|
326
|
+
valid = (node.props.value as unknown[]).every((value) => options.includes(value));
|
|
327
|
+
}
|
|
328
|
+
if (valid && source) {
|
|
329
|
+
for (const prop of Object.keys(node.bindings)) {
|
|
330
|
+
validInputs.add(source.bindings[prop]);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (node.id === choiceId && node.type === 'Choice' && parent === 'Choices' && node.props.disabled !== true) {
|
|
334
|
+
choice = node;
|
|
335
|
+
}
|
|
336
|
+
visit(node.children, node.type);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
visit(evaluated.nodes);
|
|
340
|
+
if ([...changed].some((name) => !validInputs.has(name))) {
|
|
341
|
+
throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
|
|
342
|
+
}
|
|
343
|
+
if (!choice) {
|
|
344
|
+
throw new UiFailure('refused_choice', 'This choice is not available in the current block.');
|
|
345
|
+
}
|
|
346
|
+
const text = (nodes: readonly UiViewNode[]): string =>
|
|
347
|
+
nodes.map((node) => (node.type === '$text' ? String(node.props.text ?? '') : text(node.children))).join('');
|
|
348
|
+
const hasError = (nodes: readonly UiViewNode[]): boolean => nodes.some((node) => node.error !== undefined || hasError(node.children));
|
|
349
|
+
if (hasError(choice.children)) {
|
|
350
|
+
throw new UiFailure('refused_choice', 'The choice label could not be fully read.');
|
|
351
|
+
}
|
|
352
|
+
const label = text(choice.children).replace(/\s+/g, ' ').trim();
|
|
353
|
+
if (!label) {
|
|
354
|
+
throw new UiFailure('refused_choice', 'A choice needs a visible label.');
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
label,
|
|
358
|
+
values: uiInputValues(block, state),
|
|
359
|
+
context: typeof choice.props.context === 'string' && choice.props.context.trim() ? choice.props.context : label
|
|
360
|
+
};
|
|
361
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { UiCompiler, type UiBlock, type UiCompilerOptions } from './compiler.ts';
|
|
2
|
+
|
|
3
|
+
export const UI_STREAM_INTERVAL_MS = 250;
|
|
4
|
+
|
|
5
|
+
export interface UiStreamClock {
|
|
6
|
+
now(): number;
|
|
7
|
+
setTimeout(run: () => void, ms: number): unknown;
|
|
8
|
+
clearTimeout(handle: unknown): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UiStreamPreview {
|
|
12
|
+
textLength: number;
|
|
13
|
+
blocks: UiBlock[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UiStreamOptions extends UiCompilerOptions {
|
|
17
|
+
clock: UiStreamClock;
|
|
18
|
+
emit(preview: UiStreamPreview): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class UiStream {
|
|
22
|
+
private readonly compiler: UiCompiler;
|
|
23
|
+
private readonly options: UiStreamOptions;
|
|
24
|
+
private pending: { text: string; latestAttachment?: string } | null = null;
|
|
25
|
+
private timer: { handle: unknown } | null = null;
|
|
26
|
+
private lastCompiledAt = -Infinity;
|
|
27
|
+
private closed = false;
|
|
28
|
+
|
|
29
|
+
constructor(options: UiStreamOptions) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.compiler = new UiCompiler(options);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
update(text: string, latestAttachment?: string): void {
|
|
35
|
+
if (this.closed) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.pending = { text, latestAttachment };
|
|
39
|
+
if (this.timer !== null) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const delay = Math.max(0, UI_STREAM_INTERVAL_MS - (this.options.clock.now() - this.lastCompiledAt));
|
|
43
|
+
if (delay === 0) {
|
|
44
|
+
this.flush();
|
|
45
|
+
} else {
|
|
46
|
+
this.timer = { handle: this.options.clock.setTimeout(() => this.flush(), delay) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
finish(text: string, latestAttachment?: string): UiBlock[] {
|
|
51
|
+
this.dispose();
|
|
52
|
+
const blocks = this.compiler.compile(text, { final: true, latestAttachment });
|
|
53
|
+
this.compiler.clear();
|
|
54
|
+
return blocks;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
dispose(): void {
|
|
58
|
+
this.closed = true;
|
|
59
|
+
if (this.timer !== null) {
|
|
60
|
+
this.options.clock.clearTimeout(this.timer.handle);
|
|
61
|
+
this.timer = null;
|
|
62
|
+
}
|
|
63
|
+
this.pending = null;
|
|
64
|
+
this.compiler.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private flush(): void {
|
|
68
|
+
this.timer = null;
|
|
69
|
+
const pending = this.pending;
|
|
70
|
+
if (pending === null || this.closed) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.pending = null;
|
|
74
|
+
this.lastCompiledAt = this.options.clock.now();
|
|
75
|
+
const blocks = this.compiler.compile(pending.text, { latestAttachment: pending.latestAttachment });
|
|
76
|
+
this.options.emit({ textLength: pending.text.length, blocks });
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/text.ts
CHANGED
|
@@ -7,17 +7,28 @@ export function uiCompactCatalog(): string {
|
|
|
7
7
|
const props = Object.entries(entry.schema.shape)
|
|
8
8
|
.map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
|
|
9
9
|
.join(',');
|
|
10
|
-
return `${name}
|
|
10
|
+
return `${name}${props ? `: ${props}` : ''}`;
|
|
11
11
|
})
|
|
12
12
|
.join(' · ');
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const UI_SYNTAX = `Write XML-like component tags inside a fenced ${UI_FENCE_LANGUAGE} block. Put text between opening and closing tags; empty components end with />. Quote string props; put numbers, booleans and expressions in braces.`;
|
|
16
|
+
const UI_EXAMPLE = [
|
|
17
|
+
'```' + UI_FENCE_LANGUAGE,
|
|
18
|
+
'$enabled = false',
|
|
19
|
+
'<Summary badge="Draft">Release overview</Summary>',
|
|
20
|
+
'<Stats><Stat label="Tests" value={42}/></Stats>',
|
|
21
|
+
'<Switch value={$enabled}>Notify me</Switch>',
|
|
22
|
+
'<Choices><Choice context="Review the release overview">Continue</Choice></Choices>',
|
|
23
|
+
'```'
|
|
24
|
+
].join('\n');
|
|
25
|
+
|
|
15
26
|
export function uiSessionNote(): string {
|
|
16
|
-
return
|
|
27
|
+
return `${UI_SYNTAX} Prose outside. Only named catalog props, no styling. $name = literal JSON; input value={$name}; braced expressions: @Count, @Filter(list,row,predicate), @Sum, @Join, @Round. Show/Each control children. Choice labels and precise context stay visible and send once. Image: attachment or generated="latest", never URL/path. No code execution. State resets on reload. Catalog (? means optional props): ${uiCompactCatalog()}\nExample:\n${UI_EXAMPLE}\n`;
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export function uiReferenceText(): string {
|
|
20
|
-
|
|
31
|
+
const groups = Object.entries(UI_GROUPS)
|
|
21
32
|
.map(([group, definition]) => {
|
|
22
33
|
const components = Object.entries(UI_CATALOG)
|
|
23
34
|
.filter(([, entry]) => entry.group === group)
|
|
@@ -25,11 +36,12 @@ export function uiReferenceText(): string {
|
|
|
25
36
|
const props = Object.entries(entry.schema.shape)
|
|
26
37
|
.map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
|
|
27
38
|
.join(', ');
|
|
28
|
-
return `${name}
|
|
39
|
+
return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
|
|
29
40
|
});
|
|
30
41
|
return [definition.description, definition.rules, ...components].join('\n');
|
|
31
42
|
})
|
|
32
43
|
.join('\n\n');
|
|
44
|
+
return `${UI_SYNTAX}\nExample:\n${UI_EXAMPLE}\n\n${groups}`;
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
export function uiFallbackText(text: string, blocks: readonly UiBlock[]): string {
|