@jxsuite/studio 2.4.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/cf-settings-45fgyx8y.js +21 -0
  4. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  5. package/dist/chunks/content-source-t88vb2dr.js +49 -0
  6. package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-dhw1nrz9.js} +27 -18
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-3ydgjbdy.js} +32 -23
  9. package/dist/chunks/file-ops-rskmhxyq.js +48 -0
  10. package/dist/chunks/files-0cratsr0.js +92 -0
  11. package/dist/chunks/{format-host-wewba4nq.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-0yywyk76.js} +21 -15
  14. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
  15. package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
  16. package/dist/chunks/layers-q75tj2mf.js +45 -0
  17. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  18. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  19. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
  20. package/dist/chunks/notify-yjm79n4s.js +30 -0
  21. package/dist/chunks/parse-24d0gay3.js +19 -0
  22. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  23. package/dist/chunks/publish-panel-54jvhskq.js +28 -0
  24. package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
  25. package/dist/chunks/references-3qx8fkyp.js +29 -0
  26. package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
  27. package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
  28. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  29. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  30. package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
  31. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  32. package/dist/chunks/{studio-f5yyvqxv.js → studio-1hbqnt19.js} +37 -31
  33. package/dist/chunks/studio-2zkz8evn.js +3163 -0
  34. package/dist/chunks/{studio-0b6fs05q.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-ef8t53vc.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-v2c5pmjj.js → studio-5rywrkxz.js} +565 -133
  38. package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
  39. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  40. package/dist/chunks/{studio-hyn3vhwr.js → studio-8swrjbny.js} +31 -18
  41. package/dist/chunks/studio-92ab2jqe.js +282 -0
  42. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  43. package/dist/chunks/{studio-28xc8ws4.js → studio-9c4k4vhv.js} +14 -10
  44. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  45. package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
  46. package/dist/chunks/{studio-aweyr4v1.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-26sv44br.js → studio-cb12yz9w.js} +8543 -8784
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-rqemfhz6.js → studio-eenfkt4e.js} +6 -6
  51. package/dist/chunks/studio-eesa1myv.js +498 -0
  52. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  53. package/dist/chunks/{studio-ke244pam.js → studio-ex8zyp98.js} +51 -25
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-cz2j1c0g.js → studio-htsf0w6m.js} +75 -123
  57. package/dist/chunks/{studio-pyrw8h0w.js → studio-kjjsjak4.js} +528 -212
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/studio-kyh9avvv.js +28 -0
  60. package/dist/chunks/{studio-jnakhg87.js → studio-kyk2v92a.js} +14 -14
  61. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  62. package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
  63. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  64. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  65. package/dist/chunks/{studio-sccge2wf.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-v1va0vhd.js → studio-pdfjntbk.js} +75 -139
  67. package/dist/chunks/studio-q43a5y9h.js +394 -0
  68. package/dist/chunks/studio-q7v10cg2.js +106 -0
  69. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  70. package/dist/chunks/studio-r1r7afxx.js +1066 -0
  71. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  72. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  73. package/dist/chunks/studio-sf7zjgv1.js +91 -0
  74. package/dist/chunks/studio-t7qb9db8.js +665 -0
  75. package/dist/chunks/studio-tmc5emrw.js +8 -0
  76. package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
  77. package/dist/chunks/{studio-dgj7gq44.js → studio-v38hmvx1.js} +7 -7
  78. package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
  79. package/dist/chunks/studio-wcy8trfx.js +263 -0
  80. package/dist/chunks/{studio-hb7c46jy.js → studio-wx5609xd.js} +21 -15
  81. package/dist/chunks/studio-xrapxwwc.js +1539 -0
  82. package/dist/chunks/{studio-ws6ef6js.js → studio-yxrkyv8a.js} +41 -29
  83. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  84. package/dist/chunks/workspace-xpres40v.js +89 -0
  85. package/dist/iframe-entry.js +450 -48
  86. package/dist/studio.js +2455 -1761
  87. package/package.json +16 -15
  88. package/src/browse/library-layouts.ts +3 -3
  89. package/src/browse/library-pane.ts +59 -5
  90. package/src/browse/library-preview.ts +19 -3
  91. package/src/canvas/asset-refs.ts +119 -0
  92. package/src/canvas/asset-resolve.ts +256 -0
  93. package/src/canvas/canvas-live-render.ts +23 -15
  94. package/src/canvas/canvas-render.ts +23 -1
  95. package/src/canvas/iframe-entry.ts +1 -0
  96. package/src/canvas/iframe-host.ts +4 -0
  97. package/src/canvas/iframe-patch.ts +47 -21
  98. package/src/canvas/iframe-protocol.ts +9 -0
  99. package/src/canvas/iframe-render.ts +50 -16
  100. package/src/commands/app-commands.ts +4 -0
  101. package/src/commands/command-args.ts +29 -0
  102. package/src/commands/context.ts +15 -1
  103. package/src/commands/defaults.ts +29 -1
  104. package/src/commands/levels.ts +16 -0
  105. package/src/commands/live-context.ts +3 -0
  106. package/src/content/collection-match.ts +267 -0
  107. package/src/content/entry-commands.ts +38 -29
  108. package/src/content/entry-fields.ts +6 -0
  109. package/src/editor/file-drop-action.ts +2 -1
  110. package/src/editor/shortcuts.ts +5 -0
  111. package/src/files/file-ops.ts +10 -42
  112. package/src/files/files.ts +487 -61
  113. package/src/files/fs-events.ts +9 -0
  114. package/src/files/gitignore.ts +432 -0
  115. package/src/files/media-paths.ts +37 -14
  116. package/src/files/media-upload.ts +47 -6
  117. package/src/files/serialize-document.ts +60 -0
  118. package/src/format/convert-file.ts +511 -0
  119. package/src/format/format-choices.ts +199 -0
  120. package/src/grid/grid-idle.ts +45 -0
  121. package/src/grid/grid-view.ts +35 -0
  122. package/src/grid/schema-columns.ts +2 -1
  123. package/src/grid/sources/content-source.ts +24 -3
  124. package/src/media/media-open.ts +58 -0
  125. package/src/media/media-pane.ts +310 -0
  126. package/src/new-project/import-tab.ts +333 -160
  127. package/src/new-project/new-project-modal.ts +27 -34
  128. package/src/packages/ensure-deps.ts +14 -7
  129. package/src/packages/jxsuite-update.ts +11 -11
  130. package/src/page-params.ts +10 -46
  131. package/src/panels/activity-bar.ts +45 -16
  132. package/src/panels/ai-chat/attached-context.ts +6 -1
  133. package/src/panels/ai-chat/chat-view.ts +255 -13
  134. package/src/panels/ai-chat/composer.ts +21 -80
  135. package/src/panels/ai-panel.ts +116 -2
  136. package/src/panels/frontmatter-fields.ts +6 -2
  137. package/src/panels/navigator-panels.ts +7 -6
  138. package/src/panels/properties-panel.ts +8 -2
  139. package/src/panels/seo-modal.ts +5 -6
  140. package/src/panels/settings-menu.ts +799 -0
  141. package/src/panels/signals-panel.ts +2 -1
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +34 -3
  144. package/src/platforms/devserver.ts +72 -34
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/services/ai-ask.ts +247 -0
  147. package/src/services/ai-import-tools.ts +494 -0
  148. package/src/services/ai-models.ts +101 -11
  149. package/src/services/ai-project-tools.ts +20 -26
  150. package/src/services/ai-settings.ts +69 -71
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/cf-settings.ts +19 -37
  154. package/src/services/connection.ts +74 -0
  155. package/src/services/context-manager.ts +89 -0
  156. package/src/services/document-assistant.ts +49 -7
  157. package/src/services/idle.ts +6 -1
  158. package/src/services/import-client.ts +33 -6
  159. package/src/services/import-run.ts +158 -0
  160. package/src/services/import-seed.ts +120 -0
  161. package/src/services/jx-validate.ts +58 -6
  162. package/src/services/project-adoption.ts +71 -0
  163. package/src/services/references.ts +0 -0
  164. package/src/services/settings/definitions.ts +181 -0
  165. package/src/services/settings/kernel.ts +335 -0
  166. package/src/services/settings/write-queue.ts +116 -0
  167. package/src/services/tool-executor.ts +34 -2
  168. package/src/settings/contexts-section.ts +45 -3
  169. package/src/settings/dependencies-editor.ts +43 -23
  170. package/src/settings/general-settings.ts +30 -12
  171. package/src/settings/preferences-accounts.ts +6 -19
  172. package/src/settings/preferences-dialog.ts +18 -41
  173. package/src/settings/preferences-keymap.ts +8 -18
  174. package/src/settings/preferences-sections.ts +57 -0
  175. package/src/settings/schema-field-ui.ts +2 -2
  176. package/src/settings/settings-document.ts +65 -9
  177. package/src/shell.ts +6 -14
  178. package/src/studio.ts +65 -7
  179. package/src/tabs/transact.ts +42 -19
  180. package/src/types.ts +164 -8
  181. package/src/ui/ai-credentials-form.ts +52 -27
  182. package/src/ui/ai-model-picker.ts +162 -0
  183. package/src/ui/choice-field.ts +72 -0
  184. package/src/ui/layers.ts +83 -5
  185. package/src/ui/media-picker.ts +6 -11
  186. package/src/utils/studio-utils.ts +43 -31
  187. package/styles/forced-colors.css +7 -0
  188. package/styles/overlays.css +256 -0
  189. package/styles/panels.css +13 -1
  190. package/styles/shell.css +111 -0
  191. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  192. package/dist/chunks/cf-settings-g5fbf2zp.js +0 -17
  193. package/dist/chunks/content-source-k9724vcv.js +0 -39
  194. package/dist/chunks/files-yew27sn9.js +0 -69
  195. package/dist/chunks/layers-panel-qnsghwwd.js +0 -59
  196. package/dist/chunks/publish-panel-a0pdft5w.js +0 -21
  197. package/dist/chunks/quick-search-9avyc4pc.js +0 -54
  198. package/dist/chunks/right-panel-kcnhtc5j.js +0 -38
  199. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  200. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  201. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  202. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  203. package/dist/chunks/studio-a7pzveq1.js +0 -110
  204. package/dist/chunks/studio-c31yb4t6.js +0 -55
  205. package/dist/chunks/studio-ra7ar50f.js +0 -6682
  206. package/src/canvas/content-assets.ts +0 -263
  207. package/src/canvas/site-style-css.ts +0 -121
  208. package/src/services/settings-store.ts +0 -99
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Ai-import-tools.ts — `import_site`, the assistant's other way to bootstrap a project.
3
+ *
4
+ * A sibling of `create_project` (`ai-project-tools.ts`) with a different backend: it drives the
5
+ * headless-Chrome pipeline behind `POST /__studio/import-site`, streams its progress into a record
6
+ * the transcript draws, and then adopts the result exactly as a scaffold is adopted. Its own module
7
+ * rather than an eighth tool in `ai-project-tools.ts`, which is already 650 lines under a per-file
8
+ * coverage bar.
9
+ *
10
+ * Two things it does NOT do are the point:
11
+ *
12
+ * - **It does not invent a destination.** `create_project` requires the model to supply `location`
13
+ * and refuses without one — correct when a bare chat bootstraps a project, a regression on the
14
+ * wizard path where the Location field already has a folder picker, validation and a live preview.
15
+ * The destination comes from the pending brief; a model-supplied one must match it.
16
+ * - **It does not decide what the crawl found.** The pipeline is one-way: it reports and finishes.
17
+ * Everything worth a human's opinion — which skipped pages matter, whether the components it
18
+ * extracted are really components — is in the summary for the model to raise with `ask_user`
19
+ * AFTERWARDS, against real numbers rather than a guess made before the browser launched.
20
+ *
21
+ * @license MIT
22
+ */
23
+
24
+ import { createToolDefinition, toolError, toolSuccess } from "@jxsuite/ai/tools";
25
+ import { getPlatform } from "../platform";
26
+ import { workspace } from "../workspace/workspace";
27
+ import { getBaseUrl, getOpenAiKey } from "./ai-settings";
28
+ import { preferredModel } from "./ai-models";
29
+ import { clearPendingImportBrief, pendingImportBrief } from "./import-seed";
30
+ import {
31
+ abortImportRun,
32
+ beginImportRun,
33
+ finishImportRun,
34
+ importRun,
35
+ recordImportProgress,
36
+ } from "./import-run";
37
+ import { adoptCreatedProject } from "./project-adoption";
38
+ import type { AdoptOutcome } from "./project-adoption";
39
+ import { currentToolCallId, turnSignal } from "./ai-turn-signal";
40
+
41
+ import type { ToolRegistry } from "@jxsuite/ai/tools";
42
+ import type { Tab } from "../tabs/tab";
43
+ import type { ImportBreakpointPolicy, ImportReadyEvent, ImportSiteSummary } from "../types";
44
+
45
+ /** The same bounds `packages/server/src/import-api.ts` clamps to. */
46
+ const MAX_DEPTH = 5;
47
+ const MAX_PAGES = 100;
48
+
49
+ /** Log lines quoted back on a hard failure — enough to see WHICH phase died. */
50
+ const FAILURE_TAIL = 5;
51
+
52
+ /** Low-fidelity pages named in the summary. A list of twenty is a wall, not a finding. */
53
+ const WEAKEST_PAGES = 3;
54
+
55
+ /** Below this, a page is worth naming. Above it, "close enough" is the honest reading. */
56
+ const WEAK_FIDELITY = 90;
57
+
58
+ /**
59
+ * The default bar an import is measured against, matching `jx-import --min-fidelity`.
60
+ *
61
+ * A floor for "this is not a clone of anything" rather than a quality target: a faithful import of
62
+ * a complicated site lands well under 100 for reasons no importer can fix.
63
+ */
64
+ const DEFAULT_MIN_FIDELITY = 25;
65
+ const MAX_FIDELITY = 100;
66
+
67
+ export interface ImportToolsCtx {
68
+ /** The active tab, read AFTER adoption to re-anchor the agent loop's undo batch. */
69
+ getTab: () => Tab | null;
70
+ /** Open the imported project in this window. Defaults to the late-bound adopter. */
71
+ adoptProject?: (root: string) => Promise<void>;
72
+ /** Fired once adoption is VERIFIED — the session store re-keys the live chat here. */
73
+ onProjectAdopted?: (root: string) => void;
74
+ }
75
+
76
+ function clamp(value: number, min: number, max: number): number {
77
+ return Math.min(max, Math.max(min, Math.trunc(value)));
78
+ }
79
+
80
+ /**
81
+ * The breakpoint policy for this run: what the model asked for, else what the wizard collected.
82
+ *
83
+ * The model may narrow this — it is the one option a person is likely to have an opinion about
84
+ * after seeing the crawl — but it never invents an override the user did not ask for, because the
85
+ * wizard's control is already the answer to "how many breakpoints should this project have".
86
+ *
87
+ * @param {object} args - The model's `maxBreakpoints` / `breakpointWidths` / `breakpointRounding`
88
+ * @param {ImportBreakpointPolicy | undefined} fromBrief - What the wizard collected, if anything
89
+ * @returns {ImportBreakpointPolicy | undefined}
90
+ */
91
+ function breakpointPolicyFor(
92
+ args: { maxBreakpoints?: unknown; widths?: unknown; rounding?: unknown },
93
+ fromBrief: ImportBreakpointPolicy | undefined,
94
+ ): ImportBreakpointPolicy | undefined {
95
+ const rounding =
96
+ args.rounding === "down" || args.rounding === "up" || args.rounding === "nearest"
97
+ ? args.rounding
98
+ : (fromBrief?.rounding ?? "nearest");
99
+
100
+ if (Array.isArray(args.widths) && args.widths.length > 0) {
101
+ return { mode: "explicit", rounding, widths: args.widths.map(Number) };
102
+ }
103
+ if (typeof args.maxBreakpoints === "number" && Number.isFinite(args.maxBreakpoints)) {
104
+ // Zero is the only spelling a count has for "keep all", and it is worth accepting: it is what
105
+ // A model reaches for when a person says "keep them all".
106
+ return args.maxBreakpoints <= 0
107
+ ? { mode: "all" }
108
+ : { count: Math.trunc(args.maxBreakpoints), mode: "limit", rounding };
109
+ }
110
+ return fromBrief;
111
+ }
112
+
113
+ /** Absolute on POSIX or Windows — the test `create_project` already applies to its `location`. */
114
+ function isAbsolutePath(path: string): boolean {
115
+ return /^(?:[a-zA-Z]:[/\\]|\/)/.test(path);
116
+ }
117
+
118
+ /**
119
+ * One run has already succeeded in this session.
120
+ *
121
+ * Not the same question as `workspace.projectRoot`, and that is the whole reason it exists: on
122
+ * desktop, adoption may open the project in ANOTHER window, leaving this one's root empty — so the
123
+ * `no-project` tier would happily advertise a second import over the top of the first.
124
+ * `create_project` has the identical hazard today.
125
+ */
126
+ let imported = false;
127
+
128
+ /** Forget that an import ran. New Chat, and tests. */
129
+ export function resetImportGuard(): void {
130
+ imported = false;
131
+ }
132
+
133
+ /**
134
+ * A compact, factual account of the run for the model to reason about — and to ask about.
135
+ *
136
+ * Counts only where they are real. `streamImport` returns `{ root, config }` and nothing else, so
137
+ * the page and file totals are not available here; inventing them from log prose is exactly how a
138
+ * confidently wrong number gets into a summary. It says what it knows and points at the tool that
139
+ * knows the rest.
140
+ */
141
+ function describeRun(id: string, root: string, summary?: ImportSiteSummary): string {
142
+ const record = importRun(id);
143
+ const warnings = [...(record?.warnings ?? []), ...(summary?.warnings ?? [])];
144
+ const lines = [`Imported ${record?.url ?? "the site"} into ${root} and opened it.`];
145
+
146
+ const pages = summary?.pages ?? [];
147
+ if (pages.length > 0) {
148
+ lines.push(
149
+ `${pages.length} page${pages.length === 1 ? "" : "s"}` +
150
+ `${summary?.fileCount ? `, ${summary.fileCount} files` : ""}: ` +
151
+ `${pages.map((p) => p.route).join(", ")}.`,
152
+ );
153
+ }
154
+
155
+ /* Per page, because the average cannot name one. "84% average" is a fact nobody can act on;
156
+ "the pricing page renders at 61%" is a decision — and it is the one finding here worth
157
+ interrupting a person for. */
158
+ if (summary?.verify) {
159
+ const weakest = (summary.verify.pages ?? [])
160
+ .filter((p) => p.fidelity < WEAK_FIDELITY)
161
+ .toSorted((a, b) => a.fidelity - b.fidelity)
162
+ .slice(0, WEAKEST_PAGES);
163
+ const detail =
164
+ weakest.length > 0
165
+ ? `; weakest: ${weakest.map((p) => `${p.route} at ${p.fidelity}%`).join(", ")}.`
166
+ : " on every page.";
167
+ lines.push(
168
+ `Fidelity against the original averaged ${summary.verify.averageFidelity}%${detail}`,
169
+ );
170
+ /*
171
+ * The bar the user set, and whether this run cleared it. Stated FIRST among the details,
172
+ * because it is the difference between a number to note and a result to act on: an import at
173
+ * 8% used to report exactly like one at 95%, and the only way to tell them apart was to open
174
+ * the project and look.
175
+ */
176
+ if (summary.verify.passed === false && (summary.verify.buildErrors ?? []).length === 0) {
177
+ lines.push(
178
+ `That is below the ${summary.verify.minFidelity ?? 0}% minimum this import was asked ` +
179
+ "for, so the clone does not match the original closely enough. Say so plainly, and " +
180
+ "offer to look at what went wrong rather than moving on.",
181
+ );
182
+ }
183
+ /*
184
+ * The score says a page looks wrong; this says why. A page that 404s on fifteen asset
185
+ * references scores badly for one fixable reason, and reading a percentage on its own sends
186
+ * the reader looking for the wrong thing entirely (jxsuite/jx issue 232).
187
+ */
188
+ const missing = (summary.verify.pages ?? []).reduce(
189
+ (sum, page) => sum + (page.failedRequests ?? 0),
190
+ 0,
191
+ );
192
+ if (missing > 0) {
193
+ lines.push(
194
+ `${missing} request${missing === 1 ? "" : "s"} failed or 404'd in the rendered pages — ` +
195
+ "check the emitted asset paths before treating a low score as a layout problem.",
196
+ );
197
+ }
198
+ for (const error of summary.verify.buildErrors ?? []) {
199
+ lines.push(`The emitted project did not build cleanly: ${error}`);
200
+ }
201
+ }
202
+
203
+ if (warnings.length > 0) {
204
+ lines.push(
205
+ `${warnings.length} warning${warnings.length === 1 ? "" : "s"} during the run: ` +
206
+ `${warnings.join("; ")}.`,
207
+ );
208
+ }
209
+ lines.push(
210
+ "The file and document tools are available now. Read the emitted pages, layouts and " +
211
+ "components before proposing changes — then ask the user about anything the import had to " +
212
+ "guess at (pages it skipped, components it may have split wrongly, a layout it did not find, " +
213
+ "a page that did not render faithfully).",
214
+ );
215
+ return lines.join(" ");
216
+ }
217
+
218
+ /**
219
+ * Register `import_site`.
220
+ *
221
+ * @param {Pick<ToolRegistry, "register">} registry
222
+ * @param {ImportToolsCtx} ctx
223
+ */
224
+ export function registerImportTools(
225
+ registry: Pick<ToolRegistry, "register">,
226
+ { getTab, adoptProject, onProjectAdopted }: ImportToolsCtx,
227
+ ): void {
228
+ registry.register(
229
+ createToolDefinition({
230
+ name: "import_site",
231
+ description:
232
+ "Clone a live website into a new Jx project and open it in the studio: pages, styles, " +
233
+ "assets, a shared layout and recurring components. Only available while no project is " +
234
+ "open. Progress streams into the conversation as it runs. When the user reached you " +
235
+ "through the New Project Import form, the URL, crawl options and destination are already " +
236
+ "settled — call this with the url alone and the rest is filled in. After it succeeds the " +
237
+ "file and document tools become available in the next round.",
238
+ parameters: {
239
+ type: "object",
240
+ properties: {
241
+ url: { type: "string", description: "The site to clone. Must be http(s)." },
242
+ directory: {
243
+ type: "string",
244
+ description:
245
+ "Absolute destination folder. Omit it when an import was started from the New " +
246
+ "Project form — that destination is the one the user chose, and it wins.",
247
+ },
248
+ depth: {
249
+ type: "number",
250
+ description:
251
+ "Crawl depth. 0 imports the single page and runs an entirely different, much faster " +
252
+ "pipeline — no crawl, no shared-layout detection. Default 1.",
253
+ },
254
+ maxPages: { type: "number", description: "Most pages to capture (1–100). Default 20." },
255
+ verify: {
256
+ type: "boolean",
257
+ description:
258
+ "Build the imported project and screenshot-diff every page against the original, " +
259
+ "reporting a per-page fidelity score. Roughly doubles the run, and is the only " +
260
+ "finding that says how WELL the clone came out rather than what was skipped.",
261
+ },
262
+ minFidelity: {
263
+ type: "number",
264
+ description:
265
+ "With verify on, the average fidelity (0-100) the clone must reach to count as " +
266
+ "matching the original. A floor rather than a target: a faithful import of a " +
267
+ "complicated site lands well under 100, so raise it only when the user asks for a " +
268
+ "stricter bar. 0 reports the score without judging it. Default 25.",
269
+ },
270
+ aiComponents: {
271
+ type: "boolean",
272
+ description:
273
+ "Refine component and prop names with the model. Costs one model call per extracted " +
274
+ "component, so it is worth asking about on a wide crawl. Default true.",
275
+ },
276
+ maxBreakpoints: {
277
+ type: "number",
278
+ description:
279
+ "How many of the site's declared breakpoints to keep, evenly spaced across its " +
280
+ "range (1-12). A real site declares as many as it has accumulated frameworks, and " +
281
+ "each one is a canvas size and a column in every style editor. Default 3; pass 0 to " +
282
+ "keep every one. Ignored when breakpointWidths is given.",
283
+ },
284
+ breakpointWidths: {
285
+ type: "array",
286
+ items: { type: "number" },
287
+ description:
288
+ "Keep these widths instead of a count, e.g. [640, 1024, 1440]. Each is backed by " +
289
+ "the declared width nearest it, because the styles flip where the site says they " +
290
+ "do. Ask the user before choosing widths they did not name.",
291
+ },
292
+ breakpointRounding: {
293
+ type: "string",
294
+ enum: ["nearest", "down", "up"],
295
+ description: "How a kept width matches a declared one. Default nearest.",
296
+ },
297
+ },
298
+ required: ["url"],
299
+ },
300
+ async execute(args) {
301
+ const {
302
+ url,
303
+ directory,
304
+ depth,
305
+ maxPages,
306
+ aiComponents,
307
+ verify,
308
+ minFidelity,
309
+ maxBreakpoints,
310
+ breakpointWidths,
311
+ breakpointRounding,
312
+ } = args as {
313
+ url?: unknown;
314
+ directory?: unknown;
315
+ depth?: unknown;
316
+ maxPages?: unknown;
317
+ aiComponents?: unknown;
318
+ verify?: unknown;
319
+ minFidelity?: unknown;
320
+ maxBreakpoints?: unknown;
321
+ breakpointWidths?: unknown;
322
+ breakpointRounding?: unknown;
323
+ };
324
+
325
+ if (workspace.projectRoot) {
326
+ return toolError(
327
+ "A project is already open in this window — import_site is only for bootstrapping.",
328
+ );
329
+ }
330
+ if (imported) {
331
+ return toolError(
332
+ "A site has already been imported in this conversation. Start a new chat to import " +
333
+ "another, or use the file tools on the project that was created.",
334
+ );
335
+ }
336
+ const platform = getPlatform();
337
+ if (!platform.importSite) {
338
+ return toolError("This Studio platform has no site-import backend.");
339
+ }
340
+
341
+ let target: URL;
342
+ try {
343
+ target = new URL(String(url ?? "").trim());
344
+ } catch {
345
+ return toolError('Pass "url" — an absolute http(s) address, e.g. https://example.com.');
346
+ }
347
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
348
+ return toolError("The url must start with http:// or https://.");
349
+ }
350
+
351
+ const brief = pendingImportBrief();
352
+ const requested = typeof directory === "string" ? directory.trim() : "";
353
+ if (requested && brief && requested !== brief.directory) {
354
+ /* The wizard's Location field is the user's answer to "where does this go". A model that
355
+ disagrees with it is guessing at a decision that was already made in front of them. */
356
+ return toolError(
357
+ `The destination is already set to ${brief.directory} — call import_site without ` +
358
+ '"directory", or ask the user before changing it.',
359
+ );
360
+ }
361
+ const destination = requested || brief?.directory || "";
362
+ if (!destination) {
363
+ return toolError(
364
+ 'Pass "directory" — the absolute folder to create the project in. Ask the user where ' +
365
+ "the project should live rather than guessing.",
366
+ );
367
+ }
368
+ if (!isAbsolutePath(destination)) {
369
+ return toolError(`"directory" must be an absolute path: ${destination}`);
370
+ }
371
+ if (destination.split(/[/\\]/).includes("..")) {
372
+ return toolError(`"directory" must not contain "..": ${destination}`);
373
+ }
374
+
375
+ const id = currentToolCallId();
376
+ const signal = beginImportRun(id, { directory: destination, url: target.href });
377
+ /* The turn's own signal too. `assistant.stop` aborts the request through `abortImportRun`,
378
+ but that is not the only way a turn ends: the loop's signal can be aborted directly, and
379
+ a run left going after its turn died would keep a headless browser open for minutes with
380
+ nothing left to receive the result. */
381
+ turnSignal()?.addEventListener("abort", () => {
382
+ abortImportRun();
383
+ finishImportRun(id, { status: "stopped" });
384
+ });
385
+
386
+ const apiKey = getOpenAiKey();
387
+ const baseUrl = getBaseUrl();
388
+ const model = brief?.model || preferredModel();
389
+
390
+ const breakpoints = breakpointPolicyFor(
391
+ { maxBreakpoints, rounding: breakpointRounding, widths: breakpointWidths },
392
+ brief?.breakpoints,
393
+ );
394
+
395
+ /*
396
+ * Adoption happens the moment the destination is a project, not when the run ends.
397
+ *
398
+ * A crawl takes minutes, and it used to spend all of them with the author on the welcome
399
+ * screen — the tool opened the project once `importSite` resolved, so the one view of what
400
+ * the import was doing was a log in the sidebar. Opening at `ready` puts them IN the project
401
+ * while it fills: the Files tree gets each page, component and asset as the pipeline writes
402
+ * it, through the watcher that is already running.
403
+ *
404
+ * It runs at most once (a backend may send the line once, and only one can be first), and a
405
+ * failure here is not the run's failure: the import continues either way, and the terminal
406
+ * path below reports honestly on whichever state the window ended up in.
407
+ */
408
+ let adoption: Promise<AdoptOutcome> | null = null;
409
+ const onReady = ({ root }: ImportReadyEvent) => {
410
+ adoption ??= adoptCreatedProject(root, {
411
+ getTab,
412
+ ...(adoptProject ? { adopt: adoptProject } : {}),
413
+ });
414
+ };
415
+
416
+ let result: { root: string; result?: ImportSiteSummary };
417
+ try {
418
+ result = await platform.importSite(
419
+ {
420
+ aiComponents:
421
+ typeof aiComponents === "boolean" ? aiComponents : (brief?.aiComponents ?? true),
422
+ depth: clamp(Number(depth ?? brief?.depth ?? 1) || 0, 0, MAX_DEPTH),
423
+ directory: destination,
424
+ maxPages: clamp(Number(maxPages ?? brief?.maxPages ?? 20) || 1, 1, MAX_PAGES),
425
+ name: brief?.name || target.hostname.replace(/^www\./, ""),
426
+ url: target.href,
427
+ ...(breakpoints === undefined ? {} : { breakpoints }),
428
+ ...(typeof verify === "boolean"
429
+ ? { verify }
430
+ : brief?.verify === undefined
431
+ ? {}
432
+ : { verify: brief.verify }),
433
+ /* The wizard's number is the user's own answer to "how close is close enough"; the
434
+ model may only override it when it was told to. */
435
+ verifyMinFidelity: clamp(
436
+ Number(minFidelity ?? brief?.minFidelity ?? DEFAULT_MIN_FIDELITY) || 0,
437
+ 0,
438
+ MAX_FIDELITY,
439
+ ),
440
+ ...(apiKey ? { apiKey } : {}),
441
+ ...(baseUrl ? { baseUrl } : {}),
442
+ ...(model ? { model } : {}),
443
+ },
444
+ (evt) => recordImportProgress(id, evt),
445
+ signal,
446
+ onReady,
447
+ );
448
+ } catch (error) {
449
+ const message = error instanceof Error ? error.message : String(error);
450
+ if (signal.aborted) {
451
+ finishImportRun(id, { error: message, status: "stopped" });
452
+ return toolError("The import was stopped.");
453
+ }
454
+ finishImportRun(id, { error: message, status: "failed" });
455
+ /* The tail, not just the message: the commonest recovery is retrying at depth 0, which is
456
+ an entirely different code path, and the phase that died is what says whether that
457
+ would help. */
458
+ const tail = (importRun(id)?.log ?? [])
459
+ .slice(-FAILURE_TAIL)
460
+ .map((e) => `[${e.phase}] ${e.message}`)
461
+ .join("\n");
462
+ return toolError(
463
+ `The import failed: ${message}${tail ? `\n\nLast steps:\n${tail}` : ""}`,
464
+ );
465
+ }
466
+
467
+ finishImportRun(id, { status: "done" });
468
+ imported = true;
469
+ clearPendingImportBrief();
470
+
471
+ /* The early adoption if there was one, otherwise adopt now: a backend that sends no `ready`
472
+ line is not broken, it is older, and the project must still open. */
473
+ const { adopted, error: adoptionError } = await (adoption ??
474
+ adoptCreatedProject(result.root, {
475
+ getTab,
476
+ ...(adoptProject ? { adopt: adoptProject } : {}),
477
+ }));
478
+ if (adopted) {
479
+ onProjectAdopted?.(result.root);
480
+ return toolSuccess(
481
+ { root: result.root, ...(result.result ? { summary: result.result } : {}) },
482
+ describeRun(id, result.root, result.result),
483
+ );
484
+ }
485
+ return toolSuccess(
486
+ { root: result.root },
487
+ `Imported the site into ${result.root}, but it was not opened in this window` +
488
+ `${adoptionError ? ` (${adoptionError})` : " (it may have opened in another window)"}. ` +
489
+ "Ask the user to open it from the welcome screen or recent projects.",
490
+ );
491
+ },
492
+ }),
493
+ );
494
+ }
@@ -13,14 +13,54 @@
13
13
 
