@jxsuite/studio 0.33.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.
Files changed (106) hide show
  1. package/dist/iframe-entry.js +6238 -0
  2. package/dist/iframe-entry.js.map +35 -0
  3. package/dist/studio.js +30972 -17222
  4. package/dist/studio.js.map +328 -83
  5. package/package.json +9 -9
  6. package/src/browse/browse.ts +11 -4
  7. package/src/canvas/canvas-helpers.ts +2 -56
  8. package/src/canvas/canvas-live-render.ts +102 -435
  9. package/src/canvas/canvas-origin.ts +66 -0
  10. package/src/canvas/canvas-patcher.ts +63 -403
  11. package/src/canvas/canvas-render.ts +70 -212
  12. package/src/canvas/canvas-utils.ts +37 -65
  13. package/src/canvas/iframe-channel.ts +154 -0
  14. package/src/canvas/iframe-drop.ts +484 -0
  15. package/src/canvas/iframe-entry.ts +600 -0
  16. package/src/canvas/iframe-host.ts +1373 -0
  17. package/src/canvas/iframe-inline-edit.ts +367 -0
  18. package/src/canvas/iframe-insert.ts +164 -0
  19. package/src/canvas/iframe-interaction.ts +176 -0
  20. package/src/canvas/iframe-keys.ts +85 -0
  21. package/src/canvas/iframe-overlay.ts +218 -0
  22. package/src/canvas/iframe-patch.ts +363 -0
  23. package/src/canvas/iframe-protocol.ts +361 -0
  24. package/src/canvas/iframe-render.ts +458 -0
  25. package/src/canvas/iframe-slash.ts +114 -0
  26. package/src/canvas/iframe-subtree.ts +113 -0
  27. package/src/canvas/path-mapping.ts +86 -0
  28. package/src/canvas/serialize-scope.ts +65 -0
  29. package/src/editor/canvas-context-menu.ts +40 -0
  30. package/src/editor/canvas-slash-bridge.ts +21 -0
  31. package/src/editor/context-menu.ts +2 -1
  32. package/src/editor/inline-edit-apply.ts +183 -0
  33. package/src/editor/inline-edit.ts +99 -21
  34. package/src/editor/inline-link.ts +89 -0
  35. package/src/editor/insert-zone-action.ts +35 -0
  36. package/src/editor/merge-tags.ts +26 -2
  37. package/src/editor/repeater-scope.ts +144 -0
  38. package/src/editor/shortcuts.ts +14 -28
  39. package/src/editor/slash-menu.ts +73 -42
  40. package/src/files/files.ts +5 -1
  41. package/src/new-project/design-fields.ts +260 -0
  42. package/src/new-project/import-tab.ts +322 -0
  43. package/src/new-project/new-project-modal.ts +472 -89
  44. package/src/new-project/templates.ts +61 -0
  45. package/src/packages/ensure-deps.ts +6 -0
  46. package/src/packages/pull-package-sync.ts +339 -0
  47. package/src/page-params.ts +383 -0
  48. package/src/panels/ai-chat/attached-context.ts +49 -0
  49. package/src/panels/ai-chat/chat-markdown.ts +38 -0
  50. package/src/panels/ai-chat/chat-view.ts +250 -0
  51. package/src/panels/ai-chat/composer.ts +315 -0
  52. package/src/panels/ai-chat/sessions-view.ts +98 -0
  53. package/src/panels/ai-panel.ts +189 -457
  54. package/src/panels/block-action-bar.ts +296 -138
  55. package/src/panels/canvas-dnd-bridge.ts +397 -0
  56. package/src/panels/component-preview.ts +56 -0
  57. package/src/panels/dnd.ts +41 -17
  58. package/src/panels/drag-ghost.ts +62 -0
  59. package/src/panels/editors.ts +1 -1
  60. package/src/panels/git-panel.ts +16 -1
  61. package/src/panels/overlays.ts +10 -125
  62. package/src/panels/properties-panel.ts +210 -0
  63. package/src/panels/right-panel.ts +21 -7
  64. package/src/panels/signals-panel.ts +136 -22
  65. package/src/panels/stylebook-doc.ts +373 -0
  66. package/src/panels/stylebook-panel.ts +46 -689
  67. package/src/panels/tab-bar.ts +159 -13
  68. package/src/panels/toolbar.ts +3 -2
  69. package/src/platforms/devserver.ts +45 -1
  70. package/src/services/agent-seed.ts +91 -0
  71. package/src/services/ai-models.ts +64 -0
  72. package/src/services/ai-session-store.ts +250 -0
  73. package/src/services/ai-settings.ts +5 -0
  74. package/src/services/automation.ts +140 -0
  75. package/src/services/document-assistant.ts +98 -38
  76. package/src/services/import-client.ts +134 -0
  77. package/src/services/monaco-setup.ts +12 -0
  78. package/src/services/render-critic.ts +9 -9
  79. package/src/services/settings-store.ts +93 -0
  80. package/src/settings/css-vars-editor.ts +2 -2
  81. package/src/store.ts +4 -62
  82. package/src/studio.ts +115 -40
  83. package/src/tabs/doc-op-apply.ts +89 -0
  84. package/src/tabs/patch-ops.ts +6 -2
  85. package/src/tabs/tab.ts +23 -4
  86. package/src/tabs/transact.ts +2 -74
  87. package/src/types.ts +98 -18
  88. package/src/ui/ai-credentials-form.ts +205 -0
  89. package/src/ui/jx-theme.ts +63 -0
  90. package/src/ui/media-picker.ts +6 -4
  91. package/src/ui/spectrum.ts +13 -0
  92. package/src/utils/canvas-media.ts +0 -137
  93. package/src/utils/edit-display.ts +23 -3
  94. package/src/utils/geometry.ts +43 -0
  95. package/src/utils/link-target.ts +93 -0
  96. package/src/utils/strip-events.ts +54 -0
  97. package/src/view.ts +0 -23
  98. package/src/workspace/workspace.ts +14 -1
  99. package/src/canvas/canvas-subtree-render.ts +0 -113
  100. package/src/editor/component-inline-edit.ts +0 -349
  101. package/src/editor/content-inline-edit.ts +0 -207
  102. package/src/editor/insertion-helper.ts +0 -308
  103. package/src/panels/canvas-dnd.ts +0 -329
  104. package/src/panels/panel-events.ts +0 -306
  105. package/src/panels/preview-render.ts +0 -132
  106. package/src/panels/pseudo-preview.ts +0 -75
