@gafj/gafj 0.1.3 → 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/README.md +1 -0
- 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 +31 -0
- package/ui/app.js +6 -1
- package/ui/screens/dashboard.js +2 -1
- package/ui/screens/kb.js +36 -8
- package/ui/screens/settings.js +35 -10
- package/ui/screens/welcome.js +126 -0
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/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
|
@@ -151,3 +151,34 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
|
|
|
151
151
|
.ai-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-top: 4px; }
|
|
152
152
|
.ai-options button { text-align: left; display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; height: 100%; }
|
|
153
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); }
|
|
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
|
@@ -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 && seg[0] !== "settings") 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} />`;
|
|
@@ -67,7 +72,7 @@ function App() {
|
|
|
67
72
|
else if (seg[0] === "documents" && seg[1]) screen = html`<${Document} id=${seg[1]} live=${live} />`;
|
|
68
73
|
else if (seg[0] === "postings") screen = html`<${Postings} id=${seg[1]} live=${live} />`;
|
|
69
74
|
else if (seg[0] === "kb") screen = html`<${KB} live=${live} />`;
|
|
70
|
-
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>`;
|
|
71
76
|
else screen = html`<${Dashboard} live=${live} />`;
|
|
72
77
|
return html`<div class="layout"><${Rail} route=${route} me=${me} /><main class="main">${screen}</main></div>`;
|
|
73
78
|
}
|
package/ui/screens/dashboard.js
CHANGED
|
@@ -5,7 +5,8 @@ import { api, fmtWhen } from "../lib.js";
|
|
|
5
5
|
const STEPS = {
|
|
6
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
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: "
|
|
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,
|
|
9
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"] },
|
|
10
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"] },
|
|
11
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"] },
|
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=${() =>
|
|
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
|
-
|
|
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
|
|
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); }} />`
|
package/ui/screens/settings.js
CHANGED
|
@@ -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);
|
|
@@ -11,8 +13,9 @@ export function Settings({ live }) {
|
|
|
11
13
|
const [msg, setMsg] = useState("");
|
|
12
14
|
const [prov, setProv] = useState(null);
|
|
13
15
|
const [mcp, setMcp] = useState(null);
|
|
16
|
+
const [usage, setUsage] = useState(null);
|
|
14
17
|
const inst = useInstall();
|
|
15
|
-
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));
|
|
16
19
|
useEffect(() => { load(); }, [live]);
|
|
17
20
|
const put = async (patch) => { setMsg(""); try { setS(await api.put("/api/settings", patch)); setMsg("saved"); } catch (e) { setErr(e.message); } };
|
|
18
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); } };
|
|
@@ -41,21 +44,35 @@ export function Settings({ live }) {
|
|
|
41
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>` : ""}
|
|
42
45
|
</div>
|
|
43
46
|
${s.hosted ? "" : html`<h2>My providers</h2>
|
|
44
|
-
<div class="card"
|
|
45
|
-
|
|
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>
|
|
46
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>
|
|
47
52
|
${p.last_test ? html`<span class=${"tag " + (p.last_test.ok ? "passed" : "blocked")}>${p.last_test.ok ? "ok" : "failed"}</span>` : ""}
|
|
48
53
|
<button class="small" onClick=${() => post(`/api/settings/providers/${p.id}/test`, {}, "test")}>test</button>
|
|
49
54
|
<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
55
|
<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
56
|
${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 {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
${prov ? html`<form class="inline provider-form" onSubmit=${async (e) => { e.preventDefault(); try {
|
|
58
|
+
const body = { ...prov, label: prov.label || KIND_LABEL[prov.kind] };
|
|
59
|
+
const saved = prov.id ? await api.put(`/api/settings/providers/${prov.id}`, body) : await api.post("/api/settings/providers", body);
|
|
60
|
+
// the first provider becomes the active one and the app switches to it; then the key is tried at once
|
|
61
|
+
if (!s.providers.length || !s.active_provider_id) await api.put("/api/settings", { active_provider_id: saved.id, route: "byo_key" });
|
|
62
|
+
setProv(null);
|
|
63
|
+
setMsg("testing the key");
|
|
64
|
+
const t = await api.post(`/api/settings/providers/${saved.id}/test`, {});
|
|
65
|
+
setMsg(t.ok ? `key works: ${t.message}` : `key failed: ${t.message}`);
|
|
66
|
+
load();
|
|
67
|
+
} catch (e2) { setErr(e2.message); } }}>
|
|
68
|
+
<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>
|
|
69
|
+
<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 })} />
|
|
70
|
+
<button class="primary">save and test</button><button type="button" onClick=${() => setProv(null)}>cancel</button>
|
|
71
|
+
<details class="advanced"><summary class="small muted">advanced: name, model, base url</summary>
|
|
72
|
+
<input placeholder=${"name, default " + KIND_LABEL[prov.kind]} value=${prov.label} onInput=${(e) => setProv({ ...prov, label: e.target.value })} />
|
|
73
|
+
<input placeholder=${"model, default " + (KIND_MODEL[prov.kind] || "the provider's current default")} value=${prov.model} onInput=${(e) => setProv({ ...prov, model: e.target.value })} />
|
|
74
|
+
${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 })} />` : ""}
|
|
75
|
+
</details></form>` : html`<button class="small" onClick=${() => setProv({ id: null, kind: "anthropic", label: "", model: "", base_url: "", api_key: "" })}>+ add</button>`}
|
|
59
76
|
<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
77
|
</div>`}
|
|
61
78
|
<h2>Credits</h2>
|
|
@@ -73,6 +90,14 @@ export function Settings({ live }) {
|
|
|
73
90
|
</form>`}
|
|
74
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>` : ""}
|
|
75
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>
|
|
76
101
|
<h2>Bullet register</h2>
|
|
77
102
|
<div class="card">
|
|
78
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>`)}
|
|
@@ -0,0 +1,126 @@
|
|
|
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 [view, setView] = useState(null); // a step the user went back to; null follows the next undone step
|
|
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));
|
|
89
|
+
useEffect(() => { load(); }, [live]);
|
|
90
|
+
const post = async (path, body) => { try { setErr(""); await api.post(path, body || {}); await load(); } catch (e) { setErr(e.message); } };
|
|
91
|
+
if (!setup || !ob || !kb || !me) return html`<div class="welcome"><p class="muted">loading</p></div>`;
|
|
92
|
+
const direct = me.route === "byo_key" || me.route === "credits";
|
|
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;
|
|
96
|
+
const discover = async () => {
|
|
97
|
+
if (!direct) { setModal("discover"); return; }
|
|
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("");
|
|
99
|
+
};
|
|
100
|
+
const body = {
|
|
101
|
+
ai: html`<${StepAi} s=${setup} reload=${load} />`,
|
|
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>`,
|
|
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>`,
|
|
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>
|
|
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>
|
|
106
|
+
<div class="small muted">metrics: ${(p.draft.metrics || []).join(", ") || "none"} · verbs: ${(p.draft.verbs || []).join(", ") || "none"}${p.draft.dates ? " · " + p.draft.dates : ""}</div>
|
|
107
|
+
${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); }} />`
|
|
108
|
+
: 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>`)}
|
|
109
|
+
${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>
|
|
110
|
+
${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>`,
|
|
111
|
+
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>
|
|
112
|
+
${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}
|
|
113
|
+
${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>`
|
|
114
|
+
: 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>`,
|
|
115
|
+
posting: html`<${StepPosting} reload=${load} />`,
|
|
116
|
+
};
|
|
117
|
+
return html`<div class="welcome">
|
|
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>
|
|
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>
|
|
121
|
+
${err ? html`<p class="error">${err}</p>` : ""}
|
|
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>
|
|
124
|
+
${modal === "discover" ? html`<${PacketModal} op="discover" label="Discovery questions" target=${{}} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
|
|
125
|
+
</div>`;
|
|
126
|
+
}
|