paseo-plugin-zcode-provider 0.1.0 → 0.3.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/NOTICE.md +2 -2
- package/README.md +28 -14
- package/client/account.tsx +827 -0
- package/client/diagnostics.tsx +2 -20
- package/client/{setup.tsx → runtime.tsx} +4 -55
- package/client/settings.tsx +84 -0
- package/client/value.tsx +20 -0
- package/index.client.tsx +7 -7
- package/index.server.ts +20 -2
- package/package.json +9 -6
- package/server/account.ts +781 -0
- package/server/build-info.ts +1 -1
- package/server/diagnostics.ts +17 -0
- package/server/discovery/discover.ts +1 -1
- package/server/discovery/manifest.ts +1 -1
- package/server/host/bridge.ts +51 -22
- package/server/presentation.ts +48 -6
- package/server/provider-status.ts +81 -0
- package/server/provider.ts +10 -3
- package/server/runtime/pins.ts +6 -4
- package/server/runtime-setup.ts +4 -29
- package/server/session.ts +2 -0
- package/server/unsupported-capabilities.ts +18 -0
- package/server/usage.ts +362 -0
- package/shared/account.ts +201 -0
- package/shared/runtime-setup.ts +0 -2
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Linking, Platform, Text, View } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
getPaseoClient,
|
|
5
|
+
openExternalUrl,
|
|
6
|
+
type PluginSurfaceProps,
|
|
7
|
+
useRpc,
|
|
8
|
+
} from "@getpaseo/plugin/client";
|
|
9
|
+
import { copyText } from "@getpaseo/plugin/client/react-native";
|
|
10
|
+
import {
|
|
11
|
+
SettingsAction,
|
|
12
|
+
SettingsCard,
|
|
13
|
+
SettingsInput,
|
|
14
|
+
SettingsRow,
|
|
15
|
+
SettingsSection,
|
|
16
|
+
SettingsSelect,
|
|
17
|
+
SettingsSwitch,
|
|
18
|
+
} from "@getpaseo/plugin/client/ui";
|
|
19
|
+
import {
|
|
20
|
+
zcodeAccountChange,
|
|
21
|
+
zcodeAccountView,
|
|
22
|
+
type AccountChange,
|
|
23
|
+
type AccountFamily,
|
|
24
|
+
type AccountView,
|
|
25
|
+
type ApiFormat,
|
|
26
|
+
type CustomProvider,
|
|
27
|
+
type ProviderTemplate,
|
|
28
|
+
type ReadyAccountView,
|
|
29
|
+
isHttpUrl,
|
|
30
|
+
} from "../shared/account";
|
|
31
|
+
import { Value } from "./value";
|
|
32
|
+
|
|
33
|
+
type Theme = PluginSurfaceProps["theme"];
|
|
34
|
+
type Run = (change: AccountChange) => Promise<AccountView | null>;
|
|
35
|
+
interface PartProps {
|
|
36
|
+
readonly theme: Theme;
|
|
37
|
+
readonly compact: boolean;
|
|
38
|
+
readonly busy: boolean;
|
|
39
|
+
readonly run: Run;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const FAMILIES: readonly { label: string; value: AccountFamily }[] = [
|
|
43
|
+
{ label: "Z.ai (international)", value: "zai" },
|
|
44
|
+
{ label: "BigModel (China)", value: "bigmodel" },
|
|
45
|
+
];
|
|
46
|
+
const API_FORMATS: readonly { label: string; value: ApiFormat }[] = [
|
|
47
|
+
{ label: "Anthropic messages (/messages)", value: "anthropic-messages" },
|
|
48
|
+
{
|
|
49
|
+
label: "Chat completions (/chat/completions)",
|
|
50
|
+
value: "openai-chat-completions",
|
|
51
|
+
},
|
|
52
|
+
{ label: "Responses (/responses)", value: "openai-responses" },
|
|
53
|
+
];
|
|
54
|
+
const CUSTOM = "custom";
|
|
55
|
+
|
|
56
|
+
const failure = (cause: unknown) =>
|
|
57
|
+
cause instanceof Error ? cause.message : "The account request failed.";
|
|
58
|
+
|
|
59
|
+
// openExternalUrl, copyText and getPaseoClient arrived in Paseo 0.9; 0.8
|
|
60
|
+
// hosts omit them.
|
|
61
|
+
async function openUrl(url: string): Promise<void> {
|
|
62
|
+
if (typeof openExternalUrl === "function") await openExternalUrl(url);
|
|
63
|
+
else await Linking.openURL(url);
|
|
64
|
+
}
|
|
65
|
+
const canCopy = typeof copyText === "function";
|
|
66
|
+
|
|
67
|
+
// Paseo caches provider catalogs, so new models would otherwise appear only
|
|
68
|
+
// after a manual refresh.
|
|
69
|
+
function refreshCatalog(serverId: string): void {
|
|
70
|
+
if (typeof getPaseoClient !== "function") return;
|
|
71
|
+
getPaseoClient(serverId)
|
|
72
|
+
.providers.refresh({ providers: ["zcode"] })
|
|
73
|
+
.catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function planStatus(plan: ReadyAccountView["plans"][number]): string {
|
|
77
|
+
if (plan.availability === "available") return "Available";
|
|
78
|
+
if (plan.availability === "pending") return "Checking…";
|
|
79
|
+
switch (plan.unavailableReason) {
|
|
80
|
+
case "not-entitled":
|
|
81
|
+
return "No subscription";
|
|
82
|
+
case "not-connected":
|
|
83
|
+
return "Not selected";
|
|
84
|
+
case "not-authenticated":
|
|
85
|
+
return "Sign-in required";
|
|
86
|
+
case "credential-failed":
|
|
87
|
+
return "Could not verify the account";
|
|
88
|
+
}
|
|
89
|
+
return plan.availability === "unknown" ? "Unknown" : "Unavailable";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function providerStatus(provider: CustomProvider): string {
|
|
93
|
+
if (!provider.enabled) return "Disabled";
|
|
94
|
+
if (provider.issues.length > 0) return provider.issues.join(", ");
|
|
95
|
+
if (!provider.executable)
|
|
96
|
+
return provider.models.some((model) => model.enabled)
|
|
97
|
+
? "Not ready"
|
|
98
|
+
: "Add a model to use it";
|
|
99
|
+
return "Ready";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function modelHint(model: CustomProvider["models"][number]): string {
|
|
103
|
+
const parts: string[] = [];
|
|
104
|
+
if (model.contextWindow !== undefined)
|
|
105
|
+
parts.push(
|
|
106
|
+
model.contextWindow >= 1_000_000
|
|
107
|
+
? `${model.contextWindow / 1_000_000}M context`
|
|
108
|
+
: `${Math.round(model.contextWindow / 1000)}K context`,
|
|
109
|
+
);
|
|
110
|
+
if (model.vision) parts.push("Vision");
|
|
111
|
+
parts.push(model.builtin ? "From template" : "Added by you");
|
|
112
|
+
return parts.join(" · ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function Monospace({
|
|
116
|
+
children,
|
|
117
|
+
theme,
|
|
118
|
+
compact,
|
|
119
|
+
}: {
|
|
120
|
+
children: string;
|
|
121
|
+
theme: Theme;
|
|
122
|
+
compact: boolean;
|
|
123
|
+
}) {
|
|
124
|
+
// A full-width block: a row value would squeeze the label column.
|
|
125
|
+
return (
|
|
126
|
+
<View style={{ padding: compact ? 12 : 16 }}>
|
|
127
|
+
<Text
|
|
128
|
+
selectable
|
|
129
|
+
style={{
|
|
130
|
+
color: theme.colors.foreground,
|
|
131
|
+
fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
|
|
132
|
+
fontSize: compact ? 12 : 13,
|
|
133
|
+
lineHeight: 20,
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</Text>
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function Account({
|
|
143
|
+
view,
|
|
144
|
+
theme,
|
|
145
|
+
compact,
|
|
146
|
+
busy,
|
|
147
|
+
run,
|
|
148
|
+
}: PartProps & { view: ReadyAccountView }) {
|
|
149
|
+
const [family, setFamily] = useState<AccountFamily>("zai");
|
|
150
|
+
const [confirmSignOut, setConfirmSignOut] = useState(false);
|
|
151
|
+
const [openError, setOpenError] = useState<string | null>(null);
|
|
152
|
+
const { account, signIn } = view;
|
|
153
|
+
const open = (url: string) => {
|
|
154
|
+
setOpenError(null);
|
|
155
|
+
openUrl(url).catch(() =>
|
|
156
|
+
setOpenError("The browser could not be opened. Copy the link instead."),
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
const start = async () => {
|
|
160
|
+
const next = await run({ action: "signIn", family });
|
|
161
|
+
if (next?.status === "ready" && next.signIn.state === "pending")
|
|
162
|
+
open(next.signIn.authorizeUrl);
|
|
163
|
+
};
|
|
164
|
+
const region = FAMILIES.find(
|
|
165
|
+
(entry) =>
|
|
166
|
+
entry.value ===
|
|
167
|
+
(account.state === "signed-out" ? undefined : account.family),
|
|
168
|
+
)?.label;
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<SettingsSection title="ZCode account">
|
|
172
|
+
<SettingsCard>
|
|
173
|
+
<SettingsRow
|
|
174
|
+
label="Status"
|
|
175
|
+
hint="Shared with ZCode Desktop and the CLI on the daemon machine."
|
|
176
|
+
>
|
|
177
|
+
<Value
|
|
178
|
+
color={
|
|
179
|
+
account.state === "signed-in"
|
|
180
|
+
? theme.colors.statusSuccess
|
|
181
|
+
: account.state === "reauthentication-required"
|
|
182
|
+
? theme.colors.statusWarning
|
|
183
|
+
: theme.colors.foregroundMuted
|
|
184
|
+
}
|
|
185
|
+
compact={compact}
|
|
186
|
+
>
|
|
187
|
+
{account.state === "signed-in"
|
|
188
|
+
? `Signed in${account.displayName ? ` as ${account.displayName}` : ""}`
|
|
189
|
+
: account.state === "reauthentication-required"
|
|
190
|
+
? "Session expired. Sign in again."
|
|
191
|
+
: "Signed out"}
|
|
192
|
+
</Value>
|
|
193
|
+
</SettingsRow>
|
|
194
|
+
{region === undefined ? null : (
|
|
195
|
+
<SettingsRow label="Region">
|
|
196
|
+
<Value color={theme.colors.foregroundMuted} compact={compact}>
|
|
197
|
+
{region}
|
|
198
|
+
</Value>
|
|
199
|
+
</SettingsRow>
|
|
200
|
+
)}
|
|
201
|
+
{/* SettingsCard divides direct children, so rows are keyed arrays. */}
|
|
202
|
+
{signIn.state === "pending" ? (
|
|
203
|
+
[
|
|
204
|
+
<SettingsRow
|
|
205
|
+
key="waiting"
|
|
206
|
+
label="Waiting for sign-in"
|
|
207
|
+
hint="Finish in the browser on any device. The daemon receives the result; the link expires after five minutes."
|
|
208
|
+
error={openError}
|
|
209
|
+
/>,
|
|
210
|
+
<Monospace key="url" theme={theme} compact={compact}>
|
|
211
|
+
{signIn.authorizeUrl}
|
|
212
|
+
</Monospace>,
|
|
213
|
+
<SettingsAction
|
|
214
|
+
key="open"
|
|
215
|
+
label="Sign-in page"
|
|
216
|
+
actionLabel="Open"
|
|
217
|
+
onPress={() => open(signIn.authorizeUrl)}
|
|
218
|
+
/>,
|
|
219
|
+
canCopy ? (
|
|
220
|
+
<SettingsAction
|
|
221
|
+
key="copy"
|
|
222
|
+
label="Sign-in link"
|
|
223
|
+
actionLabel="Copy"
|
|
224
|
+
onPress={() => void copyText(signIn.authorizeUrl)}
|
|
225
|
+
/>
|
|
226
|
+
) : null,
|
|
227
|
+
<SettingsAction
|
|
228
|
+
key="cancel"
|
|
229
|
+
label="Cancel sign-in"
|
|
230
|
+
actionLabel="Cancel"
|
|
231
|
+
disabled={busy}
|
|
232
|
+
onPress={() => void run({ action: "cancelSignIn" })}
|
|
233
|
+
/>,
|
|
234
|
+
]
|
|
235
|
+
) : account.state === "signed-in" ? (
|
|
236
|
+
<SettingsAction
|
|
237
|
+
label="Sign out"
|
|
238
|
+
hint="New sessions can no longer use the account's plans."
|
|
239
|
+
actionLabel={confirmSignOut ? "Confirm sign out" : "Sign out"}
|
|
240
|
+
disabled={busy}
|
|
241
|
+
onPress={() => {
|
|
242
|
+
if (!confirmSignOut) {
|
|
243
|
+
setConfirmSignOut(true);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
setConfirmSignOut(false);
|
|
247
|
+
void run({ action: "signOut" });
|
|
248
|
+
}}
|
|
249
|
+
/>
|
|
250
|
+
) : (
|
|
251
|
+
[
|
|
252
|
+
<SettingsSelect<AccountFamily>
|
|
253
|
+
key="region"
|
|
254
|
+
label="Region"
|
|
255
|
+
value={family}
|
|
256
|
+
options={FAMILIES}
|
|
257
|
+
onValueChange={setFamily}
|
|
258
|
+
disabled={busy}
|
|
259
|
+
/>,
|
|
260
|
+
<SettingsAction
|
|
261
|
+
key="sign-in"
|
|
262
|
+
label="Sign in"
|
|
263
|
+
hint="Opens the sign-in page in your browser."
|
|
264
|
+
error={signIn.state === "failed" ? signIn.message : null}
|
|
265
|
+
actionLabel="Sign in"
|
|
266
|
+
disabled={busy}
|
|
267
|
+
onPress={() => void start()}
|
|
268
|
+
/>,
|
|
269
|
+
]
|
|
270
|
+
)}
|
|
271
|
+
</SettingsCard>
|
|
272
|
+
</SettingsSection>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function Plans({
|
|
277
|
+
view,
|
|
278
|
+
theme,
|
|
279
|
+
compact,
|
|
280
|
+
}: {
|
|
281
|
+
view: ReadyAccountView;
|
|
282
|
+
theme: Theme;
|
|
283
|
+
compact: boolean;
|
|
284
|
+
}) {
|
|
285
|
+
if (view.account.state === "signed-out" || view.plans.length === 0)
|
|
286
|
+
return null;
|
|
287
|
+
return (
|
|
288
|
+
<SettingsSection title="Plans">
|
|
289
|
+
<SettingsCard>
|
|
290
|
+
{view.plans.map((plan) => (
|
|
291
|
+
<SettingsRow
|
|
292
|
+
key={plan.providerId}
|
|
293
|
+
label={plan.name}
|
|
294
|
+
hint={plan.models.length > 0 ? plan.models.join(", ") : undefined}
|
|
295
|
+
>
|
|
296
|
+
<Value
|
|
297
|
+
color={
|
|
298
|
+
plan.availability === "available"
|
|
299
|
+
? theme.colors.statusSuccess
|
|
300
|
+
: theme.colors.foregroundMuted
|
|
301
|
+
}
|
|
302
|
+
compact={compact}
|
|
303
|
+
>
|
|
304
|
+
{planStatus(plan)}
|
|
305
|
+
</Value>
|
|
306
|
+
</SettingsRow>
|
|
307
|
+
))}
|
|
308
|
+
</SettingsCard>
|
|
309
|
+
</SettingsSection>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function NewProvider({
|
|
314
|
+
templates,
|
|
315
|
+
existing,
|
|
316
|
+
busy,
|
|
317
|
+
run,
|
|
318
|
+
onCreated,
|
|
319
|
+
}: PartProps & {
|
|
320
|
+
templates: readonly ProviderTemplate[];
|
|
321
|
+
existing: readonly CustomProvider[];
|
|
322
|
+
onCreated: (providerId: string | null) => void;
|
|
323
|
+
}) {
|
|
324
|
+
const [templateId, setTemplateId] = useState(CUSTOM);
|
|
325
|
+
const template = templates.find((entry) => entry.templateId === templateId);
|
|
326
|
+
const [apiFormat, setApiFormat] = useState<ApiFormat>(
|
|
327
|
+
"openai-chat-completions",
|
|
328
|
+
);
|
|
329
|
+
const fields = useRef({ name: "", baseUrl: "", apiKey: "" });
|
|
330
|
+
const [error, setError] = useState<string | null>(null);
|
|
331
|
+
|
|
332
|
+
const choose = (id: string) => {
|
|
333
|
+
const next = templates.find((entry) => entry.templateId === id);
|
|
334
|
+
setTemplateId(id);
|
|
335
|
+
setApiFormat(next?.apiFormat ?? "openai-chat-completions");
|
|
336
|
+
fields.current = {
|
|
337
|
+
name: next?.name ?? "",
|
|
338
|
+
baseUrl: next?.baseUrl ?? "",
|
|
339
|
+
apiKey: fields.current.apiKey,
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
const create = async () => {
|
|
343
|
+
const name = fields.current.name.trim();
|
|
344
|
+
const baseUrl = fields.current.baseUrl.trim();
|
|
345
|
+
const apiKey = fields.current.apiKey.trim();
|
|
346
|
+
if (!name) return setError("Enter a name.");
|
|
347
|
+
if (!isHttpUrl(baseUrl)) return setError("Enter an http(s) base URL.");
|
|
348
|
+
if (!apiKey) return setError("Enter an API key.");
|
|
349
|
+
setError(null);
|
|
350
|
+
const next = await run({
|
|
351
|
+
action: "createProvider",
|
|
352
|
+
...(template ? { templateId: template.templateId } : {}),
|
|
353
|
+
name,
|
|
354
|
+
apiFormat,
|
|
355
|
+
baseUrl,
|
|
356
|
+
apiKey,
|
|
357
|
+
});
|
|
358
|
+
if (next?.status !== "ready") return;
|
|
359
|
+
const before = new Set(existing.map((entry) => entry.providerId));
|
|
360
|
+
onCreated(
|
|
361
|
+
next.providers.find((entry) => !before.has(entry.providerId))
|
|
362
|
+
?.providerId ?? null,
|
|
363
|
+
);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<SettingsSection title="New provider">
|
|
368
|
+
<SettingsCard>
|
|
369
|
+
<SettingsSelect
|
|
370
|
+
label="Template"
|
|
371
|
+
hint="Templates fill in the endpoint and known models."
|
|
372
|
+
value={templateId}
|
|
373
|
+
options={[
|
|
374
|
+
{ label: "Custom", value: CUSTOM },
|
|
375
|
+
...templates.map((entry) => ({
|
|
376
|
+
label: entry.name,
|
|
377
|
+
value: entry.templateId,
|
|
378
|
+
})),
|
|
379
|
+
]}
|
|
380
|
+
onValueChange={choose}
|
|
381
|
+
disabled={busy}
|
|
382
|
+
/>
|
|
383
|
+
<SettingsInput
|
|
384
|
+
key={`name-${templateId}`}
|
|
385
|
+
label="Name"
|
|
386
|
+
initialValue={template?.name ?? ""}
|
|
387
|
+
placeholder="CommandCode"
|
|
388
|
+
onChangeText={(text) => (fields.current.name = text)}
|
|
389
|
+
disabled={busy}
|
|
390
|
+
/>
|
|
391
|
+
<SettingsInput
|
|
392
|
+
key={`url-${templateId}`}
|
|
393
|
+
label="Base URL"
|
|
394
|
+
initialValue={template?.baseUrl ?? ""}
|
|
395
|
+
placeholder="https://api.example.com/v1"
|
|
396
|
+
onChangeText={(text) => (fields.current.baseUrl = text)}
|
|
397
|
+
disabled={busy}
|
|
398
|
+
/>
|
|
399
|
+
<SettingsSelect<ApiFormat>
|
|
400
|
+
label="API format"
|
|
401
|
+
value={apiFormat}
|
|
402
|
+
options={API_FORMATS}
|
|
403
|
+
onValueChange={setApiFormat}
|
|
404
|
+
disabled={busy}
|
|
405
|
+
/>
|
|
406
|
+
<SettingsInput
|
|
407
|
+
label="API key"
|
|
408
|
+
hint="Stored by ZCode on the daemon machine. It is never shown again."
|
|
409
|
+
secureTextEntry
|
|
410
|
+
onChangeText={(text) => (fields.current.apiKey = text)}
|
|
411
|
+
disabled={busy}
|
|
412
|
+
/>
|
|
413
|
+
{template?.apiKeyManagementUrl ? (
|
|
414
|
+
<SettingsAction
|
|
415
|
+
label="Get an API key"
|
|
416
|
+
hint={template.apiKeyManagementUrl}
|
|
417
|
+
actionLabel="Open"
|
|
418
|
+
onPress={() => void openUrl(template.apiKeyManagementUrl!)}
|
|
419
|
+
/>
|
|
420
|
+
) : null}
|
|
421
|
+
<SettingsAction
|
|
422
|
+
label="Create provider"
|
|
423
|
+
hint={
|
|
424
|
+
template
|
|
425
|
+
? "The template's models are added automatically."
|
|
426
|
+
: "Add models after creating the provider."
|
|
427
|
+
}
|
|
428
|
+
error={error}
|
|
429
|
+
actionLabel="Create"
|
|
430
|
+
disabled={busy}
|
|
431
|
+
onPress={() => void create()}
|
|
432
|
+
/>
|
|
433
|
+
</SettingsCard>
|
|
434
|
+
</SettingsSection>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function ProviderEditor({
|
|
439
|
+
provider,
|
|
440
|
+
busy,
|
|
441
|
+
run,
|
|
442
|
+
onDeleted,
|
|
443
|
+
}: PartProps & { provider: CustomProvider; onDeleted: () => void }) {
|
|
444
|
+
const fields = useRef({
|
|
445
|
+
name: provider.name,
|
|
446
|
+
baseUrl: provider.baseUrl ?? "",
|
|
447
|
+
apiKey: "",
|
|
448
|
+
modelId: "",
|
|
449
|
+
});
|
|
450
|
+
const [apiFormat, setApiFormat] = useState<ApiFormat>(
|
|
451
|
+
provider.apiFormat ?? "openai-chat-completions",
|
|
452
|
+
);
|
|
453
|
+
// Remount counters that clear the API key and model inputs after success.
|
|
454
|
+
const [keyInput, setKeyInput] = useState(0);
|
|
455
|
+
const [modelInput, setModelInput] = useState(0);
|
|
456
|
+
const [error, setError] = useState<string | null>(null);
|
|
457
|
+
const [modelError, setModelError] = useState<string | null>(null);
|
|
458
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
459
|
+
const added = provider.models.filter((model) => !model.builtin);
|
|
460
|
+
const [removal, setRemoval] = useState(added[0]?.modelId ?? "");
|
|
461
|
+
const id = provider.providerId;
|
|
462
|
+
|
|
463
|
+
const save = async () => {
|
|
464
|
+
const name = fields.current.name.trim();
|
|
465
|
+
const baseUrl = fields.current.baseUrl.trim();
|
|
466
|
+
const apiKey = fields.current.apiKey.trim();
|
|
467
|
+
if (!name) return setError("Enter a name.");
|
|
468
|
+
if (!isHttpUrl(baseUrl)) return setError("Enter an http(s) base URL.");
|
|
469
|
+
setError(null);
|
|
470
|
+
const next = await run({
|
|
471
|
+
action: "updateProvider",
|
|
472
|
+
providerId: id,
|
|
473
|
+
...(name === provider.name ? {} : { name }),
|
|
474
|
+
...(baseUrl === (provider.baseUrl ?? "") ? {} : { baseUrl }),
|
|
475
|
+
...(apiFormat === provider.apiFormat ? {} : { apiFormat }),
|
|
476
|
+
...(apiKey ? { apiKey } : {}),
|
|
477
|
+
});
|
|
478
|
+
if (next === null) return;
|
|
479
|
+
fields.current.apiKey = "";
|
|
480
|
+
setKeyInput((value) => value + 1);
|
|
481
|
+
};
|
|
482
|
+
const addModel = async () => {
|
|
483
|
+
const modelId = fields.current.modelId.trim();
|
|
484
|
+
if (!modelId) return setModelError("Enter a model ID.");
|
|
485
|
+
if (provider.models.some((model) => model.modelId === modelId))
|
|
486
|
+
return setModelError("The model is already listed.");
|
|
487
|
+
setModelError(null);
|
|
488
|
+
if ((await run({ action: "addModel", providerId: id, modelId })) === null)
|
|
489
|
+
return;
|
|
490
|
+
fields.current.modelId = "";
|
|
491
|
+
setRemoval((current) => current || modelId);
|
|
492
|
+
setModelInput((value) => value + 1);
|
|
493
|
+
};
|
|
494
|
+
const removeModel = async () => {
|
|
495
|
+
const next = await run({
|
|
496
|
+
action: "deleteModel",
|
|
497
|
+
providerId: id,
|
|
498
|
+
modelId: removal,
|
|
499
|
+
});
|
|
500
|
+
if (next?.status !== "ready") return;
|
|
501
|
+
const models =
|
|
502
|
+
next.providers.find((entry) => entry.providerId === id)?.models ?? [];
|
|
503
|
+
setRemoval(models.find((model) => !model.builtin)?.modelId ?? "");
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<>
|
|
508
|
+
<SettingsSection title={provider.name}>
|
|
509
|
+
<SettingsCard>
|
|
510
|
+
<SettingsSwitch
|
|
511
|
+
label="Enabled"
|
|
512
|
+
hint={providerStatus(provider)}
|
|
513
|
+
value={provider.enabled}
|
|
514
|
+
onValueChange={(enabled) =>
|
|
515
|
+
void run({ action: "updateProvider", providerId: id, enabled })
|
|
516
|
+
}
|
|
517
|
+
disabled={busy}
|
|
518
|
+
/>
|
|
519
|
+
<SettingsInput
|
|
520
|
+
label="Name"
|
|
521
|
+
initialValue={provider.name}
|
|
522
|
+
onChangeText={(text) => (fields.current.name = text)}
|
|
523
|
+
disabled={busy}
|
|
524
|
+
/>
|
|
525
|
+
<SettingsInput
|
|
526
|
+
label="Base URL"
|
|
527
|
+
initialValue={provider.baseUrl ?? ""}
|
|
528
|
+
placeholder="https://api.example.com/v1"
|
|
529
|
+
onChangeText={(text) => (fields.current.baseUrl = text)}
|
|
530
|
+
disabled={busy}
|
|
531
|
+
/>
|
|
532
|
+
<SettingsSelect<ApiFormat>
|
|
533
|
+
label="API format"
|
|
534
|
+
value={apiFormat}
|
|
535
|
+
options={API_FORMATS}
|
|
536
|
+
onValueChange={setApiFormat}
|
|
537
|
+
disabled={busy}
|
|
538
|
+
/>
|
|
539
|
+
<SettingsInput
|
|
540
|
+
key={`key-${keyInput}`}
|
|
541
|
+
label="API key"
|
|
542
|
+
hint={
|
|
543
|
+
provider.apiKeySet
|
|
544
|
+
? "Saved. Enter a new key to replace it."
|
|
545
|
+
: "Required."
|
|
546
|
+
}
|
|
547
|
+
placeholder={provider.apiKeySet ? "••••••••" : undefined}
|
|
548
|
+
secureTextEntry
|
|
549
|
+
onChangeText={(text) => (fields.current.apiKey = text)}
|
|
550
|
+
disabled={busy}
|
|
551
|
+
/>
|
|
552
|
+
{provider.apiKeyManagementUrl ? (
|
|
553
|
+
<SettingsAction
|
|
554
|
+
label="Get an API key"
|
|
555
|
+
hint={provider.apiKeyManagementUrl}
|
|
556
|
+
actionLabel="Open"
|
|
557
|
+
onPress={() => void openUrl(provider.apiKeyManagementUrl!)}
|
|
558
|
+
/>
|
|
559
|
+
) : null}
|
|
560
|
+
<SettingsAction
|
|
561
|
+
label="Save changes"
|
|
562
|
+
error={error}
|
|
563
|
+
actionLabel="Save"
|
|
564
|
+
disabled={busy}
|
|
565
|
+
onPress={() => void save()}
|
|
566
|
+
/>
|
|
567
|
+
</SettingsCard>
|
|
568
|
+
</SettingsSection>
|
|
569
|
+
<SettingsSection title="Models">
|
|
570
|
+
<SettingsCard>
|
|
571
|
+
{provider.models.map((model) => (
|
|
572
|
+
<SettingsSwitch
|
|
573
|
+
key={model.modelId}
|
|
574
|
+
label={model.modelId}
|
|
575
|
+
hint={modelHint(model)}
|
|
576
|
+
value={model.enabled}
|
|
577
|
+
onValueChange={(enabled) =>
|
|
578
|
+
void run({
|
|
579
|
+
action: "setModelEnabled",
|
|
580
|
+
providerId: id,
|
|
581
|
+
modelId: model.modelId,
|
|
582
|
+
enabled,
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
disabled={busy}
|
|
586
|
+
/>
|
|
587
|
+
))}
|
|
588
|
+
<SettingsInput
|
|
589
|
+
key={`model-${modelInput}`}
|
|
590
|
+
label="Model ID"
|
|
591
|
+
hint="As the provider names it, e.g. deepseek/deepseek-v4.1-flash."
|
|
592
|
+
onChangeText={(text) => (fields.current.modelId = text)}
|
|
593
|
+
disabled={busy}
|
|
594
|
+
/>
|
|
595
|
+
<SettingsAction
|
|
596
|
+
label="Add model"
|
|
597
|
+
hint="ZCode applies known settings for recognised models."
|
|
598
|
+
error={modelError}
|
|
599
|
+
actionLabel="Add"
|
|
600
|
+
disabled={busy}
|
|
601
|
+
onPress={() => void addModel()}
|
|
602
|
+
/>
|
|
603
|
+
{added.length === 0
|
|
604
|
+
? null
|
|
605
|
+
: [
|
|
606
|
+
<SettingsSelect
|
|
607
|
+
key="removal"
|
|
608
|
+
label="Model to remove"
|
|
609
|
+
value={removal}
|
|
610
|
+
options={added.map((model) => ({
|
|
611
|
+
label: model.modelId,
|
|
612
|
+
value: model.modelId,
|
|
613
|
+
}))}
|
|
614
|
+
onValueChange={setRemoval}
|
|
615
|
+
disabled={busy}
|
|
616
|
+
/>,
|
|
617
|
+
<SettingsAction
|
|
618
|
+
key="remove"
|
|
619
|
+
label="Remove model"
|
|
620
|
+
hint="Template models can only be turned off."
|
|
621
|
+
actionLabel="Remove"
|
|
622
|
+
disabled={busy || !removal}
|
|
623
|
+
onPress={() => void removeModel()}
|
|
624
|
+
/>,
|
|
625
|
+
]}
|
|
626
|
+
</SettingsCard>
|
|
627
|
+
</SettingsSection>
|
|
628
|
+
<SettingsSection title="Delete provider">
|
|
629
|
+
<SettingsAction
|
|
630
|
+
label={provider.name}
|
|
631
|
+
hint="Removes the provider, its API key and its models from ZCode."
|
|
632
|
+
actionLabel={confirmDelete ? "Confirm deletion" : "Delete"}
|
|
633
|
+
disabled={busy}
|
|
634
|
+
onPress={() => {
|
|
635
|
+
if (!confirmDelete) {
|
|
636
|
+
setConfirmDelete(true);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
setConfirmDelete(false);
|
|
640
|
+
void run({ action: "deleteProvider", providerId: id }).then(
|
|
641
|
+
(next) => {
|
|
642
|
+
if (next !== null) onDeleted();
|
|
643
|
+
},
|
|
644
|
+
);
|
|
645
|
+
}}
|
|
646
|
+
/>
|
|
647
|
+
</SettingsSection>
|
|
648
|
+
</>
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function Providers(props: PartProps & { view: ReadyAccountView }) {
|
|
653
|
+
const { view } = props;
|
|
654
|
+
const [editing, setEditing] = useState<string | null>(null);
|
|
655
|
+
const provider = view.providers.find((entry) => entry.providerId === editing);
|
|
656
|
+
const toggle = (id: string) =>
|
|
657
|
+
setEditing((current) => (current === id ? null : id));
|
|
658
|
+
|
|
659
|
+
return (
|
|
660
|
+
<>
|
|
661
|
+
<SettingsSection title="Model providers">
|
|
662
|
+
<SettingsCard>
|
|
663
|
+
{view.providers.map((entry) => (
|
|
664
|
+
<SettingsAction
|
|
665
|
+
key={entry.providerId}
|
|
666
|
+
label={entry.name}
|
|
667
|
+
hint={`${providerStatus(entry)} · ${entry.models.filter((model) => model.enabled).length} of ${entry.models.length} models on`}
|
|
668
|
+
actionLabel={editing === entry.providerId ? "Close" : "Edit"}
|
|
669
|
+
onPress={() => toggle(entry.providerId)}
|
|
670
|
+
/>
|
|
671
|
+
))}
|
|
672
|
+
<SettingsAction
|
|
673
|
+
label="Add provider"
|
|
674
|
+
hint="Use an API key from Z.ai, DeepSeek, OpenRouter or any compatible endpoint."
|
|
675
|
+
actionLabel={editing === CUSTOM ? "Close" : "Add"}
|
|
676
|
+
onPress={() => toggle(CUSTOM)}
|
|
677
|
+
/>
|
|
678
|
+
</SettingsCard>
|
|
679
|
+
</SettingsSection>
|
|
680
|
+
{editing === CUSTOM ? (
|
|
681
|
+
<NewProvider
|
|
682
|
+
{...props}
|
|
683
|
+
templates={view.templates}
|
|
684
|
+
existing={view.providers}
|
|
685
|
+
onCreated={setEditing}
|
|
686
|
+
/>
|
|
687
|
+
) : provider ? (
|
|
688
|
+
<ProviderEditor
|
|
689
|
+
{...props}
|
|
690
|
+
key={provider.providerId}
|
|
691
|
+
provider={provider}
|
|
692
|
+
onDeleted={() => setEditing(null)}
|
|
693
|
+
/>
|
|
694
|
+
) : null}
|
|
695
|
+
</>
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export function AccountTab({
|
|
700
|
+
theme,
|
|
701
|
+
layout,
|
|
702
|
+
host,
|
|
703
|
+
onOpenRuntime,
|
|
704
|
+
}: PluginSurfaceProps & { onOpenRuntime: () => void }) {
|
|
705
|
+
const fetchView = useRpc(zcodeAccountView);
|
|
706
|
+
const change = useRpc(zcodeAccountChange);
|
|
707
|
+
const [view, setView] = useState<AccountView | null>(null);
|
|
708
|
+
const [requestError, setRequestError] = useState<string | null>(null);
|
|
709
|
+
const [pending, setPending] = useState(false);
|
|
710
|
+
const compact = layout.compact;
|
|
711
|
+
|
|
712
|
+
const refresh = useCallback(async () => {
|
|
713
|
+
try {
|
|
714
|
+
setView(await fetchView({}));
|
|
715
|
+
setRequestError(null);
|
|
716
|
+
} catch (cause) {
|
|
717
|
+
setRequestError(failure(cause));
|
|
718
|
+
}
|
|
719
|
+
}, [fetchView]);
|
|
720
|
+
|
|
721
|
+
useEffect(() => {
|
|
722
|
+
void refresh();
|
|
723
|
+
}, [refresh]);
|
|
724
|
+
|
|
725
|
+
const waiting = view?.status === "ready" && view.signIn.state === "pending";
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
if (!waiting) return;
|
|
728
|
+
const timer = setInterval(() => void refresh(), 2000);
|
|
729
|
+
return () => clearInterval(timer);
|
|
730
|
+
}, [waiting, refresh]);
|
|
731
|
+
|
|
732
|
+
// Sign-in completes on the daemon; refresh models once it reports success.
|
|
733
|
+
const wasWaiting = useRef(false);
|
|
734
|
+
const signedIn =
|
|
735
|
+
view?.status === "ready" && view.account.state === "signed-in";
|
|
736
|
+
useEffect(() => {
|
|
737
|
+
if (wasWaiting.current && !waiting && signedIn) refreshCatalog(host.id);
|
|
738
|
+
wasWaiting.current = waiting;
|
|
739
|
+
}, [waiting, signedIn, host.id]);
|
|
740
|
+
|
|
741
|
+
const run = useCallback<Run>(
|
|
742
|
+
async (input) => {
|
|
743
|
+
setPending(true);
|
|
744
|
+
try {
|
|
745
|
+
const next = await change(input);
|
|
746
|
+
setView(next);
|
|
747
|
+
setRequestError(null);
|
|
748
|
+
if (input.action !== "signIn" && input.action !== "cancelSignIn")
|
|
749
|
+
refreshCatalog(host.id);
|
|
750
|
+
return next;
|
|
751
|
+
} catch (cause) {
|
|
752
|
+
setRequestError(failure(cause));
|
|
753
|
+
return null;
|
|
754
|
+
} finally {
|
|
755
|
+
setPending(false);
|
|
756
|
+
}
|
|
757
|
+
},
|
|
758
|
+
[change, host.id],
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
const error =
|
|
762
|
+
requestError === null ? null : (
|
|
763
|
+
<SettingsSection title="Last request">
|
|
764
|
+
<Text
|
|
765
|
+
accessibilityRole="alert"
|
|
766
|
+
style={{
|
|
767
|
+
color: theme.colors.statusDanger,
|
|
768
|
+
fontSize: compact ? 13 : 14,
|
|
769
|
+
}}
|
|
770
|
+
>
|
|
771
|
+
{requestError}
|
|
772
|
+
</Text>
|
|
773
|
+
</SettingsSection>
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
if (view === null || view.status === "unavailable")
|
|
777
|
+
return (
|
|
778
|
+
<View style={{ gap: compact ? 16 : 24 }}>
|
|
779
|
+
<SettingsSection title="ZCode account">
|
|
780
|
+
<SettingsCard>
|
|
781
|
+
<SettingsRow label="Status">
|
|
782
|
+
<Value
|
|
783
|
+
color={
|
|
784
|
+
view === null
|
|
785
|
+
? theme.colors.foregroundMuted
|
|
786
|
+
: theme.colors.statusWarning
|
|
787
|
+
}
|
|
788
|
+
compact={compact}
|
|
789
|
+
>
|
|
790
|
+
{view === null
|
|
791
|
+
? requestError === null
|
|
792
|
+
? "Starting ZCode…"
|
|
793
|
+
: "ZCode could not be reached."
|
|
794
|
+
: view.message}
|
|
795
|
+
</Value>
|
|
796
|
+
</SettingsRow>
|
|
797
|
+
{view?.code === "RUNTIME_SETUP_REQUIRED" ? (
|
|
798
|
+
<SettingsAction
|
|
799
|
+
label="ZCode runtime"
|
|
800
|
+
hint="Install it before signing in or adding providers."
|
|
801
|
+
actionLabel="Open Runtime"
|
|
802
|
+
onPress={onOpenRuntime}
|
|
803
|
+
/>
|
|
804
|
+
) : view === null && requestError === null ? null : (
|
|
805
|
+
<SettingsAction
|
|
806
|
+
label="Retry"
|
|
807
|
+
actionLabel="Retry"
|
|
808
|
+
disabled={pending}
|
|
809
|
+
onPress={() => void refresh()}
|
|
810
|
+
/>
|
|
811
|
+
)}
|
|
812
|
+
</SettingsCard>
|
|
813
|
+
</SettingsSection>
|
|
814
|
+
{error}
|
|
815
|
+
</View>
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
const parts = { theme, compact, busy: pending, run };
|
|
819
|
+
return (
|
|
820
|
+
<View style={{ gap: compact ? 16 : 24 }}>
|
|
821
|
+
<Account {...parts} view={view} />
|
|
822
|
+
<Plans view={view} theme={theme} compact={compact} />
|
|
823
|
+
<Providers {...parts} view={view} />
|
|
824
|
+
{error}
|
|
825
|
+
</View>
|
|
826
|
+
);
|
|
827
|
+
}
|