@@ -1,20 +1,23 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Tab bar — a per-tab contextual action bar rendered between the tab strip and the edit content.
4
- * Standardizes Back/breadcrumb navigation, media feature toggles, and mode actions (Code-mode
5
- * Export) into a single bar shared identically by every edit mode.
3
+ * Tab bar — a persistent per-tab settings bar rendered between the tab strip and the edit content.
4
+ * Standardizes Back/breadcrumb navigation, the view settings cluster (preview toggle, layout
5
+ * visibility, dynamic route-param pickers shown in the edit/design base modes), media feature
6
+ * toggles, and mode actions (Code-mode Export) into a single bar shared by every edit mode.
6
7
  *
7
8
  * Follows the same module shape as tab-strip.ts: mount(host, ctx) → effectScope/effect → render().
8
- * The bar collapses (renders `nothing`, so `#tab-bar:empty` hides the row) when there is nothing
9
- * contextual to show.
9
+ * The bar renders `nothing` (so `#tab-bar:empty` hides the row) only when no tab is active.
10
10
  */
11
11
 
12
12
  import { html, render as litRender, nothing } from "lit-html";
13
- import { updateUi } from "../store";
13
+ import { projectState, updateUi } from "../store";
14
14
  import { effect, effectScope } from "../reactivity";
15
15
  import { activeTab } from "../workspace/workspace";
16
- import { getEffectiveMedia } from "../site-context";
16
+ import { getEffectiveLayoutPath, getEffectiveMedia } from "../site-context";
17
+ import { dynamicRouteParams, loadParamValues, pagePathsDef } from "../page-params";
17
18
  import { mediaDisplayName } from "./shared";
19
+ import type { ParamValues } from "../page-params";
20
+ import type { Tab } from "../tabs/tab";
18
21
  import type { DocumentStackEntry, FunctionEditDef } from "../types";
19
22
  import type { EffectScope } from "@vue/reactivity";
20
23
  import type { TemplateResult } from "lit-html";
