sanity-plugin-jev-fields 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +421 -369
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,20 +5,20 @@ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffe
|
|
|
5
5
|
import { RefreshIcon } from "@sanity/icons/Refresh";
|
|
6
6
|
import { keyframes, styled } from "styled-components";
|
|
7
7
|
/** Flattens a field's value into the plain text Jev evaluates. */
|
|
8
|
-
function
|
|
8
|
+
function flattenToText(value) {
|
|
9
9
|
if (value == null) return "";
|
|
10
10
|
if (typeof value == "string") return value;
|
|
11
11
|
if (typeof value == "number" || typeof value == "boolean") return String(value);
|
|
12
12
|
if (Array.isArray(value)) {
|
|
13
|
-
let separator = value.some(
|
|
14
|
-
return value.map(
|
|
13
|
+
let separator = value.some(isPortableTextBlock) ? "\n\n" : "\n";
|
|
14
|
+
return value.map(flattenToText).filter((text) => text !== "").join(separator);
|
|
15
15
|
}
|
|
16
|
-
return isRecord
|
|
16
|
+
return isRecord(value) ? isPortableTextBlock(value) ? value.children.map((child) => child.text ?? "").join("") : value._type === "slug" ? flattenToText(value.current) : Object.entries(value).filter(([key]) => !key.startsWith("_")).map(([, child]) => flattenToText(child)).filter((text) => text !== "").join("\n") : "";
|
|
17
17
|
}
|
|
18
|
-
function
|
|
19
|
-
return isRecord
|
|
18
|
+
function isPortableTextBlock(value) {
|
|
19
|
+
return isRecord(value) && value._type === "block" && Array.isArray(value.children);
|
|
20
20
|
}
|
|
21
|
-
function isRecord
|
|
21
|
+
function isRecord(value) {
|
|
22
22
|
return !!value && typeof value == "object" && !Array.isArray(value);
|
|
23
23
|
}
|
|
24
24
|
/** FNV-1a: a small, stable fingerprint; collisions only cost a skipped re-evaluation. */
|
|
@@ -28,17 +28,17 @@ function fingerprint(input) {
|
|
|
28
28
|
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
29
29
|
}
|
|
30
30
|
/** Jev reads at most this many tokens of state (plus the longest question) per request. */
|
|
31
|
-
const
|
|
31
|
+
const estimateTokenCount = (text) => Math.ceil(text.length / 4), isNearTokenLimit = (text) => estimateTokenCount(text) > 28e3, JevFormContext = createContext(null), toPathKey = (path) => JSON.stringify(path);
|
|
32
32
|
/** Wraps a document form, so attached fields can reach the plugin config and answer fields. */
|
|
33
33
|
function JevFormProvider({ config, children }) {
|
|
34
|
-
let [
|
|
34
|
+
let [answerWriters] = useState(() => /* @__PURE__ */ new Map()), value = useMemo(() => ({
|
|
35
35
|
config,
|
|
36
|
-
|
|
37
|
-
}), [config,
|
|
38
|
-
return <
|
|
36
|
+
answerWriters
|
|
37
|
+
}), [config, answerWriters]);
|
|
38
|
+
return <JevFormContext.Provider value={value}>{children}</JevFormContext.Provider>;
|
|
39
39
|
}
|
|
40
40
|
function useJevForm() {
|
|
41
|
-
let context = useContext(
|
|
41
|
+
let context = useContext(JevFormContext);
|
|
42
42
|
if (!context) throw Error("Jev questions need the jev() plugin in sanity.config.");
|
|
43
43
|
return context;
|
|
44
44
|
}
|
|
@@ -47,26 +47,26 @@ function useJevForm() {
|
|
|
47
47
|
* registers the field's own `onChange`, so the attached field can store answers through it.
|
|
48
48
|
*/
|
|
49
49
|
function AnswerField(props) {
|
|
50
|
-
let {
|
|
51
|
-
return useEffect(() => (
|
|
52
|
-
|
|
50
|
+
let { answerWriters } = useJevForm(), pathKey = toPathKey(props.path), onChange = props.inputProps.onChange;
|
|
51
|
+
return useEffect(() => (answerWriters.set(pathKey, (value) => onChange(set(value))), () => {
|
|
52
|
+
answerWriters.delete(pathKey);
|
|
53
53
|
}), [
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
answerWriters,
|
|
55
|
+
pathKey,
|
|
56
56
|
onChange
|
|
57
57
|
]), null;
|
|
58
58
|
}
|
|
59
|
-
const NEUTRAL_COLOR = "hsl(230 80% 66%)",
|
|
59
|
+
const NEUTRAL_COLOR = "hsl(230 80% 66%)", ERROR_COLOR = "#f03e2f", clampToFraction = (value) => Math.min(1, Math.max(0, value)), capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
60
60
|
/** Red at 0, amber in the middle, green at 1. */
|
|
61
61
|
function trafficColor(fraction) {
|
|
62
|
-
return `hsl(${Math.round(
|
|
62
|
+
return `hsl(${Math.round(clampToFraction(fraction) * 120)} 70% 52%)`;
|
|
63
63
|
}
|
|
64
64
|
function trafficTone(fraction) {
|
|
65
65
|
return fraction < 1 / 3 ? "critical" : fraction < 2 / 3 ? "caution" : "positive";
|
|
66
66
|
}
|
|
67
67
|
/** A thin rounded bar. `fraction` is 0–1. */
|
|
68
68
|
function Bar({ fraction, color, label }) {
|
|
69
|
-
let percent = Math.round(
|
|
69
|
+
let percent = Math.round(clampToFraction(fraction) * 100);
|
|
70
70
|
return <Box flex={1} role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} aria-label={label} style={{
|
|
71
71
|
height: 4,
|
|
72
72
|
borderRadius: 4,
|
|
@@ -88,11 +88,11 @@ const TYPE_NAMES = {
|
|
|
88
88
|
score: "jev.score",
|
|
89
89
|
choice: "jev.choice",
|
|
90
90
|
choiceProbability: "jev.choiceProbability"
|
|
91
|
-
},
|
|
92
|
-
function
|
|
91
|
+
}, RULE_LEVELS = ["warn", "require"], roundToDigits = (value, digits = 4) => Math.round(value * 10 ** digits) / 10 ** digits;
|
|
92
|
+
function throwAnswerKindMismatch() {
|
|
93
93
|
throw Error("Jev answered a different kind of question.");
|
|
94
94
|
}
|
|
95
|
-
const
|
|
95
|
+
const evaluationFields = [
|
|
96
96
|
defineField({
|
|
97
97
|
name: "evaluatedAt",
|
|
98
98
|
type: "datetime",
|
|
@@ -109,44 +109,41 @@ const bookkeepingFields = [
|
|
|
109
109
|
hidden: !0
|
|
110
110
|
})
|
|
111
111
|
];
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return isRecord$1(stored) && stored._type === typeName ? stored : void 0;
|
|
112
|
+
function asRecordOfType(stored, typeName) {
|
|
113
|
+
return isRecord(stored) && stored._type === typeName ? stored : void 0;
|
|
115
114
|
}
|
|
116
|
-
|
|
117
|
-
function bookkeepingOf(stored) {
|
|
115
|
+
function readEvaluationFields(stored) {
|
|
118
116
|
return {
|
|
119
|
-
evaluatedAt:
|
|
120
|
-
model:
|
|
121
|
-
sourceHash:
|
|
117
|
+
evaluatedAt: stringOrUndefined(stored.evaluatedAt),
|
|
118
|
+
model: stringOrUndefined(stored.model),
|
|
119
|
+
sourceHash: stringOrUndefined(stored.sourceHash)
|
|
122
120
|
};
|
|
123
121
|
}
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
for (let level of ["warn", "require"]) {
|
|
122
|
+
const stringOrUndefined = (value) => typeof value == "string" ? value : void 0, numberOrUndefined = (value) => typeof value == "number" ? value : void 0;
|
|
123
|
+
function findRangeRuleConfigError(rules, min, max) {
|
|
124
|
+
for (let level of RULE_LEVELS) {
|
|
128
125
|
let rule = rules[level];
|
|
129
126
|
if (rule) for (let bound of ["atLeast", "atMost"]) {
|
|
130
127
|
let value = rule[bound];
|
|
131
|
-
if (value !== void 0 && (typeof value
|
|
128
|
+
if (value !== void 0 && !(typeof value == "number" && value >= min && value <= max)) return `\`${level}.${bound}\` must be a number from ${min} to ${max}.`;
|
|
132
129
|
}
|
|
133
130
|
}
|
|
134
131
|
}
|
|
135
|
-
/**
|
|
136
|
-
function
|
|
132
|
+
/** E.g. `below 60%`, using `format` for both numbers. */
|
|
133
|
+
function describeRangeViolation(value, rule, format) {
|
|
137
134
|
if (rule?.atLeast !== void 0 && value < rule.atLeast) return `below ${format(rule.atLeast)}`;
|
|
138
135
|
if (rule?.atMost !== void 0 && value > rule.atMost) return `above ${format(rule.atMost)}`;
|
|
139
136
|
}
|
|
140
|
-
|
|
141
|
-
|
|
137
|
+
function findChoiceConfigError(question) {
|
|
138
|
+
let options = Object.keys(question.criteria ?? {});
|
|
142
139
|
if (options.length < 2) return "A choice question needs at least two options.";
|
|
143
140
|
if (options.length > 255) return "A choice question allows at most 255 options.";
|
|
144
|
-
for (let level of
|
|
141
|
+
for (let level of RULE_LEVELS) {
|
|
145
142
|
let rule = question[level];
|
|
146
143
|
if (!rule) continue;
|
|
147
144
|
if (!Array.isArray(rule.oneOf) || rule.oneOf.length === 0) return `\`${level}.oneOf\` must list at least one option.`;
|
|
148
|
-
let
|
|
149
|
-
if (
|
|
145
|
+
let unknownOption = rule.oneOf.find((option) => !options.includes(option));
|
|
146
|
+
if (unknownOption !== void 0) return `\`${level}.oneOf\` names "${unknownOption}", which is not an option.`;
|
|
150
147
|
}
|
|
151
148
|
}
|
|
152
149
|
/** One option from a named set. @public */
|
|
@@ -186,47 +183,52 @@ const choice = (question) => ({
|
|
|
186
183
|
readOnly: !0,
|
|
187
184
|
of: [defineArrayMember({ type: TYPE_NAMES.choiceProbability })]
|
|
188
185
|
}),
|
|
189
|
-
...
|
|
186
|
+
...evaluationFields
|
|
190
187
|
]
|
|
191
188
|
})];
|
|
189
|
+
function readStoredProbabilities(stored) {
|
|
190
|
+
return Array.isArray(stored) ? stored.filter(isRecord).map((entry) => ({
|
|
191
|
+
_key: String(entry._key),
|
|
192
|
+
_type: TYPE_NAMES.choiceProbability,
|
|
193
|
+
option: stringOrUndefined(entry.option),
|
|
194
|
+
probability: numberOrUndefined(entry.probability)
|
|
195
|
+
})) : [];
|
|
196
|
+
}
|
|
192
197
|
function bindChoice(question) {
|
|
193
|
-
let
|
|
198
|
+
let configError = findChoiceConfigError(question), gatewayQuestion = {
|
|
199
|
+
type: "choice",
|
|
200
|
+
instructions: question.instructions,
|
|
201
|
+
criteria: question.criteria
|
|
202
|
+
};
|
|
194
203
|
return {
|
|
195
204
|
typeName: TYPE_NAMES.choice,
|
|
196
|
-
gatewayQuestion:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
toStored(answer) {
|
|
203
|
-
return answer.type === "choice" ? {
|
|
205
|
+
gatewayQuestion: configError ? void 0 : gatewayQuestion,
|
|
206
|
+
configError,
|
|
207
|
+
toStoredValue(answer) {
|
|
208
|
+
if (answer.type !== "choice") return throwAnswerKindMismatch();
|
|
209
|
+
let confidence = answer.confidence ?? answer.probabilities[answer.choice] ?? 0;
|
|
210
|
+
return {
|
|
204
211
|
_type: TYPE_NAMES.choice,
|
|
205
212
|
choice: answer.choice,
|
|
206
|
-
confidence:
|
|
207
|
-
probabilities: Object.keys(criteria).map((option, index) => ({
|
|
213
|
+
confidence: roundToDigits(confidence),
|
|
214
|
+
probabilities: Object.keys(question.criteria).map((option, index) => ({
|
|
208
215
|
_key: `option-${index}`,
|
|
209
216
|
_type: TYPE_NAMES.choiceProbability,
|
|
210
217
|
option,
|
|
211
|
-
probability:
|
|
218
|
+
probability: roundToDigits(answer.probabilities[option] ?? 0)
|
|
212
219
|
}))
|
|
213
|
-
}
|
|
220
|
+
};
|
|
214
221
|
},
|
|
215
|
-
|
|
216
|
-
let record =
|
|
222
|
+
readStoredValue(stored) {
|
|
223
|
+
let record = asRecordOfType(stored, TYPE_NAMES.choice);
|
|
217
224
|
if (!record || typeof record.choice != "string") return;
|
|
218
|
-
let probabilities =
|
|
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];
|
|
225
|
+
let probabilities = readStoredProbabilities(record.probabilities), meaningOfChoice = question.criteria?.[record.choice];
|
|
224
226
|
return {
|
|
225
227
|
value: {
|
|
226
|
-
...
|
|
228
|
+
...readEvaluationFields(record),
|
|
227
229
|
_type: TYPE_NAMES.choice,
|
|
228
230
|
choice: record.choice,
|
|
229
|
-
confidence:
|
|
231
|
+
confidence: numberOrUndefined(record.confidence),
|
|
230
232
|
probabilities
|
|
231
233
|
},
|
|
232
234
|
chip: {
|
|
@@ -234,33 +236,35 @@ function bindChoice(question) {
|
|
|
234
236
|
color: NEUTRAL_COLOR
|
|
235
237
|
},
|
|
236
238
|
tone: "default",
|
|
237
|
-
aside:
|
|
239
|
+
aside: meaningOfChoice ? { note: meaningOfChoice } : void 0,
|
|
238
240
|
body: <ChoiceBody question={question} choice={record.choice} probabilities={probabilities} />
|
|
239
241
|
};
|
|
240
242
|
},
|
|
241
|
-
|
|
242
|
-
let record =
|
|
243
|
-
if (record
|
|
243
|
+
describeRuleViolation(level, stored, title) {
|
|
244
|
+
let record = asRecordOfType(stored, TYPE_NAMES.choice), rule = question[level];
|
|
245
|
+
if (!record || typeof record.choice != "string" || !rule?.oneOf || rule.oneOf.includes(record.choice)) return;
|
|
246
|
+
let allowedOptions = rule.oneOf.map((option) => `"${option}"`).join(" or ");
|
|
247
|
+
return `${title} is "${record.choice}", not ${allowedOptions}.`;
|
|
244
248
|
}
|
|
245
249
|
};
|
|
246
250
|
}
|
|
247
251
|
function ChoiceBody({ question, choice, probabilities }) {
|
|
248
|
-
let options = Object.keys(question.criteria),
|
|
252
|
+
let options = Object.keys(question.criteria), optionColumnWidth = `${Math.max(...options.map((option) => option.length), 4) + 2}ch`;
|
|
249
253
|
return <Stack gap={3}>
|
|
250
254
|
{options.map((option) => {
|
|
251
|
-
let probability = probabilities.find((entry) => entry.option === option)?.probability ?? 0,
|
|
255
|
+
let probability = probabilities.find((entry) => entry.option === option)?.probability ?? 0, isChosen = option === choice, percent = Math.round(probability * 100);
|
|
252
256
|
return <Flex key={option} align="center" gap={3}>
|
|
253
|
-
<Box style={{ width:
|
|
254
|
-
<Text size={1} weight={
|
|
257
|
+
<Box style={{ width: optionColumnWidth }}>
|
|
258
|
+
<Text size={1} weight={isChosen ? "semibold" : "regular"} muted={!isChosen}>
|
|
255
259
|
{capitalize(option)}
|
|
256
260
|
</Text>
|
|
257
261
|
</Box>
|
|
258
|
-
<Bar fraction={probability} color={
|
|
262
|
+
<Bar fraction={probability} color={isChosen ? NEUTRAL_COLOR : "hsl(215 12% 55%)"} label={`${option}: ${percent}%`} />
|
|
259
263
|
<Box style={{
|
|
260
264
|
minWidth: "4ch",
|
|
261
265
|
textAlign: "right"
|
|
262
266
|
}}>
|
|
263
|
-
<Text size={1} muted={!
|
|
267
|
+
<Text size={1} muted={!isChosen}>
|
|
264
268
|
{percent}%
|
|
265
269
|
</Text>
|
|
266
270
|
</Box>
|
|
@@ -342,73 +346,76 @@ const JEV_NAMESPACE = "jev", jevLocaleBundle = defineLocaleResourceBundle({
|
|
|
342
346
|
name: "probability",
|
|
343
347
|
type: "number",
|
|
344
348
|
readOnly: !0
|
|
345
|
-
}), ...
|
|
346
|
-
})],
|
|
349
|
+
}), ...evaluationFields]
|
|
350
|
+
})], LEVEL_BY_TONE = {
|
|
347
351
|
critical: "low",
|
|
348
352
|
caution: "medium",
|
|
349
353
|
positive: "high"
|
|
350
|
-
}
|
|
354
|
+
}, formatAsPercent = (probability) => `${Math.round(probability * 100)}%`;
|
|
355
|
+
function findNoulConfigError(question) {
|
|
356
|
+
return !question.true?.trim() || !question.false?.trim() ? "A noul question needs both `true` and `false` text." : findRangeRuleConfigError(question, 0, 1);
|
|
357
|
+
}
|
|
351
358
|
function bindNoul(question) {
|
|
352
|
-
let
|
|
359
|
+
let configError = findNoulConfigError(question), gatewayQuestion = {
|
|
360
|
+
type: "boolean",
|
|
361
|
+
instructions: question.instructions,
|
|
362
|
+
criteria: {
|
|
363
|
+
true: question.true,
|
|
364
|
+
false: question.false
|
|
365
|
+
}
|
|
366
|
+
};
|
|
353
367
|
return {
|
|
354
368
|
typeName: TYPE_NAMES.noul,
|
|
355
|
-
gatewayQuestion:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
criteria: {
|
|
359
|
-
true: question.true,
|
|
360
|
-
false: question.false
|
|
361
|
-
}
|
|
362
|
-
},
|
|
363
|
-
problem,
|
|
364
|
-
toStored(answer) {
|
|
369
|
+
gatewayQuestion: configError ? void 0 : gatewayQuestion,
|
|
370
|
+
configError,
|
|
371
|
+
toStoredValue(answer) {
|
|
365
372
|
return answer.type === "boolean" ? {
|
|
366
373
|
_type: TYPE_NAMES.noul,
|
|
367
|
-
probability:
|
|
368
|
-
} :
|
|
374
|
+
probability: roundToDigits(answer.probability)
|
|
375
|
+
} : throwAnswerKindMismatch();
|
|
369
376
|
},
|
|
370
|
-
|
|
371
|
-
let record =
|
|
377
|
+
readStoredValue(stored) {
|
|
378
|
+
let record = asRecordOfType(stored, TYPE_NAMES.noul);
|
|
372
379
|
if (!record || typeof record.probability != "number") return;
|
|
373
380
|
let probability = record.probability, tone = trafficTone(probability);
|
|
374
381
|
return {
|
|
375
382
|
value: {
|
|
376
|
-
...
|
|
383
|
+
...readEvaluationFields(record),
|
|
377
384
|
_type: TYPE_NAMES.noul,
|
|
378
385
|
probability
|
|
379
386
|
},
|
|
380
387
|
chip: {
|
|
381
|
-
text:
|
|
388
|
+
text: formatAsPercent(probability),
|
|
382
389
|
color: trafficColor(probability)
|
|
383
390
|
},
|
|
384
391
|
tone: tone === "positive" ? "default" : tone,
|
|
385
392
|
aside: {
|
|
386
|
-
level:
|
|
393
|
+
level: LEVEL_BY_TONE[tone] ?? "medium",
|
|
387
394
|
tone
|
|
388
395
|
},
|
|
389
396
|
body: <NoulBody question={question} probability={probability} />
|
|
390
397
|
};
|
|
391
398
|
},
|
|
392
|
-
|
|
393
|
-
let record =
|
|
399
|
+
describeRuleViolation(level, stored, title) {
|
|
400
|
+
let record = asRecordOfType(stored, TYPE_NAMES.noul);
|
|
394
401
|
if (!record || typeof record.probability != "number") return;
|
|
395
|
-
let
|
|
396
|
-
return
|
|
402
|
+
let violation = describeRangeViolation(record.probability, question[level], formatAsPercent);
|
|
403
|
+
return violation && `${title} is ${formatAsPercent(record.probability)}, ${violation}.`;
|
|
397
404
|
}
|
|
398
405
|
};
|
|
399
406
|
}
|
|
400
407
|
function NoulBody({ question, probability }) {
|
|
401
|
-
let { t } = useTranslation("jev"), percent =
|
|
408
|
+
let { t } = useTranslation("jev"), percent = formatAsPercent(probability);
|
|
402
409
|
return <Stack gap={4}>
|
|
403
410
|
<Flex align="baseline" gap={2}>
|
|
404
411
|
<Text size={4} weight="medium">
|
|
405
|
-
{percent}
|
|
412
|
+
{percent}
|
|
406
413
|
</Text>
|
|
407
414
|
{question.label && <Text size={1} muted>
|
|
408
415
|
{question.label}
|
|
409
416
|
</Text>}
|
|
410
417
|
</Flex>
|
|
411
|
-
<Bar fraction={probability} color={trafficColor(probability)} label={`${question.title ?? t("noul.yes")}: ${percent}
|
|
418
|
+
<Bar fraction={probability} color={trafficColor(probability)} label={`${question.title ?? t("noul.yes")}: ${percent}`} />
|
|
412
419
|
<Text size={1} muted>
|
|
413
420
|
{probability >= .5 ? question.true : question.false}
|
|
414
421
|
</Text>
|
|
@@ -444,109 +451,115 @@ const score = (question) => ({
|
|
|
444
451
|
type: "number",
|
|
445
452
|
readOnly: !0
|
|
446
453
|
}),
|
|
447
|
-
...
|
|
454
|
+
...evaluationFields
|
|
448
455
|
]
|
|
449
|
-
})],
|
|
456
|
+
})], getCriterionShortLabel = (criterion) => criterion?.split(":")[0].trim() ?? "";
|
|
450
457
|
/** `'anecdotal: personal experience'` → `'personal experience'`; without a colon, the whole text. */
|
|
451
|
-
function
|
|
458
|
+
function getCriterionMeaning(criterion) {
|
|
452
459
|
if (!criterion) return "";
|
|
453
|
-
let
|
|
454
|
-
return (
|
|
460
|
+
let colonIndex = criterion.indexOf(":");
|
|
461
|
+
return (colonIndex === -1 ? criterion : criterion.slice(colonIndex + 1)).trim();
|
|
455
462
|
}
|
|
456
463
|
/**
|
|
457
464
|
* How full each segment of a score bar is, 0–1. Segment `i` stands for reaching criterion
|
|
458
465
|
* `i`: a score of 0.8 fills the first segment and 80% of the second.
|
|
459
466
|
*/
|
|
460
|
-
function
|
|
461
|
-
return Array.from({ length:
|
|
467
|
+
function getSegmentFillFractions(score, segmentCount) {
|
|
468
|
+
return Array.from({ length: segmentCount }, (_, index) => Math.min(1, Math.max(0, score - index + 1)));
|
|
462
469
|
}
|
|
463
|
-
/**
|
|
464
|
-
function
|
|
470
|
+
/** From the criterion the score is nearest to. */
|
|
471
|
+
function getScoreColorAndTone(question, score, max) {
|
|
465
472
|
if (question.colors === "neutral") return {
|
|
466
473
|
color: NEUTRAL_COLOR,
|
|
467
474
|
tone: "primary"
|
|
468
475
|
};
|
|
469
|
-
let
|
|
476
|
+
let fractionOfMax = max > 0 ? Math.round(score) / max : 0, goodness = question.colors === "reverse" ? 1 - fractionOfMax : fractionOfMax;
|
|
470
477
|
return {
|
|
471
|
-
color: trafficColor(
|
|
472
|
-
tone: trafficTone(
|
|
478
|
+
color: trafficColor(goodness),
|
|
479
|
+
tone: trafficTone(goodness)
|
|
473
480
|
};
|
|
474
481
|
}
|
|
482
|
+
function findScoreConfigError(question) {
|
|
483
|
+
let { criteria } = question;
|
|
484
|
+
return !Array.isArray(criteria) || criteria.length < 2 ? "A score question needs at least two criteria." : criteria.length > 10 ? "A score question allows at most ten criteria." : findRangeRuleConfigError(question, 0, criteria.length - 1);
|
|
485
|
+
}
|
|
475
486
|
function bindScore(question) {
|
|
476
|
-
let
|
|
487
|
+
let configError = findScoreConfigError(question), gatewayQuestion = {
|
|
488
|
+
type: "score",
|
|
489
|
+
instructions: question.instructions,
|
|
490
|
+
criteria: question.criteria
|
|
491
|
+
};
|
|
492
|
+
/** E.g. `0.6 (anecdotal)`: a position on the scale and the criterion nearest to it. */
|
|
493
|
+
function describeScorePosition(position) {
|
|
494
|
+
let label = getCriterionShortLabel(question.criteria[Math.round(position)]), formattedPosition = Number.isInteger(position) ? String(position) : position.toFixed(1);
|
|
495
|
+
return label ? `${formattedPosition} (${label})` : formattedPosition;
|
|
496
|
+
}
|
|
477
497
|
return {
|
|
478
498
|
typeName: TYPE_NAMES.score,
|
|
479
|
-
gatewayQuestion:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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));
|
|
499
|
+
gatewayQuestion: configError ? void 0 : gatewayQuestion,
|
|
500
|
+
configError,
|
|
501
|
+
toStoredValue(answer) {
|
|
502
|
+
if (answer.type !== "score") return throwAnswerKindMismatch();
|
|
503
|
+
let max = question.criteria.length - 1, clampedScore = Math.min(max, Math.max(0, answer.score));
|
|
488
504
|
return {
|
|
489
505
|
_type: TYPE_NAMES.score,
|
|
490
|
-
score:
|
|
506
|
+
score: roundToDigits(clampedScore),
|
|
491
507
|
max,
|
|
492
|
-
label:
|
|
493
|
-
...typeof answer.confidence == "number" && { confidence:
|
|
508
|
+
label: getCriterionShortLabel(question.criteria[Math.round(clampedScore)]),
|
|
509
|
+
...typeof answer.confidence == "number" && { confidence: roundToDigits(answer.confidence) }
|
|
494
510
|
};
|
|
495
511
|
},
|
|
496
|
-
|
|
497
|
-
let record =
|
|
512
|
+
readStoredValue(stored) {
|
|
513
|
+
let record = asRecordOfType(stored, TYPE_NAMES.score);
|
|
498
514
|
if (!record || typeof record.score != "number" || typeof record.max != "number") return;
|
|
499
|
-
let { score
|
|
515
|
+
let { score, max } = record, label = stringOrUndefined(record.label), { color, tone } = getScoreColorAndTone(question, score, max), nearestCriterion = question.criteria?.[Math.round(score)];
|
|
500
516
|
return {
|
|
501
517
|
value: {
|
|
502
|
-
...
|
|
518
|
+
...readEvaluationFields(record),
|
|
503
519
|
_type: TYPE_NAMES.score,
|
|
504
|
-
score
|
|
520
|
+
score,
|
|
505
521
|
max,
|
|
506
522
|
label,
|
|
507
|
-
confidence:
|
|
523
|
+
confidence: numberOrUndefined(record.confidence)
|
|
508
524
|
},
|
|
509
525
|
chip: {
|
|
510
|
-
text: `${
|
|
511
|
-
color
|
|
526
|
+
text: `${score.toFixed(1)}/${max}`,
|
|
527
|
+
color
|
|
512
528
|
},
|
|
513
|
-
tone:
|
|
529
|
+
tone: tone === "critical" || tone === "caution" ? tone : "default",
|
|
514
530
|
aside: {
|
|
515
|
-
badge: capitalize(label ??
|
|
516
|
-
tone
|
|
531
|
+
badge: capitalize(label ?? getCriterionShortLabel(nearestCriterion)),
|
|
532
|
+
tone
|
|
517
533
|
},
|
|
518
|
-
body: <ScoreBody question={question} score={
|
|
534
|
+
body: <ScoreBody question={question} score={score} max={max} color={color} />
|
|
519
535
|
};
|
|
520
536
|
},
|
|
521
|
-
|
|
522
|
-
let record =
|
|
537
|
+
describeRuleViolation(level, stored, title) {
|
|
538
|
+
let record = asRecordOfType(stored, TYPE_NAMES.score);
|
|
523
539
|
if (!record || typeof record.score != "number") return;
|
|
524
|
-
let
|
|
525
|
-
|
|
526
|
-
return label ? `${number} (${label})` : number;
|
|
527
|
-
}, broken = rangeViolation(record.score, question[level], describe);
|
|
528
|
-
return broken && `${title} is ${describe(record.score)}, ${broken}.`;
|
|
540
|
+
let violation = describeRangeViolation(record.score, question[level], describeScorePosition);
|
|
541
|
+
return violation && `${title} is ${describeScorePosition(record.score)}, ${violation}.`;
|
|
529
542
|
}
|
|
530
543
|
};
|
|
531
544
|
}
|
|
532
545
|
function ScoreBody({ question, score, max, color }) {
|
|
533
|
-
let { t } = useTranslation("jev"),
|
|
546
|
+
let { t } = useTranslation("jev"), nearestIndex = Math.round(score), segmentFillFractions = getSegmentFillFractions(score, question.criteria.length), nextCriterion = question.criteria[nearestIndex + 1];
|
|
534
547
|
return <Stack gap={4}>
|
|
535
548
|
<Flex gap={1}>
|
|
536
549
|
{question.criteria.map((criterion, index) => <Stack key={index} gap={2} flex={1}>
|
|
537
|
-
<Bar fraction={
|
|
538
|
-
<Text size={1} weight={index ===
|
|
539
|
-
{index} · {capitalize(
|
|
550
|
+
<Bar fraction={segmentFillFractions[index]} color={color} label={`${index}: ${getCriterionShortLabel(criterion)}`} />
|
|
551
|
+
<Text size={1} weight={index === nearestIndex ? "medium" : "regular"} muted={index > nearestIndex} style={index < nearestIndex ? { color } : void 0} textOverflow="ellipsis">
|
|
552
|
+
{index} · {capitalize(getCriterionShortLabel(criterion))}
|
|
540
553
|
</Text>
|
|
541
554
|
</Stack>)}
|
|
542
555
|
</Flex>
|
|
543
556
|
<Text size={1} muted>
|
|
544
557
|
{t("score.summary", {
|
|
545
|
-
meaning: capitalize(
|
|
558
|
+
meaning: capitalize(getCriterionMeaning(question.criteria[nearestIndex])),
|
|
546
559
|
score: score.toFixed(1),
|
|
547
560
|
max
|
|
548
561
|
})}
|
|
549
|
-
{
|
|
562
|
+
{nextCriterion ? ` ${t("score.next", { next: getCriterionMeaning(nextCriterion) })}` : ""}
|
|
550
563
|
</Text>
|
|
551
564
|
</Stack>;
|
|
552
565
|
}
|
|
@@ -556,37 +569,36 @@ const KIND_SCHEMA_TYPES = [
|
|
|
556
569
|
...scoreSchemaTypes,
|
|
557
570
|
...choiceSchemaTypes
|
|
558
571
|
];
|
|
559
|
-
/**
|
|
560
|
-
function
|
|
561
|
-
let kind =
|
|
572
|
+
/** The one place that tells Noul, Score and Choice apart. */
|
|
573
|
+
function bindQuestionToKind(question) {
|
|
574
|
+
let kind = bindQuestionByType(question);
|
|
562
575
|
return question.instructions?.trim() ? kind : {
|
|
563
576
|
...kind,
|
|
564
577
|
gatewayQuestion: void 0,
|
|
565
|
-
|
|
578
|
+
configError: "Add instructions to this question."
|
|
566
579
|
};
|
|
567
580
|
}
|
|
568
|
-
function
|
|
581
|
+
function bindQuestionByType(question) {
|
|
569
582
|
switch (question.type) {
|
|
570
583
|
case "noul": return bindNoul(question);
|
|
571
584
|
case "score": return bindScore(question);
|
|
572
585
|
case "choice": return bindChoice(question);
|
|
573
586
|
default: return {
|
|
574
587
|
typeName: "unknown",
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
588
|
+
configError: "Unknown question type.",
|
|
589
|
+
toStoredValue: throwAnswerKindMismatch,
|
|
590
|
+
readStoredValue: () => void 0,
|
|
591
|
+
describeRuleViolation: () => void 0
|
|
579
592
|
};
|
|
580
593
|
}
|
|
581
594
|
}
|
|
582
|
-
/**
|
|
583
|
-
function
|
|
595
|
+
/** The question's own `title`, or one made from its key: `readingLevel` → `Reading level`. */
|
|
596
|
+
function getQuestionTitle(key, question) {
|
|
584
597
|
if (question.title) return question.title;
|
|
585
598
|
let words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ");
|
|
586
599
|
return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase();
|
|
587
600
|
}
|
|
588
|
-
|
|
589
|
-
function questionsOf(definition) {
|
|
601
|
+
function getQuestionsFromDefinition(definition) {
|
|
590
602
|
if (!isRecord(definition) || !isRecord(definition.options)) return;
|
|
591
603
|
let { jev } = definition.options;
|
|
592
604
|
return isRecord(jev) ? jev : void 0;
|
|
@@ -601,19 +613,19 @@ function questionsOf(definition) {
|
|
|
601
613
|
* @public
|
|
602
614
|
*/
|
|
603
615
|
function withJevAnswers(types) {
|
|
604
|
-
return types.map((type) =>
|
|
616
|
+
return types.map((type) => addAnswerFieldsRecursively(type));
|
|
605
617
|
}
|
|
606
|
-
/** Rewrites `fields` (and inline object members) of a type or field definition
|
|
607
|
-
function
|
|
618
|
+
/** Rewrites `fields` (and inline object members) of a type or field definition. */
|
|
619
|
+
function addAnswerFieldsRecursively(definition) {
|
|
608
620
|
if (!isRecord(definition)) return definition;
|
|
609
621
|
let result = definition;
|
|
610
622
|
if (Array.isArray(definition.of) && (result = {
|
|
611
623
|
...result,
|
|
612
|
-
of: definition.of.map(
|
|
624
|
+
of: definition.of.map(addAnswerFieldsRecursively)
|
|
613
625
|
}), Array.isArray(definition.fields)) {
|
|
614
|
-
let
|
|
615
|
-
let
|
|
616
|
-
return !questions || !isRecord(field) || !isRecord(
|
|
626
|
+
let existingFieldTypesByName = new Map(definition.fields.filter(isRecord).map((field) => [field.name, field.type])), fields = definition.fields.flatMap((field) => {
|
|
627
|
+
let rewrittenField = addAnswerFieldsRecursively(field), questions = getQuestionsFromDefinition(field);
|
|
628
|
+
return !questions || !isRecord(field) || !isRecord(rewrittenField) ? [rewrittenField] : [addRuleValidation(rewrittenField, questions), ...createAnswerFields(field, questions, existingFieldTypesByName)];
|
|
617
629
|
});
|
|
618
630
|
result = {
|
|
619
631
|
...result,
|
|
@@ -622,61 +634,66 @@ function withAnswerFields(definition) {
|
|
|
622
634
|
}
|
|
623
635
|
return result;
|
|
624
636
|
}
|
|
625
|
-
function
|
|
637
|
+
function createAnswerFields(attachedField, questions, existingFieldTypesByName) {
|
|
626
638
|
return Object.entries(questions).flatMap(([key, question]) => {
|
|
627
|
-
let
|
|
628
|
-
if (
|
|
629
|
-
if (
|
|
630
|
-
|
|
639
|
+
let answerType = TYPE_NAMES[question.type];
|
|
640
|
+
if (existingFieldTypesByName.get(key) === answerType) return [];
|
|
641
|
+
if (existingFieldTypesByName.has(key)) throw Error(`Jev: the question "${key}" on "${String(attachedField.name)}" needs a field called "${key}" to store its answer, but that name is already taken. Rename the question.`);
|
|
642
|
+
existingFieldTypesByName.set(key, answerType);
|
|
643
|
+
let { group, fieldset } = attachedField;
|
|
644
|
+
return [defineField({
|
|
631
645
|
name: key,
|
|
632
|
-
title:
|
|
633
|
-
type,
|
|
634
|
-
group: typeof
|
|
635
|
-
fieldset: typeof
|
|
646
|
+
title: getQuestionTitle(key, question),
|
|
647
|
+
type: answerType,
|
|
648
|
+
group: typeof group == "string" || Array.isArray(group) ? group : void 0,
|
|
649
|
+
fieldset: typeof fieldset == "string" ? fieldset : void 0
|
|
636
650
|
})];
|
|
637
651
|
});
|
|
638
652
|
}
|
|
639
|
-
|
|
653
|
+
/** `undefined` → `[]`, a value → `[value]`, an array as it is. */
|
|
654
|
+
function toArray(value) {
|
|
655
|
+
return Array.isArray(value) ? value : value ? [value] : [];
|
|
656
|
+
}
|
|
640
657
|
/**
|
|
641
658
|
* Adds a validation rule to the attached field for each question's `warn` and `require`, after
|
|
642
659
|
* the field's own validation. Rules judge the stored answer, so they say nothing until there is
|
|
643
660
|
* one.
|
|
644
661
|
*/
|
|
645
|
-
function
|
|
646
|
-
let
|
|
662
|
+
function addRuleValidation(attachedField, questions) {
|
|
663
|
+
let rulesToValidate = Object.entries(questions).flatMap(([key, question]) => RULE_LEVELS.filter((level) => question[level]).map((level) => ({
|
|
647
664
|
key,
|
|
648
665
|
question,
|
|
649
666
|
level
|
|
650
667
|
})));
|
|
651
|
-
if (
|
|
652
|
-
let
|
|
653
|
-
let ownRules = (
|
|
654
|
-
let
|
|
655
|
-
return Array.isArray(result) ? result : result ? [result] : [];
|
|
656
|
-
}), jevRules = checks.map(({ key, question, level }) => {
|
|
657
|
-
let kind = kindOf(question), title = titleOf(key, question), custom = rule.custom((_value, context) => {
|
|
668
|
+
if (rulesToValidate.length === 0) return attachedField;
|
|
669
|
+
let ownValidation = attachedField.validation, validation = (rule) => {
|
|
670
|
+
let ownRules = toArray(ownValidation).flatMap((entry) => toArray(typeof entry == "function" ? entry(rule) : entry)), jevRules = rulesToValidate.map(({ key, question, level }) => {
|
|
671
|
+
let kind = bindQuestionToKind(question), title = getQuestionTitle(key, question), customRule = rule.custom((_value, context) => {
|
|
658
672
|
let stored = isRecord(context.parent) ? context.parent[key] : void 0;
|
|
659
|
-
return kind.
|
|
673
|
+
return kind.describeRuleViolation(level, stored, title) ?? !0;
|
|
660
674
|
});
|
|
661
|
-
return level === "require" ?
|
|
675
|
+
return level === "require" ? customRule.error() : customRule.warning();
|
|
662
676
|
});
|
|
663
677
|
return [...ownRules, ...jevRules];
|
|
664
678
|
};
|
|
665
679
|
return {
|
|
666
|
-
...
|
|
680
|
+
...attachedField,
|
|
667
681
|
validation
|
|
668
682
|
};
|
|
669
683
|
}
|
|
670
|
-
const
|
|
684
|
+
const SECRETS_DOCUMENT_ID = "secrets.jev";
|
|
685
|
+
function readApiKeyFromSecrets(secrets) {
|
|
686
|
+
let apiKey = secrets && typeof secrets == "object" && "gatewayApiKey" in secrets ? secrets.gatewayApiKey : void 0;
|
|
687
|
+
return typeof apiKey == "string" && apiKey ? apiKey : void 0;
|
|
688
|
+
}
|
|
671
689
|
/** The AI Gateway key stored in `secrets.jev`, kept up to date as it changes. */
|
|
672
690
|
function useStoredKey() {
|
|
673
691
|
let documentPreviewStore = useDocumentPreviewStore(), [stored, setStored] = useState({ loading: !0 });
|
|
674
692
|
return useEffect(() => {
|
|
675
|
-
let subscription = documentPreviewStore.unstable_observeDocument(
|
|
676
|
-
let secrets = document?.secrets, apiKey = secrets && typeof secrets == "object" && "gatewayApiKey" in secrets ? secrets.gatewayApiKey : void 0;
|
|
693
|
+
let subscription = documentPreviewStore.unstable_observeDocument(SECRETS_DOCUMENT_ID).subscribe((document) => {
|
|
677
694
|
setStored({
|
|
678
695
|
loading: !1,
|
|
679
|
-
apiKey:
|
|
696
|
+
apiKey: readApiKeyFromSecrets(document?.secrets),
|
|
680
697
|
updatedAt: document?._updatedAt
|
|
681
698
|
});
|
|
682
699
|
});
|
|
@@ -688,15 +705,17 @@ function useSaveKey() {
|
|
|
688
705
|
let client = useClient({ apiVersion: "2025-02-19" });
|
|
689
706
|
return async (apiKey) => {
|
|
690
707
|
await client.transaction().createIfNotExists({
|
|
691
|
-
_id:
|
|
708
|
+
_id: SECRETS_DOCUMENT_ID,
|
|
692
709
|
_type: "pluginSecrets"
|
|
693
|
-
}).patch(
|
|
710
|
+
}).patch(SECRETS_DOCUMENT_ID, (patch) => apiKey ? patch.set({ secrets: { gatewayApiKey: apiKey } }) : patch.unset(["secrets"])).commit({ tag: "jev.secrets" });
|
|
694
711
|
};
|
|
695
712
|
}
|
|
713
|
+
/** With a `transport` or `apiKey` in the plugin config, the stored key is ignored. */
|
|
714
|
+
const isKeyFromConfig = (config) => !!(config.transport || config.apiKey);
|
|
696
715
|
/** Where the API key comes from: plugin config wins over the stored key. */
|
|
697
716
|
function useKeySource(config) {
|
|
698
717
|
let stored = useStoredKey();
|
|
699
|
-
return config
|
|
718
|
+
return isKeyFromConfig(config) ? {
|
|
700
719
|
from: "config",
|
|
701
720
|
apiKey: config.apiKey
|
|
702
721
|
} : {
|
|
@@ -743,8 +762,8 @@ function JevKeyDialog({ onClose }) {
|
|
|
743
762
|
</Box>
|
|
744
763
|
</Dialog>;
|
|
745
764
|
}
|
|
746
|
-
/**
|
|
747
|
-
function
|
|
765
|
+
/** Errors from outside the Gateway are shown as they are. */
|
|
766
|
+
function translateQuestionError(t, error) {
|
|
748
767
|
switch (error.kind) {
|
|
749
768
|
case "auth": return t("error.auth");
|
|
750
769
|
case "invalid": return t("error.invalid", { detail: error.detail ?? error.status });
|
|
@@ -760,7 +779,7 @@ function errorText(t, error) {
|
|
|
760
779
|
const pulse = keyframes`
|
|
761
780
|
0%, 100% { opacity: 1; }
|
|
762
781
|
50% { opacity: 0.25; }
|
|
763
|
-
`,
|
|
782
|
+
`, ChipStatusDot = styled.span`
|
|
764
783
|
flex: none;
|
|
765
784
|
width: 6px;
|
|
766
785
|
height: 6px;
|
|
@@ -768,11 +787,11 @@ const pulse = keyframes`
|
|
|
768
787
|
background: ${({ $color }) => $color};
|
|
769
788
|
animation: ${({ $pulse }) => $pulse ? pulse : "none"} 1s ease-in-out infinite;
|
|
770
789
|
`;
|
|
771
|
-
function
|
|
790
|
+
function QuestionChip({ question, selected, onSelect }) {
|
|
772
791
|
let { t } = useTranslation("jev"), { text, color } = question.reading?.chip ?? {
|
|
773
792
|
text: t("chip.empty"),
|
|
774
793
|
color: void 0
|
|
775
|
-
},
|
|
794
|
+
}, hasError = !!question.error || !!question.configError;
|
|
776
795
|
return <Card as="button" type="button" radius={2} paddingX={2} paddingY={2} onClick={onSelect} aria-pressed={selected} title={question.stale ? t("chip.out-of-date") : void 0} style={{
|
|
777
796
|
display: "inline-flex",
|
|
778
797
|
flex: "none",
|
|
@@ -782,24 +801,24 @@ function Chip({ question, selected, onSelect }) {
|
|
|
782
801
|
background: selected ? "rgba(128, 128, 128, 0.16)" : void 0
|
|
783
802
|
}}>
|
|
784
803
|
<Flex align="center" gap={2}>
|
|
785
|
-
<
|
|
804
|
+
<ChipStatusDot aria-hidden $color={hasError ? ERROR_COLOR : color ?? "hsl(215 12% 55%)"} $pulse={question.loading} />
|
|
786
805
|
{}
|
|
787
806
|
<Text size={1}>
|
|
788
807
|
{question.title}
|
|
789
808
|
<span style={{
|
|
790
809
|
marginLeft: "0.6em",
|
|
791
810
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
792
|
-
color:
|
|
811
|
+
color: hasError ? ERROR_COLOR : color,
|
|
793
812
|
opacity: question.stale ? .5 : 1
|
|
794
813
|
}}>
|
|
795
|
-
{
|
|
814
|
+
{hasError ? t("chip.error") : text}
|
|
796
815
|
</span>
|
|
797
816
|
</Text>
|
|
798
817
|
</Flex>
|
|
799
818
|
</Card>;
|
|
800
819
|
}
|
|
801
820
|
/** The row of question chips under an attached field. */
|
|
802
|
-
function QuestionStrip({ questions,
|
|
821
|
+
function QuestionStrip({ questions, selectedKey, onSelect, setupStatus, canRun, loading, onEvaluateAll, onSetUp }) {
|
|
803
822
|
let { t } = useTranslation("jev");
|
|
804
823
|
return <Card border padding={1} style={{
|
|
805
824
|
borderTop: 0,
|
|
@@ -809,27 +828,38 @@ function QuestionStrip({ questions, selected, onSelect, setup, canRun, loading,
|
|
|
809
828
|
}}>
|
|
810
829
|
<Flex align="center" gap={2}>
|
|
811
830
|
<Flex flex={1} align="center" gap={1} wrap="wrap">
|
|
812
|
-
{questions.map((question) =>
|
|
831
|
+
{questions.map((question) => {
|
|
832
|
+
let isSelected = selectedKey === question.key;
|
|
833
|
+
return <QuestionChip key={question.key} question={question} selected={isSelected} onSelect={() => onSelect(isSelected ? null : question.key)} />;
|
|
834
|
+
})}
|
|
813
835
|
</Flex>
|
|
814
|
-
{
|
|
836
|
+
{setupStatus === "missing" ? <Button text={t("strip.set-up")} mode="ghost" tone="primary" fontSize={1} padding={2} onClick={onSetUp} /> : <Flex align="center" gap={1}>
|
|
815
837
|
<Text size={0} muted>
|
|
816
838
|
{t("strip.label")}
|
|
817
839
|
</Text>
|
|
818
840
|
{loading ? <Box padding={2}>
|
|
819
841
|
<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={
|
|
842
|
+
</Box> : <Button icon={RefreshIcon} mode="bleed" fontSize={1} padding={2} title={t("strip.evaluate-all")} aria-label={t("strip.evaluate-all")} disabled={!canRun} onClick={onEvaluateAll} />}
|
|
821
843
|
</Flex>}
|
|
822
844
|
</Flex>
|
|
823
845
|
</Card>;
|
|
824
846
|
}
|
|
847
|
+
function QuestionDetailBody({ question, empty }) {
|
|
848
|
+
let { t } = useTranslation("jev");
|
|
849
|
+
return question.configError ? <Text size={1}>{question.configError}</Text> : question.reading ? question.reading.body : <Text size={1} muted>
|
|
850
|
+
{t(empty ? "detail.empty-field" : "detail.not-evaluated")}
|
|
851
|
+
</Text>;
|
|
852
|
+
}
|
|
853
|
+
function QuestionDetailAside({ aside }) {
|
|
854
|
+
let { t } = useTranslation("jev");
|
|
855
|
+
return "badge" in aside ? <Badge tone={aside.tone}>{aside.badge}</Badge> : "level" in aside ? <Badge tone={aside.tone}>{t(`noul.level.${aside.level}`)}</Badge> : <Text size={1} muted>
|
|
856
|
+
{aside.note}
|
|
857
|
+
</Text>;
|
|
858
|
+
}
|
|
825
859
|
/** Expanded view of the selected chip. */
|
|
826
860
|
function QuestionDetail({ question, empty, onRetry, onUpdateKey }) {
|
|
827
|
-
let { t } = useTranslation("jev"),
|
|
828
|
-
|
|
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={question.problem ? "critical" : reading?.tone ?? "default"}>
|
|
861
|
+
let { t } = useTranslation("jev"), aside = question.reading?.aside, tone = question.configError ? "critical" : question.reading?.tone ?? "default";
|
|
862
|
+
return <Card padding={4} radius={2} border tone={tone}>
|
|
833
863
|
<Stack gap={4}>
|
|
834
864
|
<Flex align="center" gap={3}>
|
|
835
865
|
<Box flex={1}>
|
|
@@ -837,16 +867,16 @@ function QuestionDetail({ question, empty, onRetry, onUpdateKey }) {
|
|
|
837
867
|
{question.title}
|
|
838
868
|
</Text>
|
|
839
869
|
</Box>
|
|
840
|
-
{aside}
|
|
870
|
+
{aside && <QuestionDetailAside aside={aside} />}
|
|
841
871
|
</Flex>
|
|
842
|
-
{
|
|
872
|
+
<QuestionDetailBody question={question} empty={empty} />
|
|
843
873
|
{question.stale && !question.loading && <Text size={0} muted>
|
|
844
874
|
{t("detail.out-of-date")}
|
|
845
875
|
</Text>}
|
|
846
876
|
{question.error && <Card padding={3} radius={2} tone="critical" border>
|
|
847
877
|
<Flex align="center" gap={3} wrap="wrap">
|
|
848
878
|
<Box flex={1}>
|
|
849
|
-
<Text size={1}>{
|
|
879
|
+
<Text size={1}>{translateQuestionError(t, question.error)}</Text>
|
|
850
880
|
</Box>
|
|
851
881
|
{question.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
882
|
</Flex>
|
|
@@ -905,7 +935,7 @@ var JevError = class extends Error {
|
|
|
905
935
|
}
|
|
906
936
|
};
|
|
907
937
|
/** The default transport: calls AI Gateway directly from the browser with the API key. */
|
|
908
|
-
function
|
|
938
|
+
function createGatewayTransport(apiKey, endpoint = "https://ai-gateway.vercel.sh/v1/evaluate") {
|
|
909
939
|
return (request, { signal }) => fetch(endpoint, {
|
|
910
940
|
method: "POST",
|
|
911
941
|
signal,
|
|
@@ -916,6 +946,11 @@ function gatewayTransport(apiKey, endpoint = "https://ai-gateway.vercel.sh/v1/ev
|
|
|
916
946
|
body: JSON.stringify(request)
|
|
917
947
|
});
|
|
918
948
|
}
|
|
949
|
+
/** A `transport` in the plugin config wins; otherwise the API key, if there is one. */
|
|
950
|
+
function resolveTransport(config, apiKey) {
|
|
951
|
+
if (config.transport) return config.transport;
|
|
952
|
+
if (apiKey) return createGatewayTransport(apiKey, config.endpoint);
|
|
953
|
+
}
|
|
919
954
|
/**
|
|
920
955
|
* Asks the decision model a single typed question about the state. Resolves with the answer
|
|
921
956
|
* and the model that gave it, as the Gateway names it.
|
|
@@ -926,7 +961,7 @@ async function evaluateQuestion({ transport, model, state, question, tags, signa
|
|
|
926
961
|
state,
|
|
927
962
|
questions: { q: question },
|
|
928
963
|
providerOptions: { gateway: { tags: normalizeTags(tags) } }
|
|
929
|
-
},
|
|
964
|
+
}, sendWithRetries = async (attempt) => {
|
|
930
965
|
let response = await transport(request, { signal }), body = await response.json().catch(() => ({}));
|
|
931
966
|
if (response.ok) {
|
|
932
967
|
let answer = body.answers?.q;
|
|
@@ -936,21 +971,21 @@ async function evaluateQuestion({ transport, model, state, question, tags, signa
|
|
|
936
971
|
model: body.model ?? model
|
|
937
972
|
};
|
|
938
973
|
}
|
|
939
|
-
if (RETRY_STATUSES.has(response.status) && attempt < retryDelays.length) return await sleep(
|
|
940
|
-
throw
|
|
974
|
+
if (RETRY_STATUSES.has(response.status) && attempt < retryDelays.length) return await sleep(getRetryDelayMs(response, retryDelays[attempt]), signal), sendWithRetries(attempt + 1);
|
|
975
|
+
throw toJevError(response.status, readGatewayErrorMessage(body));
|
|
941
976
|
};
|
|
942
|
-
return
|
|
977
|
+
return sendWithRetries(0);
|
|
943
978
|
}
|
|
944
|
-
function
|
|
979
|
+
function toJevError(status, message) {
|
|
945
980
|
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
981
|
}
|
|
947
|
-
function
|
|
982
|
+
function readGatewayErrorMessage(body) {
|
|
948
983
|
return (typeof body.error == "string" ? body.error : body.error?.message) || void 0;
|
|
949
984
|
}
|
|
950
985
|
/** Honours a short `Retry-After` (in seconds) when it asks for longer than our own backoff. */
|
|
951
|
-
function
|
|
952
|
-
let
|
|
953
|
-
return !Number.isFinite(
|
|
986
|
+
function getRetryDelayMs(response, backoffMs) {
|
|
987
|
+
let retryAfterSeconds = Number(response.headers.get("retry-after"));
|
|
988
|
+
return !Number.isFinite(retryAfterSeconds) || retryAfterSeconds <= 0 ? backoffMs : Math.min(Math.max(backoffMs, retryAfterSeconds * 1e3), 1e4);
|
|
954
989
|
}
|
|
955
990
|
function sleep(ms, signal) {
|
|
956
991
|
return new Promise((resolve, reject) => {
|
|
@@ -962,8 +997,8 @@ function sleep(ms, signal) {
|
|
|
962
997
|
});
|
|
963
998
|
}
|
|
964
999
|
function normalizeTags(tags) {
|
|
965
|
-
let
|
|
966
|
-
return [...new Set(
|
|
1000
|
+
let trimmedTags = tags.map((tag) => tag.trim().slice(0, 64)).filter(Boolean);
|
|
1001
|
+
return [...new Set(trimmedTags)].slice(0, 10);
|
|
967
1002
|
}
|
|
968
1003
|
const realClock = {
|
|
969
1004
|
now: () => /* @__PURE__ */ new Date(),
|
|
@@ -971,7 +1006,16 @@ const realClock = {
|
|
|
971
1006
|
let timeout = setTimeout(callback, ms);
|
|
972
1007
|
return () => clearTimeout(timeout);
|
|
973
1008
|
}
|
|
974
|
-
}
|
|
1009
|
+
};
|
|
1010
|
+
function toQuestionError(error) {
|
|
1011
|
+
return error instanceof JevError ? {
|
|
1012
|
+
message: error.message,
|
|
1013
|
+
kind: error.kind,
|
|
1014
|
+
status: error.status,
|
|
1015
|
+
detail: error.detail
|
|
1016
|
+
} : { message: error instanceof Error ? error.message : String(error) };
|
|
1017
|
+
}
|
|
1018
|
+
const EMPTY_SNAPSHOT = {
|
|
975
1019
|
questions: [],
|
|
976
1020
|
canRun: !1,
|
|
977
1021
|
empty: !0,
|
|
@@ -983,129 +1027,125 @@ const realClock = {
|
|
|
983
1027
|
* a fingerprint that has since changed is cancelled and its answer dropped.
|
|
984
1028
|
*/
|
|
985
1029
|
function createLifecycle(clock = realClock) {
|
|
986
|
-
let inputs,
|
|
1030
|
+
let inputs, boundQuestions = [], stateFromLastLocalEdit, nextStateIsFromLocalEdit = !1, requestsInFlight = /* @__PURE__ */ new Map(), evaluationStatuses = /* @__PURE__ */ new Map(), lastAttemptedHashes = /* @__PURE__ */ new Map(), scheduledEvaluation, snapshot = EMPTY_SNAPSHOT, snapshotIsOutdated = !0, listeners = /* @__PURE__ */ new Set(), canRun = (current) => !!current.transport && !current.readOnly && current.state.trim() !== "", getHashesByKey = () => new Map(boundQuestions.map(({ key, hash }) => [key, hash]));
|
|
987
1031
|
function update(next) {
|
|
988
1032
|
let previous = inputs;
|
|
989
|
-
inputs = next, next.state !== previous?.state && (
|
|
990
|
-
let kind =
|
|
1033
|
+
inputs = next, next.state !== previous?.state && (stateFromLastLocalEdit = nextStateIsFromLocalEdit ? next.state : void 0, nextStateIsFromLocalEdit = !1), (!previous || next.questions !== previous.questions || next.state !== previous.state || next.model !== previous.model) && (boundQuestions = Object.entries(next.questions).map(([key, question]) => {
|
|
1034
|
+
let kind = bindQuestionToKind(question);
|
|
991
1035
|
return {
|
|
992
1036
|
key,
|
|
993
1037
|
question,
|
|
994
1038
|
kind,
|
|
995
|
-
title:
|
|
1039
|
+
title: getQuestionTitle(key, question),
|
|
996
1040
|
hash: fingerprint([
|
|
997
1041
|
next.state,
|
|
998
1042
|
kind.gatewayQuestion ?? null,
|
|
999
1043
|
next.model
|
|
1000
1044
|
])
|
|
1001
1045
|
};
|
|
1002
|
-
}),
|
|
1046
|
+
}), cancelWorkForChangedHashes(), snapshotIsOutdated = !0);
|
|
1047
|
+
let canRunChanged = previous && canRun(next) !== canRun(previous), answersChanged = previous && boundQuestions.some(({ key }) => next.answers[key] !== previous.answers[key]);
|
|
1048
|
+
(canRunChanged || answersChanged) && (snapshotIsOutdated = !0), rebuildSnapshotIfOutdated();
|
|
1003
1049
|
}
|
|
1004
|
-
function
|
|
1005
|
-
let
|
|
1006
|
-
for (let [key, request] of
|
|
1007
|
-
for (let [key, status] of
|
|
1008
|
-
for (let [key, hash] of
|
|
1050
|
+
function cancelWorkForChangedHashes() {
|
|
1051
|
+
let hashesByKey = getHashesByKey();
|
|
1052
|
+
for (let [key, request] of requestsInFlight) hashesByKey.get(key) !== request.hash && (request.controller.abort(), requestsInFlight.delete(key));
|
|
1053
|
+
for (let [key, status] of evaluationStatuses) hashesByKey.get(key) !== status.hash && evaluationStatuses.delete(key);
|
|
1054
|
+
for (let [key, hash] of lastAttemptedHashes) hashesByKey.get(key) !== hash && lastAttemptedHashes.delete(key);
|
|
1009
1055
|
}
|
|
1010
|
-
/**
|
|
1011
|
-
function
|
|
1012
|
-
return !canRun(current) ||
|
|
1013
|
-
let view = views[index];
|
|
1014
|
-
return
|
|
1056
|
+
/** Unanswered or stale, and not tried yet for the current fingerprint. */
|
|
1057
|
+
function getKeysToEvaluateAutomatically(current, views) {
|
|
1058
|
+
return !canRun(current) || stateFromLastLocalEdit !== current.state ? [] : boundQuestions.filter((boundQuestion, index) => {
|
|
1059
|
+
let view = views[index], needsAnswer = !view.reading || view.stale, alreadyAttempted = lastAttemptedHashes.get(boundQuestion.key) === boundQuestion.hash;
|
|
1060
|
+
return boundQuestion.kind.gatewayQuestion && needsAnswer && !alreadyAttempted;
|
|
1015
1061
|
}).map(({ key }) => key);
|
|
1016
1062
|
}
|
|
1017
|
-
|
|
1018
|
-
function refresh() {
|
|
1063
|
+
function rebuildSnapshotIfOutdated() {
|
|
1019
1064
|
let current = inputs;
|
|
1020
|
-
if (!current || !
|
|
1021
|
-
|
|
1022
|
-
let views =
|
|
1023
|
-
let reading =
|
|
1065
|
+
if (!current || !snapshotIsOutdated) return;
|
|
1066
|
+
snapshotIsOutdated = !1;
|
|
1067
|
+
let views = boundQuestions.map((boundQuestion) => {
|
|
1068
|
+
let reading = boundQuestion.kind.readStoredValue(current.answers[boundQuestion.key]), status = evaluationStatuses.get(boundQuestion.key);
|
|
1024
1069
|
return {
|
|
1025
|
-
key:
|
|
1026
|
-
question:
|
|
1027
|
-
title:
|
|
1028
|
-
|
|
1070
|
+
key: boundQuestion.key,
|
|
1071
|
+
question: boundQuestion.question,
|
|
1072
|
+
title: boundQuestion.title,
|
|
1073
|
+
configError: boundQuestion.kind.configError,
|
|
1029
1074
|
reading,
|
|
1030
|
-
stale: !!reading && reading.value.sourceHash !==
|
|
1075
|
+
stale: !!reading && reading.value.sourceHash !== boundQuestion.hash,
|
|
1031
1076
|
loading: status?.state === "loading",
|
|
1032
1077
|
error: status?.state === "error" ? status.error : null,
|
|
1033
1078
|
keyRejected: status?.state === "error" && status.error.kind === "auth"
|
|
1034
1079
|
};
|
|
1035
|
-
}),
|
|
1036
|
-
for (let view of views)
|
|
1080
|
+
}), keysToEvaluate = getKeysToEvaluateAutomatically(current, views);
|
|
1081
|
+
for (let view of views) keysToEvaluate.includes(view.key) && (view.loading = !0);
|
|
1037
1082
|
snapshot = {
|
|
1038
1083
|
questions: views,
|
|
1039
1084
|
canRun: canRun(current),
|
|
1040
1085
|
empty: current.state.trim() === "",
|
|
1041
1086
|
loading: views.some((view) => view.loading)
|
|
1042
|
-
},
|
|
1087
|
+
}, scheduleEvaluationAfterDebounce(keysToEvaluate, current.debounceMs);
|
|
1043
1088
|
for (let listener of listeners) listener();
|
|
1044
1089
|
}
|
|
1045
|
-
function
|
|
1046
|
-
let
|
|
1047
|
-
signature !==
|
|
1090
|
+
function scheduleEvaluationAfterDebounce(keys, debounceMs) {
|
|
1091
|
+
let hashesByKey = getHashesByKey(), signature = keys.map((key) => `${key}:${hashesByKey.get(key)}`).join("\n");
|
|
1092
|
+
signature !== scheduledEvaluation?.signature && (scheduledEvaluation?.cancel(), scheduledEvaluation = void 0, signature && (scheduledEvaluation = {
|
|
1048
1093
|
signature,
|
|
1049
1094
|
cancel: clock.after(debounceMs, () => run(keys))
|
|
1050
1095
|
}));
|
|
1051
1096
|
}
|
|
1052
|
-
function
|
|
1053
|
-
status ?
|
|
1097
|
+
function setEvaluationStatus(key, status) {
|
|
1098
|
+
status ? evaluationStatuses.set(key, status) : evaluationStatuses.delete(key), snapshotIsOutdated = !0, rebuildSnapshotIfOutdated();
|
|
1054
1099
|
}
|
|
1055
1100
|
function run(keys) {
|
|
1056
1101
|
let current = inputs;
|
|
1057
1102
|
if (!current?.transport || !canRun(current)) return;
|
|
1058
1103
|
let { transport, model, state } = current;
|
|
1059
|
-
for (let
|
|
1060
|
-
let gatewayQuestion =
|
|
1061
|
-
if (keys && !keys.includes(
|
|
1062
|
-
|
|
1104
|
+
for (let boundQuestion of boundQuestions) {
|
|
1105
|
+
let { key, kind, hash } = boundQuestion, gatewayQuestion = kind.gatewayQuestion;
|
|
1106
|
+
if (keys && !keys.includes(key) || !gatewayQuestion) continue;
|
|
1107
|
+
requestsInFlight.get(key)?.controller.abort();
|
|
1063
1108
|
let controller = new AbortController();
|
|
1064
|
-
|
|
1109
|
+
requestsInFlight.set(key, {
|
|
1065
1110
|
controller,
|
|
1066
|
-
hash
|
|
1067
|
-
}),
|
|
1111
|
+
hash
|
|
1112
|
+
}), lastAttemptedHashes.set(key, hash), setEvaluationStatus(key, {
|
|
1068
1113
|
state: "loading",
|
|
1069
|
-
hash
|
|
1114
|
+
hash
|
|
1070
1115
|
});
|
|
1071
|
-
let
|
|
1116
|
+
let fieldPath = [...current.tagPath, key].filter(Boolean).join("."), questionTag = `${kind.typeName}:${fieldPath}`, evaluateAndStore = async () => {
|
|
1072
1117
|
try {
|
|
1073
1118
|
let result = await evaluateQuestion({
|
|
1074
1119
|
transport,
|
|
1075
1120
|
model,
|
|
1076
1121
|
state,
|
|
1077
1122
|
question: gatewayQuestion,
|
|
1078
|
-
tags: [...current.tags,
|
|
1123
|
+
tags: [...current.tags, questionTag],
|
|
1079
1124
|
signal: controller.signal
|
|
1080
1125
|
});
|
|
1081
1126
|
if (controller.signal.aborted) return;
|
|
1082
|
-
inputs?.store(
|
|
1083
|
-
...
|
|
1127
|
+
inputs?.store(key, {
|
|
1128
|
+
...kind.toStoredValue(result.answer),
|
|
1084
1129
|
evaluatedAt: clock.now().toISOString(),
|
|
1085
1130
|
model: result.model,
|
|
1086
|
-
sourceHash:
|
|
1087
|
-
}),
|
|
1131
|
+
sourceHash: hash
|
|
1132
|
+
}), requestsInFlight.delete(key), setEvaluationStatus(key, void 0);
|
|
1088
1133
|
} catch (error) {
|
|
1089
1134
|
if (controller.signal.aborted) return;
|
|
1090
|
-
|
|
1135
|
+
requestsInFlight.delete(key), setEvaluationStatus(key, {
|
|
1091
1136
|
state: "error",
|
|
1092
|
-
hash
|
|
1093
|
-
error: error
|
|
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) }
|
|
1137
|
+
hash,
|
|
1138
|
+
error: toQuestionError(error)
|
|
1099
1139
|
});
|
|
1100
1140
|
}
|
|
1101
1141
|
};
|
|
1102
|
-
|
|
1142
|
+
evaluateAndStore();
|
|
1103
1143
|
}
|
|
1104
1144
|
}
|
|
1105
1145
|
return {
|
|
1106
1146
|
update,
|
|
1107
1147
|
localEdit() {
|
|
1108
|
-
|
|
1148
|
+
nextStateIsFromLocalEdit = !0;
|
|
1109
1149
|
},
|
|
1110
1150
|
run,
|
|
1111
1151
|
subscribe(listener) {
|
|
@@ -1115,29 +1155,35 @@ function createLifecycle(clock = realClock) {
|
|
|
1115
1155
|
},
|
|
1116
1156
|
getSnapshot: () => snapshot,
|
|
1117
1157
|
dispose() {
|
|
1118
|
-
|
|
1119
|
-
for (let [key, request] of
|
|
1120
|
-
|
|
1158
|
+
scheduledEvaluation?.cancel(), scheduledEvaluation = void 0;
|
|
1159
|
+
for (let [key, request] of requestsInFlight) request.controller.abort(), evaluationStatuses.delete(key), lastAttemptedHashes.delete(key);
|
|
1160
|
+
requestsInFlight.clear(), snapshotIsOutdated = !0;
|
|
1121
1161
|
}
|
|
1122
1162
|
};
|
|
1123
1163
|
}
|
|
1124
1164
|
const NO_ANSWERS = {};
|
|
1165
|
+
function getSetupStatus(hasTransport, keySource) {
|
|
1166
|
+
return hasTransport ? "ready" : keySource.from === "secrets" && keySource.loading ? "loading" : "missing";
|
|
1167
|
+
}
|
|
1125
1168
|
/** Connects the Evaluation lifecycle of one attached field to the Studio's form. */
|
|
1126
1169
|
function useQuestions({ questions, path, state, readOnly }) {
|
|
1127
|
-
let { config,
|
|
1170
|
+
let { config, answerWriters } = useJevForm(), parentPath = useMemo(() => path.slice(0, -1), [path]), keySource = useKeySource(config), { apiKey } = keySource, transport = useMemo(() => resolveTransport({
|
|
1171
|
+
transport: config.transport,
|
|
1172
|
+
endpoint: config.endpoint
|
|
1173
|
+
}, apiKey), [
|
|
1128
1174
|
config.transport,
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
]),
|
|
1132
|
-
let
|
|
1133
|
-
if (!
|
|
1134
|
-
|
|
1135
|
-
}, [
|
|
1175
|
+
config.endpoint,
|
|
1176
|
+
apiKey
|
|
1177
|
+
]), parent = useFormValue(parentPath), documentType = useFormValue(["_type"]), store = useCallback((key, value) => {
|
|
1178
|
+
let writeAnswer = answerWriters.get(toPathKey([...parentPath, key]));
|
|
1179
|
+
if (!writeAnswer) throw Error(`There is no "${key}" field to store this answer in. Wrap your schema types with withJevAnswers() in sanity.config.`);
|
|
1180
|
+
writeAnswer(value);
|
|
1181
|
+
}, [answerWriters, parentPath]), tagPath = useMemo(() => [typeof documentType == "string" ? documentType : "", ...parentPath.map((segment) => typeof segment == "string" ? segment : "[]")], [documentType, parentPath]), [lifecycle] = useState(() => createLifecycle());
|
|
1136
1182
|
return useLayoutEffect(() => {
|
|
1137
1183
|
lifecycle.update({
|
|
1138
1184
|
questions,
|
|
1139
1185
|
state,
|
|
1140
|
-
answers: isRecord
|
|
1186
|
+
answers: isRecord(parent) ? parent : NO_ANSWERS,
|
|
1141
1187
|
transport,
|
|
1142
1188
|
store,
|
|
1143
1189
|
model: config.model ?? "typesafe-ai/jev",
|
|
@@ -1148,17 +1194,17 @@ function useQuestions({ questions, path, state, readOnly }) {
|
|
|
1148
1194
|
});
|
|
1149
1195
|
}), useEffect(() => () => lifecycle.dispose(), [lifecycle]), {
|
|
1150
1196
|
...useSyncExternalStore(lifecycle.subscribe, lifecycle.getSnapshot),
|
|
1151
|
-
|
|
1152
|
-
/**
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1197
|
+
setupStatus: getSetupStatus(!!transport, keySource),
|
|
1198
|
+
/** Editors can set and change the key here, since it is stored with Studio secrets. */
|
|
1199
|
+
isKeyStoredInSecrets: keySource.from === "secrets",
|
|
1200
|
+
evaluateNow: (key) => lifecycle.run([key]),
|
|
1201
|
+
evaluateAllNow: () => lifecycle.run(),
|
|
1156
1202
|
localEdit: lifecycle.localEdit
|
|
1157
1203
|
};
|
|
1158
1204
|
}
|
|
1159
1205
|
/** Renders a field with `options.jev` as usual, with its question strip and details below. */
|
|
1160
1206
|
function AttachedInput({ questions, ...props }) {
|
|
1161
|
-
let { t } = useTranslation("jev"), parentOnChange = props.onChange,
|
|
1207
|
+
let { t } = useTranslation("jev"), parentOnChange = props.onChange, state = flattenToText(props.value), jev = useQuestions({
|
|
1162
1208
|
questions,
|
|
1163
1209
|
path: props.path,
|
|
1164
1210
|
state,
|
|
@@ -1168,60 +1214,77 @@ function AttachedInput({ questions, ...props }) {
|
|
|
1168
1214
|
}, inputProps = {
|
|
1169
1215
|
...props,
|
|
1170
1216
|
onChange
|
|
1171
|
-
}
|
|
1217
|
+
};
|
|
1218
|
+
if ("elementProps" in inputProps && "onChange" in inputProps.elementProps) {
|
|
1219
|
+
let elementProps = inputProps.elementProps;
|
|
1220
|
+
inputProps.elementProps = {
|
|
1221
|
+
...elementProps,
|
|
1222
|
+
onChange: (event) => {
|
|
1223
|
+
jev.localEdit(), elementProps.onChange(event);
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
let [selectedKey, setSelectedKey] = useState(null), [keyDialogOpen, setKeyDialogOpen] = useState(!1), selectedQuestion = jev.questions.find((question) => question.key === selectedKey);
|
|
1172
1228
|
return <Stack gap={2}>
|
|
1173
1229
|
<div>
|
|
1174
1230
|
{props.renderDefault(inputProps)}
|
|
1175
|
-
<QuestionStrip questions={jev.questions}
|
|
1231
|
+
<QuestionStrip questions={jev.questions} selectedKey={selectedKey} onSelect={setSelectedKey} setupStatus={jev.setupStatus} canRun={jev.canRun} loading={jev.loading} onEvaluateAll={jev.evaluateAllNow} onSetUp={() => setKeyDialogOpen(!0)} />
|
|
1176
1232
|
</div>
|
|
1177
|
-
{
|
|
1233
|
+
{isNearTokenLimit(state) && <Card padding={3} radius={2} tone="caution" border>
|
|
1178
1234
|
<Text size={1}>
|
|
1179
1235
|
{t("field.too-long", {
|
|
1180
|
-
tokens: Math.round(
|
|
1236
|
+
tokens: Math.round(estimateTokenCount(state) / 1e3),
|
|
1181
1237
|
limit: 32
|
|
1182
1238
|
})}
|
|
1183
1239
|
</Text>
|
|
1184
1240
|
</Card>}
|
|
1185
|
-
{selectedQuestion && <QuestionDetail question={selectedQuestion} empty={jev.empty} onRetry={() => jev.
|
|
1241
|
+
{selectedQuestion && <QuestionDetail question={selectedQuestion} empty={jev.empty} onRetry={() => jev.evaluateNow(selectedQuestion.key)} onUpdateKey={jev.isKeyStoredInSecrets ? () => setKeyDialogOpen(!0) : void 0} />}
|
|
1186
1242
|
{keyDialogOpen && <JevKeyDialog onClose={() => setKeyDialogOpen(!1)} />}
|
|
1187
1243
|
</Stack>;
|
|
1188
1244
|
}
|
|
1189
|
-
const Code = ({ children }) => <code>{children}</code>,
|
|
1245
|
+
const Code = ({ children }) => <code>{children}</code>, CONNECTION_TEST_QUESTION = bindQuestionToKind(noul({
|
|
1190
1246
|
instructions: "Is this text a connection test?",
|
|
1191
1247
|
true: "It is a test",
|
|
1192
1248
|
false: "It is not a test"
|
|
1193
|
-
})).gatewayQuestion
|
|
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) };
|
|
1249
|
+
})).gatewayQuestion;
|
|
1199
1250
|
/** Studio tool for setting, checking and removing the stored AI Gateway key. */
|
|
1200
1251
|
function JevTool({ config }) {
|
|
1201
|
-
let { t } = useTranslation("jev"), stored = useStoredKey(), saveKey = useSaveKey(), [
|
|
1252
|
+
let { t } = useTranslation("jev"), stored = useStoredKey(), saveKey = useSaveKey(), [keyDialogOpen, setKeyDialogOpen] = useState(!1), [isConfirmingRemove, setIsConfirmingRemove] = useState(!1), [connectionTest, setConnectionTest] = useState({ state: "idle" }), keyIsFromConfig = isKeyFromConfig(config), transport = resolveTransport(config, config.apiKey ?? stored.apiKey);
|
|
1202
1253
|
async function testConnection() {
|
|
1203
|
-
if (transport &&
|
|
1204
|
-
|
|
1254
|
+
if (transport && CONNECTION_TEST_QUESTION) {
|
|
1255
|
+
setConnectionTest({ state: "running" });
|
|
1205
1256
|
try {
|
|
1206
1257
|
let result = await evaluateQuestion({
|
|
1207
1258
|
transport,
|
|
1208
1259
|
model: config.model ?? "typesafe-ai/jev",
|
|
1209
1260
|
state: "This is a connection test.",
|
|
1210
|
-
question:
|
|
1261
|
+
question: CONNECTION_TEST_QUESTION,
|
|
1211
1262
|
tags: [...config.tags ?? DEFAULT_TAGS, "jev:connection-test"]
|
|
1212
1263
|
});
|
|
1213
|
-
|
|
1264
|
+
setConnectionTest({
|
|
1214
1265
|
state: "passed",
|
|
1215
1266
|
model: result.model
|
|
1216
1267
|
});
|
|
1217
1268
|
} catch (error) {
|
|
1218
|
-
|
|
1269
|
+
setConnectionTest({
|
|
1219
1270
|
state: "failed",
|
|
1220
|
-
error:
|
|
1271
|
+
error: toQuestionError(error)
|
|
1221
1272
|
});
|
|
1222
1273
|
}
|
|
1223
1274
|
}
|
|
1224
1275
|
}
|
|
1276
|
+
async function removeStoredKey() {
|
|
1277
|
+
setIsConfirmingRemove(!1);
|
|
1278
|
+
try {
|
|
1279
|
+
await saveKey(void 0), setConnectionTest({ state: "idle" });
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
1282
|
+
setConnectionTest({
|
|
1283
|
+
state: "failed",
|
|
1284
|
+
error: { message: t("tool.key.remove-failed", { error: message }) }
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1225
1288
|
return <Container width={1} padding={4}>
|
|
1226
1289
|
<Stack gap={5}>
|
|
1227
1290
|
<Stack gap={3}>
|
|
@@ -1239,10 +1302,10 @@ function JevTool({ config }) {
|
|
|
1239
1302
|
{t("tool.key.heading")}
|
|
1240
1303
|
</Heading>
|
|
1241
1304
|
</Box>
|
|
1242
|
-
<
|
|
1305
|
+
<KeyStatusBadge keyIsFromConfig={keyIsFromConfig} loading={stored.loading} storedKey={stored.apiKey} />
|
|
1243
1306
|
</Flex>
|
|
1244
1307
|
|
|
1245
|
-
{
|
|
1308
|
+
{keyIsFromConfig ? <Text size={1} muted>
|
|
1246
1309
|
{config.transport ? t("tool.key.from-transport") : t("tool.key.from-config")}
|
|
1247
1310
|
</Text> : stored.apiKey ? <Stack gap={3}>
|
|
1248
1311
|
<Text size={1}>
|
|
@@ -1256,37 +1319,26 @@ function JevTool({ config }) {
|
|
|
1256
1319
|
</Text>}
|
|
1257
1320
|
|
|
1258
1321
|
<Flex gap={2} wrap="wrap">
|
|
1259
|
-
{!
|
|
1260
|
-
<Button text={
|
|
1261
|
-
{!
|
|
1322
|
+
{!keyIsFromConfig && <Button text={stored.apiKey ? t("tool.key.change") : t("tool.key.set")} tone="primary" disabled={stored.loading} onClick={() => setKeyDialogOpen(!0)} />}
|
|
1323
|
+
<Button text={connectionTest.state === "running" ? t("tool.test.running") : t("tool.test.run")} mode="ghost" disabled={!transport || connectionTest.state === "running"} onClick={testConnection} />
|
|
1324
|
+
{!keyIsFromConfig && stored.apiKey && !isConfirmingRemove && <Button text={t("tool.key.remove")} mode="bleed" tone="critical" onClick={() => setIsConfirmingRemove(!0)} />}
|
|
1262
1325
|
</Flex>
|
|
1263
1326
|
|
|
1264
|
-
{
|
|
1327
|
+
{isConfirmingRemove && <Card padding={3} radius={2} tone="critical">
|
|
1265
1328
|
<Flex align="center" gap={2} wrap="wrap">
|
|
1266
1329
|
<Box flex={1}>
|
|
1267
1330
|
<Text size={1}>{t("tool.key.remove-confirm")}</Text>
|
|
1268
1331
|
</Box>
|
|
1269
|
-
<Button text={t("tool.key.remove-cancel")} mode="bleed" onClick={() =>
|
|
1270
|
-
<Button text={t("tool.key.remove-confirm-button")} tone="critical" onClick={
|
|
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
|
-
}} />
|
|
1332
|
+
<Button text={t("tool.key.remove-cancel")} mode="bleed" onClick={() => setIsConfirmingRemove(!1)} />
|
|
1333
|
+
<Button text={t("tool.key.remove-confirm-button")} tone="critical" onClick={removeStoredKey} />
|
|
1282
1334
|
</Flex>
|
|
1283
1335
|
</Card>}
|
|
1284
1336
|
|
|
1285
|
-
{
|
|
1286
|
-
<Text size={1}>{t("tool.test.passed", { model:
|
|
1337
|
+
{connectionTest.state === "passed" && <Card padding={3} radius={2} tone="positive">
|
|
1338
|
+
<Text size={1}>{t("tool.test.passed", { model: connectionTest.model })}</Text>
|
|
1287
1339
|
</Card>}
|
|
1288
|
-
{
|
|
1289
|
-
<Text size={1}>{
|
|
1340
|
+
{connectionTest.state === "failed" && <Card padding={3} radius={2} tone="critical">
|
|
1341
|
+
<Text size={1}>{translateQuestionError(t, connectionTest.error)}</Text>
|
|
1290
1342
|
</Card>}
|
|
1291
1343
|
</Stack>
|
|
1292
1344
|
</Card>
|
|
@@ -1301,14 +1353,14 @@ function JevTool({ config }) {
|
|
|
1301
1353
|
</Stack>
|
|
1302
1354
|
</Stack>
|
|
1303
1355
|
|
|
1304
|
-
{
|
|
1305
|
-
|
|
1356
|
+
{keyDialogOpen && <JevKeyDialog onClose={() => {
|
|
1357
|
+
setKeyDialogOpen(!1), setConnectionTest({ state: "idle" });
|
|
1306
1358
|
}} />}
|
|
1307
1359
|
</Container>;
|
|
1308
1360
|
}
|
|
1309
|
-
function
|
|
1361
|
+
function KeyStatusBadge({ keyIsFromConfig, loading, storedKey }) {
|
|
1310
1362
|
let { t } = useTranslation("jev");
|
|
1311
|
-
return
|
|
1363
|
+
return keyIsFromConfig ? <Badge tone="primary">{t("tool.key.status.config")}</Badge> : loading ? <Badge>{t("tool.key.status.checking")}</Badge> : storedKey ? <Badge tone="positive">{t("tool.key.status.set")}</Badge> : <Badge tone="caution">{t("tool.key.status.not-set")}</Badge>;
|
|
1312
1364
|
}
|
|
1313
1365
|
/**
|
|
1314
1366
|
* Jev questions for Sanity Studio: yes/no, score and choice questions attached to a field with
|
|
@@ -1317,18 +1369,18 @@ function KeyStatus({ fromConfig, loading, stored }) {
|
|
|
1317
1369
|
* @public
|
|
1318
1370
|
*/
|
|
1319
1371
|
const jev = definePlugin((config) => {
|
|
1320
|
-
let pluginConfig = config ?? {}
|
|
1372
|
+
let pluginConfig = config ?? {}, jevTool = {
|
|
1373
|
+
name: "jev",
|
|
1374
|
+
title: "Jev",
|
|
1375
|
+
icon: SparklesIcon,
|
|
1376
|
+
component: () => <JevTool config={pluginConfig} />
|
|
1377
|
+
};
|
|
1321
1378
|
return {
|
|
1322
1379
|
name: "jev",
|
|
1323
|
-
tools: pluginConfig.tool === !1 ? [] : [
|
|
1324
|
-
name: "jev",
|
|
1325
|
-
title: "Jev",
|
|
1326
|
-
icon: SparklesIcon,
|
|
1327
|
-
component: () => <JevTool config={pluginConfig} />
|
|
1328
|
-
}],
|
|
1380
|
+
tools: pluginConfig.tool === !1 ? [] : [jevTool],
|
|
1329
1381
|
form: { components: { input: (props) => {
|
|
1330
1382
|
if (props.path.length === 0) return <JevFormProvider config={pluginConfig}>{props.renderDefault(props)}</JevFormProvider>;
|
|
1331
|
-
let questions =
|
|
1383
|
+
let questions = getQuestionsFromDefinition(props.schemaType);
|
|
1332
1384
|
return questions ? <AttachedInput {...props} questions={questions} /> : props.renderDefault(props);
|
|
1333
1385
|
} } },
|
|
1334
1386
|
schema: { types: KIND_SCHEMA_TYPES },
|