14
14
  import type { AiModelsResponse } from "@jxsuite/protocol";
15
15
  import { getPlatform } from "../platform";
16
- import { getBaseUrl, getOpenAiKey, hasOpenAiKey } from "./ai-settings";
16
+ import { getBaseUrl, getOpenAiKey, hasOpenAiKey, storedModel } from "./ai-settings";
17
+ import { SETTINGS } from "./settings/definitions";
18
+ import { onSettingsChanged } from "./settings/kernel";
17
19
 
18
20
  export interface AiModel {
19
21
  id: string;
20
22
  name: string;
21
23
  }
22
24
 
25
+ /**
26
+ * The credentials a model list belongs to.
27
+ *
28
+ * Two fields, taken together or not at all. The per-field overrides this replaces let a caller pass
29
+ * a draft key with a stored base URL, which is a pair that was never configured anywhere.
30
+ */
31
+ export interface AiCredentials {
32
+ apiKey: string;
33
+ baseUrl: string;
34
+ }
35
+
36
+ /** The credentials the app is configured with right now. */
37
+ export function aiConnection(): AiCredentials {
38
+ return { apiKey: getOpenAiKey(), baseUrl: getBaseUrl() };
39
+ }
40
+
41
+ /**
42
+ * An opaque identity for a credential pair.
43
+ *
44
+ * NUL-joined rather than concatenated: a key ending in the base URL's first characters would
45
+ * otherwise be indistinguishable from a shorter key and a longer URL.
46
+ */
47
+ function fingerprint(credentials: AiCredentials): string {
48
+ return `${credentials.apiKey}\u0000${credentials.baseUrl}`;
49
+ }
50
+
23
51
  let cache: AiModel[] | null = null;
