@genesislcap/blank-app-seed 5.27.2-prerelease.2 → 5.28.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/.genx/ai-consumer.json +18 -0
- package/.genx/configure.js +122 -0
- package/.genx/package.json +1 -1
- package/.genx/scripts/check-ai-declaration.mjs +116 -0
- package/.genx/scripts/check-ai-emission.sh +974 -0
- package/.genx/scripts/generate-test-apps.sh +38 -4
- package/.genx/templates/react/ai/assistant-host.ts.hbs +30 -0
- package/.genx/templates/react/ai/assistant.ts.hbs +52 -0
- package/.genx/templates/react/ai/extensions.ts.hbs +17 -0
- package/.genx/templates/react/ai/pbc-elements.ts.hbs +17 -0
- package/.genx/templates/server/ai-service-web-handler.kts.hbs +394 -0
- package/.genx/tests/contracts/ai/ai-resolver-cases.json +6807 -0
- package/.genx/tests/contracts/ai/ui-config-ai.schema.json +203 -0
- package/.genx/tests/fixtures/ai-config-parse-breakers.json +16 -0
- package/.genx/tests/fixtures/ai-config.json +41 -0
- package/.genx/versions.json +3 -3
- package/.github/workflows/build.yml +20 -4
- package/CHANGELOG.md +55 -36
- package/README.md +139 -0
- package/bdd-tests/build.gradle.kts +1 -1
- package/bdd-tests/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/bdd-tests/gradle.properties +0 -1
- package/bdd-tests/settings.gradle.kts +2 -2
- package/client-tmp/angular/package.json +3 -0
- package/client-tmp/react/.oxfmtrc.json +3 -0
- package/client-tmp/react/package.json +6 -2
- package/client-tmp/web-components/package.json +3 -0
- package/client-tmp/web-components/settings.gradle.kts +0 -22
- package/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/package.json +1 -1
- package/server/build.gradle.kts +8 -1
- package/server/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/server/gradle.properties +3 -3
- package/server/settings.gradle.kts +2 -2
- package/server/{{appName}}-app/src/main/genesis/scripts/genesis-router.kts +10 -0
- package/server/{{appName}}-app/src/test/kotlin/global/genesis/EventHandlerTest.kt +1 -1
package/.genx/configure.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const versions = require('./versions.json');
|
|
2
|
+
// What the pinned @genesislcap/ai-assistant reads: the kinds and resource fields the chat writer passes
|
|
3
|
+
// on. CI proves it against that assistant's GENESIS_AI_CONSUMES (C-15A.6 S-6).
|
|
4
|
+
const aiConsumer = require('./ai-consumer.json');
|
|
2
5
|
const fs = require('fs');
|
|
3
6
|
const path = require('path');
|
|
4
7
|
const {
|
|
@@ -44,6 +47,26 @@ module.exports = async (data, utils) => {
|
|
|
44
47
|
const FDC3EventHandlersEnabled = data.routes.find(
|
|
45
48
|
(route) => route.FDC3EventHandlersEnabled,
|
|
46
49
|
);
|
|
50
|
+
// AI chat — ONE gate for everything the feature emits, server and client alike.
|
|
51
|
+
//
|
|
52
|
+
// `data.ui` here IS the project's ui.config (the generator passes it as the `ui` argument), so the
|
|
53
|
+
// block is `data.ui.ai`, not `data.ui.config.ai`: gated on the latter it would silently never
|
|
54
|
+
// fire. React only, because the panel is React only, and the server endpoint follows the same
|
|
55
|
+
// gate — an endpoint with no panel to call it would be a router compile risk with no user.
|
|
56
|
+
const aiVendor = data.ui?.ai?.vendor === 'anthropic' ? 'anthropic' : 'gemini';
|
|
57
|
+
data.AI = {
|
|
58
|
+
enabled: !!data.ui?.ai?.enabled && data.framework === 'react',
|
|
59
|
+
// The models the proxy lets a request ask for: every tier (low, high, reasoning) of the chosen
|
|
60
|
+
// vendor, copied from foundation-ui's DEFAULT_AI_TIERS (foundation-ai src/tiers/ai-tiers.ts,
|
|
61
|
+
// 3fb72a1096: lines 150, 159, 171 and 179, 187, 196). Hard-coded, so a tier-table change means
|
|
62
|
+
// updating these too.
|
|
63
|
+
allowedModels:
|
|
64
|
+
aiVendor === 'anthropic'
|
|
65
|
+
? 'claude-haiku-4-5-20251001,claude-sonnet-5,claude-opus-4-8'
|
|
66
|
+
: 'gemini-3.1-flash-lite,gemini-3.8-flash,gemini-3.1-pro-preview',
|
|
67
|
+
maxOutputTokens: 16000,
|
|
68
|
+
};
|
|
69
|
+
|
|
47
70
|
const FDC3ListenersEnabled = data.ui?.fdc3?.channels?.length;
|
|
48
71
|
data.FDC3 = {
|
|
49
72
|
includeDependencies: !!(FDC3ListenersEnabled || FDC3EventHandlersEnabled),
|
|
@@ -176,6 +199,105 @@ module.exports = async (data, utils) => {
|
|
|
176
199
|
});
|
|
177
200
|
|
|
178
201
|
|
|
202
|
+
// The chat proxy. It declares the AI_CHAT right but does not seed it: the project's rights files
|
|
203
|
+
// belong to the generator that sends them, and a second writer here would be overwritten by the
|
|
204
|
+
// csv loop above anyway (RIGHT, PROFILE and PROFILE_RIGHT arrive with no mode, so they replace).
|
|
205
|
+
if (data.AI.enabled) {
|
|
206
|
+
const appModule = path.resolve(__dirname, '../server/{{appName}}-app/src/main/genesis');
|
|
207
|
+
utils.writeFileWithData(
|
|
208
|
+
path.join(appModule, 'scripts/ai-service-web-handler.kts'),
|
|
209
|
+
data,
|
|
210
|
+
path.resolve(__dirname, 'templates/server/ai-service-web-handler.kts.hbs'),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The chat panel's configuration as Create resolved it (C-8). Only what the pinned assistant reads is
|
|
215
|
+
// copied, as .genx/ai-consumer.json declares it (C-15A.6 S-1): a resource of any other kind, and any
|
|
216
|
+
// other key on a resource, is dropped and said. A customCode is rebuilt from the two values the
|
|
217
|
+
// assistant reads, and only an insert, modify or delete keeps it (C-18.D.5); references are rebuilt
|
|
218
|
+
// the same way, and only an insert or a modify keeps them (C-17.5); and a row action's key, inputs
|
|
219
|
+
// and effects are rebuilt from theirs (C-18.8.3). So nothing else in the payload can reach a file in
|
|
220
|
+
// the customer's app. Genx runs every .json file through Handlebars after
|
|
221
|
+
// this, so each `{{` is written as the JSON escape `\u007b\u007b`: it parses back to `{{`, and leaves
|
|
222
|
+
// Handlebars nothing to expand.
|
|
223
|
+
if (data.AI.enabled) {
|
|
224
|
+
const { enabled, vendor, tier, systemPrompt, resources } = data.ui.ai;
|
|
225
|
+
const passed = `passed to @genesislcap/ai-assistant ${versions.UI}`;
|
|
226
|
+
const projectedCode = ({ op, customCode: code }) => {
|
|
227
|
+
if (!code || !['insert', 'modify', 'delete'].includes(op)) return undefined;
|
|
228
|
+
const names = Array.isArray(code.alsoWrites) ? code.alsoWrites : [];
|
|
229
|
+
const alsoWrites = names.filter((name) => typeof name === 'string');
|
|
230
|
+
// A list that had to be cut is not complete, whatever it says.
|
|
231
|
+
const whole = Array.isArray(code.alsoWrites) && alsoWrites.length === names.length;
|
|
232
|
+
return { alsoWrites, listComplete: whole && code.listComplete === true };
|
|
233
|
+
};
|
|
234
|
+
// A list rebuilt item by item. Absent stays absent; a value that is not a list, or an item that is
|
|
235
|
+
// not an object, becomes null, which the assistant refuses rather than reads.
|
|
236
|
+
const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
237
|
+
const listOf = (value, each) => {
|
|
238
|
+
if (value === undefined) return undefined;
|
|
239
|
+
if (!Array.isArray(value)) return null;
|
|
240
|
+
return value.map((item) => (isObject(item) ? each(item) : null));
|
|
241
|
+
};
|
|
242
|
+
const projectedReferences = ({ op, references }) =>
|
|
243
|
+
op === 'insert' || op === 'modify'
|
|
244
|
+
? listOf(references, ({ resource, fields }) => ({
|
|
245
|
+
resource,
|
|
246
|
+
fields: fields == null ? [] : listOf(fields, ({ field, targetField }) => ({ field, targetField })),
|
|
247
|
+
}))
|
|
248
|
+
: undefined;
|
|
249
|
+
// A row action's fields: shape and entity as given, and each list rebuilt (C-18.8.3). A key is
|
|
250
|
+
// kept only where it names a row, on a custom event, a modify or a delete (C-19); anywhere else
|
|
251
|
+
// the assistant would leave the whole resource out over it, so it is not written.
|
|
252
|
+
const rowFields = {
|
|
253
|
+
key: (key, { op }) => {
|
|
254
|
+
if (key === undefined || !['custom', 'modify', 'delete'].includes(op)) return undefined;
|
|
255
|
+
return Array.isArray(key) ? key.filter((name) => typeof name === 'string') : null;
|
|
256
|
+
},
|
|
257
|
+
inputs: (inputs) => listOf(inputs, ({ field, required }) => ({ field, required })),
|
|
258
|
+
effects: (effects) => listOf(effects, ({ op, table }) => ({ op, table })),
|
|
259
|
+
};
|
|
260
|
+
const kept = [];
|
|
261
|
+
for (const resource of resources || []) {
|
|
262
|
+
if (!aiConsumer.kinds.includes(resource.kind)) {
|
|
263
|
+
console.warn(`ai: dropped resource ${resource.name} — kind ${resource.kind} is not ${passed}`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
for (const key of Object.keys(resource).filter((key) => !aiConsumer.resourceFields.includes(key))) {
|
|
267
|
+
console.warn(`ai: dropped ${key} on ${resource.name} — not ${passed}`);
|
|
268
|
+
}
|
|
269
|
+
const copy = Object.fromEntries(
|
|
270
|
+
aiConsumer.resourceFields.filter((key) => key in resource).map((key) => [key, resource[key]]),
|
|
271
|
+
);
|
|
272
|
+
// Left undefined, it is not written at all.
|
|
273
|
+
if ('customCode' in copy) copy.customCode = projectedCode(resource);
|
|
274
|
+
if ('references' in copy) copy.references = projectedReferences(resource);
|
|
275
|
+
for (const [field, rebuilt] of Object.entries(rowFields)) {
|
|
276
|
+
if (field in copy) copy[field] = rebuilt(resource[field], resource);
|
|
277
|
+
}
|
|
278
|
+
kept.push(copy);
|
|
279
|
+
}
|
|
280
|
+
const config = { enabled, vendor, tier, systemPrompt, resources: kept };
|
|
281
|
+
const file = path.resolve(__dirname, '../client/src/ai/generated/ai-config.json');
|
|
282
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
283
|
+
const json = JSON.stringify(config, null, 2).replace(/\{\{/g, '\\u007b\\u007b');
|
|
284
|
+
fs.writeFileSync(file, `${json}\n`);
|
|
285
|
+
|
|
286
|
+
// The panel's code, copied as written. Genx renders these once more like any other file, so they
|
|
287
|
+
// hold no Handlebars and come out unchanged.
|
|
288
|
+
const templates = path.resolve(__dirname, 'templates/react/ai');
|
|
289
|
+
const clientSrc = path.resolve(__dirname, '../client/src');
|
|
290
|
+
[
|
|
291
|
+
['pbc-elements.ts.hbs', 'pbc/ai-assistant/elements.ts'],
|
|
292
|
+
['assistant-host.ts.hbs', 'ai/generated/assistant-host.ts'],
|
|
293
|
+
['assistant.ts.hbs', 'ai/generated/assistant.ts'],
|
|
294
|
+
['extensions.ts.hbs', 'ai/extensions/index.ts'],
|
|
295
|
+
].forEach(([template, target]) => {
|
|
296
|
+
fs.mkdirSync(path.dirname(path.join(clientSrc, target)), { recursive: true });
|
|
297
|
+
fs.copyFileSync(path.join(templates, template), path.join(clientSrc, target));
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
179
301
|
if (data.excludeGradleWrapper) {
|
|
180
302
|
deleteGradleWrappers();
|
|
181
303
|
}
|
package/.genx/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Proves the seed's AI declaration against the @genesislcap/ai-assistant a generated app installed
|
|
4
|
+
// (C-15A.6 S-6). This is the only place "the assistant reads it" is checked, and it is checked where
|
|
5
|
+
// the pinned package really is.
|
|
6
|
+
//
|
|
7
|
+
// baseline ⊆ declaration ⊆ GENESIS_AI_CONSUMES, for the kinds and for the resource fields.
|
|
8
|
+
//
|
|
9
|
+
// An assistant with no `./genesis/consumes` entry (before the release that added it) reads the
|
|
10
|
+
// baseline only, so the declaration must then BE the baseline. Absent means exactly Node's
|
|
11
|
+
// ERR_PACKAGE_PATH_NOT_EXPORTED for that subpath, when resolving it: the package missing, any error
|
|
12
|
+
// while loading the module (whatever its code), or a constant that is not an object of two lists of
|
|
13
|
+
// names fails the check.
|
|
14
|
+
//
|
|
15
|
+
// How: a child `node --input-type=module` runs in the client dir, so the app's own install answers,
|
|
16
|
+
// under import conditions. It calls import.meta.resolve, then import(), each in a try of its own,
|
|
17
|
+
// prints what each said and exits, so a module that keeps a process alive cannot hang the step.
|
|
18
|
+
// Resolved with require instead, an export for import only would read as absent. An export for
|
|
19
|
+
// require only does read as absent here, which fails safe: that assistant is held to the baseline.
|
|
20
|
+
//
|
|
21
|
+
// Usage: node check-ai-declaration.mjs <.genx/ai-consumer.json> <generated app's client dir>
|
|
22
|
+
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
|
|
26
|
+
// What every assistant with a chat reads, and what Create emits when a seed declares nothing (G-2).
|
|
27
|
+
const BASELINE = {
|
|
28
|
+
kinds: ['request', 'event'],
|
|
29
|
+
resourceFields: ['name', 'kind', 'op', 'context', 'maxRows'],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const [declarationFile, clientDir] = process.argv.slice(2);
|
|
33
|
+
const problems = [];
|
|
34
|
+
|
|
35
|
+
const declaration = JSON.parse(readFileSync(declarationFile, 'utf8'));
|
|
36
|
+
const listOfNames = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
37
|
+
if (declaration.version !== 1) problems.push(`version is ${declaration.version}, not 1`);
|
|
38
|
+
for (const part of ['kinds', 'resourceFields']) {
|
|
39
|
+
if (!listOfNames(declaration[part])) problems.push(`${part} is not a list of names`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// See the header for why this runs in a child.
|
|
43
|
+
const SUBPATH = '@genesislcap/ai-assistant/genesis/consumes';
|
|
44
|
+
const probe = `
|
|
45
|
+
const said = {};
|
|
46
|
+
let url;
|
|
47
|
+
try {
|
|
48
|
+
url = import.meta.resolve(${JSON.stringify(SUBPATH)});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
said.resolve = { code: error?.code, message: String(error?.message) };
|
|
51
|
+
}
|
|
52
|
+
if (url) {
|
|
53
|
+
try {
|
|
54
|
+
const loaded = await import(url);
|
|
55
|
+
const value = loaded.GENESIS_AI_CONSUMES ?? loaded.default?.GENESIS_AI_CONSUMES;
|
|
56
|
+
// JSON drops a function, so what it is travels on its own.
|
|
57
|
+
said.type =
|
|
58
|
+
value === undefined || value === null ? 'missing' : Array.isArray(value) ? 'array' : typeof value;
|
|
59
|
+
said.consumes = value;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
said.load = { code: error?.code, message: String(error?.message) };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
process.stdout.write('\\n' + JSON.stringify(said), () => process.exit(0));
|
|
65
|
+
`;
|
|
66
|
+
const run = spawnSync(process.execPath, ['--input-type=module', '-e', probe], {
|
|
67
|
+
cwd: clientDir,
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
timeout: 60_000,
|
|
70
|
+
});
|
|
71
|
+
let said = {};
|
|
72
|
+
let probed = false;
|
|
73
|
+
try {
|
|
74
|
+
// A child that had to be killed, whatever it printed, did not finish: that is not an answer.
|
|
75
|
+
if (run.error) throw run.error;
|
|
76
|
+
// The last line: whatever the module itself prints comes before it.
|
|
77
|
+
said = JSON.parse(run.stdout.trim().split('\n').pop());
|
|
78
|
+
probed = true;
|
|
79
|
+
} catch {
|
|
80
|
+
const why = run.error?.message ?? (run.stderr.trim().split('\n')[0] || `exit ${run.status}`);
|
|
81
|
+
problems.push(`${SUBPATH} could not be probed: ${why}`);
|
|
82
|
+
}
|
|
83
|
+
const absent =
|
|
84
|
+
said.resolve?.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' && said.resolve.message.includes("'./genesis/consumes'");
|
|
85
|
+
if (said.resolve && !absent) {
|
|
86
|
+
problems.push(`${SUBPATH} could not be resolved: ${said.resolve.code ?? said.resolve.message}`);
|
|
87
|
+
}
|
|
88
|
+
if (said.load) {
|
|
89
|
+
problems.push(`${SUBPATH} could not be loaded: ${said.load.code ?? said.load.message}`);
|
|
90
|
+
}
|
|
91
|
+
// Loaded, the constant must be an object of two lists of names, or nothing is known about it.
|
|
92
|
+
const consumes = said.type === 'object' ? said.consumes : undefined;
|
|
93
|
+
if (probed && !said.resolve && !said.load && !consumes) {
|
|
94
|
+
problems.push(`${SUBPATH} exports no GENESIS_AI_CONSUMES object (${said.type ?? 'nothing said'})`);
|
|
95
|
+
}
|
|
96
|
+
for (const part of consumes ? ['kinds', 'resourceFields'] : []) {
|
|
97
|
+
if (!listOfNames(consumes[part])) problems.push(`the installed assistant's ${part} is not a list of names`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const missing = (from, within) => from.filter((item) => !within.includes(item));
|
|
101
|
+
for (const part of ['kinds', 'resourceFields']) {
|
|
102
|
+
const declared = listOfNames(declaration[part]) ? declaration[part] : [];
|
|
103
|
+
const below = missing(BASELINE[part], declared);
|
|
104
|
+
if (below.length) problems.push(`${part} leaves out the baseline's ${below.join(', ')}`);
|
|
105
|
+
if (consumes) {
|
|
106
|
+
const beyond = missing(declared, listOfNames(consumes[part]) ? consumes[part] : []);
|
|
107
|
+
if (beyond.length) problems.push(`${part} names ${beyond.join(', ')}, which the installed assistant does not read`);
|
|
108
|
+
} else if (absent) {
|
|
109
|
+
// Nothing to read beyond the baseline: the declaration is the baseline, exactly.
|
|
110
|
+
const beyond = missing(declared, BASELINE[part]);
|
|
111
|
+
if (beyond.length) problems.push(`${part} names ${beyond.join(', ')}, and this assistant reads the baseline only`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
problems.forEach((problem) => console.log(` ${problem}`));
|
|
116
|
+
process.exit(problems.length ? 1 : 0);
|