@salsita-ai/sdk 0.50.0-develop.2772b27a05 → 0.50.0-develop.3679122d70
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/dist/application-spec/generate-application/code/project-scaffolding.js +2 -2
- package/dist/application-spec/generate-application/code/templates/index.d.ts +1 -0
- package/dist/application-spec/generate-application/code/templates/index.js +1 -1
- package/dist/application-spec/generate-application/code/templates/skills/agents-md.js +1 -1
- package/dist/application-spec/generate-application/code/templates/skills/phoenix-cli-skill.js +1 -1
- package/dist/application-spec/generate-application/code/templates/skills/phoenix-review-skill.js +1 -1
- package/dist/application-spec/generate-application/code/templates/skills/phoenix-variants-skill.d.ts +1 -0
- package/dist/application-spec/generate-application/code/templates/skills/phoenix-variants-skill.js +6 -0
- package/dist/application-spec/generate-application/code/templates/skills/sdk-content.d.ts +12 -2
- package/dist/application-spec/generate-application/code/templates/skills/sdk-content.js +3 -3
- package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.d.ts +17 -3
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/change-recorder.d.ts +85 -0
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/change-recorder.js +1 -0
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.d.ts +8 -3
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.d.ts +4 -0
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.d.ts +16 -1
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/side-effects.d.ts +9 -0
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/side-effects.js +1 -0
- package/dist/docs/index.json +1 -1
- package/dist/docs/other-features/side-effects.mdx +26 -0
- package/dist/docs/phoenix/phoenix-cli.mdx +79 -0
- package/dist/docs/phoenix/project-structure.mdx +3 -2
- package/dist/domain/components/updates.d.ts +16 -0
- package/dist/domain/domain-context/store-system/store-system.js +1 -1
- package/dist/editor/Editor.js +1 -1
- package/dist/editor/graphql/GraphQLPanel.js +1 -1
- package/dist/editor/variants/VariantAnswerFooter.d.ts +17 -0
- package/dist/editor/variants/VariantAnswerFooter.js +1 -0
- package/dist/editor/variants/VariantPreviewHandler.d.ts +6 -0
- package/dist/editor/variants/VariantPreviewHandler.js +1 -0
- package/dist/editor/variants/variant-preview-styles.d.ts +49 -0
- package/dist/editor/variants/variant-preview-styles.js +1 -0
- package/dist/phoenix/application-spec/application-spec-builder.js +1 -1
- package/dist/phoenix/application-spec/generate-assistant-prompt.d.ts +3 -2
- package/dist/phoenix/application-spec/generate-assistant-prompt.js +1 -1
- package/dist/phoenix/cli.js +1 -1
- package/dist/phoenix/commands/dev.js +5 -5
- package/dist/phoenix/commands/index.d.ts +1 -0
- package/dist/phoenix/commands/index.js +1 -1
- package/dist/phoenix/commands/story-dev.js +2 -2
- package/dist/phoenix/commands/variants.d.ts +35 -0
- package/dist/phoenix/commands/variants.js +1 -0
- package/dist/phoenix/editor-server/commands-router.d.ts +68 -1
- package/dist/phoenix/editor-server/commands-router.js +1 -1
- package/dist/phoenix/editor-server/editor-router.d.ts +65 -0
- package/dist/phoenix/editor-server/editor-server.d.ts +3 -3
- package/dist/phoenix/editor-server/editor-server.js +1 -1
- package/dist/phoenix/editor-server/protocol.d.ts +1 -0
- package/dist/phoenix/editor-server/spec-router.js +1 -1
- package/dist/phoenix/editor-server/trpc.d.ts +3 -0
- package/dist/phoenix/services/application-spec-project-watcher.d.ts +13 -2
- package/dist/phoenix/services/application-spec-project-watcher.js +3 -3
- package/dist/phoenix/services/index.d.ts +4 -0
- package/dist/phoenix/services/index.js +1 -1
- package/dist/phoenix/services/phoenix-application.d.ts +21 -0
- package/dist/phoenix/services/phoenix-application.js +1 -0
- package/dist/phoenix/services/phoenix-store.d.ts +48 -0
- package/dist/phoenix/services/phoenix-store.js +1 -0
- package/dist/phoenix/services/phx-variant-previews-plugin.d.ts +9 -0
- package/dist/phoenix/services/phx-variant-previews-plugin.js +1 -0
- package/dist/phoenix/services/variant-file-session.d.ts +36 -0
- package/dist/phoenix/services/variant-file-session.js +1 -0
- package/dist/phoenix/services/variant-preview-service.d.ts +80 -0
- package/dist/phoenix/services/variant-preview-service.js +1 -0
- package/dist/phoenix/services/vite-config.js +1 -1
- package/package.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import{GeneratedApplicationFile as e}from"../../core/index.js";import{followsCanaryBuilds as t,generateAgentsMd as n,generateApplicationSpecEnvCi as r,generateApplicationSpecEnvExample as o,generateApplicationSpecEslintConfig as i,generateApplicationSpecFavicon as a,generateApplicationSpecGitignore as s,generateApplicationSpecNvmrc as c,generateApplicationSpecPackageJson as l,generateApplicationSpecPrettierConfig as p,generateApplicationSpecPrettierIgnore as m,generateApplicationSpecPrivateAssetsReadme as f,generateApplicationSpecPublicAssetsReadme as d,generateApplicationSpecReadme as g,generateApplicationSpecRobotsTxt as u,generateApplicationSpecTsconfig as x,generateApplicationSpecWebhookImplementation as h,generateLinguiRcFile as v,generatePhoenixCliSkill as j,generatePhoenixCreateConfiguratorSkill as k,generatePhoenixCreateConfiguratorSkillReferences as b,generatePhoenixFeedbackSkill as E,generatePhoenixReviewSkill as F,generatePhoenixSkill as M,stripSdkMarkerContent as
|
|
1
|
+
import{GeneratedApplicationFile as e}from"../../core/index.js";import{followsCanaryBuilds as t,generateAgentsMd as n,generateApplicationSpecEnvCi as r,generateApplicationSpecEnvExample as o,generateApplicationSpecEslintConfig as i,generateApplicationSpecFavicon as a,generateApplicationSpecGitignore as s,generateApplicationSpecNvmrc as c,generateApplicationSpecPackageJson as l,generateApplicationSpecPrettierConfig as p,generateApplicationSpecPrettierIgnore as m,generateApplicationSpecPrivateAssetsReadme as f,generateApplicationSpecPublicAssetsReadme as d,generateApplicationSpecReadme as g,generateApplicationSpecRobotsTxt as u,generateApplicationSpecTsconfig as x,generateApplicationSpecWebhookImplementation as h,generateLinguiRcFile as v,generatePhoenixCliSkill as j,generatePhoenixCreateConfiguratorSkill as k,generatePhoenixCreateConfiguratorSkillReferences as b,generatePhoenixFeedbackSkill as E,generatePhoenixReviewSkill as F,generatePhoenixSkill as M,generatePhoenixVariantsSkill as P,stripSdkMarkerContent as w}from"./templates/index.js";export const agentSkillRoots=[".claude/skills",".agents/skills"];let A=[{path:"CLAUDE.md",generatedPrefix:""},{path:".cursor/rules/phoenix.mdc",generatedPrefix:`---
|
|
2
2
|
description: Phoenix SDK project rules
|
|
3
3
|
globs:
|
|
4
4
|
alwaysApply: true
|
|
5
5
|
---
|
|
6
|
-
`}],
|
|
6
|
+
`}],S=[".claude/commands/review.md",".cursor/commands/review.md"],C=(e,t)=>e.projectFiles.find(e=>e.file.name===t)?.file.content;export const generateProjectScaffolding=D=>{let L,{applicationSpec:R,categorizedFiles:$,sdkVersion:y,cliUseWorkspace:K,name:G,description:I}=D,N=C($,"package.json"),T=C($,".gitignore"),U=void 0!==K?K:R.appMetadata.workspace,q=(t=>{let n=[],r=[...S];for(let{path:o,generatedPrefix:i}of A){let a=C(t,o);if(void 0===a)continue;let s=w(a,i);void 0===s?r.push(o):s!==a&&n.push(e(o,s,{format:!1}))}return{strippedFiles:n,removedFiles:r}})($);return{generatedProjectFiles:[e("package.json",l({sdkVersion:y,useWorkspace:U,useClerk:R.appMetadata.useClerk??!1,name:G,description:I,existingContent:N})),e(".nvmrc",c(),{format:!1}),e(".linguirc",v(R.configurator.ui.availableLocales),{format:!1}),e("tsconfig.json",x()),e("eslint.config.js",i()),e(".gitignore",s(T)),...(L=[{name:"phoenix",content:M({checkLatestCanary:t({useWorkspace:U,sdkVersion:y})}),references:[]},{name:"phoenix-cli",content:j(),references:[]},{name:"phoenix-create-configurator",content:k(),references:b()},{name:"phoenix-feedback",content:E(),references:[]},{name:"phoenix-review",content:F(),references:[]},{name:"phoenix-variants",content:P(),references:[]}],agentSkillRoots.flatMap(t=>L.flatMap(n=>[e(`${t}/${n.name}/SKILL.md`,n.content),...n.references.map(r=>e(`${t}/${n.name}/${r.path}`,r.content))]))),...q.strippedFiles],initialProjectFiles:[e("README.md",g(G)),e("AGENTS.md",n()),e(".env.ci",r()),e(".env.example",o()),e("public/favicon.ico",a(),{binary:!0}),e("public/robots.txt",u(),{format:!1}),e("assets/public/README.md",d()),e("assets/private/README.md",f()),e("src/webhooks/index.ts",h())],prettierFiles:[e("prettier.config.js",p()),e(".prettierignore",m(),{format:!1})],removedProjectFiles:q.removedFiles}};
|
|
@@ -47,6 +47,7 @@ export { generatePhoenixCreateConfiguratorSkill, generatePhoenixCreateConfigurat
|
|
|
47
47
|
export { generatePhoenixFeedbackSkill } from './skills/phoenix-feedback-skill.js';
|
|
48
48
|
export { generatePhoenixReviewSkill } from './skills/phoenix-review-skill.js';
|
|
49
49
|
export { type PhoenixSkillOptions, followsCanaryBuilds, generatePhoenixSkill } from './skills/phoenix-skill.js';
|
|
50
|
+
export { generatePhoenixVariantsSkill } from './skills/phoenix-variants-skill.js';
|
|
50
51
|
export { generateApplicationSpecPrettierConfig } from './prettier-config.js';
|
|
51
52
|
export { generateApplicationSpecPrettierIgnore } from './prettier-ignore.js';
|
|
52
53
|
export { generateApplicationSpecPrivateAssetsReadme } from './private-assets-readme.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export{stripSdkMarkerContent}from"./common/strip-sdk-marker-content.js";export{generateAgentsMd}from"./skills/agents-md.js";export{generateApplicationSpecAdminConfig}from"./admin-config.js";export{generateApplicationSpecAdminCode}from"./admin-domain-code.js";export{generateApplicationSpecAdminFrontendCode}from"./admin-frontend-code.js";export{generateAdminGlobalBindingBlock,generateApplicationSpecAdminGlobals}from"./admin-globals.js";export{generateApplicationSpecTypesModule}from"./application-types.js";export{generateApplicationSpecAdminFile}from"./admin.js";export{generateApplicationSpecConfiguratorConfig}from"./configurator-config.js";export{generateApplicationSpecConfiguratorCode}from"./configurator-domain-code.js";export{generateApplicationSpecConfiguratorFrontendCode}from"./configurator-frontend-code.js";export{generateApplicationSpecConfiguratorGlobals,generateConfiguratorGlobalBindingBlock}from"./configurator-globals.js";export{generateApplicationSpecConfiguratorFile}from"./configurator.js";export{generateApplicationSpecDomainSpecFile}from"./domain-spec.js";export{generateImportedStateConstants,generatePhxExportsBlock,generatePhxGlobalsBlock,generateStateConstants}from"./domain-spec/index.js";export{generateApplicationSpecDomainFile}from"./domain.js";export{generateApplicationSpecEditorFile}from"./editor.js";export{generateApplicationSpecEmbeddedAdmin}from"./embedded-admin.js";export{generateApplicationSpecEmbeddedConfigurator}from"./embedded-configurator.js";export{generateApplicationSpecEnvCi}from"./env-ci.js";export{generateApplicationSpecEnvExample}from"./env-example.js";export{generateApplicationSpecEslintConfig}from"./eslint-config.js";export{generateApplicationSpecFavicon}from"./favicon.js";export{generateApplicationSpecFrontendConfig}from"./frontend-config.js";export{generateApplicationSpecGitignore}from"./gitignore.js";export{generateApplicationSpecGlobals}from"./globals.js";export{generateApplicationSpecHtmlAdminIndex}from"./html-admin-index.js";export{generateApplicationSpecHtmlIndex}from"./html-index.js";export{generateLinguiRcFile}from"./lingui-rc.js";export{generateApplicationSpecNvmrc}from"./nvmrc.js";export{generateApplicationSpecPackageJson}from"./package-json.js";export{generatePhoenixCliSkill}from"./skills/phoenix-cli-skill.js";export{generatePhoenixCreateConfiguratorSkill,generatePhoenixCreateConfiguratorSkillReferences}from"./skills/phoenix-create-configurator-skill.js";export{generatePhoenixFeedbackSkill}from"./skills/phoenix-feedback-skill.js";export{generatePhoenixReviewSkill}from"./skills/phoenix-review-skill.js";export{followsCanaryBuilds,generatePhoenixSkill}from"./skills/phoenix-skill.js";export{generateApplicationSpecPrettierConfig}from"./prettier-config.js";export{generateApplicationSpecPrettierIgnore}from"./prettier-ignore.js";export{generateApplicationSpecPrivateAssetsReadme}from"./private-assets-readme.js";export{generateApplicationSpecPublicAssetsReadme}from"./public-assets-readme.js";export{generateApplicationSpecReadme}from"./readme.js";export{generateApplicationSpecRobotsTxt}from"./robots-txt.js";export{generateApplicationSpecStoryDevDeclaration,generateApplicationSpecStoryPhxModule,generateApplicationSpecStoryRawProjectFiles,generateApplicationSpecStoryWrapper}from"./story/index.js";export{generateApplicationSpecTsconfig}from"./tsconfig.js";export{generateApplicationSpecWebhookBuilders}from"./webhook-builders.js";export{generateApplicationSpecWebhookImplementation}from"./webhook-implementation.js";export{generateApplicationSpecWebhookIndex}from"./webhook-index.js";export{generateApplicationSpecWebhooksConfig}from"./webhooks-config.js";
|
|
1
|
+
export{stripSdkMarkerContent}from"./common/strip-sdk-marker-content.js";export{generateAgentsMd}from"./skills/agents-md.js";export{generateApplicationSpecAdminConfig}from"./admin-config.js";export{generateApplicationSpecAdminCode}from"./admin-domain-code.js";export{generateApplicationSpecAdminFrontendCode}from"./admin-frontend-code.js";export{generateAdminGlobalBindingBlock,generateApplicationSpecAdminGlobals}from"./admin-globals.js";export{generateApplicationSpecTypesModule}from"./application-types.js";export{generateApplicationSpecAdminFile}from"./admin.js";export{generateApplicationSpecConfiguratorConfig}from"./configurator-config.js";export{generateApplicationSpecConfiguratorCode}from"./configurator-domain-code.js";export{generateApplicationSpecConfiguratorFrontendCode}from"./configurator-frontend-code.js";export{generateApplicationSpecConfiguratorGlobals,generateConfiguratorGlobalBindingBlock}from"./configurator-globals.js";export{generateApplicationSpecConfiguratorFile}from"./configurator.js";export{generateApplicationSpecDomainSpecFile}from"./domain-spec.js";export{generateImportedStateConstants,generatePhxExportsBlock,generatePhxGlobalsBlock,generateStateConstants}from"./domain-spec/index.js";export{generateApplicationSpecDomainFile}from"./domain.js";export{generateApplicationSpecEditorFile}from"./editor.js";export{generateApplicationSpecEmbeddedAdmin}from"./embedded-admin.js";export{generateApplicationSpecEmbeddedConfigurator}from"./embedded-configurator.js";export{generateApplicationSpecEnvCi}from"./env-ci.js";export{generateApplicationSpecEnvExample}from"./env-example.js";export{generateApplicationSpecEslintConfig}from"./eslint-config.js";export{generateApplicationSpecFavicon}from"./favicon.js";export{generateApplicationSpecFrontendConfig}from"./frontend-config.js";export{generateApplicationSpecGitignore}from"./gitignore.js";export{generateApplicationSpecGlobals}from"./globals.js";export{generateApplicationSpecHtmlAdminIndex}from"./html-admin-index.js";export{generateApplicationSpecHtmlIndex}from"./html-index.js";export{generateLinguiRcFile}from"./lingui-rc.js";export{generateApplicationSpecNvmrc}from"./nvmrc.js";export{generateApplicationSpecPackageJson}from"./package-json.js";export{generatePhoenixCliSkill}from"./skills/phoenix-cli-skill.js";export{generatePhoenixCreateConfiguratorSkill,generatePhoenixCreateConfiguratorSkillReferences}from"./skills/phoenix-create-configurator-skill.js";export{generatePhoenixFeedbackSkill}from"./skills/phoenix-feedback-skill.js";export{generatePhoenixReviewSkill}from"./skills/phoenix-review-skill.js";export{followsCanaryBuilds,generatePhoenixSkill}from"./skills/phoenix-skill.js";export{generatePhoenixVariantsSkill}from"./skills/phoenix-variants-skill.js";export{generateApplicationSpecPrettierConfig}from"./prettier-config.js";export{generateApplicationSpecPrettierIgnore}from"./prettier-ignore.js";export{generateApplicationSpecPrivateAssetsReadme}from"./private-assets-readme.js";export{generateApplicationSpecPublicAssetsReadme}from"./public-assets-readme.js";export{generateApplicationSpecReadme}from"./readme.js";export{generateApplicationSpecRobotsTxt}from"./robots-txt.js";export{generateApplicationSpecStoryDevDeclaration,generateApplicationSpecStoryPhxModule,generateApplicationSpecStoryRawProjectFiles,generateApplicationSpecStoryWrapper}from"./story/index.js";export{generateApplicationSpecTsconfig}from"./tsconfig.js";export{generateApplicationSpecWebhookBuilders}from"./webhook-builders.js";export{generateApplicationSpecWebhookImplementation}from"./webhook-implementation.js";export{generateApplicationSpecWebhookIndex}from"./webhook-index.js";export{generateApplicationSpecWebhooksConfig}from"./webhooks-config.js";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{sdkGuidanceSections as e}from"./sdk-content.js";export const generateAgentsMd=()=>`${[e.projectIntro,e.buildCommands,e.doNotEdit,"Phoenix SDK guidance lives in the `phoenix`, `phoenix-cli`, `phoenix-create-configurator`, `phoenix-feedback` and `phoenix-
|
|
1
|
+
import{sdkGuidanceSections as e}from"./sdk-content.js";export const generateAgentsMd=()=>`${[e.projectIntro,e.buildCommands,e.doNotEdit,"Phoenix SDK guidance lives in the `phoenix`, `phoenix-cli`, `phoenix-create-configurator`, `phoenix-feedback`, `phoenix-review` and `phoenix-variants` skills under `.claude/skills/` and `.agents/skills/`. Phoenix rewrites them on every sync, removes anything else inside their folders and removes other `phoenix`-prefixed skill folders, so to customise a skill, copy it to a folder whose name does not start with `phoenix`."].join("\n\n")}
|
|
2
2
|
`;
|
package/dist/application-spec/generate-application/code/templates/skills/phoenix-cli-skill.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{renderAgentSkill as
|
|
1
|
+
import{renderAgentSkill as e}from"./agent-skill.js";import{sdkGuidanceSections as n}from"./sdk-content.js";export const generatePhoenixCliSkill=()=>e({name:"phoenix-cli",description:"Phoenix CLI tooling for building and inspecting this configurator, covering build, dev and sync commands, dev logs, glTF asset inspection, screenshots, runtime state, data and scene inspection, variant previews, and Playwright MCP setup. Use when building, debugging build or runtime errors, checking 3D assets, or inspecting the running configurator.",sections:[n.buildCommands,n.devLogs,n.assetInspection,n.screenshotCli,n.inspectState,n.inspectData,n.inspectScene,n.variantPreviews,n.playwrightMcpSetup]});
|
package/dist/application-spec/generate-application/code/templates/skills/phoenix-review-skill.js
CHANGED
|
@@ -60,7 +60,7 @@ import{renderAgentSkill as e}from"./agent-skill.js";let i=`Review all current gi
|
|
|
60
60
|
|
|
61
61
|
## Auto-Generated Files — DO NOT modify
|
|
62
62
|
|
|
63
|
-
- .phoenix/ directory, tsconfig.json, eslint.config.js, prettier.config.js
|
|
63
|
+
- .phoenix/ directory (except the option files in .phoenix/variants/, which the phoenix-variants skill writes), tsconfig.json, eslint.config.js, prettier.config.js
|
|
64
64
|
- src/locales/\\*.ts files
|
|
65
65
|
|
|
66
66
|
### Runtime Verification
|
package/dist/application-spec/generate-application/code/templates/skills/phoenix-variants-skill.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const generatePhoenixVariantsSkill: () => string;
|
package/dist/application-spec/generate-application/code/templates/skills/phoenix-variants-skill.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import{renderAgentSkill as e}from"./agent-skill.js";let t=`## When to Offer Options
|
|
2
|
+
- Offer options when the answer is a matter of taste or brand and the supplied materials do not settle it.
|
|
3
|
+
- Do not offer options for facts (dimensions, prices, rules), for something the user already decided, or to avoid a decision you can make yourself and state.
|
|
4
|
+
- One decision per set, one set open at a time. Vary one thing and keep everything else identical.
|
|
5
|
+
- Usually offer two to four options; the system has no fixed option limit. Original is always available as the captured baseline.
|
|
6
|
+
- Make the options differ in a way the UI or scene can show. Three sheens of one matte wood in soft light look the same.`;export const generatePhoenixVariantsSkill=()=>e({name:"phoenix-variants",description:"Showing the user a few options over the dev preview and getting their pick back, with the phoenix variants command. Use when a visual or interaction decision has several reasonable answers and the taste of the user decides it, such as a shape, a material setting, lighting, an environment, a control or a flow.",sections:[t,"## Choose the Kind of Option\n- Use `<id>.variant.js` to edit real frontend project files on top of one captured baseline. Use `<id>.html` for a standalone branding, layout or flow proposal rendered in an iframe.\n- Use the same mechanism for controls, layout, brand, lights, visuals, materials, positions, components, elements and transformers.\n- Keep the model, schemas, data, assistant server settings, webhooks and code shared with them, dependencies, environment and uploaded assets unchanged. The preview rejects changes needing server preparation.\n- Options reuse the already loaded configurator, one at a time. Variant transactions bypass file-watcher debounce; hot reload still takes time to apply, without a special wait for rendering to settle.",'## Write the Options\n- One folder per set: `.phoenix/variants/<name>/`, one script or HTML file per option. HTML and scripts can share a set, but option ids must be unique. Keep HTML self-contained. This folder survives rebuilds and is never committed.\n- For HTML, set `<title>` and `<meta name="description" content="...">`; choosing a page changes no source files.\n- For scripts, export `title` and `description` as plain string literals, and a default function `({ read, write, remove }) => {}`; it may be async.\n- `read(path)` returns the original baseline file contents. `write(path, contents)` replaces or creates a file; `remove(path)` deletes it. Paths are relative to the project root.\n- Scripts run once per selection in the browser and collect edits. Phoenix validates and writes the whole batch locally; it owns backups and undo. Do not perform your own filesystem or network operations.\n- Supported edits are YAML in `spec/` outside `spec/admin/`, and TypeScript under `src/components`, `transformers`, `handlers`, `elements` and `_`-prefixed shared folders. Expressions, actions and invariants run on the server too, so they cannot be edited.\n- Every option starts from the same baseline. Original restores it; switching restores files the previous option alone changed.\n- Keep the working tree untouched while comparing. Phoenix never overwrites an edit made outside the preview: it refuses to switch options over one, and when the comparison ends it leaves that file as it is and warns in the dev log.\n- Name options in the user\'s product language; explain what changes in one line.','## Run It and Wait\n```bash\npnpm phoenix variants .phoenix/variants/<name> --title \'<the question>\' --description \'<what differs between the options>\'\n```\n- `pnpm phoenix dev` must be running with the preview open in a browser. Tell the user the options are open there.\n- Put the title and the description in single quotes, so the shell passes backticks, `$` and double quotes through unchanged. In bash or zsh, write a single quote inside them as `\'\\\'\'`; in PowerShell, double it instead.\n- The command waits for the answer, 540 seconds unless `--timeout <seconds>` says otherwise, so give it a command timeout longer than that. It prints JSON:\n - `"status": "chosen"` with `choice` (the option id, or `null` for Original) and `label`;\n - `"status": "waiting"` when the wait ran out. The options stay open: run the same command again, or end your turn and run it when the user is back. An answer given in between is returned by the next run;\n - `"status": "closed"` when the preview was closed without an answer.\n- `pnpm phoenix variants --close` closes the open set when you give up on it.\n- `--port <port>` names the editor WebSocket port when `phoenix dev` does not use 3020.\n- The live configurator keeps its current configuration. Interactions still change it; restoring source files does not undo configuration changes.',"## Use the Answer\n- **Chosen:** a script's exact file edits remain in the project. For an HTML choice, implement the selected proposal using the SDK and compare the result with the page. With `choice: null`, the user chose Original and the baseline remains. Verify the result using `pnpm phoenix inspect:scene` and `pnpm phoenix screenshot`, and tell the user anything that came out differently.\n- Record the decision and its reason where the project keeps its decisions, such as `configurator-spec.md`.\n- The user confirms Original or another option; Escape and the backdrop do not close the comparison. The agent can use `--close` to restore baseline source files without an answer. The folder is removed once the user answers. Pass `--keep` when you still need the files to build from, and remove the folder yourself afterwards."]});
|
|
@@ -36,7 +36,7 @@ Declarative 3D product configurator built with Phoenix SDK. Domain model defined
|
|
|
36
36
|
- \`src/locales/\` — Locale catalogs (authored .po sources; compiled .ts, do not edit)
|
|
37
37
|
- \`src/admin/\` — Admin interface (mirrors main src/ structure)
|
|
38
38
|
- \`src/_shared/\` — Shared utilities (NOT auto-imported, requires explicit imports)
|
|
39
|
-
- \`.phoenix/\` — Auto-generated files (DO NOT EDIT)
|
|
39
|
+
- \`.phoenix/\` — Auto-generated files (DO NOT EDIT, except the option files in \`.phoenix/variants/\`)
|
|
40
40
|
- \`public/\` — Static assets (3D models, textures, images, HDRI maps, fonts)
|
|
41
41
|
- \`assets/\` — Uploadable resources (PDF templates, email assets)`;
|
|
42
42
|
/** The YAML-first, ECS, reactive-state principles a project follows. */
|
|
@@ -75,7 +75,7 @@ All files in src/expressions/, src/actions/, src/invariants/, src/transformers/,
|
|
|
75
75
|
- Keep browser rendering in an unpinned file. Move shared server-safe logic into a separate module when both frontend and webhook code need it.`;
|
|
76
76
|
/** Generated files a project must not edit. */
|
|
77
77
|
readonly doNotEdit: `## Do Not Edit
|
|
78
|
-
- \`.phoenix/\` directory — completely regenerated on each build
|
|
78
|
+
- \`.phoenix/\` directory — completely regenerated on each build. The one exception is \`.phoenix/variants/\`, where the \`phoenix-variants\` skill has you write option files; a rebuild keeps it
|
|
79
79
|
- \`tsconfig.json\`, \`eslint.config.js\`, \`prettier.config.js\`, \`.prettierignore\`, \`.linguirc\`, \`.nvmrc\`
|
|
80
80
|
- \`src/locales/\` .ts files — compiled from .po files`;
|
|
81
81
|
/** When and how a deployed domain model gets a new version and a migration, with the recipe for replacing the starter. */
|
|
@@ -240,6 +240,16 @@ Get the current Three.js scene graph as JSON via \`pnpm phoenix inspect:scene\`.
|
|
|
240
240
|
**Tips:** Scene graphs can be large. Pipe through \`head\`/\`tail\` for partial output.
|
|
241
241
|
|
|
242
242
|
**Use cases:** AI agent context, debugging 3D scene layout, inspecting materials and lighting`;
|
|
243
|
+
/** The `variants` command; the `phoenix-variants` skill holds the workflow and the options. */
|
|
244
|
+
readonly variantPreviews: `## Variant Previews
|
|
245
|
+
|
|
246
|
+
Show the user several options over the configurator and get their pick back via \`pnpm phoenix variants .phoenix/variants/<name>\`. Use it when a visual or interaction decision is a matter of the user's taste. Load the \`phoenix-variants\` skill first: it says when to offer options, how to write them and which flags the command takes.
|
|
247
|
+
|
|
248
|
+
**Prerequisites:** same as screenshot (dev server running + browser open)
|
|
249
|
+
|
|
250
|
+
**Input:** a folder below \`.phoenix/variants/\` with one file per option: \`<id>.variant.js\` for a script that edits frontend files from a captured baseline, or \`<id>.html\` for a standalone iframe proposal. A selected HTML proposal is returned for you to implement. Options run one at a time through normal hot reload; Original always restores the baseline; picking an option keeps its edits, and the agent can use \`--close\` to restore the baseline without an answer.
|
|
251
|
+
|
|
252
|
+
**Returns:** JSON with \`status\`: \`chosen\` (with \`choice\`, or \`null\` for Original, and \`label\`), \`waiting\` (the wait ran out and the preview stays open; run the same command again) or \`closed\`.`;
|
|
243
253
|
/** Browser automation setup for agents driving the 3D configurator. */
|
|
244
254
|
readonly playwrightMcpSetup: `## Playwright MCP Setup (for AI agents)
|
|
245
255
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
export const sdkGuidanceSections={projectIntro:`## Phoenix Configurator Project
|
|
2
2
|
|
|
3
|
-
Declarative 3D product configurator built with Phoenix SDK. Domain model defined in YAML, custom logic in TypeScript.`,buildCommands:"## Build Commands\n- `pnpm phoenix build` — Full production build\n- `pnpm phoenix dev` — Development mode with hot reload\n- `pnpm phoenix sync` — Sync generated files without full build",devLogs:"## Dev Logs\n- `pnpm phoenix dev` writes all logs to `.phoenix/phoenix-dev.log`\n- Contains: spec parse errors, build errors, browser runtime errors, file watcher events\n- After making changes, always check the log for errors: `tail -50 .phoenix/phoenix-dev.log` or `grep ERROR .phoenix/phoenix-dev.log`\n- Rotated automatically at 10MB (`.log.1`, `.log.2` are older rotations)",projectStructure:"## Project Structure\n- `spec/` — Domain YAML specs (entities, relations, expressions, invariants, components, application config)\n- `src/expressions/` — Computed/derived entity properties (`phx.domain.implementCustomExpressions()`)\n- `src/actions/` — Domain operations declared under `actions:` in the spec (`phx.domain.implementActions()`), run by controls and exposed to the assistant\n- `src/invariants/` — Business rule constraints (`phx.domain.implementCustomInvariants()`)\n- `src/transformers/` — Automatic state fixers (`phx.frontend.transformerBuilder`)\n- `src/components/` — ECS system components (`phx.domain.attachComponent.withEntity()`)\n- `src/elements/` — Page-level React components (`phx.frontend.createCustomPhoenixLayoutElement()`)\n- `src/handlers/` — Event & lifecycle handlers\n- `src/webhooks/` — Server-side webhook handlers (`phx.webhooks.*Builder`)\n- `src/migrations/` — Schema version migrations (v*.ts)\n- `src/locales/` — Locale catalogs (authored .po sources; compiled .ts, do not edit)\n- `src/admin/` — Admin interface (mirrors main src/ structure)\n- `src/_shared/` — Shared utilities (NOT auto-imported, requires explicit imports)\n- `.phoenix/` — Auto-generated files (DO NOT EDIT)\n- `public/` — Static assets (3D models, textures, images, HDRI maps, fonts)\n- `assets/` — Uploadable resources (PDF templates, email assets)",architecturePrinciples:"## Architecture Principles\n- YAML-first: 99% of configuration in spec/*.yaml, custom code is last resort\n- ECS pattern: entities (domain objects) + components (behavior/UI) + systems (SDK runtime)\n- Reactive state: immutable domain state flows through RxJS pipelines\n- Data binding: `$read` (read-only), `$write` (write-only), `$bind` (bidirectional) connect components to domain\n- memoEval: all component implementations must use memoEval for stable object references",keyPatterns:"## Key Patterns\n- Expressions = computed properties on entities (prefix: `$`), implemented per-entity in src/expressions/\n- Actions = named domain operations (`actions:` in YAML with `params` and `result` schemas), implemented in src/actions/, prepared through `actionSystem.prepareAction()` and exposed as assistant mutations through `assistant.actions`\n- Invariants = validation rules returning violations, static or dynamic\n- Transformers = auto-fixers maintaining consistency after state changes\n- Components defined in YAML with `$read`/`$bind` bindings, or custom in src/components/ using attachComponent builder\n- Webhooks = server-side finalization tasks (PDF, email, CSV, image rendering)\n- Filters = contextual component activation via navigationStates, viewStates, modes, selectedEntityIds",fileConventions:`## File Conventions
|
|
3
|
+
Declarative 3D product configurator built with Phoenix SDK. Domain model defined in YAML, custom logic in TypeScript.`,buildCommands:"## Build Commands\n- `pnpm phoenix build` — Full production build\n- `pnpm phoenix dev` — Development mode with hot reload\n- `pnpm phoenix sync` — Sync generated files without full build",devLogs:"## Dev Logs\n- `pnpm phoenix dev` writes all logs to `.phoenix/phoenix-dev.log`\n- Contains: spec parse errors, build errors, browser runtime errors, file watcher events\n- After making changes, always check the log for errors: `tail -50 .phoenix/phoenix-dev.log` or `grep ERROR .phoenix/phoenix-dev.log`\n- Rotated automatically at 10MB (`.log.1`, `.log.2` are older rotations)",projectStructure:"## Project Structure\n- `spec/` — Domain YAML specs (entities, relations, expressions, invariants, components, application config)\n- `src/expressions/` — Computed/derived entity properties (`phx.domain.implementCustomExpressions()`)\n- `src/actions/` — Domain operations declared under `actions:` in the spec (`phx.domain.implementActions()`), run by controls and exposed to the assistant\n- `src/invariants/` — Business rule constraints (`phx.domain.implementCustomInvariants()`)\n- `src/transformers/` — Automatic state fixers (`phx.frontend.transformerBuilder`)\n- `src/components/` — ECS system components (`phx.domain.attachComponent.withEntity()`)\n- `src/elements/` — Page-level React components (`phx.frontend.createCustomPhoenixLayoutElement()`)\n- `src/handlers/` — Event & lifecycle handlers\n- `src/webhooks/` — Server-side webhook handlers (`phx.webhooks.*Builder`)\n- `src/migrations/` — Schema version migrations (v*.ts)\n- `src/locales/` — Locale catalogs (authored .po sources; compiled .ts, do not edit)\n- `src/admin/` — Admin interface (mirrors main src/ structure)\n- `src/_shared/` — Shared utilities (NOT auto-imported, requires explicit imports)\n- `.phoenix/` — Auto-generated files (DO NOT EDIT, except the option files in `.phoenix/variants/`)\n- `public/` — Static assets (3D models, textures, images, HDRI maps, fonts)\n- `assets/` — Uploadable resources (PDF templates, email assets)",architecturePrinciples:"## Architecture Principles\n- YAML-first: 99% of configuration in spec/*.yaml, custom code is last resort\n- ECS pattern: entities (domain objects) + components (behavior/UI) + systems (SDK runtime)\n- Reactive state: immutable domain state flows through RxJS pipelines\n- Data binding: `$read` (read-only), `$write` (write-only), `$bind` (bidirectional) connect components to domain\n- memoEval: all component implementations must use memoEval for stable object references",keyPatterns:"## Key Patterns\n- Expressions = computed properties on entities (prefix: `$`), implemented per-entity in src/expressions/\n- Actions = named domain operations (`actions:` in YAML with `params` and `result` schemas), implemented in src/actions/, prepared through `actionSystem.prepareAction()` and exposed as assistant mutations through `assistant.actions`\n- Invariants = validation rules returning violations, static or dynamic\n- Transformers = auto-fixers maintaining consistency after state changes\n- Components defined in YAML with `$read`/`$bind` bindings, or custom in src/components/ using attachComponent builder\n- Webhooks = server-side finalization tasks (PDF, email, CSV, image rendering)\n- Filters = contextual component activation via navigationStates, viewStates, modes, selectedEntityIds",fileConventions:`## File Conventions
|
|
4
4
|
- Components: PascalCase (.tsx)
|
|
5
5
|
- Utilities: kebab-case (.ts)
|
|
6
6
|
- Expressions: entity-name.ts
|
|
7
7
|
- Migrations: v*.ts (sequential)
|
|
8
8
|
- Specs: kebab-case.yaml
|
|
9
9
|
- Underscore folders (_shared/, _utils/) are NOT auto-imported`,exportRules:`## Export Rules
|
|
10
|
-
All files in src/expressions/, src/actions/, src/invariants/, src/transformers/, src/components/, src/handlers/, src/webhooks/ MUST use default exports. Can be a single item or an array. Named exports are ignored by the SDK auto-import system.`,webhookMembership:"## Webhook Membership\n- User-authored components, admin components, and handlers are frontend-only by default.\n- A handler that registers `createMapPublishedRevisionDataHandler`, `createMapPublishedChunkedDataHandler`, `createDataRowValidatorHandler`, or `createSheetValidatorHandler` is included in the webhook automatically.\n- To include another component or handler in the webhook, put `'use webhook'` as its first statement (leading comments are allowed).\n- A webhook-included file and its runtime local-import closure must not import browser SDK entrypoints (`application`, `admin`, `configurator`, `editor`, `react`, `ui`) or style/font/image assets. Spec parsing reports the unsafe import and inclusion reason as a diagnostic.\n- Keep browser rendering in an unpinned file. Move shared server-safe logic into a separate module when both frontend and webhook code need it.",doNotEdit:"## Do Not Edit\n- `.phoenix/` directory — completely regenerated on each build\n- `tsconfig.json`, `eslint.config.js`, `prettier.config.js`, `.prettierignore`, `.linguirc`, `.nvmrc`\n- `src/locales/` .ts files — compiled from .po files",modelVersioning:"## Version the Domain Model Once It Is Deployed\nA deployed model version cannot change, because saved configurations depend on it. `pnpm phoenix deploy` is refused, before anything is uploaded, when the model differs from the one already deployed under the same version. The version is not written anywhere: `model.latest` is always the highest numbered version plus one, so a spec with only `latest` is v1, and `applicationVersion` plays no part. `pnpm phoenix dev` accepts every model change, so a working preview does not show that the deploy will pass.\n\nTo change a model that has been deployed as v<N>:\n1. Keep the deployed model, unchanged, as `model.v<N>` in the spec. A file of its own, such as `spec/model-v<N>.yaml`, keeps `latest` readable.\n2. Make the change in `model.latest`, which is now v<N+1>.\n3. Add `src/migrations/v<N+1>.ts` exporting `migrateToV<N+1>`. It receives the saved entities of v<N> and returns entities that are valid in v<N+1>; `ModelV<N>` and `ModelV<N+1>` are generated global namespaces.\n\nThe admin model is versioned on its own, under `src/admin/migrations/`, with its types in `AdminV<N>`.\n\n**Replacing the starter.** When the first deployment of the workspace was the starter, its `Box` model is v1 in production. When the real model replaces it, keep the starter's model as `model.v1` and define the real model in `latest`. Configurations saved from the starter are not worth converting, so the migration returns the starting state of the new model, the `initialDomainEntities` of its preset, and drops the boxes:\n```typescript\n// src/migrations/v2.ts\nimport type { Migration } from '@salsita-ai/sdk/domain'\n\nexport const migrateToV2: Migration<ModelV1.AnyDomainEntity, ModelV2.AnyDomainEntity> = () => [\n // the entities of the new model's preset\n]\n```\nAn entity of a type the new version no longer has must not stay in the returned state. An entity id starts with its type (`Box-1`), so an entity that keeps its data under a new type name needs a new id and every relation pointing at it rewritten. Read `docs/phoenix/migrations.mdx` before writing any other migration.",sdkDocumentation:'## SDK Documentation\n\nAlways search SDK docs for relevant examples before implementing Phoenix features.\n\n### Commands\n- `pnpm phoenix docs` — Full documentation index with descriptions\n- `pnpm phoenix docs -s <term>` — Search all doc content and metadata (case-insensitive)\n- `pnpm phoenix docs --dir` — Print the absolute docs directory for grep or ripgrep\n- `pnpm phoenix docs -r <path>` — Read specific doc (raw text to stdout)\n- `pnpm phoenix docs -c <category>` — List docs in a category\n- `pnpm phoenix docs -l` — List all doc paths with titles\n\n### Key Docs\n- `docs/phoenix/getting-started.mdx` — Setup, prerequisites, first project\n- `docs/phoenix/quickstart.mdx` — End-to-end tutorial building a configurator\n- `docs/phoenix/domain-model.mdx` — Entities, attributes, relations, specs\n- `docs/phoenix/components.mdx` — ECS pattern, YAML components, custom components\n- `docs/phoenix/project-structure.mdx` — Files, folders, conventions\n- `docs/phoenix/best-practices.mdx` — YAML-first, code patterns, common pitfalls\n- `docs/phoenix/state-management.mdx` — Reactive state, RxJS pipelines, data flow\n- `docs/phoenix/expression-context.mdx` — Expression API, context object, helpers\n- `docs/phoenix/webhooks.mdx` — Server-side finalization workflows\n- `docs/phoenix/finalization.mdx` — Finalization system, PDF/email/CSV generation\n- `docs/phoenix/migrations.mdx` — Schema versioning, migration files\n\n### Agent Workflow\n1. Find relevant docs by content: `pnpm phoenix docs -s \'camera\'`\n2. Read matching doc: `pnpm phoenix docs -r docs/3d-scene/cameras/transitions.mdx`\n3. Search within a doc: `pnpm phoenix docs -r <path> | grep "example"`\n4. Search titles only as a fallback: `pnpm phoenix docs -l | grep "camera"`\n5. Search generated files directly when needed: `grep -rn "camera" "$(pnpm phoenix docs --dir)"`\n6. Browse a topic area: `pnpm phoenix docs -c "Layouts"`',assetInspection:"## 3D Asset Inspection\n\nRun `pnpm phoenix inspect:assets` to discover all glTF/GLB files in `public/` and get a summary table. Use `--file <path>` for detailed single-file inspection.\n\n**When to use:** before writing YAML/code that references objectName, materialsToOverride, morphTargetInfluences, or object positioning.\n\n**What it shows per node:**\n- Name, type (Mesh/Light/Camera/Empty), children\n- Transform: `pos=`, `rot=`, `scale=` (only non-default values shown)\n- Per-node morph targets: `morphs=Width,Height`\n- Bounding box: `bounds=WxHxD` (width x height x depth in meters)\n- Origin placement: `origin=center`, `bottom-center`, `top-center`, `bottom-corner`, etc. with numerical offset\n\n**Use cases:**\n- Find exact node names for `objectName` in scene entity components\n- Find material names for `materialsToOverride`\n- Find morph target names for `morphTargetInfluences`\n- Check model dimensions (bounds) for positioning, dimension indicators, or invariants\n- Check origin placement to understand how models anchor (center vs bottom-center)\n- Use `--json` for programmatic access to all data\n\n**Flags:** `--objects`, `--materials`, `--morphs`, `--animations`, `--json`",screenshotCli:"## Screenshot CLI\n\nCapture 3D canvas screenshots from a running dev server via `pnpm phoenix screenshot`.\n\n**Prerequisites:**\n1. `phoenix dev` must be running (starts the WebSocket server on port 3020)\n2. Configurator must be open in a browser (the browser does the actual rendering)\n\n**Options:**\n- `--output, -o` — Output file path (default: `./screenshot.png`)\n- `--width, -w` — Width in pixels (default: 1920)\n- `--height` — Height in pixels (default: 1080)\n- `--format, -f` — Image format: PNG or JPEG (default: PNG)\n- `--port` — WebSocket server port (default: 3020)\n\n**Example workflow:**\n1. Start dev server: `pnpm phoenix dev`\n2. Open configurator in browser (or via Playwright MCP)\n3. Take screenshot: `pnpm phoenix screenshot -o ./render.png -w 2560 --height 1440`\n\n**Use cases:** visual regression testing, generating product images, debugging 3D renders",inspectState:"## Inspect: State\n\nGet the current editor runtime state as JSON via `pnpm phoenix inspect:state`.\n\n**Prerequisites:** same as screenshot (dev server running + browser open)\n\n**Options:**\n- `--output, -o` — Output file path (prints to stdout if omitted)\n- `--sections, -s` — Comma-separated: entities, navigation, validation (default: all)\n- `--pretty` — Pretty-print JSON (default: true)\n- `--port` — WebSocket server port (default: 3020)\n\n**Example:** `pnpm phoenix inspect:state -o ./state.json -s entities,navigation`\n\n**Lifecycle status:** Always included in response. Shows which systems are ready:\n- `domainLoaded` — domain data loaded\n- `domainContextReady` — domain context initialized (entities available)\n- `configuratorReady` — 3D configurator initialized\n- `sessionAuthenticated` — session service active\n- `editorMode` — editor mode enabled\n\n**Tips:** Use `--sections` to limit payload size. Pipe through `head`/`tail` for partial output (e.g., `pnpm phoenix inspect:state | head -50`).\n\n**Use cases:** AI agent context, debugging entity state, inspecting navigation/validation",inspectData:`## Inspect: Data
|
|
10
|
+
All files in src/expressions/, src/actions/, src/invariants/, src/transformers/, src/components/, src/handlers/, src/webhooks/ MUST use default exports. Can be a single item or an array. Named exports are ignored by the SDK auto-import system.`,webhookMembership:"## Webhook Membership\n- User-authored components, admin components, and handlers are frontend-only by default.\n- A handler that registers `createMapPublishedRevisionDataHandler`, `createMapPublishedChunkedDataHandler`, `createDataRowValidatorHandler`, or `createSheetValidatorHandler` is included in the webhook automatically.\n- To include another component or handler in the webhook, put `'use webhook'` as its first statement (leading comments are allowed).\n- A webhook-included file and its runtime local-import closure must not import browser SDK entrypoints (`application`, `admin`, `configurator`, `editor`, `react`, `ui`) or style/font/image assets. Spec parsing reports the unsafe import and inclusion reason as a diagnostic.\n- Keep browser rendering in an unpinned file. Move shared server-safe logic into a separate module when both frontend and webhook code need it.",doNotEdit:"## Do Not Edit\n- `.phoenix/` directory — completely regenerated on each build. The one exception is `.phoenix/variants/`, where the `phoenix-variants` skill has you write option files; a rebuild keeps it\n- `tsconfig.json`, `eslint.config.js`, `prettier.config.js`, `.prettierignore`, `.linguirc`, `.nvmrc`\n- `src/locales/` .ts files — compiled from .po files",modelVersioning:"## Version the Domain Model Once It Is Deployed\nA deployed model version cannot change, because saved configurations depend on it. `pnpm phoenix deploy` is refused, before anything is uploaded, when the model differs from the one already deployed under the same version. The version is not written anywhere: `model.latest` is always the highest numbered version plus one, so a spec with only `latest` is v1, and `applicationVersion` plays no part. `pnpm phoenix dev` accepts every model change, so a working preview does not show that the deploy will pass.\n\nTo change a model that has been deployed as v<N>:\n1. Keep the deployed model, unchanged, as `model.v<N>` in the spec. A file of its own, such as `spec/model-v<N>.yaml`, keeps `latest` readable.\n2. Make the change in `model.latest`, which is now v<N+1>.\n3. Add `src/migrations/v<N+1>.ts` exporting `migrateToV<N+1>`. It receives the saved entities of v<N> and returns entities that are valid in v<N+1>; `ModelV<N>` and `ModelV<N+1>` are generated global namespaces.\n\nThe admin model is versioned on its own, under `src/admin/migrations/`, with its types in `AdminV<N>`.\n\n**Replacing the starter.** When the first deployment of the workspace was the starter, its `Box` model is v1 in production. When the real model replaces it, keep the starter's model as `model.v1` and define the real model in `latest`. Configurations saved from the starter are not worth converting, so the migration returns the starting state of the new model, the `initialDomainEntities` of its preset, and drops the boxes:\n```typescript\n// src/migrations/v2.ts\nimport type { Migration } from '@salsita-ai/sdk/domain'\n\nexport const migrateToV2: Migration<ModelV1.AnyDomainEntity, ModelV2.AnyDomainEntity> = () => [\n // the entities of the new model's preset\n]\n```\nAn entity of a type the new version no longer has must not stay in the returned state. An entity id starts with its type (`Box-1`), so an entity that keeps its data under a new type name needs a new id and every relation pointing at it rewritten. Read `docs/phoenix/migrations.mdx` before writing any other migration.",sdkDocumentation:'## SDK Documentation\n\nAlways search SDK docs for relevant examples before implementing Phoenix features.\n\n### Commands\n- `pnpm phoenix docs` — Full documentation index with descriptions\n- `pnpm phoenix docs -s <term>` — Search all doc content and metadata (case-insensitive)\n- `pnpm phoenix docs --dir` — Print the absolute docs directory for grep or ripgrep\n- `pnpm phoenix docs -r <path>` — Read specific doc (raw text to stdout)\n- `pnpm phoenix docs -c <category>` — List docs in a category\n- `pnpm phoenix docs -l` — List all doc paths with titles\n\n### Key Docs\n- `docs/phoenix/getting-started.mdx` — Setup, prerequisites, first project\n- `docs/phoenix/quickstart.mdx` — End-to-end tutorial building a configurator\n- `docs/phoenix/domain-model.mdx` — Entities, attributes, relations, specs\n- `docs/phoenix/components.mdx` — ECS pattern, YAML components, custom components\n- `docs/phoenix/project-structure.mdx` — Files, folders, conventions\n- `docs/phoenix/best-practices.mdx` — YAML-first, code patterns, common pitfalls\n- `docs/phoenix/state-management.mdx` — Reactive state, RxJS pipelines, data flow\n- `docs/phoenix/expression-context.mdx` — Expression API, context object, helpers\n- `docs/phoenix/webhooks.mdx` — Server-side finalization workflows\n- `docs/phoenix/finalization.mdx` — Finalization system, PDF/email/CSV generation\n- `docs/phoenix/migrations.mdx` — Schema versioning, migration files\n\n### Agent Workflow\n1. Find relevant docs by content: `pnpm phoenix docs -s \'camera\'`\n2. Read matching doc: `pnpm phoenix docs -r docs/3d-scene/cameras/transitions.mdx`\n3. Search within a doc: `pnpm phoenix docs -r <path> | grep "example"`\n4. Search titles only as a fallback: `pnpm phoenix docs -l | grep "camera"`\n5. Search generated files directly when needed: `grep -rn "camera" "$(pnpm phoenix docs --dir)"`\n6. Browse a topic area: `pnpm phoenix docs -c "Layouts"`',assetInspection:"## 3D Asset Inspection\n\nRun `pnpm phoenix inspect:assets` to discover all glTF/GLB files in `public/` and get a summary table. Use `--file <path>` for detailed single-file inspection.\n\n**When to use:** before writing YAML/code that references objectName, materialsToOverride, morphTargetInfluences, or object positioning.\n\n**What it shows per node:**\n- Name, type (Mesh/Light/Camera/Empty), children\n- Transform: `pos=`, `rot=`, `scale=` (only non-default values shown)\n- Per-node morph targets: `morphs=Width,Height`\n- Bounding box: `bounds=WxHxD` (width x height x depth in meters)\n- Origin placement: `origin=center`, `bottom-center`, `top-center`, `bottom-corner`, etc. with numerical offset\n\n**Use cases:**\n- Find exact node names for `objectName` in scene entity components\n- Find material names for `materialsToOverride`\n- Find morph target names for `morphTargetInfluences`\n- Check model dimensions (bounds) for positioning, dimension indicators, or invariants\n- Check origin placement to understand how models anchor (center vs bottom-center)\n- Use `--json` for programmatic access to all data\n\n**Flags:** `--objects`, `--materials`, `--morphs`, `--animations`, `--json`",screenshotCli:"## Screenshot CLI\n\nCapture 3D canvas screenshots from a running dev server via `pnpm phoenix screenshot`.\n\n**Prerequisites:**\n1. `phoenix dev` must be running (starts the WebSocket server on port 3020)\n2. Configurator must be open in a browser (the browser does the actual rendering)\n\n**Options:**\n- `--output, -o` — Output file path (default: `./screenshot.png`)\n- `--width, -w` — Width in pixels (default: 1920)\n- `--height` — Height in pixels (default: 1080)\n- `--format, -f` — Image format: PNG or JPEG (default: PNG)\n- `--port` — WebSocket server port (default: 3020)\n\n**Example workflow:**\n1. Start dev server: `pnpm phoenix dev`\n2. Open configurator in browser (or via Playwright MCP)\n3. Take screenshot: `pnpm phoenix screenshot -o ./render.png -w 2560 --height 1440`\n\n**Use cases:** visual regression testing, generating product images, debugging 3D renders",inspectState:"## Inspect: State\n\nGet the current editor runtime state as JSON via `pnpm phoenix inspect:state`.\n\n**Prerequisites:** same as screenshot (dev server running + browser open)\n\n**Options:**\n- `--output, -o` — Output file path (prints to stdout if omitted)\n- `--sections, -s` — Comma-separated: entities, navigation, validation (default: all)\n- `--pretty` — Pretty-print JSON (default: true)\n- `--port` — WebSocket server port (default: 3020)\n\n**Example:** `pnpm phoenix inspect:state -o ./state.json -s entities,navigation`\n\n**Lifecycle status:** Always included in response. Shows which systems are ready:\n- `domainLoaded` — domain data loaded\n- `domainContextReady` — domain context initialized (entities available)\n- `configuratorReady` — 3D configurator initialized\n- `sessionAuthenticated` — session service active\n- `editorMode` — editor mode enabled\n\n**Tips:** Use `--sections` to limit payload size. Pipe through `head`/`tail` for partial output (e.g., `pnpm phoenix inspect:state | head -50`).\n\n**Use cases:** AI agent context, debugging entity state, inspecting navigation/validation",inspectData:`## Inspect: Data
|
|
11
11
|
|
|
12
12
|
Get the current editor context data as JSON via \`pnpm phoenix inspect:data\`.
|
|
13
13
|
|
|
@@ -41,7 +41,7 @@ Get the current Three.js scene graph as JSON via \`pnpm phoenix inspect:scene\`.
|
|
|
41
41
|
|
|
42
42
|
**Tips:** Scene graphs can be large. Pipe through \`head\`/\`tail\` for partial output.
|
|
43
43
|
|
|
44
|
-
**Use cases:** AI agent context, debugging 3D scene layout, inspecting materials and lighting`,playwrightMcpSetup:`## Playwright MCP Setup (for AI agents)
|
|
44
|
+
**Use cases:** AI agent context, debugging 3D scene layout, inspecting materials and lighting`,variantPreviews:"## Variant Previews\n\nShow the user several options over the configurator and get their pick back via `pnpm phoenix variants .phoenix/variants/<name>`. Use it when a visual or interaction decision is a matter of the user's taste. Load the `phoenix-variants` skill first: it says when to offer options, how to write them and which flags the command takes.\n\n**Prerequisites:** same as screenshot (dev server running + browser open)\n\n**Input:** a folder below `.phoenix/variants/` with one file per option: `<id>.variant.js` for a script that edits frontend files from a captured baseline, or `<id>.html` for a standalone iframe proposal. A selected HTML proposal is returned for you to implement. Options run one at a time through normal hot reload; Original always restores the baseline; picking an option keeps its edits, and the agent can use `--close` to restore the baseline without an answer.\n\n**Returns:** JSON with `status`: `chosen` (with `choice`, or `null` for Original, and `label`), `waiting` (the wait ran out and the preview stays open; run the same command again) or `closed`.",playwrightMcpSetup:`## Playwright MCP Setup (for AI agents)
|
|
45
45
|
|
|
46
46
|
To interact with the 3D configurator in a browser, Playwright MCP needs GPU-enabled flags for WebGL rendering. Add to \`~/.claude/.mcp.json\`:
|
|
47
47
|
|
package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{GraphQLBoolean as e,GraphQLID as t,GraphQLInputObjectType as n,GraphQLInterfaceType as i,GraphQLList as r,GraphQLNonNull as o,GraphQLObjectType as s,GraphQLSchema as a,GraphQLString as p,getNullableType as l,isInputType as d,isNonNullType as c,printSchema as h,validateSchema as f}from"graphql";import{isArray as u}from"@salsita-ai/common/utils";import{DiagnosticCode as y,diagnosticMessages as m}from"../../../../errors/index.js";import{ASSISTANT_HINTS as g}from"../../../raw-spec/index.js";import{createSchemaRefResolver as S,isNullableSchema as T,phxDefinitionRefName as N}from"../../common/index.js";import{GraphQLJSON as w,assistantFieldType as A}from"./assistant-field-type.js";let E="AnyDomainEntity",I="RootEntity",_="RecipeGroup",O="Recipe",v="DryRun",b="DryRunError",$="FieldOption",R={section:"childRecipes",component:"childEntityRecipes",mutation:"addChild"},j={section:"replaceRecipes",component:"replaceEntityRecipes",mutation:"replaceEntity"},M=new Set(["id","type","meta"]),D=new Set(["Query","Mutation","Navigation",_,O,v,b,$,"JSON","String","Int","Float","Boolean","ID"]),C=(e,t)=>{if(!t||0===t.length)return e;let n=`One of: ${t.map(e=>`"${e}"`).join(", ")}.`;return e?/[.!?]$/.test(e)?`${e} ${n}`:`${e}. ${n}`:n},U=new Set(["id","type"]),P="The raw stored value: a string, a message descriptor or a multilingual object.",k=(e,t)=>Object.entries(e).find(([e])=>e===t)?.[1],L=(e,t,{model:n,components:i})=>{let r=new Set([e,...[...n.aliases].filter(([,t])=>t.includes(e)).map(([e])=>e)]);return i.filter(({componentName:e,typeOrAlias:n})=>e===t&&r.has(n)).map(({instanceName:e})=>String(e))},W=(...e)=>g.filter(t=>e.some(e=>e?.includes(t))),F=(e,t,n,i)=>{let{model:r}=n,o=new Map,s=new Map;for(let[a,{groups:p,hints:l}]of Object.entries(e)){let e=r.entityTypes.find(e=>e===a),d=e?[e]:[...r.aliases].find(([e])=>e===a)?.[1];if(!d){i.at(a).error(y.ASSISTANT_UNKNOWN_TYPE,m.ASSISTANT_UNKNOWN_TYPE(t.section,a));continue}for(let[e,r]of(d.forEach(e=>s.set(e,W(s.get(e),l))),Object.entries(p))){let s=i.at(a,"groups",e),p=`${a}.groups.${e}`,l={groupId:r?.as??e,instanceName:e,...r?.description===void 0?{}:{description:r.description},dryRuns:r?.allowDryRuns??!0};for(let i of d){let r=L(i,t.component,n),d=o.get(i)??[],c=d.find(e=>e.group.groupId===l.groupId);r.includes(e)?c?s.error(y.ASSISTANT_DUPLICATE_RECIPE_GROUP,m.ASSISTANT_DUPLICATE_RECIPE_GROUP(t,i,l.groupId,[c.entry,p])):o.set(i,[...d,{group:l,entry:p,entryCtx:s}]):s.error(y.ASSISTANT_UNKNOWN_RECIPE_GROUP,m.ASSISTANT_UNKNOWN_RECIPE_GROUP(t,a,e,i,r))}}}let a=r.entityTypes.flatMap(e=>{let t=o.get(e);return t?[{entityType:e,groups:t}]:[]});return{groups:Object.fromEntries(a.map(({entityType:e,groups:t})=>[e,t.map(({group:e})=>e)])),hints:s,metaEntries:new Map(a.flatMap(({entityType:e,groups:[t]})=>t?[[e,t.entryCtx]]:[]))}},K=e=>`${e}Meta`,G={sameForAllInstances:e=>`All ${e} instances have the same recipe groups and recipes.`,constantDuringSession:()=>"They do not change during the session."},Y={sameForAllInstances:e=>`All ${e} instances have the same options.`,constantDuringSession:()=>"The options do not change during the session."},V=(e,t,n)=>e.map(e=>` ${t[e](n)}`).join(""),x=({section:e},{groups:t,hints:n},{entityType:i,purpose:s,recipeGroup:a})=>{let p=t.filter(({dryRuns:e})=>!e).map(({groupId:e})=>e);return 0===t.length?{}:{[e]:{type:new o(new r(new o(a))),description:`The recipe groups for ${s} this ${i}: ${t.map(({groupId:e})=>e).join(", ")}. A group is listed only while the configurator offers it on the entity.${p.length>0?` Recipes of these groups cannot be dry-run: ${p.join(", ")}.`:""}${V(n,G,i)}`}}},q=({entityType:e,childRecipes:t,replaceRecipes:n,optionsEdges:i},{recipeGroup:a,fieldOption:p})=>new s({name:K(e),fields:Object.assign(x(R,t,{entityType:e,purpose:"adding children to",recipeGroup:a}),x(j,n,{entityType:e,purpose:"replacing",recipeGroup:a}),...i.map(({options:{edgeName:t,field:n,validate:i,dryRuns:s},hints:a})=>({[t]:{type:new r(new o(p)),description:`The values "${n}" of this ${e} can take, as the configurator offers them in the current state of the entity. Null when the configurator does not offer a choice on this entity. ${i?`modify${e} rejects a "${n}" value that is not in the list.`:`modify${e} does not check "${n}" against the list.`}${s?"":" Its options cannot be dry-run."}${V(a,Y,e)}`}})))}),B=e=>Object.entries(e).map(([e,t])=>`${e} (${t.map(({groupId:e})=>e).join(", ")})`),z=e=>`${e.charAt(0).toUpperCase()+e.slice(1)}Result`,H=e=>{let t=(({anyOf:e,oneOf:t})=>[...e??[],...t??[]].filter(e=>"null"!==e.type))(e);return e.$ref||1!==t.length?e:t[0]},Q=(e,t)=>{let{type:n,enumValues:i,localized:r,description:s}=A(e,t),a=l(n),p=r||!d(a)?w:a;return{type:c(n)?new o(p):p,description:r?`${C(s,i)??""} ${P}`.trim():C(s,i)}},X=(e,t)=>{let n=(e=>{let t=H(e),n=t?.type==="array"&&t.items&&!u(t.items)?H(t.items):void 0,i=n??t;if(i?.$ref!==void 0&&"$DomainEntity"===N(i.$ref))return{target:i.references??E,list:void 0!==n}})(e);if(n)return{kind:"edge",...n,nullable:T(e,t),description:e.description};let{type:i,enumValues:r,localized:s,description:a}=A(e,t);return s?{kind:"value",type:c(i)?new o(w):w,description:`${a??""} ${P}`.trim()}:{kind:"value",type:i,description:C(a,r)}},J=(e,t)=>{let{type:n,enumValues:i,localized:r,description:o}=A(e,t);return{type:{kind:"value",type:n},localized:!0===r,description:o,enumValues:i}},Z=(e,t,n,i,r)=>{let o=t.startsWith("$")?((e,t,n,i)=>{let r=((e,t,{expressions:n,entityTypes:i})=>{let r=i.find(t=>t===e);for(let i of void 0===r?[e]:n.entityExpressions.get(r)?.shapeGroups??[]){let e=[...n.expressions.get(i)?.values()??[]].find(e=>e.name===t);if(e?.declaredSchema)return{implementation:e.implementation,declaredSchema:e.declaredSchema}}})(e.name,t,n);if(!r)return void i.error(y.ASSISTANT_UNKNOWN_FIELD,m.ASSISTANT_UNKNOWN_FIELD("entities",e.name,t));let{implementation:o}=r;if("violations"===o.kind)return void i.error(y.ASSISTANT_UNSUPPORTED_EXPRESSION,m.ASSISTANT_UNSUPPORTED_EXPRESSION(e.name,t));if("relation"===o.kind){let[{target:e,cardinality:t}]=o.params;return{type:{kind:"edge",target:e,cardinality:t},localized:!1,description:void 0}}return J(r.declaredSchema,n.resolveRef)})(e,t,i,r):((e,t,{resolveRef:n},i)=>{let r=Object.entries(e.shape.properties).find(([e])=>e===t)?.[1];return r?J(r,n):void i.error(y.ASSISTANT_UNKNOWN_FIELD,m.ASSISTANT_UNKNOWN_FIELD("entities",e.name,t))})(e,t,i,r);if(!o)return;let s=n?.as??(t.startsWith("$")?`_${t.slice(1)}`:t);return M.has(s)?void r.error(y.ASSISTANT_RESERVED_FIELD_NAME,m.ASSISTANT_RESERVED_FIELD_NAME(e.name,t,s)):{name:s,source:t,type:o.type,localized:o.localized,description:C(n?.description??o.description,o.enumValues),describedByManifest:n?.description!==void 0,enumValues:o.enumValues,valueDescription:n?.description??o.description}},ee=(e,t,n,i,r)=>{let o=new Map,s=new Set(n);for(let i of[...t.flat(),...n]){let t=o.get(i.name);t?t.source!==i.source?r.error(y.ASSISTANT_FIELD_NAME_CONFLICT,m.ASSISTANT_FIELD_NAME_CONFLICT(e,i.name,[t.source,i.source])):s.has(i)&&i.describedByManifest&&o.set(i.name,{...t,description:i.description,describedByManifest:!0,valueDescription:i.valueDescription}):o.set(i.name,i)}return[...o.values()].map(e=>{let t=e.enumValues&&i(e.source);return t?{...e,enumValues:t,description:C(e.valueDescription,t)}:e})},et=({target:e,cardinality:t},n)=>{let i=n(e);switch(t){case"1-1":return new o(i);case"0-1":return i;case"0-N":case"1-N":return new o(new r(new o(i)))}},en=(e,n,i)=>{let r={id:{type:new o(t),description:i?`Unique id of the entity, e.g. "${i}-1".`:void 0},type:{type:new o(p),description:i?`Entity type name, e.g. "${i}".`:void 0}};for(let t of e)r[t.name]={type:"edge"===t.type.kind?et(t.type,n):t.type.type,description:t.description};return r},ei=e=>Object.fromEntries(e.map(({name:e,source:t,localized:n})=>[e,n?{source:t,localized:!0}:{source:t}]));export const createAssistantSchema=(c,{model:u,states:g,phxDefinitions:T,expressions:N,components:M,actions:G},Y)=>{let V,x,H,J=Y.errors.length,et=Y.at("entities"),er=S(u.commonTypes,T),eo=u.rootEntityTypes[0]??u.entityTypes[0];for(let e of u.resolvedShapes.keys())D.has(e)&&Y.error(y.ASSISTANT_RESERVED_TYPE_NAME,m.ASSISTANT_RESERVED_TYPE_NAME(e,[...D]));let es=((e,t,n)=>{let i=new Map;for(let r of e){let e=[];for(let[i,o]of Object.entries(r.entries)){let s=Z(r,i,o,t,n.at(r.name,i));s&&e.push(s)}i.set(r.name,e)}return i})(((e,t,n)=>{let i=[];for(let[n,r]of t.resolvedShapes){let t=e[n];t&&i.push({name:n,shape:r,entries:t})}let r=new Set(t.resolvedShapes.keys());for(let t of Object.keys(e))r.has(t)||n.at(t).error(y.ASSISTANT_UNKNOWN_TYPE,m.ASSISTANT_UNKNOWN_TYPE("entities",t));return i})(c.entities??{},u,et),{resolveRef:er,expressions:N,entityTypes:u.entityTypes},et),ea=ee(E,[],es.get(E)??[],()=>void 0,Y),ep=new Map,el=F(c.childRecipes??{},R,{model:u,components:M},Y.at(R.section)),ed=F(c.replaceRecipes??{},j,{model:u,components:M},Y.at(j.section)),ec=el.groups,eh=ed.groups,ef=new Map,eu=e=>{let[t,...n]=[...u.aliases].find(([t])=>t===e)?.[1]??[];return(t&&0===n.length?ep.get(t):void 0)??eg.get(e)??em},ey=e=>ep.get(e)??eu(e),em=new i({name:E,description:"Every domain entity.",fields:()=>en(ea,ey,eo)}),eg=new Map([[E,em]]),eS=new Map([[E,ea]]);for(let e of u.aliases.keys()){if(e===E)continue;let t=ee(e,[ea],es.get(e)??[],()=>void 0,Y);eS.set(e,t),eg.set(e,new i({name:e,interfaces:[em],fields:()=>en(t,ey)}))}let eT={};for(let e of u.entityTypes){let t=[E,...[...u.aliases].filter(([t,n])=>t!==E&&n.includes(e)).map(([e])=>e)],n=[...u.resolvedShapes].find(([t])=>t===e)?.[1],i=ee(e,t.map(e=>eS.get(e)??[]),es.get(e)??[],e=>{let t=Object.entries(n?.properties??{}).find(([t])=>t===e)?.[1];return t&&A(t,er).enumValues},Y);eT[e]=ei(i),ep.set(e,new s({name:e,interfaces:t.flatMap(e=>eg.get(e)??[]),fields:()=>{let t=ef.get(e);return{...en(i,ey),...t?{meta:{type:new o(t),description:"What the configurator offers on this entity besides its fields."}}:{}}}}))}let eN=((e,t,n)=>{let i=new Set;for(let r of Object.keys(e)){let e=t.entityTypes.find(e=>e===r),o=e?[e]:[...t.aliases].find(([e])=>e===r)?.[1];o?o.forEach(e=>i.add(e)):n.at(r).error(y.ASSISTANT_UNKNOWN_TYPE,m.ASSISTANT_UNKNOWN_TYPE("deletable",r))}return t.entityTypes.filter(e=>i.has(e))})(c.deletable??{},u,Y.at("deletable")),ew=((e,t,n)=>{let{model:i}=t,r=((e,t,n)=>{let i=new Map;for(let[r,o]of Object.entries(e)){let e=t.entityTypes.find(e=>e===r),s=e?[e]:[...t.aliases].find(([e])=>e===r)?.[1],a=[...t.resolvedShapes].find(([e])=>e===r)?.[1];if(!s||!a){n.at(r).error(y.ASSISTANT_UNKNOWN_TYPE,m.ASSISTANT_UNKNOWN_TYPE("writable",r));continue}for(let e of Object.keys(o)){let t=n.at(r,e);e.startsWith("$")||U.has(e)?t.error(y.ASSISTANT_NOT_WRITABLE,m.ASSISTANT_NOT_WRITABLE(r,e)):k(a.properties,e)?s.forEach(t=>{let n=i.get(t)??new Map;i.set(t,n.has(e)?n:n.set(e,r))}):t.error(y.ASSISTANT_UNKNOWN_FIELD,m.ASSISTANT_UNKNOWN_FIELD("writable",r,e))}}return i})(e,i,n);return i.entityTypes.flatMap(e=>{let o=[...i.resolvedShapes].find(([t])=>t===e)?.[1],s=[...r.get(e)??[]].flatMap(([i,r])=>{let s=o&&k(o.properties,i);return(s&&((e,{property:t,writableKey:n},i,{resolveRef:r,exposed:o,sources:s},a)=>{let p=Object.entries(k(s,e)??{}),c=o.get(e)?.find(({source:e})=>e===t)?.name??p.find(([,{source:e}])=>e===t)?.[0],h=void 0===c?p.find(([e])=>e===t)?.[1]:void 0;if(h)return void a.at(n,t).error(y.ASSISTANT_FIELD_NAME_CONFLICT,m.ASSISTANT_FIELD_NAME_CONFLICT(e,t,[h.source,t],`${n}.${t}`));let{type:f,enumValues:u,localized:g,description:S}=A(i,r),T=l(f),N=c??t,E=void 0===c?C(S,u):void 0;return g?{name:N,property:t,type:w,description:E?`${E} ${P}`:P}:{name:N,property:t,type:d(T)?T:w,description:E}})(e,{property:i,writableKey:r},s,t,n))??[]});return s.length>0?[{entityType:e,fields:s}]:[]})})(c.writable??{},{model:u,resolveRef:er,exposed:es,sources:eT},Y.at("writable")),eA=((e,t,n,i)=>{let{model:r}=n,o=new Map;for(let[s,a]of Object.entries(e)){let e=r.entityTypes.find(e=>e===s),p=e?[e]:[...r.aliases].find(([e])=>e===s)?.[1]??[];for(let[e,r]of Object.entries(a)){let a=r?.options;if(!a)continue;let l=i.at(s,e,"options"),d=`${s}.${e}`;for(let i of p){let r=t.find(e=>e.entityType===i)?.fields.find(t=>t.property===e);if(!r)continue;let s=a.as??`${r.name}Options`,p=L(i,a.componentType,n),c=o.get(i)??[],h=c.find(e=>e.options.edgeName===s||e.options.field===r.name);if(p.includes(a.instanceName))if(s===R.section||s===j.section)l.error(y.ASSISTANT_RESERVED_OPTIONS_EDGE_NAME,m.ASSISTANT_RESERVED_OPTIONS_EDGE_NAME(d,s,r.name));else if(h)l.error(y.ASSISTANT_DUPLICATE_OPTIONS_EDGE,m.ASSISTANT_DUPLICATE_OPTIONS_EDGE(i,h.options.edgeName===s?{edgeName:s}:{field:r.name},[h.entry,d]));else{let e={field:r.name,edgeName:s,componentType:a.componentType,instanceName:a.instanceName,validate:a.validate??!0,dryRuns:a.allowDryRuns??!0};o.set(i,[...c,{options:e,hints:W(a.hints),entry:d,entryCtx:l}])}else l.error(y.ASSISTANT_UNKNOWN_OPTIONS_COMPONENT,m.ASSISTANT_UNKNOWN_OPTIONS_COMPONENT(d,a,i,p))}}}return o})(c.writable??{},ew,{model:u,components:M},Y.at("writable"));var eE,eI,e_,eO=[el.metaEntries,ed.metaEntries,new Map([...eA].flatMap(([e,[t]])=>t?[[e,t.entryCtx]]:[]))];for(let e of u.entityTypes){let t=eO.flatMap(t=>t.get(e)??[])[0],n=K(e);t&&[...u.resolvedShapes.keys()].some(e=>e===n)&&t.error(y.ASSISTANT_META_TYPE_NAME_CONFLICT,m.ASSISTANT_META_TYPE_NAME_CONFLICT(e,n))}let ev=(eE=()=>eu(eg.has(I)?I:E),eI=()=>eu(E),V=new s({name:b,description:"One reason a change cannot be made right now.",fields:{type:{type:new o(p),description:'Kind of the problem, or "uncaught".'},entityId:{type:t,description:"The entity the problem is on, when it is on one."},message:{type:p,description:"The problem as the configurator words it for the user."}}}),new s({name:v,description:"What using a recipe or writing an option would do, tried out on the configuration the query reads. Nothing is changed.",fields:()=>({ok:{type:new o(e),description:"Whether the change would succeed on the configuration the query reads."},root:{type:eE(),description:"The root entity of the configuration as it would be after the change; null when ok is false. Everything selected under it reads that configuration."},changedEntities:{type:new o(new r(new o(eI()))),description:"Every entity the change would add or modify, read from that configuration; empty when ok is false. An entity whose id the configuration does not have yet would be added, and its id may differ in the real mutation. An entity that is written to is listed even when it gets the values it already has."},deletedEntityIds:{type:new o(new r(new o(t))),description:"The ids of the entities the change would remove; empty when ok is false."},errors:{type:new o(new r(new o(V))),description:"Why the change cannot be made; empty when ok is true."}})})),eb={recipeGroup:(e_=[...Object.keys(ec).length>0?[R.mutation]:[],...Object.keys(eh).length>0?[j.mutation]:[]],x=e_.join(" or "),H=new s({name:O,description:"One recipe of a recipe group: something that can be added to the entity, or something the entity can be replaced with, depending on the edge the group is listed under.",fields:{key:{type:new o(p),description:`Identifies the recipe within its group; pass it to ${x} as recipeKey. Keys can change with the configuration and with the language, so read them from the listing rather than remembering them.`},name:{type:new o(p),description:"Name shown to the user."},description:{type:p},imageUrl:{type:p,description:"Thumbnail shown to the user."},dryRun:{type:ev,description:"Tries the recipe out. Each selected dry-run costs a full recalculation, so select it only on the recipes you are deciding between. Null for a group whose recipes cannot be dry-run."}}}),new s({name:_,description:"A group of recipes of an entity, as the configurator offers them to the user.",fields:{groupId:{type:new o(p),description:`Identifies the group on its entity; pass it to ${x} as recipeGroupId.`},name:{type:new o(p),description:"Name shown to the user."},description:{type:p},recipes:{type:new o(new r(new o(H)))}}})),fieldOption:new s({name:$,description:"One choice of a field, as the configurator offers it to the user.",fields:{value:{type:new o(w),description:"The value to write to the field."},name:{type:p,description:"Name shown to the user."},description:{type:p},imageUrl:{type:p,description:"Image shown to the user."},color:{type:p,description:'Color shown to the user, as "#rrggbb".'},dryRun:{type:ev,description:"Tries writing the value to the field. Each selected dry-run costs a full recalculation, so select it only on the options you are deciding between. Null for a field whose options cannot be dry-run."}}})};for(let e of u.entityTypes){let t=k(ec,e)??[],n=k(eh,e)??[],i=eA.get(e)??[];(t.length>0||n.length>0||i.length>0)&&ef.set(e,q({entityType:e,childRecipes:{groups:t,hints:el.hints.get(e)??[]},replaceRecipes:{groups:n,hints:ed.hints.get(e)??[]},optionsEdges:i},eb))}let e$=Object.fromEntries(u.entityTypes.flatMap(e=>{let t=eA.get(e);return t?[[e,t.map(({options:e})=>e)]]:[]})),eR=((e,{actions:t,resolveRef:n,builtInMutations:i,typeNames:r},o)=>{let s=[],a=[...t.values()].map(e=>String(e.name));for(let[p,l]of Object.entries(e)){let e=o.at(p),d=[...t.values()].find(e=>e.name===p);if(!d){e.error(y.ASSISTANT_UNKNOWN_ACTION,m.ASSISTANT_UNKNOWN_ACTION(p,a));continue}let c=l?.as??p,h=s.find(e=>e.fieldName===c),f=z(c);if(i.has(c)||h){e.error(y.ASSISTANT_MUTATION_NAME_CONFLICT,m.ASSISTANT_MUTATION_NAME_CONFLICT(p,c,h?`"assistant.actions.${h.actionName}" already exposes`:"the built-in mutation of that name already takes"));continue}if(r.has(f)||s.some(e=>z(e.fieldName)===f)){e.error(y.ASSISTANT_ACTION_RESULT_TYPE_CONFLICT,m.ASSISTANT_ACTION_RESULT_TYPE_CONFLICT(p,c,f));continue}s.push({fieldName:c,actionName:p,description:l?.description??d.description,args:Object.fromEntries(Object.entries(d.params).map(([e,{schema:t}])=>[e,Q(t,n)])),result:Object.fromEntries(Object.entries(d.result).map(([e,t])=>[e,X(t,n)]))})}return s})(c.actions??{},{actions:G,resolveRef:er,builtInMutations:new Set(["deleteEntity",R.mutation,j.mutation,...ew.map(({entityType:e})=>`modify${e}`)]),typeNames:new Set([...D,...u.resolvedShapes.keys(),...ef.keys()])},Y.at("actions"));if(Y.errors.length>J)return;let ej=((e,t)=>{let n;try{let t=e();if(n=f(t).map(e=>e.message),0===n.length)return h(t)}catch(e){n=[e instanceof Error?e.message:String(e)]}t.error(y.ASSISTANT_INVALID_SCHEMA,m.ASSISTANT_INVALID_SCHEMA(n))})(()=>{var e,i;let l;return new a({query:(e=eu(eg.has(I)?I:E),i=eu(E),l=(e=>{let t={};for(let[n,i,r]of[["navigationState","Active navigation state.",e.navigationStates],["viewState","Active view state.",e.viewStates],["toolState","Active tool state.",e.toolStates]])r.length>1&&(t[n]={type:new o(p),description:C(i,r)});if(0!==Object.keys(t).length)return new s({name:"Navigation",description:"The active UI states of the configurator.",fields:t})})(g),new s({name:"Query",fields:{root:{type:new o(e),description:"The root entity of the configuration."},entities:{type:new o(new r(i)),args:{ids:{type:new r(new o(t))}},description:`Every entity of the configuration, or, when ids are given, those entities in the same order with null for an unknown id. Ids have the form "<Type>-<number>"${eo?`, e.g. "${eo}-1"`:""}; any other id is an error.`},selectedEntity:{type:new o(i),description:"The selected entity, or the root entity when nothing is selected."},...l?{navigation:{type:new o(l)}}:{}}})),mutation:(({deletableTypes:e,writableTypes:i,childRecipes:a,replaceRecipes:l,actionFields:d,objectTypes:c,anyDomainEntity:h,targetOutputType:f})=>{let u,y;if(0!==e.length||0!==i.length||0!==Object.keys(a).length||0!==Object.keys(l).length||0!==d.length)return new s({name:"Mutation",fields:Object.assign(0===e.length?{}:{deleteEntity:{type:new o(new r(new o(t))),args:{id:{type:new o(t)}},description:`Deletes the entity with the given id, together with the entities composed into it, and returns the ids of every entity the deletion removed, the given one first. Deletable entity types: ${e.join(", ")}. Nothing can be read from removed entities afterwards, so query what you need from them before deleting.`}},0===(u=B(a)).length?{}:{addChild:{type:new o(new r(new o(h))),args:{parentId:{type:new o(t)},recipeGroupId:{type:new o(p)},recipeKey:{type:new o(p)}},description:`Adds a child to the entity with the id parentId by one recipe of one of its recipe groups. Entity types with recipe groups: ${u.join("; ")}. Read the groupId and the recipe key from "meta { childRecipes }" of the parent. Returns every entity the change added, the recipe's own first and then whatever the configurator added in response, such as a part the new child needs; the list is empty when the configurator removed them again.`}},0===(y=B(l)).length?{}:{replaceEntity:{type:new o(new r(new o(h))),args:{entityId:{type:new o(t)},recipeGroupId:{type:new o(p)},recipeKey:{type:new o(p)}},description:`Replaces the entity with the id entityId by one recipe of one of its replace recipe groups. Entity types with replace recipe groups: ${y.join("; ")}. Read the groupId and the recipe key from "meta { replaceRecipes }" of the entity. The replacement is a new entity with a new id: the id passed in no longer exists afterwards, and the replacement has the field values of its recipe, not those of the entity it replaces. Returns every entity the change added, the replacement first and then whatever the configurator added in response, such as a part the replacement needs; the list is empty when the configurator removed them again.`}},...i.flatMap(e=>{let i=c.get(e.entityType);return i?[(({entityType:e,fields:i},r)=>{let s=Object.fromEntries(i.map(({name:e,type:t,description:n})=>[e,{type:t,description:n}]));return{[`modify${e}`]:{type:r,args:{id:{type:new o(t)},delta:{type:new o(new n({name:`${e}Delta`,fields:s}))}},description:`Changes writable fields of the one ${e} with the given id: ${i.map(({name:e})=>e).join(", ")}. The delta sets only the fields it names and needs at least one; values are in stored units, as the field descriptions say. Returns the entity as it is after the change, including what the configurator adjusted in response, or null when the change caused the entity to be removed.`}}})(e,i)]:[]}),Object.fromEntries(d.map(e=>[e.fieldName,{type:new o(new s({name:z(e.fieldName),description:`What ${e.fieldName} returns, read from the configuration after the change.`,fields:()=>Object.fromEntries(Object.entries(e.result).map(([e,t])=>[e,{type:"edge"===t.kind?(({target:e,list:t,nullable:n},i)=>{let s=t?new r(new o(i(e))):i(e);return n?s:new o(s)})(t,f):t.type,description:t.description}]))})),args:e.args,description:e.description}])))})})({deletableTypes:eN,writableTypes:ew,childRecipes:ec,replaceRecipes:eh,actionFields:eR,objectTypes:ep,anyDomainEntity:eu(E),targetOutputType:ey}),types:[...eg.values(),...ep.values()]})},Y);return void 0===ej?void 0:{sdl:ej,fields:eT,deletableTypes:eN,writableFields:Object.fromEntries(ew.map(({entityType:e,fields:t})=>[e,Object.fromEntries(t.map(({name:e,property:t})=>[e,t]))])),childRecipes:ec,replaceRecipes:eh,fieldOptions:e$,actions:Object.fromEntries(eR.map(({fieldName:e,actionName:t})=>[e,t]))}};
|
|
1
|
+
import{GraphQLBoolean as e,GraphQLID as t,GraphQLInputObjectType as n,GraphQLInterfaceType as i,GraphQLList as r,GraphQLNonNull as o,GraphQLObjectType as s,GraphQLSchema as a,GraphQLString as p,getNullableType as d,isInputType as l,isNonNullType as c,printSchema as h,validateSchema as f}from"graphql";import{isArray as u}from"@salsita-ai/common/utils";import{DiagnosticCode as y,diagnosticMessages as m}from"../../../../errors/index.js";import{ASSISTANT_HINTS as g}from"../../../raw-spec/index.js";import{createSchemaRefResolver as S,isNullableSchema as T,phxDefinitionRefName as w}from"../../common/index.js";import{GraphQLJSON as N,assistantFieldType as A}from"./assistant-field-type.js";let E="AnyDomainEntity",I="RootEntity",_="RecipeGroup",O="Recipe",v="DryRun",b="DryRunError",$="FieldOption",R={section:"childRecipes",component:"childEntityRecipes",mutation:"addChild"},j={section:"replaceRecipes",component:"replaceEntityRecipes",mutation:"replaceEntity"},M=new Set(["id","type","meta"]),D=new Set(["Query","Mutation","Navigation",_,O,v,b,$,"JSON","String","Int","Float","Boolean","ID"]),C=(e,t)=>{if(!t||0===t.length)return e;let n=`One of: ${t.map(e=>`"${e}"`).join(", ")}.`;return e?/[.!?]$/.test(e)?`${e} ${n}`:`${e}. ${n}`:n},U=new Set(["id","type"]),P="The raw stored value: a string, a message descriptor or a multilingual object.",k=(e,t)=>Object.entries(e).find(([e])=>e===t)?.[1],L=(e,t,{model:n,components:i})=>{let r=new Set([e,...[...n.aliases].filter(([,t])=>t.includes(e)).map(([e])=>e)]);return i.filter(({componentName:e,typeOrAlias:n})=>e===t&&r.has(n)).map(({instanceName:e})=>String(e))},W=(...e)=>g.filter(t=>e.some(e=>e?.includes(t))),F=(e,t,n,i)=>{let{model:r}=n,o=new Map,s=new Map;for(let[a,{groups:p,hints:d}]of Object.entries(e)){let e=r.entityTypes.find(e=>e===a),l=e?[e]:[...r.aliases].find(([e])=>e===a)?.[1];if(!l){i.at(a).error(y.ASSISTANT_UNKNOWN_TYPE,m.ASSISTANT_UNKNOWN_TYPE(t.section,a));continue}for(let[e,r]of(l.forEach(e=>s.set(e,W(s.get(e),d))),Object.entries(p))){let s=i.at(a,"groups",e),p=`${a}.groups.${e}`,d={groupId:r?.as??e,instanceName:e,...r?.description===void 0?{}:{description:r.description},dryRuns:r?.allowDryRuns??!0};for(let i of l){let r=L(i,t.component,n),l=o.get(i)??[],c=l.find(e=>e.group.groupId===d.groupId);r.includes(e)?c?s.error(y.ASSISTANT_DUPLICATE_RECIPE_GROUP,m.ASSISTANT_DUPLICATE_RECIPE_GROUP(t,i,d.groupId,[c.entry,p])):o.set(i,[...l,{group:d,entry:p,entryCtx:s}]):s.error(y.ASSISTANT_UNKNOWN_RECIPE_GROUP,m.ASSISTANT_UNKNOWN_RECIPE_GROUP(t,a,e,i,r))}}}let a=r.entityTypes.flatMap(e=>{let t=o.get(e);return t?[{entityType:e,groups:t}]:[]});return{groups:Object.fromEntries(a.map(({entityType:e,groups:t})=>[e,t.map(({group:e})=>e)])),hints:s,metaEntries:new Map(a.flatMap(({entityType:e,groups:[t]})=>t?[[e,t.entryCtx]]:[]))}},K=e=>`${e}Meta`,G={sameForAllInstances:e=>`All ${e} instances have the same recipe groups and recipes.`,constantDuringSession:()=>"They do not change during the session."},Y={sameForAllInstances:e=>`All ${e} instances have the same options.`,constantDuringSession:()=>"The options do not change during the session."},V=(e,t,n)=>e.map(e=>` ${t[e](n)}`).join(""),x=({section:e},{groups:t,hints:n},{entityType:i,purpose:s,recipeGroup:a})=>{let p=t.filter(({dryRuns:e})=>!e).map(({groupId:e})=>e);return 0===t.length?{}:{[e]:{type:new o(new r(new o(a))),description:`The recipe groups for ${s} this ${i}: ${t.map(({groupId:e})=>e).join(", ")}. A group is listed only while the configurator offers it on the entity.${p.length>0?` Recipes of these groups cannot be dry-run: ${p.join(", ")}.`:""}${V(n,G,i)}`}}},q=({entityType:e,childRecipes:t,replaceRecipes:n,optionsEdges:i},{recipeGroup:a,fieldOption:p})=>new s({name:K(e),fields:Object.assign(x(R,t,{entityType:e,purpose:"adding children to",recipeGroup:a}),x(j,n,{entityType:e,purpose:"replacing",recipeGroup:a}),...i.map(({options:{edgeName:t,field:n,validate:i,dryRuns:s},hints:a})=>({[t]:{type:new r(new o(p)),description:`The values "${n}" of this ${e} can take, as the configurator offers them in the current state of the entity. Null when the configurator does not offer a choice on this entity. ${i?`modify${e} rejects a "${n}" value that is not in the list.`:`modify${e} does not check "${n}" against the list.`}${s?"":" Its options cannot be dry-run."}${V(a,Y,e)}`}})))}),B=e=>Object.entries(e).map(([e,t])=>`${e} (${t.map(({groupId:e})=>e).join(", ")})`),z=e=>`${e.charAt(0).toUpperCase()+e.slice(1)}Result`,H=e=>{let t=(({anyOf:e,oneOf:t})=>[...e??[],...t??[]].filter(e=>"null"!==e.type))(e);return e.$ref||1!==t.length?e:t[0]},Q=(e,t)=>{let{type:n,enumValues:i,localized:r,description:s}=A(e,t),a=d(n),p=r||!l(a)?N:a;return{type:c(n)?new o(p):p,description:r?`${C(s,i)??""} ${P}`.trim():C(s,i)}},X=(e,t)=>{let n=(e=>{let t=H(e),n=t?.type==="array"&&t.items&&!u(t.items)?H(t.items):void 0,i=n??t;if(i?.$ref!==void 0&&"$DomainEntity"===w(i.$ref))return{target:i.references??E,list:void 0!==n}})(e);if(n)return{kind:"edge",...n,nullable:T(e,t),description:e.description};let{type:i,enumValues:r,localized:s,description:a}=A(e,t);return s?{kind:"value",type:c(i)?new o(N):N,description:`${a??""} ${P}`.trim()}:{kind:"value",type:i,description:C(a,r)}},J=(e,t)=>{let{type:n,enumValues:i,localized:r,description:o}=A(e,t);return{type:{kind:"value",type:n},localized:!0===r,description:o,enumValues:i}},Z=(e,t,n,i,r)=>{let o=t.startsWith("$")?((e,t,n,i)=>{let r=((e,t,{expressions:n,entityTypes:i})=>{let r=i.find(t=>t===e);for(let i of void 0===r?[e]:n.entityExpressions.get(r)?.shapeGroups??[]){let e=[...n.expressions.get(i)?.values()??[]].find(e=>e.name===t);if(e?.declaredSchema)return{implementation:e.implementation,declaredSchema:e.declaredSchema}}})(e.name,t,n);if(!r)return void i.error(y.ASSISTANT_UNKNOWN_FIELD,m.ASSISTANT_UNKNOWN_FIELD("entities",e.name,t));let{implementation:o}=r;if("violations"===o.kind)return void i.error(y.ASSISTANT_UNSUPPORTED_EXPRESSION,m.ASSISTANT_UNSUPPORTED_EXPRESSION(e.name,t));if("relation"===o.kind){let[{target:e,cardinality:t}]=o.params;return{type:{kind:"edge",target:e,cardinality:t},localized:!1,description:void 0}}return J(r.declaredSchema,n.resolveRef)})(e,t,i,r):((e,t,{resolveRef:n},i)=>{let r=Object.entries(e.shape.properties).find(([e])=>e===t)?.[1];return r?J(r,n):void i.error(y.ASSISTANT_UNKNOWN_FIELD,m.ASSISTANT_UNKNOWN_FIELD("entities",e.name,t))})(e,t,i,r);if(!o)return;let s=n?.as??(t.startsWith("$")?`_${t.slice(1)}`:t);return M.has(s)?void r.error(y.ASSISTANT_RESERVED_FIELD_NAME,m.ASSISTANT_RESERVED_FIELD_NAME(e.name,t,s)):{name:s,source:t,type:o.type,localized:o.localized,description:C(n?.description??o.description,o.enumValues),describedByManifest:n?.description!==void 0,enumValues:o.enumValues,valueDescription:n?.description??o.description}},ee=(e,t,n,i,r)=>{let o=new Map,s=new Set(n);for(let i of[...t.flat(),...n]){let t=o.get(i.name);t?t.source!==i.source?r.error(y.ASSISTANT_FIELD_NAME_CONFLICT,m.ASSISTANT_FIELD_NAME_CONFLICT(e,i.name,[t.source,i.source])):s.has(i)&&i.describedByManifest&&o.set(i.name,{...t,description:i.description,describedByManifest:!0,valueDescription:i.valueDescription}):o.set(i.name,i)}return[...o.values()].map(e=>{let t=e.enumValues&&i(e.source);return t?{...e,enumValues:t,description:C(e.valueDescription,t)}:e})},et=({target:e,cardinality:t},n)=>{let i=n(e);switch(t){case"1-1":return new o(i);case"0-1":return i;case"0-N":case"1-N":return new o(new r(new o(i)))}},en=(e,n,i)=>{let r={id:{type:new o(t),description:i?`Unique id of the entity, e.g. "${i}-1".`:void 0},type:{type:new o(p),description:i?`Entity type name, e.g. "${i}".`:void 0}};for(let t of e)r[t.name]={type:"edge"===t.type.kind?et(t.type,n):t.type.type,description:t.description};return r},ei=e=>Object.fromEntries(e.map(({name:e,source:t,localized:n})=>[e,n?{source:t,localized:!0}:{source:t}]));export const createAssistantSchema=(c,{model:u,states:g,phxDefinitions:T,expressions:w,components:M,actions:G},Y)=>{let V,x,H,J=Y.errors.length,et=Y.at("entities"),er=S(u.commonTypes,T),eo=u.rootEntityTypes[0]??u.entityTypes[0];for(let e of u.resolvedShapes.keys())D.has(e)&&Y.error(y.ASSISTANT_RESERVED_TYPE_NAME,m.ASSISTANT_RESERVED_TYPE_NAME(e,[...D]));let es=((e,t,n)=>{let i=new Map;for(let r of e){let e=[];for(let[i,o]of Object.entries(r.entries)){let s=Z(r,i,o,t,n.at(r.name,i));s&&e.push(s)}i.set(r.name,e)}return i})(((e,t,n)=>{let i=[];for(let[n,r]of t.resolvedShapes){let t=e[n];t&&i.push({name:n,shape:r,entries:t})}let r=new Set(t.resolvedShapes.keys());for(let t of Object.keys(e))r.has(t)||n.at(t).error(y.ASSISTANT_UNKNOWN_TYPE,m.ASSISTANT_UNKNOWN_TYPE("entities",t));return i})(c.entities??{},u,et),{resolveRef:er,expressions:w,entityTypes:u.entityTypes},et),ea=ee(E,[],es.get(E)??[],()=>void 0,Y),ep=new Map,ed=F(c.childRecipes??{},R,{model:u,components:M},Y.at(R.section)),el=F(c.replaceRecipes??{},j,{model:u,components:M},Y.at(j.section)),ec=ed.groups,eh=el.groups,ef=new Map,eu=e=>{let[t,...n]=[...u.aliases].find(([t])=>t===e)?.[1]??[];return(t&&0===n.length?ep.get(t):void 0)??eg.get(e)??em},ey=e=>ep.get(e)??eu(e),em=new i({name:E,description:"Every domain entity.",fields:()=>en(ea,ey,eo)}),eg=new Map([[E,em]]),eS=new Map([[E,ea]]);for(let e of u.aliases.keys()){if(e===E)continue;let t=ee(e,[ea],es.get(e)??[],()=>void 0,Y);eS.set(e,t),eg.set(e,new i({name:e,interfaces:[em],fields:()=>en(t,ey)}))}let eT={};for(let e of u.entityTypes){let t=[E,...[...u.aliases].filter(([t,n])=>t!==E&&n.includes(e)).map(([e])=>e)],n=[...u.resolvedShapes].find(([t])=>t===e)?.[1],i=ee(e,t.map(e=>eS.get(e)??[]),es.get(e)??[],e=>{let t=Object.entries(n?.properties??{}).find(([t])=>t===e)?.[1];return t&&A(t,er).enumValues},Y);eT[e]=ei(i),ep.set(e,new s({name:e,interfaces:t.flatMap(e=>eg.get(e)??[]),fields:()=>{let t=ef.get(e);return{...en(i,ey),...t?{meta:{type:new o(t),description:"What the configurator offers on this entity besides its fields."}}:{}}}}))}let ew=((e,t,n)=>{let i=new Set;for(let r of Object.keys(e)){let e=t.entityTypes.find(e=>e===r),o=e?[e]:[...t.aliases].find(([e])=>e===r)?.[1];o?o.forEach(e=>i.add(e)):n.at(r).error(y.ASSISTANT_UNKNOWN_TYPE,m.ASSISTANT_UNKNOWN_TYPE("deletable",r))}return t.entityTypes.filter(e=>i.has(e))})(c.deletable??{},u,Y.at("deletable")),eN=((e,t,n)=>{let{model:i}=t,r=((e,t,n)=>{let i=new Map;for(let[r,o]of Object.entries(e)){let e=t.entityTypes.find(e=>e===r),s=e?[e]:[...t.aliases].find(([e])=>e===r)?.[1],a=[...t.resolvedShapes].find(([e])=>e===r)?.[1];if(!s||!a){n.at(r).error(y.ASSISTANT_UNKNOWN_TYPE,m.ASSISTANT_UNKNOWN_TYPE("writable",r));continue}for(let e of Object.keys(o)){let t=n.at(r,e);e.startsWith("$")||U.has(e)?t.error(y.ASSISTANT_NOT_WRITABLE,m.ASSISTANT_NOT_WRITABLE(r,e)):k(a.properties,e)?s.forEach(t=>{let n=i.get(t)??new Map;i.set(t,n.has(e)?n:n.set(e,r))}):t.error(y.ASSISTANT_UNKNOWN_FIELD,m.ASSISTANT_UNKNOWN_FIELD("writable",r,e))}}return i})(e,i,n);return i.entityTypes.flatMap(e=>{let o=[...i.resolvedShapes].find(([t])=>t===e)?.[1],s=[...r.get(e)??[]].flatMap(([i,r])=>{let s=o&&k(o.properties,i);return(s&&((e,{property:t,writableKey:n},i,{resolveRef:r,exposed:o,sources:s},a)=>{let p=Object.entries(k(s,e)??{}),c=o.get(e)?.find(({source:e})=>e===t)?.name??p.find(([,{source:e}])=>e===t)?.[0],h=void 0===c?p.find(([e])=>e===t)?.[1]:void 0;if(h)return void a.at(n,t).error(y.ASSISTANT_FIELD_NAME_CONFLICT,m.ASSISTANT_FIELD_NAME_CONFLICT(e,t,[h.source,t],`${n}.${t}`));let{type:f,enumValues:u,localized:g,description:S}=A(i,r),T=d(f),w=c??t,E=void 0===c?C(S,u):void 0;return g?{name:w,property:t,type:N,description:E?`${E} ${P}`:P}:{name:w,property:t,type:l(T)?T:N,description:E}})(e,{property:i,writableKey:r},s,t,n))??[]});return s.length>0?[{entityType:e,fields:s}]:[]})})(c.writable??{},{model:u,resolveRef:er,exposed:es,sources:eT},Y.at("writable")),eA=((e,t,n,i)=>{let{model:r}=n,o=new Map;for(let[s,a]of Object.entries(e)){let e=r.entityTypes.find(e=>e===s),p=e?[e]:[...r.aliases].find(([e])=>e===s)?.[1]??[];for(let[e,r]of Object.entries(a)){let a=r?.options;if(!a)continue;let d=i.at(s,e,"options"),l=`${s}.${e}`;for(let i of p){let r=t.find(e=>e.entityType===i)?.fields.find(t=>t.property===e);if(!r)continue;let s=a.as??`${r.name}Options`,p=L(i,a.componentType,n),c=o.get(i)??[],h=c.find(e=>e.options.edgeName===s||e.options.field===r.name);if(p.includes(a.instanceName))if(s===R.section||s===j.section)d.error(y.ASSISTANT_RESERVED_OPTIONS_EDGE_NAME,m.ASSISTANT_RESERVED_OPTIONS_EDGE_NAME(l,s,r.name));else if(h)d.error(y.ASSISTANT_DUPLICATE_OPTIONS_EDGE,m.ASSISTANT_DUPLICATE_OPTIONS_EDGE(i,h.options.edgeName===s?{edgeName:s}:{field:r.name},[h.entry,l]));else{let e={field:r.name,edgeName:s,componentType:a.componentType,instanceName:a.instanceName,validate:a.validate??!0,dryRuns:a.allowDryRuns??!0};o.set(i,[...c,{options:e,hints:W(a.hints),entry:l,entryCtx:d}])}else d.error(y.ASSISTANT_UNKNOWN_OPTIONS_COMPONENT,m.ASSISTANT_UNKNOWN_OPTIONS_COMPONENT(l,a,i,p))}}}return o})(c.writable??{},eN,{model:u,components:M},Y.at("writable"));var eE,eI,e_,eO=[ed.metaEntries,el.metaEntries,new Map([...eA].flatMap(([e,[t]])=>t?[[e,t.entryCtx]]:[]))];for(let e of u.entityTypes){let t=eO.flatMap(t=>t.get(e)??[])[0],n=K(e);t&&[...u.resolvedShapes.keys()].some(e=>e===n)&&t.error(y.ASSISTANT_META_TYPE_NAME_CONFLICT,m.ASSISTANT_META_TYPE_NAME_CONFLICT(e,n))}let ev=(eE=()=>eu(eg.has(I)?I:E),eI=()=>eu(E),V=new s({name:b,description:"One reason a change cannot be made right now.",fields:{type:{type:new o(p),description:'Kind of the problem, or "uncaught".'},entityId:{type:t,description:"The entity the problem is on, when it is on one."},message:{type:p,description:"The problem as the configurator words it for the user."}}}),new s({name:v,description:"What using a recipe or writing an option would do, tried out on the configuration the query reads. Nothing is changed.",fields:()=>({ok:{type:new o(e),description:"Whether the change would succeed on the configuration the query reads."},root:{type:eE(),description:"The root entity of the configuration as it would be after the change; null when ok is false. Everything selected under it reads that configuration."},changedEntities:{type:new o(new r(new o(eI()))),description:"Every entity the change would add or modify, read from that configuration; empty when ok is false. An entity whose id the configuration does not have yet would be added, and its id may differ in the real mutation. An entity that is written to is listed even when it gets the values it already has."},deletedEntityIds:{type:new o(new r(new o(t))),description:"The ids of the entities the change would remove; empty when ok is false."},sideEffects:{type:new o(new r(new o(p))),description:"What the configurator would do beyond the change itself, such as parts it would add or replace, in its own words; empty when ok is false."},errors:{type:new o(new r(new o(V))),description:"Why the change cannot be made; empty when ok is true."}})})),eb={recipeGroup:(e_=[...Object.keys(ec).length>0?[R.mutation]:[],...Object.keys(eh).length>0?[j.mutation]:[]],x=e_.join(" or "),H=new s({name:O,description:"One recipe of a recipe group: something that can be added to the entity, or something the entity can be replaced with, depending on the edge the group is listed under.",fields:{key:{type:new o(p),description:`Identifies the recipe within its group; pass it to ${x} as recipeKey. Keys can change with the configuration and with the language, so read them from the listing rather than remembering them.`},name:{type:new o(p),description:"Name shown to the user."},description:{type:p},imageUrl:{type:p,description:"Thumbnail shown to the user."},dryRun:{type:ev,description:"Tries the recipe out. Each selected dry-run costs a full recalculation, so select it only on the recipes you are deciding between. Null for a group whose recipes cannot be dry-run."}}}),new s({name:_,description:"A group of recipes of an entity, as the configurator offers them to the user.",fields:{groupId:{type:new o(p),description:`Identifies the group on its entity; pass it to ${x} as recipeGroupId.`},name:{type:new o(p),description:"Name shown to the user."},description:{type:p},recipes:{type:new o(new r(new o(H)))}}})),fieldOption:new s({name:$,description:"One choice of a field, as the configurator offers it to the user.",fields:{value:{type:new o(N),description:"The value to write to the field."},name:{type:p,description:"Name shown to the user."},description:{type:p},imageUrl:{type:p,description:"Image shown to the user."},color:{type:p,description:'Color shown to the user, as "#rrggbb".'},dryRun:{type:ev,description:"Tries writing the value to the field. Each selected dry-run costs a full recalculation, so select it only on the options you are deciding between. Null for a field whose options cannot be dry-run."}}})};for(let e of u.entityTypes){let t=k(ec,e)??[],n=k(eh,e)??[],i=eA.get(e)??[];(t.length>0||n.length>0||i.length>0)&&ef.set(e,q({entityType:e,childRecipes:{groups:t,hints:ed.hints.get(e)??[]},replaceRecipes:{groups:n,hints:el.hints.get(e)??[]},optionsEdges:i},eb))}let e$=Object.fromEntries(u.entityTypes.flatMap(e=>{let t=eA.get(e);return t?[[e,t.map(({options:e})=>e)]]:[]})),eR=((e,{actions:t,resolveRef:n,builtInMutations:i,typeNames:r},o)=>{let s=[],a=[...t.values()].map(e=>String(e.name));for(let[p,d]of Object.entries(e)){let e=o.at(p),l=[...t.values()].find(e=>e.name===p);if(!l){e.error(y.ASSISTANT_UNKNOWN_ACTION,m.ASSISTANT_UNKNOWN_ACTION(p,a));continue}let c=d?.as??p,h=s.find(e=>e.fieldName===c),f=z(c);if(i.has(c)||h){e.error(y.ASSISTANT_MUTATION_NAME_CONFLICT,m.ASSISTANT_MUTATION_NAME_CONFLICT(p,c,h?`"assistant.actions.${h.actionName}" already exposes`:"the built-in mutation of that name already takes"));continue}if(r.has(f)||s.some(e=>z(e.fieldName)===f)){e.error(y.ASSISTANT_ACTION_RESULT_TYPE_CONFLICT,m.ASSISTANT_ACTION_RESULT_TYPE_CONFLICT(p,c,f));continue}s.push({fieldName:c,actionName:p,description:d?.description??l.description,args:Object.fromEntries(Object.entries(l.params).map(([e,{schema:t}])=>[e,Q(t,n)])),result:Object.fromEntries(Object.entries(l.result).map(([e,t])=>[e,X(t,n)]))})}return s})(c.actions??{},{actions:G,resolveRef:er,builtInMutations:new Set(["deleteEntity",R.mutation,j.mutation,...eN.map(({entityType:e})=>`modify${e}`)]),typeNames:new Set([...D,...u.resolvedShapes.keys(),...ef.keys()])},Y.at("actions"));if(Y.errors.length>J)return;let ej=((e,t)=>{let n;try{let t=e();if(n=f(t).map(e=>e.message),0===n.length)return h(t)}catch(e){n=[e instanceof Error?e.message:String(e)]}t.error(y.ASSISTANT_INVALID_SCHEMA,m.ASSISTANT_INVALID_SCHEMA(n))})(()=>{var e,i;let d;return new a({query:(e=eu(eg.has(I)?I:E),i=eu(E),d=(e=>{let t={};for(let[n,i,r]of[["navigationState","Active navigation state.",e.navigationStates],["viewState","Active view state.",e.viewStates],["toolState","Active tool state.",e.toolStates]])r.length>1&&(t[n]={type:new o(p),description:C(i,r)});if(0!==Object.keys(t).length)return new s({name:"Navigation",description:"The active UI states of the configurator.",fields:t})})(g),new s({name:"Query",fields:{root:{type:new o(e),description:"The root entity of the configuration."},entities:{type:new o(new r(i)),args:{ids:{type:new r(new o(t))}},description:`Every entity of the configuration, or, when ids are given, those entities in the same order with null for an unknown id. Ids have the form "<Type>-<number>"${eo?`, e.g. "${eo}-1"`:""}; any other id is an error.`},selectedEntity:{type:new o(i),description:"The selected entity, or the root entity when nothing is selected."},...d?{navigation:{type:new o(d)}}:{}}})),mutation:(({deletableTypes:e,writableTypes:i,childRecipes:a,replaceRecipes:d,actionFields:l,objectTypes:c,anyDomainEntity:h,targetOutputType:f})=>{let u,y;if(0!==e.length||0!==i.length||0!==Object.keys(a).length||0!==Object.keys(d).length||0!==l.length)return new s({name:"Mutation",fields:Object.assign(0===e.length?{}:{deleteEntity:{type:new o(new r(new o(t))),args:{id:{type:new o(t)}},description:`Deletes the entity with the given id, together with the entities composed into it, and returns the ids of every entity the deletion removed, the given one first. Deletable entity types: ${e.join(", ")}. Nothing can be read from removed entities afterwards, so query what you need from them before deleting.`}},0===(u=B(a)).length?{}:{addChild:{type:new o(new r(new o(h))),args:{parentId:{type:new o(t)},recipeGroupId:{type:new o(p)},recipeKey:{type:new o(p)}},description:`Adds a child to the entity with the id parentId by one recipe of one of its recipe groups. Entity types with recipe groups: ${u.join("; ")}. Read the groupId and the recipe key from "meta { childRecipes }" of the parent. Returns every entity the change added, the recipe's own first and then whatever the configurator added in response, such as a part the new child needs; the list is empty when the configurator removed them again.`}},0===(y=B(d)).length?{}:{replaceEntity:{type:new o(new r(new o(h))),args:{entityId:{type:new o(t)},recipeGroupId:{type:new o(p)},recipeKey:{type:new o(p)}},description:`Replaces the entity with the id entityId by one recipe of one of its replace recipe groups. Entity types with replace recipe groups: ${y.join("; ")}. Read the groupId and the recipe key from "meta { replaceRecipes }" of the entity. The replacement is a new entity with a new id: the id passed in no longer exists afterwards, and the replacement has the field values of its recipe, not those of the entity it replaces. Returns every entity the change added, the replacement first and then whatever the configurator added in response, such as a part the replacement needs; the list is empty when the configurator removed them again.`}},...i.flatMap(e=>{let i=c.get(e.entityType);return i?[(({entityType:e,fields:i},r)=>{let s=Object.fromEntries(i.map(({name:e,type:t,description:n})=>[e,{type:t,description:n}]));return{[`modify${e}`]:{type:r,args:{id:{type:new o(t)},delta:{type:new o(new n({name:`${e}Delta`,fields:s}))}},description:`Changes writable fields of the one ${e} with the given id: ${i.map(({name:e})=>e).join(", ")}. The delta sets only the fields it names and needs at least one; values are in stored units, as the field descriptions say. Returns the entity as it is after the change, including what the configurator adjusted in response, or null when the change caused the entity to be removed.`}}})(e,i)]:[]}),Object.fromEntries(l.map(e=>[e.fieldName,{type:new o(new s({name:z(e.fieldName),description:`What ${e.fieldName} returns, read from the configuration after the change.`,fields:()=>Object.fromEntries(Object.entries(e.result).map(([e,t])=>[e,{type:"edge"===t.kind?(({target:e,list:t,nullable:n},i)=>{let s=t?new r(new o(i(e))):i(e);return n?s:new o(s)})(t,f):t.type,description:t.description}]))})),args:e.args,description:e.description}])))})})({deletableTypes:ew,writableTypes:eN,childRecipes:ec,replaceRecipes:eh,actionFields:eR,objectTypes:ep,anyDomainEntity:eu(E),targetOutputType:ey}),types:[...eg.values(),...ep.values()]})},Y);return void 0===ej?void 0:{sdl:ej,fields:eT,deletableTypes:ew,writableFields:Object.fromEntries(eN.map(({entityType:e,fields:t})=>[e,Object.fromEntries(t.map(({name:e,property:t})=>[e,t]))])),childRecipes:ec,replaceRecipes:eh,fieldOptions:e$,actions:Object.fromEntries(eR.map(({fieldName:e,actionName:t})=>[e,t]))}};
|
|
@@ -2,7 +2,7 @@ import type { AnyClientTool } from '@tanstack/ai';
|
|
|
2
2
|
import { type UIMessage } from '@tanstack/ai-client';
|
|
3
3
|
import { type ObservableWithLastValue } from '@salsita-ai/common/utils';
|
|
4
4
|
import type { AuthorizedPhoenixApiClient } from '#package/client';
|
|
5
|
-
import type { DomainContextSelectors, DomainServicesSelectors, InitializableSystem } from '#package/domain';
|
|
5
|
+
import type { DomainContextSelectors, DomainServicesSelectors, InitializableSystem, SpecSelectors } from '#package/domain';
|
|
6
6
|
import type { ConfiguratorExtensionSelectors } from '../../selectors.js';
|
|
7
7
|
/** A tool of the assistant that the model calls and the browser runs, as the chat client accepts it. */
|
|
8
8
|
export type AssistantClientTool = AnyClientTool;
|
|
@@ -15,6 +15,7 @@ export interface AssistantSystemContext {
|
|
|
15
15
|
readonly configuratorExtensionSelectors: ConfiguratorExtensionSelectors;
|
|
16
16
|
readonly domainContextSelectors: DomainContextSelectors;
|
|
17
17
|
readonly domainServicesSelectors: DomainServicesSelectors;
|
|
18
|
+
readonly specSelectors: SpecSelectors;
|
|
18
19
|
readonly getConfigurationId: () => string;
|
|
19
20
|
readonly getAuthorizedClient: () => AuthorizedPhoenixApiClient<any, any, any>;
|
|
20
21
|
}
|
|
@@ -52,7 +53,8 @@ export interface AssistantPanelSnapshot {
|
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
54
55
|
* Owns the conversational assistant of a configurator: its client tools (`getBrowserDetails`,
|
|
55
|
-
* `query` and `apply`), the chat with the server,
|
|
56
|
+
* `query` and `apply`), the chat with the server, the state of the built-in panel, and the record
|
|
57
|
+
* of what changed in the configuration since the agent last acted, which every user message carries.
|
|
56
58
|
*
|
|
57
59
|
* The server owns the thread, so the chat keeps nothing in browser storage: its client hydrates
|
|
58
60
|
* from the server and rejoins a reply that is still streaming.
|
|
@@ -75,6 +77,9 @@ export declare class AssistantSystem implements InitializableSystem {
|
|
|
75
77
|
* background. Each leaves the set when its reply ends or when a new chat takes its thread over.
|
|
76
78
|
*/
|
|
77
79
|
private readonly _finishing;
|
|
80
|
+
private readonly _recorder;
|
|
81
|
+
/** The recorder's subscription to the committed emissions of the store; made by the first `initialize()`. */
|
|
82
|
+
private _recording;
|
|
78
83
|
constructor(_context: AssistantSystemContext);
|
|
79
84
|
/** The conversation. It never completes, and holds the empty chat whenever no chat is active. */
|
|
80
85
|
readonly chat$: ObservableWithLastValue<AssistantChatSnapshot>;
|
|
@@ -89,6 +94,10 @@ export declare class AssistantSystem implements InitializableSystem {
|
|
|
89
94
|
* Accepted cost: on such a re-entry the thread is read twice. The page's `activate()` has already
|
|
90
95
|
* created a client and started its read when this runs, and that client is replaced here, because
|
|
91
96
|
* the system cannot tell that it was created for the load that is only now being initialized.
|
|
97
|
+
*
|
|
98
|
+
* The change recorder starts over from the loaded state, so the first message after a load says
|
|
99
|
+
* that the changes are unavailable. The store is retained across loads and has already emitted the
|
|
100
|
+
* loaded state when this runs, so one subscription serves every load.
|
|
92
101
|
*/
|
|
93
102
|
initialize: () => void;
|
|
94
103
|
/**
|
|
@@ -113,7 +122,11 @@ export declare class AssistantSystem implements InitializableSystem {
|
|
|
113
122
|
* what opening the configuration again does: see `_takeOverThread`.
|
|
114
123
|
*/
|
|
115
124
|
activate: () => (() => void);
|
|
116
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* Sends a user message with the record of what changed in the configuration since the agent last
|
|
127
|
+
* acted, which the server puts in front of the message for the model. The record is taken only
|
|
128
|
+
* when there is a chat to send it on, because taking it moves its baseline.
|
|
129
|
+
*/
|
|
117
130
|
send: (content: string) => Promise<void>;
|
|
118
131
|
/**
|
|
119
132
|
* Stops the reply on both sides. Closing the stream alone keeps the server run going, because a
|
|
@@ -128,6 +141,7 @@ export declare class AssistantSystem implements InitializableSystem {
|
|
|
128
141
|
reloadThread: () => void;
|
|
129
142
|
setOpen: (open: boolean) => void;
|
|
130
143
|
setInputFocused: (isInputFocused: boolean) => void;
|
|
144
|
+
private _committedState;
|
|
131
145
|
private _patchPanel;
|
|
132
146
|
/**
|
|
133
147
|
* Disconnects every chat that is still finishing a reply on the thread of the given configuration,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{ChatClient as t,fetchServerSentEvents as e}from"@tanstack/ai-client";import{BehaviorSubject as i}from"rxjs";import{
|
|
1
|
+
import{ChatClient as t,fetchServerSentEvents as e}from"@tanstack/ai-client";import{BehaviorSubject as i}from"rxjs";import{skip as s}from"rxjs/operators";import{isShallowEqual as a}from"@salsita-ai/common/utils";import{createBrowserDetailsClientTool as n}from"./browser-details.js";import{ChangeRecorder as r}from"./change-recorder.js";import{createApplyClientTool as o,createQueryClientTool as c}from"./graphql-tools.js";let l=Object.freeze({messages:Object.freeze([]),isLoading:!1,error:void 0,failedStatus:void 0,isContinuable:!0,isLoaded:!1}),h=Object.freeze({open:!1,hasUnreadReply:!1,isInputFocused:!1}),d=Object.freeze({}),u=Object.freeze({results:Object.freeze([]),committed:!1,sideEffects:Object.freeze([]),message:"Nothing was run, because the user closed the configurator while this reply was being written. Do not call any more tools in this reply and end it now. The tools work again as soon as the user writes the next message."}),_=t=>{if(t.getError())return!0;let e=t.getMessages().at(-1)?.parts.at(-1);return e?.type!=="tool-call"&&e?.type!=="tool-result"};export class AssistantSystem{_context;_tools;_chat=new i(l);_panel=new i(h);_activation=null;_current=null;_finishing=new Set;_recorder;_recording=null;constructor(t){this._context=t;let{configuratorExtensionSelectors:e,domainContextSelectors:i,domainServicesSelectors:s,specSelectors:a}=t;this._recorder=new r({maxEntities:30},{readers:{selectedEntityId:t=>i.getNavigationSystem().getActiveSelectionState(t),navigationState:t=>i.getNavigationSystem().getActiveNavigationState(t),viewState:t=>i.getNavigationSystem().getActiveViewState(t),toolState:t=>i.getNavigationSystem().getActiveToolState(t)},getExposedFields:()=>a.getAssistant()?.fields??d,resolveLocalizable:t=>s.getResolveLocalizable()(t)});let l={getGraphQLSystem:()=>e.getGraphQLSystem(),getStoreSystem:()=>i.getStoreSystem(),getActiveMode:()=>i.getActiveMode(),clearChanges:()=>this._recorder.clear(this._committedState())};this._tools=[n(),c(l),o(l)]}chat$=this._chat;panel$=this._panel;initialize=()=>{let{committed:t}=this._context.domainContextSelectors.getStoreSystem().stateSlices;this._recording??=t.pipe(s(1)).subscribe(this._recorder.record),this._recorder.reset(t.value.state),this._endChat(),this._patchPanel(h),this._activation&&this._startClient()};activate=()=>{this._endChat();let t={};return this._activation=t,this._startClient(),()=>{this._activation===t&&(this._activation=null,this._endChat())}};send=async t=>{let e=this._current;if(!e)return;let i={changes:this._recorder.take(this._committedState())};await e.client.sendMessage({content:t,metadata:i})};stop=async()=>{let t=this._current;if(!t)return;let e=t.client.getCurrentRunId();if(t.client.stop(),null!=e)try{let i=t.getConnection();await fetch(`${i.url}/runs/${encodeURIComponent(e)}/cancel`,{method:"POST",headers:await i.headers()})}catch(t){this._context.domainServicesSelectors.getLogger().warn("Could not cancel the assistant run on the server",{error:t})}};reloadThread=()=>{if(!this._activation)return;let t=this._retireChat();t?.client.stop(),t?.dispose(),this._recorder.reset(this._committedState()),this._startClient()};setOpen=t=>{this._patchPanel(t?{open:t,hasUnreadReply:!1}:{open:t,isInputFocused:!1})};setInputFocused=t=>{this._patchPanel({isInputFocused:t})};_committedState=()=>this._context.domainContextSelectors.getStoreSystem().stateSlices.committed.value.state;_patchPanel=t=>{let e={...this._panel.value,...t};a(e,this._panel.value)||this._panel.next(e)};_takeOverThread=t=>{for(let e of this._finishing)e.configurationId===t&&(e.client.stop(),e.dispose())};_startClient=()=>{let i={active:!0},s=this._context.getConfigurationId();this._takeOverThread(s);let n=this._context.getAuthorizedClient(),r=()=>n.getAssistantChatConnection(s),o=t=>{if(!i.active)return;let e={...this._chat.value,...t};a(e,this._chat.value)||this._chat.next(e)},c=async(t,e)=>{let i=await fetch(t,e).finally(()=>o({isLoaded:!0})),s=i.headers.get("x-assistant-thread-continuable");return o({failedStatus:i.ok?void 0:i.status,...null!=s&&{isContinuable:"false"!==s}}),i},l=async()=>({headers:await r().headers(),fetchClient:c}),h=e(()=>r().url,l),d=e(()=>`${r().url}/runs`,l),g=this._tools.map(t=>({...t,execute:(e,s)=>i.active?t.execute?.(e,s):u})),p=new t({threadId:`assistant:${s}`,connection:{...h,joinRun:d.joinRun},persistence:!0,history:{pageSize:50},tools:g,onMessagesChange:t=>o({messages:t}),onLoadingChange:t=>{let e=i.active&&this._chat.value.isLoading;o({isLoading:t}),!t&&_(p)&&(i.active?e&&!this._panel.value.open&&this._patchPanel({hasUnreadReply:!0}):v())},onErrorChange:t=>{i.active&&t!==this._chat.value.error&&(o({error:t}),t&&this._context.domainServicesSelectors.getLogger().error("The assistant request failed",{status:this._chat.value.failedStatus},t))}}),m=!1,v=()=>{m||(m=!0,this._finishing.delete(f),p.dispose())},f={client:p,configurationId:s,generation:i,getConnection:r,dispose:v};this._current=f,p.attach()};_retireChat=()=>{let t=this._current;return this._current=null,t&&(t.generation.active=!1),this._chat.value!==l&&this._chat.next(l),t};_endChat=()=>{let t=this._retireChat();if(t){if(!t.client.getIsLoading()&&_(t.client))return void t.dispose();this._finishing.add(t)}}}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Localizable } from '@salsita-ai/common/utils';
|
|
2
|
+
import { type $DomainEntity, type AssistantManifest, type DomainEntity, type DomainState, type StateEmission } from '#package/domain';
|
|
3
|
+
type AnyDomainEntity = DomainEntity<string>;
|
|
4
|
+
type AnyDomainState = DomainState<AnyDomainEntity>;
|
|
5
|
+
type AnyStateEmission = StateEmission<AnyDomainEntity, Record<string, $DomainEntity<AnyDomainEntity>>>;
|
|
6
|
+
/**
|
|
7
|
+
* What the change record compares besides the entities: the values the `Query` root of the
|
|
8
|
+
* assistant's schema exposes as `selectedEntity` and `navigation`, read off a state.
|
|
9
|
+
*/
|
|
10
|
+
export interface ChangeRecorderStateReaders {
|
|
11
|
+
readonly selectedEntityId: (state: AnyDomainState) => string | undefined;
|
|
12
|
+
readonly navigationState: (state: AnyDomainState) => string | undefined;
|
|
13
|
+
readonly viewState: (state: AnyDomainState) => string | undefined;
|
|
14
|
+
readonly toolState: (state: AnyDomainState) => string | undefined;
|
|
15
|
+
}
|
|
16
|
+
/** What a {@link ChangeRecorder} reads from the running configurator. */
|
|
17
|
+
export interface ChangeRecorderContext {
|
|
18
|
+
readonly readers: ChangeRecorderStateReaders;
|
|
19
|
+
/**
|
|
20
|
+
* The field table of the assistant manifest: per entity type of the schema, each exposed field
|
|
21
|
+
* name with the stored field or expression it reads. The record lists nothing the table does not
|
|
22
|
+
* hold, so the agent is shown only what it could also query. Read at every take, because editor
|
|
23
|
+
* hot reload replaces the manifest.
|
|
24
|
+
*/
|
|
25
|
+
readonly getExposedFields: () => AssistantManifest['fields'];
|
|
26
|
+
readonly resolveLocalizable: (message: Localizable) => string;
|
|
27
|
+
}
|
|
28
|
+
export interface ChangeRecorderProps {
|
|
29
|
+
/** More differing entities than this are counted and not listed. */
|
|
30
|
+
readonly maxEntities: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Records what changed in the committed configuration since the assistant agent last acted, and
|
|
34
|
+
* prints it as the change record a user message carries to the agent.
|
|
35
|
+
*
|
|
36
|
+
* The record is a net diff: the committed state at the last take or clear is kept as the baseline
|
|
37
|
+
* and compared with the current one, so a value that was changed and changed back is not listed.
|
|
38
|
+
* Of the emissions in between, only the last update that touched each entity and each entity field
|
|
39
|
+
* is kept, to say why a line is there. Selection and navigation are read off the two states and
|
|
40
|
+
* never from their updates.
|
|
41
|
+
*
|
|
42
|
+
* Only what the assistant manifest exposes is listed: entities of the types its schema has, and of
|
|
43
|
+
* their stored fields the exposed ones, under their exposed names and localized like the schema
|
|
44
|
+
* returns them. A change of anything else is not in the record.
|
|
45
|
+
*/
|
|
46
|
+
export declare class ChangeRecorder {
|
|
47
|
+
private readonly _props;
|
|
48
|
+
private readonly _context;
|
|
49
|
+
private _baseline;
|
|
50
|
+
/** True from a reset until the first take: what happened before the baseline is not known. */
|
|
51
|
+
private _isUnavailable;
|
|
52
|
+
private readonly _touches;
|
|
53
|
+
private _lastReset;
|
|
54
|
+
private _wasUndoneOrRedone;
|
|
55
|
+
constructor(_props: ChangeRecorderProps, _context: ChangeRecorderContext);
|
|
56
|
+
/** Starts over from this committed state; the next take reports `unavailable`. */
|
|
57
|
+
reset: (state: AnyDomainState) => void;
|
|
58
|
+
/** Records one committed emission: the last update that touched each entity and field, or the reset. */
|
|
59
|
+
record: (emission: AnyStateEmission) => void;
|
|
60
|
+
/** The change record since the last take or clear, as the prompt describes it; moves the baseline. */
|
|
61
|
+
take: (state: AnyDomainState) => string;
|
|
62
|
+
/** Forgets what was recorded and makes this committed state the baseline. */
|
|
63
|
+
clear: (state: AnyDomainState) => void;
|
|
64
|
+
private _touchesOf;
|
|
65
|
+
private _touchFields;
|
|
66
|
+
private _print;
|
|
67
|
+
private _diffStates;
|
|
68
|
+
/** Added and modified entities in the order of the current state, then the deleted ones in the order they had. */
|
|
69
|
+
private _diffEntities;
|
|
70
|
+
/**
|
|
71
|
+
* The lines of the exposed stored fields that differ, in manifest order. A field no update
|
|
72
|
+
* touched since its entity was created or replaced under an id that existed before is explained
|
|
73
|
+
* by that creation.
|
|
74
|
+
*/
|
|
75
|
+
private _diffFields;
|
|
76
|
+
/** Why a line is in the record; a line no recorded update explains was left by a reset. */
|
|
77
|
+
private _comment;
|
|
78
|
+
/**
|
|
79
|
+
* What the agent is told about an update, else what the user was told, else the update tag. As
|
|
80
|
+
* for `sideEffects`, an `agentHint` replaces the `hint` whatever it holds: `null` or empty text
|
|
81
|
+
* hides the wording from the agent, not the change, so the tag stands in.
|
|
82
|
+
*/
|
|
83
|
+
private _explain;
|
|
84
|
+
}
|
|
85
|
+
export {};
|
package/dist/configurator/application-runtime/extensions/systems/assistant-system/change-recorder.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{match as e}from"ts-pattern";import{isUndoRedoReset as t}from"#package/domain";import{localize as i}from"../graphql-system/graphql-system.js";let s=[["selected","selectedEntityId"],["navigationState","navigationState"],["viewState","viewState"],["toolState","toolState"]],n=e=>e.length>80?`${e.slice(0,80)}…`:e,a=e=>void 0===e||""===e?"none":e,r=e=>new Map(Object.entries(e)),l=["id","type"];export class ChangeRecorder{_props;_context;_baseline;_isUnavailable=!0;_touches=new Map;_lastReset;_wasUndoneOrRedone=!1;constructor(e,t){this._props=e,this._context=t}reset=e=>{this.clear(e),this._isUnavailable=!0};record=i=>{if("reset"===i.kind){this._touches.clear(),this._lastReset=t(i)?"undo/redo":"reset",this._wasUndoneOrRedone||="undo/redo"===this._lastReset;return}for(let t of i.updates)e(t).with({kind:"createDomainEntity"},e=>{this._touchesOf(e.entityId).created=e}).with({kind:"deleteEntity"},e=>{let t=this._touchesOf(e.entityId);t.fields.clear(),t.deleted=e}).with({kind:"modifyDomainEntity"},e=>{this._touchFields(e.entityId,Object.keys(e.stateDelta),e)}).with({kind:"replaceDomainEntity"},e=>{let{replacementEntity:t}=e;this._touchesOf(e.entityId).fields.clear(),this._touchesOf(e.entityId).deleted=e,this._touchesOf(t.id).created=e}).with({kind:"createEphemeralRecord"},{kind:"modifyEphemeralRecord"},{kind:"deleteEphemeralRecord"},()=>{}).exhaustive()};take=e=>{let t=this._print(e);return this.clear(e),this._isUnavailable=!1,t};clear=e=>{this._baseline=e,this._touches.clear(),this._lastReset=void 0,this._wasUndoneOrRedone=!1};_touchesOf=e=>{let t=this._touches.get(e);if(t)return t;let i={created:void 0,deleted:void 0,fields:new Map};return this._touches.set(e,i),i};_touchFields=(e,t,i)=>{let s=this._touchesOf(e);for(let e of t)s.fields.set(e,i)};_print=e=>{let t=this._baseline;if(this._isUnavailable||!t)return"[changes: unavailable]";let{lines:i,count:s}=this._diffEntities(t,e);if(s>this._props.maxEntities)return`[changes: too many (${s})]`;let n=[...this._diffStates(t,e),...i];return 0===n.length?"[changes: none]":["[changes]",...this._wasUndoneOrRedone?["reset: undo/redo"]:[],...n].join("\n")};_diffStates=(e,t)=>s.flatMap(([i,s])=>{let n=a(this._context.readers[s](e)),r=a(this._context.readers[s](t));return n===r?[]:[`${i}: ${n} -> ${r}`]});_diffEntities=(e,t)=>{let i=this._context.getExposedFields(),s=new Map(e.entities.map(e=>[e.id,e])),n=new Set(t.entities.map(e=>e.id)),a=t.entities.flatMap(e=>{let t=i[e.type],n=s.get(e.id);if(!t)return[];if(!n)return[[`A ${e.id}${this._comment(this._touches.get(e.id)?.created)}`]];let a=n===e?[]:this._diffFields(n,e,t);return a.length>0?[[`M ${e.id}`,...a]]:[]}),r=e.entities.filter(e=>i[e.type]&&!n.has(e.id)).map(e=>[`D ${e.id}${this._comment(this._touches.get(e.id)?.deleted)}`]);return{lines:[...a,...r].flat(),count:a.length+r.length}};_diffFields=(e,t,s)=>{let a=r(e),d=r(t),o=this._touches.get(t.id);return Object.entries(s).flatMap(([e,{source:t,localized:s}])=>{if(!(!t.startsWith("$")&&!l.includes(t)))return[];let r=e=>{let t;return void 0===(t=s?i(e,this._context.resolveLocalizable):e)?"undefined":JSON.stringify(t)},h=r(a.get(t)),c=r(d.get(t));return h===c?[]:[` ${e}: ${n(h)} -> ${n(c)}${this._comment(o?.fields.get(t)??o?.created)}`]})};_comment=e=>e?` # ${this._explain(e).replace(/\s*\n\s*/g," ")}`:this._lastReset?` # ${this._lastReset}`:"";_explain=({agentHint:e,hint:t,updateTag:i})=>void 0!==e?null===e||""===e?i:e:t?this._context.resolveLocalizable(t):i}
|
package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { GraphQLSystem } from '../graphql-system/graphql-system.js';
|
|
|
6
6
|
* that moment, never against ones captured when its reply began streaming.
|
|
7
7
|
*/
|
|
8
8
|
export interface AssistantToolDependencies {
|
|
9
|
-
readonly getGraphQLSystem: () => Pick<GraphQLSystem, 'prepare' | 'execute'>;
|
|
9
|
+
readonly getGraphQLSystem: () => Pick<GraphQLSystem, 'prepare' | 'execute' | 'readSideEffects'>;
|
|
10
10
|
readonly getStoreSystem: () => {
|
|
11
11
|
readonly isInitialized: boolean;
|
|
12
12
|
readonly stateSlices: {
|
|
@@ -18,11 +18,14 @@ export interface AssistantToolDependencies {
|
|
|
18
18
|
};
|
|
19
19
|
};
|
|
20
20
|
readonly getActiveMode: () => DomainMode;
|
|
21
|
+
/** Forgets the changes recorded for the next change record, so it never lists what the agent itself changed. */
|
|
22
|
+
readonly clearChanges: () => void;
|
|
21
23
|
}
|
|
22
24
|
/**
|
|
23
25
|
* Browser implementation of the assistant's `query` tool: prepares the operations from the
|
|
24
26
|
* committed state and drops their emissions, so mutations in the list show a hypothetical state and
|
|
25
|
-
* the configuration, the undo stack and the saved revision stay as they were.
|
|
27
|
+
* the configuration, the undo stack and the saved revision stay as they were. The side effects of
|
|
28
|
+
* the dropped emissions are still reported, as what the changes would do.
|
|
26
29
|
*/
|
|
27
30
|
export declare const createQueryClientTool: (dependencies: AssistantToolDependencies) => import("@tanstack/ai").ClientTool<{
|
|
28
31
|
type: string;
|
|
@@ -88,7 +91,9 @@ export declare const createQueryClientTool: (dependencies: AssistantToolDependen
|
|
|
88
91
|
/**
|
|
89
92
|
* Browser implementation of the assistant's `apply` tool: hands the operations to
|
|
90
93
|
* `GraphQLSystem.execute`, which commits their changes as one undo step, and returns its result,
|
|
91
|
-
* including why nothing was committed when a commit was held or lost to a conflict.
|
|
94
|
+
* including why nothing was committed when a commit was held or lost to a conflict. A commit clears
|
|
95
|
+
* the recorded changes; a held one does not, so it shows in the next change record once the user
|
|
96
|
+
* approves it. Only an owner
|
|
92
97
|
* session may change the configuration; the check lives here and not in the `GraphQLSystem`, whose
|
|
93
98
|
* other caller, the editor panel, has its own rules.
|
|
94
99
|
*/
|
package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{AssistantGraphQLToolInput as e,applyTool as t,queryTool as o}from"@salsita-ai/contracts/assistant";let a=e=>({results:[],committed:!1,message:e}),i=e=>JSON.parse(JSON.stringify(e)),s=t=>{let o=e.safeParse(t);if(!o.success){let e=o.error.issues.map(e=>`${e.path.join(".")}: ${e.message}`).join("; ");return{ok:!1,refusal:a(`Nothing was run, because the tool input is not a list of operations (${e}). Send { "operations": [{ "query": "<one GraphQL operation>" }] } with at least one item.`)}}return{ok:!0,operations:o.data.operations.map(({query:e,variables:t,operationName:o})=>({document:e,variables:t??void 0,operationName:o??void 0}))}};export const createQueryClientTool=e=>o.client(async t=>{let o=s(t);if(!o.ok)return o.refusal;let n=e.getStoreSystem();
|
|
1
|
+
import{AssistantGraphQLToolInput as e,applyTool as t,queryTool as o}from"@salsita-ai/contracts/assistant";let a=e=>({results:[],committed:!1,message:e,sideEffects:[]}),i=e=>JSON.parse(JSON.stringify(e)),s=t=>{let o=e.safeParse(t);if(!o.success){let e=o.error.issues.map(e=>`${e.path.join(".")}: ${e.message}`).join("; ");return{ok:!1,refusal:a(`Nothing was run, because the tool input is not a list of operations (${e}). Send { "operations": [{ "query": "<one GraphQL operation>" }] } with at least one item.`)}}return{ok:!0,operations:o.data.operations.map(({query:e,variables:t,operationName:o})=>({document:e,variables:t??void 0,operationName:o??void 0}))}};export const createQueryClientTool=e=>o.client(async t=>{let o=s(t);if(!o.ok)return o.refusal;let n=e.getStoreSystem();if(!n.isInitialized)return a("Nothing was run, because the configuration is not loaded yet, so there is no state to query. Call the tool again once the configurator has finished loading.");let r=e.getGraphQLSystem(),l=await r.prepare(n.stateSlices.committed.value.state,o.operations);return i({results:l.map(({result:e})=>e),committed:!1,sideEffects:r.readSideEffects(l)})});export const createApplyClientTool=e=>t.client(async t=>{if("owner"!==e.getActiveMode())return a("Nothing was run, because changes cannot be applied in this session: the user may view this configuration but not change it. The query tool still works here, including mutations, to show what a change would do without keeping it.");let o=s(t);if(!o.ok)return o.refusal;let n=await e.getGraphQLSystem().execute(o.operations);return n.committed&&e.clearChanges(),i(n)});
|