@gafj/gafj 0.1.4 → 0.1.7
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 +17 -2
- package/package.json +1 -1
- package/store/onboarding.js +21 -2
- package/store/setup.js +4 -1
- package/ui/app.css +12 -0
- package/ui/app.js +2 -2
- package/ui/lib.js +1 -1
- package/ui/screens/kb.js +1 -0
- 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
|
@@ -81,6 +81,7 @@ route("POST", "/api/sources", async (c, p, b) => {
|
|
|
81
81
|
const buffer = Buffer.from(need(b, "data_base64"), "base64");
|
|
82
82
|
return onboarding.addSource(c.db, { candidate_id: c.candidate_id, batch_id: batch, filename: need(b, "filename"), buffer, now: c.now(), actor: ACTOR("upload") });
|
|
83
83
|
});
|
|
84
|
+
route("DELETE", "/api/sources/:id", (c, p) => onboarding.removeSource(c.db, { candidate_id: c.candidate_id, source_document_id: p.id, now: c.now(), actor: ACTOR("remove_source") }));
|
|
84
85
|
route("GET", "/api/sources/:id", (c, p) => { const s = onboarding.sourceText(c.db, p.id); if (s.candidate_id !== c.candidate_id) throw bad("unknown source document", 404); return { source_document_id: s.id, filename: s.filename, kind: s.kind, extractor: s.extractor, extractor_version: s.extractor_version, text: s.text }; });
|
|
85
86
|
route("POST", "/api/onboarding/batches/:id/dedup", (c, p) => onboarding.suggestDedup(c.db, { candidate_id: c.candidate_id, batch_id: p.id, now: c.now(), actor: ACTOR("dedup") }));
|
|
86
87
|
route("POST", "/api/kb/groups/:id/merge", (c, p, b) => onboarding.mergeGroup(c.db, { candidate_id: c.candidate_id, dedup_group_id: p.id, keep_id: b.keep_id, now: c.now(), actor: ACTOR("merge") }));
|
|
@@ -151,6 +152,14 @@ route("GET", "/api/kb/questions", (c, p, b, q) => discover.listQuestions(c.db, c
|
|
|
151
152
|
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
153
|
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
154
|
|
|
155
|
+
// usage: tokens per operation from the attempts the app recorded; the pricing test reads this
|
|
156
|
+
route("GET", "/api/usage", (c) => ({
|
|
157
|
+
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,
|
|
158
|
+
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
|
|
159
|
+
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),
|
|
160
|
+
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,
|
|
161
|
+
}));
|
|
162
|
+
|
|
154
163
|
// guided setup (first run)
|
|
155
164
|
const setup = require("../store/setup");
|
|
156
165
|
route("GET", "/api/setup", (c) => setup.setupView(c.db, c.home, c.candidate_id));
|
|
@@ -194,18 +203,24 @@ route("POST", "/api/run/:op", async (c, p, b, q) => {
|
|
|
194
203
|
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
204
|
const routeName = cfg.route;
|
|
196
205
|
let reply;
|
|
206
|
+
// resolve the provider before a run row exists: one saved provider is the active one even if nobody clicked the radio
|
|
207
|
+
let providerId = null;
|
|
208
|
+
if (routeName === "byo_key") {
|
|
209
|
+
providerId = b.provider_id || cfg.active_provider_id || ((cfg.providers || []).length === 1 ? cfg.providers[0].id : null);
|
|
210
|
+
if (!providerId) throw bad((cfg.providers || []).length ? "pick the active provider in Settings" : "add a provider key in Settings", 409);
|
|
211
|
+
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") });
|
|
212
|
+
}
|
|
197
213
|
if (p.op === "discover") require("../store/discover").assertCanOpen(c.db, c.candidate_id);
|
|
198
214
|
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
215
|
if (routeName === "credits") {
|
|
200
216
|
const { runPacket } = require("../bridge/credits");
|
|
201
217
|
reply = await runPacket(c.home, { packet: opened.packet, fetchImpl: c.fetch, auth: c.relayAuth });
|
|
202
218
|
} else {
|
|
203
|
-
const providerId = b.provider_id || cfg.active_provider_id;
|
|
204
|
-
if (!providerId) throw bad("no active provider", 409);
|
|
205
219
|
const { runPacket } = require("../bridge/byo_key");
|
|
206
220
|
reply = await runPacket(c.home, providerId, { packet: opened.packet, fetchImpl: c.fetch });
|
|
207
221
|
}
|
|
208
222
|
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 });
|
|
223
|
+
if (r.status !== "passed" && reply.stop_reason === "max_tokens") r.errors = [...(r.errors || []), "the reply was cut off at the token limit"];
|
|
209
224
|
return reply.credits_left === undefined ? r : { ...r, charged: reply.charged, credits_left: reply.credits_left };
|
|
210
225
|
});
|
|
211
226
|
|
package/package.json
CHANGED
package/store/onboarding.js
CHANGED
|
@@ -70,6 +70,24 @@ function listSources(db, candidate_id, batch_id) {
|
|
|
70
70
|
.map((s) => ({ ...s, empty: s.chars < 20 }));
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** Remove an uploaded file and everything that came only from it: its pending rows, its extraction runs and attempts. Confirmed records stay; they are the candidate's, not the file's. */
|
|
74
|
+
function removeSource(db, { candidate_id, source_document_id, now, actor }) {
|
|
75
|
+
return withTx(db, (d) => {
|
|
76
|
+
const s = d.prepare("SELECT id, filename FROM source_document WHERE id = ? AND candidate_id = ?").get(source_document_id, candidate_id);
|
|
77
|
+
if (!s) throw new NotFound("source document");
|
|
78
|
+
const pending = d.prepare("DELETE FROM pending_accomplishment WHERE source_document_id = ?").run(s.id).changes;
|
|
79
|
+
const runs = d.prepare("SELECT id FROM ai_run WHERE source_document_id = ?").all(s.id).map((r) => r.id);
|
|
80
|
+
for (const id of runs) {
|
|
81
|
+
d.prepare("UPDATE pending_accomplishment SET ai_attempt_id = NULL WHERE ai_attempt_id IN (SELECT id FROM ai_attempt WHERE ai_run_id = ?)").run(id);
|
|
82
|
+
d.prepare("DELETE FROM ai_attempt WHERE ai_run_id = ?").run(id);
|
|
83
|
+
d.prepare("DELETE FROM ai_run WHERE id = ?").run(id);
|
|
84
|
+
}
|
|
85
|
+
d.prepare("DELETE FROM source_document WHERE id = ?").run(s.id);
|
|
86
|
+
appendEvent(d, { candidate_id, entity: "source_document", entity_id: s.id, event: "removed", actor_type: actor.type, actor_ref: actor.ref, payload: { filename: s.filename, pending, runs: runs.length }, at: now });
|
|
87
|
+
return { source_document_id: s.id, pending_removed: pending, runs_removed: runs.length };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
function sourceText(db, source_document_id) {
|
|
74
92
|
const s = db.prepare("SELECT * FROM source_document WHERE id = ?").get(source_document_id);
|
|
75
93
|
if (!s) throw new NotFound("source document");
|
|
@@ -145,7 +163,8 @@ function saveExtraction(db, { run_id, content, route, actor, now, provider, mode
|
|
|
145
163
|
const ex = extractJson(content);
|
|
146
164
|
let result;
|
|
147
165
|
let parsed = null;
|
|
148
|
-
|
|
166
|
+
// the reply is never stored; on a schema miss its opening is echoed back to the caller once so the failure can be read
|
|
167
|
+
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
168
|
else { parsed = ex.value; result = { accepted: false, errors: [], pending_ids: [] }; }
|
|
150
169
|
const attemptId = ulid(Date.parse(now) + 100 + attemptNo);
|
|
151
170
|
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)
|
|
@@ -264,4 +283,4 @@ function batchView(db, candidate_id) {
|
|
|
264
283
|
return { batch_id: batch.id, status: batch.status, created_at: batch.created_at, sources, pending, groups: [...new Set(pending.map((p) => p.dedup_group_id).filter(Boolean))] };
|
|
265
284
|
}
|
|
266
285
|
|
|
267
|
-
module.exports = { openBatch, setBatchStatus, addSource, listSources, sourceText, checkDraft, proposeFromSource, saveExtraction, suggestDedup, mergeGroup, keepSeparate, proposeStyle, batchView, MAX_ATTEMPTS };
|
|
286
|
+
module.exports = { openBatch, setBatchStatus, addSource, removeSource, listSources, sourceText, checkDraft, proposeFromSource, saveExtraction, suggestDedup, mergeGroup, keepSeparate, proposeStyle, batchView, MAX_ATTEMPTS };
|
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/lib.js
CHANGED
|
@@ -9,7 +9,7 @@ async function req(method, path, body) {
|
|
|
9
9
|
if (!r.ok) { const e = new Error(data.error || r.statusText); e.status = r.status; e.allowed = data.allowed; e.detail = data.detail; throw e; }
|
|
10
10
|
return data;
|
|
11
11
|
}
|
|
12
|
-
export const api = { get: (p) => req("GET", p), post: (p, b) => req("POST", p, b === undefined ? {} : b), put: (p, b) => req("PUT", p, b === undefined ? {} : b) };
|
|
12
|
+
export const api = { get: (p) => req("GET", p), post: (p, b) => req("POST", p, b === undefined ? {} : b), put: (p, b) => req("PUT", p, b === undefined ? {} : b), del: (p) => req("DELETE", p, {}) };
|
|
13
13
|
|
|
14
14
|
export function fmtWhen(iso, tz) {
|
|
15
15
|
if (!iso) return "";
|
package/ui/screens/kb.js
CHANGED
|
@@ -58,6 +58,7 @@ export function Onboarding({ d, reload, setErr, route, compact }) {
|
|
|
58
58
|
${s.empty ? html`<span class="tag blocked">no text</span>` : html`<span class="muted small">${s.chars} chars</span>`}
|
|
59
59
|
<span class="muted small">${s.pending} pending, ${s.runs} runs</span>
|
|
60
60
|
${!s.empty ? html`<button class="small" disabled=${running[s.source_document_id] === "running"} onClick=${() => extract(s)}>${running[s.source_document_id] === "running" ? "extracting" : direct ? "Extract" : "Extract (paste)"}</button>` : ""}
|
|
61
|
+
<button class="small danger" title="remove this file and anything proposed from it; confirmed records stay" onClick=${async () => { if (!confirm(`Remove ${s.filename}? Records already confirmed stay; pending rows from this file go.`)) return; try { await api.del(`/api/sources/${s.source_document_id}`); await reload(); } catch (e) { setErr(e.message); } }}>remove</button>
|
|
61
62
|
${running[s.source_document_id] && running[s.source_document_id] !== "running" ? html`<span class="small muted">${running[s.source_document_id]}</span>` : ""}</li>`)}
|
|
62
63
|
${b.sources.length ? "" : html`<li class="muted">upload a resume to start</li>`}</ul>` : ""}
|
|
63
64
|
${b && b.pending.length ? html`<div class="row"><button onClick=${() => post(`/api/onboarding/batches/${b.batch_id}/dedup`)}>Suggest duplicates</button><span class="small muted">same employer, overlapping dates, a shared figure; nothing merges without you</span></div>` : ""}
|
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
|
}
|