diff --git a/README.md b/README.md index b11e9ee..a6cdc91 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ requests and the same answers, with no key needed from the caller. - **Link calls.** Every answer comes back with an event id in `events`. When a later request follows from one of those answers, send its event id in a `Jeview-Trigger` header, and the map grows that request off the answer. Jeview drops its own headers before calling Jev and sends the body on unchanged. +- **Show names, not keys.** The map labels each answer with its option's key, such as `c14`. When the criteria behind + the keys are objects, a `Jeview-Display` header says which part to show instead: `Jeview-Display: name`, or a field + per question, `Jeview-Display: category=name, kind=title`. An option without that field keeps its key, and so does + everything sent without the header. The header is only for show: one that cannot be read is ignored, never refused. Agents can read `http://127.0.0.1:4777/llms.txt`: a running Jeview serves it with its own address and whether a key is set. [llms.txt](llms.txt) here is the same text, for the default address. diff --git a/llms.txt b/llms.txt index 44a0ca8..cace28c 100644 --- a/llms.txt +++ b/llms.txt @@ -21,6 +21,13 @@ When a later request follows from one of those answers, send its event id in a h The viewer then grows that request's questions as a branch off the answer that triggered them. Jeview drops its own headers before calling Jev, and sends the body on exactly as it came. +## Show an option by its name, not its key + +The viewer labels each answer with the option's key, such as "c14". When the criteria behind the keys are objects, a +header says which part to show instead: Jeview-Display: name, or a field per question: Jeview-Display: category=name, +kind=title. A field may be a path, such as meta.title. An option without that field keeps its key, and a header that +cannot be read is ignored: it never stops a call. + ## When a call is refused Whatever Jev answers, a refusal included, comes back as Jev sent it. Jeview's own refusals are JSON, { "error": "..." }: diff --git a/src/jeview.ts b/src/jeview.ts index 217813d..973153a 100644 --- a/src/jeview.ts +++ b/src/jeview.ts @@ -41,7 +41,7 @@ const OPTIONS_LISTED = 60; /** One recorded Jev call, as listed. `key` is the sha256 of the exact body sent to Jev: the same request always has * the same key, so a client that caches Jev answers by that hash can find the call here. */ export type JeviewSummary = { - id: number; at: string; label: string; trigger: string | null; + id: number; at: string; label: string; trigger: string | null; display?: Record; key: string; stateKey: string | null; status: number; elapsedMs: number; bytes: number; model: string | null; answeredBy: string | null; inputTokens: number | null; cost: number | null; questions: AnswerSummary[]; error?: string; }; @@ -63,13 +63,29 @@ export function requestTrigger(value: string | null): string | null { return value.trim(); } +/** Which part of an option's criteria the viewer shows for it, from a Jeview-Display header: one field for every question + * ("name"), or a field per question ("category=name, kind=title"); a field may be a path ("meta.title"). "*" holds the + * field for every question. Without the header an option is shown by its key. The header is only for show, so one that + * cannot be read is dropped: it is never a reason to refuse a call. */ +const DISPLAY_FIELD = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/; +export function requestDisplay(value: string | null): Record | null { + if (!value || value.length > 500) return null; + const display: Record = {}; + for (const part of value.split(",")) { + const [left = "", right] = part.split("=").map((word) => word.trim()); + if (right === undefined) { if (DISPLAY_FIELD.test(left)) display["*"] = left; } + else if (left && left.length <= 100 && DISPLAY_FIELD.test(right)) display[left] = right; + } + return Object.keys(display).length ? display : null; +} + /** The question's own sentence: its instructions, or their `question` field when they carry context beside it. */ export function asks(instructions: unknown): string { const text = typeof instructions === "string" ? instructions : object(instructions) && typeof instructions.question === "string" ? instructions.question : JSON.stringify(instructions) ?? ""; return text.length > 300 ? text.slice(0, 297) + "..." : text; } -export function summarize(call: { id: number; at: string; label: string; trigger: string | null; status: number; elapsedMs: number; error?: string }, body: Buffer, request: unknown, response: unknown): JeviewSummary { +export function summarize(call: { id: number; at: string; label: string; trigger: string | null; display?: Record; status: number; elapsedMs: number; error?: string }, body: Buffer, request: unknown, response: unknown): JeviewSummary { const questions = object(request) && object(request.questions) ? request.questions : {}; const answers = object(response) && object(response.answers) ? response.answers : {}; const usage = object(response) && object(response.usage) ? response.usage : {}; @@ -224,6 +240,13 @@ When a later request follows from one of those answers, send its event id in a h The viewer then grows that request's questions as a branch off the answer that triggered them. Jeview drops its own headers before calling Jev, and sends the body on exactly as it came. +## Show an option by its name, not its key + +The viewer labels each answer with the option's key, such as "c14". When the criteria behind the keys are objects, a +header says which part to show instead: Jeview-Display: name, or a field per question: Jeview-Display: category=name, +kind=title. A field may be a path, such as meta.title. An option without that field keeps its key, and a header that +cannot be read is ignored: it never stops a call. + ## When a call is refused Whatever Jev answers, a refusal included, comes back as Jev sent it. Jeview's own refusals are JSON, { "error": "..." }: @@ -269,11 +292,12 @@ export function createJeview(options: JeviewOptions): Jeview { const triggerHeader = req.headers[`${OWN_HEADER}trigger`]; let trigger: string | null; try { trigger = requestTrigger(triggerHeader === undefined ? null : String(triggerHeader)); } catch (error) { return send(res, 400, { error: `jeview: ${(error as Error).message}` }); } + const displayHeader = req.headers[`${OWN_HEADER}display`], display = requestDisplay(displayHeader === undefined ? null : String(displayHeader)); const sent = body, requestValue = parse(body.toString("utf8")); // sent on as it came const id = store.allocate(), at = new Date().toISOString(), started = Date.now(); const keep = (status: number, text: string, error?: string) => { const responseValue = text ? parse(text) : null; - store.save({ summary: summarize({ id, at, label, trigger, status, elapsedMs: Date.now() - started, ...(error ? { error } : {}) }, sent, requestValue, responseValue), request: requestValue, response: responseValue }); + store.save({ summary: summarize({ id, at, label, trigger, ...(display ? { display } : {}), status, elapsedMs: Date.now() - started, ...(error ? { error } : {}) }, sent, requestValue, responseValue), request: requestValue, response: responseValue }); }; const fail = (status: number, message: string) => { keep(status, "", message); return send(res, status, { error: message }); }; const key = jevKey(); diff --git a/test/jeview.test.ts b/test/jeview.test.ts index ae8d170..ce5d399 100644 --- a/test/jeview.test.ts +++ b/test/jeview.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { createJeview, DATABASE, JEV_USD_PER_INPUT_TOKEN, llmsText, summarize, type JeviewSummary } from "../src/jeview.ts"; +import { createJeview, DATABASE, JEV_USD_PER_INPUT_TOKEN, llmsText, requestDisplay, summarize, type JeviewSummary } from "../src/jeview.ts"; type Seen = { method: string; url: string; headers: IncomingMessage["headers"]; body: string }; type Hooks = { after(fn: () => unknown): void }; @@ -110,6 +110,18 @@ test("a Jeview-Trigger header names the answer a request follows from: recorded assert.deepEqual([labelled.label, labelled.trigger], ["june", "1:kind"]); }); +test("a Jeview-Display header says which part of an option to show: kept with the call, never sent on, and never a reason to refuse one", async (t) => { + const upstream = await jev(t, () => ({ body: jevAnswer })); + const { base } = await proxy(t, upstream.url); + const ask = (display?: string) => fetch(`${base}/v1/systemone`, { method: "POST", headers: display === undefined ? {} : { "Jeview-Display": display }, body: jevBody }); + for (const display of [undefined, "name", "kind=name, is_urgent=meta.title", " kind = label ,name ", "no spaces allowed in a field", "=name", "x".repeat(501), "kind=na me, name"]) assert.equal((await ask(display)).status, 200); + assert.deepEqual((await records(base)).records.map((r) => r.display ?? null), [null, { "*": "name" }, { kind: "name", is_urgent: "meta.title" }, { kind: "label", "*": "name" }, null, null, null, { "*": "name" }]); + // Jev never hears of it, and the body is the caller's + assert.deepEqual(upstream.seen.flatMap((seen) => Object.keys(seen.headers).filter((name) => name.startsWith("jeview-"))), []); + assert.deepEqual([...new Set(upstream.seen.map((seen) => seen.body))], [jevBody]); + assert.deepEqual([requestDisplay(null), requestDisplay(""), requestDisplay("a.b-c_d"), requestDisplay("a..b"), requestDisplay("q=a=b")], [null, null, { "*": "a.b-c_d" }, null, { q: "a" }]); +}); + test("without a key a Jev request is refused and recorded; Jev's own refusals reach the caller as sent; nothing else is accepted", async (t) => { const upstream = await jev(t, () => ({ status: 429, headers: { "content-type": "application/json", "retry-after": "2" }, body: JSON.stringify({ error: "slow down" }) })); const { base, store } = await proxy(t, upstream.url, { key: null }); diff --git a/ui/app.js b/ui/app.js index acf23fa..eba98d9 100644 --- a/ui/app.js +++ b/ui/app.js @@ -51,8 +51,8 @@ return null; } /** An answer in its own terms, in a few words. */ - function answerWords(q) { - if (q.choice !== undefined) return q.choice === "none" ? "none of the options" : human(q.choice); + function answerWords(q, record) { + if (q.choice !== undefined) return q.choice === "none" ? "none of the options" : nameOf(record?.label, kindOf(q.id), q.choice); if (q.noul !== undefined) return `${pct(q.noul)} yes`; if (q.score !== undefined) return `${q.score.toFixed(2)}${q.levels ? ` of ${q.levels - 1}` : ""}`; return "no answer"; @@ -80,6 +80,29 @@ if (!missing.length) return; try { for (const record of (await (await fetch(`/_/api/records?ids=${missing.join(",")}`)).json()).records) if (!data.byId.has(record.id)) data.byId.set(record.id, record); } catch { /* listed without them */ } } + // ---------- an option's name ---------- + // An option is shown by its key, unless the call said in a Jeview-Display header which part of its criteria to show + // instead. The names are not in the summaries: they are read once, from one whole call that carries the header, and then + // hold for that question in that run, earlier calls included. Another run may hang other things on the same keys, so it + // keeps its own names, or its keys. An option without that part keeps its key (null here, so it is not looked for again). + const labels = new Map(), fields = new Map(), learning = new Set(); + const scopeOf = (run, question) => `${run}\u0000${question}`; + const dig = (value, path) => path.split(".").reduce((part, key) => (part && typeof part === "object" ? part[key] : undefined), value); + const labelIn = (value, field) => { const part = field ? dig(value, field) : undefined; return typeof part === "string" && part.trim() ? part.trim() : typeof part === "number" ? String(part) : null; }; + const fieldFor = (record, qid) => record.display?.[qid] ?? record.display?.[kindOf(qid)] ?? record.display?.["*"]; + /** What an answer is called in a run: `question` is the question's name, as `kindOf` gives it. */ + const nameOf = (run, question, key) => labels.get(scopeOf(run, question))?.get(key) ?? human(key); + async function learn(record, q) { + const scope = scopeOf(record.label, kindOf(q.id)), field = fieldFor(record, q.id); + if (!field || q.choice === undefined || learning.has(scope) || (fields.get(scope) === field && labels.get(scope)?.has(q.choice))) return; + learning.add(scope); + try { + const criteria = (await fullRecord(record.id)).request?.questions?.[q.id]?.criteria, known = fields.get(scope) === field ? labels.get(scope) : new Map(); + for (const [key, value] of Object.entries(criteria && typeof criteria === "object" ? criteria : {})) known.set(key, labelIn(value, field)); + if (!known.has(q.choice)) known.set(q.choice, null); + labels.set(scope, known); fields.set(scope, field); renderCorner(true); // the map measures its labels again by itself + } catch { /* shown by their keys */ } finally { learning.delete(scope); } + } async function fullRecord(id) { if (!data.full.has(id)) { const response = await fetch(`/_/api/records/${id}`); if (!response.ok) throw Error(`Call ${id} could not be read`); data.full.set(id, await response.json()); } return data.full.get(id); @@ -182,6 +205,7 @@ function reveal(body, now = performance.now()) { if (!body.revealed) { body.revealed = true; body.born = now; } } function land(record, target) { target.kind.count++; target.option.count++; target.option.ids.push(record.id); target.kind.asked.push({ q: target.q, record }); + target.kind.run = record.label; void learn(record, target.q); for (const o of target.kind.options.values()) { const p = probabilityOf(target.q, o.name); if (p !== null) o.latest = p; } // the latest spread across every answer } /** A new answer lights its dot up; it cools back over two seconds, whatever lands after it. */ @@ -262,7 +286,7 @@ kind.w = Math.max(kind.R * 2, kind.textW) + 10; kind.h = kind.R * 2 + 6 + kind.lines.length * Q_LINE + 4; for (const o of kind.options.values()) { o.r += (ANSWER_R - o.r) * grow; o.R = ANSWER_R; // one size for every answer, reserved before it shows: it carries its latest probability - const labelText = clip(human(o.name), 28), okey = `${labelText}|${colors.body}`; + const labelText = clip(nameOf(kind.run, kind.name, o.name), 28), okey = `${labelText}|${colors.body}`; if (o.labelKey !== okey) { o.label = labelText; o.labelW = textWidth(labelText, A_SIZE, 400); o.labelKey = okey; } o.w = Math.max(o.R * 2, o.labelW) + 8; o.h = o.R * 2 + 4 + 16; } @@ -795,7 +819,7 @@ if (!hover || hover.kind === "question") return hideTip(); // a hovered question shows itself whole on the map if (hover.kind === "invalid") showTip(event.clientX, event.clientY, h("b", {}, "Invalid"), ` · ${plural(hover.node.count, "call")} Jev rejected`, h("div", { class: "sub" }, "Click to see them")); else if (hover.kind === "jev") showTip(event.clientX, event.clientY, h("b", {}, "Jev"), h("div", { class: "sub" }, `${plural(data.records.filter(visible).length, "call")} so far`)); - else { const o = hover.node; showTip(event.clientX, event.clientY, h("b", {}, human(o.name)), ` · chosen ${plural(o.count, "time")}`, h("div", { class: "sub" }, `${typeof o.latest === "number" ? `Jev's latest probability ${pct(o.latest)}. ` : ""}${o.count ? "Click for the calls behind it" : ""}`)); } + else { const o = hover.node, called = nameOf(o.kind.run, o.kind.name, o.name); showTip(event.clientX, event.clientY, h("b", {}, called), ` · chosen ${plural(o.count, "time")}`, h("div", { class: "sub" }, `${called === human(o.name) ? "" : `Its key is ${o.name}. `}${typeof o.latest === "number" ? `Jev's latest probability ${pct(o.latest)}. ` : ""}${o.count ? "Click for the calls behind it" : ""}`)); } }); canvas.addEventListener("pointerleave", () => { hover = null; hideTip(); }); canvas.addEventListener("click", (event) => { @@ -803,7 +827,7 @@ const hit = nodeAt(...toWorld(event.clientX, event.clientY)); if (!hit) { if (drawer.classList.contains("open")) closeDrawer(); else focus(null); return; } if (hit.kind === "invalid") openList("Invalid calls", "Calls Jev rejected, newest first", [...hit.node.ids].reverse()); - else if (hit.kind === "answer") openList(human(hit.node.name), `${plural(hit.node.count, "time")} the answer to: ${hit.node.kind.asks || human(hit.node.kind.name)}`, [...hit.node.ids].reverse()); + else if (hit.kind === "answer") openList(nameOf(hit.node.kind.run, hit.node.kind.name, hit.node.name), `${plural(hit.node.count, "time")} the answer to: ${hit.node.kind.asks || human(hit.node.kind.name)}`, [...hit.node.ids].reverse()); else if (hit.kind === "question") { if (hit.node.pin) release(hit.node); else focus(hit.node.key); } // moved by hand: a click puts it back else { data.tab = "recent"; data.focus = null; renderCorner(true); } }); @@ -834,7 +858,7 @@ /** Where a branch grows from, in words: "after no to ". */ const after = (kind) => { if (!kind.parent) return ""; - const answers = [...kind.parents].map((a) => `“${human(a.name)}”`), shown = answers.slice(0, 3).join(" or ") + (answers.length > 3 ? ` or ${answers.length - 3} more` : ""); + const answers = [...kind.parents].map((a) => `“${nameOf(a.kind.run, a.kind.name, a.name)}”`), shown = answers.slice(0, 3).join(" or ") + (answers.length > 3 ? ` or ${answers.length - 3} more` : ""); return `after ${shown} to ${clip(kind.parent.kind.asks || human(kind.parent.kind.name), 48)}`; }; function bins(values, count, lo, hi) { const b = new Array(count).fill(0); for (const v of values) b[Math.min(count - 1, Math.max(0, Math.floor(((v - lo) / (hi - lo)) * count)))]++; return b; } @@ -847,8 +871,9 @@ n ? h("svg:rect", { x: i * (bar + gap), y: height - barHeight, width: bar, height: barHeight, rx: 1.5, class: "bar" }) : h("svg:rect", { x: i * (bar + gap), y: height - 1, width: bar, height: 1, class: "base" })); })); } function share(name, count, total, ids, extra) { - return h("button", { class: "share", onclick: () => ids.length && openList(human(name), `${plural(count, "time")} the answer to: ${world.kinds.get(data.focus)?.asks ?? ""}`, [...ids].reverse()) }, - h("span", { class: "name" }, human(name), extra ? h("small", {}, extra) : null), h("span", { class: "n" }, `${count} · ${pct(total ? count / total : 0)}`), + const focused = world.kinds.get(data.focus), called = nameOf(focused?.run, focused?.name, name); + return h("button", { class: "share", onclick: () => ids.length && openList(called, `${plural(count, "time")} the answer to: ${world.kinds.get(data.focus)?.asks ?? ""}`, [...ids].reverse()) }, + h("span", { class: "name" }, called, extra ? h("small", {}, extra) : null), h("span", { class: "n" }, `${count} · ${pct(total ? count / total : 0)}`), h("span", { class: "track" }, h("i", { style: `width:${total ? (count / total) * 100 : 0}%` }))); } function questionStats(key) { @@ -889,7 +914,7 @@ return h("li", { "data-id": record.id, class: data.selected?.id === record.id ? "here" : "" }, h("button", { onclick: () => openCall(record.id) }, h("span", { class: "what" }, h("span", { class: `mark-dot${failed(record) ? " failed" : ""}` }), h("span", {}, headline(record))), h("span", { class: "when", "data-at": finished(record) }, ago(finished(record))), - h("span", { class: "sub" }, failed(record) ? "Jev rejected the call" : [q ? answerWords(q) : "no answer", ...record.questions.filter((item) => item !== q).slice(0, 2).map(answerWords)].join(" · ") + (record.questions.length > 3 ? ` · +${record.questions.length - 3}` : "")))); + h("span", { class: "sub" }, failed(record) ? "Jev rejected the call" : [q ? answerWords(q, record) : "no answer", ...record.questions.filter((item) => item !== q).slice(0, 2).map((item) => answerWords(item, record))].join(" · ") + (record.questions.length > 3 ? ` · +${record.questions.length - 3}` : "")))); } /** New calls pile onto the top of the recent list without redrawing the rows already there. */ function pileUp(records) { @@ -951,7 +976,7 @@ function brief(record) { const q = record.questions.find((item) => landing(item) !== null); if (failed(record) || !q) return [h("span", { class: "mark-dot failed" }), h("span", {}, clip(headline(record), 58), " ", h("b", {}, failed(record) ? "→ failed" : "→ no answer"))]; - return [h("span", { class: "mark-dot" }), h("span", {}, clip(questionOf(q), 58), " ", h("b", {}, `→ ${answerWords(q)}`), record.questions.length > 1 ? ` +${record.questions.length - 1}` : "")]; + return [h("span", { class: "mark-dot" }), h("span", {}, clip(questionOf(q), 58), " ", h("b", {}, `→ ${answerWords(q, record)}`), record.questions.length > 1 ? ` +${record.questions.length - 1}` : "")]; } function openList(title, subtitle, ids, back = false) { visit({ list: [title, subtitle, ids] }, back); @@ -979,13 +1004,16 @@ } /** One question's answer. In a call with one question the title already asks it, so it is not repeated; in a call * with several, each is numbered. */ - function answerBlock(question, answer, alone, n) { + function answerBlock(question, answer, alone, n, field) { const type = question?.type, parts = [h("p", { class: alone ? "q quiet-q" : "q" }, questionText(question?.instructions, !alone))]; const block = (content) => alone ? h("div", { class: "qa" }, content) : h("div", { class: "qa numbered" }, h("span", { class: "num" }, String(n)), h("div", {}, content)); if (!answer) return block([parts, h("p", { class: "a" }, "No answer")]); if (type === "choice") { - parts.push(h("p", { class: "a" }, answer.choice === "none" ? "None of the options" : human(answer.choice)), meter(answer.confidence, `Jev's confidence ${pct(answer.confidence)}`), - options(Object.entries(question.criteria || {}).map(([name, description]) => ({ name: human(name), description, p: answer.probabilities?.[name], chosen: name === answer.choice })))); + const called = (key) => labelIn(question.criteria?.[key], field) ?? human(key); + // an option that is a whole object is described by its description, if it has one, not by its JSON + const described = (value) => (value && typeof value === "object" && typeof value.description === "string" ? value.description : value); + parts.push(h("p", { class: "a" }, answer.choice === "none" ? "None of the options" : called(answer.choice)), meter(answer.confidence, `Jev's confidence ${pct(answer.confidence)}`), + options(Object.entries(question.criteria || {}).map(([name, description]) => ({ name: called(name), description: described(description), p: answer.probabilities?.[name], chosen: name === answer.choice })))); } else if (type === "noul") { parts.push(h("p", { class: "a" }, `${pct(answer.noul)} yes`), meter(answer.noul, "Jev's probability that the answer is yes"), question.criteria ? options([{ name: "Yes", description: question.criteria.true, p: answer.noul, chosen: answer.noul >= 0.5 }, { name: "No", description: question.criteria.false, p: 1 - answer.noul, chosen: answer.noul < 0.5 }]) : null); @@ -1014,8 +1042,8 @@ if (!record.trigger && !children.length) return null; const link = (call, words) => h("button", { class: "link", onclick: () => openCall(call.id) }, words); return h("div", { class: "chain" }, - record.trigger ? [h("p", { class: "eyebrow" }, "Triggered by"), parent?.q ? link(parent.call, [clip(questionOf(parent.q), 90), " ", h("b", {}, `→ ${answerWords(parent.q)}`)]) : h("p", { class: "quiet" }, `${record.trigger}, which is not recorded here`)] : null, - children.length ? [h("p", { class: "eyebrow" }, "Led to"), children.map((child) => { const via = eventCall(child.trigger)?.q, q = child.questions[0]; return link(child, [via ? h("b", {}, `${answerWords(via)} → `) : null, clip(q ? questionOf(q) : "a call", 90)]); })] : null); + record.trigger ? [h("p", { class: "eyebrow" }, "Triggered by"), parent?.q ? link(parent.call, [clip(questionOf(parent.q), 90), " ", h("b", {}, `→ ${answerWords(parent.q, parent.call)}`)]) : h("p", { class: "quiet" }, `${record.trigger}, which is not recorded here`)] : null, + children.length ? [h("p", { class: "eyebrow" }, "Led to"), children.map((child) => { const via = eventCall(child.trigger)?.q, q = child.questions[0]; return link(child, [via ? h("b", {}, `${answerWords(via, record)} → `) : null, clip(q ? questionOf(q) : "a call", 90)]); })] : null); } async function openCall(id, back = false) { @@ -1055,7 +1083,7 @@ h("div", { class: "d-head" }, h("h2", { class: "d-title" }, alone && record.questions[0] ? questionOf(record.questions[0]) : entries.length ? `${entries.length} questions in one call` : headline(record)), viewState), state, errorText ? h("div", { class: "notice" }, `Jev did not answer: ${text(errorText)}`) : null, - entries.map(([qid, question], i) => answerBlock(question, answers[qid], alone, i + 1)), + entries.map(([qid, question], i) => answerBlock(question, answers[qid], alone, i + 1, fieldFor(record, qid) ?? fields.get(scopeOf(record.label, kindOf(qid))))), chainLinks(record), h("div", { class: "actions" }, toggle("Details", () => h("dl", { class: "facts" }, diff --git a/ui/index.html b/ui/index.html index 32d9e35..32c44db 100644 --- a/ui/index.html +++ b/ui/index.html @@ -39,6 +39,8 @@

The same requests and answers as TypeSafe's address, with no key needed. To group requests under a project or label, add it to the path: /<label>/v1/systemone.

Link a call to the answer that led to it

Each answer comes back with an event id in events. Send it with a later call in a Jeview-Trigger header and the map grows that call off the answer. Jeview drops the header before calling Jev.

+

Show names, not keys

+

Answers are labelled with their option's key. When the criteria behind the keys are objects, send Jeview-Display: name and the map shows that part of each option instead; an option without it keeps its key.

Where calls are kept

Nothing is stored anywhere else. Jeview runs on your computer, has no accounts and no tracking, and sends your requests to TypeSafe only.