@gafj/gafj 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -28,6 +28,7 @@ Status: phases 1 through 6 built and tested (82 public tests on the synthetic ca
28
28
  - Bullet register (screen 6): `house_rules.bullet_register` is `result_first` (default, RAS), `action_first`, or `posting`; it travels in the resume packet's HOUSE RULES and `core/rules/resume.md` says what each means. Same facts, same claims, same gate; only word order.
29
29
  - Backup folder (screen 6): `config.backup_dir`, meant for a synced folder (Drive, OneDrive, Dropbox); `store/backup_dir.js` writes dated consistent copies there (`gafj backup`, the Back up now button, and one a day at `serve` start), keeps the newest 14, never prunes a pre-restore copy, and refuses the data folder itself. The live store never goes to a synced folder.
30
30
  - Hosted tier (plan W, gates 2 and 4 as code): `http/hosted.js` runs the same routes, screens, and doors behind a Google sign-in with one store per user under `<root>/tenants/<uid>/`; the session names the uid, the uid names the folder, and no query spans tenants (`test/hosted.test.js` drives every parameterized route as user B with user A's ids and expects 404 and no leaked text). Provider keys, MCP config, and the restore test are 404 for hosted users; the model is reached through the relay with a service key and the uid (`x-gafj-service`, `x-gafj-uid`; the relay's second auth path), never a stored user token. `/account/export` streams a restorable backup, `/account/delete` removes the folder and every session. `http/identity.js` verifies Firebase ID tokens with node:crypto against Google's JWK set, no SDK. `ui/hosted/signin.html` is the one page with a loosened CSP (Firebase Auth from gstatic). `gafj serve-hosted` is configured by environment only; `deploy/hosted/` holds the Dockerfile and fly.toml; `functions/relay/deploy.js` builds the relay with Firebase Admin, Stripe, and the Anthropic SDK and `functions/index.js` exports it as `relay`. Nothing is deployed; `docs/operator-setup.md` is the list of what only the operator can do.
31
+ - First run: `ui/screens/welcome.js` gates the app until the six setup steps are done or skipped (`store/setup.js`, `/api/setup`): which AI (key saved and tested inline; paste; Claude Desktop config inline), upload, extract (Extract all runs every file through the active provider; paste opens a packet per file), confirm pending inline, discovery questions inline, first posting inline. The dashboard carries the same checklist afterwards. Provider form is provider plus key with an advanced fold; the first provider becomes active and the route switches to it.
31
32
  - `bin/cli.js`: `start` (the one command: candidate if none, then url or serve), `migrate`, `import`, `snapshot`, `review`, `lint`, `list`, `score-freeze`, `packet`, `ingest`, `render`, `export`, `backup`, `restore`, `mcp`, `print-mcp-config`, `serve`, `url`, `rotate-token`, `install-startup`, `allow-host`, `init`, `onboard`; every other command names its phase and exits
32
33
 
33
34
  Onboarding another candidate, phase 5b:
package/http/api.js CHANGED
@@ -151,6 +151,12 @@ 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
+ // guided setup (first run)
155
+ const setup = require("../store/setup");
156
+ route("GET", "/api/setup", (c) => setup.setupView(c.db, c.home, c.candidate_id));
157
+ route("POST", "/api/setup/ai", (c, p, b) => { const r = setup.chooseAi(c.db, c.home, c.candidate_id, { route: need(b, "route"), now: c.now(), actor: ACTOR("setup") }); if (c.reload) c.reload(); return r; });
158
+ route("POST", "/api/setup/dismiss", (c, p, b) => setup.dismiss(c.db, c.home, c.candidate_id, { dismissed: b.dismissed !== false }));
159
+
154
160
  // settings (screen 6)
155
161
  const settings = require("../store/settings");
156
162
  route("GET", "/api/settings", (c) => settings.getSettings(c.db, c.home, c.candidate_id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gafj/gafj",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
5
5
  "homepage": "https://gaf-j.com",
6
6
  "repository": {
package/store/setup.js ADDED
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The guided setup (first run): six steps in the order they must happen,
5
+ * each computed from the store, so the checklist is never out of date and
6
+ * nothing is stored except two flags in config: which AI route the user
7
+ * chose on purpose (the default is paste, which says nothing), and whether
8
+ * they dismissed the card. The card leaves on its own when every step is done.
9
+ */
10
+
11
+ const { readConfig, writeConfig } = require("./config");
12
+
13
+ const ROUTE_LABEL = { paste: "Paste", mcp: "Claude Desktop", byo_key: "My providers", credits: "Credits" };
14
+
15
+ function setupView(db, homeDir, candidate_id) {
16
+ const cfg = readConfig(homeDir);
17
+ const one = (sql, ...a) => db.prepare(sql).get(...a).c;
18
+ const sources = one("SELECT count(*) c FROM source_document WHERE candidate_id = ?", candidate_id);
19
+ const extracted = one("SELECT count(*) c FROM ai_run WHERE candidate_id = ? AND operation = 'extract' AND final_result = 'passed'", candidate_id);
20
+ const pending = one("SELECT count(*) c FROM pending_accomplishment WHERE candidate_id = ?", candidate_id);
21
+ const confirmed = one("SELECT count(*) c FROM accomplishment WHERE candidate_id = ?", candidate_id);
22
+ const questions = one("SELECT count(*) c FROM kb_question WHERE candidate_id = ?", candidate_id);
23
+ const postings = one("SELECT count(*) c FROM posting WHERE candidate_id = ?", candidate_id);
24
+ const aiChosen = !!(cfg.setup && cfg.setup.ai_chosen);
25
+ const steps = [
26
+ { key: "ai", title: "Tell GAF-J which AI you use", done: aiChosen, detail: aiChosen ? `Using ${ROUTE_LABEL[cfg.route] || cfg.route}` : null },
27
+ { key: "upload", title: "Upload your resumes", done: sources > 0, detail: sources ? `${sources} file${sources === 1 ? "" : "s"} uploaded` : null },
28
+ { key: "extract", title: "Extract records from them", done: extracted > 0 || pending > 0 || confirmed > 0, detail: extracted ? `${extracted} extraction${extracted === 1 ? "" : "s"} saved` : null },
29
+ { key: "confirm", title: "Confirm the records you stand behind", done: confirmed > 0, detail: confirmed ? `${confirmed} confirmed${pending ? `, ${pending} still pending` : ""}` : (pending ? `${pending} waiting for you` : null) },
30
+ { key: "discover", title: "Answer what's missing", done: questions > 0, detail: questions ? `${questions} question${questions === 1 ? "" : "s"} asked` : null },
31
+ { key: "posting", title: "Paste your first job posting", done: postings > 0, detail: postings ? `${postings} posting${postings === 1 ? "" : "s"}` : null },
32
+ ];
33
+ const next = steps.find((s) => !s.done);
34
+ return { steps, next: next ? next.key : null, complete: !next, dismissed: !!(cfg.setup && cfg.setup.dismissed), route: cfg.route || "paste" };
35
+ }
36
+
37
+ /** The user picked how they will use AI; the route is set and the step is done. */
38
+ function chooseAi(db, homeDir, candidate_id, { route, now, actor }) {
39
+ const settings = require("./settings");
40
+ settings.putSettings(db, homeDir, candidate_id, { route }, { now, actor });
41
+ const cfg = readConfig(homeDir);
42
+ cfg.setup = { ...(cfg.setup || {}), ai_chosen: true };
43
+ writeConfig(homeDir, cfg);
44
+ return setupView(db, homeDir, candidate_id);
45
+ }
46
+
47
+ function dismiss(db, homeDir, candidate_id, { dismissed = true } = {}) {
48
+ const cfg = readConfig(homeDir);
49
+ cfg.setup = { ...(cfg.setup || {}), dismissed: !!dismissed };
50
+ writeConfig(homeDir, cfg);
51
+ return setupView(db, homeDir, candidate_id);
52
+ }
53
+
54
+ module.exports = { setupView, chooseAi, dismiss };
package/ui/app.css CHANGED
@@ -135,3 +135,38 @@ input.narrow { width: 76px; }
135
135
  pre.wrap { white-space: pre-wrap; }
136
136
  /* KB "receipt" moment: the confirmed span highlighted inline against its muted source sentence. */
137
137
  mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px; padding: 0 3px; font-weight: 500; }
138
+
139
+ /* guided setup on the dashboard: the next step is open with its words and its button; done steps collapse to a line */
140
+ .setup { margin-top: 10px; border-color: var(--accent); }
141
+ .setup-steps { list-style: none; margin: 10px 0 0; padding: 0; }
142
+ .setup-steps li { display: grid; grid-template-columns: 28px 1fr; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); align-items: start; }
143
+ .setup-steps li:first-child { border-top: 0; }
144
+ .setup-steps .n { width: 24px; height: 24px; border-radius: 50%; border: 1px solid var(--line-2); display: inline-flex; align-items: center; justify-content: center; font-size: 11px; color: var(--muted); }
145
+ .setup-steps li.done .n { background: var(--good-bg); color: var(--good-bright); border-color: var(--good-bg); }
146
+ .setup-steps li.next .n { border-color: var(--accent); color: var(--accent); font-weight: 600; }
147
+ .setup-steps li.done .t { color: var(--muted); }
148
+ .setup-steps li.later .t { color: var(--dim); }
149
+ .setup-steps li.next .t { font-weight: 600; }
150
+ .setup-steps p { margin: 4px 0 8px; color: var(--muted); max-width: 66ch; }
151
+ .ai-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-top: 4px; }
152
+ .ai-options button { text-align: left; display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; height: 100%; }
153
+ .ai-options button:hover { border-color: var(--accent); }
154
+ .provider-form details.advanced { width: 100%; }
155
+ .provider-form details.advanced input { margin: 6px 6px 0 0; min-width: 240px; }
156
+
157
+ /* the first-run screen: no rail, one step at a time */
158
+ .welcome { max-width: 860px; margin: 0 auto; padding: 36px 24px 60px; background: var(--surface); min-height: 100vh; }
159
+ .welcome-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
160
+ .welcome-head img { border-radius: 9px; }
161
+ .welcome-head a { margin-left: auto; }
162
+ .stepper { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12.5px; color: var(--dim); }
163
+ .stepper li { display: inline-flex; align-items: center; gap: 6px; }
164
+ .stepper li.done { color: var(--muted); }
165
+ .stepper li.now { color: var(--ink); font-weight: 600; }
166
+ .stepper .n { width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--line-2); display: inline-flex; align-items: center; justify-content: center; font-size: 10.5px; }
167
+ .stepper li.done .n { background: var(--good-bg); color: var(--good-bright); border-color: var(--good-bg); }
168
+ .stepper li.now .n { border-color: var(--accent); color: var(--accent); }
169
+ .welcome-body h3 { margin-top: 0; }
170
+ .welcome-body p { color: var(--muted); max-width: 66ch; }
171
+ .welcome-body .card { border: 0; padding: 0; background: transparent; }
172
+ .ai-options button.picked { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
package/ui/app.js CHANGED
@@ -7,6 +7,7 @@ import { Document } from "./screens/document.js";
7
7
  import { Postings } from "./screens/postings.js";
8
8
  import { KB } from "./screens/kb.js";
9
9
  import { Settings } from "./screens/settings.js";
10
+ import { Welcome } from "./screens/welcome.js";
10
11
 
11
12
  function Rail({ route, me }) {
12
13
  const is = (p) => (route.path === p || (p !== "/" && route.path.startsWith(p)) ? "active" : "");
@@ -57,8 +58,12 @@ function App() {
57
58
  const route = useRoute();
58
59
  const live = useLive();
59
60
  const [me, setMe] = useState(null);
61
+ const [gate, setGate] = useState(null); // null: unknown; true: show the first-run screen; false: the app
60
62
  useEffect(() => { api.get("/api/me").then(setMe).catch(() => setMe({ name: "no session", route: "", version: "" })); }, []);
63
+ useEffect(() => { if (gate === false) return; api.get("/api/setup").then((s) => setGate(!s.complete && !s.dismissed)).catch(() => setGate(false)); }, [live]);
61
64
  const seg = route.path.split("/").filter(Boolean);
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)} />`;
62
67
  let screen;
63
68
  if (seg[0] === "applications" && seg[1]) screen = html`<${Application} id=${seg[1]} live=${live} />`;
64
69
  else if (seg[0] === "applications") screen = html`<${Applications} live=${live} />`;
@@ -1,11 +1,50 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
2
  import { api, fmtWhen } from "../lib.js";
3
3
 
4
+ /** The six steps a new user takes, in order, with the words for each and where the button goes. */
5
+ const STEPS = {
6
+ ai: { why: "GAF-J never writes text itself. It hands a packet of your confirmed facts to an AI you already have, then checks every figure in the reply. Pick how you will connect one; you can change it later in Settings.", cta: null },
7
+ upload: { why: "Every resume you have, old ones too. Word, PDF, Markdown, or plain text. The text is read on this PC; nothing is uploaded anywhere.", cta: ["#/kb", "Open Knowledge base"] },
8
+ extract: { why: "Your AI reads each uploaded file and proposes records where every number and phrase is quoted from the file; anything it cannot quote is refused. With a provider key, Extract all runs the whole pile in one click; on the paste route each file is one packet you carry to your chat and back.", cta: ["#/kb", "Extract on Knowledge base"] },
9
+ discover_direct: null,
10
+ confirm: { why: "Under Pending, read each proposed record. 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.", cta: ["#/kb", "Review Pending"] },
11
+ discover: { why: "Click Ask what's missing. Your AI reads the confirmed records and asks the questions a coach would: the figure a record lacks, the size of a team, the story behind a bare bullet. Each answer strengthens a record.", cta: ["#/kb", "Ask what's missing"] },
12
+ posting: { why: "Paste the text of a job you want. GAF-J scores it against your record, shows what the posting asks for and what you have, and from there builds the resume, the interview prep, and the rest.", cta: ["#/postings", "Paste a posting"] },
13
+ };
14
+ const AI_OPTIONS = [
15
+ ["paste", "Paste", "Works with any chat you already pay for: ChatGPT, Claude, Gemini. Copy the packet, paste the reply. No keys, no setup."],
16
+ ["mcp", "Claude Desktop", "Claude talks to the app directly through a local connector. Print the config in Settings and add it to Claude Desktop."],
17
+ ["byo_key", "My own API key", "The app calls the provider itself with a key you add in Settings. The key never leaves this PC."],
18
+ ];
19
+
20
+ function Setup({ s, reload }) {
21
+ const [busy, setBusy] = useState(false);
22
+ const choose = async (route) => { setBusy(true); try { await api.post("/api/setup/ai", { route }); await reload(); } finally { setBusy(false); } };
23
+ const done = s.steps.filter((x) => x.done).length;
24
+ return html`<div class="card setup">
25
+ <div class="row between"><b>Getting started: ${done} of ${s.steps.length}</b><button class="small" onClick=${async () => { await api.post("/api/setup/dismiss", {}); reload(); }}>hide</button></div>
26
+ <ol class="setup-steps">${s.steps.map((st, i) => {
27
+ const meta = STEPS[st.key];
28
+ const isNext = s.next === st.key;
29
+ return html`<li key=${st.key} class=${st.done ? "done" : isNext ? "next" : "later"}>
30
+ <span class="n mono">${st.done ? "✓" : i + 1}</span>
31
+ <div>
32
+ <div class="t">${st.title}${st.detail ? html` <span class="muted small">· ${st.detail}</span>` : ""}</div>
33
+ ${isNext ? html`<p class="small">${meta.why}</p>
34
+ ${st.key === "ai" ? html`<div class="ai-options">${AI_OPTIONS.map(([r, label, text]) => html`<button key=${r} disabled=${busy} onClick=${() => choose(r)}><b>${label}</b><span class="small muted">${text}</span></button>`)}</div>`
35
+ : html`<div class="row"><a href=${meta.cta[0]}><button class="primary small">${meta.cta[1]}</button></a></div>`}` : ""}
36
+ </div></li>`;
37
+ })}</ol>
38
+ </div>`;
39
+ }
40
+
4
41
  export function Dashboard({ live }) {
5
42
  const [snap, setSnap] = useState(null);
43
+ const [setup, setSetup] = useState(null);
6
44
  const [msg, setMsg] = useState("");
7
45
  const [err, setErr] = useState("");
8
- useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); }, [live]);
46
+ const loadSetup = () => api.get("/api/setup").then(setSetup).catch(() => setSetup(null));
47
+ useEffect(() => { api.get("/api/snapshot").then(setSnap).catch((e) => setErr(e.message)); loadSetup(); }, [live]);
9
48
  const act = async (path, label) => { setMsg(""); setErr(""); try { const r = await api.post(path); setMsg(`${label}: ${r.file}`); } catch (e) { setErr(e.message); } };
10
49
  if (err && !snap) return html`<p class="error">${err}</p>`;
11
50
  if (!snap) return html`<p class="muted">loading</p>`;
@@ -13,6 +52,8 @@ export function Dashboard({ live }) {
13
52
  return html`<div>
14
53
  <h1>Dashboard</h1>
15
54
  <div class="sub">${new Date(snap.now).toLocaleString()}</div>
55
+ ${setup && !setup.complete && !setup.dismissed ? html`<${Setup} s=${setup} reload=${loadSetup} />` : ""}
56
+ ${setup && !setup.complete && setup.dismissed ? html`<p class="small muted">Setup is hidden. <a href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", { dismissed: false }); loadSetup(); }}>Show the steps again</a>.</p>` : ""}
16
57
  <h2>Needs you now</h2>
17
58
  <div class="card">${snap.needs_you.length ? html`<ul class="list">${snap.needs_you.map((n, i) => html`<li key=${i}>
18
59
  <span class=${"need " + (n.kind === "thankyou_due" || n.kind === "review" ? "soft" : "")}>●</span> <a href=${link(n)}>${n.text}</a></li>`)}</ul>` : html`<span class="muted">nothing waiting on you</span>`}</div>
package/ui/screens/kb.js CHANGED
@@ -7,11 +7,35 @@ function readFileBase64(file) {
7
7
  }
8
8
 
9
9
  /** Onboarding: upload resumes, build extraction packets, decide duplicate groups. */
10
- function Onboarding({ d, reload, setErr }) {
10
+ export function Onboarding({ d, reload, setErr, route, compact }) {
11
11
  const [modal, setModal] = useState(null);
12
12
  const [busy, setBusy] = useState(false);
13
13
  const [style, setStyle] = useState(null);
14
+ const [running, setRunning] = useState({});
14
15
  const b = d.batch;
16
+ const direct = route === "byo_key" || route === "credits";
17
+ // with a provider or credits the extraction runs here; otherwise the packet opens for paste
18
+ const runOne = async (s) => {
19
+ setRunning((r) => ({ ...r, [s.source_document_id]: "running" }));
20
+ try {
21
+ const r = await api.post(`/api/run/extract?source_document_id=${s.source_document_id}`, {});
22
+ setRunning((x) => ({ ...x, [s.source_document_id]: r.status === "passed" ? `${r.pending_ids.length} records proposed` : `refused: ${(r.errors || []).slice(0, 2).join("; ")}` }));
23
+ } catch (e) { setRunning((x) => ({ ...x, [s.source_document_id]: "failed: " + e.message })); }
24
+ };
25
+ const extract = async (s) => {
26
+ if (!direct) { setModal({ source: s }); return; }
27
+ await runOne(s);
28
+ await reload();
29
+ };
30
+ // one click for the whole pile: every file that has not been extracted yet, one after another
31
+ const [all, setAll] = useState(null);
32
+ const extractAll = async () => {
33
+ const todo = (b ? b.sources : []).filter((s) => !s.empty && s.pending === 0);
34
+ for (let i = 0; i < todo.length; i++) { setAll(`${i + 1} of ${todo.length}: ${todo[i].filename}`); await runOne(todo[i]); }
35
+ setAll(null);
36
+ await reload();
37
+ };
38
+ const notYet = b ? b.sources.filter((s) => !s.empty && s.pending === 0).length : 0;
15
39
  const upload = async (e) => {
16
40
  setBusy(true);
17
41
  try {
@@ -28,16 +52,19 @@ function Onboarding({ d, reload, setErr }) {
28
52
  const groups = b ? b.groups.map((g) => ({ id: g, rows: b.pending.filter((p) => p.dedup_group_id === g) })) : [];
29
53
  return html`<div class="card">
30
54
  <div class="row"><input type="file" multiple accept=".docx,.pdf,.md,.txt" disabled=${busy} onChange=${upload} /><span class="small muted">docx, pdf, md, txt; text is extracted locally, no OCR</span></div>
55
+ ${direct && notYet > 1 ? html`<div class="row"><button class="primary" disabled=${!!all} onClick=${extractAll}>${all ? "Extracting " + all : `Extract all ${notYet} files`}</button><span class="small muted">one after another through your AI; a few seconds each</span></div>` : ""}
56
+ ${!direct && notYet > 1 ? html`<p class="small muted">On the paste route each file is one packet you carry to your chat and back; with a provider key in Settings the whole pile runs in one click.</p>` : ""}
31
57
  ${b ? html`<ul class="list">${b.sources.map((s) => html`<li key=${s.source_document_id}><b>${s.filename}</b> <span class="tag">${s.kind}</span>
32
58
  ${s.empty ? html`<span class="tag blocked">no text</span>` : html`<span class="muted small">${s.chars} chars</span>`}
33
59
  <span class="muted small">${s.pending} pending, ${s.runs} runs</span>
34
- ${!s.empty ? html`<button class="small" onClick=${() => setModal({ source: s })}>Extract</button>` : ""}</li>`)}
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
+ ${running[s.source_document_id] && running[s.source_document_id] !== "running" ? html`<span class="small muted">${running[s.source_document_id]}</span>` : ""}</li>`)}
35
62
  ${b.sources.length ? "" : html`<li class="muted">upload a resume to start</li>`}</ul>` : ""}
36
63
  ${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>` : ""}
37
64
  ${groups.map((g) => html`<div class="block warn" key=${g.id}><b>These may be the same accomplishment</b>
38
65
  <ul class="list">${g.rows.map((p) => html`<li key=${p.pending_id}><span class="mono small">${p.pending_id.slice(-6)}</span> ${p.draft.title} <span class="muted small">${p.draft.company} · ${p.draft.metrics.join(", ")}</span> <button class="small" onClick=${() => post(`/api/kb/groups/${g.id}/merge`, { keep_id: p.pending_id })}>merge into this one</button></li>`)}</ul>
39
66
  <button class="small" onClick=${() => post(`/api/kb/groups/${g.id}/keep`)}>Keep separate</button></div>`)}
40
- <div class="row"><button class="small" onClick=${() => setStyle(style ? null : d.style)}>${style ? "hide" : "Proposed house style from these sources"}</button></div>
67
+ ${compact ? "" : html`<div class="row"><button class="small" onClick=${() => setStyle(style ? null : d.style)}>${style ? "hide" : "Proposed house style from these sources"}</button></div>`}
41
68
  ${style ? html`<pre class="mono small wrap">${JSON.stringify(style, null, 1)}</pre><p class="small muted">Set your choices on the settings screen; a new candidate defaults to warn.</p>` : ""}
42
69
  ${modal ? html`<${PacketModal} op="extract" label=${"Extract " + modal.source.filename} target=${{ source_document_id: modal.source.source_document_id, name: modal.source.filename }} onClose=${() => setModal(null)} onSaved=${reload} />` : ""}
43
70
  </div>`;
@@ -54,9 +81,9 @@ function Excerpt({ text, span }) {
54
81
  const EMPTY = { title: "", company: "", role: "", dates: "", summary: "", metrics: "", verbs: "", wordings: "", tenure: "" };
55
82
  const toDraft = (f) => ({ title: f.title, company: f.company, role: f.role, dates: f.dates, summary: f.summary,
56
83
  metrics: f.metrics.split(/[;,]/).map((s) => s.trim()).filter(Boolean), verbs: f.verbs.split(/[;,]/).map((s) => s.trim()).filter(Boolean), wordings: f.wordings.split(/\n/).map((s) => s.trim()).filter(Boolean), ...(f.tenure && f.tenure.trim() ? { tenure: f.tenure.trim() } : {}) });
57
- const fromDraft = (d) => ({ title: d.title || "", company: d.company || "", role: d.role || "", dates: d.dates || "", summary: d.summary || "", metrics: (d.metrics || []).join(", "), verbs: (d.verbs || []).join(", "), wordings: (d.wordings || []).join("\n"), tenure: d.tenure || "" });
84
+ export const fromDraft = (d) => ({ title: d.title || "", company: d.company || "", role: d.role || "", dates: d.dates || "", summary: d.summary || "", metrics: (d.metrics || []).join(", "), verbs: (d.verbs || []).join(", "), wordings: (d.wordings || []).join("\n"), tenure: d.tenure || "" });
58
85
 
59
- function DraftForm({ initial, onSubmit, label }) {
86
+ export function DraftForm({ initial, onSubmit, label }) {
60
87
  const [f, setF] = useState(initial || EMPTY);
61
88
  const up = (k) => (e) => setF({ ...f, [k]: e.target.value });
62
89
  return html`<form onSubmit=${(e) => { e.preventDefault(); onSubmit(toDraft(f)); }}>
@@ -81,7 +108,8 @@ export function KB({ live }) {
81
108
  const [ob, setOb] = useState(null);
82
109
  const [modal, setModal] = useState(null);
83
110
  const [texts, setTexts] = useState({});
84
- const load = () => Promise.all([api.get("/api/kb"), api.get("/api/onboarding")]).then(([x, o]) => { setD(x); setOb(o); setErr(""); }).catch((e) => setErr(e.message));
111
+ const [me, setMe] = useState(null);
112
+ const load = () => Promise.all([api.get("/api/kb"), api.get("/api/onboarding"), api.get("/api/me")]).then(([x, o, m]) => { setD(x); setOb(o); setMe(m); setErr(""); }).catch((e) => setErr(e.message));
85
113
  const showSource = async (id) => { if (texts[id]) return; const s = await api.get(`/api/sources/${id}`); setTexts({ ...texts, [id]: s.text }); };
86
114
  useEffect(() => { load(); }, [live]);
87
115
  const post = async (path, body) => { try { await api.post(path, body); await load(); } catch (e) { setErr(e.message); } };
@@ -94,14 +122,14 @@ export function KB({ live }) {
94
122
  <div class="sub">${d.confirmed} confirmed · ${d.gaps.length} gap${d.gaps.length === 1 ? "" : "s"} · ${(d.questions || []).length} question${(d.questions || []).length === 1 ? "" : "s"} · ${(d.holes || []).length} hole${(d.holes || []).length === 1 ? "" : "s"} · ${d.pending.length} pending · ${d.review.length} dimensions to review · revision ${d.kb_revision}</div>
95
123
  ${err ? html`<p class="error">${err}</p>` : ""}
96
124
  <h2>Onboarding (your resumes to records)</h2>
97
- ${ob ? html`<${Onboarding} d=${ob} reload=${load} setErr=${setErr} />` : ""}
125
+ ${ob ? html`<${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me && me.route} />` : ""}
98
126
  <h2>Gaps (questions from postings)</h2>
99
127
  <div class="card"><ul class="list">${d.gaps.map((g) => html`<li key=${g.gap_id}><div><b>?</b> ${g.company} asks: <i>"${g.requirement_text}"</i>. ${g.question}
100
128
  ${answering === g.gap_id ? html`<${DraftForm} label="Answer" onSubmit=${(draft) => { post(`/api/kb/gaps/${g.gap_id}/answer`, { draft }); setAnswering(null); }} />` : html`<button class="small" onClick=${() => setAnswering(g.gap_id)}>Answer</button>`}</div></li>`)}
101
129
  ${d.gaps.length ? "" : html`<li class="muted">no open gaps</li>`}</ul></div>
102
130
  <h2>Discovery (the AI asks, you answer)</h2>
103
131
  <div class="card"><p class="small muted">The shakedown after upload: the AI reads your confirmed records and asks what a coach would ask, one atom per question. Your answer lands pending against that record; nothing changes until you confirm it. Ask again once these are answered or dismissed.</p>
104
- <div class="row"><button disabled=${(d.questions || []).length > 0 || d.confirmed === 0} onClick=${() => setModal({ op: "discover" })}>Ask what's missing</button>
132
+ <div class="row"><button disabled=${(d.questions || []).length > 0 || d.confirmed === 0} onClick=${async () => { if (me && (me.route === "byo_key" || me.route === "credits")) { try { setErr(""); const r = await api.post("/api/run/discover", {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); await load(); } catch (e) { setErr(e.message); } } else setModal({ op: "discover" }); }}>Ask what's missing</button>
105
133
  <span class="small muted">${d.confirmed === 0 ? "confirm at least one record first" : (d.questions || []).length ? `${d.questions.length} open` : "twelve questions at most per round"}</span></div>
106
134
  <ul class="list">${(d.questions || []).map((q) => html`<li key=${q.question_id}><div><span class="tag">${q.kind}</span> <b>${q.title}</b> <span class="muted">${q.company}, ${q.role}</span><br />${q.question}${q.why ? html` <span class="small muted">(${q.why})</span>` : ""}
107
135
  ${answering === q.question_id ? html`<${DraftForm} label="Add answer as pending" initial=${fromDraft(q.draft)} onSubmit=${(draft) => { post(`/api/kb/questions/${q.question_id}/answer`, { draft }); setAnswering(null); }} />`
@@ -37,6 +37,25 @@ export function Postings({ live, id }) {
37
37
  const [rows, setRows] = useState([]);
38
38
  const [sel, setSel] = useState(null);
39
39
  const [form, setForm] = useState({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
40
+ const [guessed, setGuessed] = useState({ company: true, title: true });
41
+ // the paste is all a person should have to give; company and title are guessed from its first lines and can be corrected
42
+ const onPaste = (text) => {
43
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 12);
44
+ const next = { ...form, raw_text: text };
45
+ if (guessed.title) {
46
+ const t = lines.find((l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l));
47
+ next.title = t || "";
48
+ }
49
+ if (guessed.company) {
50
+ let c = "";
51
+ for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { c = m[1].trim(); break; } }
52
+ if (!c) { const i = lines.findIndex((l) => l === next.title); const cand = lines[i + 1] || ""; if (cand && cand.length <= 50 && !/^https?:/i.test(cand) && !/\d{4}/.test(cand)) c = cand.replace(/^[·|\-\s]+/, ""); }
53
+ next.company = c;
54
+ }
55
+ const url = lines.find((l) => /^https?:\/\//i.test(l));
56
+ if (url && !form.source_url) next.source_url = url;
57
+ setForm(next);
58
+ };
40
59
  const [err, setErr] = useState("");
41
60
  const [msg, setMsg] = useState("");
42
61
  const load = async () => { setRows(await api.get("/api/postings")); if (id) setSel(await api.get(`/api/postings/${id}`)); };
@@ -47,6 +66,7 @@ export function Postings({ live, id }) {
47
66
  const r = await api.post("/api/postings", form);
48
67
  await api.post(`/api/postings/${r.posting_id}/scan`);
49
68
  setForm({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
69
+ setGuessed({ company: true, title: true });
50
70
  location.hash = "#/postings/" + r.posting_id;
51
71
  setMsg(r.created ? "stored and scanned" : "already stored; scanned again");
52
72
  } catch (e2) { setErr(e2.message); }
@@ -61,10 +81,10 @@ export function Postings({ live, id }) {
61
81
  <div>
62
82
  <h2>Paste posting</h2>
63
83
  <form class="card" onSubmit=${store}>
64
- <div class="row"><input required placeholder="company" value=${form.company} onInput=${(e) => setForm({ ...form, company: e.target.value })} /><input required placeholder="title" value=${form.title} onInput=${(e) => setForm({ ...form, title: e.target.value })} /></div>
65
- <div class="row"><input placeholder="url" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /><input placeholder="location" value=${form.location} onInput=${(e) => setForm({ ...form, location: e.target.value })} /><input placeholder="comp text" value=${form.comp_text} onInput=${(e) => setForm({ ...form, comp_text: e.target.value })} /></div>
66
- <textarea required placeholder="raw posting text, stored verbatim, now" value=${form.raw_text} onInput=${(e) => setForm({ ...form, raw_text: e.target.value })}></textarea>
67
- <div class="row"><button class="primary">Store + scan</button><span class="small muted">local, no model</span></div>
84
+ <textarea required placeholder="Paste the whole job posting here, exactly as the site shows it. Company and title fill in below from what you paste." value=${form.raw_text} onInput=${(e) => onPaste(e.target.value)}></textarea>
85
+ <div class="row"><input required placeholder="company (guessed from the paste; fix if wrong)" value=${form.company} onInput=${(e) => { setGuessed({ ...guessed, company: false }); setForm({ ...form, company: e.target.value }); }} /><input required placeholder="title (guessed; fix if wrong)" value=${form.title} onInput=${(e) => { setGuessed({ ...guessed, title: false }); setForm({ ...form, title: e.target.value }); }} /></div>
86
+ <div class="row"><input placeholder="link to the posting (optional)" value=${form.source_url} onInput=${(e) => setForm({ ...form, source_url: e.target.value })} /></div>
87
+ <div class="row"><button class="primary">Store + scan</button><span class="small muted">stored on this PC and scored locally; no AI is called for this step</span></div>
68
88
  </form>
69
89
  <h2>Stored</h2>
70
90
  <div class="card"><ul class="list">${rows.map((r) => html`<li key=${r.posting_id}><a href=${"#/postings/" + r.posting_id}>${r.company}: ${r.title}</a> <span class="tag">${r.status}</span> ${r.scoring_frozen ? html`<span class="tag passed">frozen</span>` : html`<span class="tag warn">unscored</span>`} ${r.application_id ? html`<a class="small" href=${"#/applications/" + r.application_id}>application</a>` : ""} ${r.taken_down_at ? html`<span class="tag warn">taken down</span>` : ""}</li>`)}${rows.length ? "" : html`<li class="muted">none yet</li>`}</ul></div>
@@ -4,6 +4,8 @@ import { api, useInstall } from "../lib.js";
4
4
  /** Screen 6: AI route, providers (keys write-only), house rules per kind, data folder, backup, startup. */
5
5
 
6
6
  const KINDS = ["resume", "cover", "onepager", "email", "prep", "deep_answers", "practice", "cheatsheet"];
7
+ const KIND_LABEL = { anthropic: "Anthropic (Claude)", openai_compatible: "OpenAI, or any OpenAI-compatible server", gemini: "Google Gemini" };
8
+ const KIND_MODEL = { anthropic: "claude-fable-5-1" };
7
9
 
8
10
  export function Settings({ live }) {
9
11
  const [s, setS] = useState(null);
@@ -49,13 +51,25 @@ export function Settings({ live }) {
49
51
  <button class="small" onClick=${() => setProv({ id: p.id, kind: p.kind, label: p.label, model: p.model || "", base_url: p.base_url || "", api_key: "" })}>edit</button>
50
52
  <button class="small danger" onClick=${async () => { await fetch(`/api/settings/providers/${p.id}`, { method: "DELETE", headers: { "content-type": "application/json" }, body: "{}" }); load(); }}>remove</button></li>`)}
51
53
  ${s.providers.length ? "" : html`<li class="muted">none; add one to use the byo_key route</li>`}</ul>
52
- ${prov ? html`<form class="inline" onSubmit=${async (e) => { e.preventDefault(); try { if (prov.id) await api.put(`/api/settings/providers/${prov.id}`, prov); else await api.post("/api/settings/providers", prov); setProv(null); load(); } catch (e2) { setErr(e2.message); } }}>
53
- <select onChange=${(e) => setProv({ ...prov, kind: e.target.value })}>${["anthropic", "openai_compatible", "gemini"].map((k) => html`<option key=${k} value=${k} selected=${prov.kind === k}>${k}</option>`)}</select>
54
- <input placeholder="label" value=${prov.label} onInput=${(e) => setProv({ ...prov, label: e.target.value })} />
55
- <input placeholder="model" value=${prov.model} onInput=${(e) => setProv({ ...prov, model: e.target.value })} />
56
- <input placeholder="base url (blank for the vendor default; http only on localhost)" value=${prov.base_url} onInput=${(e) => setProv({ ...prov, base_url: e.target.value })} />
57
- <input type="password" placeholder=${prov.id ? "new key (blank keeps the stored one)" : "api key"} value=${prov.api_key} onInput=${(e) => setProv({ ...prov, api_key: e.target.value })} />
58
- <button class="primary">save</button><button type="button" onClick=${() => setProv(null)}>cancel</button></form>` : html`<button class="small" onClick=${() => setProv({ id: null, kind: "anthropic", label: "", model: "", base_url: "", api_key: "" })}>+ add</button>`}
54
+ ${prov ? html`<form class="inline provider-form" onSubmit=${async (e) => { e.preventDefault(); try {
55
+ const body = { ...prov, label: prov.label || KIND_LABEL[prov.kind] };
56
+ const saved = prov.id ? await api.put(`/api/settings/providers/${prov.id}`, body) : await api.post("/api/settings/providers", body);
57
+ // the first provider becomes the active one and the app switches to it; then the key is tried at once
58
+ if (!s.providers.length || !s.active_provider_id) await api.put("/api/settings", { active_provider_id: saved.id, route: "byo_key" });
59
+ setProv(null);
60
+ setMsg("testing the key");
61
+ const t = await api.post(`/api/settings/providers/${saved.id}/test`, {});
62
+ setMsg(t.ok ? `key works: ${t.message}` : `key failed: ${t.message}`);
63
+ load();
64
+ } catch (e2) { setErr(e2.message); } }}>
65
+ <select onChange=${(e) => setProv({ ...prov, kind: e.target.value })}>${Object.entries(KIND_LABEL).map(([k, label]) => html`<option key=${k} value=${k} selected=${prov.kind === k}>${label}</option>`)}</select>
66
+ <input type="password" required=${!prov.id} placeholder=${prov.id ? "new key (blank keeps the stored one)" : "paste your API key"} value=${prov.api_key} onInput=${(e) => setProv({ ...prov, api_key: e.target.value })} />
67
+ <button class="primary">save and test</button><button type="button" onClick=${() => setProv(null)}>cancel</button>
68
+ <details class="advanced"><summary class="small muted">advanced: name, model, base url</summary>
69
+ <input placeholder=${"name, default " + KIND_LABEL[prov.kind]} value=${prov.label} onInput=${(e) => setProv({ ...prov, label: e.target.value })} />
70
+ <input placeholder=${"model, default " + (KIND_MODEL[prov.kind] || "the provider's current default")} value=${prov.model} onInput=${(e) => setProv({ ...prov, model: e.target.value })} />
71
+ ${prov.kind === "openai_compatible" ? html`<input placeholder="base url, blank for api.openai.com; http only on localhost" value=${prov.base_url} onInput=${(e) => setProv({ ...prov, base_url: e.target.value })} />` : ""}
72
+ </details></form>` : html`<button class="small" onClick=${() => setProv({ id: null, kind: "anthropic", label: "", model: "", base_url: "", api_key: "" })}>+ add</button>`}
59
73
  <p class="small muted">Keys live in config.json under your user only, never in the store, never in a log. A read shows the last four characters.</p>
60
74
  </div>`}
61
75
  <h2>Credits</h2>
@@ -0,0 +1,123 @@
1
+ import { html, useState, useEffect } from "../vendor/preact.mjs";
2
+ import { api } from "../lib.js";
3
+ import { Onboarding, DraftForm, fromDraft } from "./kb.js";
4
+ import { PacketModal } from "./packet.js";
5
+
6
+ /**
7
+ * The first run, before the app: one step at a time, everything done on this
8
+ * screen. The order is the order the data needs: an AI to read with, files to
9
+ * read, records to confirm, questions to answer, a posting to aim at. Each
10
+ * step flips when the store says so; "skip for now" opens the app anyway.
11
+ */
12
+
13
+ const KIND_LABEL = { anthropic: "Anthropic (Claude)", openai_compatible: "OpenAI, or any OpenAI-compatible server", gemini: "Google Gemini" };
14
+
15
+ function StepAi({ s, reload }) {
16
+ const [pick, setPick] = useState(null);
17
+ const [key, setKey] = useState("");
18
+ const [kind, setKind] = useState("anthropic");
19
+ const [msg, setMsg] = useState("");
20
+ const [mcp, setMcp] = useState(null);
21
+ const choose = async (route) => { await api.post("/api/setup/ai", { route }); await reload(); };
22
+ const saveKey = async (e) => {
23
+ e.preventDefault(); setMsg("saving");
24
+ try {
25
+ const saved = await api.post("/api/settings/providers", { kind, label: KIND_LABEL[kind], model: "", base_url: "", api_key: key });
26
+ await api.put("/api/settings", { active_provider_id: saved.id, route: "byo_key" });
27
+ setMsg("testing the key");
28
+ const t = await api.post(`/api/settings/providers/${saved.id}/test`, {});
29
+ if (!t.ok) { setMsg("the key did not work: " + t.message); return; }
30
+ setMsg("key works");
31
+ await api.post("/api/setup/ai", { route: "byo_key" });
32
+ await reload();
33
+ } catch (e2) { setMsg(e2.message); }
34
+ };
35
+ return html`<div>
36
+ <p>GAF-J never writes text itself. It hands a packet of your confirmed facts to an AI you already have, then checks every figure that comes back. How will you connect one?</p>
37
+ <div class="ai-options">
38
+ <button class=${pick === "byo_key" ? "picked" : ""} onClick=${() => setPick("byo_key")}><b>My own API key</b><span class="small muted">Best. The app calls the provider itself, everything runs in one click. The key stays on this PC.</span></button>
39
+ <button class=${pick === "paste" ? "picked" : ""} onClick=${() => setPick("paste")}><b>Paste</b><span class="small muted">Any chat you already pay for: ChatGPT, Claude, Gemini. You copy a packet in and paste the reply back, each time.</span></button>
40
+ <button class=${pick === "mcp" ? "picked" : ""} onClick=${async () => { setPick("mcp"); setMcp(await api.get("/api/mcp-config")); }}><b>Claude Desktop</b><span class="small muted">Claude talks to the app directly through a local connector you add once.</span></button>
41
+ </div>
42
+ ${pick === "byo_key" ? html`<form class="inline" onSubmit=${saveKey} style="margin-top:12px">
43
+ <select value=${kind} onChange=${(e) => setKind(e.target.value)}>${Object.entries(KIND_LABEL).map(([k, l]) => html`<option key=${k} value=${k}>${l}</option>`)}</select>
44
+ <input type="password" required placeholder="paste your API key" value=${key} onInput=${(e) => setKey(e.target.value)} />
45
+ <button class="primary">Save and test</button>${msg ? html`<span class="small muted">${msg}</span>` : ""}
46
+ <p class="small muted" style="width:100%">Get a key at console.anthropic.com, platform.openai.com, or aistudio.google.com. It is stored in a file under your user only, never in the database, never in a log.</p></form>` : ""}
47
+ ${pick === "paste" ? html`<div style="margin-top:12px"><p class="small muted">Fine for a first try. Each step below will show a packet to copy into your chat and a box to paste the reply into.</p><button class="primary" onClick=${() => choose("paste")}>Use paste</button></div>` : ""}
48
+ ${pick === "mcp" && mcp ? html`<div style="margin-top:12px"><p class="small">Claude Desktop, Settings, Developer, Edit Config, merge this into <span class="mono">mcpServers</span>, save, restart Claude Desktop:</p><textarea readonly value=${JSON.stringify(mcp.desktop, null, 2)}></textarea><button class="primary" onClick=${() => choose("mcp")}>I added it</button></div>` : ""}
49
+ </div>`;
50
+ }
51
+
52
+ function StepPosting({ reload }) {
53
+ const [text, setText] = useState("");
54
+ const [company, setCompany] = useState("");
55
+ const [title, setTitle] = useState("");
56
+ const [g, setG] = useState({ company: true, title: true });
57
+ const [err, setErr] = useState("");
58
+ const onPaste = (t) => {
59
+ setText(t);
60
+ const lines = t.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 12);
61
+ let ti = title, co = company;
62
+ if (g.title) ti = lines.find((l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l)) || "";
63
+ if (g.company) { co = ""; for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { co = m[1].trim(); break; } } if (!co) { const i = lines.findIndex((l) => l === ti); const c = lines[i + 1] || ""; if (c && c.length <= 50 && !/^https?:/i.test(c) && !/\d{4}/.test(c)) co = c.replace(/^[·|\-\s]+/, ""); } }
64
+ setTitle(ti); setCompany(co);
65
+ };
66
+ const store = async (e) => {
67
+ e.preventDefault(); setErr("");
68
+ try { const r = await api.post("/api/postings", { company, title, raw_text: text }); await api.post(`/api/postings/${r.posting_id}/scan`, {}); await reload(); } catch (e2) { setErr(e2.message); }
69
+ };
70
+ return html`<form onSubmit=${store}>
71
+ <p>Paste the text of a job you want, exactly as the site shows it. GAF-J scores it against your record and shows what the posting asks for beside what you have. Everything else (the resume, the prep) starts from there.</p>
72
+ <textarea required placeholder="the whole posting" value=${text} onInput=${(e) => onPaste(e.target.value)}></textarea>
73
+ <div class="row"><input required placeholder="company (guessed; fix if wrong)" value=${company} onInput=${(e) => { setG({ ...g, company: false }); setCompany(e.target.value); }} /><input required placeholder="title (guessed; fix if wrong)" value=${title} onInput=${(e) => { setG({ ...g, title: false }); setTitle(e.target.value); }} /></div>
74
+ <div class="row"><button class="primary">Store and score</button>${err ? html`<span class="error small">${err}</span>` : ""}</div>
75
+ </form>`;
76
+ }
77
+
78
+ export function Welcome({ live, onDone }) {
79
+ const [setup, setSetup] = useState(null);
80
+ const [ob, setOb] = useState(null);
81
+ const [kb, setKb] = useState(null);
82
+ const [me, setMe] = useState(null);
83
+ const [err, setErr] = useState("");
84
+ const [modal, setModal] = useState(null);
85
+ const [answering, setAnswering] = useState(null);
86
+ const [busy, setBusy] = useState("");
87
+ 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
+ useEffect(() => { load(); }, [live]);
89
+ const post = async (path, body) => { try { setErr(""); await api.post(path, body || {}); await load(); } catch (e) { setErr(e.message); } };
90
+ if (!setup || !ob || !kb || !me) return html`<div class="welcome"><p class="muted">loading</p></div>`;
91
+ const direct = me.route === "byo_key" || me.route === "credits";
92
+ const step = setup.next;
93
+ const idx = setup.steps.findIndex((x) => x.key === step);
94
+ const discover = async () => {
95
+ if (!direct) { setModal("discover"); return; }
96
+ 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
+ };
98
+ const body = {
99
+ ai: html`<${StepAi} s=${setup} reload=${load} />`,
100
+ upload: html`<div><p>Every resume you have, old ones too: Word, PDF, Markdown, or plain text. 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
+ 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
+ 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
+ <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>
104
+ <div class="small muted">metrics: ${(p.draft.metrics || []).join(", ") || "none"} · verbs: ${(p.draft.verbs || []).join(", ") || "none"}${p.draft.dates ? " · " + p.draft.dates : ""}</div>
105
+ ${answering === p.pending_id ? html`<${DraftForm} initial=${fromDraft(p.draft)} label="Confirm with these edits" onSubmit=${(draft) => { post(`/api/kb/pending/${p.pending_id}/confirm`, { edits: draft }); setAnswering(null); }} />`
106
+ : html`<div class="row"><button class="primary small" onClick=${() => post(`/api/kb/pending/${p.pending_id}/confirm`, {})}>Confirm</button><button class="small" onClick=${() => setAnswering(p.pending_id)}>Edit</button><button class="small danger" onClick=${() => post(`/api/kb/pending/${p.pending_id}/dismiss`, {})}>Dismiss</button></div>`}</div></li>`)}
107
+ ${kb.pending.length ? "" : html`<li class="muted">nothing pending; go back and extract, or add a record by hand on the Knowledge base screen later</li>`}</ul>
108
+ ${kb.pending.length > 1 ? html`<div class="row"><button onClick=${async () => { for (const p of kb.pending) await api.post(`/api/kb/pending/${p.pending_id}/confirm`, {}); await load(); }}>Confirm all ${kb.pending.length}</button><span class="small muted">only if you have read them</span></div>` : ""}</div>`,
109
+ discover: html`<div><p>Your AI reads the confirmed records and asks what a coach would: the figure a record lacks, the size of a team, the story behind a bare bullet. Each answer lands on the record it is about.</p>
110
+ ${kb.questions.length ? html`<ul class="list">${kb.questions.map((q) => html`<li key=${q.question_id}><div><span class="tag">${q.kind}</span> <b>${q.title}</b> <span class="muted">${q.company}</span><br />${q.question}
111
+ ${answering === q.question_id ? html`<${DraftForm} label="Add answer" initial=${fromDraft(q.draft)} onSubmit=${(draft) => { post(`/api/kb/questions/${q.question_id}/answer`, { draft }); setAnswering(null); }} />` : html`<div class="row"><button class="small" onClick=${() => setAnswering(q.question_id)}>Answer</button><button class="small danger" onClick=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})}>Skip</button></div>`}</div></li>`)}</ul>`
112
+ : html`<div class="row"><button class="primary" disabled=${!!busy} onClick=${discover}>${busy ? "asking" : "Ask what's missing"}</button><button onClick=${() => post("/api/setup/dismiss", { dismissed: true })}>Later</button></div>`}</div>`,
113
+ posting: html`<${StepPosting} reload=${load} />`,
114
+ };
115
+ return html`<div class="welcome">
116
+ <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
+ ${err ? html`<p class="error">${err}</p>` : ""}
120
+ <div class="card welcome-body"><h3>${idx + 1}. ${setup.steps[idx].title}</h3>${body[step]}</div>
121
+ ${modal === "discover" ? html`<${PacketModal} op="discover" label="Discovery questions" target=${{}} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
122
+ </div>`;
123
+ }