@jxsuite/studio 3.0.0 → 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.
- package/README.md +5 -5
- package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
- package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
- package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
- package/dist/chunks/content-source-t88vb2dr.js +49 -0
- package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-dhw1nrz9.js} +27 -19
- package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
- package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-3ydgjbdy.js} +32 -24
- package/dist/chunks/file-ops-rskmhxyq.js +48 -0
- package/dist/chunks/files-0cratsr0.js +92 -0
- package/dist/chunks/{format-host-8fr3545e.js → format-host-tgcmfs4p.js} +3 -3
- package/dist/chunks/fs-events-hrrg328g.js +21 -0
- package/dist/chunks/{git-panel-g01d93vc.js → git-panel-0yywyk76.js} +21 -16
- package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
- package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
- package/dist/chunks/layers-q75tj2mf.js +45 -0
- package/dist/chunks/lit-html-j055mkh8.js +22 -0
- package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
- package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
- package/dist/chunks/notify-yjm79n4s.js +30 -0
- package/dist/chunks/parse-24d0gay3.js +19 -0
- package/dist/chunks/platform-ba1j1gbh.js +16 -0
- package/dist/chunks/publish-panel-54jvhskq.js +28 -0
- package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
- package/dist/chunks/references-3qx8fkyp.js +29 -0
- package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
- package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
- package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
- package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
- package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
- package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
- package/dist/chunks/{studio-8ezdegcx.js → studio-1hbqnt19.js} +36 -32
- package/dist/chunks/{studio-r5xbtctf.js → studio-2zkz8evn.js} +446 -2451
- package/dist/chunks/{studio-pkp61mh8.js → studio-4pqqxbrt.js} +23 -15
- package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
- package/dist/chunks/{studio-3dkcd4c5.js → studio-5q69gfag.js} +60 -56
- package/dist/chunks/{studio-rh1cnamv.js → studio-5rywrkxz.js} +563 -133
- package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
- package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
- package/dist/chunks/{studio-xntfdy1x.js → studio-8swrjbny.js} +31 -18
- package/dist/chunks/studio-92ab2jqe.js +282 -0
- package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
- package/dist/chunks/{studio-ry1vz5sv.js → studio-9c4k4vhv.js} +15 -13
- package/dist/chunks/studio-9n4mrmnb.js +184 -0
- package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
- package/dist/chunks/{studio-2x9zbxmx.js → studio-ahjfdy6e.js} +9 -7
- package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
- package/dist/chunks/{studio-dc8s8heb.js → studio-cb12yz9w.js} +8528 -8780
- package/dist/chunks/studio-cft9r4r7.js +119 -0
- package/dist/chunks/{studio-hcxzyk3x.js → studio-eenfkt4e.js} +6 -6
- package/dist/chunks/studio-eesa1myv.js +498 -0
- package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
- package/dist/chunks/{studio-7p35254k.js → studio-ex8zyp98.js} +30 -26
- package/dist/chunks/studio-gv8vvshc.js +118 -0
- package/dist/chunks/studio-hbtp0eh1.js +1315 -0
- package/dist/chunks/{studio-a0yp7syn.js → studio-htsf0w6m.js} +29 -114
- package/dist/chunks/{studio-j595e748.js → studio-kjjsjak4.js} +139 -63
- package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
- package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
- package/dist/chunks/{studio-g5gdqnyd.js → studio-kyk2v92a.js} +14 -14
- package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
- package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
- package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
- package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
- package/dist/chunks/{studio-p5dja96s.js → studio-p8j86wag.js} +2 -2
- package/dist/chunks/{studio-6z5eyjwn.js → studio-pdfjntbk.js} +66 -129
- package/dist/chunks/studio-q43a5y9h.js +394 -0
- package/dist/chunks/studio-q7v10cg2.js +106 -0
- package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
- package/dist/chunks/studio-r1r7afxx.js +1066 -0
- package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
- package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
- package/dist/chunks/studio-sf7zjgv1.js +91 -0
- package/dist/chunks/studio-t7qb9db8.js +665 -0
- package/dist/chunks/studio-tmc5emrw.js +8 -0
- package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
- package/dist/chunks/{studio-k4jvjcfx.js → studio-v38hmvx1.js} +7 -7
- package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
- package/dist/chunks/studio-wcy8trfx.js +263 -0
- package/dist/chunks/{studio-5efrtn1m.js → studio-wx5609xd.js} +21 -17
- package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
- package/dist/chunks/{studio-hj2se583.js → studio-yxrkyv8a.js} +41 -31
- package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
- package/dist/chunks/workspace-xpres40v.js +89 -0
- package/dist/iframe-entry.js +108 -24
- package/dist/studio.js +2362 -1695
- package/package.json +16 -15
- package/src/browse/library-pane.ts +57 -3
- package/src/canvas/canvas-render.ts +23 -1
- package/src/canvas/iframe-patch.ts +47 -21
- package/src/canvas/iframe-render.ts +22 -6
- package/src/commands/app-commands.ts +4 -0
- package/src/commands/command-args.ts +29 -0
- package/src/commands/context.ts +15 -1
- package/src/commands/defaults.ts +29 -1
- package/src/commands/levels.ts +16 -0
- package/src/commands/live-context.ts +3 -0
- package/src/content/collection-match.ts +267 -0
- package/src/content/entry-commands.ts +38 -29
- package/src/content/entry-fields.ts +6 -0
- package/src/editor/shortcuts.ts +5 -0
- package/src/files/file-ops.ts +10 -42
- package/src/files/files.ts +485 -59
- package/src/files/fs-events.ts +9 -0
- package/src/files/gitignore.ts +432 -0
- package/src/files/serialize-document.ts +60 -0
- package/src/format/convert-file.ts +511 -0
- package/src/format/format-choices.ts +199 -0
- package/src/grid/grid-view.ts +16 -2
- package/src/media/media-open.ts +58 -0
- package/src/media/media-pane.ts +310 -0
- package/src/new-project/import-tab.ts +332 -160
- package/src/new-project/new-project-modal.ts +27 -34
- package/src/packages/ensure-deps.ts +14 -7
- package/src/packages/jxsuite-update.ts +11 -11
- package/src/page-params.ts +10 -46
- package/src/panels/activity-bar.ts +45 -16
- package/src/panels/ai-chat/attached-context.ts +6 -1
- package/src/panels/ai-chat/chat-view.ts +255 -13
- package/src/panels/ai-chat/composer.ts +21 -97
- package/src/panels/ai-panel.ts +116 -2
- package/src/panels/navigator-panels.ts +7 -6
- package/src/panels/settings-menu.ts +799 -0
- package/src/panels/toolbar.ts +135 -15
- package/src/platforms/cloud.ts +20 -0
- package/src/platforms/devserver.ts +67 -33
- package/src/preview/preview-overlay.ts +226 -0
- package/src/services/ai-ask.ts +247 -0
- package/src/services/ai-import-tools.ts +494 -0
- package/src/services/ai-project-tools.ts +20 -26
- package/src/services/ai-system-prompt.ts +88 -2
- package/src/services/ai-turn-signal.ts +70 -0
- package/src/services/connection.ts +74 -0
- package/src/services/context-manager.ts +89 -0
- package/src/services/document-assistant.ts +45 -4
- package/src/services/import-client.ts +33 -6
- package/src/services/import-run.ts +158 -0
- package/src/services/import-seed.ts +120 -0
- package/src/services/jx-validate.ts +58 -6
- package/src/services/project-adoption.ts +71 -0
- package/src/services/references.ts +0 -0
- package/src/services/settings/definitions.ts +17 -0
- package/src/services/tool-executor.ts +34 -2
- package/src/settings/contexts-section.ts +45 -3
- package/src/settings/dependencies-editor.ts +43 -23
- package/src/settings/general-settings.ts +30 -12
- package/src/settings/preferences-dialog.ts +18 -41
- package/src/settings/preferences-sections.ts +57 -0
- package/src/settings/settings-document.ts +65 -9
- package/src/studio.ts +33 -6
- package/src/tabs/transact.ts +42 -19
- package/src/types.ts +103 -5
- package/src/ui/ai-model-picker.ts +162 -0
- package/src/ui/choice-field.ts +72 -0
- package/src/ui/layers.ts +83 -5
- package/src/utils/studio-utils.ts +22 -52
- package/styles/forced-colors.css +7 -0
- package/styles/overlays.css +256 -0
- package/styles/panels.css +13 -1
- package/styles/shell.css +111 -0
- package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
- package/dist/chunks/content-source-7fy9qrm0.js +0 -40
- package/dist/chunks/files-xcybanxe.js +0 -70
- package/dist/chunks/layers-panel-27essgkc.js +0 -60
- package/dist/chunks/publish-panel-09sy2bab.js +0 -22
- package/dist/chunks/quick-search-sefb61v8.js +0 -55
- package/dist/chunks/right-panel-9r32jd5j.js +0 -39
- package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
- package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
- package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
- package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
- package/dist/chunks/studio-c31yb4t6.js +0 -55
- package/src/canvas/site-style-css.ts +0 -117
|
@@ -29,6 +29,12 @@ interface BuildSystemPromptOptions {
|
|
|
29
29
|
* prompt-shape tests — advertises the same list it always did.
|
|
30
30
|
*/
|
|
31
31
|
treeEditable?: boolean | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Whether the platform can import a site. Defaults TRUE so this stays a pure function of what it
|
|
34
|
+
* is given — a caller that knows nothing about the platform gets the full list, and only
|
|
35
|
+
* `document-assistant.ts`, which does know, narrows it.
|
|
36
|
+
*/
|
|
37
|
+
canImport?: boolean | undefined;
|
|
32
38
|
/** Project-relative file paths for the inventory section (project modes; capped). */
|
|
33
39
|
fileInventory?: string[] | undefined;
|
|
34
40
|
}
|
|
@@ -52,13 +58,21 @@ const FILE_INVENTORY_CAP = 100;
|
|
|
52
58
|
* a weaker test than `structurallyEditable`, so a repeater template or `$switch` case was removable
|
|
53
59
|
* by the agent and not by the person.
|
|
54
60
|
*/
|
|
55
|
-
export type AiToolTier = "no-project" | "project" | "document" | "document-tree";
|
|
61
|
+
export type AiToolTier = "always" | "no-project" | "project" | "document" | "document-tree";
|
|
56
62
|
|
|
57
63
|
export interface AiToolInfo {
|
|
58
64
|
name: string;
|
|
59
65
|
tier: AiToolTier;
|
|
60
66
|
/** One-line signature + purpose shown in the system prompt's tool list. */
|
|
61
67
|
blurb: string;
|
|
68
|
+
/**
|
|
69
|
+
* A PAL member the tool needs, beyond whatever its tier requires.
|
|
70
|
+
*
|
|
71
|
+
* The tier answers "what is open"; this answers "can this backend do it at all". Cloud ships no
|
|
72
|
+
* `importSite`, so `import_site` must not be advertised there — and a tier cannot express that,
|
|
73
|
+
* because "no project is open" is exactly as true on cloud as anywhere else.
|
|
74
|
+
*/
|
|
75
|
+
capability?: "importSite";
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
/**
|
|
@@ -67,7 +81,27 @@ export interface AiToolInfo {
|
|
|
67
81
|
* list cannot drift (a test asserts the names match the registered tools).
|
|
68
82
|
*/
|
|
69
83
|
export const AI_TOOL_TIERS: AiToolInfo[] = [
|
|
84
|
+
// Always — a question is not gated on what happens to be open
|
|
85
|
+
{
|
|
86
|
+
name: "ask_user",
|
|
87
|
+
tier: "always",
|
|
88
|
+
blurb:
|
|
89
|
+
"ask_user(question, options?, context?) — pause and put a question to the user; the turn " +
|
|
90
|
+
"waits for their reply and resumes with it. Only for a judgement that is genuinely theirs.",
|
|
91
|
+
},
|
|
70
92
|
// Bootstrap (no project open)
|
|
93
|
+
{
|
|
94
|
+
name: "import_site",
|
|
95
|
+
tier: "no-project",
|
|
96
|
+
capability: "importSite",
|
|
97
|
+
blurb:
|
|
98
|
+
"import_site(url, directory?, depth?, maxPages?, aiComponents?, maxBreakpoints?, " +
|
|
99
|
+
"breakpointWidths?, breakpointRounding?) — clone a live website into a new Jx project and " +
|
|
100
|
+
"open it: pages, styles, assets, a shared layout and recurring components. The project opens " +
|
|
101
|
+
"as soon as the destination exists, so the user watches it fill up while the crawl runs. " +
|
|
102
|
+
"When the user came through the New Project Import form the destination and options are " +
|
|
103
|
+
"already settled — pass the url alone.",
|
|
104
|
+
},
|
|
71
105
|
{
|
|
72
106
|
name: "create_project",
|
|
73
107
|
tier: "no-project",
|
|
@@ -174,6 +208,8 @@ export const AI_TOOL_TIERS: AiToolInfo[] = [
|
|
|
174
208
|
export interface AiToolState {
|
|
175
209
|
hasProject: boolean;
|
|
176
210
|
hasDocument: boolean;
|
|
211
|
+
/** Whether the platform implements `importSite`. Cloud does not. */
|
|
212
|
+
canImport?: boolean;
|
|
177
213
|
/**
|
|
178
214
|
* The open document is an element tree the canvas is editing — `editor.kind === "canvas"`.
|
|
179
215
|
*
|
|
@@ -189,6 +225,9 @@ export interface AiToolState {
|
|
|
189
225
|
* tools need an active document (even in single-file mode without a project).
|
|
190
226
|
*/
|
|
191
227
|
export function tierActive(tier: AiToolTier, state: AiToolState): boolean {
|
|
228
|
+
if (tier === "always") {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
192
231
|
if (tier === "no-project") {
|
|
193
232
|
return !state.hasProject;
|
|
194
233
|
}
|
|
@@ -201,12 +240,56 @@ export function tierActive(tier: AiToolTier, state: AiToolState): boolean {
|
|
|
201
240
|
return state.hasDocument;
|
|
202
241
|
}
|
|
203
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Whether a tool is active: its tier, AND any capability it declares.
|
|
245
|
+
*
|
|
246
|
+
* The one predicate the prompt's tool list and the gating registry both run, which is the whole
|
|
247
|
+
* point of {@link AI_TOOL_TIERS} — a tool the model is told about and then refused is a surprise to
|
|
248
|
+
* it, and one it is refused without being told is invisible.
|
|
249
|
+
*
|
|
250
|
+
* @param {AiToolInfo} tool
|
|
251
|
+
* @param {AiToolState} state
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
*/
|
|
254
|
+
export function toolActive(tool: AiToolInfo, state: AiToolState): boolean {
|
|
255
|
+
if (tool.capability === "importSite" && state.canImport === false) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
return tierActive(tool.tier, state);
|
|
259
|
+
}
|
|
260
|
+
|
|
204
261
|
// ─── Jx Schema Reference (condensed) ────────────────────────────────────────
|
|
205
262
|
|
|
206
263
|
/**
|
|
207
264
|
* Condensed reference of Jx document structure rules. Included inline in the system prompt so the
|
|
208
265
|
* LLM understands the schema without consuming excessive tokens.
|
|
209
266
|
*/
|
|
267
|
+
/**
|
|
268
|
+
* When to stop and ask, and — more importantly — when not to.
|
|
269
|
+
*
|
|
270
|
+
* Rendered into the closing section of every prompt, because `ask_user` is an `always` tier tool.
|
|
271
|
+
* The prohibitions carry most of the weight: a tool that suspends a turn on a human is only worth
|
|
272
|
+
* having if the questions are ones a human actually wants. A model that asks what it could have
|
|
273
|
+
* read, or asks about a knob its answer cannot reach, trains the author to stop reading the
|
|
274
|
+
* questions.
|
|
275
|
+
*/
|
|
276
|
+
const ASKING_THE_USER = `## Asking the user
|
|
277
|
+
|
|
278
|
+
You can stop and ask with ask_user; the turn waits and resumes with the reply. Waiting on a person
|
|
279
|
+
is not free, so ask only when all three hold:
|
|
280
|
+
|
|
281
|
+
1. It is genuinely THEIR judgement — taste, priority, or intent. "Which of these pages matter?",
|
|
282
|
+
"keep their brand colours or restyle?", "is this page close enough?"
|
|
283
|
+
2. You could not answer it yourself with another tool. Read the file, list the directory, look at
|
|
284
|
+
the document. Asking what you could have discovered is the commonest way to waste the question.
|
|
285
|
+
3. You can ACT on any answer they give. Never ask about an option you have no way to change.
|
|
286
|
+
|
|
287
|
+
Ask one question at a time. Offer options when the sensible answers are a short list; the user can
|
|
288
|
+
always reply in their own words instead. If they decline, decide yourself and say what you chose.
|
|
289
|
+
|
|
290
|
+
Prefer doing to asking. On a vague prompt, make a defensible choice, do the work, and say what you
|
|
291
|
+
assumed — one good question beats five, and beats none only when it changes what you build.`;
|
|
292
|
+
|
|
210
293
|
const JX_SCHEMA_REFERENCE = `## Jx Document Format
|
|
211
294
|
|
|
212
295
|
A Jx document is a JSON object. Key top-level fields:
|
|
@@ -560,6 +643,7 @@ export function buildSystemPrompt({
|
|
|
560
643
|
projectRoot,
|
|
561
644
|
hasProject = Boolean(projectRoot),
|
|
562
645
|
treeEditable = true,
|
|
646
|
+
canImport = true,
|
|
563
647
|
fileInventory,
|
|
564
648
|
}: BuildSystemPromptOptions = {}) {
|
|
565
649
|
const hasDocument = Boolean(document);
|
|
@@ -568,7 +652,7 @@ export function buildSystemPrompt({
|
|
|
568
652
|
// The list the model is TOLD about and the list the gate will honour are the same filter, so a
|
|
569
653
|
// Refusal is never a surprise to it.
|
|
570
654
|
const toolList = AI_TOOL_TIERS.filter((t) =>
|
|
571
|
-
|
|
655
|
+
toolActive(t, { canImport, hasDocument, hasProject, treeEditable }),
|
|
572
656
|
)
|
|
573
657
|
.map((t) => `- ${t.blurb}`)
|
|
574
658
|
.join("\n");
|
|
@@ -608,6 +692,8 @@ Your document edits apply to the live canvas immediately and are individually un
|
|
|
608
692
|
|
|
609
693
|
const closing = `You have a limited number of tool-call rounds per message. On vague or open-ended prompts ("make it look better", "improve this"), prefer a small number of targeted, high-impact changes over attempting to rebuild the entire page. Explain what you changed and offer to do more.
|
|
610
694
|
|
|
695
|
+
${ASKING_THE_USER}
|
|
696
|
+
|
|
611
697
|
Be concise. Don't explain what Jx is unless asked. Just build.`;
|
|
612
698
|
|
|
613
699
|
const sections = [`${role}\n\n${workflow}\n\n${closing}`];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ai-turn-signal.ts — what the agent loop knows that a tool needs and cannot be passed.
|
|
3
|
+
*
|
|
4
|
+
* `ToolRegistry.execute(name, args)` takes no signal, and its signature is fixed in the published
|
|
5
|
+
* `@jxsuite/ai`. So `runAgentLoop` could hand its signal to `streamChat` and to nothing else: a tool
|
|
6
|
+
* that ran for minutes (an import) could not be cancelled, and a tool that waits for a human
|
|
7
|
+
* (`ask_user`) would have hung on Stop forever, because the promise the loop is awaiting has no
|
|
8
|
+
* other way to learn the turn is over.
|
|
9
|
+
*
|
|
10
|
+
* An ambient slot rather than a parameter, for the same reason `services/ai-writes.ts` is one: the
|
|
11
|
+
* loop already brackets each turn with `beginTurn`/`endTurn` in a try/finally, and this rides that
|
|
12
|
+
* bracket. Only one turn runs at a time — `sendMessage` and `beginAssistantTurn` are both no-ops
|
|
13
|
+
* while streaming — so a module-level slot is the whole of the state.
|
|
14
|
+
*
|
|
15
|
+
* @license MIT
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
let _signal: AbortSignal | undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Publish the turn's signal for the tools it will run. Called by `runAgentLoop`.
|
|
22
|
+
*
|
|
23
|
+
* @param {AbortSignal} [signal] - The turn's signal; `undefined` leaves tools uncancellable, which
|
|
24
|
+
* is what a caller that passed no signal asked for.
|
|
25
|
+
*/
|
|
26
|
+
export function beginTurnSignal(signal?: AbortSignal): void {
|
|
27
|
+
_signal = signal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The running turn's signal, or `undefined` outside a turn.
|
|
32
|
+
*
|
|
33
|
+
* A tool must treat `undefined` as "not cancellable" rather than as an error: the evals runner and
|
|
34
|
+
* several tests drive the loop with no signal at all.
|
|
35
|
+
*
|
|
36
|
+
* @returns {AbortSignal | undefined}
|
|
37
|
+
*/
|
|
38
|
+
export function turnSignal(): AbortSignal | undefined {
|
|
39
|
+
return _signal;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Clear the slots. Called from `runAgentLoop`'s `finally`, so it runs on every exit path. */
|
|
43
|
+
export function endTurnSignal(): void {
|
|
44
|
+
_signal = undefined;
|
|
45
|
+
_toolCallId = "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let _toolCallId = "";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Publish the id of the call about to execute. Called by `runAgentLoop` per tool call.
|
|
52
|
+
*
|
|
53
|
+
* A tool that outlives its own execution — `ask_user`, whose question stays on screen until a human
|
|
54
|
+
* answers — needs the id to join what it registered to the chip the transcript is drawing. Without
|
|
55
|
+
* it a restored, permanently unanswered question is indistinguishable from the live one.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} id
|
|
58
|
+
*/
|
|
59
|
+
export function beginToolCall(id: string): void {
|
|
60
|
+
_toolCallId = id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The id of the tool call currently executing, or `""` outside one.
|
|
65
|
+
*
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function currentToolCallId(): string {
|
|
69
|
+
return _toolCallId;
|
|
70
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backend connection, said out loud.
|
|
3
|
+
*
|
|
4
|
+
* Only one launcher can lose a backend mid-session: the chromium desktop shell holds a single
|
|
5
|
+
* long-lived WebSocket to its project server. When that socket died, every later call returned a
|
|
6
|
+
* promise that NEVER SETTLED — `WebSocket.send()` on a closed socket discards the frame instead of
|
|
7
|
+
* throwing, so nothing rejected, nothing resolved, and the visible symptom was **Open Project**
|
|
8
|
+
* doing nothing at all. No toast, no log, no failed state. Restarting Studio was the only
|
|
9
|
+
* recovery.
|
|
10
|
+
*
|
|
11
|
+
* The transport now rejects those calls, and this is the other half: a person needs to be told
|
|
12
|
+
* which of "it is broken" and "it is coming back" they are in. One keyed problem so a flapping
|
|
13
|
+
* connection cannot stack up a column of identical rows, dismissed on recovery so a fixed thing
|
|
14
|
+
* does not stay on the Problems list (§16.1's third tier, and §16.6's rule that a record is retired
|
|
15
|
+
* by whoever owns its life).
|
|
16
|
+
*
|
|
17
|
+
* A platform without `subscribeConnection` — the dev server, electrobun — installs nothing. There
|
|
18
|
+
* is no connection to lose there, and a notice about one would be a lie.
|
|
19
|
+
*
|
|
20
|
+
* @docs studio/interface/problems-and-progress
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { getPlatform } from "../platform";
|
|
24
|
+
import { dismiss, notify } from "./notify";
|
|
25
|
+
import type { Notification } from "./notify";
|
|
26
|
+
|
|
27
|
+
/** One key for every connection notice, so a flapping socket replaces rather than accumulates. */
|
|
28
|
+
const CONNECTION_KEY = "platform:connection";
|
|
29
|
+
|
|
30
|
+
/** The outstanding "we are disconnected" record, so recovery can retire it. */
|
|
31
|
+
let outstanding: Notification | null = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start reporting the backend connection. Returns an unsubscribe, or null on a platform that has no
|
|
35
|
+
* connection to report.
|
|
36
|
+
*
|
|
37
|
+
* @returns {(() => void) | null}
|
|
38
|
+
*/
|
|
39
|
+
export function watchConnection(): (() => void) | null {
|
|
40
|
+
const subscribe = getPlatform().subscribeConnection;
|
|
41
|
+
if (!subscribe) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const unsubscribe = subscribe(({ online, reason }) => {
|
|
45
|
+
if (online) {
|
|
46
|
+
if (outstanding) {
|
|
47
|
+
dismiss(outstanding.id);
|
|
48
|
+
outstanding = null;
|
|
49
|
+
notify.success("Reconnected to the Jx backend.", { source: "Backend" });
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
/* Already reported. A reconnect attempt failing is the same state, not a new one. */
|
|
54
|
+
if (outstanding) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
outstanding = notify.error(reason ?? "Lost connection to the Jx backend.", {
|
|
58
|
+
detail:
|
|
59
|
+
"Editing continues, but anything that reads or writes files will fail until it comes " +
|
|
60
|
+
"back. Studio is retrying.",
|
|
61
|
+
key: CONNECTION_KEY,
|
|
62
|
+
source: "Backend",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
/* Unsubscribing retires the notice with it: the watcher is the only thing that would ever have
|
|
66
|
+
dismissed it, so leaving it on the Problems list would leave a report nobody can clear. */
|
|
67
|
+
return () => {
|
|
68
|
+
unsubscribe();
|
|
69
|
+
if (outstanding) {
|
|
70
|
+
dismiss(outstanding.id);
|
|
71
|
+
outstanding = null;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -198,3 +198,92 @@ export function trimContext(
|
|
|
198
198
|
|
|
199
199
|
return { estimatedTokens: newTotal, droppedCount };
|
|
200
200
|
}
|
|
201
|
+
|
|
202
|
+
// ─── Orphaned tool calls ────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/** What a synthesized result says, so a reader and the model see the same reason. */
|
|
205
|
+
const UNANSWERED_TOOL_RESULT = JSON.stringify({
|
|
206
|
+
success: false,
|
|
207
|
+
error:
|
|
208
|
+
"This tool call was never completed — the session was reloaded or the history was trimmed.",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Repair tool-call pairing in place, and report how many messages it touched.
|
|
213
|
+
*
|
|
214
|
+
* `toMessagesArray()` serializes the array verbatim: an assistant message with `tool_calls` becomes
|
|
215
|
+
* a `tool_calls` request, and a `tool` message becomes a reply carrying `tool_call_id`. Providers
|
|
216
|
+
* require the two to come in pairs, and **three ordinary things break the pairing**:
|
|
217
|
+
*
|
|
218
|
+
* 1. `saveSession` persists `msgs.slice(-MAX_PERSIST_MESSAGES)` and `restoreChat` pushes the result
|
|
219
|
+
* back, so a session longer than the cap can be restored starting mid-pair.
|
|
220
|
+
* 2. {@link trimContext} splices from the front for the token budget, with no more regard for pairs.
|
|
221
|
+
* 3. A turn that stopped while a tool was still running — which `ask_user` makes routine, because its
|
|
222
|
+
* tool is _designed_ to be outstanding — leaves a request with no reply.
|
|
223
|
+
*
|
|
224
|
+
* Each of those produced a 400 on the NEXT send, from a conversation the user could see was fine.
|
|
225
|
+
* So the repair belongs on the send path, after trimming, rather than at any one of the three
|
|
226
|
+
* sites: a request with no reply gets a synthesized failure (keeping the assistant's text, which
|
|
227
|
+
* dropping the message would lose), and a reply with no request is dropped.
|
|
228
|
+
*
|
|
229
|
+
* @param {ReturnType<typeof createChatState>} chatState
|
|
230
|
+
* @returns {{ sealed: number; dropped: number }}
|
|
231
|
+
*/
|
|
232
|
+
export function pruneOrphanToolMessages(chatState: ReturnType<typeof createChatState>): {
|
|
233
|
+
sealed: number;
|
|
234
|
+
dropped: number;
|
|
235
|
+
} {
|
|
236
|
+
const { messages } = chatState;
|
|
237
|
+
|
|
238
|
+
const requested = new Set<string>();
|
|
239
|
+
const answered = new Set<string>();
|
|
240
|
+
for (const msg of messages) {
|
|
241
|
+
if (msg.role === "assistant" && msg.toolCalls) {
|
|
242
|
+
for (const call of msg.toolCalls) {
|
|
243
|
+
requested.add(call.id);
|
|
244
|
+
}
|
|
245
|
+
} else if (msg.role === "tool" && msg.toolCallId) {
|
|
246
|
+
answered.add(msg.toolCallId);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Replies with no request, and replies with no id at all — neither can be serialized into a
|
|
251
|
+
// Well-formed pair, and a reply is the half that carries no author text worth keeping.
|
|
252
|
+
let dropped = 0;
|
|
253
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
254
|
+
const msg = messages[i]!;
|
|
255
|
+
if (msg.role === "tool" && (!msg.toolCallId || !requested.has(msg.toolCallId))) {
|
|
256
|
+
messages.splice(i, 1);
|
|
257
|
+
dropped += 1;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* Requests with no reply. Walk backwards so each insertion is past the indices still to visit,
|
|
262
|
+
and seal immediately after the requesting message — `toMessagesArray` emits in array order, so
|
|
263
|
+
that is the only position the pair is adjacent in. */
|
|
264
|
+
let sealed = 0;
|
|
265
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
266
|
+
const msg = messages[i]!;
|
|
267
|
+
if (msg.role !== "assistant" || !msg.toolCalls) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const missing = msg.toolCalls.filter((call) => !answered.has(call.id));
|
|
271
|
+
if (missing.length === 0) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
messages.splice(
|
|
275
|
+
i + 1,
|
|
276
|
+
0,
|
|
277
|
+
...missing.map((call, n) => ({
|
|
278
|
+
content: UNANSWERED_TOOL_RESULT,
|
|
279
|
+
id: `sealed_${call.id}_${n}`,
|
|
280
|
+
role: "tool" as const,
|
|
281
|
+
timestamp: msg.timestamp,
|
|
282
|
+
toolCallId: call.id,
|
|
283
|
+
})),
|
|
284
|
+
);
|
|
285
|
+
sealed += missing.length;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { dropped, sealed };
|
|
289
|
+
}
|
|
@@ -20,15 +20,18 @@ import type { Tab } from "../tabs/tab";
|
|
|
20
20
|
import { componentRegistry } from "../files/components";
|
|
21
21
|
import { activeRegistry } from "../commands/active-registry";
|
|
22
22
|
import { registerAiTools } from "./ai-tools";
|
|
23
|
+
import { cancelAsk, registerAskTool, resetAsk } from "./ai-ask";
|
|
23
24
|
import { registerProjectTools } from "./ai-project-tools";
|
|
25
|
+
import { registerImportTools, resetImportGuard } from "./ai-import-tools";
|
|
24
26
|
import { createGatedToolRegistry } from "./gated-registry";
|
|
25
27
|
import type { ToolAvailability } from "./gated-registry";
|
|
26
28
|
import { adoptProject } from "./project-adoption";
|
|
29
|
+
import { abortImportRun, resetImportRuns } from "./import-run";
|
|
27
30
|
import { runAgentLoop } from "./tool-executor";
|
|
28
|
-
import { AI_TOOL_TIERS, buildSystemPrompt,
|
|
31
|
+
import { AI_TOOL_TIERS, buildSystemPrompt, toolActive } from "./ai-system-prompt";
|
|
29
32
|
import { getBaseUrl, getOpenAiKey } from "./ai-settings";
|
|
30
33
|
import { preferredModel } from "./ai-models";
|
|
31
|
-
import { trimContext } from "./context-manager";
|
|
34
|
+
import { pruneOrphanToolMessages, trimContext } from "./context-manager";
|
|
32
35
|
import { renderCheck } from "./render-critic";
|
|
33
36
|
import { openFileInTab, reloadFileInTab } from "../files/files";
|
|
34
37
|
import { refreshExtensionUi } from "../format/format-host";
|
|
@@ -77,6 +80,8 @@ export function createDocumentAssistant() {
|
|
|
77
80
|
};
|
|
78
81
|
|
|
79
82
|
const innerRegistry = createToolRegistry();
|
|
83
|
+
// Ungated: a question is not a function of what happens to be open.
|
|
84
|
+
registerAskTool(innerRegistry);
|
|
80
85
|
registerAiTools(innerRegistry, {
|
|
81
86
|
getTab: () => activeTab.value,
|
|
82
87
|
saveFile: async (relPath: string, content: string) => {
|
|
@@ -91,6 +96,17 @@ export function createDocumentAssistant() {
|
|
|
91
96
|
findOpenTab,
|
|
92
97
|
reloadTab: reloadFileInTab,
|
|
93
98
|
});
|
|
99
|
+
registerImportTools(innerRegistry, {
|
|
100
|
+
adoptProject,
|
|
101
|
+
getTab: () => activeTab.value,
|
|
102
|
+
// Same re-keying as `create_project`: an import bootstraps a project, so the bootstrap
|
|
103
|
+
// Conversation has to follow it out of the unscoped store.
|
|
104
|
+
onProjectAdopted: (root: string) => {
|
|
105
|
+
if (sessionId) {
|
|
106
|
+
sessionStore.moveSession("", root, sessionId);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
94
110
|
registerProjectTools(innerRegistry, {
|
|
95
111
|
getTab: () => activeTab.value,
|
|
96
112
|
renderCheck: renderCheck as (
|
|
@@ -126,6 +142,9 @@ export function createDocumentAssistant() {
|
|
|
126
142
|
* every agent-loop round, so a mid-loop create_project unlocks the higher tiers immediately.
|
|
127
143
|
*/
|
|
128
144
|
const TIER_REQUIREMENTS = {
|
|
145
|
+
/* Never refused, so the sentence is unreachable — it exists because the map is derived from
|
|
146
|
+
the tier union, and a tier without one would not compile. */
|
|
147
|
+
always: "nothing",
|
|
129
148
|
"no-project": "no project to be open (it bootstraps one)",
|
|
130
149
|
project: "an open project",
|
|
131
150
|
document: "an open document (use open_document first)",
|
|
@@ -155,12 +174,18 @@ export function createDocumentAssistant() {
|
|
|
155
174
|
t.name,
|
|
156
175
|
{
|
|
157
176
|
when: () =>
|
|
158
|
-
|
|
177
|
+
toolActive(t, {
|
|
178
|
+
/* The platform's own answer, read live rather than captured: the assistant is
|
|
179
|
+
constructed at module load, before a platform is registered in some hosts. */
|
|
180
|
+
canImport: Boolean(getPlatform().importSite),
|
|
159
181
|
hasDocument: Boolean(activeTab.value),
|
|
160
182
|
hasProject: Boolean(workspace.projectRoot),
|
|
161
183
|
treeEditable: treeEditable(),
|
|
162
184
|
}),
|
|
163
|
-
requires:
|
|
185
|
+
requires:
|
|
186
|
+
t.capability === "importSite"
|
|
187
|
+
? "a platform with a site-import backend"
|
|
188
|
+
: TIER_REQUIREMENTS[t.tier],
|
|
164
189
|
},
|
|
165
190
|
]),
|
|
166
191
|
);
|
|
@@ -182,6 +207,7 @@ export function createDocumentAssistant() {
|
|
|
182
207
|
components: componentRegistry.length > 0 ? componentRegistry : undefined,
|
|
183
208
|
projectRoot: workspace.projectRoot || undefined,
|
|
184
209
|
hasProject: Boolean(workspace.projectRoot),
|
|
210
|
+
canImport: Boolean(getPlatform().importSite),
|
|
185
211
|
...(inventory && inventory.length > 0 ? { fileInventory: inventory } : {}),
|
|
186
212
|
});
|
|
187
213
|
}
|
|
@@ -205,6 +231,11 @@ export function createDocumentAssistant() {
|
|
|
205
231
|
chatState.setTokenCount(trimmed.estimatedTokens);
|
|
206
232
|
}
|
|
207
233
|
|
|
234
|
+
/* AFTER the trim, not before: the trim splices from the front and is itself one of the three
|
|
235
|
+
ways a tool_calls request loses its reply (see pruneOrphanToolMessages). Repairing first
|
|
236
|
+
would leave the very pair the trim then broke. */
|
|
237
|
+
pruneOrphanToolMessages(chatState);
|
|
238
|
+
|
|
208
239
|
// Persist after trimming so the saved history reflects what's actually sent.
|
|
209
240
|
persistChat();
|
|
210
241
|
|
|
@@ -248,11 +279,19 @@ export function createDocumentAssistant() {
|
|
|
248
279
|
|
|
249
280
|
function stop() {
|
|
250
281
|
controller?.abort();
|
|
282
|
+
/* The abort settles an outstanding question through the turn signal, but only when the turn
|
|
283
|
+
HAS one — the evals runner and several tests drive the loop without. Settling here as well
|
|
284
|
+
is what makes Stop terminal in every case rather than in most of them. */
|
|
285
|
+
cancelAsk();
|
|
286
|
+
abortImportRun();
|
|
251
287
|
chatState.cancelStream();
|
|
252
288
|
}
|
|
253
289
|
|
|
254
290
|
function newChat() {
|
|
255
291
|
stop();
|
|
292
|
+
resetAsk();
|
|
293
|
+
resetImportRuns();
|
|
294
|
+
resetImportGuard();
|
|
256
295
|
chatState.clearChat();
|
|
257
296
|
sessionId = null;
|
|
258
297
|
sessionStore.setActiveSession(projectRoot(), null);
|
|
@@ -272,6 +311,7 @@ export function createDocumentAssistant() {
|
|
|
272
311
|
return;
|
|
273
312
|
}
|
|
274
313
|
stop();
|
|
314
|
+
resetAsk();
|
|
275
315
|
chatState.clearChat();
|
|
276
316
|
pushRestoredMessages(msgs);
|
|
277
317
|
sessionId = id;
|
|
@@ -283,6 +323,7 @@ export function createDocumentAssistant() {
|
|
|
283
323
|
sessionStore.deleteSession(projectRoot(), id);
|
|
284
324
|
if (sessionId === id) {
|
|
285
325
|
stop();
|
|
326
|
+
resetAsk();
|
|
286
327
|
chatState.clearChat();
|
|
287
328
|
sessionId = null;
|
|
288
329
|
}
|
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
* streaming fetch to some URL" — the dev server posts to its own origin, the desktop platforms post
|
|
4
4
|
* to their token-gated loopback servers — so the request/parse/settle logic lives here once.
|
|
5
5
|
*
|
|
6
|
-
* The endpoint emits one JSON object per line: `progress` lines (forwarded to onProgress),
|
|
7
|
-
* `
|
|
6
|
+
* The endpoint emits one JSON object per line: `progress` lines (forwarded to onProgress), a
|
|
7
|
+
* `ready` line the moment the destination is an openable project (forwarded to onReady),
|
|
8
|
+
* `heartbeat` keep-alives (ignored), and a terminal `done` ({root, config, result}) or `error`
|
|
9
|
+
* line. `result` is optional — a backend that does not send one is not broken, and every caller
|
|
10
|
+
* treats its absence as "the run happened, and it did not say what it found". `ready` is optional
|
|
11
|
+
* for the same reason: an older backend simply never sends one, and the caller opens the project at
|
|
12
|
+
* the end as it always did.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
ImportProgressEvent,
|
|
17
|
+
ImportReadyEvent,
|
|
18
|
+
ImportSiteOptions,
|
|
19
|
+
ImportSiteSummary,
|
|
20
|
+
} from "../types";
|
|
11
21
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
12
22
|
|
|
13
23
|
/**
|
|
@@ -27,6 +37,7 @@ interface StreamLine {
|
|
|
27
37
|
total?: number;
|
|
28
38
|
root?: string;
|
|
29
39
|
config?: ProjectConfig;
|
|
40
|
+
result?: ImportSiteSummary;
|
|
30
41
|
error?: string;
|
|
31
42
|
}
|
|
32
43
|
|
|
@@ -37,13 +48,17 @@ interface StreamLine {
|
|
|
37
48
|
* @param {ImportSiteOptions} opts — the import request; `apiKey`/`baseUrl` travel as headers
|
|
38
49
|
* @param {(evt: ImportProgressEvent) => void} onProgress
|
|
39
50
|
* @param {AbortSignal} [signal]
|
|
51
|
+
* @param {(evt: ImportReadyEvent) => void} [onReady] — fires once, as soon as the destination holds
|
|
52
|
+
* an openable project. Minutes before `done` on a real crawl, which is the whole reason it
|
|
53
|
+
* exists.
|
|
40
54
|
*/
|
|
41
55
|
export async function streamImport(
|
|
42
56
|
endpoint: string,
|
|
43
57
|
opts: ImportSiteOptions,
|
|
44
58
|
onProgress: (evt: ImportProgressEvent) => void,
|
|
45
59
|
signal?: AbortSignal,
|
|
46
|
-
|
|
60
|
+
onReady?: (evt: ImportReadyEvent) => void,
|
|
61
|
+
): Promise<{ root: string; config: ProjectConfig; result?: ImportSiteSummary }> {
|
|
47
62
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
48
63
|
if (opts.apiKey) {
|
|
49
64
|
headers["X-Api-Key"] = opts.apiKey;
|
|
@@ -59,7 +74,13 @@ export async function streamImport(
|
|
|
59
74
|
depth: opts.depth,
|
|
60
75
|
maxPages: opts.maxPages,
|
|
61
76
|
aiComponents: opts.aiComponents,
|
|
77
|
+
...(opts.breakpoints === undefined ? {} : { breakpoints: opts.breakpoints }),
|
|
62
78
|
...(opts.model === undefined ? {} : { aiModel: opts.model }),
|
|
79
|
+
...(opts.verify === undefined ? {} : { verify: opts.verify }),
|
|
80
|
+
...(opts.verifyThreshold === undefined ? {} : { verifyThreshold: opts.verifyThreshold }),
|
|
81
|
+
...(opts.verifyMinFidelity === undefined
|
|
82
|
+
? {}
|
|
83
|
+
: { verifyMinFidelity: opts.verifyMinFidelity }),
|
|
63
84
|
}),
|
|
64
85
|
headers,
|
|
65
86
|
method: "POST",
|
|
@@ -85,7 +106,7 @@ export async function streamImport(
|
|
|
85
106
|
const reader = res.body.getReader();
|
|
86
107
|
const decoder = new TextDecoder();
|
|
87
108
|
let buffer = "";
|
|
88
|
-
let result: { root: string; config: ProjectConfig } | null = null;
|
|
109
|
+
let result: { root: string; config: ProjectConfig; result?: ImportSiteSummary } | null = null;
|
|
89
110
|
/*
|
|
90
111
|
* Lines the parser could not read. Tolerating one is right — an import that ran for two minutes
|
|
91
112
|
* should not die on a garbled progress line — but doing it *silently* is not: the import then
|
|
@@ -113,8 +134,14 @@ export async function streamImport(
|
|
|
113
134
|
...(parsed.current === undefined ? {} : { current: parsed.current }),
|
|
114
135
|
...(parsed.total === undefined ? {} : { total: parsed.total }),
|
|
115
136
|
});
|
|
137
|
+
} else if (parsed.type === "ready" && parsed.root !== undefined) {
|
|
138
|
+
onReady?.({ root: parsed.root });
|
|
116
139
|
} else if (parsed.type === "done" && parsed.root !== undefined && parsed.config) {
|
|
117
|
-
result = {
|
|
140
|
+
result = {
|
|
141
|
+
root: parsed.root,
|
|
142
|
+
config: parsed.config,
|
|
143
|
+
...(parsed.result === undefined ? {} : { result: parsed.result }),
|
|
144
|
+
};
|
|
118
145
|
} else if (parsed.type === "error") {
|
|
119
146
|
throw new Error(parsed.error || "Import failed");
|
|
120
147
|
}
|