@gafj/gafj 0.1.4 → 0.1.6
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/bridge/byo_key.js +2 -2
- package/bridge/providers/anthropic.js +1 -1
- package/http/api.js +16 -2
- package/package.json +1 -1
- package/store/onboarding.js +2 -1
- package/store/setup.js +4 -1
- package/ui/app.css +12 -0
- package/ui/app.js +2 -2
- package/ui/screens/settings.js +14 -3
- package/ui/screens/welcome.js +9 -6
package/bridge/byo_key.js
CHANGED
|
@@ -40,8 +40,8 @@ async function runPacket(homeDir, id, { packet, fetchImpl }) {
|
|
|
40
40
|
const { renderPasteText } = require("./paste");
|
|
41
41
|
const started = Date.now();
|
|
42
42
|
try {
|
|
43
|
-
const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else.", user: renderPasteText(packet), max_tokens:
|
|
44
|
-
return { content: r.text, provider: p.kind, model: r.model || p.model, tokens_in: r.tokens_in, tokens_out: r.tokens_out, latency_ms: Date.now() - started };
|
|
43
|
+
const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else.", user: renderPasteText(packet), max_tokens: 16000, fetch: fetchImpl });
|
|
44
|
+
return { content: r.text, provider: p.kind, model: r.model || p.model, tokens_in: r.tokens_in, tokens_out: r.tokens_out, latency_ms: Date.now() - started, stop_reason: r.stop_reason || null };
|
|
45
45
|
} catch (e) {
|
|
46
46
|
throw Object.assign(new Error(scrub(e.message, p.api_key)), { status: 502 });
|
|
47
47
|
}
|
|
@@ -10,7 +10,7 @@ async function complete({ provider, system, user, max_tokens = 4000, fetch }) {
|
|
|
10
10
|
const data = await post(fetch, `${base}/v1/messages`, { "x-api-key": provider.api_key || "", "anthropic-version": "2023-06-01" },
|
|
11
11
|
{ model, max_tokens, system, messages: [{ role: "user", content: user }] });
|
|
12
12
|
const text = (data.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
13
|
-
return { text, model: data.model || model, tokens_in: data.usage && data.usage.input_tokens, tokens_out: data.usage && data.usage.output_tokens };
|
|
13
|
+
return { text, model: data.model || model, tokens_in: data.usage && data.usage.input_tokens, tokens_out: data.usage && data.usage.output_tokens, stop_reason: data.stop_reason || null };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
module.exports = { complete, DEFAULT_URL, DEFAULT_MODEL };
|
package/http/api.js
CHANGED
|
@@ -151,6 +151,14 @@ route("GET", "/api/kb/questions", (c, p, b, q) => discover.listQuestions(c.db, c
|
|
|
151
151
|
route("POST", "/api/kb/questions/:id/answer", (c, p, b) => discover.answerQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, draft: need(b, "draft"), now: c.now(), actor: ACTOR("answer_question") }));
|
|
152
152
|
route("POST", "/api/kb/questions/:id/dismiss", (c, p) => discover.dismissQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, now: c.now(), actor: ACTOR("dismiss_question") }));
|
|
153
153
|
|
|
154
|
+
// usage: tokens per operation from the attempts the app recorded; the pricing test reads this
|
|
155
|
+
route("GET", "/api/usage", (c) => ({
|
|
156
|
+
by_op: c.db.prepare(`SELECT r.operation AS op, count(*) AS attempts, sum(CASE WHEN a.result = 'passed' THEN 1 ELSE 0 END) AS passed,
|
|
157
|
+
coalesce(sum(a.tokens_in), 0) AS tokens_in, coalesce(sum(a.tokens_out), 0) AS tokens_out, coalesce(avg(a.latency_ms), 0) AS avg_ms, max(a.model) AS model
|
|
158
|
+
FROM ai_attempt a JOIN ai_run r ON r.id = a.ai_run_id WHERE r.candidate_id = ? AND a.tokens_in IS NOT NULL GROUP BY r.operation ORDER BY r.operation`).all(c.candidate_id),
|
|
159
|
+
since: c.db.prepare("SELECT min(a.started_at) AS s FROM ai_attempt a JOIN ai_run r ON r.id = a.ai_run_id WHERE r.candidate_id = ? AND a.tokens_in IS NOT NULL").get(c.candidate_id).s,
|
|
160
|
+
}));
|
|
161
|
+
|
|
154
162
|
// guided setup (first run)
|
|
155
163
|
const setup = require("../store/setup");
|
|
156
164
|
route("GET", "/api/setup", (c) => setup.setupView(c.db, c.home, c.candidate_id));
|
|
@@ -194,18 +202,24 @@ route("POST", "/api/run/:op", async (c, p, b, q) => {
|
|
|
194
202
|
if (cfg.route !== "byo_key" && cfg.route !== "credits") throw bad("the active route is neither byo_key nor credits; set it in settings", 409);
|
|
195
203
|
const routeName = cfg.route;
|
|
196
204
|
let reply;
|
|
205
|
+
// resolve the provider before a run row exists: one saved provider is the active one even if nobody clicked the radio
|
|
206
|
+
let providerId = null;
|
|
207
|
+
if (routeName === "byo_key") {
|
|
208
|
+
providerId = b.provider_id || cfg.active_provider_id || ((cfg.providers || []).length === 1 ? cfg.providers[0].id : null);
|
|
209
|
+
if (!providerId) throw bad((cfg.providers || []).length ? "pick the active provider in Settings" : "add a provider key in Settings", 409);
|
|
210
|
+
if (!cfg.active_provider_id && !b.provider_id) settings.putSettings(c.db, c.home, c.candidate_id, { active_provider_id: providerId }, { now: c.now(), actor: ACTOR("run") });
|
|
211
|
+
}
|
|
197
212
|
if (p.op === "discover") require("../store/discover").assertCanOpen(c.db, c.candidate_id);
|
|
198
213
|
const opened = openRun(c.db, { op: p.op, candidate_id: c.candidate_id, interview_id: q.get("interview_id") || undefined, posting_id: q.get("posting_id") || undefined, source_document_id: q.get("source_document_id") || undefined, route: routeName, actor: ACTOR("run"), now: c.now() });
|
|
199
214
|
if (routeName === "credits") {
|
|
200
215
|
const { runPacket } = require("../bridge/credits");
|
|
201
216
|
reply = await runPacket(c.home, { packet: opened.packet, fetchImpl: c.fetch, auth: c.relayAuth });
|
|
202
217
|
} else {
|
|
203
|
-
const providerId = b.provider_id || cfg.active_provider_id;
|
|
204
|
-
if (!providerId) throw bad("no active provider", 409);
|
|
205
218
|
const { runPacket } = require("../bridge/byo_key");
|
|
206
219
|
reply = await runPacket(c.home, providerId, { packet: opened.packet, fetchImpl: c.fetch });
|
|
207
220
|
}
|
|
208
221
|
const r = saveReply(c.db, { run_id: opened.run_id, content: reply.content, route: routeName, actor: ACTOR("run"), now: c.now(), provider: reply.provider, model: reply.model, tokens_in: reply.tokens_in, tokens_out: reply.tokens_out, latency_ms: reply.latency_ms });
|
|
222
|
+
if (r.status !== "passed" && reply.stop_reason === "max_tokens") r.errors = [...(r.errors || []), "the reply was cut off at the token limit"];
|
|
209
223
|
return reply.credits_left === undefined ? r : { ...r, charged: reply.charged, credits_left: reply.credits_left };
|
|
210
224
|
});
|
|
211
225
|
|
package/package.json
CHANGED
package/store/onboarding.js
CHANGED
|
@@ -145,7 +145,8 @@ function saveExtraction(db, { run_id, content, route, actor, now, provider, mode
|
|
|
145
145
|
const ex = extractJson(content);
|
|
146
146
|
let result;
|
|
147
147
|
let parsed = null;
|
|
148
|
-
|
|
148
|
+
// the reply is never stored; on a schema miss its opening is echoed back to the caller once so the failure can be read
|
|
149
|
+
if (ex.error) result = { accepted: false, errors: ["schema: " + ex.error + (typeof content === "string" && content.trim() ? ` (reply began: "${content.trim().slice(0, 160).replace(/\s+/g, " ")}")` : " (the reply was empty)")], pending_ids: [] };
|
|
149
150
|
else { parsed = ex.value; result = { accepted: false, errors: [], pending_ids: [] }; }
|
|
150
151
|
const attemptId = ulid(Date.parse(now) + 100 + attemptNo);
|
|
151
152
|
d.prepare(`INSERT INTO ai_attempt (id, ai_run_id, attempt_no, provider, model, tokens_in, tokens_out, latency_ms, output_hash, result, error_code, started_at, finished_at)
|
package/store/setup.js
CHANGED
|
@@ -21,7 +21,10 @@ function setupView(db, homeDir, candidate_id) {
|
|
|
21
21
|
const confirmed = one("SELECT count(*) c FROM accomplishment WHERE candidate_id = ?", candidate_id);
|
|
22
22
|
const questions = one("SELECT count(*) c FROM kb_question WHERE candidate_id = ?", candidate_id);
|
|
23
23
|
const postings = one("SELECT count(*) c FROM posting WHERE candidate_id = ?", candidate_id);
|
|
24
|
-
|
|
24
|
+
// byo_key counts only with a usable provider: the active one, or the single one saved
|
|
25
|
+
const providers = cfg.providers || [];
|
|
26
|
+
const usable = cfg.route !== "byo_key" || !!(cfg.active_provider_id && providers.some((x) => x.id === cfg.active_provider_id)) || providers.length === 1;
|
|
27
|
+
const aiChosen = !!(cfg.setup && cfg.setup.ai_chosen) && usable;
|
|
25
28
|
const steps = [
|
|
26
29
|
{ key: "ai", title: "Tell GAF-J which AI you use", done: aiChosen, detail: aiChosen ? `Using ${ROUTE_LABEL[cfg.route] || cfg.route}` : null },
|
|
27
30
|
{ key: "upload", title: "Upload your resumes", done: sources > 0, detail: sources ? `${sources} file${sources === 1 ? "" : "s"} uploaded` : null },
|
package/ui/app.css
CHANGED
|
@@ -170,3 +170,15 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
|
|
|
170
170
|
.welcome-body p { color: var(--muted); max-width: 66ch; }
|
|
171
171
|
.welcome-body .card { border: 0; padding: 0; background: transparent; }
|
|
172
172
|
.ai-options button.picked { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
173
|
+
.welcome-head .links { margin-left: auto; display: flex; gap: 14px; }
|
|
174
|
+
.stepper li.can { cursor: pointer; }
|
|
175
|
+
.stepper li.can:hover { color: var(--accent); }
|
|
176
|
+
.welcome-nav { margin-top: 16px; justify-content: space-between; }
|
|
177
|
+
/* a switch: one provider is on at a time; off everywhere is a warning, not a state to sit in */
|
|
178
|
+
.switch { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
179
|
+
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
180
|
+
.switch .track { width: 34px; height: 18px; border-radius: 100px; background: var(--line-2); position: relative; transition: background .15s; }
|
|
181
|
+
.switch .track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--surface); transition: left .15s; box-shadow: 0 1px 2px #0003; }
|
|
182
|
+
.switch input:checked + .track { background: var(--accent); }
|
|
183
|
+
.switch input:checked + .track::after { left: 18px; }
|
|
184
|
+
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
package/ui/app.js
CHANGED
|
@@ -63,7 +63,7 @@ function App() {
|
|
|
63
63
|
useEffect(() => { if (gate === false) return; api.get("/api/setup").then((s) => setGate(!s.complete && !s.dismissed)).catch(() => setGate(false)); }, [live]);
|
|
64
64
|
const seg = route.path.split("/").filter(Boolean);
|
|
65
65
|
if (gate === null) return html`<div class="welcome"><p class="muted">loading</p></div>`;
|
|
66
|
-
if (gate) return html`<${Welcome} live=${live} onDone=${() => setGate(false)} />`;
|
|
66
|
+
if (gate && seg[0] !== "settings") return html`<${Welcome} live=${live} onDone=${() => setGate(false)} />`;
|
|
67
67
|
let screen;
|
|
68
68
|
if (seg[0] === "applications" && seg[1]) screen = html`<${Application} id=${seg[1]} live=${live} />`;
|
|
69
69
|
else if (seg[0] === "applications") screen = html`<${Applications} live=${live} />`;
|
|
@@ -72,7 +72,7 @@ function App() {
|
|
|
72
72
|
else if (seg[0] === "documents" && seg[1]) screen = html`<${Document} id=${seg[1]} live=${live} />`;
|
|
73
73
|
else if (seg[0] === "postings") screen = html`<${Postings} id=${seg[1]} live=${live} />`;
|
|
74
74
|
else if (seg[0] === "kb") screen = html`<${KB} live=${live} />`;
|
|
75
|
-
else if (seg[0] === "settings") screen = html
|
|
75
|
+
else if (seg[0] === "settings") screen = html`<div>${gate ? html`<p class="small"><a href="#/">← back to setup</a></p>` : ""}<${Settings} live=${live} /></div>`;
|
|
76
76
|
else screen = html`<${Dashboard} live=${live} />`;
|
|
77
77
|
return html`<div class="layout"><${Rail} route=${route} me=${me} /><main class="main">${screen}</main></div>`;
|
|
78
78
|
}
|
package/ui/screens/settings.js
CHANGED
|
@@ -13,8 +13,9 @@ export function Settings({ live }) {
|
|
|
13
13
|
const [msg, setMsg] = useState("");
|
|
14
14
|
const [prov, setProv] = useState(null);
|
|
15
15
|
const [mcp, setMcp] = useState(null);
|
|
16
|
+
const [usage, setUsage] = useState(null);
|
|
16
17
|
const inst = useInstall();
|
|
17
|
-
const load = () => api.get("/api/settings").then((x) => { setS(x); setErr(""); }).catch((e) => setErr(e.message));
|
|
18
|
+
const load = () => Promise.all([api.get("/api/settings"), api.get("/api/usage").catch(() => null)]).then(([x, u]) => { setS(x); setUsage(u); setErr(""); }).catch((e) => setErr(e.message));
|
|
18
19
|
useEffect(() => { load(); }, [live]);
|
|
19
20
|
const put = async (patch) => { setMsg(""); try { setS(await api.put("/api/settings", patch)); setMsg("saved"); } catch (e) { setErr(e.message); } };
|
|
20
21
|
const post = async (path, body, label) => { setMsg(""); try { const r = await api.post(path, body); setMsg(label ? `${label}: ${r.file || r.message || JSON.stringify(r)}` : JSON.stringify(r)); await load(); } catch (e) { setErr(e.message); } };
|
|
@@ -43,8 +44,10 @@ export function Settings({ live }) {
|
|
|
43
44
|
${mcp ? html`<div><p class="small">Claude Desktop, merge into mcpServers:</p><textarea readonly value=${JSON.stringify(mcp.desktop, null, 2)}></textarea><p class="small">Claude Code:</p><textarea readonly value=${mcp.code}></textarea></div>` : ""}
|
|
44
45
|
</div>
|
|
45
46
|
${s.hosted ? "" : html`<h2>My providers</h2>
|
|
46
|
-
<div class="card"
|
|
47
|
-
|
|
47
|
+
<div class="card">
|
|
48
|
+
${s.providers.length && !s.providers.some((p) => p.id === s.active_provider_id) ? html`<div class="block warn"><b>No provider is switched on.</b> Turn one on below, or nothing can run. Exactly one is on at a time.</div>` : ""}
|
|
49
|
+
<ul class="list">${s.providers.map((p) => html`<li key=${p.id}>
|
|
50
|
+
<label class="switch" title=${s.active_provider_id === p.id ? "on: this provider runs your documents" : "off"}><input type="checkbox" checked=${s.active_provider_id === p.id} onChange=${(e) => put({ active_provider_id: e.target.checked ? p.id : null })} /><span class="track"></span><span class="small">${s.active_provider_id === p.id ? "on" : "off"}</span></label>
|
|
48
51
|
<b>${p.label}</b> <span class="tag">${p.kind}</span> <span class="muted">${p.model || ""}</span> <span class="mono small">${p.key || "no key"}</span> <span class="muted small">${p.base_url || ""}</span>
|
|
49
52
|
${p.last_test ? html`<span class=${"tag " + (p.last_test.ok ? "passed" : "blocked")}>${p.last_test.ok ? "ok" : "failed"}</span>` : ""}
|
|
50
53
|
<button class="small" onClick=${() => post(`/api/settings/providers/${p.id}/test`, {}, "test")}>test</button>
|
|
@@ -87,6 +90,14 @@ export function Settings({ live }) {
|
|
|
87
90
|
</form>`}
|
|
88
91
|
${s.credits && s.credits.last_balance ? html`<p class="small">${s.credits.last_balance.available} credits available (${s.credits.last_balance.reserved} held), checked ${s.credits.last_balance.at}</p>` : ""}
|
|
89
92
|
</div>
|
|
93
|
+
<h2>Usage</h2>
|
|
94
|
+
<div class="card">
|
|
95
|
+
${usage && usage.by_op.length ? html`<table class="cats"><thead><tr><th>operation</th><th>runs</th><th>passed</th><th>tokens in</th><th>tokens out</th><th>avg seconds</th><th>model</th></tr></thead><tbody>
|
|
96
|
+
${usage.by_op.map((u) => html`<tr key=${u.op}><td>${u.op}</td><td>${u.attempts}</td><td>${u.passed}</td><td>${u.tokens_in.toLocaleString()}</td><td>${u.tokens_out.toLocaleString()}</td><td>${(u.avg_ms / 1000).toFixed(1)}</td><td class="small muted">${u.model || ""}</td></tr>`)}
|
|
97
|
+
<tr><td><b>total</b></td><td>${usage.by_op.reduce((n, u) => n + u.attempts, 0)}</td><td>${usage.by_op.reduce((n, u) => n + u.passed, 0)}</td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_in, 0).toLocaleString()}</b></td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_out, 0).toLocaleString()}</b></td><td></td><td></td></tr>
|
|
98
|
+
</tbody></table><p class="small muted">Counted from every run through your own provider or credits since ${usage.since ? new Date(usage.since).toLocaleDateString() : "the start"}. Paste runs are not counted; the app never sees those token counts. Multiply by your provider's price per million tokens for the cost.</p>`
|
|
99
|
+
: html`<p class="small muted">Nothing counted yet. Runs through your own provider key or credits record their token counts here.</p>`}
|
|
100
|
+
</div>
|
|
90
101
|
<h2>Bullet register</h2>
|
|
91
102
|
<div class="card">
|
|
92
103
|
${[["result_first", "Result first (RAS): the figure leads, the action follows. \"29.3% off annual freight by rebidding 40 lanes.\""], ["action_first", "Action first: the verb leads, the result closes. \"Rebid 40 lanes, taking 29.3% off annual freight.\""], ["posting", "Let the posting decide: verb-led responsibility lines get action first, outcome-led lines get result first; the AI says which in its notes."]].map(([r, label]) => html`<div key=${r}><label><input type="radio" name="register" checked=${(h.bullet_register || "result_first") === r} onChange=${() => hr({ bullet_register: r })} /> ${label}</label></div>`)}
|
package/ui/screens/welcome.js
CHANGED
|
@@ -84,20 +84,22 @@ export function Welcome({ live, onDone }) {
|
|
|
84
84
|
const [modal, setModal] = useState(null);
|
|
85
85
|
const [answering, setAnswering] = useState(null);
|
|
86
86
|
const [busy, setBusy] = useState("");
|
|
87
|
+
const [view, setView] = useState(null); // a step the user went back to; null follows the next undone step
|
|
87
88
|
const load = () => Promise.all([api.get("/api/setup"), api.get("/api/onboarding"), api.get("/api/kb"), api.get("/api/me")]).then(([s, o, k, m]) => { setSetup(s); setOb(o); setKb(k); setMe(m); if (s.complete) onDone(); }).catch((e) => setErr(e.message));
|
|
88
89
|
useEffect(() => { load(); }, [live]);
|
|
89
90
|
const post = async (path, body) => { try { setErr(""); await api.post(path, body || {}); await load(); } catch (e) { setErr(e.message); } };
|
|
90
91
|
if (!setup || !ob || !kb || !me) return html`<div class="welcome"><p class="muted">loading</p></div>`;
|
|
91
92
|
const direct = me.route === "byo_key" || me.route === "credits";
|
|
92
|
-
const
|
|
93
|
-
const idx =
|
|
93
|
+
const nextIdx = setup.steps.findIndex((x) => x.key === setup.next);
|
|
94
|
+
const idx = view !== null && view <= Math.max(nextIdx, 0) ? view : nextIdx;
|
|
95
|
+
const step = setup.steps[idx].key;
|
|
94
96
|
const discover = async () => {
|
|
95
97
|
if (!direct) { setModal("discover"); return; }
|
|
96
98
|
setBusy("asking"); try { const r = await api.post("/api/run/discover", {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); await load(); } catch (e) { setErr(e.message); } setBusy("");
|
|
97
99
|
};
|
|
98
100
|
const body = {
|
|
99
101
|
ai: html`<${StepAi} s=${setup} reload=${load} />`,
|
|
100
|
-
upload: html`<div><p>
|
|
102
|
+
upload: html`<div><p>Your resumes: Word, PDF, Markdown, or plain text. Five to ten of your most different versions is plenty; near-identical copies only add duplicates to merge. The text is read on this PC. Nothing is uploaded anywhere.</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
|
|
101
103
|
extract: html`<div><p>${direct ? "Your AI reads each file and proposes records where every number and phrase is quoted from the file; anything it cannot quote is refused. One click runs the whole pile." : "For each file, Extract opens a packet: copy it into your chat, paste the JSON reply back. Your AI proposes records where every number and phrase is quoted from the file."}</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
|
|
102
104
|
confirm: html`<div><p>These are the records your AI proposed. Confirm the ones you can stand behind in an interview, edit what is off, dismiss the rest. Only confirmed records can ever appear in a document.</p>
|
|
103
105
|
<ul class="list">${kb.pending.map((p) => html`<li key=${p.pending_id}><div><b>${p.draft.title}</b> <span class="muted">${p.draft.company}, ${p.draft.role}</span>
|
|
@@ -114,10 +116,11 @@ export function Welcome({ live, onDone }) {
|
|
|
114
116
|
};
|
|
115
117
|
return html`<div class="welcome">
|
|
116
118
|
<div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted">Six short steps, then the app is yours.</div></div>
|
|
117
|
-
<a class="small" href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", {}); onDone(); }}>skip for now</a></div>
|
|
118
|
-
<ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${st.done ? "done" : i === idx ? "now" : ""}><span class="n mono">${st.done ? "✓" : i + 1}</span>${st.title}</li>`)}</ol>
|
|
119
|
+
<span class="links"><a class="small" href="#/settings">Settings</a><a class="small" href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", {}); onDone(); }}>skip for now</a></span></div>
|
|
120
|
+
<ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${(st.done ? "done" : "") + (i === idx ? " now" : "") + (i <= nextIdx ? " can" : "")} onClick=${() => { if (i <= nextIdx) setView(i); }} title=${i <= nextIdx ? "open this step" : ""}><span class="n mono">${st.done ? "✓" : i + 1}</span>${st.title}</li>`)}</ol>
|
|
119
121
|
${err ? html`<p class="error">${err}</p>` : ""}
|
|
120
|
-
<div class="card welcome-body"><h3>${idx + 1}. ${setup.steps[idx].title}</h3>${body[step]}
|
|
122
|
+
<div class="card welcome-body"><h3>${idx + 1}. ${setup.steps[idx].title}</h3>${body[step]}
|
|
123
|
+
<div class="row welcome-nav">${idx > 0 ? html`<button class="small" onClick=${() => setView(idx - 1)}>← Back</button>` : ""}${idx < nextIdx ? html`<button class="small" onClick=${() => setView(null)}>Continue →</button>` : ""}</div></div>
|
|
121
124
|
${modal === "discover" ? html`<${PacketModal} op="discover" label="Discovery questions" target=${{}} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
|
|
122
125
|
</div>`;
|
|
123
126
|
}
|