@jxsuite/studio 2.4.2 → 3.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/dist/chunks/{cf-settings-g5fbf2zp.js → cf-settings-zj2cj1ar.js} +6 -3
- package/dist/chunks/{content-source-k9724vcv.js → content-source-7fy9qrm0.js} +10 -9
- package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-6vvx96n2.js} +9 -8
- package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-kwzvpemr.js} +13 -12
- package/dist/chunks/{files-yew27sn9.js → files-xcybanxe.js} +14 -13
- package/dist/chunks/{format-host-wewba4nq.js → format-host-8fr3545e.js} +2 -2
- package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-g01d93vc.js} +9 -8
- package/dist/chunks/{layers-panel-qnsghwwd.js → layers-panel-27essgkc.js} +12 -11
- package/dist/chunks/{publish-panel-a0pdft5w.js → publish-panel-09sy2bab.js} +8 -7
- package/dist/chunks/{quick-search-9avyc4pc.js → quick-search-sefb61v8.js} +15 -14
- package/dist/chunks/{right-panel-kcnhtc5j.js → right-panel-9r32jd5j.js} +11 -10
- package/dist/chunks/{studio-aweyr4v1.js → studio-2x9zbxmx.js} +2 -2
- package/dist/chunks/{studio-ef8t53vc.js → studio-3dkcd4c5.js} +3 -3
- package/dist/chunks/{studio-hb7c46jy.js → studio-5efrtn1m.js} +9 -7
- package/dist/chunks/{studio-v1va0vhd.js → studio-6z5eyjwn.js} +24 -25
- package/dist/chunks/{studio-ke244pam.js → studio-7p35254k.js} +33 -11
- package/dist/chunks/{studio-f5yyvqxv.js → studio-8ezdegcx.js} +13 -11
- package/dist/chunks/{studio-cz2j1c0g.js → studio-a0yp7syn.js} +55 -18
- package/dist/chunks/{studio-26sv44br.js → studio-dc8s8heb.js} +34 -23
- package/dist/chunks/{studio-jnakhg87.js → studio-g5gdqnyd.js} +4 -4
- package/dist/chunks/{studio-rqemfhz6.js → studio-hcxzyk3x.js} +2 -2
- package/dist/chunks/{studio-ws6ef6js.js → studio-hj2se583.js} +12 -10
- package/dist/chunks/{studio-pyrw8h0w.js → studio-j595e748.js} +407 -167
- package/dist/chunks/{studio-dgj7gq44.js → studio-k4jvjcfx.js} +4 -4
- package/dist/chunks/studio-kfpek536.js +28 -0
- package/dist/chunks/{studio-sccge2wf.js → studio-p5dja96s.js} +2 -2
- package/dist/chunks/{studio-0b6fs05q.js → studio-pkp61mh8.js} +5 -5
- package/dist/chunks/studio-qpfq7xhm.js +1802 -0
- package/dist/chunks/{studio-ra7ar50f.js → studio-r5xbtctf.js} +51 -1565
- package/dist/chunks/{studio-v2c5pmjj.js → studio-rh1cnamv.js} +17 -15
- package/dist/chunks/{studio-28xc8ws4.js → studio-ry1vz5sv.js} +10 -8
- package/dist/chunks/{studio-hyn3vhwr.js → studio-xntfdy1x.js} +5 -5
- package/dist/iframe-entry.js +344 -26
- package/dist/studio.js +374 -248
- package/package.json +9 -9
- package/src/browse/library-layouts.ts +3 -3
- package/src/browse/library-pane.ts +2 -2
- package/src/browse/library-preview.ts +19 -3
- package/src/canvas/asset-refs.ts +119 -0
- package/src/canvas/asset-resolve.ts +256 -0
- package/src/canvas/canvas-live-render.ts +23 -15
- package/src/canvas/iframe-entry.ts +1 -0
- package/src/canvas/iframe-host.ts +4 -0
- package/src/canvas/iframe-protocol.ts +9 -0
- package/src/canvas/iframe-render.ts +28 -10
- package/src/canvas/site-style-css.ts +2 -6
- package/src/editor/file-drop-action.ts +2 -1
- package/src/files/files.ts +2 -2
- package/src/files/media-paths.ts +37 -14
- package/src/files/media-upload.ts +47 -6
- package/src/grid/grid-idle.ts +45 -0
- package/src/grid/grid-view.ts +21 -0
- package/src/grid/schema-columns.ts +2 -1
- package/src/grid/sources/content-source.ts +24 -3
- package/src/new-project/import-tab.ts +3 -2
- package/src/panels/ai-chat/composer.ts +30 -13
- package/src/panels/frontmatter-fields.ts +6 -2
- package/src/panels/properties-panel.ts +8 -2
- package/src/panels/seo-modal.ts +5 -6
- package/src/panels/signals-panel.ts +2 -1
- package/src/platforms/cloud.ts +75 -5
- package/src/platforms/devserver.ts +5 -1
- package/src/services/ai-models.ts +101 -11
- package/src/services/ai-settings.ts +69 -71
- package/src/services/cf-settings.ts +19 -37
- package/src/services/document-assistant.ts +4 -3
- package/src/services/idle.ts +6 -1
- package/src/services/settings/definitions.ts +164 -0
- package/src/services/settings/kernel.ts +335 -0
- package/src/services/settings/write-queue.ts +116 -0
- package/src/settings/preferences-accounts.ts +6 -19
- package/src/settings/preferences-keymap.ts +8 -18
- package/src/settings/schema-field-ui.ts +2 -2
- package/src/shell.ts +6 -14
- package/src/studio.ts +32 -1
- package/src/types.ts +61 -3
- package/src/ui/ai-credentials-form.ts +52 -27
- package/src/ui/media-picker.ts +6 -11
- package/src/utils/studio-utils.ts +45 -3
- package/dist/chunks/studio-a7pzveq1.js +0 -110
- package/src/canvas/content-assets.ts +0 -263
- package/src/services/settings-store.ts +0 -99
|
@@ -34,12 +34,14 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { normalizeChord, overlappingScopes, parseChord } from "../commands/keymap";
|
|
37
|
+
import { SETTINGS } from "../services/settings/definitions";
|
|
38
|
+
import { clearSettings, readStoredSetting, setSetting } from "../services/settings/kernel";
|
|
37
39
|
import type { KeybindingOverrides } from "../commands/keymap";
|
|
38
40
|
import type { KeyScope } from "../commands/levels";
|
|
39
41
|
import type { AnyCommand, CommandRegistry } from "../commands/registry";
|
|
40
42
|
|
|
41
43
|
/** Where the user's layer is kept. One key, one JSON object of `id → chords`. */
|
|
42
|
-
export const KEYBINDINGS_STORAGE_KEY =
|
|
44
|
+
export const KEYBINDINGS_STORAGE_KEY = SETTINGS.keybindings.key;
|
|
43
45
|
|
|
44
46
|
/** Keys that type nothing, and so may be bound without a modifier. */
|
|
45
47
|
const UNMODIFIED_KEYS = new Set([
|
|
@@ -114,12 +116,7 @@ export function isBindableChord(chord: string): boolean {
|
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
function readRaw(): string {
|
|
117
|
-
|
|
118
|
-
return globalThis.localStorage?.getItem(KEYBINDINGS_STORAGE_KEY) ?? "";
|
|
119
|
-
} catch {
|
|
120
|
-
// Storage unavailable (private mode): every command keeps the chord it declared.
|
|
121
|
-
return "";
|
|
122
|
-
}
|
|
119
|
+
return readStoredSetting(SETTINGS.keybindings);
|
|
123
120
|
}
|
|
124
121
|
|
|
125
122
|
/** Every chord in one stored entry, canonicalised — or `null` if any of them is not a chord. */
|
|
@@ -175,18 +172,11 @@ export function loadKeybindingOverrides(): Map<string, string[]> {
|
|
|
175
172
|
}
|
|
176
173
|
|
|
177
174
|
function store(layer: KeybindingOverrides): void {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
globalThis.localStorage?.setItem(
|
|
184
|
-
KEYBINDINGS_STORAGE_KEY,
|
|
185
|
-
JSON.stringify(Object.fromEntries(layer)),
|
|
186
|
-
);
|
|
187
|
-
} catch {
|
|
188
|
-
// Storage unavailable — the rebinding is live in this window and is not remembered.
|
|
175
|
+
if (layer.size === 0) {
|
|
176
|
+
clearSettings([SETTINGS.keybindings]);
|
|
177
|
+
return;
|
|
189
178
|
}
|
|
179
|
+
setSetting(SETTINGS.keybindings, JSON.stringify(Object.fromEntries(layer)));
|
|
190
180
|
}
|
|
191
181
|
|
|
192
182
|
/**
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { html, nothing } from "lit-html";
|
|
8
|
-
import { camelToLabel } from "../utils/studio-utils";
|
|
8
|
+
import { camelToLabel, isMediaFormat } from "../utils/studio-utils";
|
|
9
9
|
|
|
10
10
|
export interface SchemaProperty {
|
|
11
11
|
type?: string;
|
|
@@ -483,7 +483,7 @@ export function yamlDefault(type: string, format?: string) {
|
|
|
483
483
|
if (format === "date") {
|
|
484
484
|
return new Date().toISOString().split("T")[0];
|
|
485
485
|
}
|
|
486
|
-
if (format
|
|
486
|
+
if (isMediaFormat(format)) {
|
|
487
487
|
return '""';
|
|
488
488
|
}
|
|
489
489
|
switch (type) {
|
package/src/shell.ts
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { effect, effectScope, reactive } from "./reactivity";
|
|
25
|
+
import { SETTINGS } from "./services/settings/definitions";
|
|
26
|
+
import { readStoredSetting, setSetting } from "./services/settings/kernel";
|
|
25
27
|
import { loadedMonaco } from "./services/monaco-lazy";
|
|
26
28
|
import { applyStartupProfile } from "./services/profile";
|
|
27
29
|
import { workspace } from "./workspace/workspace";
|
|
@@ -349,8 +351,6 @@ export interface ShellState {
|
|
|
349
351
|
|
|
350
352
|
const DOCK_STORAGE_KEY = "jx-studio-panel-widths";
|
|
351
353
|
|
|
352
|
-
const THEME_STORAGE_KEY = "jx-studio-theme";
|
|
353
|
-
|
|
354
354
|
/**
|
|
355
355
|
* The per-project record's key prefix — one namespaced record per project root (§4.4).
|
|
356
356
|
*
|
|
@@ -378,14 +378,10 @@ function isChromeTheme(value: unknown): value is ChromeTheme {
|
|
|
378
378
|
return typeof value === "string" && (CHROME_THEMES as readonly string[]).includes(value);
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
/** Read the persisted theme, falling back to {@link DEFAULT_THEME} on absent/
|
|
381
|
+
/** Read the persisted theme, falling back to {@link DEFAULT_THEME} on an absent/unknown value. */
|
|
382
382
|
function readPersistedTheme(): ChromeTheme {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return isChromeTheme(stored) ? stored : DEFAULT_THEME;
|
|
386
|
-
} catch {
|
|
387
|
-
return DEFAULT_THEME;
|
|
388
|
-
}
|
|
383
|
+
const stored = readStoredSetting(SETTINGS.theme);
|
|
384
|
+
return isChromeTheme(stored) ? stored : DEFAULT_THEME;
|
|
389
385
|
}
|
|
390
386
|
|
|
391
387
|
export const DOCK_IDS: DockId[] = ["left", "right", "bottom"];
|
|
@@ -1168,11 +1164,7 @@ export function setChromeTheme(theme: ChromeTheme): void {
|
|
|
1168
1164
|
return;
|
|
1169
1165
|
}
|
|
1170
1166
|
shell.theme = theme;
|
|
1171
|
-
|
|
1172
|
-
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
1173
|
-
} catch {
|
|
1174
|
-
// Storage full or unavailable — the theme is still applied, just not remembered.
|
|
1175
|
-
}
|
|
1167
|
+
setSetting(SETTINGS.theme, theme);
|
|
1176
1168
|
}
|
|
1177
1169
|
|
|
1178
1170
|
/**
|
package/src/studio.ts
CHANGED
|
@@ -204,7 +204,12 @@ import { initQuickSearch } from "./panels/quick-search";
|
|
|
204
204
|
import { hydrateAccountStatus } from "./account-status";
|
|
205
205
|
import { hydrateProjectList } from "./project-list";
|
|
206
206
|
import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
|
|
207
|
-
import {
|
|
207
|
+
import {
|
|
208
|
+
hydrateSettings,
|
|
209
|
+
onSettingsChanged,
|
|
210
|
+
settingsSettled,
|
|
211
|
+
watchRemoteSettings,
|
|
212
|
+
} from "./services/settings/kernel";
|
|
208
213
|
import { initWelcome } from "./panels/welcome-screen";
|
|
209
214
|
import {
|
|
210
215
|
openAddRepoModal,
|
|
@@ -1136,6 +1141,32 @@ void hydrateSettings().then(() => {
|
|
|
1136
1141
|
render();
|
|
1137
1142
|
});
|
|
1138
1143
|
|
|
1144
|
+
/*
|
|
1145
|
+
* A setting changing repaints, wherever the change came from — this window's Preferences, or
|
|
1146
|
+
* hydration landing after boot. Key-gated surfaces (the assistant's setup notice, the New Project
|
|
1147
|
+
* gates, the Accounts list) read the store on the render path rather than caching a copy, so this
|
|
1148
|
+
* one subscription is what keeps every one of them honest.
|
|
1149
|
+
*/
|
|
1150
|
+
onSettingsChanged(() => {
|
|
1151
|
+
render();
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
/*
|
|
1155
|
+
* Another window changing a setting is news this one wants. Nothing is lost without it — a patch
|
|
1156
|
+
* cannot clobber a key it does not name — but a second window would otherwise show a provider that
|
|
1157
|
+
* had been reconfigured somewhere else until it was restarted.
|
|
1158
|
+
*/
|
|
1159
|
+
watchRemoteSettings();
|
|
1160
|
+
|
|
1161
|
+
/*
|
|
1162
|
+
* Settings are written on a queue that coalesces a burst into one send, so a change made in the
|
|
1163
|
+
* last moments before the window closes may still be in flight. Draining it here is the difference
|
|
1164
|
+
* between "your provider was saved" and a settings file that never heard about it.
|
|
1165
|
+
*/
|
|
1166
|
+
globalThis.addEventListener("beforeunload", () => {
|
|
1167
|
+
void settingsSettled();
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1139
1170
|
// ─── Left panel: delegated to panels/left-panel.js ───────────────────────────
|
|
1140
1171
|
|
|
1141
1172
|
function renderLeftPanel() {
|
package/src/types.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
|
|
14
14
|
import type {
|
|
15
15
|
AppInfo,
|
|
16
|
+
AssetCapabilities,
|
|
16
17
|
CfConnection,
|
|
17
18
|
CodeServiceResult,
|
|
18
19
|
ComponentMeta,
|
|
@@ -42,7 +43,9 @@ import type {
|
|
|
42
43
|
RenameResult,
|
|
43
44
|
SecretsSetRequest,
|
|
44
45
|
SecretsSetResponse,
|
|
46
|
+
SettingsPatch,
|
|
45
47
|
StarterInfo,
|
|
48
|
+
UploadResult,
|
|
46
49
|
} from "@jxsuite/protocol";
|
|
47
50
|
|
|
48
51
|
export type {
|
|
@@ -93,6 +96,7 @@ export type {
|
|
|
93
96
|
SecretsListResponse,
|
|
94
97
|
SecretsSetRequest,
|
|
95
98
|
SecretsSetResponse,
|
|
99
|
+
SettingsPatch,
|
|
96
100
|
StarterInfo,
|
|
97
101
|
} from "@jxsuite/protocol";
|
|
98
102
|
|
|
@@ -200,6 +204,33 @@ export interface StudioPlatform {
|
|
|
200
204
|
* trailing `/` is added: without it `new URL` drops the last segment.
|
|
201
205
|
*/
|
|
202
206
|
documentBaseUrl?: string;
|
|
207
|
+
/**
|
|
208
|
+
* What the canvas ORIGIN answers for a SITE URL — and therefore how Studio must address media.
|
|
209
|
+
*
|
|
210
|
+
* `"site"` (the default when absent): the canvas origin already serves the published site URL
|
|
211
|
+
* space, so `/hero.jpg` and `/styles/main.css` resolve on their own and Studio touches neither.
|
|
212
|
+
* The dev server and the desktop loopback both do — `serveProjectFile` in `@jxsuite/server` IS
|
|
213
|
+
* that URL space — so neither declares anything here and both stay byte-identical.
|
|
214
|
+
*
|
|
215
|
+
* `"repo"`: the host serves PROJECT-RELATIVE paths under {@link documentBaseUrl}, and nothing
|
|
216
|
+
* answers the site URL space. Studio resolves each authored reference to the project file it
|
|
217
|
+
* names and rebases that path onto `documentBaseUrl`. Inert without one — a host that says its
|
|
218
|
+
* site URLs are wrong without saying what is right has told Studio nothing it can act on.
|
|
219
|
+
*
|
|
220
|
+
* This is about the ORIGIN, not the backend: a host whose files are perfectly reachable can still
|
|
221
|
+
* be `"repo"` space, because what decides it is what answers `GET /hero.jpg` on the document the
|
|
222
|
+
* canvas is running in.
|
|
223
|
+
*/
|
|
224
|
+
assetSpace?: "site" | "repo";
|
|
225
|
+
/**
|
|
226
|
+
* What this backend will accept as an upload, when it says.
|
|
227
|
+
*
|
|
228
|
+
* Absent, or any field absent, means "no declared limit", and Studio must not invent one — a
|
|
229
|
+
* limit it made up is a file the user cannot upload for no reason anyone can name. A DECLARED
|
|
230
|
+
* limit is different: Studio refuses before spending the round trip and names the number in the
|
|
231
|
+
* refusal, and narrows the file picker's `accept` to match.
|
|
232
|
+
*/
|
|
233
|
+
assetCapabilities?: AssetCapabilities;
|
|
203
234
|
activate: (root?: string) => Promise<void>;
|
|
204
235
|
openProject: () => Promise<{
|
|
205
236
|
config: ProjectConfig;
|
|
@@ -224,7 +255,16 @@ export interface StudioPlatform {
|
|
|
224
255
|
listDirectory: (dir: string) => Promise<DirEntry[]>;
|
|
225
256
|
readFile: (path: string) => Promise<string>;
|
|
226
257
|
writeFile: (path: string, content: string) => Promise<void>;
|
|
227
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Store bytes at a project path, and report where they really landed.
|
|
260
|
+
*
|
|
261
|
+
* The result's `path` is the ANSWER, not an echo: a backend may de-duplicate by content hash,
|
|
262
|
+
* append a collision suffix, or normalize a name, and the reference Studio writes into the
|
|
263
|
+
* document has to name what was actually written. This was `Promise<unknown>` and every caller
|
|
264
|
+
* used the REQUESTED path, so any such backend produced documents pointing at files that are not
|
|
265
|
+
* there.
|
|
266
|
+
*/
|
|
267
|
+
uploadFile: (path: string, data: string | File | Blob | ArrayBuffer) => Promise<UploadResult>;
|
|
228
268
|
deleteFile: (path: string) => Promise<void>;
|
|
229
269
|
renameFile: (from: string, to: string) => Promise<RenameResult>;
|
|
230
270
|
/**
|
|
@@ -508,8 +548,26 @@ export interface StudioPlatform {
|
|
|
508
548
|
* settings live in localStorage only.
|
|
509
549
|
*/
|
|
510
550
|
getSettings?: () => Promise<Record<string, string>>;
|
|
511
|
-
/**
|
|
512
|
-
|
|
551
|
+
/**
|
|
552
|
+
* Apply a set of changes to the backend store, and answer with the store as it then stands.
|
|
553
|
+
*
|
|
554
|
+
* A PATCH rather than the whole map, which is a correctness property and not an optimisation: a
|
|
555
|
+
* key named by neither `set` nor `remove` must be left exactly as it was found. Writing the whole
|
|
556
|
+
* map let any window overwrite the shared file with its own view of it, and on the chromium
|
|
557
|
+
* launcher every window is a separate process with a separate browser profile — so a window that
|
|
558
|
+
* held no settings could, and did, clear the credentials another window had just stored. It also
|
|
559
|
+
* preserves keys this build does not know: one written by a newer version, or by hand.
|
|
560
|
+
*/
|
|
561
|
+
patchSettings?: (patch: SettingsPatch) => Promise<Record<string, string>>;
|
|
562
|
+
/**
|
|
563
|
+
* Be told when the backend store changes — by another window, or by hand. Returns the
|
|
564
|
+
* unsubscribe.
|
|
565
|
+
*
|
|
566
|
+
* Optional, and a display concern rather than a correctness one: `patchSettings` already stops a
|
|
567
|
+
* window from clobbering another's keys, so a platform without this leaves a second window
|
|
568
|
+
* showing a stale value until its next boot rather than losing anything.
|
|
569
|
+
*/
|
|
570
|
+
subscribeSettings?: (handler: (settings: Record<string, string>) => void) => () => void;
|
|
513
571
|
}
|
|
514
572
|
|
|
515
573
|
// ─── Studio Types ───────────────────────────────────────────────────────────
|
|
@@ -19,16 +19,15 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { html, nothing } from "lit-html";
|
|
22
|
+
import { live } from "lit-html/directives/live.js";
|
|
22
23
|
import type { TemplateResult } from "lit-html";
|
|
23
|
-
import { fetchAvailableModels
|
|
24
|
+
import { fetchAvailableModels } from "../services/ai-models";
|
|
24
25
|
import {
|
|
25
26
|
getBaseUrl,
|
|
26
|
-
getModel,
|
|
27
27
|
getOpenAiKey,
|
|
28
28
|
hasOpenAiKey,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
setOpenAiKey,
|
|
29
|
+
saveAiProvider,
|
|
30
|
+
storedModel,
|
|
32
31
|
} from "../services/ai-settings";
|
|
33
32
|
|
|
34
33
|
export interface AiCredentialsFormOptions {
|
|
@@ -64,11 +63,23 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
|
|
|
64
63
|
let modelsLoading = false;
|
|
65
64
|
let modelsError = "";
|
|
66
65
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Load the drafts from what is stored.
|
|
68
|
+
*
|
|
69
|
+
* The model comes from {@link storedModel} rather than `getModel()`, so a user who has never
|
|
70
|
+
* picked one drafts an empty field instead of the `"gpt-4o"` default. Saving a prefilled default
|
|
71
|
+
* writes a choice nobody made, and `jx.ai.model: "gpt-4o"` is exactly what a broken install was
|
|
72
|
+
* left holding.
|
|
73
|
+
*/
|
|
74
|
+
function loadDrafts() {
|
|
69
75
|
keyDraft = getOpenAiKey();
|
|
70
76
|
baseUrlDraft = getBaseUrl();
|
|
71
|
-
modelDraft =
|
|
77
|
+
modelDraft = storedModel();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Open the form pre-filled with the current settings, and load the model list. */
|
|
81
|
+
function startEdit() {
|
|
82
|
+
loadDrafts();
|
|
72
83
|
opts.requestRender();
|
|
73
84
|
// Auto-fetch available models if not already loaded.
|
|
74
85
|
if (availableModels.length === 0 && !modelsLoading) {
|
|
@@ -76,26 +87,33 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
|
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* Persist the drafted key + endpoint + model and notify the host.
|
|
92
|
+
*
|
|
93
|
+
* **Re-seeds the drafts; it must never blank them.** Preferences is a place rather than a wizard
|
|
94
|
+
* step, so the sheet stays open across Save and `startEdit` is not called again — which meant
|
|
95
|
+
* every field emptied the instant a save succeeded. That reads as "it didn't take", and the
|
|
96
|
+
* obvious response to it destroyed the credentials: a blank draft is what
|
|
97
|
+
* `setOpenAiKey`/`setBaseUrl` treat as _clear_, so pressing Save a second time on the emptied
|
|
98
|
+
* form deleted the key and endpoint that the first press had just stored. Reading back through
|
|
99
|
+
* the getters also shows what was actually kept — trimmed, and with the endpoint's trailing slash
|
|
100
|
+
* stripped — rather than what was typed.
|
|
101
|
+
*/
|
|
80
102
|
function save() {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// Clear fetched models so they're re-fetched with the new credentials next time.
|
|
88
|
-
availableModels = [];
|
|
89
|
-
invalidateModelCache();
|
|
103
|
+
saveAiProvider({ apiKey: keyDraft, baseUrl: baseUrlDraft, model: modelDraft });
|
|
104
|
+
loadDrafts();
|
|
105
|
+
modelsError = "";
|
|
106
|
+
/* The fetched list stays: it was listed under exactly these credentials, and dropping it
|
|
107
|
+
collapsed the model combobox back to a free-text field on every save. The module cache is
|
|
108
|
+
dropped by ai-models' own settings subscription, so this form does not have to remember to. */
|
|
90
109
|
opts.onSaved?.();
|
|
91
110
|
opts.requestRender();
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
/** Dismiss the form without saving (only offered when a key already exists). */
|
|
95
114
|
function cancel() {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
modelDraft = "";
|
|
115
|
+
loadDrafts();
|
|
116
|
+
modelsError = "";
|
|
99
117
|
opts.onCancel?.();
|
|
100
118
|
opts.requestRender();
|
|
101
119
|
}
|
|
@@ -104,6 +122,11 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
|
|
|
104
122
|
* Fetch available models via src/services/ai-models.ts, preferring the in-form drafts over the
|
|
105
123
|
* stored settings so the list reflects the credentials being edited. Always forces past the
|
|
106
124
|
* module cache — this runs on explicit user action (or first open) with possibly-new drafts.
|
|
125
|
+
*
|
|
126
|
+
* Both lines below read draft-first. The key used to read stored-first, against this paragraph
|
|
127
|
+
* and against the line beside it: editing a key in place and pressing Fetch models listed the OLD
|
|
128
|
+
* key's models, and — worse — a form whose drafts had been blanked still fetched successfully
|
|
129
|
+
* from storage, which is what made an emptied form look like it was working.
|
|
107
130
|
*/
|
|
108
131
|
async function fetchModels() {
|
|
109
132
|
modelsLoading = true;
|
|
@@ -111,8 +134,10 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
|
|
|
111
134
|
opts.requestRender();
|
|
112
135
|
try {
|
|
113
136
|
availableModels = await fetchAvailableModels({
|
|
114
|
-
|
|
115
|
-
|
|
137
|
+
credentials: {
|
|
138
|
+
apiKey: keyDraft || getOpenAiKey(),
|
|
139
|
+
baseUrl: baseUrlDraft || getBaseUrl(),
|
|
140
|
+
},
|
|
116
141
|
force: true,
|
|
117
142
|
});
|
|
118
143
|
} catch (error: unknown) {
|
|
@@ -144,7 +169,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
|
|
|
144
169
|
type="password"
|
|
145
170
|
size="s"
|
|
146
171
|
placeholder="sk-… or any compatible key"
|
|
147
|
-
.value=${keyDraft}
|
|
172
|
+
.value=${live(keyDraft)}
|
|
148
173
|
@input=${(e: Event) => {
|
|
149
174
|
keyDraft = (e.target as HTMLInputElement).value;
|
|
150
175
|
}}
|
|
@@ -157,7 +182,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
|
|
|
157
182
|
class="ai-creds-field"
|
|
158
183
|
size="s"
|
|
159
184
|
allows-custom-value
|
|
160
|
-
.value=${modelDraft}
|
|
185
|
+
.value=${live(modelDraft)}
|
|
161
186
|
@change=${(e: Event) => {
|
|
162
187
|
modelDraft = (e.target as HTMLInputElement).value;
|
|
163
188
|
}}
|
|
@@ -175,7 +200,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
|
|
|
175
200
|
class="ai-creds-field"
|
|
176
201
|
size="s"
|
|
177
202
|
placeholder="Model ID (e.g. gpt-4o, claude-sonnet-4-20250514, etc.)"
|
|
178
|
-
.value=${modelDraft}
|
|
203
|
+
.value=${live(modelDraft)}
|
|
179
204
|
@input=${(e: Event) => {
|
|
180
205
|
modelDraft = (e.target as HTMLInputElement).value;
|
|
181
206
|
}}
|
|
@@ -198,7 +223,7 @@ export function createAiCredentialsForm(opts: AiCredentialsFormOptions): AiCrede
|
|
|
198
223
|
class="ai-creds-field"
|
|
199
224
|
size="s"
|
|
200
225
|
placeholder="Endpoint (optional, e.g. http://localhost:11434/v1)"
|
|
201
|
-
.value=${baseUrlDraft}
|
|
226
|
+
.value=${live(baseUrlDraft)}
|
|
202
227
|
@input=${(e: Event) => {
|
|
203
228
|
baseUrlDraft = (e.target as HTMLInputElement).value;
|
|
204
229
|
}}
|
package/src/ui/media-picker.ts
CHANGED
|
@@ -26,12 +26,11 @@ import { debouncedStyleCommit, renderOnly } from "../store";
|
|
|
26
26
|
import { getLayerSlot, popoverLayerFor } from "./layers";
|
|
27
27
|
import type { LayerKind } from "./layers";
|
|
28
28
|
import { rectOf } from "../utils/geometry";
|
|
29
|
-
import {
|
|
30
|
-
import { previewAssetSrc } from "../canvas/content-assets";
|
|
29
|
+
import { previewAssetSrc } from "../canvas/asset-refs";
|
|
31
30
|
import {
|
|
32
31
|
IMAGE_EXTENSIONS,
|
|
33
32
|
MEDIA_EXTENSIONS,
|
|
34
|
-
|
|
33
|
+
uploadAccept,
|
|
35
34
|
extensionOf,
|
|
36
35
|
uploadAssets,
|
|
37
36
|
} from "../files/media-upload";
|
|
@@ -42,7 +41,7 @@ import {
|
|
|
42
41
|
recordImageSize,
|
|
43
42
|
seedMediaMeta,
|
|
44
43
|
} from "../files/media-meta";
|
|
45
|
-
import { mediaSiteUrl } from "../files/media-paths";
|
|
44
|
+
import { mediaSiteUrl, previewFileSrc } from "../files/media-paths";
|
|
46
45
|
|
|
47
46
|
// ─── Media file cache ────────────────────────────────────────────────────────
|
|
48
47
|
|
|
@@ -283,7 +282,7 @@ function renderMediaPickerPopover() {
|
|
|
283
282
|
m.isImage
|
|
284
283
|
? html`<img
|
|
285
284
|
slot="icon"
|
|
286
|
-
src=${
|
|
285
|
+
src=${previewFileSrc(m.file)}
|
|
287
286
|
alt=""
|
|
288
287
|
style="width:24px;height:24px;object-fit:cover;border-radius:var(--spectrum-corner-radius-75, 2px)"
|
|
289
288
|
@load=${(e: Event) => noteImageSize(m.file, e.target)}
|
|
@@ -394,7 +393,7 @@ function pickAndUpload(onCommit: (val: string) => void) {
|
|
|
394
393
|
const input = document.createElement("input");
|
|
395
394
|
input.type = "file";
|
|
396
395
|
input.multiple = true;
|
|
397
|
-
input.accept =
|
|
396
|
+
input.accept = uploadAccept();
|
|
398
397
|
input.addEventListener("change", () => {
|
|
399
398
|
if (input.files?.length) {
|
|
400
399
|
void uploadAndAssign(input.files, onCommit);
|
|
@@ -422,11 +421,7 @@ export function renderMediaPicker(prop: string, value: string, onCommit: (val: s
|
|
|
422
421
|
<div class="media-picker">
|
|
423
422
|
${
|
|
424
423
|
isImage && currentValue
|
|
425
|
-
? html`<img
|
|
426
|
-
class="media-picker-thumb"
|
|
427
|
-
src=${loopbackAssetSrc(previewAssetSrc(currentValue))}
|
|
428
|
-
alt=""
|
|
429
|
-
/>`
|
|
424
|
+
? html`<img class="media-picker-thumb" src=${previewAssetSrc(currentValue)} alt="" />`
|
|
430
425
|
: nothing
|
|
431
426
|
}
|
|
432
427
|
<sp-textfield
|
|
@@ -117,6 +117,35 @@ export function abbreviateValue(val: string) {
|
|
|
117
117
|
return map[val] || val;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The placeholder a content type's `source` may carry to say "one directory per locale".
|
|
122
|
+
*
|
|
123
|
+
* Held here rather than imported out of the parser extension's content loader ({@link
|
|
124
|
+
* file://../../../../extensions/parser/src/content-loader.ts}): that module reads the filesystem
|
|
125
|
+
* and this one runs in a browser. It is also an extension, and core may not import one.
|
|
126
|
+
*/
|
|
127
|
+
const LOCALE_PLACEHOLDER = "{locale}";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* True when a schema `format` names a MEDIA reference.
|
|
131
|
+
*
|
|
132
|
+
* Two spellings, and both are real. `"uri-reference"` is JSON Schema's own and the one the spec
|
|
133
|
+
* uses — it is what the content loader keys its asset rewrite on (`rewriteEntryAssets`) and what a
|
|
134
|
+
* schema written against the documentation says. `"image"` is Studio's own shorthand, used by
|
|
135
|
+
* component props and settings schemas.
|
|
136
|
+
*
|
|
137
|
+
* They were accepted in different places, so which editor a field got depended on which panel was
|
|
138
|
+
* asking: `inferInputType` took both, the frontmatter panel took only `"image"`, and the drop
|
|
139
|
+
* target, the signals panel and the data grid each took only one. One predicate, so a field is a
|
|
140
|
+
* media field everywhere or nowhere.
|
|
141
|
+
*
|
|
142
|
+
* @param {unknown} format - A schema entry's `format`
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function isMediaFormat(format: unknown): boolean {
|
|
146
|
+
return format === "image" || format === "uri-reference";
|
|
147
|
+
}
|
|
148
|
+
|
|
120
149
|
/**
|
|
121
150
|
* Determine input widget type from a css-meta entry
|
|
122
151
|
*
|
|
@@ -136,7 +165,7 @@ export function inferInputType(entry: Record<string, unknown>) {
|
|
|
136
165
|
if (entry.format === "color") {
|
|
137
166
|
return "color";
|
|
138
167
|
}
|
|
139
|
-
if (entry.format
|
|
168
|
+
if (isMediaFormat(entry.format)) {
|
|
140
169
|
return "media";
|
|
141
170
|
}
|
|
142
171
|
if (entry.$units !== undefined) {
|
|
@@ -179,7 +208,7 @@ export function findContentTypeSchema(
|
|
|
179
208
|
continue;
|
|
180
209
|
}
|
|
181
210
|
const src = def.source.replace(/^\.\//, "").replace(/\/$/, "");
|
|
182
|
-
const hasExt = src.includes(".") && !src.endsWith("/");
|
|
211
|
+
const hasExt = src.includes(".") && !src.endsWith("/") && !src.includes(LOCALE_PLACEHOLDER);
|
|
183
212
|
if (hasExt) {
|
|
184
213
|
if (docPath === src || docPath.endsWith(`/${src}`)) {
|
|
185
214
|
return { name, schema: def.schema };
|
|
@@ -191,7 +220,20 @@ export function findContentTypeSchema(
|
|
|
191
220
|
: (formatByName(def.format)?.extensions[0] ??
|
|
192
221
|
defaultContentFormat()?.extensions[0] ??
|
|
193
222
|
".json");
|
|
194
|
-
|
|
223
|
+
/* A localized source is N directories, one content type: `content/posts/{locale}` matches
|
|
224
|
+
`content/posts/fr/hello.md` and carries the SAME schema, because a translation is the same
|
|
225
|
+
post. Without this the placeholder was compared literally, matched no path anybody has, and
|
|
226
|
+
a translated entry showed no frontmatter fields at all. */
|
|
227
|
+
const prefix = src.includes(LOCALE_PLACEHOLDER)
|
|
228
|
+
? new RegExp(
|
|
229
|
+
`^${src
|
|
230
|
+
.split(LOCALE_PLACEHOLDER)
|
|
231
|
+
.map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
|
|
232
|
+
.join("[^/]+")}/`,
|
|
233
|
+
)
|
|
234
|
+
: null;
|
|
235
|
+
const matches = prefix ? prefix.test(docPath) : docPath.startsWith(`${src}/`);
|
|
236
|
+
if (matches && docPath.endsWith(ext)) {
|
|
195
237
|
return { name, schema: def.schema };
|
|
196
238
|
}
|
|
197
239
|
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getPlatform,
|
|
3
|
-
hasPlatform
|
|
4
|
-
} from "./studio-sas7mt3k.js";
|
|
5
|
-
|
|
6
|
-
// src/services/settings-store.ts
|
|
7
|
-
var PERSISTED_SETTINGS_KEYS = [
|
|
8
|
-
"jx.ai.openaiKey",
|
|
9
|
-
"jx.ai.baseUrl",
|
|
10
|
-
"jx.ai.model",
|
|
11
|
-
"jx.cf.token",
|
|
12
|
-
"jx.cf.accountId"
|
|
13
|
-
];
|
|
14
|
-
function readLocal(key) {
|
|
15
|
-
try {
|
|
16
|
-
return globalThis.localStorage?.getItem(key) ?? "";
|
|
17
|
-
} catch {
|
|
18
|
-
return "";
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
async function hydrateSettings() {
|
|
22
|
-
if (!hasPlatform()) {
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
const platform = getPlatform();
|
|
26
|
-
if (!platform.getSettings) {
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
let stored;
|
|
30
|
-
try {
|
|
31
|
-
stored = await platform.getSettings();
|
|
32
|
-
} catch {
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
const merged = { ...stored };
|
|
36
|
-
let needsMigration = false;
|
|
37
|
-
for (const key of PERSISTED_SETTINGS_KEYS) {
|
|
38
|
-
const backendValue = stored[key];
|
|
39
|
-
if (backendValue) {
|
|
40
|
-
try {
|
|
41
|
-
globalThis.localStorage?.setItem(key, backendValue);
|
|
42
|
-
} catch {}
|
|
43
|
-
} else {
|
|
44
|
-
const localValue = readLocal(key);
|
|
45
|
-
if (localValue) {
|
|
46
|
-
merged[key] = localValue;
|
|
47
|
-
needsMigration = true;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
if (needsMigration && platform.saveSettings) {
|
|
52
|
-
platform.saveSettings(merged).catch(() => {});
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
function persistSettings() {
|
|
56
|
-
if (!hasPlatform()) {
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
const platform = getPlatform();
|
|
60
|
-
if (!platform.saveSettings) {
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
const settings = {};
|
|
64
|
-
for (const key of PERSISTED_SETTINGS_KEYS) {
|
|
65
|
-
const value = readLocal(key);
|
|
66
|
-
if (value) {
|
|
67
|
-
settings[key] = value;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
platform.saveSettings(settings).catch(() => {});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// src/services/cf-settings.ts
|
|
74
|
-
var TOKEN_STORAGE = "jx.cf.token";
|
|
75
|
-
var ACCOUNT_STORAGE = "jx.cf.accountId";
|
|
76
|
-
function read(key) {
|
|
77
|
-
try {
|
|
78
|
-
return globalThis.localStorage?.getItem(key) ?? "";
|
|
79
|
-
} catch {
|
|
80
|
-
return "";
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
function write(key, value) {
|
|
84
|
-
try {
|
|
85
|
-
const trimmed = (value || "").trim();
|
|
86
|
-
if (trimmed) {
|
|
87
|
-
globalThis.localStorage?.setItem(key, trimmed);
|
|
88
|
-
} else {
|
|
89
|
-
globalThis.localStorage?.removeItem(key);
|
|
90
|
-
}
|
|
91
|
-
} catch {}
|
|
92
|
-
persistSettings();
|
|
93
|
-
}
|
|
94
|
-
function getCfToken() {
|
|
95
|
-
return read(TOKEN_STORAGE);
|
|
96
|
-
}
|
|
97
|
-
function setCfToken(token) {
|
|
98
|
-
write(TOKEN_STORAGE, token);
|
|
99
|
-
}
|
|
100
|
-
function getCfAccountId() {
|
|
101
|
-
return read(ACCOUNT_STORAGE);
|
|
102
|
-
}
|
|
103
|
-
function setCfAccountId(accountId) {
|
|
104
|
-
write(ACCOUNT_STORAGE, accountId);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export { hydrateSettings, persistSettings, getCfToken, setCfToken, getCfAccountId, setCfAccountId };
|
|
108
|
-
|
|
109
|
-
//# debugId=521A72F1905394A564756E2164756E21
|
|
110
|
-
//# sourceMappingURL=studio-a7pzveq1.js.map
|