@adecore/intelligent-ui 0.21.0 → 0.21.2
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 +29 -57
- package/dist/actions.d.ts +5 -0
- package/dist/actions.js +34 -0
- package/dist/catalog.d.ts +9 -1
- package/dist/catalog.js +12 -8
- package/dist/compiler.d.ts +9 -1
- package/dist/compiler.js +104 -87
- package/dist/expression.d.ts +1 -0
- package/dist/expression.js +16 -0
- package/dist/fallback.d.ts +2 -0
- package/dist/fallback.js +25 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/inputs.d.ts +5 -0
- package/dist/inputs.js +69 -0
- package/dist/links.d.ts +3 -1
- package/dist/links.js +6 -3
- package/dist/prop-list.d.ts +7 -0
- package/dist/prop-list.js +5 -0
- package/dist/query.d.ts +3 -2
- package/dist/query.js +14 -77
- package/dist/runtime.d.ts +4 -2
- package/dist/runtime.js +63 -54
- package/dist/text.d.ts +5 -2
- package/dist/text.js +21 -20
- package/package.json +1 -1
- package/src/actions.ts +39 -0
- package/src/catalog.ts +12 -8
- package/src/compiler.ts +118 -87
- package/src/expression.ts +17 -0
- package/src/fallback.ts +26 -0
- package/src/index.ts +5 -2
- package/src/inputs.ts +74 -0
- package/src/links.ts +8 -3
- package/src/prop-list.ts +7 -0
- package/src/query.ts +26 -80
- package/src/runtime.ts +64 -53
- package/src/text.ts +27 -20
package/README.md
CHANGED
|
@@ -1,72 +1,44 @@
|
|
|
1
1
|
# @adecore/intelligent-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@adecore/intelligent-ui)
|
|
4
|
+
[](https://adecore.dev/intelligent-ui/)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Lets an agent answer with a block of components instead of prose: a summary, a table, a checklist, the next steps a person picks from. The package holds the catalog, a compiler that reads blocks out of a reply while it streams, and a bounded interpreter for their state and expressions. No framework and no DOM: a renderer draws what it returns. A block cannot run code, load an address or act outside itself.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
import { compileUi, evaluateUiBlock, UiState } from '@adecore/intelligent-ui';
|
|
9
|
-
|
|
10
|
-
const blocks = compileUi(reply, { id: assistantItemId, final: true });
|
|
11
|
-
for (const block of blocks) {
|
|
12
|
-
const state = new UiState(block);
|
|
13
|
-
const view = evaluateUiBlock(block, state);
|
|
14
|
-
render(view.nodes);
|
|
15
|
-
}
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
## Compilation
|
|
19
|
-
|
|
20
|
-
`compileUi(text, options)` finds blocks marked with `UI_FENCE_LANGUAGE` among Markdown, including unfinished fences. It ignores UI fences nested inside another code fence. Each `UiBlock` has its own ID, text range, catalog version, declarations, nodes, diagnostics and Markdown fallback. `compileUiBlock(source, options)` compiles one fence's contents.
|
|
21
|
-
|
|
22
|
-
`UiCompileOptions.id` identifies the assistant item or block. Supply the same ID on each recompile. Node IDs include the start of their tag, so appending text preserves them. `final` reports unfinished syntax when the authoritative response arrives. `latestAttachment` resolves `Image generated="latest"` to an attachment. `limits` overrides the work budget; `now` supplies a clock for deterministic tests.
|
|
23
|
-
|
|
24
|
-
The parser recovers at the next tag or line after malformed input. Open tags remain incomplete. A streamed quoted prop exposes the text already received. CodeBlock contents are literal, including angle brackets and braces. Unknown components retain their own fallback; unknown props produce `refused_prop` and are dropped. A budget failure replaces the whole compiled block with a bounded fallback and one diagnosis.
|
|
25
|
-
|
|
26
|
-
`UiNode` keeps static `props` separate from `expressions` and local-variable `bindings`. Its `type` is a string so an unfamiliar component can fall back without rejecting neighboring content. `UiNodeSchema`, `UiBlockSchema`, `UiBlocksSchema` and `UiDiagnosticSchema` describe the wire envelope. Component names, props and expressions stay open, so a future catalog can be retained and shown as fallback. The optional assistant field and daemon integration still await the package's first publication setup.
|
|
8
|
+
**[Documentation](https://adecore.dev/intelligent-ui/)**
|
|
27
9
|
|
|
28
|
-
|
|
10
|
+
## Install
|
|
29
11
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`UiComponentName` is the key union. `UiProps<Name>` is inferred from that component's schema. `UiToneSchema` and `UiTone` provide neutral, info, success, warning and danger. `UI_CATALOG_VERSION` identifies this catalog.
|
|
35
|
-
|
|
36
|
-
| Group | Components |
|
|
37
|
-
| --- | --- |
|
|
38
|
-
| Status | Summary, Callout, Tag, Progress, Steps / Step |
|
|
39
|
-
| Data | Stats / Stat, EntityList / Entry, Table / Column, Chart |
|
|
40
|
-
| Structure | Tabs / Tab, Sections / Section |
|
|
41
|
-
| Content | CodeBlock, Image, Sources / Source |
|
|
42
|
-
| Host links | File, Diff, Commit, Node |
|
|
43
|
-
| Local inputs | Checklist / Item, Switch, Slider, Segmented / Option, Show, Each |
|
|
44
|
-
| Choices | Choices / Choice |
|
|
45
|
-
|
|
46
|
-
Image accepts exactly one of `attachment` and `generated="latest"`, plus an optional alt and caption. It never takes a URL or a filesystem path. Charts use rows with a label and numeric series; their renderer will validate usable series before drawing. A Column names a field and optionally its title, unit and presentation.
|
|
47
|
-
|
|
48
|
-
## Expressions and state
|
|
12
|
+
```sh
|
|
13
|
+
bun add @adecore/intelligent-ui
|
|
14
|
+
```
|
|
49
15
|
|
|
50
|
-
|
|
16
|
+
## Use
|
|
51
17
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
| `@Count` | list or string |
|
|
55
|
-
| `@Filter` | list, local name, boolean predicate, e.g. `@Filter($rows, row, row.failed)` |
|
|
56
|
-
| `@Sum` | numeric list, or record list and a field name |
|
|
57
|
-
| `@Join` | scalar list and optional separator |
|
|
58
|
-
| `@Round` | number and optional precision from zero to six |
|
|
18
|
+
```ts
|
|
19
|
+
import { compileUi, evaluateUiBlock, uiSessionNote, UiState } from '@adecore/intelligent-ui';
|
|
59
20
|
|
|
60
|
-
|
|
21
|
+
const instructions = uiSessionNote(); // tell the agent the language
|
|
61
22
|
|
|
62
|
-
|
|
23
|
+
const blocks = compileUi(replyText, { id: replyId, final: true });
|
|
24
|
+
for (const block of blocks) {
|
|
25
|
+
const state = new UiState(block);
|
|
26
|
+
render(evaluateUiBlock(block, state).nodes);
|
|
27
|
+
}
|
|
28
|
+
```
|
|
63
29
|
|
|
64
|
-
`
|
|
30
|
+
`@adecore/agents` compiles the blocks of a chat session, and `@adecore/agents-react` draws them with live data, links and choices.
|
|
65
31
|
|
|
66
|
-
|
|
32
|
+
## Documentation
|
|
67
33
|
|
|
68
|
-
|
|
34
|
+
| Page | What it covers |
|
|
35
|
+
|---|---|
|
|
36
|
+
| [Getting started](https://adecore.dev/intelligent-ui/guide/getting-started) | Instructions, compilation, the wire and rendering |
|
|
37
|
+
| [Security model](https://adecore.dev/intelligent-ui/guide/security) | What a block can and cannot do |
|
|
38
|
+
| [Syntax](https://adecore.dev/intelligent-ui/language/syntax) | The language an agent writes |
|
|
39
|
+
| [Components](https://adecore.dev/intelligent-ui/components/status) | Every component and its props |
|
|
40
|
+
| [Live queries](https://adecore.dev/intelligent-ui/host/queries) | Data a host reads for a block |
|
|
69
41
|
|
|
70
|
-
|
|
42
|
+
## License
|
|
71
43
|
|
|
72
|
-
|
|
44
|
+
FSL-1.1-MIT, see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { UiBlock } from './compiler.ts';
|
|
2
|
+
import type { UiExpression } from './expression.ts';
|
|
3
|
+
export declare function uiActionProp(type: string): string | undefined;
|
|
4
|
+
export declare function uiActionTarget(action: UiExpression, local: (name: string) => boolean): string | null;
|
|
5
|
+
export declare function uiLocalState(block: Pick<UiBlock, 'defaults' | 'queries'>): (name: string) => boolean;
|
package/dist/actions.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { UiFailure } from './budget.js';
|
|
2
|
+
import { isUiComponent, UI_CATALOG } from './catalog.js';
|
|
3
|
+
/* The prop of a component that holds a state action, kept unevaluated, instead of a value. */
|
|
4
|
+
export function uiActionProp(type) {
|
|
5
|
+
if (!isUiComponent(type)) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
const entry = UI_CATALOG[type];
|
|
9
|
+
return 'action' in entry ? entry.action : undefined;
|
|
10
|
+
}
|
|
11
|
+
/*
|
|
12
|
+
* The variable an action writes, or null for `@Reset()`, which restores them all. Throws for anything
|
|
13
|
+
* but `@Set($name, value)`, `@Reset($name)` and `@Reset()` on a variable that `local` accepts.
|
|
14
|
+
*/
|
|
15
|
+
export function uiActionTarget(action, local) {
|
|
16
|
+
if (action.kind !== 'call' || (action.name !== 'Set' && action.name !== 'Reset')) {
|
|
17
|
+
throw new UiFailure('refused_action', 'Expected @Set or @Reset.');
|
|
18
|
+
}
|
|
19
|
+
if (action.name === 'Reset' && action.args.length === 0) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (action.args.length !== (action.name === 'Set' ? 2 : 1)) {
|
|
23
|
+
throw new UiFailure('refused_action', 'Invalid state action arguments.');
|
|
24
|
+
}
|
|
25
|
+
const reference = action.args[0];
|
|
26
|
+
if (reference.kind !== 'reference' || !local(reference.name)) {
|
|
27
|
+
throw new UiFailure('refused_binding', 'The action needs a local state variable.');
|
|
28
|
+
}
|
|
29
|
+
return reference.name;
|
|
30
|
+
}
|
|
31
|
+
/* Whether `name` is local state of the block: declared with a default and never a query. */
|
|
32
|
+
export function uiLocalState(block) {
|
|
33
|
+
return (name) => Object.hasOwn(block.defaults, name) && !Object.hasOwn(block.queries, name);
|
|
34
|
+
}
|
package/dist/catalog.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export declare const UI_GROUPS: {
|
|
|
31
31
|
};
|
|
32
32
|
readonly inputs: {
|
|
33
33
|
readonly description: 'Change local block state.';
|
|
34
|
-
readonly rules: 'Bind value={$name}. State stays local until a Choice sends visible context.';
|
|
34
|
+
readonly rules: 'Bind value={$name}. A Button sets a literal with action={@Set($name, value)} or restores a default with {@Reset($name)} or {@Reset()}. State stays local until a Choice sends visible context.';
|
|
35
35
|
};
|
|
36
36
|
readonly choices: {
|
|
37
37
|
readonly description: 'Ask the person to pick a next step.';
|
|
@@ -330,6 +330,14 @@ export declare const UI_CATALOG: {
|
|
|
330
330
|
}, z.core.$strict>;
|
|
331
331
|
readonly parent: 'Segmented';
|
|
332
332
|
};
|
|
333
|
+
readonly Button: {
|
|
334
|
+
readonly group: 'inputs';
|
|
335
|
+
readonly description: 'Run one local state action when pressed; children are the label.';
|
|
336
|
+
readonly schema: z.ZodObject<{
|
|
337
|
+
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
338
|
+
}, z.core.$strict>;
|
|
339
|
+
readonly action: 'action';
|
|
340
|
+
};
|
|
333
341
|
readonly Show: {
|
|
334
342
|
readonly group: 'inputs';
|
|
335
343
|
readonly description: 'Show children when the condition is true.';
|
package/dist/catalog.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { uiPropList } from './prop-list.js';
|
|
2
3
|
export const UI_CATALOG_VERSION = 1;
|
|
3
4
|
export const UiToneSchema = z.enum(['neutral', 'info', 'success', 'warning', 'danger']);
|
|
4
5
|
const text = z.string().max(16384);
|
|
@@ -17,7 +18,10 @@ export const UI_GROUPS = {
|
|
|
17
18
|
rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path.'
|
|
18
19
|
},
|
|
19
20
|
links: { description: 'Refer to a host-owned resource.', rules: 'Targets are checked by the host. No arbitrary navigation or file reads.' },
|
|
20
|
-
inputs: {
|
|
21
|
+
inputs: {
|
|
22
|
+
description: 'Change local block state.',
|
|
23
|
+
rules: 'Bind value={$name}. A Button sets a literal with action={@Set($name, value)} or restores a default with {@Reset($name)} or {@Reset()}. State stays local until a Choice sends visible context.'
|
|
24
|
+
},
|
|
21
25
|
choices: {
|
|
22
26
|
description: 'Ask the person to pick a next step.',
|
|
23
27
|
rules: 'Give a short label and precise visible context. A Choice sends once after streaming ends.'
|
|
@@ -115,6 +119,12 @@ export const UI_CATALOG = {
|
|
|
115
119
|
},
|
|
116
120
|
Segmented: { group: 'inputs', description: 'Choose one local value.', schema: z.strictObject({ value: scalar }), children: ['Option'], binding: 'value' },
|
|
117
121
|
Option: { group: 'inputs', description: 'One local alternative.', schema: z.strictObject({ value: scalar }), parent: 'Segmented' },
|
|
122
|
+
Button: {
|
|
123
|
+
group: 'inputs',
|
|
124
|
+
description: 'Run one local state action when pressed; children are the label.',
|
|
125
|
+
schema: z.strictObject({ disabled: z.boolean().optional() }),
|
|
126
|
+
action: 'action'
|
|
127
|
+
},
|
|
118
128
|
Show: { group: 'inputs', description: 'Show children when the condition is true.', schema: z.strictObject({ when: z.boolean() }) },
|
|
119
129
|
Each: {
|
|
120
130
|
group: 'inputs',
|
|
@@ -134,12 +144,6 @@ export function isUiComponent(name) {
|
|
|
134
144
|
}
|
|
135
145
|
export function uiCatalogText() {
|
|
136
146
|
return Object.entries(UI_CATALOG)
|
|
137
|
-
.map(([name, entry]) => {
|
|
138
|
-
const shape = entry.schema.shape;
|
|
139
|
-
const props = Object.entries(shape)
|
|
140
|
-
.map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
|
|
141
|
-
.join(', ');
|
|
142
|
-
return `${name}(${props}): ${entry.description}`;
|
|
143
|
-
})
|
|
147
|
+
.map(([name, entry]) => `${name}(${uiPropList(entry).join(', ')}): ${entry.description}`)
|
|
144
148
|
.join('\n');
|
|
145
149
|
}
|
package/dist/compiler.d.ts
CHANGED
|
@@ -2,13 +2,19 @@ import { type UiLimits } from './budget.ts';
|
|
|
2
2
|
import { type UiExpression, type UiValue } from './expression.ts';
|
|
3
3
|
import { type UiDiagnostic } from './syntax.ts';
|
|
4
4
|
import { type z } from 'zod';
|
|
5
|
-
export declare const UI_FENCE_LANGUAGE = "
|
|
5
|
+
export declare const UI_FENCE_LANGUAGE = "ui";
|
|
6
6
|
export declare const UI_REPLY_LIMITS: {
|
|
7
7
|
readonly blocks: 16;
|
|
8
8
|
readonly characters: 262144;
|
|
9
9
|
readonly nodes: 2048;
|
|
10
10
|
readonly milliseconds: 60;
|
|
11
11
|
};
|
|
12
|
+
export declare const UI_HOST_LIMITS: {
|
|
13
|
+
readonly queries: 8;
|
|
14
|
+
readonly links: 64;
|
|
15
|
+
readonly refreshMilliseconds: 10000;
|
|
16
|
+
};
|
|
17
|
+
export declare function uiMayReferenceHost(text: string): boolean;
|
|
12
18
|
export interface UiNode {
|
|
13
19
|
id: string;
|
|
14
20
|
type: string;
|
|
@@ -46,6 +52,7 @@ export interface UiCompileOptions {
|
|
|
46
52
|
limits?: Partial<UiLimits>;
|
|
47
53
|
latestAttachment?: string;
|
|
48
54
|
querySchemas?: Readonly<Record<string, z.ZodType>>;
|
|
55
|
+
fenceLanguage?: string;
|
|
49
56
|
now?: () => number;
|
|
50
57
|
}
|
|
51
58
|
export declare function compileUiBlock(source: string, options: UiCompileOptions): UiBlock;
|
|
@@ -58,4 +65,5 @@ export declare class UiCompiler {
|
|
|
58
65
|
compile(text: string, update?: UiCompileUpdate): UiBlock[];
|
|
59
66
|
clear(): void;
|
|
60
67
|
}
|
|
68
|
+
export declare function uiHasFence(text: string, fenceLanguage?: string): boolean;
|
|
61
69
|
export declare function compileUi(text: string, options: UiCompileOptions): UiBlock[];
|
package/dist/compiler.js
CHANGED
|
@@ -1,10 +1,69 @@
|
|
|
1
|
-
import { safeKey, UiBudget, UiFailure } from './budget.js';
|
|
1
|
+
import { safeKey, UI_LIMITS, UiBudget, UiFailure } from './budget.js';
|
|
2
2
|
import { isUiComponent, UI_CATALOG, UI_CATALOG_VERSION } from './catalog.js';
|
|
3
|
+
import { uiActionProp, uiActionTarget, uiLocalState } from './actions.js';
|
|
4
|
+
import { uiNodeFallback, uiPlainText } from './fallback.js';
|
|
3
5
|
import { evaluateUiExpression } from './expression.js';
|
|
4
6
|
import { parseUiSyntax, uiDiagnostic } from './syntax.js';
|
|
5
7
|
import {} from 'zod';
|
|
6
|
-
|
|
8
|
+
// The info string a fence carries when the host names none of its own.
|
|
9
|
+
export const UI_FENCE_LANGUAGE = 'ui';
|
|
7
10
|
export const UI_REPLY_LIMITS = { blocks: 16, characters: 262144, nodes: 2048, milliseconds: 60 };
|
|
11
|
+
// What a host reads for one block: its queries, its link targets and how often a visible block reads again.
|
|
12
|
+
export const UI_HOST_LIMITS = { queries: 8, links: 64, refreshMilliseconds: 10_000 };
|
|
13
|
+
/* A cheap test for whether `text` may declare a query or name a link target, before anything is read for it. */
|
|
14
|
+
export function uiMayReferenceHost(text) {
|
|
15
|
+
if (text.includes('@Query')) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
const links = Object.entries(UI_CATALOG)
|
|
19
|
+
.filter(([, entry]) => entry.group === 'links')
|
|
20
|
+
.map(([name]) => name);
|
|
21
|
+
return new RegExp(`<(?:${links.join('|')})\\b`).test(text);
|
|
22
|
+
}
|
|
23
|
+
/* A spent budget ends the whole block; every other failure only marks the node or declaration it came from. */
|
|
24
|
+
function rethrowBudget(error) {
|
|
25
|
+
if (error instanceof UiFailure && error.code === 'budget_exceeded') {
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function isQuery(expression) {
|
|
30
|
+
return expression.kind === 'call' && expression.name === 'Query';
|
|
31
|
+
}
|
|
32
|
+
/* Throws when a query argument reads anything but declared local state. */
|
|
33
|
+
function checkQueryReferences(part, defaults) {
|
|
34
|
+
if (!part || typeof part !== 'object') {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if ('kind' in part && part.kind === 'reference' && 'name' in part && !Object.hasOwn(defaults, String(part.name))) {
|
|
38
|
+
throw new UiFailure('invalid_query', 'Query arguments may reference only declared local state.');
|
|
39
|
+
}
|
|
40
|
+
for (const value of Object.values(part)) {
|
|
41
|
+
checkQueryReferences(value, defaults);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function compileQuery(expression, block, budget, querySchemas) {
|
|
45
|
+
if (expression.args.length !== 2 || !isConstant(expression.args[0]) || expression.args[1].kind !== 'object') {
|
|
46
|
+
throw new UiFailure('invalid_query', '@Query needs a literal source and an argument record.');
|
|
47
|
+
}
|
|
48
|
+
const querySource = evaluateUiExpression(expression.args[0], {}, budget);
|
|
49
|
+
const argument = expression.args[1];
|
|
50
|
+
checkQueryReferences(argument, block.defaults);
|
|
51
|
+
const args = evaluateUiExpression(argument, block.defaults, budget);
|
|
52
|
+
if (typeof querySource !== 'string' || !args || Array.isArray(args) || typeof args !== 'object') {
|
|
53
|
+
throw new UiFailure('invalid_query', 'Invalid query source or argument record.');
|
|
54
|
+
}
|
|
55
|
+
if (!querySchemas || !Object.hasOwn(querySchemas, querySource)) {
|
|
56
|
+
throw new UiFailure('unknown_query', 'The host did not register this query source.');
|
|
57
|
+
}
|
|
58
|
+
const checked = querySchemas[querySource].safeParse(args);
|
|
59
|
+
if (!checked.success) {
|
|
60
|
+
throw new UiFailure('invalid_query', 'Query arguments do not match the host schema.');
|
|
61
|
+
}
|
|
62
|
+
if (Object.keys(block.queries).length >= UI_HOST_LIMITS.queries) {
|
|
63
|
+
throw new UiFailure('budget_exceeded', `A block may declare at most ${UI_HOST_LIMITS.queries} queries.`);
|
|
64
|
+
}
|
|
65
|
+
return { source: querySource, args, ...(!isConstant(argument) ? { expression: argument } : {}) };
|
|
66
|
+
}
|
|
8
67
|
function isConstant(expression) {
|
|
9
68
|
switch (expression.kind) {
|
|
10
69
|
case 'literal':
|
|
@@ -19,9 +78,6 @@ function isConstant(expression) {
|
|
|
19
78
|
return false;
|
|
20
79
|
}
|
|
21
80
|
}
|
|
22
|
-
function plain(value) {
|
|
23
|
-
return value === null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
24
|
-
}
|
|
25
81
|
export function compileUiBlock(source, options) {
|
|
26
82
|
const budget = new UiBudget(options.limits, options.now);
|
|
27
83
|
const block = {
|
|
@@ -41,8 +97,16 @@ export function compileUiBlock(source, options) {
|
|
|
41
97
|
block.diagnostics.push(uiDiagnostic(error, start, end, nodeId));
|
|
42
98
|
}
|
|
43
99
|
};
|
|
44
|
-
|
|
45
|
-
|
|
100
|
+
// A pressed action must set the same value whatever state it is pressed in, so a choice can check it.
|
|
101
|
+
const checkedAction = (action) => {
|
|
102
|
+
uiActionTarget(action, uiLocalState(block));
|
|
103
|
+
if (action.kind === 'call' && action.name === 'Set' && !isConstant(action.args[1])) {
|
|
104
|
+
throw new UiFailure('refused_action', '@Set in an action takes a literal value.');
|
|
105
|
+
}
|
|
106
|
+
return action;
|
|
107
|
+
};
|
|
108
|
+
const compile = (syntax, depth, parent) => {
|
|
109
|
+
budget.depth(depth);
|
|
46
110
|
const node = {
|
|
47
111
|
id: syntax.id,
|
|
48
112
|
type: syntax.type,
|
|
@@ -61,12 +125,10 @@ export function compileUiBlock(source, options) {
|
|
|
61
125
|
if (syntax.expression) {
|
|
62
126
|
node.expressions.text = syntax.expression;
|
|
63
127
|
try {
|
|
64
|
-
node.fallback =
|
|
128
|
+
node.fallback = uiPlainText(evaluateUiExpression(syntax.expression, block.defaults, budget));
|
|
65
129
|
}
|
|
66
130
|
catch (error) {
|
|
67
|
-
|
|
68
|
-
throw error;
|
|
69
|
-
}
|
|
131
|
+
rethrowBudget(error);
|
|
70
132
|
}
|
|
71
133
|
}
|
|
72
134
|
else {
|
|
@@ -91,8 +153,13 @@ export function compileUiBlock(source, options) {
|
|
|
91
153
|
throw new UiFailure('invalid_child', `${syntax.type} is not a child of ${parent}.`);
|
|
92
154
|
}
|
|
93
155
|
}
|
|
156
|
+
const actionProp = uiActionProp(syntax.type);
|
|
94
157
|
for (const [key, expression] of Object.entries(syntax.props)) {
|
|
95
158
|
safeKey(key);
|
|
159
|
+
if (key === actionProp) {
|
|
160
|
+
node.expressions[key] = checkedAction(expression);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
96
163
|
if (!Object.hasOwn(entry.schema.shape, key)) {
|
|
97
164
|
diagnose(new UiFailure('refused_prop', `${syntax.type} does not accept ${key}.`), syntax.start, syntax.end, syntax.id);
|
|
98
165
|
continue;
|
|
@@ -102,11 +169,7 @@ export function compileUiBlock(source, options) {
|
|
|
102
169
|
}
|
|
103
170
|
else {
|
|
104
171
|
node.expressions[key] = expression;
|
|
105
|
-
if ('binding' in entry &&
|
|
106
|
-
entry.binding === key &&
|
|
107
|
-
expression.kind === 'reference' &&
|
|
108
|
-
Object.hasOwn(block.defaults, expression.name) &&
|
|
109
|
-
!Object.hasOwn(block.queries, expression.name)) {
|
|
172
|
+
if ('binding' in entry && entry.binding === key && expression.kind === 'reference' && uiLocalState(block)(expression.name)) {
|
|
110
173
|
node.bindings[key] = expression.name;
|
|
111
174
|
}
|
|
112
175
|
}
|
|
@@ -115,17 +178,21 @@ export function compileUiBlock(source, options) {
|
|
|
115
178
|
delete node.props.generated;
|
|
116
179
|
node.props.attachment = options.latestAttachment;
|
|
117
180
|
}
|
|
181
|
+
if (actionProp !== undefined && syntax.complete && !node.expressions[actionProp]) {
|
|
182
|
+
throw new UiFailure('invalid_props', `${syntax.type} needs ${actionProp}.`);
|
|
183
|
+
}
|
|
118
184
|
const evaluated = { ...node.props };
|
|
119
185
|
fallbackProps = evaluated;
|
|
120
186
|
let unresolved = false;
|
|
121
187
|
for (const [key, expression] of Object.entries(node.expressions)) {
|
|
188
|
+
if (key === actionProp) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
122
191
|
try {
|
|
123
192
|
evaluated[key] = evaluateUiExpression(expression, block.defaults, budget);
|
|
124
193
|
}
|
|
125
194
|
catch (error) {
|
|
126
|
-
|
|
127
|
-
throw error;
|
|
128
|
-
}
|
|
195
|
+
rethrowBudget(error);
|
|
129
196
|
unresolved = true;
|
|
130
197
|
}
|
|
131
198
|
}
|
|
@@ -138,39 +205,13 @@ export function compileUiBlock(source, options) {
|
|
|
138
205
|
}
|
|
139
206
|
}
|
|
140
207
|
catch (error) {
|
|
141
|
-
|
|
142
|
-
throw error;
|
|
143
|
-
}
|
|
208
|
+
rethrowBudget(error);
|
|
144
209
|
node.error = error instanceof UiFailure ? error.code : 'invalid_props';
|
|
145
210
|
diagnose(error, syntax.start, syntax.end, syntax.id);
|
|
146
211
|
}
|
|
147
212
|
const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
|
|
148
|
-
node.children = syntax.children.map((child) => compile(child, effectiveParent));
|
|
149
|
-
|
|
150
|
-
const label = fallbackProps.label ?? fallbackProps.title;
|
|
151
|
-
const value = fallbackProps.value;
|
|
152
|
-
const target = fallbackProps.path ?? fallbackProps.sha ?? (syntax.type === 'Node' ? fallbackProps.id : undefined);
|
|
153
|
-
if (syntax.type === 'Stat') {
|
|
154
|
-
node.fallback = `${plain(label ?? '')}: ${plain(value ?? '')}${fallbackProps.unit ? ` ${plain(fallbackProps.unit)}` : ''}\n`;
|
|
155
|
-
}
|
|
156
|
-
else if (syntax.type === 'Source') {
|
|
157
|
-
node.fallback = `${plain(label ?? '')}: ${plain(fallbackProps.url ?? '')}\n`;
|
|
158
|
-
}
|
|
159
|
-
else if (syntax.type === 'Table') {
|
|
160
|
-
node.fallback = `${plain(fallbackProps.rows ?? [])}\n`;
|
|
161
|
-
}
|
|
162
|
-
else if (syntax.type === 'Chart') {
|
|
163
|
-
node.fallback = `${plain(fallbackProps.data ?? [])}\n`;
|
|
164
|
-
}
|
|
165
|
-
else if (syntax.type === 'Image') {
|
|
166
|
-
node.fallback = `[Image: ${plain(fallbackProps.alt ?? fallbackProps.attachment ?? fallbackProps.generated ?? 'unavailable')}]${children}\n`;
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
node.fallback = `${label !== undefined ? `${plain(label)}: ` : ''}${target !== undefined ? `${plain(target)} ` : ''}${children}${fallbackProps.context ? `\n${plain(fallbackProps.context)}` : ''}`;
|
|
170
|
-
if (syntax.type !== 'Tag' && !node.fallback.endsWith('\n')) {
|
|
171
|
-
node.fallback += '\n';
|
|
172
|
-
}
|
|
173
|
-
}
|
|
213
|
+
node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
|
|
214
|
+
node.fallback = uiNodeFallback(syntax.type, fallbackProps, node.children.map((child) => child.fallback).join(''));
|
|
174
215
|
if (!node.fallback.trim()) {
|
|
175
216
|
node.fallback = source.slice(syntax.start, Math.min(syntax.end, syntax.start + 1024));
|
|
176
217
|
}
|
|
@@ -179,41 +220,12 @@ export function compileUiBlock(source, options) {
|
|
|
179
220
|
try {
|
|
180
221
|
const syntax = parseUiSyntax(source, options.id, options.final, budget);
|
|
181
222
|
block.diagnostics = syntax.diagnostics;
|
|
182
|
-
|
|
223
|
+
// Defaults first, so a query argument can read every one of them.
|
|
224
|
+
const declarations = Object.entries(syntax.declarations).sort(([, left], [, right]) => Number(isQuery(left)) - Number(isQuery(right)));
|
|
225
|
+
for (const [name, expression] of declarations) {
|
|
183
226
|
try {
|
|
184
227
|
if (expression.kind === 'call' && expression.name === 'Query') {
|
|
185
|
-
|
|
186
|
-
throw new UiFailure('invalid_query', '@Query needs a literal source and an argument record.');
|
|
187
|
-
}
|
|
188
|
-
const querySource = evaluateUiExpression(expression.args[0], {}, budget);
|
|
189
|
-
const argument = expression.args[1];
|
|
190
|
-
const references = (part) => {
|
|
191
|
-
if (!part || typeof part !== 'object') {
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
if ('kind' in part && part.kind === 'reference' && 'name' in part && !Object.hasOwn(block.defaults, String(part.name))) {
|
|
195
|
-
throw new UiFailure('invalid_query', 'Query arguments may reference only declared local state.');
|
|
196
|
-
}
|
|
197
|
-
for (const value of Object.values(part)) {
|
|
198
|
-
references(value);
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
references(argument);
|
|
202
|
-
const args = evaluateUiExpression(argument, block.defaults, budget);
|
|
203
|
-
if (typeof querySource !== 'string' || !args || Array.isArray(args) || typeof args !== 'object') {
|
|
204
|
-
throw new UiFailure('invalid_query', 'Invalid query source or argument record.');
|
|
205
|
-
}
|
|
206
|
-
if (!options.querySchemas || !Object.hasOwn(options.querySchemas, querySource)) {
|
|
207
|
-
throw new UiFailure('unknown_query', 'The host did not register this query source.');
|
|
208
|
-
}
|
|
209
|
-
const checked = options.querySchemas[querySource].safeParse(args);
|
|
210
|
-
if (!checked.success) {
|
|
211
|
-
throw new UiFailure('invalid_query', 'Query arguments do not match the host schema.');
|
|
212
|
-
}
|
|
213
|
-
if (Object.keys(block.queries).length >= 8) {
|
|
214
|
-
throw new UiFailure('budget_exceeded', 'A block may declare at most eight queries.');
|
|
215
|
-
}
|
|
216
|
-
block.queries[name] = { source: querySource, args, ...(!isConstant(argument) ? { expression: argument } : {}) };
|
|
228
|
+
block.queries[name] = compileQuery(expression, block, budget, options.querySchemas);
|
|
217
229
|
}
|
|
218
230
|
else {
|
|
219
231
|
if (!isConstant(expression)) {
|
|
@@ -223,13 +235,11 @@ export function compileUiBlock(source, options) {
|
|
|
223
235
|
}
|
|
224
236
|
}
|
|
225
237
|
catch (error) {
|
|
226
|
-
|
|
227
|
-
throw error;
|
|
228
|
-
}
|
|
238
|
+
rethrowBudget(error);
|
|
229
239
|
diagnose(error, 0, source.length);
|
|
230
240
|
}
|
|
231
241
|
}
|
|
232
|
-
block.nodes = syntax.nodes.map((node) => compile(node));
|
|
242
|
+
block.nodes = syntax.nodes.map((node) => compile(node, 0));
|
|
233
243
|
block.fallback = block.nodes
|
|
234
244
|
.map((node) => node.fallback)
|
|
235
245
|
.join('')
|
|
@@ -262,6 +272,8 @@ export class UiCompiler {
|
|
|
262
272
|
let nodes = 0;
|
|
263
273
|
let exhausted = false;
|
|
264
274
|
const now = this.options.now ?? Date.now;
|
|
275
|
+
const language = this.options.fenceLanguage ?? UI_FENCE_LANGUAGE;
|
|
276
|
+
const diagnostics = this.options.limits?.diagnostics ?? UI_LIMITS.diagnostics;
|
|
265
277
|
const started = now();
|
|
266
278
|
const nodeCount = (items) => items.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
|
|
267
279
|
const rejected = (start, codeStart, codeEnd, end, complete) => ({
|
|
@@ -285,7 +297,7 @@ export class UiCompiler {
|
|
|
285
297
|
blocks[blocks.length - 1] = {
|
|
286
298
|
...last,
|
|
287
299
|
diagnostics: [
|
|
288
|
-
...last.diagnostics.slice(0,
|
|
300
|
+
...last.diagnostics.slice(0, diagnostics - 1),
|
|
289
301
|
uiDiagnostic(new UiFailure('budget_exceeded', 'Further UI fences stay text because this reply has too many blocks.'), start, end)
|
|
290
302
|
]
|
|
291
303
|
};
|
|
@@ -336,7 +348,7 @@ export class UiCompiler {
|
|
|
336
348
|
length: opening[1].length,
|
|
337
349
|
start: position,
|
|
338
350
|
codeStart: position + line.length,
|
|
339
|
-
ui: opening[2].trim() ===
|
|
351
|
+
ui: opening[2].trim() === language
|
|
340
352
|
};
|
|
341
353
|
}
|
|
342
354
|
else if (fence && opening && opening[1][0] === fence.marker && opening[1].length >= fence.length && !opening[2].trim()) {
|
|
@@ -357,6 +369,11 @@ export class UiCompiler {
|
|
|
357
369
|
this.cache.clear();
|
|
358
370
|
}
|
|
359
371
|
}
|
|
372
|
+
/* A cheap test for whether `text` opens a UI fence at all, before a compiler is worth creating. */
|
|
373
|
+
export function uiHasFence(text, fenceLanguage = UI_FENCE_LANGUAGE) {
|
|
374
|
+
const language = fenceLanguage.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
375
|
+
return new RegExp(`^ {0,3}(?:\`{3,}|~{3,})[ \\t]*${language}[ \\t]*\\r?$`, 'm').test(text);
|
|
376
|
+
}
|
|
360
377
|
export function compileUi(text, options) {
|
|
361
378
|
return new UiCompiler(options).compile(text, options);
|
|
362
379
|
}
|
package/dist/expression.d.ts
CHANGED
|
@@ -36,5 +36,6 @@ export type UiExpression = {
|
|
|
36
36
|
args: UiExpression[];
|
|
37
37
|
};
|
|
38
38
|
export declare function parseUiExpression(source: string, budget?: UiBudget): UiExpression;
|
|
39
|
+
export declare function sameUiValue(first: UiValue, second: UiValue): boolean;
|
|
39
40
|
export declare function copyUiValue(value: unknown, budget?: UiBudget, depth?: number): UiValue;
|
|
40
41
|
export declare function evaluateUiExpression(expression: UiExpression, variables?: Readonly<Record<string, UiValue>>, budget?: UiBudget): UiValue;
|
package/dist/expression.js
CHANGED
|
@@ -230,6 +230,22 @@ class ExpressionParser {
|
|
|
230
230
|
export function parseUiExpression(source, budget = new UiBudget()) {
|
|
231
231
|
return new ExpressionParser(source, budget).parse();
|
|
232
232
|
}
|
|
233
|
+
/* Deep equality of two values, regardless of the order their record keys were written in. */
|
|
234
|
+
export function sameUiValue(first, second) {
|
|
235
|
+
if (first === second) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if (first === null || second === null || typeof first !== 'object' || typeof second !== 'object' || Array.isArray(first) !== Array.isArray(second)) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
if (Array.isArray(first) && Array.isArray(second)) {
|
|
242
|
+
return first.length === second.length && first.every((item, i) => sameUiValue(item, second[i]));
|
|
243
|
+
}
|
|
244
|
+
const left = first;
|
|
245
|
+
const right = second;
|
|
246
|
+
const keys = Object.keys(left);
|
|
247
|
+
return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && sameUiValue(left[key], right[key]));
|
|
248
|
+
}
|
|
233
249
|
export function copyUiValue(value, budget = new UiBudget(), depth = 0) {
|
|
234
250
|
budget.depth(depth);
|
|
235
251
|
if (value === null || typeof value === 'boolean') {
|
package/dist/fallback.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function uiPlainText(value) {
|
|
2
|
+
return value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
3
|
+
}
|
|
4
|
+
/* The readable text a node leaves behind where it cannot be drawn, from its props and its children's text. */
|
|
5
|
+
export function uiNodeFallback(type, props, children) {
|
|
6
|
+
const label = props.label ?? props.title;
|
|
7
|
+
switch (type) {
|
|
8
|
+
case '$text':
|
|
9
|
+
return uiPlainText(props.text);
|
|
10
|
+
case 'Stat':
|
|
11
|
+
return `${uiPlainText(label)}: ${uiPlainText(props.value)}${props.unit ? ` ${uiPlainText(props.unit)}` : ''}\n`;
|
|
12
|
+
case 'Source':
|
|
13
|
+
return `${uiPlainText(label)}: ${uiPlainText(props.url)}\n`;
|
|
14
|
+
case 'Table':
|
|
15
|
+
return `${uiPlainText(props.rows ?? [])}\n`;
|
|
16
|
+
case 'Chart':
|
|
17
|
+
return `${uiPlainText(props.data ?? [])}\n`;
|
|
18
|
+
case 'Image':
|
|
19
|
+
return `[Image: ${uiPlainText(props.alt ?? props.attachment ?? props.generated ?? 'unavailable')}]${children}\n`;
|
|
20
|
+
}
|
|
21
|
+
const target = props.path ?? props.sha ?? (type === 'Node' ? props.id : undefined);
|
|
22
|
+
const context = props.context ? `\n${uiPlainText(props.context)}` : '';
|
|
23
|
+
const text = `${label !== undefined ? `${uiPlainText(label)}: ` : ''}${target !== undefined ? `${uiPlainText(target)} ` : ''}${children}${context}`;
|
|
24
|
+
return type === 'Tag' || text.endsWith('\n') ? text : `${text}\n`;
|
|
25
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent, uiCatalogText, type UiComponentName, type UiProps, type UiTone } from './catalog.ts';
|
|
2
2
|
export { UI_LIMITS, UiBudget, UiFailure, safeKey, type UiLimits } from './budget.ts';
|
|
3
|
-
export { copyUiValue, evaluateUiExpression, parseUiExpression, type UiExpression, type UiValue } from './expression.ts';
|
|
3
|
+
export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
|
|
4
4
|
export { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntax, type UiSyntaxNode } from './syntax.ts';
|
|
5
|
-
export { UI_FENCE_LANGUAGE, UI_REPLY_LIMITS, UiCompiler, compileUi, compileUiBlock, type UiCompilerOptions, type UiCompileUpdate, type UiBlock, type UiCompileOptions, type UiNode, type UiQuery } from './compiler.ts';
|
|
5
|
+
export { UI_FENCE_LANGUAGE, UI_HOST_LIMITS, UI_REPLY_LIMITS, UiCompiler, compileUi, compileUiBlock, uiHasFence, uiMayReferenceHost, type UiCompilerOptions, type UiCompileUpdate, type UiBlock, type UiCompileOptions, type UiNode, type UiQuery } from './compiler.ts';
|
|
6
6
|
export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, type UiChoiceSelection, type UiBinding, type UiEvaluation, type UiViewNode } from './runtime.ts';
|
|
7
7
|
export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.ts';
|
|
8
8
|
export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.ts';
|
|
9
|
-
export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText } from './text.ts';
|
|
9
|
+
export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
|
|
10
10
|
export { UI_STREAM_INTERVAL_MS, UiStream, type UiStreamClock, type UiStreamOptions, type UiStreamPreview } from './stream.ts';
|
|
11
11
|
export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget, type UiLinkResolution } from './links.ts';
|