@jxsuite/studio 4.0.0 → 4.1.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/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
- package/dist/chunks/{Overlay.dev-723bv0df.js → Overlay.dev-etqjwyf9.js} +4 -4
- package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
- package/dist/chunks/{content-source-t88vb2dr.js → content-source-48xg86wj.js} +17 -17
- package/dist/chunks/{convert-to-repeater-dhw1nrz9.js → convert-to-repeater-43tfn7cs.js} +24 -24
- package/dist/chunks/{entry-commands-6z6bhxg0.js → entry-commands-e63f1xc0.js} +20 -20
- package/dist/chunks/{extension-sections-3ydgjbdy.js → extension-sections-txv08xxg.js} +23 -23
- package/dist/chunks/{file-ops-rskmhxyq.js → file-ops-wbh4hhsx.js} +16 -16
- package/dist/chunks/{files-0cratsr0.js → files-3kfkwhk4.js} +25 -24
- package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
- package/dist/chunks/{format-host-tgcmfs4p.js → format-host-w16q81tf.js} +2 -2
- package/dist/chunks/fs-events-hrrg328g.js +1 -1
- package/dist/chunks/{git-panel-0yywyk76.js → git-panel-532whr1y.js} +16 -15
- package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
- package/dist/chunks/{jx-validate-8q2g05x1.js → jx-validate-8er6gbxy.js} +3 -3
- package/dist/chunks/{layers-panel-z3tqe5cd.js → layers-panel-07hm7g5j.js} +19 -19
- package/dist/chunks/{monaco-setup-rws7k4br.js → monaco-setup-vx9bhp1d.js} +2 -2
- package/dist/chunks/{publish-panel-54jvhskq.js → publish-panel-v9st96ja.js} +11 -10
- package/dist/chunks/{quick-search-cgfmjwq5.js → quick-search-zccjp6gv.js} +26 -25
- package/dist/chunks/{right-panel-cqp8bc4v.js → right-panel-vm3b6kwy.js} +17 -17
- package/dist/chunks/{sp-overlay.dev-yztmtrs1.js → sp-overlay.dev-qgvhnxf4.js} +5 -5
- package/dist/chunks/{sp-tooltip.dev-xcvpnqr0.js → sp-tooltip.dev-4sastdnw.js} +3 -3
- package/dist/chunks/{studio-txwkm6p9.js → studio-0bq6eh7v.js} +2 -2
- package/dist/chunks/studio-1ngbejft.js +181 -0
- package/dist/chunks/{studio-gv8vvshc.js → studio-1wagb550.js} +3 -3
- package/dist/chunks/{studio-kjjsjak4.js → studio-2b946z26.js} +58 -57
- package/dist/chunks/{studio-4pqqxbrt.js → studio-3bqpr0zh.js} +16 -16
- package/dist/chunks/{studio-v38hmvx1.js → studio-4b5z1x43.js} +42 -8
- package/dist/chunks/{studio-9c4k4vhv.js → studio-4n2b5gzb.js} +4 -4
- package/dist/chunks/{studio-p8j86wag.js → studio-4r1pv6zf.js} +2 -2
- package/dist/chunks/{studio-mmv72a27.js → studio-5fx3g9jg.js} +29 -48
- package/dist/chunks/{studio-5rywrkxz.js → studio-5myknm0a.js} +42 -42
- package/dist/chunks/{studio-sf7zjgv1.js → studio-6yw4y3my.js} +3 -3
- package/dist/chunks/{studio-pdfjntbk.js → studio-950k0dx6.js} +35 -35
- package/dist/chunks/{studio-eenfkt4e.js → studio-ahn3pdgx.js} +5 -5
- package/dist/chunks/{studio-eesa1myv.js → studio-b55y6ewx.js} +9 -9
- package/dist/chunks/{studio-cb12yz9w.js → studio-cmb03bg5.js} +99 -1264
- package/dist/chunks/studio-dm19q43r.js +195 -0
- package/dist/chunks/{studio-ahjfdy6e.js → studio-ef49pnzx.js} +6 -6
- package/dist/chunks/{studio-wx5609xd.js → studio-ew0djmhg.js} +10 -10
- package/dist/chunks/{studio-9r6j5ev1.js → studio-f3wqjtk5.js} +4 -3
- package/dist/chunks/{studio-8swrjbny.js → studio-fbf92ckf.js} +14 -14
- package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
- package/dist/chunks/{studio-ex8zyp98.js → studio-gfgb8pe6.js} +23 -184
- package/dist/chunks/{studio-r1r7afxx.js → studio-jc994dnr.js} +24 -24
- package/dist/chunks/{studio-yxrkyv8a.js → studio-jkftxyvz.js} +52 -3416
- package/dist/chunks/{studio-hbtp0eh1.js → studio-msd847zn.js} +13 -13
- package/dist/chunks/{studio-kyk2v92a.js → studio-mtfnaqs9.js} +14 -14
- package/dist/chunks/{studio-vadzgkrz.js → studio-n6j9t10q.js} +2 -2
- package/dist/chunks/{studio-68n67094.js → studio-q9jd1a60.js} +4 -4
- package/dist/chunks/studio-qcy05dp8.js +1743 -0
- package/dist/chunks/{studio-htsf0w6m.js → studio-qjy2z9bv.js} +12 -30
- package/dist/chunks/{studio-2zkz8evn.js → studio-trxrabqf.js} +12 -12
- package/dist/chunks/{studio-5q69gfag.js → studio-x4qkdat5.js} +12 -11
- package/dist/chunks/studio-x813zp48.js +195 -0
- package/dist/chunks/{studio-1hbqnt19.js → studio-zqazsct2.js} +29 -29
- package/dist/chunks/{workspace-xpres40v.js → workspace-fjx52ryv.js} +4 -4
- package/dist/iframe-entry.js +34 -33
- package/dist/manifest.json +7 -0
- package/dist/studio.js +534 -546
- package/favicon.ico +0 -0
- package/index.html +1 -0
- package/package.json +15 -14
- package/src/about/about-modal.ts +4 -3
- package/src/files/files.ts +6 -4
- package/src/files/media-paths.ts +13 -42
- package/src/files/media-usage.ts +41 -131
- package/src/hosting/document.ts +2 -0
- package/src/hosting/layout.ts +8 -0
- package/src/new-project/import-tab.ts +37 -15
- package/src/new-project/location-fields.ts +8 -5
- package/src/panels/ai-chat/composer.ts +10 -0
- package/src/platforms/cloud.ts +241 -70
- package/src/publish/publish-panel.ts +62 -2
- package/src/services/ai-import-tools.ts +50 -12
- package/src/services/ai-models.ts +108 -1
- package/src/services/context-manager.ts +12 -1
- package/src/services/tool-executor.ts +11 -0
- package/src/settings/preferences-accounts.ts +193 -12
- package/src/settings/preferences-dialog.ts +75 -18
- package/src/types.ts +27 -3
- package/src/ui/ai-managed-connect.ts +62 -13
- package/src/ui/ai-model-picker.ts +29 -2
- package/src/ui/cf-account-picker.ts +200 -0
- package/styles/shell.css +36 -0
- package/dist/chunks/studio-bw5dz6km.js +0 -608
|
@@ -20,6 +20,16 @@ import { onSettingsChanged } from "./settings/kernel";
|
|
|
20
20
|
export interface AiModel {
|
|
21
21
|
id: string;
|
|
22
22
|
name: string;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the backend says the model can call tools.
|
|
25
|
+
*
|
|
26
|
+
* Absent when it did not say, which is the ordinary case for a BYOK provider — only a managed
|
|
27
|
+
* catalogue knows. `false` is therefore a statement and `undefined` is silence, and no gate may
|
|
28
|
+
* collapse the two: a Workers AI chat-only model must be labelled, an OpenAI model must not.
|
|
29
|
+
*/
|
|
30
|
+
toolSupport?: boolean;
|
|
31
|
+
/** The backend's declared context window in tokens, when it declares one. */
|
|
32
|
+
contextWindow?: number;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
/**
|
|
@@ -74,6 +84,23 @@ let proxyCode: AiModelsResponse["code"];
|
|
|
74
84
|
let proxyProbe: Promise<void> | null = null;
|
|
75
85
|
const probeListeners = new Set<() => void>();
|
|
76
86
|
|
|
87
|
+
/**
|
|
88
|
+
* How long a settled probe is trusted before a window refocus re-runs it.
|
|
89
|
+
*
|
|
90
|
+
* The probe is one-shot by construction (`proxyProbe ??=`) and the only thing that re-arms it is
|
|
91
|
+
* the settings subscription below — which never fires for a HOSTED grant, because nothing about it
|
|
92
|
+
* is stored in this browser. So a Cloudflare grant that lapsed mid-session left every gate showing
|
|
93
|
+
* the reading it took at boot: the assistant reported itself configured, and every send failed. Ten
|
|
94
|
+
* minutes is well inside the hour a Cloudflare access token lives.
|
|
95
|
+
*/
|
|
96
|
+
export const PROBE_STALE_MS = 600_000;
|
|
97
|
+
|
|
98
|
+
/** When the probe last settled (0 = never). The age {@link PROBE_STALE_MS} is measured against. */
|
|
99
|
+
let probeSettledAt = 0;
|
|
100
|
+
|
|
101
|
+
/** Whether {@link installProbeRefresh} has already registered its listener. */
|
|
102
|
+
let probeRefreshInstalled = false;
|
|
103
|
+
|
|
77
104
|
/**
|
|
78
105
|
* Drop the cached list and the capability reading.
|
|
79
106
|
*
|
|
@@ -93,6 +120,7 @@ export function resetModelCache() {
|
|
|
93
120
|
would strand every gate on a permanent "unconfigured, unmanaged" reading — ensureProxyProbe
|
|
94
121
|
would no-op forever and the managed option would vanish until a full reload. */
|
|
95
122
|
proxyProbe = null;
|
|
123
|
+
probeSettledAt = 0;
|
|
96
124
|
}
|
|
97
125
|
|
|
98
126
|
/** The keys whose value changes what a provider would answer with. */
|
|
@@ -117,6 +145,33 @@ export function cachedModels(credentials: AiCredentials): AiModel[] | null {
|
|
|
117
145
|
return cache && cacheKey === fingerprint(credentials) ? cache : null;
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
/**
|
|
149
|
+
* A model's entry in whatever list is cached, by id.
|
|
150
|
+
*
|
|
151
|
+
* Deliberately NOT keyed by credentials, unlike {@link cachedModels}. That key exists because
|
|
152
|
+
* SHOWING one provider's catalogue under another's credentials is a lie about what is available;
|
|
153
|
+
* asking "what did the backend say about this id" is not — the answer is either recorded or it is
|
|
154
|
+
* not, and a mismatched key just yields `undefined`, which every caller already has to handle.
|
|
155
|
+
*/
|
|
156
|
+
function cachedModel(id: string): AiModel | undefined {
|
|
157
|
+
return cache?.find((model) => model.id === id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether the backend said this model can call tools — `undefined` when it said nothing.
|
|
162
|
+
*
|
|
163
|
+
* The distinction is the whole point: a chat-only model is a legitimate choice, so this labels and
|
|
164
|
+
* warns rather than gating, and silence must read as "no opinion" rather than as "no tools".
|
|
165
|
+
*/
|
|
166
|
+
export function modelToolSupport(id: string): boolean | undefined {
|
|
167
|
+
return cachedModel(id)?.toolSupport;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The backend's declared context window for a model, in tokens — `undefined` when it declared none. */
|
|
171
|
+
export function modelContextWindow(id: string): number | undefined {
|
|
172
|
+
return cachedModel(id)?.contextWindow;
|
|
173
|
+
}
|
|
174
|
+
|
|
120
175
|
/**
|
|
121
176
|
* Run the capability probe once, repainting `onSettled` when it lands. Idempotent: concurrent and
|
|
122
177
|
* repeated callers share the single in-flight fetch, and every registered host is notified. Probe
|
|
@@ -133,12 +188,50 @@ export function ensureProxyProbe(onSettled?: () => void) {
|
|
|
133
188
|
// Unreachable proxy — the key gate stays up.
|
|
134
189
|
})
|
|
135
190
|
.then(() => {
|
|
191
|
+
probeSettledAt = Date.now();
|
|
136
192
|
for (const notify of probeListeners) {
|
|
137
193
|
notify();
|
|
138
194
|
}
|
|
139
195
|
});
|
|
140
196
|
}
|
|
141
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Re-run the capability probe when it is older than {@link PROBE_STALE_MS} and the backend holds
|
|
200
|
+
* the credentials. Returns whether it re-armed, so a caller (and a test) can see that it did.
|
|
201
|
+
*
|
|
202
|
+
* Managed-only on purpose. A BYOK reading changes only when the stored key does, and the settings
|
|
203
|
+
* subscription already covers that; a hosted grant expires on a clock this browser cannot see.
|
|
204
|
+
*/
|
|
205
|
+
export function refreshStaleProbe(): boolean {
|
|
206
|
+
if (!proxyManaged || probeSettledAt === 0 || Date.now() - probeSettledAt < PROBE_STALE_MS) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
proxyProbe = null;
|
|
210
|
+
probeSettledAt = 0;
|
|
211
|
+
ensureProxyProbe();
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Re-check the probe whenever the window regains focus, which is where a lapsed grant is noticed.
|
|
217
|
+
*
|
|
218
|
+
* `focus` rather than a timer: the reading only matters when somebody is looking at Studio, and the
|
|
219
|
+
* OAuth reconnect the stale reading would have hidden happens in another tab — so coming back IS
|
|
220
|
+
* the event. Idempotent, and a no-op outside a browser so the module stays importable under a
|
|
221
|
+
* bare-`bun` runner.
|
|
222
|
+
*/
|
|
223
|
+
export function installProbeRefresh(): void {
|
|
224
|
+
if (typeof window === "undefined" || probeRefreshInstalled) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
probeRefreshInstalled = true;
|
|
228
|
+
window.addEventListener("focus", () => {
|
|
229
|
+
refreshStaleProbe();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
installProbeRefresh();
|
|
234
|
+
|
|
142
235
|
/**
|
|
143
236
|
* Whether the assistant can run at all: a key stored in this browser, or a backend that holds
|
|
144
237
|
* credentials itself (managed cloud platforms, env-keyed dev servers). Every credentials gate must
|
|
@@ -263,7 +356,21 @@ export async function fetchAvailableModels(
|
|
|
263
356
|
proxyManaged = data.managed === true;
|
|
264
357
|
proxyDefaultModel = data.defaultModel ?? "";
|
|
265
358
|
proxyCode = data.code;
|
|
266
|
-
|
|
359
|
+
/* Capabilities are kept, not dropped. The backend has reported `toolSupport` all along and the
|
|
360
|
+
ingest mapped `{id, name}` only, so a Workers AI model that cannot call tools looked exactly
|
|
361
|
+
like one that can — the agent loop ran, called nothing, and answered as if that were normal.
|
|
362
|
+
The keys are OMITTED rather than set to undefined: absent means "the backend said nothing",
|
|
363
|
+
which is a third state both consumers depend on. */
|
|
364
|
+
cache = (data.models || []).map((m) => {
|
|
365
|
+
const model: AiModel = { id: m.id, name: m.name || m.id };
|
|
366
|
+
if (typeof m.contextWindow === "number") {
|
|
367
|
+
model.contextWindow = m.contextWindow;
|
|
368
|
+
}
|
|
369
|
+
if (typeof m.toolSupport === "boolean") {
|
|
370
|
+
model.toolSupport = m.toolSupport;
|
|
371
|
+
}
|
|
372
|
+
return model;
|
|
373
|
+
});
|
|
267
374
|
cacheKey = key;
|
|
268
375
|
return cache;
|
|
269
376
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { createChatState } from "@jxsuite/ai/chat-state";
|
|
15
|
+
import { modelContextWindow } from "./ai-models";
|
|
15
16
|
|
|
16
17
|
/** Shape of a single entry returned by `chatState.toMessagesArray()`. */
|
|
17
18
|
interface MessageArrayEntry {
|
|
@@ -52,7 +53,13 @@ const BUDGET_FRACTION = 0.8;
|
|
|
52
53
|
const WARN_FRACTION = 0.5;
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
* Resolve the context window (tokens) for a model id
|
|
56
|
+
* Resolve the context window (tokens) for a model id: what the backend reported, else a
|
|
57
|
+
* longest-prefix match on the table above, else the conservative default.
|
|
58
|
+
*
|
|
59
|
+
* The backend's number goes first because the table can only ever know the names it was written
|
|
60
|
+
* with. Every `@cf/*` model missed it and was budgeted at {@link DEFAULT_CONTEXT_WINDOW}, so a
|
|
61
|
+
* managed 128k model started dropping the oldest turns at about 25.6k — a fifth of what it could
|
|
62
|
+
* hold, on the one backend that actually publishes the figure.
|
|
56
63
|
*
|
|
57
64
|
* @param {string | undefined} model
|
|
58
65
|
* @returns {number}
|
|
@@ -61,6 +68,10 @@ function contextWindowFor(model: string | undefined): number {
|
|
|
61
68
|
if (!model) {
|
|
62
69
|
return DEFAULT_CONTEXT_WINDOW;
|
|
63
70
|
}
|
|
71
|
+
const reported = modelContextWindow(model);
|
|
72
|
+
if (reported !== undefined && reported > 0) {
|
|
73
|
+
return reported;
|
|
74
|
+
}
|
|
64
75
|
const id = model.toLowerCase();
|
|
65
76
|
let best = 0;
|
|
66
77
|
let window = DEFAULT_CONTEXT_WINDOW;
|
|
@@ -27,6 +27,7 @@ import type { ToolRegistry } from "@jxsuite/ai/tools";
|
|
|
27
27
|
|
|
28
28
|
import type { Tab } from "../tabs/tab";
|
|
29
29
|
import { batchTab, beginBatch, endBatch } from "../tabs/transact";
|
|
30
|
+
import { ensureProxyProbe, resetModelCache } from "./ai-models";
|
|
30
31
|
import { beginTurn, endTurn } from "./ai-writes";
|
|
31
32
|
import { beginToolCall, beginTurnSignal, endTurnSignal } from "./ai-turn-signal";
|
|
32
33
|
|
|
@@ -112,6 +113,7 @@ export async function runAgentLoop({
|
|
|
112
113
|
const toolCalls = new Map<string, { name: string; arguments: string }>();
|
|
113
114
|
let stopReason = "stop";
|
|
114
115
|
let streamError = null;
|
|
116
|
+
let streamErrorCode: string | undefined;
|
|
115
117
|
|
|
116
118
|
for await (const event of streamingClient.streamChat(
|
|
117
119
|
messages,
|
|
@@ -147,6 +149,7 @@ export async function runAgentLoop({
|
|
|
147
149
|
}
|
|
148
150
|
case "error": {
|
|
149
151
|
streamError = event.message;
|
|
152
|
+
streamErrorCode = event.code;
|
|
150
153
|
break;
|
|
151
154
|
}
|
|
152
155
|
default: {
|
|
@@ -158,6 +161,14 @@ export async function runAgentLoop({
|
|
|
158
161
|
chatState.finishStream(stopReason);
|
|
159
162
|
|
|
160
163
|
if (streamError) {
|
|
164
|
+
/* A lapsed hosted grant is the one stream error that makes the app's OWN reading wrong.
|
|
165
|
+
The probe settles once at boot, so without this every gate keeps offering an assistant
|
|
166
|
+
that cannot answer, and the send that just failed is the only evidence anyone has. Drop
|
|
167
|
+
the reading and re-probe, so the next paint carries the Reconnect CTA instead. */
|
|
168
|
+
if (streamErrorCode === "cf_reconnect_required") {
|
|
169
|
+
resetModelCache();
|
|
170
|
+
ensureProxyProbe();
|
|
171
|
+
}
|
|
161
172
|
chatState.setError(streamError);
|
|
162
173
|
return;
|
|
163
174
|
}
|
|
@@ -17,12 +17,31 @@
|
|
|
17
17
|
*
|
|
18
18
|
* **No secret is ever returned.** A record says whether something is stored and what it is for;
|
|
19
19
|
* printing a token into a list is how a screenshot leaks one.
|
|
20
|
+
*
|
|
21
|
+
* **Cloudflare has two spellings, and this file used to know only one.** On a platform that brokers
|
|
22
|
+
* the connection (Jx Cloud), there is no local token to enumerate — `getCfToken()` is empty by
|
|
23
|
+
* design — so the row said "Not connected" to every hosted user, named no account, and offered a
|
|
24
|
+
* Disconnect that cleared nothing server-side. The hosted state comes from the PAL instead, cached
|
|
25
|
+
* here because a template must not start a fetch on every repaint.
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
28
|
import { clearGithubToken, githubTokenLocation, githubTokenStored } from "../github/github-auth";
|
|
23
29
|
import { clearCfConnection, getCfAccountId, getCfToken } from "../services/cf-settings";
|
|
24
30
|
import { clearAiProvider, getBaseUrl, hasOpenAiKey } from "../services/ai-settings";
|
|
25
|
-
import { preferredModel } from "../services/ai-models";
|
|
31
|
+
import { preferredModel, resetModelCache } from "../services/ai-models";
|
|
32
|
+
import { getPlatform, hasPlatform } from "../platform";
|
|
33
|
+
import type { CfConnection } from "../types";
|
|
34
|
+
|
|
35
|
+
/** One thing a row can do besides existing. */
|
|
36
|
+
export interface AccountAction {
|
|
37
|
+
/** Stable key — the button's `data-action`, so a test names an intent rather than a label. */
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
/** Spectrum `variant`; omitted for the default treatment. */
|
|
41
|
+
variant?: string;
|
|
42
|
+
/** Do it. Async ones repaint the sheet when they settle. */
|
|
43
|
+
run: () => void | Promise<void>;
|
|
44
|
+
}
|
|
26
45
|
|
|
27
46
|
/** One stored credential, as the Accounts section renders it. */
|
|
28
47
|
export interface AccountRecord {
|
|
@@ -35,6 +54,178 @@ export interface AccountRecord {
|
|
|
35
54
|
connected: boolean;
|
|
36
55
|
/** Forget it. Idempotent: revoking a disconnected account is a no-op, not an error. */
|
|
37
56
|
revoke: () => void;
|
|
57
|
+
/**
|
|
58
|
+
* What this row offers, when a plain Disconnect is not the whole story.
|
|
59
|
+
*
|
|
60
|
+
* Absent on the rows whose only verb is "forget it", which is every locally stored credential. A
|
|
61
|
+
* brokered Cloudflare connection has three more — reconnect a lapsed grant, choose an account,
|
|
62
|
+
* connect for the first time — and none of them is expressible as `revoke`.
|
|
63
|
+
*/
|
|
64
|
+
actions?: AccountAction[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── The brokered Cloudflare connection ──────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** What the broker last said, or "checking" before it has been asked. */
|
|
70
|
+
let _cfState: CfConnection | null | "checking" = "checking";
|
|
71
|
+
|
|
72
|
+
/** The in-flight read, so a repaint during one does not start a second. */
|
|
73
|
+
let _cfInFlight: Promise<void> | null = null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether the platform brokers the Cloudflare connection rather than holding a pasted token.
|
|
77
|
+
*
|
|
78
|
+
* Keyed on `cfConnect` — the hosted OAuth flow — because that is the member whose presence means
|
|
79
|
+
* "there is a connection this app did not store locally".
|
|
80
|
+
*/
|
|
81
|
+
export function platformBrokersCf(): boolean {
|
|
82
|
+
return hasPlatform() && Boolean(getPlatform().cfConnect);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Re-read the brokered connection and announce it. Always refetches; the Accounts section runs it
|
|
87
|
+
* when it is shown and after every action, because the answer changes server-side.
|
|
88
|
+
*/
|
|
89
|
+
export async function refreshCfConnection(): Promise<void> {
|
|
90
|
+
if (!platformBrokersCf()) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
_cfInFlight ??= (async () => {
|
|
94
|
+
try {
|
|
95
|
+
_cfState = (await getPlatform().cfConnection?.()) ?? null;
|
|
96
|
+
} catch {
|
|
97
|
+
/* An unreachable broker is not a connection. The row says "not connected", which is what the
|
|
98
|
+
app can act on; the error itself belongs to whatever surface made the call the user asked
|
|
99
|
+
for, not to a list that repaints itself. */
|
|
100
|
+
_cfState = null;
|
|
101
|
+
}
|
|
102
|
+
_cfInFlight = null;
|
|
103
|
+
notifyCredentialsChanged();
|
|
104
|
+
})();
|
|
105
|
+
await _cfInFlight;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Kick the first read, once. Safe to call from a template — later paints are no-ops. */
|
|
109
|
+
export function ensureCfConnection(): void {
|
|
110
|
+
if (platformBrokersCf() && _cfState === "checking" && !_cfInFlight) {
|
|
111
|
+
void refreshCfConnection();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Forget the cached broker answer.
|
|
117
|
+
*
|
|
118
|
+
* Called when Preferences opens, and that is the point: the row would otherwise paint the answer
|
|
119
|
+
* from the LAST visit — an hour-old "Connected — Acme" over a grant that has since lapsed — and
|
|
120
|
+
* only correct itself when the re-read landed. Saying "checking" while it checks is the honest
|
|
121
|
+
* version of the same half-second.
|
|
122
|
+
*/
|
|
123
|
+
export function resetCfConnectionCache(): void {
|
|
124
|
+
_cfState = "checking";
|
|
125
|
+
_cfInFlight = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Run the hosted flow, then re-read. The models cache goes because the AI gate keys on it too. */
|
|
129
|
+
async function hostedConnect(): Promise<void> {
|
|
130
|
+
const outcome = await getPlatform().cfConnect?.();
|
|
131
|
+
if (outcome?.status === "connected" && !outcome.connection.accountId) {
|
|
132
|
+
/* Dynamically, and this is the load-bearing half: the picker imports THIS module for
|
|
133
|
+
`notifyCredentialsChanged`, so a static import here would close the cycle. */
|
|
134
|
+
const { openCfAccountPicker } = await import("../ui/cf-account-picker");
|
|
135
|
+
await openCfAccountPicker();
|
|
136
|
+
}
|
|
137
|
+
resetModelCache();
|
|
138
|
+
await refreshCfConnection();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The brokered row — five states, and each one names the next move. */
|
|
142
|
+
function brokeredCloudflareRecord(): AccountRecord {
|
|
143
|
+
const disconnect: AccountAction = {
|
|
144
|
+
id: "disconnect",
|
|
145
|
+
label: "Disconnect",
|
|
146
|
+
run: async () => {
|
|
147
|
+
await getPlatform().cfDisconnect?.();
|
|
148
|
+
resetModelCache();
|
|
149
|
+
await refreshCfConnection();
|
|
150
|
+
},
|
|
151
|
+
variant: "negative",
|
|
152
|
+
};
|
|
153
|
+
const connect = (label: string): AccountAction => ({
|
|
154
|
+
id: label === "Connect" ? "connect" : "reconnect",
|
|
155
|
+
label,
|
|
156
|
+
run: hostedConnect,
|
|
157
|
+
variant: "accent",
|
|
158
|
+
});
|
|
159
|
+
const base = { id: "cloudflare", label: "Cloudflare", revoke: () => {} };
|
|
160
|
+
|
|
161
|
+
if (_cfState === "checking") {
|
|
162
|
+
return { ...base, actions: [], connected: false, detail: "Checking your connection…" };
|
|
163
|
+
}
|
|
164
|
+
if (!_cfState?.connected) {
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
actions: [connect("Connect")],
|
|
168
|
+
connected: false,
|
|
169
|
+
detail: "Not connected. Connecting publishes this site and runs the assistant on Workers AI.",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const revoke = () => {
|
|
173
|
+
void disconnect.run();
|
|
174
|
+
};
|
|
175
|
+
if (_cfState.needsReconnect) {
|
|
176
|
+
return {
|
|
177
|
+
...base,
|
|
178
|
+
actions: [connect("Reconnect"), disconnect],
|
|
179
|
+
connected: true,
|
|
180
|
+
detail: "Connection expired. Reconnect to keep publishing and the assistant working.",
|
|
181
|
+
revoke,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (_cfState.needsAccount || !_cfState.accountId) {
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
actions: [
|
|
188
|
+
{
|
|
189
|
+
id: "choose-account",
|
|
190
|
+
label: "Choose account",
|
|
191
|
+
run: async () => {
|
|
192
|
+
const { openCfAccountPicker } = await import("../ui/cf-account-picker");
|
|
193
|
+
if (await openCfAccountPicker()) {
|
|
194
|
+
resetModelCache();
|
|
195
|
+
await refreshCfConnection();
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
variant: "accent",
|
|
199
|
+
},
|
|
200
|
+
disconnect,
|
|
201
|
+
],
|
|
202
|
+
connected: true,
|
|
203
|
+
detail: "Connected, but no account is chosen yet — publishing and the assistant need one.",
|
|
204
|
+
revoke,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
...base,
|
|
209
|
+
actions: [disconnect],
|
|
210
|
+
connected: true,
|
|
211
|
+
detail: `Connected — ${_cfState.accountName ?? _cfState.accountId}.`,
|
|
212
|
+
revoke,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The locally stored row: a pasted API token, on desktop and the dev server. */
|
|
217
|
+
function localCloudflareRecord(): AccountRecord {
|
|
218
|
+
const cfToken = getCfToken();
|
|
219
|
+
const cfAccount = getCfAccountId();
|
|
220
|
+
return {
|
|
221
|
+
connected: Boolean(cfToken),
|
|
222
|
+
detail: cfToken
|
|
223
|
+
? `Connected${cfAccount ? ` — account ${cfAccount}` : ""}.`
|
|
224
|
+
: "Not connected. Publishing will ask when it needs it.",
|
|
225
|
+
id: "cloudflare",
|
|
226
|
+
label: "Cloudflare",
|
|
227
|
+
revoke: clearCfConnection,
|
|
228
|
+
};
|
|
38
229
|
}
|
|
39
230
|
|
|
40
231
|
/**
|
|
@@ -45,8 +236,6 @@ export interface AccountRecord {
|
|
|
45
236
|
*/
|
|
46
237
|
export function listAccounts(): AccountRecord[] {
|
|
47
238
|
const githubToken = githubTokenStored();
|
|
48
|
-
const cfToken = getCfToken();
|
|
49
|
-
const cfAccount = getCfAccountId();
|
|
50
239
|
const model = preferredModel();
|
|
51
240
|
const endpoint = getBaseUrl();
|
|
52
241
|
return [
|
|
@@ -70,15 +259,7 @@ export function listAccounts(): AccountRecord[] {
|
|
|
70
259
|
connected: hasOpenAiKey(),
|
|
71
260
|
revoke: clearAiProvider,
|
|
72
261
|
},
|
|
73
|
-
|
|
74
|
-
id: "cloudflare",
|
|
75
|
-
label: "Cloudflare",
|
|
76
|
-
detail: cfToken
|
|
77
|
-
? `Connected${cfAccount ? ` — account ${cfAccount}` : ""}.`
|
|
78
|
-
: "Not connected. Publishing will ask when it needs it.",
|
|
79
|
-
connected: Boolean(cfToken),
|
|
80
|
-
revoke: clearCfConnection,
|
|
81
|
-
},
|
|
262
|
+
platformBrokersCf() ? brokeredCloudflareRecord() : localCloudflareRecord(),
|
|
82
263
|
];
|
|
83
264
|
}
|
|
84
265
|
|
|
@@ -38,7 +38,15 @@ import { createManagedConnect } from "../ui/ai-managed-connect";
|
|
|
38
38
|
import { activeRegistry } from "../commands/active-registry";
|
|
39
39
|
import { shortcutReference, SCOPE_LABELS } from "../commands/reference";
|
|
40
40
|
import { chordFromEvent } from "../commands/keymap";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
ensureCfConnection,
|
|
43
|
+
listAccounts,
|
|
44
|
+
notifyCredentialsChanged,
|
|
45
|
+
platformBrokersCf,
|
|
46
|
+
refreshCfConnection,
|
|
47
|
+
resetCfConnectionCache,
|
|
48
|
+
revokeAccount,
|
|
49
|
+
} from "./preferences-accounts";
|
|
42
50
|
import { applyKeybindingOverrides, rebindCommand, resetKeybinding } from "./preferences-keymap";
|
|
43
51
|
import { overlayRegion } from "../ui/regions";
|
|
44
52
|
|
|
@@ -97,6 +105,19 @@ function credentialsChanged(): void {
|
|
|
97
105
|
repaint();
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Showing a section is a chance to find out it is stale.
|
|
110
|
+
*
|
|
111
|
+
* Only Accounts has anything to re-read: the brokered Cloudflare connection lives on the server, so
|
|
112
|
+
* a grant that lapsed while Preferences was closed — or while the author was in Keyboard — would
|
|
113
|
+
* otherwise be reported from a cache taken before it did.
|
|
114
|
+
*/
|
|
115
|
+
function sectionShown(): void {
|
|
116
|
+
if (_section === "accounts" && platformBrokersCf()) {
|
|
117
|
+
void refreshCfConnection().then(repaint);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
100
121
|
/** The shared credentials form — draft state lives inside the form's closure. */
|
|
101
122
|
const credsForm = createAiCredentialsForm({
|
|
102
123
|
intro: html`
|
|
@@ -144,7 +165,52 @@ function assistantTpl(): TemplateResult {
|
|
|
144
165
|
return html`<div class="prefs-assistant">${managedConnect.render()} ${credsForm.render()}</div>`;
|
|
145
166
|
}
|
|
146
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The buttons on one row.
|
|
170
|
+
*
|
|
171
|
+
* A record that declares `actions` decides its own verbs; everything else keeps the original rule —
|
|
172
|
+
* a connected credential offers Disconnect and a disconnected one offers nothing. The async ones
|
|
173
|
+
* repaint when they settle, because a hosted Reconnect is a round trip through a Cloudflare popup
|
|
174
|
+
* and the row must not still be reading "expired" when it comes back.
|
|
175
|
+
*/
|
|
176
|
+
function accountActionsTpl(account: ReturnType<typeof listAccounts>[number]) {
|
|
177
|
+
if (account.actions) {
|
|
178
|
+
return account.actions.map(
|
|
179
|
+
(action) => html`
|
|
180
|
+
<sp-button
|
|
181
|
+
size="s"
|
|
182
|
+
data-action=${action.id}
|
|
183
|
+
variant=${action.variant ?? nothing}
|
|
184
|
+
treatment=${action.variant === "negative" ? "outline" : nothing}
|
|
185
|
+
@click=${() => {
|
|
186
|
+
void Promise.resolve(action.run()).finally(repaint);
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
${action.label}
|
|
190
|
+
</sp-button>
|
|
191
|
+
`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return account.connected
|
|
195
|
+
? html`
|
|
196
|
+
<sp-button
|
|
197
|
+
size="s"
|
|
198
|
+
variant="negative"
|
|
199
|
+
treatment="outline"
|
|
200
|
+
@click=${() => {
|
|
201
|
+
revokeAccount(account.id);
|
|
202
|
+
repaint();
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
Disconnect
|
|
206
|
+
</sp-button>
|
|
207
|
+
`
|
|
208
|
+
: nothing;
|
|
209
|
+
}
|
|
210
|
+
|
|
147
211
|
function accountsTpl(): TemplateResult {
|
|
212
|
+
// First paint asks the broker what it holds; the answer arrives as a credentials-changed repaint.
|
|
213
|
+
ensureCfConnection();
|
|
148
214
|
return html`
|
|
149
215
|
<div class="prefs-accounts">
|
|
150
216
|
${listAccounts().map(
|
|
@@ -154,23 +220,7 @@ function accountsTpl(): TemplateResult {
|
|
|
154
220
|
<span class="prefs-account-label">${account.label}</span>
|
|
155
221
|
<span class="prefs-account-detail">${account.detail}</span>
|
|
156
222
|
</div>
|
|
157
|
-
|
|
158
|
-
account.connected
|
|
159
|
-
? html`
|
|
160
|
-
<sp-button
|
|
161
|
-
size="s"
|
|
162
|
-
variant="negative"
|
|
163
|
-
treatment="outline"
|
|
164
|
-
@click=${() => {
|
|
165
|
-
revokeAccount(account.id);
|
|
166
|
-
repaint();
|
|
167
|
-
}}
|
|
168
|
-
>
|
|
169
|
-
Disconnect
|
|
170
|
-
</sp-button>
|
|
171
|
-
`
|
|
172
|
-
: nothing
|
|
173
|
-
}
|
|
223
|
+
<div class="prefs-account-actions">${accountActionsTpl(account)}</div>
|
|
174
224
|
</div>
|
|
175
225
|
`,
|
|
176
226
|
)}
|
|
@@ -449,15 +499,21 @@ function sectionBodyTpl(): TemplateResult {
|
|
|
449
499
|
* @param section One of {@link PREFERENCES_SECTIONS}' ids. An unknown id falls back to Appearance.
|
|
450
500
|
*/
|
|
451
501
|
export function openPreferences(section?: string): Promise<null> {
|
|
502
|
+
/* Opening is a fresh look, so nothing may be reported from the last one: a brokered Cloudflare
|
|
503
|
+
grant can lapse between visits, and a row that repaints the previous answer first is stating
|
|
504
|
+
something it has no reason to believe. */
|
|
505
|
+
resetCfConnectionCache();
|
|
452
506
|
_section = section !== undefined && isPreferencesSection(section) ? section : _section;
|
|
453
507
|
if (_close) {
|
|
454
508
|
repaint();
|
|
509
|
+
sectionShown();
|
|
455
510
|
return Promise.resolve(null);
|
|
456
511
|
}
|
|
457
512
|
_section =
|
|
458
513
|
section !== undefined && isPreferencesSection(section) ? section : DEFAULT_PREFERENCES_SECTION;
|
|
459
514
|
credsForm.startEdit();
|
|
460
515
|
resetKeyboardState();
|
|
516
|
+
sectionShown();
|
|
461
517
|
return showDialog<null>(
|
|
462
518
|
(done) => {
|
|
463
519
|
let wrapperEl: HTMLElement | null = null;
|
|
@@ -500,6 +556,7 @@ export function openPreferences(section?: string): Promise<null> {
|
|
|
500
556
|
// First chord pressed on the author's NEXT visit.
|
|
501
557
|
resetKeyboardState();
|
|
502
558
|
repaint();
|
|
559
|
+
sectionShown();
|
|
503
560
|
}}
|
|
504
561
|
>
|
|
505
562
|
${candidate.title}
|
package/src/types.ts
CHANGED
|
@@ -14,7 +14,9 @@ import type {
|
|
|
14
14
|
import type {
|
|
15
15
|
AppInfo,
|
|
16
16
|
AssetCapabilities,
|
|
17
|
+
CfAccountSummary,
|
|
17
18
|
CfConnection,
|
|
19
|
+
CfConnectOutcome,
|
|
18
20
|
CodeServiceResult,
|
|
19
21
|
ComponentMeta,
|
|
20
22
|
DataConnectionsResponse,
|
|
@@ -54,7 +56,9 @@ export type {
|
|
|
54
56
|
AiModelInfo,
|
|
55
57
|
AiModelsResponse,
|
|
56
58
|
AppInfo,
|
|
59
|
+
CfAccountSummary,
|
|
57
60
|
CfConnection,
|
|
61
|
+
CfConnectOutcome,
|
|
58
62
|
CodeServiceResult,
|
|
59
63
|
ComponentMeta,
|
|
60
64
|
ComponentSlotMeta,
|
|
@@ -612,14 +616,34 @@ export interface StudioPlatform {
|
|
|
612
616
|
head?: string;
|
|
613
617
|
base?: string;
|
|
614
618
|
}) => Promise<{ url: string; number: number }>;
|
|
615
|
-
/**
|
|
619
|
+
/**
|
|
620
|
+
* Current Cloudflare connection state, when the platform can broker one. See {@link CfConnection}
|
|
621
|
+
* for what null, `{connected: false}` and `needsReconnect` each mean — they are three different
|
|
622
|
+
* states and the UI says a different sentence for each.
|
|
623
|
+
*/
|
|
616
624
|
cfConnection?: () => Promise<CfConnection | null>;
|
|
617
625
|
/**
|
|
618
626
|
* Interactively connect a Cloudflare account (hosted OAuth on the cloud platform). Local
|
|
619
627
|
* platforms omit it — the publish UI collects an API token instead and verifies via
|
|
620
628
|
* cfConnection.
|
|
621
|
-
|
|
622
|
-
|
|
629
|
+
*
|
|
630
|
+
* Resolves a {@link CfConnectOutcome} rather than a connection, because "connected" is only one of
|
|
631
|
+
* four endings: a blocked popup navigates the whole page (`redirect`), a closed popup is a
|
|
632
|
+
* cancellation, and a passed deadline is a timeout. Rejects only when the flow actually failed —
|
|
633
|
+
* a relayed OAuth error, or a success the platform could not confirm. Resolves null only where
|
|
634
|
+
* there is no DOM to open a popup in.
|
|
635
|
+
*/
|
|
636
|
+
cfConnect?: () => Promise<CfConnectOutcome | null>;
|
|
637
|
+
/**
|
|
638
|
+
* Every Cloudflare account the connected grant can reach. Backs the account picker a
|
|
639
|
+
* multi-account user needs before publishing: the broker stores one account id per connection,
|
|
640
|
+
* and until one is chosen every Cloudflare-backed call answers `cf_account_required`.
|
|
641
|
+
*/
|
|
642
|
+
cfAccounts?: () => Promise<CfAccountSummary[]>;
|
|
643
|
+
/** Store the chosen account on the brokered connection (the picker's commit). */
|
|
644
|
+
cfSelectAccount?: (account: { id: string; name?: string }) => Promise<void>;
|
|
645
|
+
/** Forget the brokered connection and revoke its tokens upstream. */
|
|
646
|
+
cfDisconnect?: () => Promise<void>;
|
|
623
647
|
/**
|
|
624
648
|
* Allowlisted Cloudflare API passthrough (accounts, Pages projects and deployments). The backend
|
|
625
649
|
* injects credentials — an OAuth token on the cloud platform, the user's pasted API token locally
|