paseo-prompt-kit 0.5.2
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/LICENSE +21 -0
- package/README.md +249 -0
- package/client/actions/enabled.ts +30 -0
- package/client/commands/rewrite-command.ts +54 -0
- package/client/composer-bridge/adapter.ts +15 -0
- package/client/composer-bridge/dom.ts +101 -0
- package/client/composer-bridge/effect.ts +64 -0
- package/client/composer-bridge/fiber.ts +97 -0
- package/client/composer-bridge/web.ts +58 -0
- package/client/icon.ts +13 -0
- package/client/pills/agent-pills.ts +207 -0
- package/client/pills/rewrite-runner.ts +123 -0
- package/client/settings/action-samples.ts +102 -0
- package/client/settings/api-endpoints.ts +156 -0
- package/client/settings/custom-actions.ts +79 -0
- package/client/settings/draft.ts +82 -0
- package/client/settings/model-filter.ts +33 -0
- package/client/settings/read-settings.ts +45 -0
- package/client/settings/readiness.ts +84 -0
- package/client/settings/sections/actions-section.tsx +75 -0
- package/client/settings/sections/advanced-section.tsx +127 -0
- package/client/settings/sections/api-endpoint-section.tsx +388 -0
- package/client/settings/sections/custom-actions-section.tsx +163 -0
- package/client/settings/sections/dedicated-model-section.tsx +136 -0
- package/client/settings/sections/engine-section.tsx +101 -0
- package/client/settings/sections/provider-map-card.tsx +89 -0
- package/client/settings/sections/stored-key-rows.tsx +106 -0
- package/client/settings/selection.ts +46 -0
- package/client/settings/settings-saved.ts +17 -0
- package/client/settings/settings-screen.tsx +197 -0
- package/client/settings/ui/button.tsx +56 -0
- package/client/settings/ui/notice.tsx +61 -0
- package/client/settings/ui/split-select.tsx +26 -0
- package/client/settings/ui/status-bar.tsx +89 -0
- package/client/settings/ui/tokens.ts +38 -0
- package/client/settings/validation.ts +50 -0
- package/client/sheet/rewrite-sheet.tsx +249 -0
- package/index.client.tsx +71 -0
- package/index.server.ts +98 -0
- package/package.json +53 -0
- package/paseo-plugin.json +6 -0
- package/server/log.ts +20 -0
- package/server/model-resolver/provider-catalog.ts +37 -0
- package/server/model-resolver/resolver.ts +196 -0
- package/server/paseo-types.ts +13 -0
- package/server/rewrite-engine/engine.ts +88 -0
- package/server/rewrite-engine/handler.ts +94 -0
- package/server/rewrite-engine/output-validator.ts +130 -0
- package/server/transports/api/anthropic.ts +61 -0
- package/server/transports/api/cloudflare.ts +52 -0
- package/server/transports/api/gemini.ts +62 -0
- package/server/transports/api/key.ts +95 -0
- package/server/transports/api/openai.ts +52 -0
- package/server/transports/api/protocol.ts +96 -0
- package/server/transports/api/runner.ts +284 -0
- package/server/transports/api/secrets-store.ts +90 -0
- package/server/transports/cli/family.ts +216 -0
- package/server/transports/cli/process.ts +118 -0
- package/server/transports/cli/runner.ts +89 -0
- package/shared/action-registry/loader.ts +63 -0
- package/shared/action-registry/registry.ts +47 -0
- package/shared/action-registry/rewrite-contract.ts +31 -0
- package/shared/action-registry/schema.ts +65 -0
- package/shared/action-registry/wrapper.ts +30 -0
- package/shared/api-protocol.ts +56 -0
- package/shared/cli-families.ts +29 -0
- package/shared/language-registry/loader.ts +53 -0
- package/shared/language-registry/registry.ts +20 -0
- package/shared/language-registry/schema.ts +21 -0
- package/shared/languages/en.json +6 -0
- package/shared/languages/index.ts +5 -0
- package/shared/languages/vi.json +6 -0
- package/shared/packs/general.json +17 -0
- package/shared/packs/index.ts +12 -0
- package/shared/protected-literals.ts +550 -0
- package/shared/rpc.ts +187 -0
- package/shared/settings.ts +90 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
SettingsAction,
|
|
4
|
+
SettingsCard,
|
|
5
|
+
SettingsInput,
|
|
6
|
+
SettingsSection,
|
|
7
|
+
SettingsSelect,
|
|
8
|
+
} from "@getpaseo/plugin/client/ui";
|
|
9
|
+
import { isApiProtocolId, protocolNeedsAccountId, type ApiEndpoint, type ApiProtocolId } from "../../../shared/api-protocol.js";
|
|
10
|
+
import type { ApiTestOutput } from "../../../shared/rpc.js";
|
|
11
|
+
import type { PromptKitSettings } from "../../../shared/settings.js";
|
|
12
|
+
import {
|
|
13
|
+
ENDPOINT_PRESETS,
|
|
14
|
+
KEY_SOURCE_OPTIONS,
|
|
15
|
+
MODEL_PLACEHOLDER,
|
|
16
|
+
PROTOCOL_OPTIONS,
|
|
17
|
+
endpointFromPreset,
|
|
18
|
+
isUsableSecretsDir,
|
|
19
|
+
validateEndpoint,
|
|
20
|
+
} from "../api-endpoints.js";
|
|
21
|
+
import type { SettingsPatch } from "../draft.js";
|
|
22
|
+
import { MODEL_FILTER_THRESHOLD, describeFilter, filterModels } from "../model-filter.js";
|
|
23
|
+
import { StoredKeyRows, type StoredKeyRowsProps } from "./stored-key-rows.js";
|
|
24
|
+
|
|
25
|
+
export interface ApiEndpointSectionProps {
|
|
26
|
+
values: PromptKitSettings;
|
|
27
|
+
disabled: boolean;
|
|
28
|
+
/** Bumps when the draft is discarded or saved, so text fields re-read their value. */
|
|
29
|
+
epoch: number;
|
|
30
|
+
patch(update: SettingsPatch): void;
|
|
31
|
+
/** Runs the real key lookup and model list against one endpoint. */
|
|
32
|
+
test(endpoint: ApiEndpoint, secretsDir: string | null): Promise<ApiTestOutput>;
|
|
33
|
+
keyStatus: StoredKeyRowsProps["status"];
|
|
34
|
+
writeKey: StoredKeyRowsProps["write"];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type TestState =
|
|
38
|
+
| { kind: "idle" }
|
|
39
|
+
| { kind: "running" }
|
|
40
|
+
| { kind: "ok"; models: number }
|
|
41
|
+
| { kind: "error"; message: string };
|
|
42
|
+
|
|
43
|
+
const NONE = "";
|
|
44
|
+
/** The value of the "add an endpoint that is not a preset" option. */
|
|
45
|
+
const CUSTOM = "__custom__";
|
|
46
|
+
const PRESET_IDS: ReadonlySet<string> = new Set(ENDPOINT_PRESETS.map((preset) => preset.id));
|
|
47
|
+
|
|
48
|
+
/** A free id for a custom endpoint, so two of them cannot collide. */
|
|
49
|
+
function nextCustomId(endpoints: readonly ApiEndpoint[]): string {
|
|
50
|
+
for (let n = 1; ; n += 1) {
|
|
51
|
+
const id = `custom-${n}`;
|
|
52
|
+
if (!endpoints.some((endpoint) => endpoint.id === id)) return id;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function withEndpoint(
|
|
57
|
+
endpoints: readonly ApiEndpoint[],
|
|
58
|
+
id: string,
|
|
59
|
+
changes: Partial<ApiEndpoint>,
|
|
60
|
+
): ApiEndpoint[] {
|
|
61
|
+
return endpoints.map((endpoint) => (endpoint.id === id ? { ...endpoint, ...changes } : endpoint));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pick endpoint → fill details → Test → pick model. One picker for presets and custom entries. */
|
|
65
|
+
export function ApiEndpointSection({ values, disabled, epoch, patch, test, keyStatus, writeKey }: ApiEndpointSectionProps) {
|
|
66
|
+
const [tests, setTests] = useState<Readonly<Record<string, TestState>>>({});
|
|
67
|
+
const [modelQuery, setModelQuery] = useState("");
|
|
68
|
+
|
|
69
|
+
const selected = values.apiEndpoints.find((endpoint) => endpoint.id === values.apiEndpointId) ?? null;
|
|
70
|
+
const selectedIsPreset = selected !== null && PRESET_IDS.has(selected.id);
|
|
71
|
+
const testState: TestState = selected === null ? { kind: "idle" } : (tests[selected.id] ?? { kind: "idle" });
|
|
72
|
+
const problem = selected === null ? null : validateEndpoint(selected, values.apiEndpoints, selected.id);
|
|
73
|
+
|
|
74
|
+
// Presets and saved custom endpoints together, A–Z; "Custom endpoint…" always last.
|
|
75
|
+
const endpointOptions = useMemo(() => {
|
|
76
|
+
const protocolLabel = (id: ApiProtocolId) => PROTOCOL_OPTIONS.find((option) => option.value === id)?.label ?? id;
|
|
77
|
+
const entries = [
|
|
78
|
+
...ENDPOINT_PRESETS.map((preset) => ({ label: `${preset.label} — ${preset.note}`, value: preset.id })),
|
|
79
|
+
...values.apiEndpoints
|
|
80
|
+
.filter((endpoint) => !PRESET_IDS.has(endpoint.id))
|
|
81
|
+
.map((endpoint) => ({ label: `${endpoint.label} — ${protocolLabel(endpoint.protocol)}`, value: endpoint.id })),
|
|
82
|
+
].sort((left, right) => left.label.localeCompare(right.label, undefined, { sensitivity: "base" }));
|
|
83
|
+
return [{ label: "Choose an endpoint…", value: NONE }, ...entries, { label: "Custom endpoint…", value: CUSTOM }];
|
|
84
|
+
}, [values.apiEndpoints]);
|
|
85
|
+
|
|
86
|
+
const modelOptions = useMemo(() => {
|
|
87
|
+
const declared = (selected?.models ?? []).map((model) => ({ label: model, value: model }));
|
|
88
|
+
// Keep the saved model selectable even if the endpoint no longer lists it.
|
|
89
|
+
if (values.apiModel !== null && !declared.some((option) => option.value === values.apiModel)) {
|
|
90
|
+
return [{ label: values.apiModel, value: values.apiModel }, ...declared];
|
|
91
|
+
}
|
|
92
|
+
return declared;
|
|
93
|
+
}, [selected, values.apiModel]);
|
|
94
|
+
|
|
95
|
+
/** Choosing an endpoint selects one that exists or adds it from its preset. */
|
|
96
|
+
const choose = (choice: string) => {
|
|
97
|
+
setModelQuery("");
|
|
98
|
+
patch((current) => {
|
|
99
|
+
if (choice === NONE) return { apiEndpointId: null, apiModel: null };
|
|
100
|
+
if (current.apiEndpoints.some((endpoint) => endpoint.id === choice)) {
|
|
101
|
+
return {
|
|
102
|
+
apiEndpointId: choice,
|
|
103
|
+
// The model belongs to one endpoint, so switching endpoint drops it.
|
|
104
|
+
apiModel: choice === current.apiEndpointId ? current.apiModel : null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (choice === CUSTOM) {
|
|
108
|
+
const id = nextCustomId(current.apiEndpoints);
|
|
109
|
+
return {
|
|
110
|
+
apiEndpoints: [
|
|
111
|
+
...current.apiEndpoints,
|
|
112
|
+
{
|
|
113
|
+
id,
|
|
114
|
+
label: "Custom endpoint",
|
|
115
|
+
protocol: "openai" as ApiProtocolId,
|
|
116
|
+
baseUrl: "https://",
|
|
117
|
+
keySource: "env" as const,
|
|
118
|
+
apiKeyEnv: "",
|
|
119
|
+
accountIdVar: "",
|
|
120
|
+
models: [],
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
apiEndpointId: id,
|
|
124
|
+
apiModel: null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const preset = ENDPOINT_PRESETS.find((candidate) => candidate.id === choice);
|
|
128
|
+
if (preset === undefined) return {};
|
|
129
|
+
return {
|
|
130
|
+
apiEndpoints: [...current.apiEndpoints, endpointFromPreset(preset)],
|
|
131
|
+
apiEndpointId: preset.id,
|
|
132
|
+
apiModel: null,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const edit = (changes: Partial<ApiEndpoint>) => {
|
|
138
|
+
if (selected === null) return;
|
|
139
|
+
const id = selected.id;
|
|
140
|
+
patch((current) => ({ apiEndpoints: withEndpoint(current.apiEndpoints, id, changes) }));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const remove = () => {
|
|
144
|
+
if (selected === null) return;
|
|
145
|
+
const id = selected.id;
|
|
146
|
+
patch((current) => ({
|
|
147
|
+
apiEndpoints: current.apiEndpoints.filter((endpoint) => endpoint.id !== id),
|
|
148
|
+
apiEndpointId: null,
|
|
149
|
+
apiModel: null,
|
|
150
|
+
apiEndpointByProvider: Object.fromEntries(
|
|
151
|
+
Object.entries(current.apiEndpointByProvider).filter(([, target]) => target !== id),
|
|
152
|
+
),
|
|
153
|
+
}));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const runTest = async (endpoint: ApiEndpoint) => {
|
|
157
|
+
setTests((previous) => ({ ...previous, [endpoint.id]: { kind: "running" } }));
|
|
158
|
+
try {
|
|
159
|
+
const output = await test(endpoint, values.secretsDir);
|
|
160
|
+
if (output.status === "ok") {
|
|
161
|
+
setTests((previous) => ({ ...previous, [endpoint.id]: { kind: "ok", models: output.models.length } }));
|
|
162
|
+
// Test fills the model list; an empty answer leaves it alone.
|
|
163
|
+
if (output.models.length > 0) {
|
|
164
|
+
const models = [...output.models];
|
|
165
|
+
patch((current) => ({ apiEndpoints: withEndpoint(current.apiEndpoints, endpoint.id, { models }) }));
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
setTests((previous) => ({
|
|
169
|
+
...previous,
|
|
170
|
+
[endpoint.id]: { kind: "error", message: output.error.message },
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
} catch (thrown) {
|
|
174
|
+
setTests((previous) => ({
|
|
175
|
+
...previous,
|
|
176
|
+
[endpoint.id]: { kind: "error", message: thrown instanceof Error ? thrown.message : String(thrown) },
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const testHint =
|
|
182
|
+
testState.kind === "ok"
|
|
183
|
+
? testState.models === 0
|
|
184
|
+
? "Reachable, but the endpoint lists no models. Type the model id below."
|
|
185
|
+
: `Reachable. ${testState.models} models are now in the model list below.`
|
|
186
|
+
: "Asks the endpoint which models it offers, with the same key lookup a rewrite uses.";
|
|
187
|
+
const keyFound =
|
|
188
|
+
selected === null || testState.kind !== "ok"
|
|
189
|
+
? ""
|
|
190
|
+
: selected.keySource === "none"
|
|
191
|
+
? " No key was sent."
|
|
192
|
+
: selected.keySource === "env"
|
|
193
|
+
? ` Key read from the environment variable ${selected.apiKeyEnv.trim()}.`
|
|
194
|
+
: ` Key read from secrets.json (${selected.apiKeyEnv.trim()}).`;
|
|
195
|
+
const secretsDirError =
|
|
196
|
+
values.secretsDir !== null && !isUsableSecretsDir(values.secretsDir)
|
|
197
|
+
? "Must be an absolute path or start with ~/."
|
|
198
|
+
: null;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<SettingsSection
|
|
202
|
+
title="API endpoint"
|
|
203
|
+
info="The key itself is never stored here, because this document reaches your browser. Choose where the daemon reads it — an environment variable or secrets.json — and store only its name. See README, “API keys”."
|
|
204
|
+
>
|
|
205
|
+
<SettingsCard>
|
|
206
|
+
<SettingsSelect
|
|
207
|
+
label="Endpoint"
|
|
208
|
+
hint={selected === null ? "Choosing a preset adds it; it is saved with the screen." : undefined}
|
|
209
|
+
error={selected === null ? "API rewrites will not run until an endpoint is chosen." : null}
|
|
210
|
+
value={values.apiEndpointId ?? NONE}
|
|
211
|
+
options={endpointOptions}
|
|
212
|
+
disabled={disabled}
|
|
213
|
+
onValueChange={choose}
|
|
214
|
+
/>
|
|
215
|
+
|
|
216
|
+
{selected === null ? null : (
|
|
217
|
+
<>
|
|
218
|
+
{selectedIsPreset ? null : (
|
|
219
|
+
<>
|
|
220
|
+
<SettingsInput
|
|
221
|
+
key={`${epoch}-${selected.id}-label`}
|
|
222
|
+
label="Name"
|
|
223
|
+
initialValue={selected.label}
|
|
224
|
+
placeholder="My endpoint"
|
|
225
|
+
disabled={disabled}
|
|
226
|
+
onChangeText={(label) => edit({ label })}
|
|
227
|
+
/>
|
|
228
|
+
<SettingsSelect
|
|
229
|
+
key={`${selected.id}-protocol`}
|
|
230
|
+
label="Protocol"
|
|
231
|
+
hint="Which wire shape the endpoint speaks."
|
|
232
|
+
value={selected.protocol}
|
|
233
|
+
options={PROTOCOL_OPTIONS}
|
|
234
|
+
disabled={disabled}
|
|
235
|
+
onValueChange={(protocol) =>
|
|
236
|
+
edit({ protocol: isApiProtocolId(protocol) ? protocol : "openai" })
|
|
237
|
+
}
|
|
238
|
+
/>
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
<SettingsInput
|
|
242
|
+
key={`${epoch}-${selected.id}-baseUrl`}
|
|
243
|
+
label="Base URL"
|
|
244
|
+
error={problem !== null && problem.includes("base URL") ? problem : null}
|
|
245
|
+
initialValue={selected.baseUrl}
|
|
246
|
+
placeholder="https://api.openai.com/v1"
|
|
247
|
+
disabled={disabled}
|
|
248
|
+
onChangeText={(baseUrl) => edit({ baseUrl })}
|
|
249
|
+
/>
|
|
250
|
+
<SettingsSelect
|
|
251
|
+
key={`${selected.id}-keySource`}
|
|
252
|
+
label="Key source"
|
|
253
|
+
hint={
|
|
254
|
+
selected.keySource === "env"
|
|
255
|
+
? "An environment variable of the Paseo daemon. Paseo reads your shell variables once, when it starts: after adding one, quit and reopen Paseo."
|
|
256
|
+
: selected.keySource === "secrets_file"
|
|
257
|
+
? "An entry in secrets.json on the daemon's machine. Works however Paseo was started."
|
|
258
|
+
: "Send no key. For a local server such as LM Studio, vLLM or llama.cpp."
|
|
259
|
+
}
|
|
260
|
+
value={selected.keySource}
|
|
261
|
+
options={KEY_SOURCE_OPTIONS}
|
|
262
|
+
disabled={disabled}
|
|
263
|
+
onValueChange={(keySource) =>
|
|
264
|
+
edit({ keySource: keySource === "secrets_file" || keySource === "none" ? keySource : "env" })
|
|
265
|
+
}
|
|
266
|
+
/>
|
|
267
|
+
{protocolNeedsAccountId(selected.protocol) ? (
|
|
268
|
+
<SettingsInput
|
|
269
|
+
key={`${epoch}-${selected.id}-account`}
|
|
270
|
+
label="Account ID"
|
|
271
|
+
hint={
|
|
272
|
+
selected.keySource === "secrets_file"
|
|
273
|
+
? "Name of the entry under apiKeys in secrets.json holding the account ID, not the ID."
|
|
274
|
+
: "Name of the environment variable holding the account ID, not the ID."
|
|
275
|
+
}
|
|
276
|
+
error={selected.accountIdVar.trim() === "" ? "Enter the account ID variable." : null}
|
|
277
|
+
initialValue={selected.accountIdVar}
|
|
278
|
+
placeholder="CLAUDFLARE_ACCOUNT_ID"
|
|
279
|
+
disabled={disabled}
|
|
280
|
+
onChangeText={(accountIdVar) => edit({ accountIdVar })}
|
|
281
|
+
/>
|
|
282
|
+
) : null}
|
|
283
|
+
{selected.keySource === "none" ? null : (
|
|
284
|
+
<SettingsInput
|
|
285
|
+
key={`${epoch}-${selected.id}-key`}
|
|
286
|
+
label="Key variable"
|
|
287
|
+
hint={
|
|
288
|
+
selected.keySource === "env"
|
|
289
|
+
? "Name of the environment variable, not the key."
|
|
290
|
+
: "Name of the entry under apiKeys in secrets.json, not the key."
|
|
291
|
+
}
|
|
292
|
+
error={selected.apiKeyEnv.trim() === "" ? "Enter the key variable." : null}
|
|
293
|
+
initialValue={selected.apiKeyEnv}
|
|
294
|
+
placeholder="GEMINI_API_KEY"
|
|
295
|
+
disabled={disabled}
|
|
296
|
+
onChangeText={(apiKeyEnv) => edit({ apiKeyEnv })}
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
{selected.keySource === "secrets_file" ? (
|
|
300
|
+
<SettingsInput
|
|
301
|
+
key={`${epoch}-secretsDir`}
|
|
302
|
+
label="Secrets directory"
|
|
303
|
+
hint="Folder holding secrets.json, shared by every endpoint that uses it. Empty means <PASEO_HOME>/plugin-settings/prompt-kit."
|
|
304
|
+
error={secretsDirError}
|
|
305
|
+
initialValue={values.secretsDir ?? ""}
|
|
306
|
+
placeholder="~/.paseo/plugin-settings/prompt-kit"
|
|
307
|
+
disabled={disabled}
|
|
308
|
+
onChangeText={(text) => patch({ secretsDir: text.trim() === "" ? null : text.trim() })}
|
|
309
|
+
/>
|
|
310
|
+
) : null}
|
|
311
|
+
{selected.keySource === "secrets_file" && secretsDirError === null ? (
|
|
312
|
+
<>
|
|
313
|
+
{protocolNeedsAccountId(selected.protocol) ? (
|
|
314
|
+
<StoredKeyRows
|
|
315
|
+
subject="Account ID"
|
|
316
|
+
name={selected.accountIdVar}
|
|
317
|
+
secretsDir={values.secretsDir}
|
|
318
|
+
disabled={disabled}
|
|
319
|
+
status={keyStatus}
|
|
320
|
+
write={writeKey}
|
|
321
|
+
/>
|
|
322
|
+
) : null}
|
|
323
|
+
<StoredKeyRows
|
|
324
|
+
subject="API key"
|
|
325
|
+
name={selected.apiKeyEnv}
|
|
326
|
+
secretsDir={values.secretsDir}
|
|
327
|
+
disabled={disabled}
|
|
328
|
+
status={keyStatus}
|
|
329
|
+
write={writeKey}
|
|
330
|
+
/>
|
|
331
|
+
</>
|
|
332
|
+
) : null}
|
|
333
|
+
<SettingsAction
|
|
334
|
+
label="Connection"
|
|
335
|
+
hint={`${testHint}${keyFound}`}
|
|
336
|
+
error={testState.kind === "error" ? testState.message : null}
|
|
337
|
+
actionLabel={testState.kind === "running" ? "Testing…" : "Test"}
|
|
338
|
+
disabled={disabled || problem !== null || testState.kind === "running"}
|
|
339
|
+
onPress={() => void runTest(selected)}
|
|
340
|
+
/>
|
|
341
|
+
|
|
342
|
+
{modelOptions.length > MODEL_FILTER_THRESHOLD ? (
|
|
343
|
+
<SettingsInput
|
|
344
|
+
key={`filter-${selected.id}`}
|
|
345
|
+
label="Filter models"
|
|
346
|
+
hint={describeFilter(modelOptions.length, filterModels(modelOptions, modelQuery).length, modelQuery)}
|
|
347
|
+
initialValue=""
|
|
348
|
+
placeholder="flash, llama, @cf/meta…"
|
|
349
|
+
disabled={disabled}
|
|
350
|
+
onChangeText={setModelQuery}
|
|
351
|
+
/>
|
|
352
|
+
) : null}
|
|
353
|
+
{selected.models.length > 0 ? (
|
|
354
|
+
<SettingsSelect
|
|
355
|
+
label="Model"
|
|
356
|
+
hint="Which model the rewrite asks this endpoint for. A provider mapped under Advanced sends its own model instead."
|
|
357
|
+
error={values.apiModel === null ? "Choose a model." : null}
|
|
358
|
+
value={values.apiModel ?? NONE}
|
|
359
|
+
options={[{ label: "Select a model", value: NONE }, ...filterModels(modelOptions, modelQuery, values.apiModel)]}
|
|
360
|
+
disabled={disabled}
|
|
361
|
+
onValueChange={(apiModel) => patch({ apiModel: apiModel === NONE ? null : apiModel })}
|
|
362
|
+
/>
|
|
363
|
+
) : (
|
|
364
|
+
<SettingsInput
|
|
365
|
+
key={`${epoch}-${selected.id}-apiModel`}
|
|
366
|
+
label="Model"
|
|
367
|
+
hint="This endpoint has not listed its models. Press Test to fill the list, or type the id."
|
|
368
|
+
error={values.apiModel === null ? "Choose a model." : null}
|
|
369
|
+
initialValue={values.apiModel ?? ""}
|
|
370
|
+
placeholder={MODEL_PLACEHOLDER[selected.protocol]}
|
|
371
|
+
disabled={disabled}
|
|
372
|
+
onChangeText={(model) => patch({ apiModel: model.trim() === "" ? null : model.trim() })}
|
|
373
|
+
/>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
<SettingsAction
|
|
377
|
+
label="Remove endpoint"
|
|
378
|
+
hint="Drops it from this document. A preset can be added again from the list."
|
|
379
|
+
actionLabel="Remove"
|
|
380
|
+
disabled={disabled}
|
|
381
|
+
onPress={remove}
|
|
382
|
+
/>
|
|
383
|
+
</>
|
|
384
|
+
)}
|
|
385
|
+
</SettingsCard>
|
|
386
|
+
</SettingsSection>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
4
|
+
import { SettingsAction, SettingsCard, SettingsSection, SettingsSelect } from "@getpaseo/plugin/client/ui";
|
|
5
|
+
import { TextInput } from "@getpaseo/plugin/client/react-native";
|
|
6
|
+
import type { ActionSummary } from "../../../shared/rpc.js";
|
|
7
|
+
import type { PromptKitSettings } from "../../../shared/settings.js";
|
|
8
|
+
import { ACTION_SAMPLES } from "../action-samples.js";
|
|
9
|
+
import { formatPack, freeActionId, parseCustomAction, removeCustomAction, storeCustomAction } from "../custom-actions.js";
|
|
10
|
+
import type { SettingsPatch } from "../draft.js";
|
|
11
|
+
import { Button } from "../ui/button.js";
|
|
12
|
+
import { RADIUS, SPACE, textStyles } from "../ui/tokens.js";
|
|
13
|
+
|
|
14
|
+
export interface CustomActionsSectionProps {
|
|
15
|
+
theme: PluginTheme;
|
|
16
|
+
/** Null while the registry has not answered yet. */
|
|
17
|
+
actions: readonly ActionSummary[] | null;
|
|
18
|
+
values: PromptKitSettings;
|
|
19
|
+
disabled: boolean;
|
|
20
|
+
patch(update: SettingsPatch): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Editing = { id: string | null; text: string; error: string | null };
|
|
24
|
+
|
|
25
|
+
const COPY_PREFIX = "copy:";
|
|
26
|
+
|
|
27
|
+
/** Custom actions as JSON: pick a sample or an existing action, edit, Apply. Save stores them. */
|
|
28
|
+
export function CustomActionsSection({ theme, actions, values, disabled, patch }: CustomActionsSectionProps) {
|
|
29
|
+
const [editing, setEditing] = useState<Editing | null>(null);
|
|
30
|
+
const text = useMemo(() => textStyles(theme), [theme]);
|
|
31
|
+
const busy = disabled || actions === null;
|
|
32
|
+
|
|
33
|
+
const takenIds = (except: string | null): Set<string> =>
|
|
34
|
+
new Set((actions ?? []).map((action) => action.id).filter((id) => id !== except));
|
|
35
|
+
|
|
36
|
+
const startFrom = (choice: string) => {
|
|
37
|
+
const copy = choice.startsWith(COPY_PREFIX)
|
|
38
|
+
? values.customActions.find((pack) => pack.id === choice.slice(COPY_PREFIX.length))
|
|
39
|
+
: undefined;
|
|
40
|
+
const base = copy ?? ACTION_SAMPLES.find((sample) => sample.key === choice)?.pack;
|
|
41
|
+
if (base === undefined) return;
|
|
42
|
+
const pack = { ...base, id: freeActionId(base.id, takenIds(null)) };
|
|
43
|
+
setEditing({ id: null, text: formatPack(pack), error: null });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const apply = () => {
|
|
47
|
+
if (editing === null || actions === null) return;
|
|
48
|
+
const parsed = parseCustomAction(editing.text, takenIds(editing.id));
|
|
49
|
+
if (!parsed.ok) {
|
|
50
|
+
setEditing({ ...editing, error: parsed.error });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const replacingId = editing.id;
|
|
54
|
+
patch((current) => storeCustomAction(current, actions, parsed.pack, replacingId));
|
|
55
|
+
setEditing(null);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const remove = () => {
|
|
59
|
+
if (editing?.id == null) return;
|
|
60
|
+
const id = editing.id;
|
|
61
|
+
patch((current) => removeCustomAction(current, id));
|
|
62
|
+
setEditing(null);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const startOptions = [
|
|
66
|
+
...ACTION_SAMPLES.map((sample) => ({ label: sample.label, value: sample.key })),
|
|
67
|
+
...values.customActions.map((pack) => ({ label: `Copy of ${pack.title}`, value: `${COPY_PREFIX}${pack.id}` })),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const box = {
|
|
71
|
+
minHeight: 280,
|
|
72
|
+
padding: SPACE.md,
|
|
73
|
+
borderRadius: RADIUS.lg,
|
|
74
|
+
borderWidth: 1,
|
|
75
|
+
borderColor: editing?.error ? theme.colors.statusDanger : theme.colors.border,
|
|
76
|
+
backgroundColor: theme.colors.surface1,
|
|
77
|
+
color: theme.colors.foreground,
|
|
78
|
+
fontFamily: "monospace",
|
|
79
|
+
fontSize: 12,
|
|
80
|
+
textAlignVertical: "top" as const,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<SettingsSection
|
|
85
|
+
title="Custom actions"
|
|
86
|
+
info="Your own actions, written as action-pack JSON. Start from a sample, change the id, title and instructions, then Apply and Save. They run through the same rewrite as the bundled actions."
|
|
87
|
+
>
|
|
88
|
+
<SettingsCard>
|
|
89
|
+
{values.customActions.map((pack) => (
|
|
90
|
+
<SettingsAction
|
|
91
|
+
key={pack.id}
|
|
92
|
+
label={pack.title}
|
|
93
|
+
hint={`${pack.id} · ${pack.description}`}
|
|
94
|
+
actionLabel="Edit"
|
|
95
|
+
disabled={busy || editing !== null}
|
|
96
|
+
onPress={() => setEditing({ id: pack.id, text: formatPack(pack), error: null })}
|
|
97
|
+
/>
|
|
98
|
+
))}
|
|
99
|
+
{editing === null ? (
|
|
100
|
+
<SettingsAction
|
|
101
|
+
label="Add action"
|
|
102
|
+
hint="Opens a sample to edit."
|
|
103
|
+
actionLabel="Add"
|
|
104
|
+
disabled={busy}
|
|
105
|
+
onPress={() => startFrom(ACTION_SAMPLES[0]!.key)}
|
|
106
|
+
testID="prompt-kit-custom-add"
|
|
107
|
+
/>
|
|
108
|
+
) : editing.id === null ? (
|
|
109
|
+
<SettingsSelect
|
|
110
|
+
label="Start from"
|
|
111
|
+
hint="Replaces the JSON below."
|
|
112
|
+
value=""
|
|
113
|
+
options={[{ label: "Choose a sample…", value: "" }, ...startOptions]}
|
|
114
|
+
onValueChange={(choice) => {
|
|
115
|
+
if (choice !== "") startFrom(choice);
|
|
116
|
+
}}
|
|
117
|
+
disabled={busy}
|
|
118
|
+
/>
|
|
119
|
+
) : null}
|
|
120
|
+
</SettingsCard>
|
|
121
|
+
|
|
122
|
+
{editing !== null ? (
|
|
123
|
+
<View style={{ gap: SPACE.md, marginTop: SPACE.md }}>
|
|
124
|
+
<TextInput
|
|
125
|
+
multiline
|
|
126
|
+
value={editing.text}
|
|
127
|
+
onChangeText={(next) => setEditing({ ...editing, text: next, error: null })}
|
|
128
|
+
autoCapitalize="none"
|
|
129
|
+
autoCorrect={false}
|
|
130
|
+
spellCheck={false}
|
|
131
|
+
editable={!busy}
|
|
132
|
+
style={box}
|
|
133
|
+
testID="prompt-kit-custom-json"
|
|
134
|
+
/>
|
|
135
|
+
{editing.error !== null ? (
|
|
136
|
+
<Text style={text.danger} testID="prompt-kit-custom-error">
|
|
137
|
+
{editing.error}
|
|
138
|
+
</Text>
|
|
139
|
+
) : (
|
|
140
|
+
<Text style={text.muted}>
|
|
141
|
+
Apply checks the JSON and adds it to the unsaved changes; Save stores it.
|
|
142
|
+
</Text>
|
|
143
|
+
)}
|
|
144
|
+
<View style={{ flexDirection: "row", gap: SPACE.md }}>
|
|
145
|
+
{editing.id !== null ? (
|
|
146
|
+
<Button theme={theme} label="Delete" onPress={remove} disabled={busy} testID="prompt-kit-custom-delete" />
|
|
147
|
+
) : null}
|
|
148
|
+
<View style={{ flex: 1 }} />
|
|
149
|
+
<Button theme={theme} label="Cancel" onPress={() => setEditing(null)} testID="prompt-kit-custom-cancel" />
|
|
150
|
+
<Button
|
|
151
|
+
theme={theme}
|
|
152
|
+
label="Apply"
|
|
153
|
+
variant="primary"
|
|
154
|
+
onPress={apply}
|
|
155
|
+
disabled={busy}
|
|
156
|
+
testID="prompt-kit-custom-apply"
|
|
157
|
+
/>
|
|
158
|
+
</View>
|
|
159
|
+
</View>
|
|
160
|
+
) : null}
|
|
161
|
+
</SettingsSection>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { SettingsAction, SettingsCard, SettingsInput, SettingsSection, SettingsSelect } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import type { ProviderCatalogOutput } from "../../../shared/rpc.js";
|
|
4
|
+
import type { PromptKitSettings } from "../../../shared/settings.js";
|
|
5
|
+
import type { SettingsPatch } from "../draft.js";
|
|
6
|
+
import { MODEL_FILTER_THRESHOLD, describeFilter, filterModels } from "../model-filter.js";
|
|
7
|
+
|
|
8
|
+
type Providers = ProviderCatalogOutput["providers"];
|
|
9
|
+
|
|
10
|
+
export interface DedicatedModelSectionProps {
|
|
11
|
+
values: PromptKitSettings;
|
|
12
|
+
/** Null until the catalog has been read. */
|
|
13
|
+
providers: Providers | null;
|
|
14
|
+
providersError: string | null;
|
|
15
|
+
disabled: boolean;
|
|
16
|
+
patch(update: SettingsPatch): void;
|
|
17
|
+
reloadProviders(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Empty string is the "no selection" option; the schema stores null for it. */
|
|
21
|
+
const NONE = "";
|
|
22
|
+
|
|
23
|
+
/** Dedicated provider/model/thinking for the CLI transport, with per-row errors. */
|
|
24
|
+
export function DedicatedModelSection({
|
|
25
|
+
values,
|
|
26
|
+
providers,
|
|
27
|
+
providersError,
|
|
28
|
+
disabled,
|
|
29
|
+
patch,
|
|
30
|
+
reloadProviders,
|
|
31
|
+
}: DedicatedModelSectionProps) {
|
|
32
|
+
const [query, setQuery] = useState("");
|
|
33
|
+
const catalog = providers ?? [];
|
|
34
|
+
const provider = catalog.find((entry) => entry.provider === values.dedicatedProvider);
|
|
35
|
+
const model = provider?.models.find((entry) => entry.id === values.dedicatedModel);
|
|
36
|
+
const modelOptions = (provider?.models ?? []).map((entry) => ({ label: entry.label, value: entry.id }));
|
|
37
|
+
const shownModels = filterModels(modelOptions, query, values.dedicatedModel);
|
|
38
|
+
|
|
39
|
+
const providerError =
|
|
40
|
+
values.dedicatedProvider === null
|
|
41
|
+
? "Choose a provider."
|
|
42
|
+
: providers !== null && (provider === undefined || !provider.available)
|
|
43
|
+
? `Provider is unavailable: ${values.dedicatedProvider}`
|
|
44
|
+
: null;
|
|
45
|
+
const modelError =
|
|
46
|
+
values.dedicatedModel === null
|
|
47
|
+
? "Choose a model."
|
|
48
|
+
: provider !== undefined && model === undefined
|
|
49
|
+
? `Model is unavailable: ${values.dedicatedModel}`
|
|
50
|
+
: null;
|
|
51
|
+
const thinkingError =
|
|
52
|
+
values.dedicatedThinkingOptionId !== null &&
|
|
53
|
+
model !== undefined &&
|
|
54
|
+
!model.thinkingOptions.some((option) => option.id === values.dedicatedThinkingOptionId)
|
|
55
|
+
? `Thinking option is unavailable: ${values.dedicatedThinkingOptionId}`
|
|
56
|
+
: null;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<SettingsSection
|
|
60
|
+
title="Dedicated model"
|
|
61
|
+
info="Read from the daemon's provider catalog. A provider marked unavailable has no working CLI or credentials on the daemon host."
|
|
62
|
+
>
|
|
63
|
+
<SettingsCard>
|
|
64
|
+
<SettingsSelect
|
|
65
|
+
label="Provider"
|
|
66
|
+
error={providerError}
|
|
67
|
+
value={values.dedicatedProvider ?? NONE}
|
|
68
|
+
options={[
|
|
69
|
+
{ label: providers === null ? "Loading…" : "Select a provider", value: NONE },
|
|
70
|
+
...catalog.map((entry) => ({
|
|
71
|
+
label: entry.available ? entry.label : `${entry.label} (unavailable)`,
|
|
72
|
+
value: entry.provider,
|
|
73
|
+
})),
|
|
74
|
+
]}
|
|
75
|
+
disabled={disabled || providers === null}
|
|
76
|
+
onValueChange={(next) => {
|
|
77
|
+
setQuery("");
|
|
78
|
+
patch({
|
|
79
|
+
dedicatedProvider: next === NONE ? null : next,
|
|
80
|
+
dedicatedModel: null,
|
|
81
|
+
dedicatedThinkingOptionId: null,
|
|
82
|
+
});
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
{modelOptions.length > MODEL_FILTER_THRESHOLD ? (
|
|
86
|
+
<SettingsInput
|
|
87
|
+
key={`filter-${values.dedicatedProvider ?? ""}`}
|
|
88
|
+
label="Filter models"
|
|
89
|
+
hint={describeFilter(modelOptions.length, filterModels(modelOptions, query).length, query)}
|
|
90
|
+
initialValue=""
|
|
91
|
+
placeholder="flash, llama, @cf/meta…"
|
|
92
|
+
disabled={disabled}
|
|
93
|
+
onChangeText={setQuery}
|
|
94
|
+
/>
|
|
95
|
+
) : null}
|
|
96
|
+
<SettingsSelect
|
|
97
|
+
label="Model"
|
|
98
|
+
error={providerError === null ? modelError : null}
|
|
99
|
+
value={values.dedicatedModel ?? NONE}
|
|
100
|
+
options={[{ label: "Select a model", value: NONE }, ...shownModels]}
|
|
101
|
+
disabled={disabled || provider === undefined}
|
|
102
|
+
onValueChange={(next) =>
|
|
103
|
+
patch({ dedicatedModel: next === NONE ? null : next, dedicatedThinkingOptionId: null })
|
|
104
|
+
}
|
|
105
|
+
/>
|
|
106
|
+
<SettingsSelect
|
|
107
|
+
label="Thinking"
|
|
108
|
+
hint="Model default unless you choose one."
|
|
109
|
+
error={thinkingError}
|
|
110
|
+
value={values.dedicatedThinkingOptionId ?? NONE}
|
|
111
|
+
options={[
|
|
112
|
+
{ label: "Model default", value: NONE },
|
|
113
|
+
...(model?.thinkingOptions ?? []).map((option) => ({
|
|
114
|
+
label: option.label,
|
|
115
|
+
value: option.id,
|
|
116
|
+
})),
|
|
117
|
+
]}
|
|
118
|
+
disabled={disabled || model === undefined || model.thinkingOptions.length === 0}
|
|
119
|
+
onValueChange={(next) => patch({ dedicatedThinkingOptionId: next === NONE ? null : next })}
|
|
120
|
+
/>
|
|
121
|
+
<SettingsAction
|
|
122
|
+
label="Provider catalog"
|
|
123
|
+
hint={
|
|
124
|
+
providers === null
|
|
125
|
+
? "Not read yet."
|
|
126
|
+
: `${catalog.filter((entry) => entry.available).length} of ${catalog.length} providers available.`
|
|
127
|
+
}
|
|
128
|
+
error={providersError}
|
|
129
|
+
actionLabel="Refresh"
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
onPress={reloadProviders}
|
|
132
|
+
/>
|
|
133
|
+
</SettingsCard>
|
|
134
|
+
</SettingsSection>
|
|
135
|
+
);
|
|
136
|
+
}
|