@nan0web/ui 1.6.2 → 1.7.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 +89 -85
- package/src/core/GeneratorRunner.js +213 -0
- package/src/core/Intent.js +168 -0
- package/src/core/IntentErrorModel.js +94 -0
- package/src/core/MaskHandler.js +125 -0
- package/src/core/index.js +7 -0
- package/src/domain/SandboxModel.js +193 -0
- package/src/domain/ShowcaseAppModel.js +88 -0
- package/src/domain/components/AutocompleteModel.js +58 -0
- package/src/domain/components/BreadcrumbModel.js +265 -0
- package/src/domain/components/ButtonModel.js +92 -0
- package/src/domain/components/ConfirmModel.js +64 -0
- package/src/domain/components/InputModel.js +142 -0
- package/src/domain/components/SelectModel.js +59 -0
- package/src/domain/components/SpinnerModel.js +58 -0
- package/src/domain/components/TableModel.js +60 -0
- package/src/domain/components/ToastModel.js +77 -0
- package/src/domain/components/TreeModel.js +53 -0
- package/src/domain/components/index.js +11 -0
- package/src/domain/index.js +16 -0
- package/src/format.js +21 -0
- package/src/index.js +6 -0
- package/types/core/GeneratorRunner.d.ts +51 -0
- package/types/core/Intent.d.ts +227 -85
- package/types/core/IntentErrorModel.d.ts +55 -0
- package/types/core/MaskHandler.d.ts +33 -0
- package/types/core/index.d.ts +4 -0
- package/types/domain/SandboxModel.d.ts +59 -0
- package/types/domain/ShowcaseAppModel.d.ts +62 -0
- package/types/domain/components/AutocompleteModel.d.ts +47 -0
- package/types/domain/components/BreadcrumbModel.d.ts +164 -0
- package/types/domain/components/ButtonModel.d.ts +81 -0
- package/types/domain/components/ConfirmModel.d.ts +54 -0
- package/types/domain/components/InputModel.d.ts +121 -0
- package/types/domain/components/SelectModel.d.ts +48 -0
- package/types/domain/components/SpinnerModel.d.ts +45 -0
- package/types/domain/components/TableModel.d.ts +44 -0
- package/types/domain/components/ToastModel.d.ts +62 -0
- package/types/domain/components/TreeModel.d.ts +49 -0
- package/types/domain/components/index.d.ts +10 -0
- package/types/domain/index.d.ts +3 -0
- package/types/format.d.ts +5 -0
- package/types/index.d.ts +4 -0
package/package.json
CHANGED
|
@@ -1,86 +1,90 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
2
|
+
"name": "@nan0web/ui",
|
|
3
|
+
"version": "1.7.0",
|
|
4
|
+
"description": "NaN•Web UI. One application logic (algorithm) and many UI.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"types": "types/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"src/**/*.js",
|
|
10
|
+
"!src/**/*.test.js",
|
|
11
|
+
"types/**/*.d.ts"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./src/index.js",
|
|
16
|
+
"types": "./types/index.d.ts"
|
|
17
|
+
},
|
|
18
|
+
"./components": {
|
|
19
|
+
"import": "./src/Component/index.js",
|
|
20
|
+
"types": "./types/Component/index.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./core": {
|
|
23
|
+
"import": "./src/core/index.js",
|
|
24
|
+
"types": "./types/core/index.d.ts"
|
|
25
|
+
},
|
|
26
|
+
"./domain": {
|
|
27
|
+
"import": "./src/domain/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./cli-app": "./apps/cli/src/index.js",
|
|
30
|
+
"./mobile-app": "./apps/mobile/src/index.js",
|
|
31
|
+
"./web-app": "./apps/web/src/App.jsx"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"ui",
|
|
35
|
+
"nanoweb"
|
|
36
|
+
],
|
|
37
|
+
"author": "YaRaSLove (ЯRаСлав) <support@yaro.page>",
|
|
38
|
+
"license": "ISC",
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@nan0web/event": "*",
|
|
41
|
+
"@nan0web/i18n": "^1.1.0",
|
|
42
|
+
"@nan0web/release": "^1.0.2",
|
|
43
|
+
"@nan0web/test": "^1.1.3",
|
|
44
|
+
"@nan0web/ui-cli": "^2.3.0",
|
|
45
|
+
"@playwright/test": "^1.58.2",
|
|
46
|
+
"@rollup/plugin-yaml": "^4.1.2",
|
|
47
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
48
|
+
"husky": "^9.1.7",
|
|
49
|
+
"js-yaml": "^4.1.1",
|
|
50
|
+
"knip": "^5.86.0",
|
|
51
|
+
"lit": "^3.3.2",
|
|
52
|
+
"vite": "^6.4.1",
|
|
53
|
+
"vitest": "^3.2.4",
|
|
54
|
+
"@nan0web/ui-lit": "1.1.0"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@nan0web/co": "^2.0.0",
|
|
58
|
+
"@nan0web/event": "^1.0.1",
|
|
59
|
+
"@nan0web/log": "^1.1.1",
|
|
60
|
+
"@nan0web/types": "^1.4.0",
|
|
61
|
+
"string-width": "^7.2.0"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsc",
|
|
65
|
+
"play": "node play/main.js",
|
|
66
|
+
"test:unit": "node --test --test-timeout=3333 \"src/**/*.test.js\"",
|
|
67
|
+
"test": "npm run test:unit && npm run test:ssg && npm run build",
|
|
68
|
+
"test:nan0test": "node --test --test-timeout=3333 \"src/**/*.test.js\" | nan0test parse --fail",
|
|
69
|
+
"test:coverage": "node --experimental-test-coverage --test-coverage-include=\"src/**/*.js\" --test-coverage-exclude=\"src/**/*.test.js\" --test \"src/**/*.test.js\"",
|
|
70
|
+
"test:coverage:collect": "nan0test coverage",
|
|
71
|
+
"test:docs": "node --test src/README.md.js",
|
|
72
|
+
"release:spec": "node --test \"releases/**/*.spec.js\"",
|
|
73
|
+
"test:release": "node --test \"releases/**/*.test.js\"",
|
|
74
|
+
"test:status": "nan0test status --hide-name",
|
|
75
|
+
"test:play": "node --test --test-timeout=3333 \"play/**/*.test.js\"",
|
|
76
|
+
"test:ssg": "node --test --test-timeout=5000 \"ssg/ssg.test.js\"",
|
|
77
|
+
"test:e2e": "playwright test --ignore-snapshots e2e/components.spec.js e2e/debug-label.spec.js",
|
|
78
|
+
"test:e2e:slow": "E2E_SLOW=1 playwright test e2e/visual.spec.js",
|
|
79
|
+
"test:all": "npm run test && npm run test:docs && npm run test:play && npm run test:e2e && npm run knip",
|
|
80
|
+
"knip": "knip --production",
|
|
81
|
+
"precommit": "npm test",
|
|
82
|
+
"prepush": "npm test",
|
|
83
|
+
"release": "nan0release publish",
|
|
84
|
+
"clean": "rm -rf .cache/ && rm -rf dist/",
|
|
85
|
+
"clean:modules": "rm -rf node_modules",
|
|
86
|
+
"docs:build-data": "node docs/site/scripts/generate-data.js",
|
|
87
|
+
"docs:dev": "node docs/site/scripts/generate-data.js && node docs/site/scripts/generate-pages.js && vite docs/site",
|
|
88
|
+
"docs:build": "node docs/site/scripts/generate-data.js && node docs/site/scripts/generate-pages.js && vite build docs/site"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file GeneratorRunner — Universal Adapter Loop with Timeout, Abort, and Contract Enforcement.
|
|
3
|
+
*
|
|
4
|
+
* This is the core engine that runs any OLMUI async generator (Model.run())
|
|
5
|
+
* through a set of adapter handlers. It enforces:
|
|
6
|
+
*
|
|
7
|
+
* 1. Intent validation (the "Judge" — Ярослав Мудрий).
|
|
8
|
+
* 2. Timeout / Abort support (Іван Сірко).
|
|
9
|
+
* 3. Type-safe handler dispatch (Борис Патон).
|
|
10
|
+
*
|
|
11
|
+
* Every UI Adapter (CLI, Lit, Chat, Test) uses this runner
|
|
12
|
+
* instead of writing its own while(true) loop.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { validateIntent } from './Intent.js'
|
|
16
|
+
import { IntentErrorModel } from './IntentErrorModel.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} AdapterHandlers
|
|
20
|
+
* @property {(intent: import('./Intent.js').AskIntent) => Promise<import('./Intent.js').AskResponse>} ask
|
|
21
|
+
* Handler for 'ask' intents. Must return { value: ... }.
|
|
22
|
+
* @property {(intent: import('./Intent.js').ProgressIntent) => void | Promise<void>} [progress]
|
|
23
|
+
* Handler for 'progress' intents. Optional (defaults to no-op).
|
|
24
|
+
* @property {(intent: import('./Intent.js').LogIntent) => void | Promise<void>} [log]
|
|
25
|
+
* Handler for 'log' intents. Optional (defaults to no-op).
|
|
26
|
+
* @property {(intent: import('./Intent.js').ResultIntent) => void | Promise<void>} [result]
|
|
27
|
+
* Handler for the final 'result'. Optional (defaults to no-op).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} RunnerOptions
|
|
32
|
+
* @property {number} [timeoutMs=0]
|
|
33
|
+
* Maximum milliseconds to wait for an adapter handler to respond.
|
|
34
|
+
* Default is 0 (disabled) — web forms may wait indefinitely.
|
|
35
|
+
* Set to a positive value for CLI/Chat adapters where hanging is unacceptable.
|
|
36
|
+
* @property {AbortSignal} [signal]
|
|
37
|
+
* External AbortSignal for cancellation from outside.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wraps a promise with a timeout. Rejects if the promise doesn't resolve in time.
|
|
42
|
+
* If ms <= 0, timeout is disabled (promise runs without time limit).
|
|
43
|
+
*
|
|
44
|
+
* @template T
|
|
45
|
+
* @param {Promise<T>} promise
|
|
46
|
+
* @param {number} ms
|
|
47
|
+
* @param {string} label - Description for the timeout error.
|
|
48
|
+
* @returns {Promise<T>}
|
|
49
|
+
*/
|
|
50
|
+
function withTimeout(promise, ms, label) {
|
|
51
|
+
if (!ms || ms <= 0) return promise
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
const error = IntentErrorModel.error('timeout', { label, ms })
|
|
56
|
+
reject(error)
|
|
57
|
+
}, ms)
|
|
58
|
+
|
|
59
|
+
promise.then(
|
|
60
|
+
(val) => {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
resolve(val)
|
|
63
|
+
},
|
|
64
|
+
(err) => {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
reject(err)
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Runs an OLMUI async generator through the provided adapter handlers.
|
|
74
|
+
*
|
|
75
|
+
* This function is the SINGLE point of execution for all adapters.
|
|
76
|
+
* It guarantees:
|
|
77
|
+
* - Every yielded intent is validated (contract enforcement).
|
|
78
|
+
* - Every 'ask' intent gets a response or times out (if timeoutMs > 0).
|
|
79
|
+
* - External abort signals are respected.
|
|
80
|
+
* - The final result is returned.
|
|
81
|
+
*
|
|
82
|
+
* @template T
|
|
83
|
+
* @param {AsyncGenerator<import('./Intent.js').Intent, import('./Intent.js').ResultIntent, import('./Intent.js').IntentResponse>} generator
|
|
84
|
+
* The model's async generator (from Model.run()).
|
|
85
|
+
* @param {AdapterHandlers} handlers
|
|
86
|
+
* Platform-specific handlers for each intent type.
|
|
87
|
+
* @param {RunnerOptions} [options={}]
|
|
88
|
+
* Runner configuration (timeout, abort signal).
|
|
89
|
+
* @returns {Promise<T>}
|
|
90
|
+
* The final result data from the generator.
|
|
91
|
+
*/
|
|
92
|
+
export async function runGenerator(generator, handlers, options = {}) {
|
|
93
|
+
const { timeoutMs = 0, signal } = options
|
|
94
|
+
|
|
95
|
+
// Validate that at least 'ask' handler is provided (Ярослав Мудрий's law)
|
|
96
|
+
if (!handlers || typeof handlers.ask !== 'function') {
|
|
97
|
+
throw IntentErrorModel.error('adapter_missing_ask')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @type {import('./Intent.js').IntentResponse | Error | undefined} */
|
|
101
|
+
let nextVal = undefined
|
|
102
|
+
|
|
103
|
+
while (true) {
|
|
104
|
+
// ─── Check external abort signal ───
|
|
105
|
+
if (signal?.aborted) {
|
|
106
|
+
await generator.return({ type: 'result', data: null })
|
|
107
|
+
const error = IntentErrorModel.error('aborted')
|
|
108
|
+
error.name = 'AbortError'
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let nextEvent
|
|
113
|
+
try {
|
|
114
|
+
if (nextVal instanceof Error) {
|
|
115
|
+
nextEvent = await generator.throw(nextVal)
|
|
116
|
+
} else {
|
|
117
|
+
nextEvent = await generator.next(nextVal)
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {
|
|
120
|
+
// If the generator didn't catch the CancelError, we gracefully abort the runner.
|
|
121
|
+
const err = /** @type {Error} */ (e)
|
|
122
|
+
if (err.name === 'CancelError') {
|
|
123
|
+
return /** @type {T} */ (null)
|
|
124
|
+
}
|
|
125
|
+
throw e // Bubble up unexpected runtime errors
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { value: intent, done } = nextEvent
|
|
129
|
+
|
|
130
|
+
// ─── Generator completed (return statement) ───
|
|
131
|
+
if (done) {
|
|
132
|
+
if (handlers.result) {
|
|
133
|
+
await handlers.result(intent)
|
|
134
|
+
}
|
|
135
|
+
return intent?.data
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Validate intent structure (the Judge) ───
|
|
139
|
+
validateIntent(intent)
|
|
140
|
+
|
|
141
|
+
// ─── Dispatch to adapter handler ───
|
|
142
|
+
switch (intent.type) {
|
|
143
|
+
case 'ask': {
|
|
144
|
+
const response = await withTimeout(
|
|
145
|
+
handlers.ask(intent),
|
|
146
|
+
timeoutMs,
|
|
147
|
+
`ask("${intent.field}")`,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// Validate response shape (Борис Патон's weld check)
|
|
151
|
+
if (!response || typeof response !== 'object' || !('value' in response)) {
|
|
152
|
+
throw IntentErrorModel.error('ask_wrong_response', {
|
|
153
|
+
field: intent.field,
|
|
154
|
+
actual: JSON.stringify(response),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Handle cancellation (ESC / back navigation) ───
|
|
159
|
+
if (response.cancelled) {
|
|
160
|
+
// Prepare CancelError to be thrown INTO the generator on the next loop iteration.
|
|
161
|
+
// This allows the Model to elegantly intercept ESC using try...catch!
|
|
162
|
+
const err = new Error('User cancelled interaction')
|
|
163
|
+
err.name = 'CancelError'
|
|
164
|
+
nextVal = err
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Run field validation if schema has a validator (the Judge again)
|
|
169
|
+
if (!intent.model) {
|
|
170
|
+
/** @type {import('./Intent.js').FieldSchema} */
|
|
171
|
+
const fieldSchema = /** @type {import('./Intent.js').FieldSchema} */ (intent.schema)
|
|
172
|
+
if (fieldSchema.validate) {
|
|
173
|
+
const validationResult = fieldSchema.validate(response.value)
|
|
174
|
+
if (validationResult !== true) {
|
|
175
|
+
throw IntentErrorModel.error('validation_failed', {
|
|
176
|
+
field: intent.field,
|
|
177
|
+
reason: validationResult,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Instantiate Model-as-Schema class with collected data
|
|
184
|
+
if (intent.model && typeof intent.schema === 'function') {
|
|
185
|
+
const ModelClass = /** @type {new (data: any) => any} */ (intent.schema)
|
|
186
|
+
response.value = new ModelClass(response.value)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
nextVal = response
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case 'progress': {
|
|
194
|
+
if (handlers.progress) {
|
|
195
|
+
await handlers.progress(intent)
|
|
196
|
+
}
|
|
197
|
+
nextVal = undefined
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case 'log': {
|
|
202
|
+
if (handlers.log) {
|
|
203
|
+
await handlers.log(intent)
|
|
204
|
+
}
|
|
205
|
+
nextVal = undefined
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
default:
|
|
210
|
+
throw IntentErrorModel.error('unhandled_intent', { type: /** @type {any} */ (intent).type })
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Intent — Yield Contract Types for OLMUI Generators.
|
|
3
|
+
*
|
|
4
|
+
* Defines the strict set of intent types that a Model generator can yield,
|
|
5
|
+
* and the response shapes that Adapters must return.
|
|
6
|
+
*
|
|
7
|
+
* These types serve as the "welding contract" between Domain Models
|
|
8
|
+
* and UI Adapters (CLI, Lit, React, Chat, Test).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { IntentErrorModel } from './IntentErrorModel.js'
|
|
12
|
+
|
|
13
|
+
// ─── Intent Types (Model → Adapter) ───
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} FieldSchema
|
|
17
|
+
* @property {string} help - Human-readable label / i18n key.
|
|
18
|
+
* @property {*} default - Default value for the field.
|
|
19
|
+
* @property {string} [type] - Field type hint ('text', 'number', 'text/markdown').
|
|
20
|
+
* @property {Array<{value: *, label: string}>} [options] - Enum options for select.
|
|
21
|
+
* @property {(val: *) => true | string} [validate] - Validator: true = ok, string = error key from Model.
|
|
22
|
+
* @property {boolean} [hidden] - If true, field is excluded from UI forms.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Model needs data from the environment (user input, LLM extraction, test fixture).
|
|
27
|
+
* Adapter MUST return an `AskResponse`.
|
|
28
|
+
* @typedef {Object} AskIntent
|
|
29
|
+
* @property {'ask'} type
|
|
30
|
+
* @property {string} field - Property name on the model.
|
|
31
|
+
* @property {FieldSchema | Function} schema - Field metadata or Model-as-Schema class constructor.
|
|
32
|
+
* @property {true} [model] - When true, schema is a Model-as-Schema class (full form).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Model informs about a long-running operation. No response expected.
|
|
37
|
+
* Message MUST come from the Model (i18n static field value).
|
|
38
|
+
* @typedef {Object} ProgressIntent
|
|
39
|
+
* @property {'progress'} type
|
|
40
|
+
* @property {number} [value] - Progress value (0-1).
|
|
41
|
+
* @property {string} [id] - Progress ID for tracking multiple parallel operations.
|
|
42
|
+
* @property {string} message - Status message from Model (i18n static field value).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Model emits a log message. No response expected.
|
|
47
|
+
* Message MUST come from the Model (i18n static field value).
|
|
48
|
+
* @typedef {Object} LogIntent
|
|
49
|
+
* @property {'log'} type
|
|
50
|
+
* @property {'info' | 'warn' | 'error' | 'success'} level
|
|
51
|
+
* @property {string} message - Log message from Model (i18n static field value).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Final return value from the generator.
|
|
56
|
+
* @typedef {Object} ResultIntent
|
|
57
|
+
* @property {'result'} type
|
|
58
|
+
* @property {*} data - The raw result data (JSON-serializable).
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Union of all possible yielded intents.
|
|
63
|
+
* @typedef {AskIntent | ProgressIntent | LogIntent} Intent
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
// ─── Response Types (Adapter → Model) ───
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Response to an AskIntent. Adapter provides the collected value.
|
|
70
|
+
* The value MUST conform to the type described in the requested FieldSchema.
|
|
71
|
+
* @typedef {Object} AskResponse
|
|
72
|
+
* @property {*} value - The value matching schema.type (collected from user / LLM / test fixture).
|
|
73
|
+
* @property {boolean} [cancelled] - Whether the user cancelled this interaction (e.g. pressed ESC).
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
// ─── Abort Support ───
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Special response that Adapters can send to abort the generator.
|
|
80
|
+
*
|
|
81
|
+
* The `reason` is a KEY from the Model's static `abort` dictionary,
|
|
82
|
+
* not a freeform message. This enables proper i18n translation:
|
|
83
|
+
*
|
|
84
|
+
* Model defines: static abort = { user_cancelled: 'Скасовано', timeout: 'Час вичерпано' }
|
|
85
|
+
* Adapter sends: { abort: true, reason: 'user_cancelled' }
|
|
86
|
+
* UI translates: t(Model.abort[reason])
|
|
87
|
+
*
|
|
88
|
+
* @typedef {Object} AbortResponse
|
|
89
|
+
* @property {true} abort - Signal to the model that execution was cancelled.
|
|
90
|
+
* @property {string} [reason] - Key from Model's static abort dictionary (not a freeform message).
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Union of all possible responses an Adapter can send back via iterator.next().
|
|
95
|
+
* @typedef {AskResponse | AbortResponse | undefined} IntentResponse
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
export const INTENT_TYPES = /** @type {const} */ (['ask', 'progress', 'log'])
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Detects if a value is a Model-as-Schema class (has static fields with `help`).
|
|
102
|
+
* @param {*} schema
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
export function isModelSchema(schema) {
|
|
106
|
+
if (typeof schema !== 'function') return false
|
|
107
|
+
return Object.keys(schema).some((key) => {
|
|
108
|
+
const meta = schema[key]
|
|
109
|
+
return meta && typeof meta === 'object' && 'help' in meta
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Validates that an object is a well-formed Intent.
|
|
115
|
+
* Throws ModelError if the intent is malformed (the "Judge").
|
|
116
|
+
*
|
|
117
|
+
* @param {*} intent
|
|
118
|
+
* @returns {intent is Intent}
|
|
119
|
+
*/
|
|
120
|
+
export function validateIntent(intent) {
|
|
121
|
+
if (!intent || typeof intent !== 'object') {
|
|
122
|
+
throw IntentErrorModel.error('intent_not_object', { actual: typeof intent })
|
|
123
|
+
}
|
|
124
|
+
if (!INTENT_TYPES.includes(intent.type) && intent.type !== 'result') {
|
|
125
|
+
throw IntentErrorModel.error('intent_unknown_type', {
|
|
126
|
+
type: intent.type,
|
|
127
|
+
allowed: INTENT_TYPES.join(', ') + ', result',
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
if (intent.type === 'ask') {
|
|
131
|
+
if (typeof intent.field !== 'string' || !intent.field) {
|
|
132
|
+
throw IntentErrorModel.error('ask_missing_field')
|
|
133
|
+
}
|
|
134
|
+
// Accept both: plain schema {help: '...'} and Model-as-Schema class
|
|
135
|
+
const isModel = intent.model === true
|
|
136
|
+
if (!isModel && (!intent.schema || typeof intent.schema !== 'object' || !('help' in intent.schema))) {
|
|
137
|
+
throw IntentErrorModel.error('ask_missing_schema_help')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (intent.type === 'progress' || intent.type === 'log') {
|
|
141
|
+
if (typeof intent.message !== 'string') {
|
|
142
|
+
throw IntentErrorModel.error('intent_missing_message', { type: intent.type })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create an ask intent.
|
|
150
|
+
*
|
|
151
|
+
* Two modes:
|
|
152
|
+
* ask('amount', { help: 'Enter amount', type: 'number' }) → single field
|
|
153
|
+
* ask('transfer', TransferMoneyModel) → full Model form
|
|
154
|
+
*
|
|
155
|
+
* @param {string} field - Field name or form name.
|
|
156
|
+
* @param {object | Function} schema - Field descriptor or Model-as-Schema class.
|
|
157
|
+
* @returns {AskIntent}
|
|
158
|
+
*/
|
|
159
|
+
export const ask = (field, schema) => {
|
|
160
|
+
if (isModelSchema(schema)) {
|
|
161
|
+
return { type: 'ask', field, schema, model: true }
|
|
162
|
+
}
|
|
163
|
+
return { type: 'ask', field, schema }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const progress = (message) => ({ type: 'progress', message })
|
|
167
|
+
export const log = (level, message, data = {}) => ({ type: 'log', level, message, ...data })
|
|
168
|
+
export const result = (data) => ({ type: 'result', data })
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file IntentErrorModel — Model-as-Schema for all OLMUI contract errors.
|
|
3
|
+
*
|
|
4
|
+
* All error messages in the Generator/Intent system are defined here
|
|
5
|
+
* as static fields with i18n-ready keys. No hardcoded strings in
|
|
6
|
+
* validators or runners.
|
|
7
|
+
*
|
|
8
|
+
* Pattern:
|
|
9
|
+
* static field_name = {
|
|
10
|
+
* help: 'Developer description (docs/IDE)',
|
|
11
|
+
* error: 'User-facing message template with {params}',
|
|
12
|
+
* }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ModelError } from '@nan0web/types'
|
|
16
|
+
|
|
17
|
+
export class IntentErrorModel {
|
|
18
|
+
// ─── Intent Validation Errors ───
|
|
19
|
+
|
|
20
|
+
static intent_not_object = {
|
|
21
|
+
help: 'Intent must be a valid object',
|
|
22
|
+
error: "Intent must be an object, got: '{actual}'",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static intent_unknown_type = {
|
|
26
|
+
help: 'Only ask, progress, log, result types are allowed',
|
|
27
|
+
error: "Unknown intent type: '{type}'. Allowed: {allowed}",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static ask_missing_field = {
|
|
31
|
+
help: 'AskIntent requires a field property name',
|
|
32
|
+
error: 'AskIntent requires a non-empty "field" string',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static ask_missing_schema_help = {
|
|
36
|
+
help: 'AskIntent schema must contain help for i18n',
|
|
37
|
+
error: 'AskIntent.schema must have at least a "help" property',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static intent_missing_message = {
|
|
41
|
+
help: 'Progress and Log intents require a message',
|
|
42
|
+
error: '\'{type}\' intent requires a "message" string',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Runner Contract Errors ───
|
|
46
|
+
|
|
47
|
+
static adapter_missing_ask = {
|
|
48
|
+
help: 'Every adapter must handle ask intents',
|
|
49
|
+
error:
|
|
50
|
+
'Adapter MUST provide at least an "ask" handler. This is the minimum contract for any UI adapter.',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static ask_wrong_response = {
|
|
54
|
+
help: 'Ask handler must return { value: ... }',
|
|
55
|
+
error: "ask('{field}') handler must return { value: ... }, got: {actual}",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static validation_failed = {
|
|
59
|
+
help: 'Value returned by adapter failed schema validation',
|
|
60
|
+
error: "Field '{field}' failed validation: {reason}",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static unhandled_intent = {
|
|
64
|
+
help: 'Intent type not handled by the runner dispatch',
|
|
65
|
+
error: "Unhandled intent type: '{type}'",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Timeout / Abort ───
|
|
69
|
+
|
|
70
|
+
static timeout = {
|
|
71
|
+
help: 'Adapter exceeded the allowed response time',
|
|
72
|
+
error: '{label} — adapter did not respond within {ms}ms',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static aborted = {
|
|
76
|
+
help: 'External signal cancelled the generator',
|
|
77
|
+
error: 'Generator aborted by external signal',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a ModelError for a specific error field.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} field - Static field name on IntentErrorModel.
|
|
84
|
+
* @param {Record<string, *>} [params] - Template parameters to substitute {key} placeholders.
|
|
85
|
+
* @returns {ModelError}
|
|
86
|
+
*/
|
|
87
|
+
static error(field, params = {}) {
|
|
88
|
+
let message = IntentErrorModel[field]?.error || field
|
|
89
|
+
for (const [key, val] of Object.entries(params)) {
|
|
90
|
+
message = message.replaceAll(`{${key}}`, String(val))
|
|
91
|
+
}
|
|
92
|
+
return new ModelError({ [field]: message })
|
|
93
|
+
}
|
|
94
|
+
}
|