sanity-plugin-jev-fields 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +184 -3
- package/dist/index.d.ts +283 -0
- package/dist/index.js +1340 -0
- package/dist/index.js.map +1 -0
- package/package.json +98 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1340 @@
|
|
|
1
|
+
import { SparklesIcon } from "@sanity/icons/Sparkles";
|
|
2
|
+
import { Translate, defineArrayMember, defineField, defineLocaleResourceBundle, definePlugin, defineType, set, useClient, useDocumentPreviewStore, useFormValue, useTranslation } from "sanity";
|
|
3
|
+
import { Badge, Box, Button, Card, Container, Dialog, Flex, Heading, Spinner, Stack, Text, TextInput } from "@sanity/ui";
|
|
4
|
+
import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { RefreshIcon } from "@sanity/icons/Refresh";
|
|
6
|
+
import { keyframes, styled } from "styled-components";
|
|
7
|
+
/** Flattens a field's value into the plain text Jev evaluates. */
|
|
8
|
+
function toText(value) {
|
|
9
|
+
if (value == null) return "";
|
|
10
|
+
if (typeof value == "string") return value;
|
|
11
|
+
if (typeof value == "number" || typeof value == "boolean") return String(value);
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
let separator = value.some(isBlock) ? "\n\n" : "\n";
|
|
14
|
+
return value.map(toText).filter((text) => text !== "").join(separator);
|
|
15
|
+
}
|
|
16
|
+
return isRecord$1(value) ? isBlock(value) ? value.children.map((child) => child.text ?? "").join("") : value._type === "slug" ? toText(value.current) : Object.entries(value).filter(([key]) => !key.startsWith("_")).map(([, child]) => toText(child)).filter((text) => text !== "").join("\n") : "";
|
|
17
|
+
}
|
|
18
|
+
function isBlock(value) {
|
|
19
|
+
return isRecord$1(value) && value._type === "block" && Array.isArray(value.children);
|
|
20
|
+
}
|
|
21
|
+
function isRecord$1(value) {
|
|
22
|
+
return !!value && typeof value == "object" && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
/** FNV-1a: a small, stable fingerprint; collisions only cost a skipped re-evaluation. */
|
|
25
|
+
function fingerprint(input) {
|
|
26
|
+
let text = JSON.stringify(input), hash = 2166136261;
|
|
27
|
+
for (let i = 0; i < text.length; i++) hash ^= text.charCodeAt(i), hash = Math.imul(hash, 16777619);
|
|
28
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
29
|
+
}
|
|
30
|
+
/** Jev reads at most this many tokens of state (plus the longest question) per request. */
|
|
31
|
+
const estimateTokens = (text) => Math.ceil(text.length / 4), nearTokenLimit = (text) => estimateTokens(text) > 28e3, Context = createContext(null), pathKey = (path) => JSON.stringify(path);
|
|
32
|
+
/** Wraps a document form, so attached fields can reach the plugin config and answer fields. */
|
|
33
|
+
function JevFormProvider({ config, children }) {
|
|
34
|
+
let [writers] = useState(() => /* @__PURE__ */ new Map()), value = useMemo(() => ({
|
|
35
|
+
config,
|
|
36
|
+
writers
|
|
37
|
+
}), [config, writers]);
|
|
38
|
+
return <Context.Provider value={value}>{children}</Context.Provider>;
|
|
39
|
+
}
|
|
40
|
+
function useJevForm() {
|
|
41
|
+
let context = useContext(Context);
|
|
42
|
+
if (!context) throw Error("Jev signals need the jev() plugin in sanity.config.");
|
|
43
|
+
return context;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Field component for answer fields: shows nothing (answers appear on the attached field) and
|
|
47
|
+
* registers the field's own `onChange`, so the attached field can store answers through it.
|
|
48
|
+
*/
|
|
49
|
+
function AnswerField(props) {
|
|
50
|
+
let { writers } = useJevForm(), key = pathKey(props.path), onChange = props.inputProps.onChange;
|
|
51
|
+
return useEffect(() => (writers.set(key, (value) => onChange(set(value))), () => {
|
|
52
|
+
writers.delete(key);
|
|
53
|
+
}), [
|
|
54
|
+
writers,
|
|
55
|
+
key,
|
|
56
|
+
onChange
|
|
57
|
+
]), null;
|
|
58
|
+
}
|
|
59
|
+
const NEUTRAL_COLOR = "hsl(230 80% 66%)", clamp = (value) => Math.min(1, Math.max(0, value)), capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
60
|
+
/** Red at 0, amber in the middle, green at 1. */
|
|
61
|
+
function trafficColor(fraction) {
|
|
62
|
+
return `hsl(${Math.round(clamp(fraction) * 120)} 70% 52%)`;
|
|
63
|
+
}
|
|
64
|
+
function trafficTone(fraction) {
|
|
65
|
+
return fraction < 1 / 3 ? "critical" : fraction < 2 / 3 ? "caution" : "positive";
|
|
66
|
+
}
|
|
67
|
+
/** A thin rounded bar. `fraction` is 0–1. */
|
|
68
|
+
function Bar({ fraction, color, label }) {
|
|
69
|
+
let percent = Math.round(clamp(fraction) * 100);
|
|
70
|
+
return <Box flex={1} role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} aria-label={label} style={{
|
|
71
|
+
height: 4,
|
|
72
|
+
borderRadius: 4,
|
|
73
|
+
overflow: "hidden",
|
|
74
|
+
background: "var(--card-border-color)"
|
|
75
|
+
}}>
|
|
76
|
+
<div style={{
|
|
77
|
+
width: `${percent}%`,
|
|
78
|
+
height: "100%",
|
|
79
|
+
borderRadius: 4,
|
|
80
|
+
background: color,
|
|
81
|
+
transition: "width 400ms ease, background-color 400ms ease"
|
|
82
|
+
}} />
|
|
83
|
+
</Box>;
|
|
84
|
+
}
|
|
85
|
+
/** Schema type names. Stored as `_type` on every value, so frontends query them: keep stable. */
|
|
86
|
+
const TYPE_NAMES = {
|
|
87
|
+
noul: "jev.noul",
|
|
88
|
+
score: "jev.score",
|
|
89
|
+
choice: "jev.choice",
|
|
90
|
+
choiceProbability: "jev.choiceProbability"
|
|
91
|
+
}, round = (value, digits = 4) => Math.round(value * 10 ** digits) / 10 ** digits;
|
|
92
|
+
function mismatch() {
|
|
93
|
+
throw Error("Jev answered a different kind of question.");
|
|
94
|
+
}
|
|
95
|
+
const bookkeepingFields = [
|
|
96
|
+
defineField({
|
|
97
|
+
name: "evaluatedAt",
|
|
98
|
+
type: "datetime",
|
|
99
|
+
readOnly: !0
|
|
100
|
+
}),
|
|
101
|
+
defineField({
|
|
102
|
+
name: "model",
|
|
103
|
+
type: "string",
|
|
104
|
+
readOnly: !0
|
|
105
|
+
}),
|
|
106
|
+
defineField({
|
|
107
|
+
name: "sourceHash",
|
|
108
|
+
type: "string",
|
|
109
|
+
hidden: !0
|
|
110
|
+
})
|
|
111
|
+
];
|
|
112
|
+
/** The stored value as a record of the given `_type`, or nothing. */
|
|
113
|
+
function storedOf(stored, typeName) {
|
|
114
|
+
return isRecord$1(stored) && stored._type === typeName ? stored : void 0;
|
|
115
|
+
}
|
|
116
|
+
/** The bookkeeping fields a stored value has. */
|
|
117
|
+
function bookkeepingOf(stored) {
|
|
118
|
+
return {
|
|
119
|
+
evaluatedAt: text(stored.evaluatedAt),
|
|
120
|
+
model: text(stored.model),
|
|
121
|
+
sourceHash: text(stored.sourceHash)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const text = (value) => typeof value == "string" ? value : void 0, number = (value) => typeof value == "number" ? value : void 0;
|
|
125
|
+
/** Why a range rule is invalid for values from `min` to `max`, if it is. */
|
|
126
|
+
function rangeRuleProblem(rules, min, max) {
|
|
127
|
+
for (let level of ["warn", "require"]) {
|
|
128
|
+
let rule = rules[level];
|
|
129
|
+
if (rule) for (let bound of ["atLeast", "atMost"]) {
|
|
130
|
+
let value = rule[bound];
|
|
131
|
+
if (value !== void 0 && (typeof value != "number" || value < min || value > max)) return `\`${level}.${bound}\` must be a number from ${min} to ${max}.`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** How `value` breaks a range rule, e.g. `below 60%`, using `format` for both numbers. */
|
|
136
|
+
function rangeViolation(value, rule, format) {
|
|
137
|
+
if (rule?.atLeast !== void 0 && value < rule.atLeast) return `below ${format(rule.atLeast)}`;
|
|
138
|
+
if (rule?.atMost !== void 0 && value > rule.atMost) return `above ${format(rule.atMost)}`;
|
|
139
|
+
}
|
|
140
|
+
/** Why a choice signal can't be asked, if it can't. */
|
|
141
|
+
function choiceProblem(signal, options) {
|
|
142
|
+
if (options.length < 2) return "A choice signal needs at least two options.";
|
|
143
|
+
if (options.length > 255) return "A choice signal allows at most 255 options.";
|
|
144
|
+
for (let level of ["warn", "require"]) {
|
|
145
|
+
let rule = signal[level];
|
|
146
|
+
if (!rule) continue;
|
|
147
|
+
if (!Array.isArray(rule.oneOf) || rule.oneOf.length === 0) return `\`${level}.oneOf\` must list at least one option.`;
|
|
148
|
+
let unknown = rule.oneOf.find((option) => !options.includes(option));
|
|
149
|
+
if (unknown !== void 0) return `\`${level}.oneOf\` names "${unknown}", which is not an option.`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** One option from a named set. @public */
|
|
153
|
+
const choice = (signal) => ({
|
|
154
|
+
type: "choice",
|
|
155
|
+
...signal
|
|
156
|
+
}), choiceSchemaTypes = [defineType({
|
|
157
|
+
name: TYPE_NAMES.choiceProbability,
|
|
158
|
+
title: "Jev choice probability",
|
|
159
|
+
type: "object",
|
|
160
|
+
fields: [defineField({
|
|
161
|
+
name: "option",
|
|
162
|
+
type: "string"
|
|
163
|
+
}), defineField({
|
|
164
|
+
name: "probability",
|
|
165
|
+
type: "number"
|
|
166
|
+
})]
|
|
167
|
+
}), defineType({
|
|
168
|
+
name: TYPE_NAMES.choice,
|
|
169
|
+
title: "Jev choice",
|
|
170
|
+
type: "object",
|
|
171
|
+
components: { field: AnswerField },
|
|
172
|
+
fields: [
|
|
173
|
+
defineField({
|
|
174
|
+
name: "choice",
|
|
175
|
+
type: "string",
|
|
176
|
+
readOnly: !0
|
|
177
|
+
}),
|
|
178
|
+
defineField({
|
|
179
|
+
name: "confidence",
|
|
180
|
+
type: "number",
|
|
181
|
+
readOnly: !0
|
|
182
|
+
}),
|
|
183
|
+
defineField({
|
|
184
|
+
name: "probabilities",
|
|
185
|
+
type: "array",
|
|
186
|
+
readOnly: !0,
|
|
187
|
+
of: [defineArrayMember({ type: TYPE_NAMES.choiceProbability })]
|
|
188
|
+
}),
|
|
189
|
+
...bookkeepingFields
|
|
190
|
+
]
|
|
191
|
+
})];
|
|
192
|
+
function bindChoice(signal) {
|
|
193
|
+
let criteria = signal.criteria ?? {}, problem = choiceProblem(signal, Object.keys(criteria));
|
|
194
|
+
return {
|
|
195
|
+
typeName: TYPE_NAMES.choice,
|
|
196
|
+
question: problem ? void 0 : {
|
|
197
|
+
type: "choice",
|
|
198
|
+
instructions: signal.instructions,
|
|
199
|
+
criteria: signal.criteria
|
|
200
|
+
},
|
|
201
|
+
problem,
|
|
202
|
+
toStored(answer) {
|
|
203
|
+
return answer.type === "choice" ? {
|
|
204
|
+
_type: TYPE_NAMES.choice,
|
|
205
|
+
choice: answer.choice,
|
|
206
|
+
confidence: round(answer.confidence ?? answer.probabilities[answer.choice] ?? 0),
|
|
207
|
+
probabilities: Object.keys(criteria).map((option, index) => ({
|
|
208
|
+
_key: `option-${index}`,
|
|
209
|
+
_type: TYPE_NAMES.choiceProbability,
|
|
210
|
+
option,
|
|
211
|
+
probability: round(answer.probabilities[option] ?? 0)
|
|
212
|
+
}))
|
|
213
|
+
} : mismatch();
|
|
214
|
+
},
|
|
215
|
+
read(stored) {
|
|
216
|
+
let record = storedOf(stored, TYPE_NAMES.choice);
|
|
217
|
+
if (!record || typeof record.choice != "string") return;
|
|
218
|
+
let probabilities = Array.isArray(record.probabilities) ? record.probabilities.filter(isRecord$1).map((entry) => ({
|
|
219
|
+
_key: String(entry._key),
|
|
220
|
+
_type: TYPE_NAMES.choiceProbability,
|
|
221
|
+
option: text(entry.option),
|
|
222
|
+
probability: number(entry.probability)
|
|
223
|
+
})) : [], meaning = criteria[record.choice];
|
|
224
|
+
return {
|
|
225
|
+
value: {
|
|
226
|
+
...bookkeepingOf(record),
|
|
227
|
+
_type: TYPE_NAMES.choice,
|
|
228
|
+
choice: record.choice,
|
|
229
|
+
confidence: number(record.confidence),
|
|
230
|
+
probabilities
|
|
231
|
+
},
|
|
232
|
+
chip: {
|
|
233
|
+
text: capitalize(record.choice),
|
|
234
|
+
color: NEUTRAL_COLOR
|
|
235
|
+
},
|
|
236
|
+
tone: "default",
|
|
237
|
+
aside: meaning ? { note: meaning } : void 0,
|
|
238
|
+
body: <ChoiceBody signal={signal} choice={record.choice} probabilities={probabilities} />
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
check(level, stored, title) {
|
|
242
|
+
let record = storedOf(stored, TYPE_NAMES.choice), rule = signal[level];
|
|
243
|
+
if (record && typeof record.choice == "string" && rule?.oneOf && !rule.oneOf.includes(record.choice)) return `${title} is "${record.choice}", not ${rule.oneOf.map((option) => `"${option}"`).join(" or ")}.`;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function ChoiceBody({ signal, choice, probabilities }) {
|
|
248
|
+
let options = Object.keys(signal.criteria), nameWidth = `${Math.max(...options.map((name) => name.length), 4) + 2}ch`;
|
|
249
|
+
return <Stack gap={3}>
|
|
250
|
+
{options.map((option) => {
|
|
251
|
+
let probability = probabilities.find((entry) => entry.option === option)?.probability ?? 0, winner = option === choice, percent = Math.round(probability * 100);
|
|
252
|
+
return <Flex key={option} align="center" gap={3}>
|
|
253
|
+
<Box style={{ width: nameWidth }}>
|
|
254
|
+
<Text size={1} weight={winner ? "semibold" : "regular"} muted={!winner}>
|
|
255
|
+
{capitalize(option)}
|
|
256
|
+
</Text>
|
|
257
|
+
</Box>
|
|
258
|
+
<Bar fraction={probability} color={winner ? NEUTRAL_COLOR : "hsl(215 12% 55%)"} label={`${option}: ${percent}%`} />
|
|
259
|
+
<Box style={{
|
|
260
|
+
minWidth: "4ch",
|
|
261
|
+
textAlign: "right"
|
|
262
|
+
}}>
|
|
263
|
+
<Text size={1} muted={!winner}>
|
|
264
|
+
{percent}%
|
|
265
|
+
</Text>
|
|
266
|
+
</Box>
|
|
267
|
+
</Flex>;
|
|
268
|
+
})}
|
|
269
|
+
</Stack>;
|
|
270
|
+
}
|
|
271
|
+
/** The plugin's translation namespace: `useTranslation(JEV_NAMESPACE)`. */
|
|
272
|
+
const JEV_NAMESPACE = "jev", jevLocaleBundle = defineLocaleResourceBundle({
|
|
273
|
+
locale: "en-US",
|
|
274
|
+
namespace: "jev",
|
|
275
|
+
resources: {
|
|
276
|
+
"strip.label": "Jev",
|
|
277
|
+
"strip.set-up": "Set up Jev",
|
|
278
|
+
"strip.evaluate-all": "Evaluate all signals now",
|
|
279
|
+
"chip.empty": "–",
|
|
280
|
+
"chip.error": "Error",
|
|
281
|
+
"chip.out-of-date": "Out of date",
|
|
282
|
+
"detail.empty-field": "Add content to this field to evaluate it.",
|
|
283
|
+
"detail.not-evaluated": "Not evaluated yet.",
|
|
284
|
+
"detail.out-of-date": "Out of date: the field changed since this was evaluated.",
|
|
285
|
+
"detail.update-key": "Update API key",
|
|
286
|
+
"detail.try-again": "Try again",
|
|
287
|
+
"field.too-long": "This field is about {{tokens}}k tokens long. Jev reads at most {{limit}}k per question, so longer text may be refused.",
|
|
288
|
+
"error.auth": "AI Gateway rejected the API key. Check the key in the Jev tool.",
|
|
289
|
+
"error.invalid": "AI Gateway rejected the question: {{detail}}",
|
|
290
|
+
"error.busy": "AI Gateway is busy. Try again in a moment.",
|
|
291
|
+
"error.failed": "AI Gateway responded with {{status}}.",
|
|
292
|
+
"error.failed-with-detail": "AI Gateway responded with {{status}}: {{detail}}",
|
|
293
|
+
"error.unexpected": "AI Gateway returned no answer.",
|
|
294
|
+
"noul.level.low": "Low",
|
|
295
|
+
"noul.level.medium": "Medium",
|
|
296
|
+
"noul.level.high": "High",
|
|
297
|
+
"noul.yes": "Yes",
|
|
298
|
+
"score.summary": "{{meaning}} (score {{score}} of {{max}}).",
|
|
299
|
+
"score.next": "To move up: {{next}}.",
|
|
300
|
+
"key-dialog.title": "AI Gateway API key",
|
|
301
|
+
"key-dialog.label": "API key",
|
|
302
|
+
"key-dialog.description": "Create one in the Vercel dashboard under AI Gateway → API Keys, ideally with a spend limit. It is stored in this dataset, so Studio users and API tokens that can read the dataset can see it.",
|
|
303
|
+
"key-dialog.cancel": "Cancel",
|
|
304
|
+
"key-dialog.save": "Save",
|
|
305
|
+
"key-dialog.saving": "Saving…",
|
|
306
|
+
"key-dialog.save-failed": "Could not save the key: {{error}}",
|
|
307
|
+
"tool.title": "Jev",
|
|
308
|
+
"tool.intro": "Jev signals are answered by TypeSafe’s Jev model through Vercel AI Gateway.",
|
|
309
|
+
"tool.key.heading": "AI Gateway API key",
|
|
310
|
+
"tool.key.status.config": "From plugin config",
|
|
311
|
+
"tool.key.status.checking": "Checking",
|
|
312
|
+
"tool.key.status.set": "Set",
|
|
313
|
+
"tool.key.status.not-set": "Not set",
|
|
314
|
+
"tool.key.from-transport": "Requests go through the transport in the plugin config, so no key is needed here.",
|
|
315
|
+
"tool.key.from-config": "The key comes from apiKey in the plugin config. A key stored here is ignored.",
|
|
316
|
+
"tool.key.ends-with": "Key ending in <Code>{{last4}}</Code>",
|
|
317
|
+
"tool.key.changed": "Last changed {{date}}",
|
|
318
|
+
"tool.key.checking": "Checking for a stored key…",
|
|
319
|
+
"tool.key.none": "No key is stored, so Jev signals cannot evaluate yet.",
|
|
320
|
+
"tool.key.set": "Set key",
|
|
321
|
+
"tool.key.change": "Change key",
|
|
322
|
+
"tool.key.remove": "Remove key",
|
|
323
|
+
"tool.key.remove-confirm": "Remove the key? Jev signals stop evaluating until a new one is set.",
|
|
324
|
+
"tool.key.remove-cancel": "Cancel",
|
|
325
|
+
"tool.key.remove-confirm-button": "Remove",
|
|
326
|
+
"tool.key.remove-failed": "Could not remove the key: {{error}}",
|
|
327
|
+
"tool.test.run": "Test connection",
|
|
328
|
+
"tool.test.running": "Testing…",
|
|
329
|
+
"tool.test.passed": "Connection works. {{model}} answered.",
|
|
330
|
+
"tool.visibility.heading": "Who can see the key",
|
|
331
|
+
"tool.visibility.body": "The key is stored in this dataset in the document <Code>secrets.jev</Code>. It is not public, but anyone who can read the dataset can see it: every Studio user, and every API token with read access, such as a frontend’s read or preview token. It is also included in dataset exports and backups. Use a dedicated key with a spend limit. If editors and tokens must never see the key, set a <Code>transport</Code> that sends requests through your own server."
|
|
332
|
+
}
|
|
333
|
+
}), noul = (signal) => ({
|
|
334
|
+
type: "noul",
|
|
335
|
+
...signal
|
|
336
|
+
}), noulSchemaTypes = [defineType({
|
|
337
|
+
name: TYPE_NAMES.noul,
|
|
338
|
+
title: "Jev yes/no",
|
|
339
|
+
type: "object",
|
|
340
|
+
components: { field: AnswerField },
|
|
341
|
+
fields: [defineField({
|
|
342
|
+
name: "probability",
|
|
343
|
+
type: "number",
|
|
344
|
+
readOnly: !0
|
|
345
|
+
}), ...bookkeepingFields]
|
|
346
|
+
})], LEVELS$1 = {
|
|
347
|
+
critical: "low",
|
|
348
|
+
caution: "medium",
|
|
349
|
+
positive: "high"
|
|
350
|
+
};
|
|
351
|
+
function bindNoul(signal) {
|
|
352
|
+
let problem = !signal.true?.trim() || !signal.false?.trim() ? "A noul signal needs both `true` and `false` text." : rangeRuleProblem(signal, 0, 1);
|
|
353
|
+
return {
|
|
354
|
+
typeName: TYPE_NAMES.noul,
|
|
355
|
+
question: problem ? void 0 : {
|
|
356
|
+
type: "boolean",
|
|
357
|
+
instructions: signal.instructions,
|
|
358
|
+
criteria: {
|
|
359
|
+
true: signal.true,
|
|
360
|
+
false: signal.false
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
problem,
|
|
364
|
+
toStored(answer) {
|
|
365
|
+
return answer.type === "boolean" ? {
|
|
366
|
+
_type: TYPE_NAMES.noul,
|
|
367
|
+
probability: round(answer.probability)
|
|
368
|
+
} : mismatch();
|
|
369
|
+
},
|
|
370
|
+
read(stored) {
|
|
371
|
+
let record = storedOf(stored, TYPE_NAMES.noul);
|
|
372
|
+
if (!record || typeof record.probability != "number") return;
|
|
373
|
+
let probability = record.probability, tone = trafficTone(probability);
|
|
374
|
+
return {
|
|
375
|
+
value: {
|
|
376
|
+
...bookkeepingOf(record),
|
|
377
|
+
_type: TYPE_NAMES.noul,
|
|
378
|
+
probability
|
|
379
|
+
},
|
|
380
|
+
chip: {
|
|
381
|
+
text: `${Math.round(probability * 100)}%`,
|
|
382
|
+
color: trafficColor(probability)
|
|
383
|
+
},
|
|
384
|
+
tone: tone === "positive" ? "default" : tone,
|
|
385
|
+
aside: {
|
|
386
|
+
level: LEVELS$1[tone] ?? "medium",
|
|
387
|
+
tone
|
|
388
|
+
},
|
|
389
|
+
body: <NoulBody signal={signal} probability={probability} />
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
check(level, stored, title) {
|
|
393
|
+
let record = storedOf(stored, TYPE_NAMES.noul);
|
|
394
|
+
if (!record || typeof record.probability != "number") return;
|
|
395
|
+
let percent = (value) => `${Math.round(value * 100)}%`, broken = rangeViolation(record.probability, signal[level], percent);
|
|
396
|
+
return broken && `${title} is ${percent(record.probability)}, ${broken}.`;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function NoulBody({ signal, probability }) {
|
|
401
|
+
let { t } = useTranslation("jev"), percent = Math.round(probability * 100);
|
|
402
|
+
return <Stack gap={4}>
|
|
403
|
+
<Flex align="baseline" gap={2}>
|
|
404
|
+
<Text size={4} weight="medium">
|
|
405
|
+
{percent}%
|
|
406
|
+
</Text>
|
|
407
|
+
{signal.label && <Text size={1} muted>
|
|
408
|
+
{signal.label}
|
|
409
|
+
</Text>}
|
|
410
|
+
</Flex>
|
|
411
|
+
<Bar fraction={probability} color={trafficColor(probability)} label={`${signal.title ?? t("noul.yes")}: ${percent}%`} />
|
|
412
|
+
<Text size={1} muted>
|
|
413
|
+
{probability >= .5 ? signal.true : signal.false}
|
|
414
|
+
</Text>
|
|
415
|
+
</Stack>;
|
|
416
|
+
}
|
|
417
|
+
/** A position on an ordered scale. @public */
|
|
418
|
+
const score = (signal) => ({
|
|
419
|
+
type: "score",
|
|
420
|
+
...signal
|
|
421
|
+
}), scoreSchemaTypes = [defineType({
|
|
422
|
+
name: TYPE_NAMES.score,
|
|
423
|
+
title: "Jev score",
|
|
424
|
+
type: "object",
|
|
425
|
+
components: { field: AnswerField },
|
|
426
|
+
fields: [
|
|
427
|
+
defineField({
|
|
428
|
+
name: "score",
|
|
429
|
+
type: "number",
|
|
430
|
+
readOnly: !0
|
|
431
|
+
}),
|
|
432
|
+
defineField({
|
|
433
|
+
name: "max",
|
|
434
|
+
type: "number",
|
|
435
|
+
readOnly: !0
|
|
436
|
+
}),
|
|
437
|
+
defineField({
|
|
438
|
+
name: "label",
|
|
439
|
+
type: "string",
|
|
440
|
+
readOnly: !0
|
|
441
|
+
}),
|
|
442
|
+
defineField({
|
|
443
|
+
name: "confidence",
|
|
444
|
+
type: "number",
|
|
445
|
+
readOnly: !0
|
|
446
|
+
}),
|
|
447
|
+
...bookkeepingFields
|
|
448
|
+
]
|
|
449
|
+
})], shortLabel = (criterion) => criterion?.split(":")[0].trim() ?? "";
|
|
450
|
+
/** `'anecdotal: personal experience'` → `'personal experience'`; without a colon, the whole text. */
|
|
451
|
+
function meaningOf(criterion) {
|
|
452
|
+
if (!criterion) return "";
|
|
453
|
+
let colon = criterion.indexOf(":");
|
|
454
|
+
return (colon === -1 ? criterion : criterion.slice(colon + 1)).trim();
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* How full each segment of a score bar is, 0–1. Segment `i` stands for reaching criterion
|
|
458
|
+
* `i`: a score of 0.8 fills the first segment and 80% of the second.
|
|
459
|
+
*/
|
|
460
|
+
function segmentFills(score, count) {
|
|
461
|
+
return Array.from({ length: count }, (_, index) => Math.min(1, Math.max(0, score - index + 1)));
|
|
462
|
+
}
|
|
463
|
+
/** Colour and tone of a score, from the criterion it is nearest to. */
|
|
464
|
+
function scoreLook(signal, value, max) {
|
|
465
|
+
if (signal.colors === "neutral") return {
|
|
466
|
+
color: NEUTRAL_COLOR,
|
|
467
|
+
tone: "primary"
|
|
468
|
+
};
|
|
469
|
+
let fraction = max > 0 ? Math.round(value) / max : 0, good = signal.colors === "reverse" ? 1 - fraction : fraction;
|
|
470
|
+
return {
|
|
471
|
+
color: trafficColor(good),
|
|
472
|
+
tone: trafficTone(good)
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function bindScore(signal) {
|
|
476
|
+
let { criteria } = signal, problem = !Array.isArray(criteria) || criteria.length < 2 ? "A score signal needs at least two criteria." : criteria.length > 10 ? "A score signal allows at most ten criteria." : rangeRuleProblem(signal, 0, criteria.length - 1);
|
|
477
|
+
return {
|
|
478
|
+
typeName: TYPE_NAMES.score,
|
|
479
|
+
question: problem ? void 0 : {
|
|
480
|
+
type: "score",
|
|
481
|
+
instructions: signal.instructions,
|
|
482
|
+
criteria
|
|
483
|
+
},
|
|
484
|
+
problem,
|
|
485
|
+
toStored(answer) {
|
|
486
|
+
if (answer.type !== "score") return mismatch();
|
|
487
|
+
let max = criteria.length - 1, value = Math.min(max, Math.max(0, answer.score));
|
|
488
|
+
return {
|
|
489
|
+
_type: TYPE_NAMES.score,
|
|
490
|
+
score: round(value),
|
|
491
|
+
max,
|
|
492
|
+
label: shortLabel(criteria[Math.round(value)]),
|
|
493
|
+
...typeof answer.confidence == "number" && { confidence: round(answer.confidence) }
|
|
494
|
+
};
|
|
495
|
+
},
|
|
496
|
+
read(stored) {
|
|
497
|
+
let record = storedOf(stored, TYPE_NAMES.score);
|
|
498
|
+
if (!record || typeof record.score != "number" || typeof record.max != "number") return;
|
|
499
|
+
let { score: value, max } = record, label = text(record.label), look = scoreLook(signal, value, max);
|
|
500
|
+
return {
|
|
501
|
+
value: {
|
|
502
|
+
...bookkeepingOf(record),
|
|
503
|
+
_type: TYPE_NAMES.score,
|
|
504
|
+
score: value,
|
|
505
|
+
max,
|
|
506
|
+
label,
|
|
507
|
+
confidence: number(record.confidence)
|
|
508
|
+
},
|
|
509
|
+
chip: {
|
|
510
|
+
text: `${value.toFixed(1)}/${max}`,
|
|
511
|
+
color: look.color
|
|
512
|
+
},
|
|
513
|
+
tone: look.tone === "critical" || look.tone === "caution" ? look.tone : "default",
|
|
514
|
+
aside: {
|
|
515
|
+
badge: capitalize(label ?? shortLabel(criteria?.[Math.round(value)])),
|
|
516
|
+
tone: look.tone
|
|
517
|
+
},
|
|
518
|
+
body: <ScoreBody signal={signal} score={value} max={max} color={look.color} />
|
|
519
|
+
};
|
|
520
|
+
},
|
|
521
|
+
check(level, stored, title) {
|
|
522
|
+
let record = storedOf(stored, TYPE_NAMES.score);
|
|
523
|
+
if (!record || typeof record.score != "number") return;
|
|
524
|
+
let describe = (position) => {
|
|
525
|
+
let label = shortLabel(criteria[Math.round(position)]), number = Number.isInteger(position) ? String(position) : position.toFixed(1);
|
|
526
|
+
return label ? `${number} (${label})` : number;
|
|
527
|
+
}, broken = rangeViolation(record.score, signal[level], describe);
|
|
528
|
+
return broken && `${title} is ${describe(record.score)}, ${broken}.`;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function ScoreBody({ signal, score, max, color }) {
|
|
533
|
+
let { t } = useTranslation("jev"), nearest = Math.round(score), fills = segmentFills(score, signal.criteria.length), next = signal.criteria[nearest + 1];
|
|
534
|
+
return <Stack gap={4}>
|
|
535
|
+
<Flex gap={1}>
|
|
536
|
+
{signal.criteria.map((criterion, index) => <Stack key={index} gap={2} flex={1}>
|
|
537
|
+
<Bar fraction={fills[index]} color={color} label={`${index}: ${shortLabel(criterion)}`} />
|
|
538
|
+
<Text size={1} weight={index === nearest ? "medium" : "regular"} muted={index > nearest} style={index < nearest ? { color } : void 0} textOverflow="ellipsis">
|
|
539
|
+
{index} · {capitalize(shortLabel(criterion))}
|
|
540
|
+
</Text>
|
|
541
|
+
</Stack>)}
|
|
542
|
+
</Flex>
|
|
543
|
+
<Text size={1} muted>
|
|
544
|
+
{t("score.summary", {
|
|
545
|
+
meaning: capitalize(meaningOf(signal.criteria[nearest])),
|
|
546
|
+
score: score.toFixed(1),
|
|
547
|
+
max
|
|
548
|
+
})}
|
|
549
|
+
{next ? ` ${t("score.next", { next: meaningOf(next) })}` : ""}
|
|
550
|
+
</Text>
|
|
551
|
+
</Stack>;
|
|
552
|
+
}
|
|
553
|
+
/** Where every kind stores its answers. */
|
|
554
|
+
const KIND_SCHEMA_TYPES = [
|
|
555
|
+
...noulSchemaTypes,
|
|
556
|
+
...scoreSchemaTypes,
|
|
557
|
+
...choiceSchemaTypes
|
|
558
|
+
];
|
|
559
|
+
/** Binds a signal to its kind. The one place that tells Noul, Score and Choice apart. */
|
|
560
|
+
function kindOf(signal) {
|
|
561
|
+
let kind = bind(signal);
|
|
562
|
+
return signal.instructions?.trim() ? kind : {
|
|
563
|
+
...kind,
|
|
564
|
+
question: void 0,
|
|
565
|
+
problem: "Add instructions to this signal."
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function bind(signal) {
|
|
569
|
+
switch (signal.type) {
|
|
570
|
+
case "noul": return bindNoul(signal);
|
|
571
|
+
case "score": return bindScore(signal);
|
|
572
|
+
case "choice": return bindChoice(signal);
|
|
573
|
+
default: return {
|
|
574
|
+
typeName: "unknown",
|
|
575
|
+
problem: "Unknown signal type.",
|
|
576
|
+
toStored: mismatch,
|
|
577
|
+
read: () => void 0,
|
|
578
|
+
check: () => void 0
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/** `readable` → `Readable`, `readingLevel` → `Reading level`. */
|
|
583
|
+
function titleOf(key, signal) {
|
|
584
|
+
if (signal.title) return signal.title;
|
|
585
|
+
let words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ");
|
|
586
|
+
return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase();
|
|
587
|
+
}
|
|
588
|
+
const isRecord = (value) => !!value && typeof value == "object" && !Array.isArray(value);
|
|
589
|
+
function signalsOf(definition) {
|
|
590
|
+
if (!isRecord(definition) || !isRecord(definition.options)) return;
|
|
591
|
+
let { jev } = definition.options;
|
|
592
|
+
return isRecord(jev) ? jev : void 0;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Adds a field that stores each signal's answer next to every field with `options.jev`.
|
|
596
|
+
* Plugins can't see the Studio's own schema types, so wrap them in `sanity.config`:
|
|
597
|
+
*
|
|
598
|
+
* ```ts
|
|
599
|
+
* schema: {types: withJevAnswers(schemaTypes)}
|
|
600
|
+
* ```
|
|
601
|
+
* @public
|
|
602
|
+
*/
|
|
603
|
+
function withJevAnswers(types) {
|
|
604
|
+
return types.map((type) => withAnswerFields(type));
|
|
605
|
+
}
|
|
606
|
+
/** Rewrites `fields` (and inline object members) of a type or field definition, recursively. */
|
|
607
|
+
function withAnswerFields(definition) {
|
|
608
|
+
if (!isRecord(definition)) return definition;
|
|
609
|
+
let result = definition;
|
|
610
|
+
if (Array.isArray(definition.of) && (result = {
|
|
611
|
+
...result,
|
|
612
|
+
of: definition.of.map(withAnswerFields)
|
|
613
|
+
}), Array.isArray(definition.fields)) {
|
|
614
|
+
let existing = new Map(definition.fields.filter(isRecord).map((field) => [field.name, field.type])), fields = definition.fields.flatMap((field) => {
|
|
615
|
+
let rewritten = withAnswerFields(field), signals = signalsOf(field);
|
|
616
|
+
return !signals || !isRecord(field) || !isRecord(rewritten) ? [rewritten] : [withRules(rewritten, signals), ...answerFieldsFor(field, signals, existing)];
|
|
617
|
+
});
|
|
618
|
+
result = {
|
|
619
|
+
...result,
|
|
620
|
+
fields
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
function answerFieldsFor(field, signals, existing) {
|
|
626
|
+
return Object.entries(signals).flatMap(([key, signal]) => {
|
|
627
|
+
let type = TYPE_NAMES[signal.type];
|
|
628
|
+
if (existing.get(key) === type) return [];
|
|
629
|
+
if (existing.has(key)) throw Error(`Jev: the signal "${key}" on "${String(field.name)}" needs a field called "${key}" to store its answer, but that name is already taken. Rename the signal.`);
|
|
630
|
+
return existing.set(key, type), [defineField({
|
|
631
|
+
name: key,
|
|
632
|
+
title: titleOf(key, signal),
|
|
633
|
+
type,
|
|
634
|
+
group: typeof field.group == "string" || Array.isArray(field.group) ? field.group : void 0,
|
|
635
|
+
fieldset: typeof field.fieldset == "string" ? field.fieldset : void 0
|
|
636
|
+
})];
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
const LEVELS = ["warn", "require"];
|
|
640
|
+
/**
|
|
641
|
+
* Adds a validation rule to the attached field for each signal's `warn` and `require`, after
|
|
642
|
+
* the field's own validation. Rules judge the stored answer, so they say nothing until there is
|
|
643
|
+
* one.
|
|
644
|
+
*/
|
|
645
|
+
function withRules(field, signals) {
|
|
646
|
+
let checks = Object.entries(signals).flatMap(([key, signal]) => LEVELS.filter((level) => signal[level]).map((level) => ({
|
|
647
|
+
key,
|
|
648
|
+
signal,
|
|
649
|
+
level
|
|
650
|
+
})));
|
|
651
|
+
if (checks.length === 0) return field;
|
|
652
|
+
let own = field.validation, validation = (rule) => {
|
|
653
|
+
let ownRules = (Array.isArray(own) ? own : own ? [own] : []).flatMap((entry) => {
|
|
654
|
+
let result = typeof entry == "function" ? entry(rule) : entry;
|
|
655
|
+
return Array.isArray(result) ? result : result ? [result] : [];
|
|
656
|
+
}), jevRules = checks.map(({ key, signal, level }) => {
|
|
657
|
+
let kind = kindOf(signal), title = titleOf(key, signal), custom = rule.custom((_value, context) => {
|
|
658
|
+
let stored = isRecord(context.parent) ? context.parent[key] : void 0;
|
|
659
|
+
return kind.check(level, stored, title) ?? !0;
|
|
660
|
+
});
|
|
661
|
+
return level === "require" ? custom.error() : custom.warning();
|
|
662
|
+
});
|
|
663
|
+
return [...ownRules, ...jevRules];
|
|
664
|
+
};
|
|
665
|
+
return {
|
|
666
|
+
...field,
|
|
667
|
+
validation
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
const SECRETS_ID = "secrets.jev";
|
|
671
|
+
/** The AI Gateway key stored in `secrets.jev`, kept up to date as it changes. */
|
|
672
|
+
function useStoredKey() {
|
|
673
|
+
let documentPreviewStore = useDocumentPreviewStore(), [stored, setStored] = useState({ loading: !0 });
|
|
674
|
+
return useEffect(() => {
|
|
675
|
+
let subscription = documentPreviewStore.unstable_observeDocument(SECRETS_ID).subscribe((document) => {
|
|
676
|
+
let secrets = document?.secrets, apiKey = secrets && typeof secrets == "object" && "gatewayApiKey" in secrets ? secrets.gatewayApiKey : void 0;
|
|
677
|
+
setStored({
|
|
678
|
+
loading: !1,
|
|
679
|
+
apiKey: typeof apiKey == "string" && apiKey ? apiKey : void 0,
|
|
680
|
+
updatedAt: document?._updatedAt
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
return () => subscription.unsubscribe();
|
|
684
|
+
}, [documentPreviewStore]), stored;
|
|
685
|
+
}
|
|
686
|
+
/** Stores a new key, or removes the stored one when `apiKey` is undefined. */
|
|
687
|
+
function useSaveKey() {
|
|
688
|
+
let client = useClient({ apiVersion: "2025-02-19" });
|
|
689
|
+
return async (apiKey) => {
|
|
690
|
+
await client.transaction().createIfNotExists({
|
|
691
|
+
_id: SECRETS_ID,
|
|
692
|
+
_type: "pluginSecrets"
|
|
693
|
+
}).patch(SECRETS_ID, (patch) => apiKey ? patch.set({ secrets: { gatewayApiKey: apiKey } }) : patch.unset(["secrets"])).commit({ tag: "jev.secrets" });
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
/** Where the API key comes from: plugin config wins over the stored key. */
|
|
697
|
+
function useKeySource(config) {
|
|
698
|
+
let stored = useStoredKey();
|
|
699
|
+
return config.transport || config.apiKey ? {
|
|
700
|
+
from: "config",
|
|
701
|
+
apiKey: config.apiKey
|
|
702
|
+
} : {
|
|
703
|
+
from: "secrets",
|
|
704
|
+
loading: stored.loading,
|
|
705
|
+
apiKey: stored.apiKey
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
/** Dialog for entering or replacing the stored key. Closes once the key is saved. */
|
|
709
|
+
function JevKeyDialog({ onClose }) {
|
|
710
|
+
let { t } = useTranslation("jev"), saveKey = useSaveKey(), inputId = useId(), [value, setValue] = useState(""), [saving, setSaving] = useState(!1), [error, setError] = useState(null);
|
|
711
|
+
async function save() {
|
|
712
|
+
setSaving(!0), setError(null);
|
|
713
|
+
try {
|
|
714
|
+
await saveKey(value.trim()), onClose();
|
|
715
|
+
} catch (saveError) {
|
|
716
|
+
setSaving(!1), setError(saveError instanceof Error ? saveError.message : String(saveError));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return <Dialog id="jev-key-dialog" header={t("key-dialog.title")} onClose={onClose} width={1}>
|
|
720
|
+
<Box as="form" padding={4} onSubmit={(event) => {
|
|
721
|
+
event.preventDefault(), save();
|
|
722
|
+
}}>
|
|
723
|
+
<Stack gap={4}>
|
|
724
|
+
<Stack gap={3}>
|
|
725
|
+
<Text as="label" htmlFor={inputId} size={1} weight="semibold">
|
|
726
|
+
{t("key-dialog.label")}
|
|
727
|
+
</Text>
|
|
728
|
+
<Text size={1} muted>
|
|
729
|
+
{t("key-dialog.description")}
|
|
730
|
+
</Text>
|
|
731
|
+
<TextInput id={inputId} type="password" autoComplete="off" value={value} disabled={saving} onChange={(event) => setValue(event.currentTarget.value)} />
|
|
732
|
+
</Stack>
|
|
733
|
+
|
|
734
|
+
{error && <Card padding={3} radius={2} tone="critical">
|
|
735
|
+
<Text size={1}>{t("key-dialog.save-failed", { error })}</Text>
|
|
736
|
+
</Card>}
|
|
737
|
+
|
|
738
|
+
<Flex gap={2} justify="flex-end">
|
|
739
|
+
<Button text={t("key-dialog.cancel")} mode="bleed" disabled={saving} onClick={onClose} />
|
|
740
|
+
<Button type="submit" text={t(saving ? "key-dialog.saving" : "key-dialog.save")} tone="primary" disabled={saving || value.trim() === ""} />
|
|
741
|
+
</Flex>
|
|
742
|
+
</Stack>
|
|
743
|
+
</Box>
|
|
744
|
+
</Dialog>;
|
|
745
|
+
}
|
|
746
|
+
/** An evaluation error in the Studio's language; errors from outside the Gateway as they are. */
|
|
747
|
+
function errorText(t, error) {
|
|
748
|
+
switch (error.kind) {
|
|
749
|
+
case "auth": return t("error.auth");
|
|
750
|
+
case "invalid": return t("error.invalid", { detail: error.detail ?? error.status });
|
|
751
|
+
case "busy": return t("error.busy");
|
|
752
|
+
case "failed": return error.detail ? t("error.failed-with-detail", {
|
|
753
|
+
status: error.status,
|
|
754
|
+
detail: error.detail
|
|
755
|
+
}) : t("error.failed", { status: error.status });
|
|
756
|
+
case "unexpected": return t("error.unexpected");
|
|
757
|
+
default: return error.message;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const pulse = keyframes`
|
|
761
|
+
0%, 100% { opacity: 1; }
|
|
762
|
+
50% { opacity: 0.25; }
|
|
763
|
+
`, Dot = styled.span`
|
|
764
|
+
flex: none;
|
|
765
|
+
width: 6px;
|
|
766
|
+
height: 6px;
|
|
767
|
+
border-radius: 50%;
|
|
768
|
+
background: ${({ $color }) => $color};
|
|
769
|
+
animation: ${({ $pulse }) => $pulse ? pulse : "none"} 1s ease-in-out infinite;
|
|
770
|
+
`;
|
|
771
|
+
function Chip({ signal, selected, onSelect }) {
|
|
772
|
+
let { t } = useTranslation("jev"), { text, color } = signal.reading?.chip ?? {
|
|
773
|
+
text: t("chip.empty"),
|
|
774
|
+
color: void 0
|
|
775
|
+
}, failed = !!signal.error || !!signal.problem;
|
|
776
|
+
return <Card as="button" type="button" radius={2} paddingX={2} paddingY={2} onClick={onSelect} aria-pressed={selected} title={signal.stale ? t("chip.out-of-date") : void 0} style={{
|
|
777
|
+
display: "inline-flex",
|
|
778
|
+
flex: "none",
|
|
779
|
+
width: "auto",
|
|
780
|
+
border: 0,
|
|
781
|
+
cursor: "pointer",
|
|
782
|
+
background: selected ? "rgba(128, 128, 128, 0.16)" : void 0
|
|
783
|
+
}}>
|
|
784
|
+
<Flex align="center" gap={2}>
|
|
785
|
+
<Dot aria-hidden $color={failed ? "#f03e2f" : color ?? "hsl(215 12% 55%)"} $pulse={signal.loading} />
|
|
786
|
+
{}
|
|
787
|
+
<Text size={1}>
|
|
788
|
+
{signal.title}
|
|
789
|
+
<span style={{
|
|
790
|
+
marginLeft: "0.6em",
|
|
791
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
792
|
+
color: failed ? "#f03e2f" : color,
|
|
793
|
+
opacity: signal.stale ? .5 : 1
|
|
794
|
+
}}>
|
|
795
|
+
{failed ? t("chip.error") : text}
|
|
796
|
+
</span>
|
|
797
|
+
</Text>
|
|
798
|
+
</Flex>
|
|
799
|
+
</Card>;
|
|
800
|
+
}
|
|
801
|
+
/** The row of signal chips under an attached field. */
|
|
802
|
+
function SignalStrip({ signals, selected, onSelect, setup, canRun, loading, onRunAll, onSetUp }) {
|
|
803
|
+
let { t } = useTranslation("jev");
|
|
804
|
+
return <Card border padding={1} style={{
|
|
805
|
+
borderTop: 0,
|
|
806
|
+
marginTop: -2,
|
|
807
|
+
borderBottomLeftRadius: 3,
|
|
808
|
+
borderBottomRightRadius: 3
|
|
809
|
+
}}>
|
|
810
|
+
<Flex align="center" gap={2}>
|
|
811
|
+
<Flex flex={1} align="center" gap={1} wrap="wrap">
|
|
812
|
+
{signals.map((signal) => <Chip key={signal.key} signal={signal} selected={selected === signal.key} onSelect={() => onSelect(selected === signal.key ? null : signal.key)} />)}
|
|
813
|
+
</Flex>
|
|
814
|
+
{setup === "missing" ? <Button text={t("strip.set-up")} mode="ghost" tone="primary" fontSize={1} padding={2} onClick={onSetUp} /> : <Flex align="center" gap={1}>
|
|
815
|
+
<Text size={0} muted>
|
|
816
|
+
{t("strip.label")}
|
|
817
|
+
</Text>
|
|
818
|
+
{loading ? <Box padding={2}>
|
|
819
|
+
<Spinner muted />
|
|
820
|
+
</Box> : <Button icon={RefreshIcon} mode="bleed" fontSize={1} padding={2} title={t("strip.evaluate-all")} aria-label={t("strip.evaluate-all")} disabled={!canRun} onClick={onRunAll} />}
|
|
821
|
+
</Flex>}
|
|
822
|
+
</Flex>
|
|
823
|
+
</Card>;
|
|
824
|
+
}
|
|
825
|
+
/** Expanded view of the selected chip. */
|
|
826
|
+
function SignalDetail({ signal, empty, onRetry, onUpdateKey }) {
|
|
827
|
+
let { t } = useTranslation("jev"), { reading } = signal, body = signal.problem ? <Text size={1}>{signal.problem}</Text> : reading ? reading.body : <Text size={1} muted>
|
|
828
|
+
{t(empty ? "detail.empty-field" : "detail.not-evaluated")}
|
|
829
|
+
</Text>, aside = reading?.aside ? "badge" in reading.aside ? <Badge tone={reading.aside.tone}>{reading.aside.badge}</Badge> : "level" in reading.aside ? <Badge tone={reading.aside.tone}>{t(`noul.level.${reading.aside.level}`)}</Badge> : <Text size={1} muted>
|
|
830
|
+
{reading.aside.note}
|
|
831
|
+
</Text> : null;
|
|
832
|
+
return <Card padding={4} radius={2} border tone={signal.problem ? "critical" : reading?.tone ?? "default"}>
|
|
833
|
+
<Stack gap={4}>
|
|
834
|
+
<Flex align="center" gap={3}>
|
|
835
|
+
<Box flex={1}>
|
|
836
|
+
<Text size={1} weight="semibold">
|
|
837
|
+
{signal.title}
|
|
838
|
+
</Text>
|
|
839
|
+
</Box>
|
|
840
|
+
{aside}
|
|
841
|
+
</Flex>
|
|
842
|
+
{body}
|
|
843
|
+
{signal.stale && !signal.loading && <Text size={0} muted>
|
|
844
|
+
{t("detail.out-of-date")}
|
|
845
|
+
</Text>}
|
|
846
|
+
{signal.error && <Card padding={3} radius={2} tone="critical" border>
|
|
847
|
+
<Flex align="center" gap={3} wrap="wrap">
|
|
848
|
+
<Box flex={1}>
|
|
849
|
+
<Text size={1}>{errorText(t, signal.error)}</Text>
|
|
850
|
+
</Box>
|
|
851
|
+
{signal.keyRejected && onUpdateKey ? <Button text={t("detail.update-key")} mode="ghost" fontSize={1} onClick={onUpdateKey} /> : <Button text={t("detail.try-again")} mode="ghost" fontSize={1} onClick={onRetry} />}
|
|
852
|
+
</Flex>
|
|
853
|
+
</Card>}
|
|
854
|
+
</Stack>
|
|
855
|
+
</Card>;
|
|
856
|
+
}
|
|
857
|
+
function _typeof(o) {
|
|
858
|
+
"@babel/helpers - typeof";
|
|
859
|
+
return _typeof = typeof Symbol == "function" && typeof Symbol.iterator == "symbol" ? function(o) {
|
|
860
|
+
return typeof o;
|
|
861
|
+
} : function(o) {
|
|
862
|
+
return o && typeof Symbol == "function" && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
863
|
+
}, _typeof(o);
|
|
864
|
+
}
|
|
865
|
+
function toPrimitive(t, r) {
|
|
866
|
+
if (_typeof(t) != "object" || !t) return t;
|
|
867
|
+
var e = t[Symbol.toPrimitive];
|
|
868
|
+
if (e !== void 0) {
|
|
869
|
+
var i = e.call(t, r || "default");
|
|
870
|
+
if (_typeof(i) != "object") return i;
|
|
871
|
+
throw TypeError("@@toPrimitive must return a primitive value.");
|
|
872
|
+
}
|
|
873
|
+
return (r === "string" ? String : Number)(t);
|
|
874
|
+
}
|
|
875
|
+
function toPropertyKey(t) {
|
|
876
|
+
var i = toPrimitive(t, "string");
|
|
877
|
+
return _typeof(i) == "symbol" ? i : i + "";
|
|
878
|
+
}
|
|
879
|
+
function _defineProperty(e, r, t) {
|
|
880
|
+
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
881
|
+
value: t,
|
|
882
|
+
enumerable: !0,
|
|
883
|
+
configurable: !0,
|
|
884
|
+
writable: !0
|
|
885
|
+
}) : e[r] = t, e;
|
|
886
|
+
}
|
|
887
|
+
const DEFAULT_TAGS = ["feature:jev-fields"], RETRY_STATUSES = /* @__PURE__ */ new Set([
|
|
888
|
+
429,
|
|
889
|
+
502,
|
|
890
|
+
503,
|
|
891
|
+
504,
|
|
892
|
+
529
|
|
893
|
+
]), DEFAULT_RETRY_DELAYS = [
|
|
894
|
+
500,
|
|
895
|
+
1e3,
|
|
896
|
+
2e3
|
|
897
|
+
];
|
|
898
|
+
/**
|
|
899
|
+
* An evaluation that failed. `message` is in English; the Studio shows a translated text built
|
|
900
|
+
* from `kind`, `status` and `detail` (the Gateway's own explanation).
|
|
901
|
+
*/
|
|
902
|
+
var JevError = class extends Error {
|
|
903
|
+
constructor(kind, message, status, detail) {
|
|
904
|
+
super(message), _defineProperty(this, "kind", void 0), _defineProperty(this, "status", void 0), _defineProperty(this, "detail", void 0), this.name = "JevError", this.kind = kind, this.status = status, this.detail = detail;
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
/** The default transport: calls AI Gateway directly from the browser with the API key. */
|
|
908
|
+
function gatewayTransport(apiKey, endpoint = "https://ai-gateway.vercel.sh/v1/evaluate") {
|
|
909
|
+
return (request, { signal }) => fetch(endpoint, {
|
|
910
|
+
method: "POST",
|
|
911
|
+
signal,
|
|
912
|
+
headers: {
|
|
913
|
+
Authorization: `Bearer ${apiKey}`,
|
|
914
|
+
"Content-Type": "application/json"
|
|
915
|
+
},
|
|
916
|
+
body: JSON.stringify(request)
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Asks the decision model a single typed question about the state. Resolves with the answer
|
|
921
|
+
* and the model that gave it, as the Gateway names it.
|
|
922
|
+
*/
|
|
923
|
+
async function evaluateQuestion({ transport, model, state, question, tags, signal, retryDelays = DEFAULT_RETRY_DELAYS }) {
|
|
924
|
+
let request = {
|
|
925
|
+
model,
|
|
926
|
+
state,
|
|
927
|
+
questions: { q: question },
|
|
928
|
+
providerOptions: { gateway: { tags: normalizeTags(tags) } }
|
|
929
|
+
}, send = async (attempt) => {
|
|
930
|
+
let response = await transport(request, { signal }), body = await response.json().catch(() => ({}));
|
|
931
|
+
if (response.ok) {
|
|
932
|
+
let answer = body.answers?.q;
|
|
933
|
+
if (answer?.type !== question.type) throw new JevError("unexpected", "AI Gateway returned no answer.", response.status);
|
|
934
|
+
return {
|
|
935
|
+
answer,
|
|
936
|
+
model: body.model ?? model
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
if (RETRY_STATUSES.has(response.status) && attempt < retryDelays.length) return await sleep(retryDelay(response, retryDelays[attempt]), signal), send(attempt + 1);
|
|
940
|
+
throw toError(response.status, errorMessage(body));
|
|
941
|
+
};
|
|
942
|
+
return send(0);
|
|
943
|
+
}
|
|
944
|
+
function toError(status, message) {
|
|
945
|
+
return status === 401 || status === 403 ? new JevError("auth", "AI Gateway rejected the API key. Check the key in the Jev tool.", status, message) : status === 400 || status === 422 ? new JevError("invalid", `AI Gateway rejected the question: ${message ?? status}`, status, message) : RETRY_STATUSES.has(status) ? new JevError("busy", "AI Gateway is busy. Try again in a moment.", status, message) : new JevError("failed", `AI Gateway responded with ${status}${message ? `: ${message}` : ""}`, status, message);
|
|
946
|
+
}
|
|
947
|
+
function errorMessage(body) {
|
|
948
|
+
return (typeof body.error == "string" ? body.error : body.error?.message) || void 0;
|
|
949
|
+
}
|
|
950
|
+
/** Honours a short `Retry-After` (in seconds) when it asks for longer than our own backoff. */
|
|
951
|
+
function retryDelay(response, backoff) {
|
|
952
|
+
let seconds = Number(response.headers.get("retry-after"));
|
|
953
|
+
return !Number.isFinite(seconds) || seconds <= 0 ? backoff : Math.min(Math.max(backoff, seconds * 1e3), 1e4);
|
|
954
|
+
}
|
|
955
|
+
function sleep(ms, signal) {
|
|
956
|
+
return new Promise((resolve, reject) => {
|
|
957
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
958
|
+
let timeout = setTimeout(resolve, ms);
|
|
959
|
+
signal?.addEventListener("abort", () => {
|
|
960
|
+
clearTimeout(timeout), reject(signal.reason);
|
|
961
|
+
}, { once: !0 });
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
function normalizeTags(tags) {
|
|
965
|
+
let cleaned = tags.map((tag) => tag.trim().slice(0, 64)).filter(Boolean);
|
|
966
|
+
return [...new Set(cleaned)].slice(0, 10);
|
|
967
|
+
}
|
|
968
|
+
const realClock = {
|
|
969
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
970
|
+
after(ms, callback) {
|
|
971
|
+
let timeout = setTimeout(callback, ms);
|
|
972
|
+
return () => clearTimeout(timeout);
|
|
973
|
+
}
|
|
974
|
+
}, EMPTY = {
|
|
975
|
+
signals: [],
|
|
976
|
+
canRun: !1,
|
|
977
|
+
empty: !0,
|
|
978
|
+
loading: !1
|
|
979
|
+
};
|
|
980
|
+
/**
|
|
981
|
+
* The Evaluation lifecycle of the signals on one attached field. Signals evaluate on their own
|
|
982
|
+
* shortly after a local edit, only while the field's state is one this Studio produced; work for
|
|
983
|
+
* a fingerprint that has since changed is cancelled and its answer dropped.
|
|
984
|
+
*/
|
|
985
|
+
function createLifecycle(clock = realClock) {
|
|
986
|
+
let inputs, entries = [], localState, expectLocal = !1, requests = /* @__PURE__ */ new Map(), statuses = /* @__PURE__ */ new Map(), attempted = /* @__PURE__ */ new Map(), scheduled, snapshot = EMPTY, dirty = !0, listeners = /* @__PURE__ */ new Set(), canRun = (current) => !!current.transport && !current.readOnly && current.state.trim() !== "";
|
|
987
|
+
function update(next) {
|
|
988
|
+
let previous = inputs;
|
|
989
|
+
inputs = next, next.state !== previous?.state && (localState = expectLocal ? next.state : void 0, expectLocal = !1), (!previous || next.signals !== previous.signals || next.state !== previous.state || next.model !== previous.model) && (entries = Object.entries(next.signals).map(([key, signal]) => {
|
|
990
|
+
let kind = kindOf(signal);
|
|
991
|
+
return {
|
|
992
|
+
key,
|
|
993
|
+
signal,
|
|
994
|
+
kind,
|
|
995
|
+
title: titleOf(key, signal),
|
|
996
|
+
hash: fingerprint([
|
|
997
|
+
next.state,
|
|
998
|
+
kind.question ?? null,
|
|
999
|
+
next.model
|
|
1000
|
+
])
|
|
1001
|
+
};
|
|
1002
|
+
}), forgetObsoleteWork(), dirty = !0), previous && (canRun(next) !== canRun(previous) || entries.some(({ key }) => next.answers[key] !== previous.answers[key])) && (dirty = !0), refresh();
|
|
1003
|
+
}
|
|
1004
|
+
function forgetObsoleteWork() {
|
|
1005
|
+
let hashes = new Map(entries.map(({ key, hash }) => [key, hash]));
|
|
1006
|
+
for (let [key, request] of requests) hashes.get(key) !== request.hash && (request.controller.abort(), requests.delete(key));
|
|
1007
|
+
for (let [key, status] of statuses) hashes.get(key) !== status.hash && statuses.delete(key);
|
|
1008
|
+
for (let [key, hash] of attempted) hashes.get(key) !== hash && attempted.delete(key);
|
|
1009
|
+
}
|
|
1010
|
+
/** Signals that would evaluate on their own: unanswered or stale, and not tried yet. */
|
|
1011
|
+
function pendingKeys(current, views) {
|
|
1012
|
+
return !canRun(current) || localState !== current.state ? [] : entries.filter((entry, index) => {
|
|
1013
|
+
let view = views[index];
|
|
1014
|
+
return entry.kind.question && (!view.reading || view.stale) && attempted.get(entry.key) !== entry.hash;
|
|
1015
|
+
}).map(({ key }) => key);
|
|
1016
|
+
}
|
|
1017
|
+
/** Rebuilds the snapshot and the debounce after anything they depend on changed. */
|
|
1018
|
+
function refresh() {
|
|
1019
|
+
let current = inputs;
|
|
1020
|
+
if (!current || !dirty) return;
|
|
1021
|
+
dirty = !1;
|
|
1022
|
+
let views = entries.map((entry) => {
|
|
1023
|
+
let reading = entry.kind.read(current.answers[entry.key]), status = statuses.get(entry.key);
|
|
1024
|
+
return {
|
|
1025
|
+
key: entry.key,
|
|
1026
|
+
signal: entry.signal,
|
|
1027
|
+
title: entry.title,
|
|
1028
|
+
problem: entry.kind.problem,
|
|
1029
|
+
reading,
|
|
1030
|
+
stale: !!reading && reading.value.sourceHash !== entry.hash,
|
|
1031
|
+
loading: status?.state === "loading",
|
|
1032
|
+
error: status?.state === "error" ? status.error : null,
|
|
1033
|
+
keyRejected: status?.state === "error" && status.error.kind === "auth"
|
|
1034
|
+
};
|
|
1035
|
+
}), pending = pendingKeys(current, views);
|
|
1036
|
+
for (let view of views) pending.includes(view.key) && (view.loading = !0);
|
|
1037
|
+
snapshot = {
|
|
1038
|
+
signals: views,
|
|
1039
|
+
canRun: canRun(current),
|
|
1040
|
+
empty: current.state.trim() === "",
|
|
1041
|
+
loading: views.some((view) => view.loading)
|
|
1042
|
+
}, schedule(pending, current.debounceMs);
|
|
1043
|
+
for (let listener of listeners) listener();
|
|
1044
|
+
}
|
|
1045
|
+
function schedule(keys, debounceMs) {
|
|
1046
|
+
let hashes = new Map(entries.map(({ key, hash }) => [key, hash])), signature = keys.map((key) => `${key}:${hashes.get(key)}`).join("\n");
|
|
1047
|
+
signature !== scheduled?.signature && (scheduled?.cancel(), scheduled = void 0, signature && (scheduled = {
|
|
1048
|
+
signature,
|
|
1049
|
+
cancel: clock.after(debounceMs, () => run(keys))
|
|
1050
|
+
}));
|
|
1051
|
+
}
|
|
1052
|
+
function setStatus(key, status) {
|
|
1053
|
+
status ? statuses.set(key, status) : statuses.delete(key), dirty = !0, refresh();
|
|
1054
|
+
}
|
|
1055
|
+
function run(keys) {
|
|
1056
|
+
let current = inputs;
|
|
1057
|
+
if (!current?.transport || !canRun(current)) return;
|
|
1058
|
+
let { transport, model, state } = current;
|
|
1059
|
+
for (let entry of entries) {
|
|
1060
|
+
let question = entry.kind.question;
|
|
1061
|
+
if (keys && !keys.includes(entry.key) || !question) continue;
|
|
1062
|
+
requests.get(entry.key)?.controller.abort();
|
|
1063
|
+
let controller = new AbortController();
|
|
1064
|
+
requests.set(entry.key, {
|
|
1065
|
+
controller,
|
|
1066
|
+
hash: entry.hash
|
|
1067
|
+
}), attempted.set(entry.key, entry.hash), setStatus(entry.key, {
|
|
1068
|
+
state: "loading",
|
|
1069
|
+
hash: entry.hash
|
|
1070
|
+
});
|
|
1071
|
+
let tag = `${entry.kind.typeName}:${[...current.tagPath, entry.key].filter(Boolean).join(".")}`, evaluate = async () => {
|
|
1072
|
+
try {
|
|
1073
|
+
let result = await evaluateQuestion({
|
|
1074
|
+
transport,
|
|
1075
|
+
model,
|
|
1076
|
+
state,
|
|
1077
|
+
question,
|
|
1078
|
+
tags: [...current.tags, tag],
|
|
1079
|
+
signal: controller.signal
|
|
1080
|
+
});
|
|
1081
|
+
if (controller.signal.aborted) return;
|
|
1082
|
+
inputs?.store(entry.key, {
|
|
1083
|
+
...entry.kind.toStored(result.answer),
|
|
1084
|
+
evaluatedAt: clock.now().toISOString(),
|
|
1085
|
+
model: result.model,
|
|
1086
|
+
sourceHash: entry.hash
|
|
1087
|
+
}), requests.delete(entry.key), setStatus(entry.key, void 0);
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
if (controller.signal.aborted) return;
|
|
1090
|
+
requests.delete(entry.key), setStatus(entry.key, {
|
|
1091
|
+
state: "error",
|
|
1092
|
+
hash: entry.hash,
|
|
1093
|
+
error: error instanceof JevError ? {
|
|
1094
|
+
message: error.message,
|
|
1095
|
+
kind: error.kind,
|
|
1096
|
+
status: error.status,
|
|
1097
|
+
detail: error.detail
|
|
1098
|
+
} : { message: error instanceof Error ? error.message : String(error) }
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
evaluate();
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
update,
|
|
1107
|
+
localEdit() {
|
|
1108
|
+
expectLocal = !0;
|
|
1109
|
+
},
|
|
1110
|
+
run,
|
|
1111
|
+
subscribe(listener) {
|
|
1112
|
+
return listeners.add(listener), () => {
|
|
1113
|
+
listeners.delete(listener);
|
|
1114
|
+
};
|
|
1115
|
+
},
|
|
1116
|
+
getSnapshot: () => snapshot,
|
|
1117
|
+
dispose() {
|
|
1118
|
+
scheduled?.cancel(), scheduled = void 0;
|
|
1119
|
+
for (let [key, request] of requests) request.controller.abort(), statuses.delete(key), attempted.delete(key);
|
|
1120
|
+
requests.clear(), dirty = !0;
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
const NO_ANSWERS = {};
|
|
1125
|
+
/** Connects the Evaluation lifecycle of one attached field to the Studio's form. */
|
|
1126
|
+
function useSignals({ signals, path, state, readOnly }) {
|
|
1127
|
+
let { config, writers } = useJevForm(), parentPath = useMemo(() => path.slice(0, -1), [path]), keySource = useKeySource(config), apiKey = keySource.apiKey, transport = useMemo(() => config.transport ?? (apiKey ? gatewayTransport(apiKey, config.endpoint) : void 0), [
|
|
1128
|
+
config.transport,
|
|
1129
|
+
apiKey,
|
|
1130
|
+
config.endpoint
|
|
1131
|
+
]), setup = transport ? "ready" : keySource.from === "secrets" && keySource.loading ? "loading" : "missing", parent = useFormValue(parentPath), documentType = useFormValue(["_type"]), store = useCallback((key, value) => {
|
|
1132
|
+
let write = writers.get(pathKey([...parentPath, key]));
|
|
1133
|
+
if (!write) throw Error(`There is no "${key}" field to store this answer in. Wrap your schema types with withJevAnswers() in sanity.config.`);
|
|
1134
|
+
write(value);
|
|
1135
|
+
}, [writers, parentPath]), tagPath = useMemo(() => [typeof documentType == "string" ? documentType : "", ...parentPath.map((segment) => typeof segment == "string" ? segment : "[]")], [documentType, parentPath]), [lifecycle] = useState(() => createLifecycle());
|
|
1136
|
+
return useLayoutEffect(() => {
|
|
1137
|
+
lifecycle.update({
|
|
1138
|
+
signals,
|
|
1139
|
+
state,
|
|
1140
|
+
answers: isRecord$1(parent) ? parent : NO_ANSWERS,
|
|
1141
|
+
transport,
|
|
1142
|
+
store,
|
|
1143
|
+
model: config.model ?? "typesafe-ai/jev",
|
|
1144
|
+
tags: config.tags ?? DEFAULT_TAGS,
|
|
1145
|
+
tagPath,
|
|
1146
|
+
readOnly,
|
|
1147
|
+
debounceMs: config.debounceMs ?? 500
|
|
1148
|
+
});
|
|
1149
|
+
}), useEffect(() => () => lifecycle.dispose(), [lifecycle]), {
|
|
1150
|
+
...useSyncExternalStore(lifecycle.subscribe, lifecycle.getSnapshot),
|
|
1151
|
+
setup,
|
|
1152
|
+
/** The key is stored with Studio secrets, so editors can set and change it here. */
|
|
1153
|
+
keyInSecrets: keySource.from === "secrets",
|
|
1154
|
+
run: (key) => lifecycle.run([key]),
|
|
1155
|
+
runAll: () => lifecycle.run(),
|
|
1156
|
+
localEdit: lifecycle.localEdit
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
/** Renders a field with `options.jev` as usual, with its signal strip and details below. */
|
|
1160
|
+
function AttachedInput({ signals, ...props }) {
|
|
1161
|
+
let { t } = useTranslation("jev"), parentOnChange = props.onChange, { value } = props, state = toText(value), jev = useSignals({
|
|
1162
|
+
signals,
|
|
1163
|
+
path: props.path,
|
|
1164
|
+
state,
|
|
1165
|
+
readOnly: !!props.readOnly
|
|
1166
|
+
}), onChange = (patch) => {
|
|
1167
|
+
jev.localEdit(), parentOnChange(patch);
|
|
1168
|
+
}, inputProps = {
|
|
1169
|
+
...props,
|
|
1170
|
+
onChange
|
|
1171
|
+
}, [selected, setSelected] = useState(null), [keyDialogOpen, setKeyDialogOpen] = useState(!1), selectedSignal = jev.signals.find((signal) => signal.key === selected);
|
|
1172
|
+
return <Stack gap={2}>
|
|
1173
|
+
<div>
|
|
1174
|
+
{props.renderDefault(inputProps)}
|
|
1175
|
+
<SignalStrip signals={jev.signals} selected={selected} onSelect={setSelected} setup={jev.setup} canRun={jev.canRun} loading={jev.loading} onRunAll={jev.runAll} onSetUp={() => setKeyDialogOpen(!0)} />
|
|
1176
|
+
</div>
|
|
1177
|
+
{nearTokenLimit(state) && <Card padding={3} radius={2} tone="caution" border>
|
|
1178
|
+
<Text size={1}>
|
|
1179
|
+
{t("field.too-long", {
|
|
1180
|
+
tokens: Math.round(estimateTokens(state) / 1e3),
|
|
1181
|
+
limit: 32
|
|
1182
|
+
})}
|
|
1183
|
+
</Text>
|
|
1184
|
+
</Card>}
|
|
1185
|
+
{selectedSignal && <SignalDetail signal={selectedSignal} empty={jev.empty} onRetry={() => jev.run(selectedSignal.key)} onUpdateKey={jev.keyInSecrets ? () => setKeyDialogOpen(!0) : void 0} />}
|
|
1186
|
+
{keyDialogOpen && <JevKeyDialog onClose={() => setKeyDialogOpen(!1)} />}
|
|
1187
|
+
</Stack>;
|
|
1188
|
+
}
|
|
1189
|
+
const Code = ({ children }) => <code>{children}</code>, CONNECTION_TEST = kindOf(noul({
|
|
1190
|
+
instructions: "Is this text a connection test?",
|
|
1191
|
+
true: "It is a test",
|
|
1192
|
+
false: "It is not a test"
|
|
1193
|
+
})).question, asSignalError = (error) => error instanceof JevError ? {
|
|
1194
|
+
message: error.message,
|
|
1195
|
+
kind: error.kind,
|
|
1196
|
+
status: error.status,
|
|
1197
|
+
detail: error.detail
|
|
1198
|
+
} : { message: error instanceof Error ? error.message : String(error) };
|
|
1199
|
+
/** Studio tool for setting, checking and removing the stored AI Gateway key. */
|
|
1200
|
+
function JevTool({ config }) {
|
|
1201
|
+
let { t } = useTranslation("jev"), stored = useStoredKey(), saveKey = useSaveKey(), [settingsOpen, setSettingsOpen] = useState(!1), [confirmRemove, setConfirmRemove] = useState(!1), [test, setTest] = useState({ state: "idle" }), fromConfig = !!(config.transport || config.apiKey), apiKey = config.apiKey ?? stored.apiKey, transport = config.transport ?? (apiKey ? gatewayTransport(apiKey, config.endpoint) : void 0);
|
|
1202
|
+
async function testConnection() {
|
|
1203
|
+
if (transport && CONNECTION_TEST) {
|
|
1204
|
+
setTest({ state: "running" });
|
|
1205
|
+
try {
|
|
1206
|
+
let result = await evaluateQuestion({
|
|
1207
|
+
transport,
|
|
1208
|
+
model: config.model ?? "typesafe-ai/jev",
|
|
1209
|
+
state: "This is a connection test.",
|
|
1210
|
+
question: CONNECTION_TEST,
|
|
1211
|
+
tags: [...config.tags ?? DEFAULT_TAGS, "jev:connection-test"]
|
|
1212
|
+
});
|
|
1213
|
+
setTest({
|
|
1214
|
+
state: "passed",
|
|
1215
|
+
model: result.model
|
|
1216
|
+
});
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
setTest({
|
|
1219
|
+
state: "failed",
|
|
1220
|
+
error: asSignalError(error)
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return <Container width={1} padding={4}>
|
|
1226
|
+
<Stack gap={5}>
|
|
1227
|
+
<Stack gap={3}>
|
|
1228
|
+
<Heading size={2}>{t("tool.title")}</Heading>
|
|
1229
|
+
<Text muted size={1}>
|
|
1230
|
+
{t("tool.intro")}
|
|
1231
|
+
</Text>
|
|
1232
|
+
</Stack>
|
|
1233
|
+
|
|
1234
|
+
<Card padding={4} radius={2} border>
|
|
1235
|
+
<Stack gap={4}>
|
|
1236
|
+
<Flex align="center" gap={3}>
|
|
1237
|
+
<Box flex={1}>
|
|
1238
|
+
<Heading size={0} as="h2">
|
|
1239
|
+
{t("tool.key.heading")}
|
|
1240
|
+
</Heading>
|
|
1241
|
+
</Box>
|
|
1242
|
+
<KeyStatus fromConfig={fromConfig} loading={stored.loading} stored={stored.apiKey} />
|
|
1243
|
+
</Flex>
|
|
1244
|
+
|
|
1245
|
+
{fromConfig ? <Text size={1} muted>
|
|
1246
|
+
{config.transport ? t("tool.key.from-transport") : t("tool.key.from-config")}
|
|
1247
|
+
</Text> : stored.apiKey ? <Stack gap={3}>
|
|
1248
|
+
<Text size={1}>
|
|
1249
|
+
<Translate t={t} i18nKey="tool.key.ends-with" values={{ last4: stored.apiKey.slice(-4) }} components={{ Code }} />
|
|
1250
|
+
</Text>
|
|
1251
|
+
{stored.updatedAt && <Text size={1} muted>
|
|
1252
|
+
{t("tool.key.changed", { date: new Date(stored.updatedAt).toLocaleString() })}
|
|
1253
|
+
</Text>}
|
|
1254
|
+
</Stack> : <Text size={1} muted>
|
|
1255
|
+
{stored.loading ? t("tool.key.checking") : t("tool.key.none")}
|
|
1256
|
+
</Text>}
|
|
1257
|
+
|
|
1258
|
+
<Flex gap={2} wrap="wrap">
|
|
1259
|
+
{!fromConfig && <Button text={stored.apiKey ? t("tool.key.change") : t("tool.key.set")} tone="primary" disabled={stored.loading} onClick={() => setSettingsOpen(!0)} />}
|
|
1260
|
+
<Button text={test.state === "running" ? t("tool.test.running") : t("tool.test.run")} mode="ghost" disabled={!transport || test.state === "running"} onClick={testConnection} />
|
|
1261
|
+
{!fromConfig && stored.apiKey && !confirmRemove && <Button text={t("tool.key.remove")} mode="bleed" tone="critical" onClick={() => setConfirmRemove(!0)} />}
|
|
1262
|
+
</Flex>
|
|
1263
|
+
|
|
1264
|
+
{confirmRemove && <Card padding={3} radius={2} tone="critical">
|
|
1265
|
+
<Flex align="center" gap={2} wrap="wrap">
|
|
1266
|
+
<Box flex={1}>
|
|
1267
|
+
<Text size={1}>{t("tool.key.remove-confirm")}</Text>
|
|
1268
|
+
</Box>
|
|
1269
|
+
<Button text={t("tool.key.remove-cancel")} mode="bleed" onClick={() => setConfirmRemove(!1)} />
|
|
1270
|
+
<Button text={t("tool.key.remove-confirm-button")} tone="critical" onClick={async () => {
|
|
1271
|
+
setConfirmRemove(!1);
|
|
1272
|
+
try {
|
|
1273
|
+
await saveKey(void 0), setTest({ state: "idle" });
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
1276
|
+
setTest({
|
|
1277
|
+
state: "failed",
|
|
1278
|
+
error: { message: t("tool.key.remove-failed", { error: message }) }
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
}} />
|
|
1282
|
+
</Flex>
|
|
1283
|
+
</Card>}
|
|
1284
|
+
|
|
1285
|
+
{test.state === "passed" && <Card padding={3} radius={2} tone="positive">
|
|
1286
|
+
<Text size={1}>{t("tool.test.passed", { model: test.model })}</Text>
|
|
1287
|
+
</Card>}
|
|
1288
|
+
{test.state === "failed" && <Card padding={3} radius={2} tone="critical">
|
|
1289
|
+
<Text size={1}>{errorText(t, test.error)}</Text>
|
|
1290
|
+
</Card>}
|
|
1291
|
+
</Stack>
|
|
1292
|
+
</Card>
|
|
1293
|
+
|
|
1294
|
+
<Stack gap={3}>
|
|
1295
|
+
<Heading size={0} as="h2">
|
|
1296
|
+
{t("tool.visibility.heading")}
|
|
1297
|
+
</Heading>
|
|
1298
|
+
<Text size={1} muted>
|
|
1299
|
+
<Translate t={t} i18nKey="tool.visibility.body" components={{ Code }} />
|
|
1300
|
+
</Text>
|
|
1301
|
+
</Stack>
|
|
1302
|
+
</Stack>
|
|
1303
|
+
|
|
1304
|
+
{settingsOpen && <JevKeyDialog onClose={() => {
|
|
1305
|
+
setSettingsOpen(!1), setTest({ state: "idle" });
|
|
1306
|
+
}} />}
|
|
1307
|
+
</Container>;
|
|
1308
|
+
}
|
|
1309
|
+
function KeyStatus({ fromConfig, loading, stored }) {
|
|
1310
|
+
let { t } = useTranslation("jev");
|
|
1311
|
+
return fromConfig ? <Badge tone="primary">{t("tool.key.status.config")}</Badge> : loading ? <Badge>{t("tool.key.status.checking")}</Badge> : stored ? <Badge tone="positive">{t("tool.key.status.set")}</Badge> : <Badge tone="caution">{t("tool.key.status.not-set")}</Badge>;
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Jev signals for Sanity Studio: yes/no, score and choice questions attached to a field with
|
|
1315
|
+
* `options.jev`, answered by TypeSafe's Jev model through Vercel AI Gateway. Pair it with
|
|
1316
|
+
* `withJevAnswers(schemaTypes)` in `schema.types`.
|
|
1317
|
+
* @public
|
|
1318
|
+
*/
|
|
1319
|
+
const jev = definePlugin((config) => {
|
|
1320
|
+
let pluginConfig = config ?? {};
|
|
1321
|
+
return {
|
|
1322
|
+
name: "jev",
|
|
1323
|
+
tools: pluginConfig.tool === !1 ? [] : [{
|
|
1324
|
+
name: "jev",
|
|
1325
|
+
title: "Jev",
|
|
1326
|
+
icon: SparklesIcon,
|
|
1327
|
+
component: () => <JevTool config={pluginConfig} />
|
|
1328
|
+
}],
|
|
1329
|
+
form: { components: { input: (props) => {
|
|
1330
|
+
if (props.path.length === 0) return <JevFormProvider config={pluginConfig}>{props.renderDefault(props)}</JevFormProvider>;
|
|
1331
|
+
let signals = signalsOf(props.schemaType);
|
|
1332
|
+
return signals ? <AttachedInput {...props} signals={signals} /> : props.renderDefault(props);
|
|
1333
|
+
} } },
|
|
1334
|
+
schema: { types: KIND_SCHEMA_TYPES },
|
|
1335
|
+
i18n: { bundles: [jevLocaleBundle] }
|
|
1336
|
+
};
|
|
1337
|
+
});
|
|
1338
|
+
export { JEV_NAMESPACE, choice, jev, noul, score, withJevAnswers };
|
|
1339
|
+
|
|
1340
|
+
//# sourceMappingURL=index.js.map
|