52
+
53
+ /**
54
+ * Which credentials {@link cache} was listed under.
55
+ *
56
+ * The cache used to be unkeyed, and two callers with different credentials wrote to it: the
57
+ * credentials form fetches with the drafts being edited, while the capability probe fetches with
58
+ * whatever is stored. Whichever landed last won, so the chat composer's picker could list a
59
+ * different provider's models than the one actually configured — which is exactly what a user saw
60
+ * after configuring a provider and finding the shell offering someone else's catalogue.
61
+ */
62
+ let cacheKey = "";
63
+
24
64
  let proxyConfigured = false;
25
65
  let proxyManaged = false;
26
66
  let proxyDefaultModel = "";
@@ -34,9 +74,17 @@ let proxyCode: AiModelsResponse["code"];
34
74
  let proxyProbe: Promise<void> | null = null;
35
75
  const probeListeners = new Set<() => void>();
36
76
 
37
- /** Drop the cached model list (call after credentials/endpoint changes). */
38
- export function invalidateModelCache() {
77
+ /**
78
+ * Drop the cached list and the capability reading.
79
+ *
80
+ * No surface calls this: it runs off the settings subscription below, because "the credentials
81
+ * changed" is a fact about the store rather than something each form has to remember to announce —
82
+ * and a form that forgot left every gate reading a stale capability probe. It stays exported only
83
+ * so a test can start from a cold module.
84
+ */
85
+ export function resetModelCache() {
39
86
  cache = null;
87
+ cacheKey = "";
40
88
  proxyConfigured = false;
41
89
  proxyManaged = false;
42
90
  proxyDefaultModel = "";
@@ -47,6 +95,28 @@ export function invalidateModelCache() {
47
95
  proxyProbe = null;
48
96
  }
49
97
 
98
+ /** The keys whose value changes what a provider would answer with. */
99
+ const CONNECTION_KEYS = new Set<string>([
100
+ SETTINGS.aiOpenAiKey.key,
101
+ SETTINGS.aiBaseUrl.key,
102
+ SETTINGS.cfToken.key,
103
+ SETTINGS.cfAccountId.key,
104
+ ]);
105
+
106
+ onSettingsChanged((keys) => {
107
+ if (keys.some((key) => CONNECTION_KEYS.has(key))) {
108
+ resetModelCache();
109
+ }
110
+ });
111
+
112
+ /**
113
+ * The cached list, if it was listed under `credentials`. Null when it was not, or when there is
114
+ * none — a caller must not show one provider's catalogue for another's key.
115
+ */
116
+ export function cachedModels(credentials: AiCredentials): AiModel[] | null {
117
+ return cache && cacheKey === fingerprint(credentials) ? cache : null;
118
+ }
119
+
50
120
  /**
51
121
  * Run the capability probe once, repainting `onSettled` when it lands. Idempotent: concurrent and
52
122
  * repeated callers share the single in-flight fetch, and every registered host is notified. Probe
@@ -108,6 +178,23 @@ export function getProxyDefaultModel(): string {
108
178
  return proxyDefaultModel;
109
179
  }
110
180
 
181
+ /**
182
+ * The model to SEND: what the user chose, else what the backend says it prefers, else the built-in
183
+ * default.
184
+ *
185
+ * The middle term is the point. A managed Workers AI backend declares its own `defaultModel`, and
186
+ * that value was fetched, stored and never read — so a user who had chosen nothing got the
187
+ * hardcoded `"gpt-4o"`, which Workers AI does not serve. Asking the backend what it prefers before
188
+ * falling back to a name invented here is the difference between working and a 404 on the first
189
+ * message.
190
+ *
191
+ * It lives here rather than in `ai-settings.ts` because only this module knows what the proxy said,
192
+ * and `ai-settings` must not import it — this module already imports `ai-settings`.
193
+ */
194
+ export function preferredModel(): string {
195
+ return storedModel() || getProxyDefaultModel() || SETTINGS.aiModel.default;
196
+ }
197
+
111
198
  /**
112
199
  * The sibling of the chat route, replacing the last path segment and keeping everything else.
113
200
  *
@@ -146,9 +233,13 @@ export function siblingRoute(chatUrl: string, segment: string): string {
146
233
  * @returns {Promise<AiModel[]>}
147
234
  */
148
235
  export async function fetchAvailableModels(
149
- opts: { apiKey?: string; baseUrl?: string; force?: boolean } = {},
236
+ opts: { credentials?: AiCredentials; force?: boolean } = {},
150
237
  ): Promise<AiModel[]> {
151
- if (cache && !opts.force) {
238
+ /* Resolve the credentials BEFORE consulting the cache. Checking the cache first is what let a
239
+ list fetched under one provider be handed to a caller asking about another. */
240
+ const credentials = opts.credentials ?? aiConnection();
241
+ const key = fingerprint(credentials);
242
+ if (cache && cacheKey === key && !opts.force) {
152
243
  return cache;
153
244
  }
154
245
  const plat = getPlatform();
@@ -156,13 +247,11 @@ export async function fetchAvailableModels(
156
247
  const modelsUrl = siblingRoute(chatUrl, "models");
157
248
 
158
249
  const headers: Record<string, string> = {};
159
- const apiKey = opts.apiKey || getOpenAiKey();
160
- if (apiKey) {
161
- headers["X-Api-Key"] = apiKey;
250
+ if (credentials.apiKey) {
251
+ headers["X-Api-Key"] = credentials.apiKey;
162
252
  }
163
- const baseUrl = opts.baseUrl || getBaseUrl();
164
- if (baseUrl) {
165
- headers["X-Api-Base-URL"] = baseUrl;
253
+ if (credentials.baseUrl) {
254
+ headers["X-Api-Base-URL"] = credentials.baseUrl;
166
255
  }
167
256
 
168
257
  const resp = await fetch(modelsUrl, { headers });
@@ -175,5 +264,6 @@ export async function fetchAvailableModels(
175
264
  proxyDefaultModel = data.defaultModel ?? "";
176
265
  proxyCode = data.code;
177
266
  cache = (data.models || []).map((m) => ({ id: m.id, name: m.name || m.id }));
267
+ cacheKey = key;
178
268
  return cache;
179
269
  }