@@ -59,12 +62,16 @@ export function mount(host: HTMLElement, ctx: TabBarCtx) {
59
62
  if (tab) {
60
63
  // Read reactive properties to establish tracking — mirrors the toolbar's subset
61
64
  void tab.doc.document;
65
+ void tab.doc.document?.$layout;
62
66
  void tab.doc.mode;
63
67
  void tab.documentPath;
64
68
  void tab.session.documentStack.length;
65
69
  void tab.session.ui.canvasMode;
66
70
  void tab.session.ui.editingFunction;
67
71
  void tab.session.ui.featureToggles;
72
+ void tab.session.ui.preview;
73
+ void tab.session.ui.previewParams;
74
+ void tab.session.ui.showLayout;
68
75
  }
69
76
  render();
70
77
  });
@@ -143,6 +150,55 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
143
150
  `;
144
151
  }
145
152
 
153
+ // ── Right region: view settings cluster (edit/design base modes only) ──
154
+ // Gates on the BASE mode (not the effective mode): the cluster stays visible while the preview
155
+ // Toggle is on so it can be toggled back off.
156
+ const baseMode = S.ui.canvasMode;
157
+ const isPage = Boolean(
158
+ S.documentPath &&
159
+ projectState?.isSiteProject &&
160
+ (S.documentPath.startsWith("pages/") || S.documentPath.startsWith("./pages/")),
161
+ );
162
+ let settingsTpl: TemplateResult | typeof nothing = nothing;
163
+ if (!editing && (baseMode === "edit" || baseMode === "design")) {
164
+ const canPreview = tab.capabilities.modes.includes("preview");
165
+ const hasLayout = isPage && Boolean(getEffectiveLayoutPath(S.document?.$layout));
166
+ const pickersTpl = isPage ? paramPickersTpl(tab) : nothing;
167
+ const previewTpl = canPreview
168
+ ? html`
169
+ <sp-action-button
170
+ toggles
171
+ size="s"
172
+ title="Preview resolved values"
173
+ ?selected=${Boolean(S.ui.preview)}
174
+ @click=${() => updateUi("preview", !S.ui.preview)}
175
+ >
176
+ <sp-icon-preview slot="icon"></sp-icon-preview>
177
+ Preview
178
+ </sp-action-button>
179
+ `
180
+ : nothing;
181
+ const layoutTpl = hasLayout
182
+ ? html`
183
+ <sp-action-button
184
+ toggles
185
+ size="s"
186
+ title="Show layout elements"
187
+ ?selected=${S.ui.showLayout !== false}
188
+ @click=${() => updateUi("showLayout", S.ui.showLayout === false)}
189
+ >
190
+ Layout
191
+ </sp-action-button>
192
+ `
193
+ : nothing;
194
+ if (pickersTpl !== nothing || previewTpl !== nothing || layoutTpl !== nothing) {
195
+ settingsTpl = html`
196
+ ${pickersTpl}
197
+ <sp-action-group compact size="s">${layoutTpl} ${previewTpl}</sp-action-group>
198
+ `;
199
+ }
200
+ }
201
+
146
202
  // ── Right region: media feature toggles ──
147
203
  const { featureQueries } = ctx.parseMediaEntries(getEffectiveMedia(S.document?.$media));
148
204
  const togglesTpl =
@@ -183,16 +239,106 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
183
239
  `
184
240
  : nothing;
185
241
 
186
- // Collapse the bar entirely when there is nothing contextual to show.
187
- if (navTpl === nothing && togglesTpl === nothing && exportTpl === nothing) {
188
- return nothing;
189
- }
190
-
191
242
  return html`
192
243
  <div class="tab-bar">
193
244
  ${navTpl}
194
245
  <div class="tb-spacer"></div>
195
- ${togglesTpl} ${exportTpl}
246
+ ${settingsTpl} ${togglesTpl} ${exportTpl}
196
247
  </div>
197
248
  `;
198
249
  }
