@jxsuite/studio 0.34.0 → 0.35.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/dist/iframe-entry.js +9 -1
- package/dist/iframe-entry.js.map +3 -3
- package/dist/studio.js +21771 -7715
- package/dist/studio.js.map +258 -24
- package/package.json +5 -5
- package/src/files/files.ts +3 -0
- package/src/new-project/design-fields.ts +260 -0
- package/src/new-project/import-tab.ts +322 -0
- package/src/new-project/new-project-modal.ts +472 -89
- package/src/new-project/templates.ts +61 -0
- package/src/packages/ensure-deps.ts +6 -0
- package/src/packages/pull-package-sync.ts +339 -0
- package/src/panels/ai-chat/attached-context.ts +49 -0
- package/src/panels/ai-chat/chat-markdown.ts +38 -0
- package/src/panels/ai-chat/chat-view.ts +250 -0
- package/src/panels/ai-chat/composer.ts +315 -0
- package/src/panels/ai-chat/sessions-view.ts +98 -0
- package/src/panels/ai-panel.ts +189 -455
- package/src/panels/drag-ghost.ts +1 -1
- package/src/panels/git-panel.ts +16 -1
- package/src/panels/right-panel.ts +21 -5
- package/src/platforms/devserver.ts +30 -1
- package/src/services/agent-seed.ts +91 -0
- package/src/services/ai-models.ts +64 -0
- package/src/services/ai-session-store.ts +250 -0
- package/src/services/ai-settings.ts +5 -0
- package/src/services/automation.ts +140 -0
- package/src/services/document-assistant.ts +98 -38
- package/src/services/import-client.ts +134 -0
- package/src/services/settings-store.ts +93 -0
- package/src/studio.ts +25 -0
- package/src/types.ts +84 -0
- package/src/ui/ai-credentials-form.ts +205 -0
- package/src/ui/spectrum.ts +8 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Automation hook — a small, gated scripting surface for driving Studio from a browser-automation
|
|
4
|
+
* runner (see scripts/screenshots/ at the repo root). Installed as `window.__jxAutomation` only
|
|
5
|
+
* when the page URL carries `?automation=1`; without the flag production behavior is untouched.
|
|
6
|
+
*
|
|
7
|
+
* The API mutates the same reactive state the real UI mutates (canvas mode, activity tab,
|
|
8
|
+
* selection, inspector tab, function editor) instead of clicking through Spectrum shadow DOM, so
|
|
9
|
+
* shot definitions stay stable across chrome refactors.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { renderOnly, updateUi } from "../store";
|
|
13
|
+
import { activeTab, workspace } from "../workspace/workspace";
|
|
14
|
+
import { applyPanelCollapse, view } from "../view";
|
|
15
|
+
import type { JxPath } from "../state";
|
|
16
|
+
|
|
17
|
+
/** Callbacks injected from studio.ts (module-local helpers or imports kept out of this module). */
|
|
18
|
+
export interface AutomationDeps {
|
|
19
|
+
getCanvasMode: () => string;
|
|
20
|
+
openBrowseModal: () => void;
|
|
21
|
+
openNewProjectModal: () => void;
|
|
22
|
+
render: () => void;
|
|
23
|
+
renderActivityBar: () => void;
|
|
24
|
+
setCanvasMode: (mode: string) => void;
|
|
25
|
+
statusMessage: (text: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AutomationApi {
|
|
29
|
+
editDef: (defName: string) => void;
|
|
30
|
+
editFunction: (path: JxPath, eventKey: string) => void;
|
|
31
|
+
openBrowse: () => void;
|
|
32
|
+
openNewProject: () => void;
|
|
33
|
+
getState: () => {
|
|
34
|
+
activeTabId: string | null;
|
|
35
|
+
canvasMode: string;
|
|
36
|
+
canvasStatus: string | null;
|
|
37
|
+
leftTab: string;
|
|
38
|
+
};
|
|
39
|
+
select: (path: JxPath | null) => void;
|
|
40
|
+
setActivity: (tab: string) => void;
|
|
41
|
+
setCanvasMode: (mode: string) => void;
|
|
42
|
+
setRightTab: (tab: string) => void;
|
|
43
|
+
setStatus: (text: string) => void;
|
|
44
|
+
setTheme: (color: string) => void;
|
|
45
|
+
setZoom: (zoom: number) => void;
|
|
46
|
+
waitForCanvasReady: (timeoutMs?: number) => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The hook is opt-in per page load: only `?automation=1` installs it. */
|
|
50
|
+
export function shouldInstallAutomation(search: string): boolean {
|
|
51
|
+
return new URLSearchParams(search).get("automation") === "1";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createAutomationApi(deps: AutomationDeps): AutomationApi {
|
|
55
|
+
return {
|
|
56
|
+
editDef(defName: string) {
|
|
57
|
+
updateUi("editingFunction", { defName, type: "def" });
|
|
58
|
+
deps.render();
|
|
59
|
+
},
|
|
60
|
+
editFunction(path: JxPath, eventKey: string) {
|
|
61
|
+
updateUi("editingFunction", { eventKey, path, type: "event" });
|
|
62
|
+
deps.render();
|
|
63
|
+
},
|
|
64
|
+
openBrowse() {
|
|
65
|
+
deps.openBrowseModal();
|
|
66
|
+
},
|
|
67
|
+
openNewProject() {
|
|
68
|
+
deps.openNewProjectModal();
|
|
69
|
+
},
|
|
70
|
+
getState() {
|
|
71
|
+
return {
|
|
72
|
+
activeTabId: workspace.activeTabId,
|
|
73
|
+
canvasMode: deps.getCanvasMode(),
|
|
74
|
+
canvasStatus: activeTab.value?.session.canvas.status ?? null,
|
|
75
|
+
leftTab: view.leftTab,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
select(path: JxPath | null) {
|
|
79
|
+
const tab = activeTab.value;
|
|
80
|
+
if (tab) {
|
|
81
|
+
tab.session.selection = path;
|
|
82
|
+
}
|
|
83
|
+
deps.render();
|
|
84
|
+
},
|
|
85
|
+
setActivity(tab: string) {
|
|
86
|
+
view.leftTab = tab;
|
|
87
|
+
view.leftPanelCollapsed = false;
|
|
88
|
+
applyPanelCollapse();
|
|
89
|
+
renderOnly("leftPanel");
|
|
90
|
+
deps.renderActivityBar();
|
|
91
|
+
},
|
|
92
|
+
setCanvasMode(mode: string) {
|
|
93
|
+
deps.setCanvasMode(mode);
|
|
94
|
+
deps.render();
|
|
95
|
+
},
|
|
96
|
+
setRightTab(tab: string) {
|
|
97
|
+
updateUi("rightTab", tab);
|
|
98
|
+
deps.render();
|
|
99
|
+
},
|
|
100
|
+
setStatus(text: string) {
|
|
101
|
+
deps.statusMessage(text);
|
|
102
|
+
},
|
|
103
|
+
setTheme(color: string) {
|
|
104
|
+
document.querySelector("sp-theme")?.setAttribute("color", color);
|
|
105
|
+
},
|
|
106
|
+
setZoom(zoom: number) {
|
|
107
|
+
updateUi("zoom", zoom);
|
|
108
|
+
deps.render();
|
|
109
|
+
},
|
|
110
|
+
waitForCanvasReady(timeoutMs = 30_000) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const started = Date.now();
|
|
113
|
+
const tick = () => {
|
|
114
|
+
if (activeTab.value?.session.canvas.status === "ready") {
|
|
115
|
+
resolve();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (Date.now() - started > timeoutMs) {
|
|
119
|
+
reject(new Error(`canvas not ready after ${timeoutMs}ms`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
setTimeout(tick, 50);
|
|
123
|
+
};
|
|
124
|
+
tick();
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Install the hook on globalThis when the current URL opts in. Returns whether it installed, so
|
|
132
|
+
* callers (and tests) can assert the gate.
|
|
133
|
+
*/
|
|
134
|
+
export function installAutomationHook(deps: AutomationDeps): boolean {
|
|
135
|
+
if (!shouldInstallAutomation(location.search)) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
(globalThis as Record<string, unknown>).__jxAutomation = createAutomationApi(deps);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
@@ -22,20 +22,16 @@ import { getBaseUrl, getModel, getOpenAiKey } from "./ai-settings";
|
|
|
22
22
|
import { trimContext } from "./context-manager";
|
|
23
23
|
import { renderCheck } from "./render-critic";
|
|
24
24
|
import { openFileInTab } from "../files/files";
|
|
25
|
-
|
|
26
|
-
const PERSIST_KEY_PREFIX = "jx-ai-chat-history";
|
|
27
|
-
const MAX_PERSIST_MESSAGES = 50;
|
|
25
|
+
import * as sessionStore from "./ai-session-store";
|
|
28
26
|
|
|
29
27
|
/**
|
|
30
|
-
* Project
|
|
31
|
-
* projects
|
|
28
|
+
* Project root scoping the session store (ADR §11.5 / §14.2 — conversations don't bleed across
|
|
29
|
+
* projects). Falls back to a shared unscoped store when no project is open.
|
|
32
30
|
*
|
|
33
31
|
* @returns {string}
|
|
34
32
|
*/
|
|
35
|
-
function
|
|
36
|
-
return workspace.projectRoot
|
|
37
|
-
? `${PERSIST_KEY_PREFIX}:${workspace.projectRoot}`
|
|
38
|
-
: PERSIST_KEY_PREFIX;
|
|
33
|
+
function projectRoot() {
|
|
34
|
+
return workspace.projectRoot || "";
|
|
39
35
|
}
|
|
40
36
|
|
|
41
37
|
/**
|
|
@@ -46,6 +42,10 @@ function persistKey() {
|
|
|
46
42
|
* sendMessage: (text: string) => Promise<void>;
|
|
47
43
|
* stop: () => void;
|
|
48
44
|
* newChat: () => void;
|
|
45
|
+
* listSessions: () => import("./ai-session-store").SessionMeta[];
|
|
46
|
+
* openSession: (id: string) => void;
|
|
47
|
+
* deleteSession: (id: string) => void;
|
|
48
|
+
* activeSessionId: () => string | null;
|
|
49
49
|
* }}
|
|
50
50
|
*/
|
|
51
51
|
export function createDocumentAssistant() {
|
|
@@ -69,6 +69,9 @@ export function createDocumentAssistant() {
|
|
|
69
69
|
|
|
70
70
|
let controller: AbortController | null = null;
|
|
71
71
|
|
|
72
|
+
/** The persisted session backing the live chat; null = fresh unsaved chat. */
|
|
73
|
+
let sessionId: string | null = null;
|
|
74
|
+
|
|
72
75
|
function buildPrompt() {
|
|
73
76
|
const tab = activeTab.value;
|
|
74
77
|
return buildSystemPrompt({
|
|
@@ -84,6 +87,12 @@ export function createDocumentAssistant() {
|
|
|
84
87
|
return;
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
// Lazily create the backing session on the first message so empty "New Chat"
|
|
91
|
+
// Clicks never pollute the session list.
|
|
92
|
+
if (!sessionId) {
|
|
93
|
+
sessionId = sessionStore.createSession(projectRoot(), text).id;
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
chatState.sendMessage(text);
|
|
88
97
|
|
|
89
98
|
// Trim context before streaming to keep the conversation within token limits.
|
|
@@ -93,7 +102,7 @@ export function createDocumentAssistant() {
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
// Persist after trimming so the saved history reflects what's actually sent.
|
|
96
|
-
persistChat(
|
|
105
|
+
persistChat();
|
|
97
106
|
|
|
98
107
|
try {
|
|
99
108
|
const plat = getPlatform();
|
|
@@ -127,6 +136,9 @@ export function createDocumentAssistant() {
|
|
|
127
136
|
chatState.setError(error instanceof Error ? error.message : String(error));
|
|
128
137
|
} finally {
|
|
129
138
|
controller = null;
|
|
139
|
+
// Persist again once the stream settled so the completed reply (or the state
|
|
140
|
+
// After an error/abort cleanup) survives a reload without another send.
|
|
141
|
+
persistChat();
|
|
130
142
|
}
|
|
131
143
|
}
|
|
132
144
|
|
|
@@ -138,46 +150,94 @@ export function createDocumentAssistant() {
|
|
|
138
150
|
function newChat() {
|
|
139
151
|
stop();
|
|
140
152
|
chatState.clearChat();
|
|
141
|
-
|
|
153
|
+
sessionId = null;
|
|
154
|
+
sessionStore.setActiveSession(projectRoot(), null);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Sessions ──────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/** The project's persisted sessions, most recently updated first. */
|
|
160
|
+
function listSessions() {
|
|
161
|
+
return sessionStore.listSessions(projectRoot());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Replace the live chat with a persisted session's messages. */
|
|
165
|
+
function openSession(id: string) {
|
|
166
|
+
const msgs = sessionStore.loadSession(projectRoot(), id);
|
|
167
|
+
if (!msgs) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
stop();
|
|
171
|
+
chatState.clearChat();
|
|
172
|
+
pushRestoredMessages(msgs);
|
|
173
|
+
sessionId = id;
|
|
174
|
+
sessionStore.setActiveSession(projectRoot(), id);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Delete a persisted session; deleting the open one leaves a fresh unsaved chat. */
|
|
178
|
+
function deleteSession(id: string) {
|
|
179
|
+
sessionStore.deleteSession(projectRoot(), id);
|
|
180
|
+
if (sessionId === id) {
|
|
181
|
+
stop();
|
|
182
|
+
chatState.clearChat();
|
|
183
|
+
sessionId = null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function activeSessionId() {
|
|
188
|
+
return sessionId;
|
|
142
189
|
}
|
|
143
190
|
|
|
144
191
|
// ── Persistence ───────────────────────────────────────────────────────
|
|
145
192
|
|
|
146
|
-
/** Persist
|
|
147
|
-
function persistChat(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
localStorage.setItem(persistKey(), JSON.stringify(msgs));
|
|
151
|
-
} catch {
|
|
152
|
-
// Storage full or unavailable — not critical.
|
|
193
|
+
/** Persist the live conversation into its backing session (non-blocking). */
|
|
194
|
+
function persistChat() {
|
|
195
|
+
if (!sessionId) {
|
|
196
|
+
return;
|
|
153
197
|
}
|
|
198
|
+
// Skip a still-empty streaming placeholder so reloads don't restore blank bubbles.
|
|
199
|
+
const msgs = chatState.messages.filter(
|
|
200
|
+
(m) => m.role !== "assistant" || m.content || (m.toolCalls?.length ?? 0) > 0,
|
|
201
|
+
);
|
|
202
|
+
sessionStore.saveSession(projectRoot(), sessionId, msgs);
|
|
154
203
|
}
|
|
155
204
|
|
|
156
|
-
/**
|
|
205
|
+
/** Push persisted messages into chat state, synthesizing ids where missing. */
|
|
206
|
+
function pushRestoredMessages(msgs: sessionStore.PersistedMessage[]) {
|
|
207
|
+
for (const m of msgs) {
|
|
208
|
+
chatState.messages.push({
|
|
209
|
+
...m,
|
|
210
|
+
id: m.id || `restored_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
|
211
|
+
} as (typeof chatState.messages)[number]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Restore the last-active session once on creation, if any. */
|
|
157
216
|
function restoreChat() {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
// Push into chat state (skips streaming state)
|
|
168
|
-
for (const m of msgs) {
|
|
169
|
-
chatState.messages.push({
|
|
170
|
-
...m,
|
|
171
|
-
id: m.id || `restored_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
} catch {
|
|
175
|
-
// Corrupt or empty — ignore.
|
|
217
|
+
const root = projectRoot();
|
|
218
|
+
const activeId = sessionStore.getActiveSessionId(root);
|
|
219
|
+
if (!activeId) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const msgs = sessionStore.loadSession(root, activeId);
|
|
223
|
+
if (!msgs || msgs.length === 0) {
|
|
224
|
+
return;
|
|
176
225
|
}
|
|
226
|
+
pushRestoredMessages(msgs);
|
|
227
|
+
sessionId = activeId;
|
|
177
228
|
}
|
|
178
229
|
|
|
179
230
|
// Restore once on creation.
|
|
180
231
|
restoreChat();
|
|
181
232
|
|
|
182
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
chatState,
|
|
235
|
+
sendMessage,
|
|
236
|
+
stop,
|
|
237
|
+
newChat,
|
|
238
|
+
listSessions,
|
|
239
|
+
openSession,
|
|
240
|
+
deleteSession,
|
|
241
|
+
activeSessionId,
|
|
242
|
+
};
|
|
183
243
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared client for the /__studio/import-site NDJSON stream. Every platform's importSite is "a
|
|
3
|
+
* streaming fetch to some URL" — the dev server posts to its own origin, the desktop platforms post
|
|
4
|
+
* to their token-gated loopback servers — so the request/parse/settle logic lives here once.
|
|
5
|
+
*
|
|
6
|
+
* The endpoint emits one JSON object per line: `progress` lines (forwarded to onProgress),
|
|
7
|
+
* `heartbeat` keep-alives (ignored), and a terminal `done` ({root, config}) or `error` line.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ImportProgressEvent, ImportSiteOptions } from "../types";
|
|
11
|
+
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
12
|
+
|
|
13
|
+
interface StreamLine {
|
|
14
|
+
type: string;
|
|
15
|
+
phase?: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
current?: number;
|
|
18
|
+
total?: number;
|
|
19
|
+
root?: string;
|
|
20
|
+
config?: ProjectConfig;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* POST an import request and consume its NDJSON progress stream.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} endpoint — the platform's import-site URL (may carry an auth token)
|
|
28
|
+
* @param {ImportSiteOptions} opts — the import request; `apiKey`/`baseUrl` travel as headers
|
|
29
|
+
* @param {(evt: ImportProgressEvent) => void} onProgress
|
|
30
|
+
* @param {AbortSignal} [signal]
|
|
31
|
+
*/
|
|
32
|
+
export async function streamImport(
|
|
33
|
+
endpoint: string,
|
|
34
|
+
opts: ImportSiteOptions,
|
|
35
|
+
onProgress: (evt: ImportProgressEvent) => void,
|
|
36
|
+
signal?: AbortSignal,
|
|
37
|
+
): Promise<{ root: string; config: ProjectConfig }> {
|
|
38
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
39
|
+
if (opts.apiKey) {
|
|
40
|
+
headers["X-Api-Key"] = opts.apiKey;
|
|
41
|
+
}
|
|
42
|
+
if (opts.baseUrl) {
|
|
43
|
+
headers["X-Api-Base-URL"] = opts.baseUrl;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const res = await fetch(endpoint, {
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
url: opts.url,
|
|
49
|
+
directory: opts.directory,
|
|
50
|
+
depth: opts.depth,
|
|
51
|
+
maxPages: opts.maxPages,
|
|
52
|
+
aiComponents: opts.aiComponents,
|
|
53
|
+
...(opts.model === undefined ? {} : { aiModel: opts.model }),
|
|
54
|
+
}),
|
|
55
|
+
headers,
|
|
56
|
+
method: "POST",
|
|
57
|
+
...(signal === undefined ? {} : { signal }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
let message = `Import failed (${res.status})`;
|
|
62
|
+
try {
|
|
63
|
+
const body = (await res.json()) as { error?: string };
|
|
64
|
+
if (body.error) {
|
|
65
|
+
message = body.error;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
/* Non-JSON error body — keep the status message. */
|
|
69
|
+
}
|
|
70
|
+
throw new Error(message);
|
|
71
|
+
}
|
|
72
|
+
if (!res.body) {
|
|
73
|
+
throw new Error("Import stream had no response body");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const reader = res.body.getReader();
|
|
77
|
+
const decoder = new TextDecoder();
|
|
78
|
+
let buffer = "";
|
|
79
|
+
let result: { root: string; config: ProjectConfig } | null = null;
|
|
80
|
+
|
|
81
|
+
const handleLine = (line: string) => {
|
|
82
|
+
const trimmed = line.trim();
|
|
83
|
+
if (!trimmed) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
let parsed: StreamLine;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(trimmed) as StreamLine;
|
|
89
|
+
} catch {
|
|
90
|
+
return; // Tolerate a mangled line rather than killing the whole import.
|
|
91
|
+
}
|
|
92
|
+
if (parsed.type === "progress") {
|
|
93
|
+
onProgress({
|
|
94
|
+
phase: parsed.phase ?? "",
|
|
95
|
+
message: parsed.message ?? "",
|
|
96
|
+
...(parsed.current === undefined ? {} : { current: parsed.current }),
|
|
97
|
+
...(parsed.total === undefined ? {} : { total: parsed.total }),
|
|
98
|
+
});
|
|
99
|
+
} else if (parsed.type === "done" && parsed.root !== undefined && parsed.config) {
|
|
100
|
+
result = { root: parsed.root, config: parsed.config };
|
|
101
|
+
} else if (parsed.type === "error") {
|
|
102
|
+
throw new Error(parsed.error || "Import failed");
|
|
103
|
+
}
|
|
104
|
+
// Heartbeats and unknown line types are ignored.
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
for (;;) {
|
|
109
|
+
const { done, value } = await reader.read();
|
|
110
|
+
if (done) {
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
buffer += decoder.decode(value, { stream: true });
|
|
114
|
+
// Lines can split across chunks: keep the trailing partial in the buffer.
|
|
115
|
+
const lines = buffer.split("\n");
|
|
116
|
+
buffer = lines.pop() ?? "";
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
handleLine(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
handleLine(buffer);
|
|
122
|
+
} finally {
|
|
123
|
+
try {
|
|
124
|
+
await reader.cancel();
|
|
125
|
+
} catch {
|
|
126
|
+
/* The stream is already closed (e.g. after an abort). */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!result) {
|
|
131
|
+
throw new Error("Import stream ended without a result");
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Settings-store.js — sync of user settings between localStorage and the platform backend.
|
|
4
|
+
*
|
|
5
|
+
* LocalStorage stays the synchronous source of truth for every consumer (ai-settings getters read
|
|
6
|
+
* it directly). On platforms with a backend user-settings store (desktop/chromium — where
|
|
7
|
+
* localStorage does not roam across browser profiles), the persisted keys are hydrated from the
|
|
8
|
+
* backend at boot and written through on every change. The dev server omits the platform methods
|
|
9
|
+
* and settings remain localStorage-only.
|
|
10
|
+
*
|
|
11
|
+
* @license MIT
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getPlatform, hasPlatform } from "../platform";
|
|
15
|
+
|
|
16
|
+
/** The localStorage keys mirrored into the platform's user-settings store. */
|
|
17
|
+
export const PERSISTED_SETTINGS_KEYS = ["jx.ai.openaiKey", "jx.ai.baseUrl", "jx.ai.model"] as const;
|
|
18
|
+
|
|
19
|
+
/** Read a localStorage value defensively, treating unavailable/throwing storage as empty. */
|
|
20
|
+
function readLocal(key: string): string {
|
|
21
|
+
try {
|
|
22
|
+
return globalThis.localStorage?.getItem(key) ?? "";
|
|
23
|
+
} catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pull the persisted settings from the backend store into localStorage. Call once after the
|
|
30
|
+
* platform is registered; a no-op on platforms without a settings store (dev server).
|
|
31
|
+
*
|
|
32
|
+
* Migration: keys the backend does not know yet but localStorage does (an existing user upgrading
|
|
33
|
+
* from localStorage-only builds) are pushed back to the backend once, fire-and-forget.
|
|
34
|
+
*/
|
|
35
|
+
export async function hydrateSettings(): Promise<void> {
|
|
36
|
+
if (!hasPlatform()) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const platform = getPlatform();
|
|
40
|
+
if (!platform.getSettings) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let stored: Record<string, string>;
|
|
44
|
+
try {
|
|
45
|
+
stored = await platform.getSettings();
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const merged: Record<string, string> = { ...stored };
|
|
50
|
+
let needsMigration = false;
|
|
51
|
+
for (const key of PERSISTED_SETTINGS_KEYS) {
|
|
52
|
+
const backendValue = stored[key];
|
|
53
|
+
if (backendValue) {
|
|
54
|
+
try {
|
|
55
|
+
globalThis.localStorage?.setItem(key, backendValue);
|
|
56
|
+
} catch {
|
|
57
|
+
/* Storage unavailable — consumers fall back to defaults. */
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
const localValue = readLocal(key);
|
|
61
|
+
if (localValue) {
|
|
62
|
+
merged[key] = localValue;
|
|
63
|
+
needsMigration = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (needsMigration && platform.saveSettings) {
|
|
68
|
+
void platform.saveSettings(merged).catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write the current values of the persisted keys through to the backend store, fire-and-forget.
|
|
74
|
+
* Call after mutating any of {@link PERSISTED_SETTINGS_KEYS} in localStorage; a no-op on platforms
|
|
75
|
+
* without a settings store (dev server).
|
|
76
|
+
*/
|
|
77
|
+
export function persistSettings(): void {
|
|
78
|
+
if (!hasPlatform()) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const platform = getPlatform();
|
|
82
|
+
if (!platform.saveSettings) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const settings: Record<string, string> = {};
|
|
86
|
+
for (const key of PERSISTED_SETTINGS_KEYS) {
|
|
87
|
+
const value = readLocal(key);
|
|
88
|
+
if (value) {
|
|
89
|
+
settings[key] = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
void platform.saveSettings(settings).catch(() => {});
|
|
93
|
+
}
|
package/src/studio.ts
CHANGED
|
@@ -80,6 +80,8 @@ import { startFsSync } from "./files/fs-events";
|
|
|
80
80
|
import { renderImportsTemplate } from "./panels/imports-panel";
|
|
81
81
|
import { renderHeadTemplate } from "./panels/head-panel";
|
|
82
82
|
import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
|
|
83
|
+
import { installAutomationHook } from "./services/automation";
|
|
84
|
+
import { openBrowseModal } from "./browse/browse-modal";
|
|
83
85
|
|
|
84
86
|
import { getPlatform, hasPlatform, registerPlatform } from "./platform";
|
|
85
87
|
import { parseMediaEntries } from "./utils/canvas-media";
|
|
@@ -90,6 +92,7 @@ import { defBadgeLabel, defCategory, renderSignalsTemplate } from "./panels/sign
|
|
|
90
92
|
import { loadComponentRegistry } from "./files/components";
|
|
91
93
|
import { ensureDependenciesInstalled } from "./packages/ensure-deps";
|
|
92
94
|
import { maybePromptJxsuiteUpdate } from "./packages/jxsuite-update";
|
|
95
|
+
import { autoSyncProjectOnOpen } from "./packages/pull-package-sync";
|
|
93
96
|
|
|
94
97
|
import { html, render as litRender } from "lit-html";
|
|
95
98
|
|
|
@@ -124,6 +127,7 @@ import {
|
|
|
124
127
|
import { initCssData } from "./panels/style-utils";
|
|
125
128
|
import { initQuickSearch } from "./panels/quick-search";
|
|
126
129
|
import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
|
|
130
|
+
import { hydrateSettings } from "./services/settings-store";
|
|
127
131
|
import { initWelcome } from "./panels/welcome-screen";
|
|
128
132
|
import { openNewProjectModal } from "./new-project/new-project-modal";
|
|
129
133
|
import type { DocumentStackEntry, GitDiffState } from "./types";
|
|
@@ -342,6 +346,18 @@ if (!hasPlatform()) {
|
|
|
342
346
|
registerPlatform(createDevServerPlatform());
|
|
343
347
|
}
|
|
344
348
|
|
|
349
|
+
// Screenshot/automation runners (scripts/screenshots/) await window.__jxAutomation right after
|
|
350
|
+
// Navigation, so the gated hook must install before the async deep-link project load below.
|
|
351
|
+
installAutomationHook({
|
|
352
|
+
getCanvasMode,
|
|
353
|
+
openBrowseModal,
|
|
354
|
+
openNewProjectModal,
|
|
355
|
+
render,
|
|
356
|
+
renderActivityBar,
|
|
357
|
+
setCanvasMode,
|
|
358
|
+
statusMessage,
|
|
359
|
+
});
|
|
360
|
+
|
|
345
361
|
mountResizeEdges();
|
|
346
362
|
|
|
347
363
|
// ─── Render loop ──────────────────────────────────────────────────────────────
|
|
@@ -619,6 +635,7 @@ if (_projectParam) {
|
|
|
619
635
|
selectedPath: siteCtx.fileRelPath || null,
|
|
620
636
|
});
|
|
621
637
|
|
|
638
|
+
await autoSyncProjectOnOpen();
|
|
622
639
|
await ensureDependenciesInstalled();
|
|
623
640
|
await loadComponentRegistry();
|
|
624
641
|
|
|
@@ -727,6 +744,13 @@ void hydrateRecentProjects().then(() => {
|
|
|
727
744
|
render();
|
|
728
745
|
});
|
|
729
746
|
|
|
747
|
+
// Hydrate user settings (AI connection parameters) from the backend store, then re-render so
|
|
748
|
+
// Key-gated surfaces (assistant gate, New Project Import/Agent tabs) see the stored key.
|
|
749
|
+
// oxlint-disable-next-line unicorn/prefer-top-level-await -- deliberate fire-and-forget: hydration must not block initial render
|
|
750
|
+
void hydrateSettings().then(() => {
|
|
751
|
+
render();
|
|
752
|
+
});
|
|
753
|
+
|
|
730
754
|
// ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
|
|
731
755
|
|
|
732
756
|
function renderLeftPanel() {
|
|
@@ -787,6 +811,7 @@ async function openRecentProject(root: string) {
|
|
|
787
811
|
selectedPath: null,
|
|
788
812
|
});
|
|
789
813
|
|
|
814
|
+
await autoSyncProjectOnOpen();
|
|
790
815
|
await ensureDependenciesInstalled();
|
|
791
816
|
await loadDirectory(".");
|
|
792
817
|
await loadComponentRegistry();
|