250
+
251
+ // ── Dynamic route-param pickers ──────────────────────────────────────────────
252
+ // Candidate values load asynchronously (ContentCollection resolution / data-file read); the module
253
+ // Caches the last result per (documentPath, $paths) and re-renders when it lands — the same lazy
254
+ // Fill pattern as head-panel's loadLayoutEntries. When values arrive, any param without a chosen
255
+ // Value auto-selects the first candidate (matching the compiler, whose first expanded route is the
256
+ // First path entry).
257
+
258
+ let _paramValues: ParamValues | null = null;
259
+
260
+ let _paramValuesKey: string | null = null;
261
+
262
+ /**
263
+ * @param {Tab} tab
264
+ * @returns {ParamValues | null} — null while loading (or when the doc declares no params)
265
+ */
266
+ function paramValuesFor(tab: Tab): ParamValues | null {
267
+ const pathsDef = pagePathsDef({
268
+ document: tab.doc.document,
269
+ frontmatter: tab.doc.content.frontmatter,
270
+ });
271
+ if (!pathsDef && dynamicRouteParams(tab.documentPath).length === 0) {
272
+ return null;
273
+ }
274
+ const key = `${tab.documentPath ?? ""}::${JSON.stringify(pathsDef)}`;
275
+ if (_paramValuesKey === key) {
276
+ return _paramValues;
277
+ }
278
+ _paramValuesKey = key;
279
+ _paramValues = null;
280
+ void loadParamValues(tab.documentPath, pathsDef).then((values) => {
281
+ if (_paramValuesKey !== key || activeTab.value !== tab) {
282
+ return;
283
+ }
284
+ _paramValues = values;
285
+ autoSelectParams(tab, values);
286
+ render();
287
+ });
288
+ return _paramValues;
289
+ }
290
+
291
+ /**
292
+ * @param {Tab} tab
293
+ * @param {ParamValues} values
294
+ */
295
+ function autoSelectParams(tab: Tab, values: ParamValues) {
296
+ const current = tab.session.ui.previewParams ?? {};
297
+ const additions: Record<string, string> = {};
298
+ for (const [name, list] of Object.entries(values)) {
299
+ if (!current[name] && list.length > 0) {
300
+ additions[name] = list[0]!;
301
+ }
302
+ }
303
+ if (Object.keys(additions).length > 0) {
304
+ updateUi("previewParams", { ...current, ...additions });
305
+ }
306
+ }
307
+
308
+ /**
309
+ * @param {Tab} tab
310
+ * @returns {TemplateResult | typeof nothing}
311
+ */
312
+ function paramPickersTpl(tab: Tab): TemplateResult | typeof nothing {
313
+ const values = paramValuesFor(tab);
314
+ const names = new Set(dynamicRouteParams(tab.documentPath));
315
+ for (const name of Object.keys(values ?? {})) {
316
+ names.add(name);
317
+ }
318
+ if (names.size === 0) {
319
+ return nothing;
320
+ }
321
+ const { previewParams } = tab.session.ui;
322
+ return html`
323
+ ${[...names].map(
324
+ (name) => html`
325
+ <sp-picker
326
+ size="s"
327
+ quiet
328
+ class="tab-bar-param"
329
+ label=${name}
330
+ title=${`Preview value for [${name}]`}
331
+ value=${previewParams?.[name] ?? ""}
332
+ @change=${(e: Event) => {
333
+ const { value } = e.target as HTMLInputElement;
334
+ updateUi("previewParams", { ...tab.session.ui.previewParams, [name]: value });
335
+ }}
336
+ >
337
+ ${(values?.[name] ?? []).map(
338
+ (v: string) => html`<sp-menu-item value=${v}>${v}</sp-menu-item>`,
339
+ )}
340
+ </sp-picker>
341
+ `,
342
+ )}
343
+ `;
344
+ }
@@ -305,7 +305,6 @@ function minimalToolbarTemplate(ctx: ToolbarCtx) {
305
305
  const modes = [
306
306
  { iconTag: "sp-icon-edit", key: "edit", label: "Edit" },
307
307
  { iconTag: "sp-icon-artboard", key: "design", label: "Design" },
308
- { iconTag: "sp-icon-preview", key: "preview", label: "Preview" },
309
308
  { iconTag: "sp-icon-code", key: "source", label: "Code" },
310
309
  { iconTag: "sp-icon-brush", key: "stylebook", label: "Stylebook" },
311
310
  ];
@@ -333,7 +332,9 @@ function toolbarTemplate() {
333
332
  selection: tab.session.selection,
334
333
  ui: tab.session.ui,
335
334
  };
336
- const canvasMode = ctx.getCanvasMode();
335
+ // Base mode, not the effective mode: the switcher keeps Edit/Design highlighted while the
336
+ // Tab-bar preview toggle is on (preview is no longer a switchable mode).
337
+ const { canvasMode } = tab.session.ui;
337
338
 
338
339
  const modeSwitcherTpl = html`
339
340
  <sp-action-group selects="single" size="s" compact>
@@ -8,8 +8,16 @@
8
8
  * See spec/desktop.md §8 for the full specification.
9
9
  */
10
10
 
11
+ import { streamImport } from "../services/import-client";
11
12
  import type { ProjectConfig } from "@jxsuite/schema/types";
12
- import type { DirEntry, FsEvent, RenameResult } from "../types";
13
+ import type {
14
+ DirEntry,
15
+ FsEvent,
16
+ ImportProgressEvent,
17
+ ImportSiteOptions,
18
+ RenameResult,
19
+ StarterInfo,
20
+ } from "../types";
13
21
 
14
22
  /** A directory entry from the server, tolerating extra wire fields. */
15
23
  type WireDirEntry = DirEntry & Record<string, unknown>;
@@ -215,6 +223,9 @@ export function createDevServerPlatform() {
215
223
  url?: string;
216
224
  adapter?: string;
217
225
  directory: string;
226
+ starter?: string;
227
+ template?: string;
228
+ design?: Record<string, unknown>;
218
229
  }) {
219
230
  const res = await fetch("/__studio/create-project", {
220
231
  body: JSON.stringify(opts),
@@ -228,6 +239,24 @@ export function createDevServerPlatform() {
228
239
  return await res.json();
229
240
  },
230
241
 
242
+ /** List starter templates from the dev server. */
243
+ async listStarters(): Promise<StarterInfo[]> {
244
+ const res = await fetch("/__studio/starters");
245
+ if (!res.ok) {
246
+ throw new Error("Failed to load starters");
247
+ }
248
+ return await readJson<StarterInfo[]>(res);
249
+ },
250
+
251
+ /** AI-guided site import: NDJSON progress stream from the dev server's import endpoint. */
252
+ async importSite(
253
+ opts: ImportSiteOptions,
254
+ onProgress: (evt: ImportProgressEvent) => void,
255
+ signal?: AbortSignal,
256
+ ) {
257
+ return await streamImport("/__studio/import-site", opts, onProgress, signal);
258
+ },
259
+
231
260
  // ─── File operations ──────────────────────────────────────────────────
232
261
 
233
262
  /** @param {string} dir */
@@ -588,6 +617,21 @@ export function createDevServerPlatform() {
588
617
  return schema;
589
618
  },
590
619
 
620
+ // ─── Class resolution (dev-proxy) ─────────────────────────────────────
621
+
622
+ /** @param {Record<string, unknown>} body */
623
+ async resolveClass(body: Record<string, unknown>) {
624
+ const res = await fetch("/__jx_resolve__", {
625
+ body: JSON.stringify(body),
626
+ headers: { "content-type": "application/json" },
627
+ method: "POST",
628
+ });
629
+ if (!res.ok) {
630
+ throw new Error(`Class resolution failed: ${res.status}`);
631
+ }
632
+ return await readJson<unknown>(res);
633
+ },
634
+
591
635
  // ─── Git operations ──────────────────────────────────────────────────
592
636
 
593
637
  async gitStatus() {
@@ -0,0 +1,91 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Agent-seed.ts — cross-window handoff of a pending AI assistant prompt.
4
+ *
5
+ * The New Project flow stores the user's project brief here; the Studio window that opens the
6
+ * project consumes it and seeds the Assistant tab. localStorage (not module state) is deliberate:
7
+ * on desktop the new project opens in a NEW window, and the per-root localStorage key is the
8
+ * cross-window channel (precedent: the chat-history key `jx-ai-chat-history:<root>`).
9
+ *
10
+ * @license MIT
11
+ */
12
+
13
+ const PENDING_PROMPT_PREFIX = "jx.ai.pendingAgentPrompt";
14
+
15
+ /** Entries older than this are considered stale and dropped on read. */
16
+ const MAX_AGE_MS = 15 * 60 * 1000;
17
+
18
+ function storageKey(root: string) {
19
+ return `${PENDING_PROMPT_PREFIX}:${root}`;
20
+ }
21
+
22
+ /**
23
+ * Read the pending entry for a project root, dropping stale or malformed entries.
24
+ *
25
+ * @param {string} root
26
+ * @returns {{ prompt: string; ts: number } | null}
27
+ */
28
+ function readEntry(root: string): { prompt: string; ts: number } | null {
29
+ try {
30
+ const raw = globalThis.localStorage?.getItem(storageKey(root));
31
+ if (!raw) {
32
+ return null;
33
+ }
34
+ const parsed = JSON.parse(raw) as { prompt?: unknown; ts?: unknown };
35
+ const valid =
36
+ typeof parsed.prompt === "string" &&
37
+ typeof parsed.ts === "number" &&
38
+ Date.now() - parsed.ts <= MAX_AGE_MS;
39
+ if (!valid) {
40
+ // Stale or malformed — remove so it never seeds a later session.
41
+ globalThis.localStorage?.removeItem(storageKey(root));
42
+ return null;
43
+ }
44
+ return { prompt: parsed.prompt as string, ts: parsed.ts as number };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Store a pending assistant prompt for the project at `root`.
52
+ *
53
+ * @param {string} root
54
+ * @param {string} prompt
55
+ */
56
+ export function setPendingAgentPrompt(root: string, prompt: string): void {
57
+ try {
58
+ globalThis.localStorage?.setItem(storageKey(root), JSON.stringify({ prompt, ts: Date.now() }));
59
+ } catch {
60
+ /* LocalStorage unavailable — the prompt is simply not handed off. */
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Whether a fresh (younger than 15 min) pending prompt exists for `root`.
66
+ *
67
+ * @param {string} root
68
+ */
69
+ export function hasPendingAgentPrompt(root: string): boolean {
70
+ return readEntry(root) !== null;
71
+ }
72
+
73
+ /**
74
+ * Read AND delete the pending prompt for `root` (consume-on-read keeps repeated renders
75
+ * idempotent). Returns null when there is no fresh entry.
76
+ *
77
+ * @param {string} root
78
+ * @returns {string | null}
79
+ */
80
+ export function consumePendingAgentPrompt(root: string): string | null {
81
+ const entry = readEntry(root);
82
+ if (!entry) {
83
+ return null;
84
+ }
85
+ try {
86
+ globalThis.localStorage?.removeItem(storageKey(root));
87
+ } catch {
88
+ /* LocalStorage unavailable — nothing to delete. */
89
+ }
90
+ return entry.prompt;
91
+ }
@@ -0,0 +1,64 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Ai-models.js — available-model listing from the studio AI proxy.
4
+ *
5
+ * Fetches the proxy's /models endpoint (the chat endpoint's sibling), forwarding the
6
+ * stored API key as X-Api-Key and the endpoint override as X-Api-Base-URL so the proxy
7
+ * lists models from the user's chosen provider. Shared by the credentials form and the
8
+ * chat composer's model picker; results are cached module-wide until invalidated (e.g.
9
+ * after credentials change).
10
+ *
11
+ * @license MIT
12
+ */
13
+
14
+ import { getPlatform } from "../platform";
15
+ import { getBaseUrl, getOpenAiKey } from "./ai-settings";
16
+
17
+ export interface AiModel {
18
+ id: string;
19
+ name: string;
20
+ }
21
+
22
+ let cache: AiModel[] | null = null;
23
+
24
+ /** Drop the cached model list (call after credentials/endpoint changes). */
25
+ export function invalidateModelCache() {
26
+ cache = null;
27
+ }
28
+
29
+ /**
30
+ * Fetch the models available through the AI proxy. Returns the cached list when present unless
31
+ * `force` is set; throws on HTTP/network failure (callers surface the error).
32
+ *
33
+ * @param {{ apiKey?: string; baseUrl?: string; force?: boolean }} [opts] - Credential overrides for
34
+ * drafts not yet saved to ai-settings.
35
+ * @returns {Promise<AiModel[]>}
36
+ */
37
+ export async function fetchAvailableModels(
38
+ opts: { apiKey?: string; baseUrl?: string; force?: boolean } = {},
39
+ ): Promise<AiModel[]> {
40
+ if (cache && !opts.force) {
41
+ return cache;
42
+ }
43
+ const plat = getPlatform();
44
+ const chatUrl = await Promise.resolve(plat.aiChatUrl());
45
+ const modelsUrl = chatUrl.replace(/\/chat$/, "/models");
46
+
47
+ const headers: Record<string, string> = {};
48
+ const apiKey = opts.apiKey || getOpenAiKey();
49
+ if (apiKey) {
50
+ headers["X-Api-Key"] = apiKey;
51
+ }
52
+ const baseUrl = opts.baseUrl || getBaseUrl();
53
+ if (baseUrl) {
54
+ headers["X-Api-Base-URL"] = baseUrl;
55
+ }
56
+
57
+ const resp = await fetch(modelsUrl, { headers });
58
+ if (!resp.ok) {
59
+ throw new Error(`HTTP ${resp.status}`);
60
+ }
61
+ const data = (await resp.json()) as { models?: { id: string; name?: string }[] };
62
+ cache = (data.models || []).map((m) => ({ id: m.id, name: m.name || m.id }));
63
+ return cache;
64
+ }