projectkeeper 0.0.0-stage → 1.0.0
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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
// The Keeper view as two pages (Spec §6.10; D105; CKC-03 AC-12): `Takeover` — choose a depth, Start, the progress as the
|
|
2
|
+
// Keeper works, the briefing at the end, Clear — and `Daily` — the schedule the owner sets, Follow up now, one line of
|
|
3
|
+
// status. Under both, the same settings blocks. What the Keeper is doing and has done is not listed here: it is in
|
|
4
|
+
// `Keeper activity` (keeper-activity.js), which both pages open.
|
|
5
|
+
//
|
|
6
|
+
// The page stays short (§6.13 给 agent 看的折起): what the owner decides or acts on is open; what an agent uses, or what
|
|
7
|
+
// is only read when looking into a problem, is folded under a one-line head that says what is inside and how much.
|
|
8
|
+
// A head carries what the owner must know, so nothing that matters hides in a fold.
|
|
9
|
+
import { api, append, fmtRel, fmtTime, h, navigate, openDialog, refreshActivity, refreshProject, state, toast, visHtml } from './app.js';
|
|
10
|
+
import { STAGE_LABEL, STEP_LABEL, LANE_KIND_LABEL, readText, coverageText, lanesAtOnce, isMainAgentRound } from './k/round-tree.js';
|
|
11
|
+
import { openKeeperActivity } from './keeper-activity.js';
|
|
12
|
+
import { chooseKey, mainChosen, modelFor, moveRow, orderBody, orderRows } from './key-order.js';
|
|
13
|
+
import { folderStatus, grantOutcome, grantWords } from './project-folder.js';
|
|
14
|
+
|
|
15
|
+
const P = () => encodeURIComponent(state.projectId);
|
|
16
|
+
const ensureStyle = () => {
|
|
17
|
+
if (document.getElementById('keeper-css')) return;
|
|
18
|
+
document.head.append(h('link', { id: 'keeper-css', rel: 'stylesheet', href: '/keeper.css' }));
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ── small formatters ──────────────────────────────────────────────────────
|
|
22
|
+
const fmtMinutes = (m) => (m == null ? '—' : m < 1 ? 'under a minute' : m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} h`);
|
|
23
|
+
const fmtWall = (ms) => (ms == null ? '—' : fmtMinutes(Math.round(ms / 60000)));
|
|
24
|
+
const fmtCost = (c) => (c == null ? 'cost not reported' : `$${c.toFixed(2)}`);
|
|
25
|
+
const kindsText = (byKind) => Object.entries(byKind || {}).map(([k, v]) => `${v} ${k}${v === 1 ? '' : 's'}`).join(', ') || '—';
|
|
26
|
+
const countsText = (byKind) => Object.entries(byKind || {}).map(([k, v]) => `${v} ${k}`).join(', ') || '—';
|
|
27
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
28
|
+
const statusTag = (st) => h('span', { class: `tag ${/fail|stop/i.test(st) ? 'red' : /done/i.test(st) ? 'green' : /running|organizing/i.test(st) ? 'blue' : ''}` }, st);
|
|
29
|
+
|
|
30
|
+
/** A block folded by default: one line that says what is inside and how much; opening it shows all of it. */
|
|
31
|
+
function fold(id, head, body, { open = false, note = null } = {}) {
|
|
32
|
+
return h('details', { class: 'fold kp-fold', id, open: open || folds.has(id), onToggle: (e) => { if (e.target.open) folds.add(id); else folds.delete(id); } },
|
|
33
|
+
h('summary', {}, h('span', { class: 'kp-fold-head' }, head), note ? h('span', { class: 'kp-fold-note' }, note) : null),
|
|
34
|
+
h('div', { class: 'content stack' }, body));
|
|
35
|
+
}
|
|
36
|
+
/** The folds the owner opened stay open while the page is redrawn for new data. */
|
|
37
|
+
const folds = new Set();
|
|
38
|
+
|
|
39
|
+
// ── navigation to where a thing lives on the workbench ────────────────────
|
|
40
|
+
const goNode = (id, label) => { state.selection = { kind: 'node', id, label }; state.pendingPopover = {}; navigate(state.projectId, 'graph'); };
|
|
41
|
+
const goNote = (id, label) => { state.selection = { kind: 'note', id, label }; state.pendingPopover = {}; navigate(state.projectId, 'notes'); };
|
|
42
|
+
const goWaiting = (w) => (w.kind === 'note' ? goNote(w.id, w.text) : navigate(state.projectId, 'scope'));
|
|
43
|
+
const waitingList = (items) => h('ul', { class: 'kp-list kp-waiting' }, ...items.map((w) => h('li', {},
|
|
44
|
+
h('button', { class: 'text-btn', 'data-waiting': w.kind, title: w.kind === 'note' ? 'Open the note' : 'Answer it in Project scope', onClick: () => goWaiting(w) }, w.text),
|
|
45
|
+
h('span', { class: 'faint' }, w.kind === 'note' ? ' · note for your decision' : ' · scope question'))));
|
|
46
|
+
|
|
47
|
+
// ── the page ──────────────────────────────────────────────────────────────
|
|
48
|
+
const STATE_LINE = {
|
|
49
|
+
'Not taken over': 'This project has not been taken over. Nothing runs until you choose a depth and press Start.',
|
|
50
|
+
'Takeover under way': 'The takeover is under way. Daily organizing begins when it is done.',
|
|
51
|
+
Daily: 'The takeover is done. The Keeper organizes what changes on your schedule, or now when you press Follow up.',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const keeperView = {
|
|
55
|
+
_page: null, _for: null, _dirty: false, _choice: null,
|
|
56
|
+
|
|
57
|
+
async render(main) {
|
|
58
|
+
ensureStyle();
|
|
59
|
+
// Everything the page shows is read before it is drawn, so a redraw for new data can be compared with what stands.
|
|
60
|
+
const [view, c, summary, mpv, usage, usageRounds] = await Promise.all([api(`/api/projects/${P()}/keeper-page`), api(`/api/projects/${P()}/connections`), api(`/api/projects/${P()}`),
|
|
61
|
+
api(`/api/projects/${P()}/model-provider`).catch(() => null), api(`/api/projects/${P()}/usage`).catch(() => null), api(`/api/projects/${P()}/usage-rounds`).catch(() => null)]);
|
|
62
|
+
// The page that opens: Takeover until the takeover is done, Daily from then on; a link or the owner's own choice
|
|
63
|
+
// of tab stands while they stay on this project.
|
|
64
|
+
if (keeperView._for !== state.projectId) { keeperView._for = state.projectId; keeperView._page = null; keeperView._dirty = false; keeperView._choice = null; }
|
|
65
|
+
const asked = state.routeRest === 'takeover' || state.routeRest === 'daily' ? state.routeRest : null;
|
|
66
|
+
const page = asked ?? keeperView._page ?? view.page;
|
|
67
|
+
// Always the page on screen: the element this render drew into may be a detached one an in-place update drew into.
|
|
68
|
+
const redraw = () => keeperView.redraw();
|
|
69
|
+
const show = (p) => { keeperView._page = p; if (state.routeRest) navigate(state.projectId, 'keeper'); else redraw(); };
|
|
70
|
+
append(main,
|
|
71
|
+
h('div', { class: 'page-head kp-head', id: 'kp-head' },
|
|
72
|
+
h('div', {}, h('h1', {}, 'Keeper'),
|
|
73
|
+
h('p', { class: 'sub' }, h('span', { class: `tag ${view.state === 'Daily' ? 'green' : view.state === 'Takeover under way' ? 'blue' : 'amber'}`, id: 'kp-state' }, view.state), ' ', STATE_LINE[view.state])),
|
|
74
|
+
h('div', { class: 'row' }, h('button', { class: 'btn small', id: 'kp-activity', title: 'What the Keeper is doing and has done: every round, with its tree', onClick: () => openKeeperActivity() }, 'Keeper activity'))),
|
|
75
|
+
h('div', { class: 'segmented kp-tabs', role: 'tablist', 'aria-label': 'Keeper pages' },
|
|
76
|
+
...[['takeover', 'Takeover'], ['daily', 'Daily']].map(([id, label]) => h('button', { class: page === id ? 'active' : '', role: 'tab', 'aria-selected': String(page === id), 'data-page': id, onClick: () => { if (page !== id) show(id); } }, label))),
|
|
77
|
+
h('div', { id: 'kp-page', 'data-page': page }, page === 'takeover' ? takeoverPage(view, summary, redraw) : dailyPage(view, redraw)),
|
|
78
|
+
h('div', { id: 'kp-settings', class: 'stack' }, ...settingsBlocks(c, view, { keyFirst: page === 'takeover' && view.takeover.phase === 'Not started', mpv, usageRounds, usageLines: usage?.lines ?? null, redraw })),
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The page drawn again after the owner changed something (a key, the route, the page): drawn off screen, then swapped
|
|
84
|
+
* into the page on screen, keeping where it is scrolled. A redraw that a later one overtook is dropped, so two changes
|
|
85
|
+
* in a row end on the second.
|
|
86
|
+
*/
|
|
87
|
+
_gen: 0,
|
|
88
|
+
async redraw() {
|
|
89
|
+
await keeperView._swap(false);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/** New data while the page is open: redrawn in place, unless the owner is in the middle of changing the schedule. */
|
|
93
|
+
async onAssets() {
|
|
94
|
+
const main = document.querySelector('#main');
|
|
95
|
+
if (!main || state.view !== 'keeper' || keeperView._dirty) return;
|
|
96
|
+
if (main.querySelector('#kp-page input:focus, #kp-page select:focus, #kp-settings input:focus, #kp-settings select:focus')) return;
|
|
97
|
+
await keeperView._swap(true);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async _swap(onlyIfChanged) {
|
|
101
|
+
const gen = ++keeperView._gen;
|
|
102
|
+
const project = state.projectId;
|
|
103
|
+
const next = h('main', {});
|
|
104
|
+
try { await keeperView.render(next); } catch (e) { if (!onlyIfChanged) toast(e.message); return; }
|
|
105
|
+
const main = document.querySelector('#main');
|
|
106
|
+
if (gen !== keeperView._gen || !main || state.view !== 'keeper' || state.projectId !== project) return;
|
|
107
|
+
if (onlyIfChanged && visHtml(next.innerHTML) === visHtml(main.innerHTML)) return;
|
|
108
|
+
const scroll = main.scrollTop;
|
|
109
|
+
main.replaceChildren(...next.childNodes);
|
|
110
|
+
main.scrollTop = scroll;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// ── Takeover ──────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** The three depths: selectable before the start and — a deeper one — once it is done; otherwise disabled, with why. */
|
|
117
|
+
function depthChoices(tk, onChoose) {
|
|
118
|
+
const anySelectable = tk.options.some((o) => o.selectable);
|
|
119
|
+
return h('div', { class: 'kp-depths', role: 'radiogroup', 'aria-label': 'Takeover depth' }, ...tk.options.map((o) => {
|
|
120
|
+
const chosen = keeperView._choice === o.depth && o.selectable;
|
|
121
|
+
const mark = o.ran ? h('span', { class: 'tag green' }, 'ran') : tk.phase === 'Under way' && o.chosen ? h('span', { class: 'tag blue' }, 'chosen') : null;
|
|
122
|
+
return h('label', { class: `kp-depth${o.selectable ? '' : ' disabled'}${chosen ? ' selected' : ''}`, 'data-depth': o.depth, title: o.selectable ? '' : o.why ?? '' },
|
|
123
|
+
h('div', { class: 'row' },
|
|
124
|
+
h('input', { type: 'radio', name: 'kp-depth', value: o.depth, disabled: !o.selectable, checked: chosen, onChange: () => { keeperView._choice = o.depth; onChoose(); } }),
|
|
125
|
+
h('b', {}, o.depth), mark),
|
|
126
|
+
h('div', { class: 'muted' }, o.does),
|
|
127
|
+
h('div', { class: 'faint' }, h('span', {}, 'Beyond the first usable picture: '), o.gains),
|
|
128
|
+
!o.selectable && anySelectable && o.why ? h('div', { class: 'faint kp-why' }, o.why) : null);
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function startRow(tk, redraw) {
|
|
133
|
+
const choice = tk.options.find((o) => o.depth === keeperView._choice && o.selectable) ?? null;
|
|
134
|
+
const why = !tk.key.usable ? 'No usable key: add a key in Model provider below first.' : !choice ? (tk.phase === 'Done' ? 'Choose a deeper depth to go on from what is done.' : 'Choose a depth.') : null;
|
|
135
|
+
const start = async () => {
|
|
136
|
+
try {
|
|
137
|
+
await api(`/api/projects/${P()}/takeover/start`, { method: 'POST', body: { depth: choice.depth } });
|
|
138
|
+
keeperView._choice = null;
|
|
139
|
+
toast(tk.phase === 'Done' ? `Going on to ${choice.depth}` : `Takeover started: ${choice.depth}`);
|
|
140
|
+
await refreshProject();
|
|
141
|
+
await refreshActivity();
|
|
142
|
+
} catch (e) { toast(e.message); redraw(); }
|
|
143
|
+
};
|
|
144
|
+
return h('div', { class: 'row wrap kp-start-row' },
|
|
145
|
+
h('button', { class: 'btn primary', id: 'kp-start', disabled: Boolean(why), title: why ?? (tk.phase === 'Done' ? `Go on to ${choice.depth} from what is done` : 'Start the takeover at this depth'), onClick: start }, 'Start'),
|
|
146
|
+
why ? h('span', { class: 'faint', id: 'kp-start-why' }, why) : h('span', { class: 'muted' }, tk.phase === 'Done' ? `${choice.depth}: goes on from what is done; nothing already organized is done again.` : `${choice.depth}: the first usable picture comes first, then the Keeper goes on to this depth without asking again.`));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clearButton(redraw) {
|
|
150
|
+
return h('button', { class: 'btn small danger', id: 'kp-clear', title: 'Remove everything the Keeper organized for this project, to choose again. The project’s own files are not touched.', onClick: () => openClearDialog(redraw) }, 'Clear');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The dialog of `Clear` (§3.7 清空; CKC-13 AC-40): what goes, what stays, and the word DELETE to confirm. */
|
|
154
|
+
async function openClearDialog(redraw) {
|
|
155
|
+
let preview;
|
|
156
|
+
try { preview = await api(`/api/projects/${P()}/takeover/clear`); } catch (e) { toast(e.message); return; }
|
|
157
|
+
const word = h('input', { class: 'input', id: 'kp-clear-word', placeholder: 'DELETE', autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Type DELETE to confirm' });
|
|
158
|
+
const err = h('small', { class: 'faint', id: 'kp-clear-err' });
|
|
159
|
+
const confirm = h('button', { class: 'btn danger', id: 'kp-clear-confirm', disabled: true }, 'Clear this project');
|
|
160
|
+
word.addEventListener('input', () => { confirm.disabled = word.value !== 'DELETE'; err.textContent = ''; });
|
|
161
|
+
const run = async () => {
|
|
162
|
+
confirm.disabled = true;
|
|
163
|
+
err.textContent = preview.running ? 'Stopping what is running, then clearing…' : 'Clearing…';
|
|
164
|
+
try {
|
|
165
|
+
const r = await api(`/api/projects/${P()}/takeover/clear`, { method: 'POST', body: { confirm: word.value } });
|
|
166
|
+
document.querySelector('#dialog').close();
|
|
167
|
+
keeperView._page = null; keeperView._choice = null;
|
|
168
|
+
folds.clear();
|
|
169
|
+
toast(r.leftBehind?.length ? `Cleared. Could not remove: ${r.leftBehind.join(', ')}` : 'Cleared — the project is not organized');
|
|
170
|
+
await refreshProject();
|
|
171
|
+
await refreshActivity();
|
|
172
|
+
} catch (e) { err.textContent = e.message; confirm.disabled = word.value !== 'DELETE'; }
|
|
173
|
+
};
|
|
174
|
+
confirm.addEventListener('click', run);
|
|
175
|
+
word.addEventListener('keydown', (e) => { if (e.key === 'Enter' && word.value === 'DELETE') run(); });
|
|
176
|
+
openDialog('Clear this project', [h('div', { class: 'stack kp-clear' },
|
|
177
|
+
h('p', {}, 'Everything the Keeper organized for this project is removed, and the project goes back to ', h('b', {}, 'Not organized yet'), ': every depth can be chosen again and nothing runs until you press Start. This cannot be undone.'),
|
|
178
|
+
preview.running ? h('p', { class: 'kp-warn' }, `${plural(preview.running, 'piece')} of Keeper work ${preview.running === 1 ? 'is' : 'are'} under way and will be stopped first.`) : null,
|
|
179
|
+
h('h4', {}, 'Removed'),
|
|
180
|
+
h('table', { class: 'list kp-clear-removed' }, h('tbody', {}, ...preview.removed.map((r) => h('tr', {}, h('td', {}, r.what), h('td', { class: 'kp-num' }, r.count == null ? '' : r.count), h('td', { class: 'faint' }, r.note ?? ''))))),
|
|
181
|
+
h('p', { id: 'kp-clear-owner-only' }, h('b', {}, preview.ownerOnly), preview.ownerOnly === 1 ? ' thing you said only to the Keeper is among them' : ' things you said only to the Keeper are among them', ' — decisions and corrections given in the Keeper conversation or on a note, which the project’s documents do not hold.'),
|
|
182
|
+
h('h4', {}, 'Kept'),
|
|
183
|
+
h('ul', { class: 'kp-list kp-clear-kept' }, ...preview.kept.map((k) => h('li', {}, h('b', {}, k.what), ' — ', k.detail))),
|
|
184
|
+
h('div', { class: 'field' }, h('label', { for: 'kp-clear-word' }, 'Type DELETE to confirm'), word),
|
|
185
|
+
err,
|
|
186
|
+
h('div', { class: 'dialog-foot' }, h('button', { class: 'btn', onClick: () => document.querySelector('#dialog').close() }, 'Cancel'), confirm))]);
|
|
187
|
+
word.focus();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The round under way, as the page fills in: its steps, the main agent's stage, the lanes and what each answers. */
|
|
191
|
+
function progressBlock(tk, summary, redraw) {
|
|
192
|
+
const r = tk.round;
|
|
193
|
+
const cov = summary.coverage?.takeover ?? null;
|
|
194
|
+
const chosen = cov?.options?.find((o) => o.depth === tk.chosen) ?? null;
|
|
195
|
+
// The chosen depth's amount on this project is counted from the organizing plan the first usable round wrote (§3.7).
|
|
196
|
+
const planned = Boolean(cov?.firstUsable?.completedAt) && Boolean(chosen?.toOrganize);
|
|
197
|
+
const failures = summary.coverage?.scopes?.find((s) => s.id === 'project')?.failed ?? [];
|
|
198
|
+
const stepRows = r ? r.steps.filter((s) => s.kind !== 'lane') : [];
|
|
199
|
+
const stopped = r ? r.steps.filter((s) => /stopped|failed/i.test(s.status)) : [];
|
|
200
|
+
const open = (roundId) => openKeeperActivity({ roundId });
|
|
201
|
+
const act = async (jobId, action) => { try { await api(`/api/projects/${P()}/keeper/jobs/${encodeURIComponent(jobId)}/${action}`, { method: 'POST' }); await refreshActivity(); } catch (e) { toast(e.message); } redraw(); };
|
|
202
|
+
const stage = r?.main?.stage ?? null;
|
|
203
|
+
return h('div', { class: 'stack kp-progress', id: 'kp-progress' },
|
|
204
|
+
h('div', { class: 'row wrap' }, h('b', {}, tk.stage ?? 'Starting'),
|
|
205
|
+
r ? h('span', { class: 'muted' }, `Round ${r.number} · ${r.kind}${stage ? ` · main agent in ${STAGE_LABEL[stage] ?? stage}` : ''}`) : h('span', { class: 'muted' }, 'The boundary is drawn and the material read first; the first round starts when that is done.'),
|
|
206
|
+
r && r.status === 'Running' ? h('button', { class: 'btn small', id: 'kp-stop', title: 'Stop this round: what is saved stays, and it can be continued', onClick: async () => { const root = r.rootJobId; try { await api(`/api/projects/${P()}/keeper/jobs/${encodeURIComponent(root)}/stop`, { method: 'POST' }); for (const s of r.steps.filter((x) => /running|queued|paused|waiting/i.test(x.status))) await api(`/api/projects/${P()}/keeper/jobs/${encodeURIComponent(s.jobId)}/stop`, { method: 'POST' }); toast('Stopping the round'); await refreshActivity(); } catch (e) { toast(e.message); } redraw(); } }, 'Stop') : null),
|
|
207
|
+
h('div', { class: 'faint', id: 'kp-so-far' }, `So far: ${fmtWall(tk.total.wallMs)} · ${fmtCost(tk.total.cost)}${tk.total.models.length ? ` · ${tk.total.models.join(', ')}` : ''}`),
|
|
208
|
+
stepRows.length ? h('ol', { class: 'kp-steps' }, ...stepRows.map((s) => h('li', { 'data-step': s.kind },
|
|
209
|
+
h('button', { class: 'text-btn', title: 'Open this round’s tree in Keeper activity', onClick: () => open(r.id) }, STEP_LABEL[s.kind] ?? s.label),
|
|
210
|
+
s.kind === 'main' && stage ? h('span', { class: 'faint' }, ` ${STAGE_LABEL[stage] ?? stage}`) : null, ' ', statusTag(s.status),
|
|
211
|
+
/stopped/i.test(s.status) ? h('button', { class: 'btn small', onClick: () => act(s.jobId, 'continue') }, 'Continue') : /failed/i.test(s.status) ? h('button', { class: 'btn small', onClick: () => act(s.jobId, 'retry') }, 'Retry') : null))) : null,
|
|
212
|
+
r?.lanes?.length ? h('div', {}, h('div', { class: 'muted' }, `${plural(r.lanes.length, 'lane')} sent${lanesAtOnce(r.lanes) > 1 ? `, up to ${lanesAtOnce(r.lanes)} at once` : ''}`),
|
|
213
|
+
h('ul', { class: 'kp-list kp-lanes' }, ...r.lanes.map((l) => h('li', { 'data-lane': l.name }, h('b', {}, l.name), ' ', statusTag(l.status), l.question ? h('div', { class: 'faint kp-lane-q' }, l.question.split('\n')[0]) : null)))) : null,
|
|
214
|
+
chosen && tk.chosen !== 'First picture only' ? h('div', { class: 'muted', id: 'kp-amount' }, planned
|
|
215
|
+
? `${tk.chosen} on this project: ${chosen.toOrganize} materials to read closely · about ${fmtMinutes(chosen.minutes)} · ${chosen.cost == null ? 'cost not reported' : `about $${chosen.cost.toFixed(2)}`}${cov?.remaining ? ` · remaining: ${cov.remaining.materials} materials, about ${fmtMinutes(cov.remaining.minutes)}` : ''}.${cov?.rates?.basis ? ` Basis: ${cov.rates.basis}.` : ''}`
|
|
216
|
+
: `How much ${tk.chosen} is on this project — the lanes, the materials to read closely, the time and cost — is counted once orientation has written the organizing plan.`) : null,
|
|
217
|
+
tk.waiting.length ? h('div', {}, h('b', {}, 'Waiting for you'), waitingList(tk.waiting)) : null,
|
|
218
|
+
stopped.length || failures.length ? h('div', { class: 'faint' }, `${plural(stopped.length, 'step')} stopped or failed; the round’s tree in Keeper activity says why.`) : null);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The briefing (§3.7 接手简报; CKC-13 AC-39): five things, each item opening where it lives on the workbench. */
|
|
222
|
+
function briefingBlock(b, tk) {
|
|
223
|
+
const w = b.work;
|
|
224
|
+
const planLine = (p) => `${p.done} done · ${p.inProgress} under way · ${p.notStarted} not started${p.onHold ? ` · ${p.onHold} on hold` : ''}`;
|
|
225
|
+
const notRead = b.read.levels.filter((l) => /Indexed|Sampled|Not organized|Skipped/.test(l.level));
|
|
226
|
+
return h('div', { class: 'section kp-briefing', id: 'kp-briefing' },
|
|
227
|
+
h('header', {}, 'Takeover briefing', h('span', { class: 'faint', style: { fontWeight: 400 } }, `as of ${fmtTime(tk.completedAt)}`)),
|
|
228
|
+
h('div', { class: 'content' },
|
|
229
|
+
h('h4', {}, 'What this project is'),
|
|
230
|
+
b.project.text
|
|
231
|
+
? h('p', { class: 'kp-brief-text' }, b.project.nodeId ? h('button', { class: 'text-btn', onClick: () => goNode(b.project.nodeId, b.project.name) }, b.project.name) : h('b', {}, b.project.name), h('br'), b.project.text)
|
|
232
|
+
: h('p', { class: 'faint' }, 'The takeover recorded no product overview for this project.'),
|
|
233
|
+
h('h4', {}, `Its areas (${b.areas.length})`),
|
|
234
|
+
b.areas.length ? h('ul', { class: 'kp-list' }, ...b.areas.map((a) => h('li', {}, h('button', { class: 'text-btn', onClick: () => goNode(a.nodeId, a.name) }, a.name), a.sentence ? h('div', { class: 'muted' }, a.sentence) : null))) : h('p', { class: 'faint' }, 'No area was established.'),
|
|
235
|
+
h('h4', {}, 'Where the work stands'),
|
|
236
|
+
w.plans.length || w.noPlan.total ? h('table', { class: 'list kp-work' }, h('tbody', {},
|
|
237
|
+
...w.plans.map((p) => h('tr', {}, h('td', {}, p.nodeId ? h('button', { class: 'text-btn', onClick: () => goNode(p.nodeId, p.name) }, p.name) : p.name, p.progress ? h('span', { class: `tag ${p.progress === 'In progress' ? 'blue' : p.progress === 'Done' ? 'green' : ''}` }, p.progress) : null), h('td', { class: 'muted' }, p.total ? planLine(p) : 'no work item placed under it'))),
|
|
238
|
+
w.noPlan.total ? h('tr', {}, h('td', { class: 'muted' }, 'In no plan'), h('td', { class: 'muted' }, planLine(w.noPlan))) : null)) : h('p', { class: 'faint' }, 'No plan or work item was established.'),
|
|
239
|
+
w.underWay.length ? h('div', {}, h('span', { class: 'muted' }, 'Under way now: '), ...w.underWay.map((t, i) => [i ? ' · ' : null, h('button', { class: 'text-btn', onClick: () => goNode(t.nodeId, t.name) }, t.name)])) : null,
|
|
240
|
+
h('h4', {}, `Waiting for your decision (${b.waiting.length})`),
|
|
241
|
+
b.waiting.length ? waitingList(b.waiting) : h('p', { class: 'faint' }, 'Nothing waits for your decision.'),
|
|
242
|
+
h('h4', {}, 'What was read, and what it cost'),
|
|
243
|
+
h('div', {}, h('b', {}, b.read.depth ?? '—'), b.read.models.length ? ` · ${b.read.models.join(', ')}` : '', ` · ${fmtWall(b.read.total.wallMs)} · ${fmtCost(b.read.total.cost)}`),
|
|
244
|
+
h('table', { class: 'list kp-rounds' }, h('tbody', {}, ...b.read.rounds.map((r) => h('tr', { 'data-round': r.id },
|
|
245
|
+
h('td', {}, h('button', { class: 'text-btn', title: 'Open this round’s tree in Keeper activity', onClick: () => openKeeperActivity({ roundId: r.id }) }, `Round ${r.number} · ${r.kind === 'Deepen' ? `deepening (${r.depth ?? '—'})` : 'first usable picture'}`)),
|
|
246
|
+
h('td', { class: 'muted' }, fmtWall(r.wallMs)), h('td', { class: 'muted' }, fmtCost(r.cost)))))),
|
|
247
|
+
b.read.levels.length ? h('div', { class: 'muted' }, 'Materials by how far they were read: ', b.read.levels.map((l) => `${l.level} ${l.materials}`).join(' · '), '.') : null,
|
|
248
|
+
notRead.length ? h('div', { class: 'faint' }, `Not read closely: ${notRead.map((l) => `${l.materials} ${l.level.toLowerCase()}`).join(', ')} — indexed material is searchable and read when a question needs it; what waits is taken in by the next round.`) : null,
|
|
249
|
+
b.read.missingSourceKinds.length ? h('div', { class: 'faint' }, 'Kinds of source that could not be read: ', b.read.missingSourceKinds.map((m) => `${m.kind} (${m.reason})`).join('; '), '.') : null));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function takeoverPage(view, summary, redraw) {
|
|
253
|
+
const tk = view.takeover;
|
|
254
|
+
const cov = summary.coverage?.takeover ?? null;
|
|
255
|
+
// §6.10 第一次用 (CKC-03 AC-36): the key and the model the takeover will run on, and where to change them; without a usable key, add one first.
|
|
256
|
+
const toModel = () => { const el = document.querySelector('#kp-model'); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); (el.querySelector('#kp-add-provider, #kp-main-key'))?.focus({ preventScroll: true }); } };
|
|
257
|
+
const modelId = (tk.key.model ?? '').split('/').slice(1).join('/') || tk.key.model;
|
|
258
|
+
const key = tk.key.usable ? h('span', { title: tk.key.model ?? '' }, h('span', { class: 'tag green' }, 'key usable'), ' The takeover runs on the key ', h('b', {}, tk.key.keyName ?? '—'), ' with the model ', h('b', {}, modelId ?? '—'), tk.key.thinking ? ` · thinking ${tk.key.thinking}` : '', '. ', h('button', { class: 'text-btn', id: 'kp-change-key', onClick: toModel }, 'Change the key or the models'))
|
|
259
|
+
: h('span', {}, h('span', { class: 'tag red' }, 'no usable key'), ' ', tk.key.reason ?? '', ' ', h('button', { class: 'text-btn', id: 'kp-change-key', onClick: toModel }, 'Add a key'));
|
|
260
|
+
const head = tk.phase === 'Not started'
|
|
261
|
+
? [h('p', { id: 'kp-intro' }, tk.intro), h('div', { id: 'kp-key' }, key)]
|
|
262
|
+
: tk.phase === 'Under way'
|
|
263
|
+
? [h('div', { class: 'row spread wrap', id: 'kp-ran' }, h('div', {}, h('b', {}, tk.chosen), ` chosen · started ${fmtTime(tk.startedAt)}${tk.total.models.length ? ` · ${tk.total.models.join(', ')}` : tk.key.model ? ` · ${tk.key.model}` : ''}`), clearButton(redraw))]
|
|
264
|
+
: [h('div', { class: 'row spread wrap', id: 'kp-ran' }, h('div', {}, h('b', {}, tk.ran), ` ran · finished ${fmtTime(tk.completedAt)}${tk.total.models.length ? ` · ${tk.total.models.join(', ')}` : ''} · ${fmtWall(tk.total.wallMs)} · ${fmtCost(tk.total.cost)}`), clearButton(redraw))];
|
|
265
|
+
const canChoose = tk.options.some((o) => o.selectable);
|
|
266
|
+
return h('div', { class: 'stack' },
|
|
267
|
+
h('div', { class: 'section', id: 'takeover' }, h('header', {}, 'Takeover'), h('div', { class: 'content' },
|
|
268
|
+
...head,
|
|
269
|
+
depthChoices(tk, redraw),
|
|
270
|
+
canChoose ? startRow(tk, redraw) : tk.phase === 'Done' ? h('div', { class: 'faint' }, 'To run a shallower depth, or the same one from the beginning, Clear first.') : null,
|
|
271
|
+
tk.phase === 'Under way' ? progressBlock(tk, summary, redraw) : null)),
|
|
272
|
+
tk.phase === 'Done' && tk.briefing ? briefingBlock(tk.briefing, tk) : null,
|
|
273
|
+
tk.phase !== 'Not started' && cov ? fold('kp-reading', readingHead(cov, tk), readingDetails(cov, tk)) : null);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// What was read, path by path and level by level: an agent's and an investigator's table, folded (§6.10).
|
|
277
|
+
function readingHead(cov, tk) {
|
|
278
|
+
const lanes = isMainAgentRound(tk.round) ? tk.round.lanes?.length ?? 0 : 0;
|
|
279
|
+
const materials = (cov.levels ?? []).reduce((n, l) => n + l.materials, 0);
|
|
280
|
+
return `Reading details — ${lanes ? `${plural(lanes, 'lane')} of the latest round, ` : ''}${plural(materials, 'material')} by organizing level, the estimates of each depth`;
|
|
281
|
+
}
|
|
282
|
+
function readingDetails(tk, page) {
|
|
283
|
+
const dp = tk.deepening ?? null;
|
|
284
|
+
const round = page.round;
|
|
285
|
+
const byLane = isMainAgentRound(round) ? round : null;
|
|
286
|
+
const pair = (b) => (b.whole || b.part ? `${b.whole} whole · ${b.part} in part` : '—');
|
|
287
|
+
const minutesOf = (l) => (l.startedAt ? Math.round(((l.endedAt ? Date.parse(l.endedAt) : Date.now()) - Date.parse(l.startedAt)) / 60000) : null);
|
|
288
|
+
const optRows = tk.options.map((o) => h('tr', { 'data-depth': o.depth }, h('td', {}, h('b', {}, o.depth)), h('td', {}, o.toOrganize),
|
|
289
|
+
h('td', {}, o.depth === 'First picture only' ? '—' : fmtMinutes(o.minutes)), h('td', {}, o.depth === 'First picture only' ? '—' : o.cost == null ? 'not reported' : `$${o.cost.toFixed(2)}`)));
|
|
290
|
+
const levelRows = (tk.levels ?? []).filter((l) => l.materials > 0).map((l) => h('tr', {}, h('td', {}, l.level), h('td', {}, l.materials), h('td', { class: 'faint' }, kindsText(l.byKind))));
|
|
291
|
+
const ow = tk.firstUsable?.ownerWords ?? null;
|
|
292
|
+
const counted = Boolean(dp?.paths.some((p) => p.basis));
|
|
293
|
+
const act = dp?.actual ?? null;
|
|
294
|
+
return [
|
|
295
|
+
h('div', { class: 'faint' }, `First usable picture: started ${fmtTime(tk.firstUsable.startedAt)}${tk.firstUsable.completedAt ? `, complete ${fmtTime(tk.firstUsable.completedAt)} (${fmtMinutes(tk.firstUsable.minutes)})` : ', not complete yet'} · organized from ${countsText(tk.firstUsable.byKind)}.`),
|
|
296
|
+
ow ? h('div', { class: 'faint' }, `Owner’s words step: ${fmtMinutes(ow.minutes)} · read ${ow.utterances} of ${ow.total} of the owner’s messages in ${plural(ow.calls, 'call')} · wrote ${ow.items} Owner’s words items.`) : null,
|
|
297
|
+
byLane && byLane.lanes?.length ? h('div', {},
|
|
298
|
+
h('h4', {}, `What each lane read — round ${byLane.number}`),
|
|
299
|
+
h('div', { class: 'kp-table-frame' }, h('table', { class: 'list deepening-progress deepening-by-lane' },
|
|
300
|
+
h('thead', {}, h('tr', {}, ...['Lane', 'Answers', 'Slots', 'Status', 'Time', 'Read'].map((t) => h('th', {}, t)))),
|
|
301
|
+
h('tbody', {}, ...byLane.lanes.map((l) => h('tr', { 'data-lane': l.name },
|
|
302
|
+
h('td', {}, l.name), h('td', {}, LANE_KIND_LABEL[l.kind] ?? l.kind), h('td', { class: 'faint' }, l.slots.join(', ') || '—'),
|
|
303
|
+
h('td', {}, statusTag(l.status)), h('td', {}, fmtMinutes(minutesOf(l))), h('td', { class: 'faint lane-read' }, readText(l.read))))))),
|
|
304
|
+
h('div', { class: 'faint' }, h('b', {}, 'Coverage check: '), coverageText(byLane.coverage ?? null))) : null,
|
|
305
|
+
act ? h('div', { class: 'faint as-run' }, `${act.depth}, as it ran: read ${act.whole + act.part} of the ${act.planned} the question list plans — ${act.whole} whole, ${act.part} in part; ${act.left} not read${act.beyond.whole + act.beyond.part ? `; beyond the plan ${pair(act.beyond)}` : ''} · ${fmtMinutes(act.minutes)} · ${act.cost == null ? 'cost not reported' : `$${act.cost.toFixed(2)}`}.`) : null,
|
|
306
|
+
dp?.paths.length ? h('div', {}, h('h4', {}, 'What a Full deepening reads, path by path'),
|
|
307
|
+
h('table', { class: 'list deepening-paths' }, h('thead', {}, h('tr', {}, h('th', {}, 'Path'), h('th', {}, 'Materials by category'), counted ? h('th', {}, 'How it was counted') : null)),
|
|
308
|
+
h('tbody', {}, ...dp.paths.map((p) => h('tr', { 'data-path': p.path }, h('td', {}, p.path), h('td', { class: 'faint' }, p.reads.map((r) => h('div', {}, `${r.count} ${r.category}`))), counted ? h('td', { class: 'faint path-basis' }, p.basis ?? '—') : null)))),
|
|
309
|
+
dp.focused.length ? h('div', { class: 'faint' }, h('b', {}, 'Focused covers: '), dp.focused.map((c) => `${c.count} ${c.category}`).join(' · '), ', with the history they directly involve.') : null) : null,
|
|
310
|
+
h('h4', {}, 'Each depth on this project'),
|
|
311
|
+
h('table', { class: 'list' }, h('thead', {}, h('tr', {}, h('th', {}, 'Depth'), h('th', {}, 'Materials to read closely'), h('th', {}, 'Estimated time'), h('th', {}, 'Estimated cost'))), h('tbody', {}, ...optRows)),
|
|
312
|
+
h('div', { class: 'faint' }, `Estimates: ${tk.rates.basis}.`),
|
|
313
|
+
levelRows.length ? h('h4', {}, 'Organizing levels of all materials') : null,
|
|
314
|
+
levelRows.length ? h('table', { class: 'list' }, h('thead', {}, h('tr', {}, h('th', {}, 'Level'), h('th', {}, 'Materials'), h('th', {}, 'By kind'))), h('tbody', {}, ...levelRows)) : null,
|
|
315
|
+
tk.readInPart?.materials ? h('div', { class: 'faint read-in-part' }, `Read in part: ${plural(tk.readInPart.materials, 'material')} (${kindsText(tk.readInPart.byKind)}) — some of their lines were read, not all.`) : null,
|
|
316
|
+
h('div', { class: 'faint history-not-organized' }, tk.historyNotOrganized ? `Older material not organized: ${tk.historyNotOrganized} — indexed, sampled or waiting for a round; read when a question needs it.` : 'Older material not organized: none.'),
|
|
317
|
+
tk.sampled?.length ? h('div', { class: 'faint' }, `Sampling rules (${tk.sampled.length}): `, tk.sampled.map((g) => g.rule).join('; ')) : null,
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Daily ─────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
function dailyPage(view, redraw) {
|
|
324
|
+
const d = view.daily;
|
|
325
|
+
const s = d.schedule;
|
|
326
|
+
const timed = (f) => ['Every day', 'On selected days', 'Every N days'].includes(f);
|
|
327
|
+
const frequency = h('select', { class: 'input', id: 'kp-frequency', disabled: !d.available, 'aria-label': 'How often' }, ...d.frequencies.map((f) => h('option', { value: f, selected: s.frequency === f }, f)));
|
|
328
|
+
const time = h('input', { class: 'input', id: 'kp-time', type: 'time', value: s.time, disabled: !d.available, 'aria-label': 'Time of day' });
|
|
329
|
+
const days = h('div', { class: 'row wrap kp-days', id: 'kp-days' }, ...d.dayNames.map((name, i) => h('label', { class: 'kp-day' }, h('input', { type: 'checkbox', value: String(i), checked: (s.days ?? []).includes(i), disabled: !d.available }), name.slice(0, 3))));
|
|
330
|
+
const every = h('input', { class: 'input', id: 'kp-every', type: 'number', min: 1, max: 365, value: s.everyDays ?? 2, disabled: !d.available, 'aria-label': 'Every how many days' });
|
|
331
|
+
const fTime = h('div', { class: 'field' }, h('label', { for: 'kp-time' }, 'Starts at (this machine’s time)'), time);
|
|
332
|
+
const fDays = h('div', { class: 'field' }, h('label', {}, 'On'), days);
|
|
333
|
+
const fEvery = h('div', { class: 'field' }, h('label', { for: 'kp-every' }, 'Every … days'), every);
|
|
334
|
+
const hint = h('span', { class: 'faint', id: 'kp-unsaved' });
|
|
335
|
+
const save = h('button', { class: 'btn primary small', id: 'kp-save', disabled: true }, 'Save');
|
|
336
|
+
const body = () => ({ frequency: frequency.value, time: time.value, days: [...days.querySelectorAll('input:checked')].map((x) => Number(x.value)), everyDays: Number(every.value) });
|
|
337
|
+
const sync = (changed) => {
|
|
338
|
+
fTime.hidden = !timed(frequency.value);
|
|
339
|
+
fDays.hidden = frequency.value !== 'On selected days';
|
|
340
|
+
fEvery.hidden = frequency.value !== 'Every N days';
|
|
341
|
+
if (!changed) return;
|
|
342
|
+
keeperView._dirty = true;
|
|
343
|
+
save.disabled = false;
|
|
344
|
+
hint.textContent = 'Changed, not saved: it has no effect until you Save.';
|
|
345
|
+
};
|
|
346
|
+
for (const el of [frequency, time, every]) el.addEventListener('input', () => sync(true));
|
|
347
|
+
days.addEventListener('change', () => sync(true));
|
|
348
|
+
save.addEventListener('click', async () => {
|
|
349
|
+
try {
|
|
350
|
+
await api(`/api/projects/${P()}/keeper/schedule`, { method: 'POST', body: body() });
|
|
351
|
+
keeperView._dirty = false;
|
|
352
|
+
toast('Schedule saved');
|
|
353
|
+
await refreshProject();
|
|
354
|
+
} catch (e) { hint.textContent = e.message; }
|
|
355
|
+
});
|
|
356
|
+
sync(false);
|
|
357
|
+
const followUp = async () => {
|
|
358
|
+
try {
|
|
359
|
+
const r = await api(`/api/projects/${P()}/keeper/follow-up`, { method: 'POST' });
|
|
360
|
+
toast(r?.queued ? `Follow up: organizing ${plural(r.queued, 'material')}` : 'Follow up: nothing has changed since the last round');
|
|
361
|
+
await refreshActivity();
|
|
362
|
+
} catch (e) { toast(`Follow up: ${e.message}`); }
|
|
363
|
+
redraw();
|
|
364
|
+
};
|
|
365
|
+
const last = d.lastRound;
|
|
366
|
+
const startedBy = last ? (last.kind !== 'Follow up' ? 'the takeover' : last.startedBy === 'owner' ? 'you pressed Follow up' : last.startedBy === 'schedule' ? 'the schedule started it' : last.startedBy === 'continuous' ? 'started by Continuous' : null) : null;
|
|
367
|
+
const slot = d.lastTime;
|
|
368
|
+
const status = h('div', { class: 'kp-status', id: 'kp-daily-status' },
|
|
369
|
+
d.running ? h('div', {}, statusTag('Running'), ` Round ${d.running.number} · ${d.running.kind} · started ${fmtRel(d.running.startedAt)}. `, h('button', { class: 'text-btn', onClick: () => openKeeperActivity({ roundId: d.running.id }) }, 'Open it in Keeper activity')) : null,
|
|
370
|
+
last ? h('div', {}, h('span', { class: 'muted' }, 'Last round: '), `round ${last.number} (${last.kind}) finished ${fmtTime(last.endedAt)}${startedBy ? ` — ${startedBy}` : ''}. `,
|
|
371
|
+
last.nothingNew === true ? 'It found nothing new. ' : last.statement ? `${last.statement}. ` : '',
|
|
372
|
+
last.resultId ? h('button', { class: 'text-btn', id: 'kp-last-result', onClick: async () => { const { openRound } = await import('./views.js'); openRound(last.resultId); } }, 'Open its result') : h('button', { class: 'text-btn', onClick: () => openKeeperActivity({ roundId: last.id }) }, 'Open it in Keeper activity')) : h('div', { class: 'faint' }, 'No round has finished yet.'),
|
|
373
|
+
slot && slot.outcome !== 'round' ? h('div', { class: 'faint', id: 'kp-last-time' }, slot.outcome === 'nothing changed' ? `At the scheduled time ${fmtTime(slot.slot)} nothing had changed, so no round ran.` : `The scheduled time ${fmtTime(slot.slot)} came while a round was running, so no second round started; what changed waits for the next one.`) : null,
|
|
374
|
+
h('div', {}, h('b', {}, d.pending), ` change${d.pending === 1 ? '' : 's'} waiting for the next round.`, d.paused ? h('span', { class: 'tag amber', style: { marginLeft: '8px' } }, 'Organizing paused') : null,
|
|
375
|
+
d.paused ? h('span', { class: 'faint' }, ' Scheduled rounds do not start while paused; Follow up still runs.') : null));
|
|
376
|
+
return h('div', { class: 'stack' },
|
|
377
|
+
!d.available ? h('div', { class: 'section', id: 'kp-daily-unavailable' }, h('div', { class: 'content' }, h('div', {}, d.why), h('div', {}, h('button', { class: 'btn small', onClick: () => { keeperView._page = 'takeover'; if (state.routeRest) navigate(state.projectId, 'keeper'); else redraw(); } }, 'Open the Takeover page')))) : null,
|
|
378
|
+
h('div', { class: `section${d.available ? '' : ' kp-disabled'}`, id: 'kp-schedule' }, h('header', {}, 'Schedule'), h('div', { class: 'content' },
|
|
379
|
+
h('div', { class: 'row wrap kp-schedule-row' }, h('div', { class: 'field' }, h('label', { for: 'kp-frequency' }, 'How often'), frequency), fTime, fDays, fEvery, save),
|
|
380
|
+
hint,
|
|
381
|
+
d.available ? h('div', { class: 'muted', id: 'kp-next' }, h('span', {}, 'In force: '), h('b', {}, d.scheduleText), s.savedAt ? '' : ' (not changed since the takeover)', ' · ',
|
|
382
|
+
d.paused ? 'no scheduled round while organizing is paused' : d.nextAt ? ['next: ', h('b', {}, fmtTime(d.nextAt))] : s.frequency === 'Off' ? 'no round starts by itself' : 'a round follows each stretch of work') : null,
|
|
383
|
+
h('div', { class: 'faint' }, 'At the scheduled time a round starts when something changed since the last one. A time missed while ProjectKeeper was not running is made up for with one round.'))),
|
|
384
|
+
h('div', { class: `section${d.available ? '' : ' kp-disabled'}`, id: 'kp-follow-up' }, h('div', { class: 'content' },
|
|
385
|
+
h('div', { class: 'row wrap' }, h('button', { class: 'btn', id: 'kp-follow-up-btn', disabled: !d.available || Boolean(d.running), title: d.running ? 'A round is already running' : 'Organize what changed since the last round, now', onClick: followUp }, '↻ Follow up'),
|
|
386
|
+
h('span', { class: 'muted' }, 'Organize what changed since the last round now, without waiting for the schedule.')),
|
|
387
|
+
d.available ? status : null,
|
|
388
|
+
h('div', { class: 'faint' }, 'Earlier rounds are in ', h('button', { class: 'text-btn', onClick: () => openKeeperActivity() }, 'Keeper activity'), '.'))));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── the settings blocks, under both pages (§6.10 设置区块) ──────────────────
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* How an execution agent reaches this project by itself (Spec §6.10, §7.10): the same lines the context pack gives in
|
|
395
|
+
* `Explore further`, so what the owner copies to an agent works as written.
|
|
396
|
+
*/
|
|
397
|
+
function agentUsageBlock(lines) {
|
|
398
|
+
if (!lines?.length) return null; // an older server without the entry: nothing to show
|
|
399
|
+
const text = lines.map((l) => `- ${l}`).join('\n');
|
|
400
|
+
return h('div', { class: 'stack' },
|
|
401
|
+
h('div', {}, h('b', {}, 'An agent can reach this project by itself'), h('span', { class: 'muted' }, ' — from a terminal in the project, without you passing anything on:')),
|
|
402
|
+
h('ul', {}, ...lines.map((l) => h('li', { class: 'muted' }, l))),
|
|
403
|
+
h('div', {}, h('button', { class: 'btn small', onClick: async () => { try { await navigator.clipboard.writeText(text); toast('Usage copied — paste it to an agent'); } catch { openDialog('Copy the usage', [h('textarea', { class: 'input', style: { minHeight: '30vh' } }, text)]); } } }, 'Copy for an agent')));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function settingsBlocks(c, view, { keyFirst, mpv, usageRounds, usageLines, redraw }) {
|
|
407
|
+
const ka = c.keeperAgent;
|
|
408
|
+
const status = ka.status;
|
|
409
|
+
// ── Keeper agent: who it is and its state are open; the capability table and the loaded resources are an agent's
|
|
410
|
+
// matter and folded. What the owner must know stands on the fold's head.
|
|
411
|
+
const untrusted = ka.projectTrusted === false;
|
|
412
|
+
const changed = ka.resourceChanges?.length ?? 0;
|
|
413
|
+
const agentNote = [untrusted ? 'project-local pi resources are not loaded: the project is not trusted in pi' : null, changed ? `${plural(changed, 'change')} to project content during autonomous work` : null, ka.resourcesError ? 'resources could not be listed' : null].filter(Boolean).join(' · ') || null;
|
|
414
|
+
const agent = h('div', { class: 'section', id: 'kp-agent' },
|
|
415
|
+
h('header', {}, 'Keeper agent', h('span', { class: `tag ${status === 'Idle' || status === 'Working' || status === 'Working on your request' ? 'green' : status === 'Not connected' || status === 'Unavailable' ? 'red' : 'amber'}` }, status)),
|
|
416
|
+
h('div', { class: 'content' },
|
|
417
|
+
h('div', { class: 'row spread' }, h('div', {}, h('b', {}, ka.name), h('div', { class: 'muted' }, ka.reason || ka.detail || 'Full pi with the resources it loads in this project directory'), ka.trustDecision ? h('div', { class: 'faint' }, `Project trust in pi: ${ka.trustDecision}`) : null),
|
|
418
|
+
h('button', { class: 'btn small', onClick: async () => { const r = await api(`/api/projects/${P()}/keeper/open-in-pi`, { method: 'POST' }); toast(r.message); } }, 'Open in pi')),
|
|
419
|
+
fold('kp-agent-details', `Capabilities (${ka.capabilities.length}) and resources loaded in this project (${ka.resources.length})`, [
|
|
420
|
+
h('h4', {}, 'Capabilities'), ka.capabilities.length ? h('table', { class: 'list' }, h('thead', {}, h('tr', {}, h('th', {}, 'Capability'), h('th', {}, 'In the workbench'), h('th', {}, 'In pi'), h('th', {}, 'Availability'))), h('tbody', {}, ...ka.capabilities.map((x) => h('tr', {}, h('td', {}, x.name, x.source ? h('small', {}, ` · via ${x.source}`) : null), h('td', {}, x.workbench), h('td', {}, x.native), h('td', {}, h('span', { class: `tag ${x.available ? 'green' : ''}` }, x.available ? 'Available' : 'Not provided by this agent'), x.affects ? h('small', {}, ` ${x.affects}`) : null))))) : h('div', { class: 'faint' }, 'The capability table appears once the Keeper runtime is attached.'),
|
|
421
|
+
h('h4', {}, 'Resources loaded in this project'), ka.resources.length ? h('ul', {}, ...ka.resources.map((r) => h('li', {}, h('span', { class: 'tag' }, r.kind), ' ', r.name, r.path ? h('small', { class: 'mono' }, ` ${r.path}`) : null))) : h('div', { class: 'faint' }, untrusted ? 'Project-local pi resources are not loaded because the project is not trusted in pi. Trust it in pi (/trust) to load them.' : 'No resources listed yet.'),
|
|
422
|
+
ka.resourcesError ? h('div', { class: 'faint' }, `Resources could not be listed: ${ka.resourcesError}`) : null,
|
|
423
|
+
changed ? h('div', {}, h('h4', {}, 'Project content changed during autonomous Keeper work'), ...ka.resourceChanges.map((r) => h('div', {}, h('small', { class: 'faint' }, fmtTime(r.at)), ' ', r.detail))) : null,
|
|
424
|
+
], { note: agentNote })));
|
|
425
|
+
|
|
426
|
+
// ── Model provider (§6.10 key、模型与路由): the keys, this project's route — the main model, each step's model, the
|
|
427
|
+
// backups in order — and each key's jobs at once are open (they are what the owner chooses before Start); the record of
|
|
428
|
+
// switches is folded.
|
|
429
|
+
const provider = modelProviderBlock(mpv, redraw);
|
|
430
|
+
|
|
431
|
+
// ── Usage: the totals and each round split by model are open (CKC-03 AC-37); the usage of each piece of work is folded.
|
|
432
|
+
const u = c.usage;
|
|
433
|
+
const before = view.usageBeforeClear;
|
|
434
|
+
const usage = h('div', { class: 'section', id: 'kp-usage' }, h('header', {}, 'Usage'), h('div', { class: 'content', id: 'kp-usage-content' },
|
|
435
|
+
u.total ? h('div', { id: 'kp-usage-total' }, h('b', {}, 'In all: '), `input ${fmtTokens(u.total.input)} · output ${fmtTokens(u.total.output)} · cache read ${fmtTokens(u.total.cacheRead)} · cost ${u.total.cost == null ? 'not reported' : '$' + u.total.cost.toFixed(2)}`, u.quota ? h('div', { class: 'muted' }, u.quota) : null) : h('div', { class: 'faint' }, 'No usage reported yet.'),
|
|
436
|
+
before ? h('div', { class: 'muted', id: 'kp-usage-before' }, `Before the last clear (${fmtTime(before.clearedAt)}${before.times > 1 ? `, ${before.times} clears in all` : ''}): input ${before.usage.input} · output ${before.usage.output} · cost ${before.usage.cost == null ? 'not reported' : '$' + before.usage.cost.toFixed(4)}`) : null,
|
|
437
|
+
roundsUsage(usageRounds),
|
|
438
|
+
fold('kp-usage-jobs', `Usage of each piece of work (${u.byJob?.length ?? 0})`, u.byJob?.length ? [h('table', { class: 'list' }, h('thead', {}, h('tr', {}, h('th', {}, 'Work'), h('th', {}, 'Started'), h('th', {}, 'Model'), h('th', {}, 'Input'), h('th', {}, 'Output'), h('th', {}, 'Cost'))), h('tbody', {}, ...u.byJob.map((j) => h('tr', {}, h('td', {}, `${j.kind} · ${j.label}`), h('td', {}, fmtTime(j.startedAt)), h('td', { class: 'faint' }, j.model ?? '—'), h('td', {}, j.usage.input), h('td', {}, j.usage.output), h('td', {}, j.usage.cost == null ? '—' : `$${j.usage.cost.toFixed(4)}`)))))] : [h('div', { class: 'faint' }, 'No piece of work has run yet.')])));
|
|
439
|
+
|
|
440
|
+
// ── Project memory and execution-agent access: nothing here is a daily decision; both are folded whole.
|
|
441
|
+
const memory = fold('kp-memory', 'Project memory belongs to ProjectKeeper — what is kept when the Keeper agent changes', [
|
|
442
|
+
h('div', {}, 'Kept when the Keeper agent changes: ', c.memory.keeps.join(', '), '.'), h('div', { class: 'muted' }, 'Not promised: ', c.memory.doesNotPromise, '.'),
|
|
443
|
+
h('button', { class: 'text-btn', onClick: () => navigate(state.projectId, 'graph') }, 'Back to the same project')]);
|
|
444
|
+
const hosts = c.executionAgents.connected.length;
|
|
445
|
+
const access = fold('kp-access', `Execution-agent access — ${hosts ? `${plural(hosts, 'host')} connected` : 'no host connected yet'} · how an agent reaches this project by itself`, [
|
|
446
|
+
hosts ? h('table', { class: 'list' }, h('thead', {}, h('tr', {}, h('th', {}, 'Host'), h('th', {}, 'Status'))), h('tbody', {}, ...c.executionAgents.connected.map((x) => h('tr', {}, h('td', {}, x.host), h('td', {}, x.status))))) : h('div', {}, h('b', {}, 'None connected yet.')),
|
|
447
|
+
h('p', { class: 'muted' }, c.executionAgents.note, ' The session sources the Keeper reads are listed in Project scope.'),
|
|
448
|
+
agentUsageBlock(usageLines)]);
|
|
449
|
+
// ── Project folder (§1.14): the one thing the Keeper may write inside the project, and only on the owner's word. It is
|
|
450
|
+
// the owner's decision, so it is open: what it allows, whether it stands, and the control to give or withdraw it.
|
|
451
|
+
const folder = projectFolderBlock(view.projectFolder, redraw);
|
|
452
|
+
// Before the first Start the key and the model are what the owner chooses first, so that block comes first.
|
|
453
|
+
return keyFirst ? [provider, folder, agent, usage, memory, access] : [agent, provider, folder, usage, memory, access];
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The project-folder authorization: one sentence on what it allows, its state, and `Authorize…` or `Withdraw`. */
|
|
457
|
+
function projectFolderBlock(pf, redraw) {
|
|
458
|
+
if (!pf) return null; // an older server without the state: nothing to show
|
|
459
|
+
const s = folderStatus(pf, { time: fmtTime, rel: fmtRel });
|
|
460
|
+
const withdraw = async () => {
|
|
461
|
+
try {
|
|
462
|
+
await api(`/api/projects/${P()}/authorizations/${encodeURIComponent(pf.authorizationId)}/revoke`, { method: 'POST' });
|
|
463
|
+
toast('Withdrawn: the Keeper no longer writes the folder. What it wrote stays in the project.');
|
|
464
|
+
await refreshProject();
|
|
465
|
+
} catch (e) { toast(e.message); }
|
|
466
|
+
redraw();
|
|
467
|
+
};
|
|
468
|
+
return h('div', { class: 'section', id: 'kp-folder', 'data-granted': pf.granted ? 'yes' : 'no' },
|
|
469
|
+
h('header', {}, 'Project folder', h('span', { class: `tag ${s.tone}`, id: 'kp-folder-state' }, s.tag)),
|
|
470
|
+
h('div', { class: 'content' },
|
|
471
|
+
h('div', { class: 'row spread wrap kp-folder-row' },
|
|
472
|
+
h('div', { class: 'stack kp-folder-text' },
|
|
473
|
+
h('div', { id: 'kp-folder-allows' }, pf.allows),
|
|
474
|
+
...s.lines.map((line, i) => h('div', { class: i === 0 && pf.granted ? 'mono' : 'muted' }, line))),
|
|
475
|
+
s.action === 'withdraw'
|
|
476
|
+
? h('button', { class: 'btn small danger', id: 'kp-folder-withdraw', title: 'The Keeper stops writing the folder. The folder and its commits stay in the project.', onClick: withdraw }, 'Withdraw')
|
|
477
|
+
: h('button', { class: 'btn small', id: 'kp-folder-grant', title: 'Choose the folder and whether the Keeper commits it, then authorize', onClick: () => openGrantFolder(pf, redraw) }, 'Authorize…'))));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The dialog of `Authorize…`: the sentence again, the folder's name, whether the Keeper commits it, and the grant. */
|
|
481
|
+
function openGrantFolder(pf, redraw) {
|
|
482
|
+
const name = h('input', { class: 'input', id: 'kp-folder-name', value: pf.usual, autocomplete: 'off', spellcheck: 'false' });
|
|
483
|
+
const commits = h('input', { type: 'checkbox', id: 'kp-folder-commits', checked: true });
|
|
484
|
+
const err = h('small', { class: 'faint', id: 'kp-folder-err' });
|
|
485
|
+
const confirm = h('button', { class: 'btn primary', id: 'kp-folder-confirm' }, 'Authorize');
|
|
486
|
+
const run = async () => {
|
|
487
|
+
const folder = name.value.trim();
|
|
488
|
+
if (!folder) { err.textContent = 'Name the folder.'; return; }
|
|
489
|
+
confirm.disabled = true;
|
|
490
|
+
err.textContent = 'Writing the folder…';
|
|
491
|
+
try {
|
|
492
|
+
const r = await api(`/api/projects/${P()}/authorizations/project-folder`, { method: 'POST', body: { path: folder, commits: commits.checked, quote: grantWords({ folder, commits: commits.checked }) } });
|
|
493
|
+
document.querySelector('#dialog').close();
|
|
494
|
+
toast(grantOutcome(r.sync));
|
|
495
|
+
await refreshProject();
|
|
496
|
+
redraw();
|
|
497
|
+
} catch (e) { err.textContent = e.message; confirm.disabled = false; }
|
|
498
|
+
};
|
|
499
|
+
confirm.addEventListener('click', run);
|
|
500
|
+
name.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
|
|
501
|
+
openDialog('Authorize the project folder', [h('div', { class: 'stack kp-folder-grant' },
|
|
502
|
+
h('p', {}, pf.allows),
|
|
503
|
+
h('div', { class: 'field' }, h('label', { for: 'kp-folder-name' }, 'Folder, relative to the project’s root'), name),
|
|
504
|
+
h('label', { class: 'chk' }, commits, ' The Keeper commits the folder itself — each commit contains only this folder and is never pushed. Unticked, it only writes the files and they appear as uncommitted changes.'),
|
|
505
|
+
h('p', { class: 'muted' }, 'The folder is written now, from what is organized so far, and again after each round. It holds four files: a README, the meaning changes recognised in your documents, the numbers the Keeper gave to things your project left unnumbered, and the decisions it distilled. You can withdraw the authorization here at any time; what was written stays in the project.'),
|
|
506
|
+
err,
|
|
507
|
+
h('div', { class: 'dialog-foot' }, h('button', { class: 'btn', onClick: () => document.querySelector('#dialog').close() }, 'Cancel'), confirm))]);
|
|
508
|
+
name.focus();
|
|
509
|
+
name.select();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const STEP_NAMES = STEP_LABEL;
|
|
513
|
+
const fmtTokens = (n) => (n == null ? '—' : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : String(n));
|
|
514
|
+
const KEY_TAG = { Usable: 'green', Refused: 'red', 'Out of quota': 'red', 'Rate-limited': 'amber', 'Could not check': 'amber', 'No credentials': '' };
|
|
515
|
+
const keyLabel = (mpv, id) => mpv?.keys.find((k) => k.id === id)?.name ?? id;
|
|
516
|
+
const isUsable = (k) => k.state.status === 'Usable' || k.state.status === 'Rate-limited';
|
|
517
|
+
/** After a key or the route changed: the Keeper's status (left rail, dock) is read again with the page. */
|
|
518
|
+
const afterKeys = (redraw) => { void refreshActivity().catch(() => undefined); redraw(); };
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* `Model provider` (Spec §6.10 key、模型与路由; CKC-03 AC-33~AC-36): the keys of this machine — the owner's own, saved
|
|
522
|
+
* here and shown only masked, and those configured outside, saying where they come from — each with its state, the jobs
|
|
523
|
+
* it carries and how many it runs at once; then this project's route: the main model, each step's model or whom it
|
|
524
|
+
* follows, the backups in order. Changes apply to the steps that start afterwards.
|
|
525
|
+
*/
|
|
526
|
+
function modelProviderBlock(mpv, redraw) {
|
|
527
|
+
if (!mpv) {
|
|
528
|
+
return h('div', { class: 'section', id: 'kp-model' }, h('header', {}, 'Model provider'), h('div', { class: 'content', id: 'kp-model-content' }, h('div', { class: 'faint' }, 'The keys and routes could not be read from this server.')));
|
|
529
|
+
}
|
|
530
|
+
const usableKeys = mpv.keys.filter(isUsable);
|
|
531
|
+
const unusable = mpv.keys.filter((k) => !isUsable(k) && k.state.status !== 'No credentials');
|
|
532
|
+
const head = usableKeys.length
|
|
533
|
+
? h('span', { class: 'tag green' }, `${plural(usableKeys.length, 'usable key')}`)
|
|
534
|
+
: h('span', { class: 'tag red' }, 'no usable key');
|
|
535
|
+
return h('div', { class: 'section', id: 'kp-model' },
|
|
536
|
+
h('header', {}, 'Model provider', head, unusable.length ? h('span', { class: 'tag amber' }, `${plural(unusable.length, 'key')} not usable now`) : null),
|
|
537
|
+
h('div', { class: 'content', id: 'kp-model-content' },
|
|
538
|
+
h('h4', {}, 'Keys on this machine'),
|
|
539
|
+
keysTable(mpv, redraw),
|
|
540
|
+
addKeyRow(mpv, redraw),
|
|
541
|
+
h('div', { class: 'faint' }, 'A key you save is kept on this machine in ProjectKeeper’s own settings, not in any project, and is never shown again — only masked. Any project can use it. Keys set outside ProjectKeeper (an environment variable, pi’s own login) are listed too and are used the same way.'),
|
|
542
|
+
routeBlock(mpv, redraw),
|
|
543
|
+
fold('kp-switches', `Switches between keys (${mpv.switches?.length ?? 0})`, mpv.switches?.length ? mpv.switches.map((s) => h('div', { class: 'faint' }, `${fmtTime(s.at)} · ${s.from} → ${s.to}: ${s.reason}`)) : [h('div', { class: 'faint' }, 'No switch between keys has happened in this run.')])));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function keyStateCell(k) {
|
|
547
|
+
const s = k.state;
|
|
548
|
+
const tag = h('span', { class: `tag ${KEY_TAG[s.status] ?? ''}`, 'data-key-state': s.status }, s.status);
|
|
549
|
+
const lines = [];
|
|
550
|
+
if (s.status === 'Out of quota' && s.until) lines.push(`back ${fmtTime(s.until)}`);
|
|
551
|
+
if (s.reason) lines.push(s.reason);
|
|
552
|
+
lines.push(s.quotaReported ? (s.quota ?? 'quota: press Check to read it') : 'this provider does not report quota');
|
|
553
|
+
if (s.checkedAt) lines.push(`checked ${fmtRel(s.checkedAt)} (${s.how})`);
|
|
554
|
+
return h('td', { class: 'kp-key-state' }, tag, ...lines.map((l) => h('div', { class: 'faint' }, l)));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function keysTable(mpv, redraw) {
|
|
558
|
+
if (!mpv.keys.length) return h('div', { class: 'kp-warn', id: 'kp-no-keys' }, 'No key yet. Add one below: choose the provider, paste the key, Save. Start stays off until a key can be used.');
|
|
559
|
+
const act = async (fn, done) => { try { const r = await fn(); if (done) toast(done(r)); } catch (e) { toast(e.message); } afterKeys(redraw); };
|
|
560
|
+
const lanesInput = (k) => {
|
|
561
|
+
const input = h('input', { class: 'input small kp-key-lanes', type: 'number', min: 1, max: 32, value: k.lanes, title: k.ownLanes ? 'Set for this key' : 'The setting for every key', 'aria-label': `Jobs at once on ${k.name}` });
|
|
562
|
+
input.addEventListener('change', () => act(() => api(`/api/keys/${encodeURIComponent(k.id)}/lanes`, { method: 'POST', body: { lanes: Number(input.value) } }), (r) => `${k.name}: up to ${r.lanes} at once`));
|
|
563
|
+
return input;
|
|
564
|
+
};
|
|
565
|
+
return h('div', { class: 'kp-table-frame' }, h('table', { class: 'list kp-keys', id: 'kp-keys' },
|
|
566
|
+
h('thead', {}, h('tr', {}, ...['Key', 'Provider', 'Value', 'From', 'State', 'Jobs now', 'At once', ''].map((t) => h('th', {}, t)))),
|
|
567
|
+
h('tbody', {}, ...mpv.keys.map((k) => h('tr', { 'data-key': k.id },
|
|
568
|
+
h('td', {}, h('b', {}, k.name)),
|
|
569
|
+
h('td', {}, k.providerName, h('div', { class: 'faint mono' }, k.provider)),
|
|
570
|
+
h('td', { class: 'mono kp-mask' }, k.mask ?? '—'),
|
|
571
|
+
h('td', { class: 'faint' }, k.fromDetail),
|
|
572
|
+
keyStateCell(k),
|
|
573
|
+
h('td', { class: 'kp-num' }, k.running),
|
|
574
|
+
h('td', {}, lanesInput(k)),
|
|
575
|
+
h('td', { class: 'kp-key-actions' },
|
|
576
|
+
h('button', { class: 'btn small', 'data-act': 'check', title: 'Ask the provider whether this key works: one request of one output token', onClick: (e) => { e.target.disabled = true; e.target.textContent = 'Checking…'; act(() => api(`/api/keys/${encodeURIComponent(k.id)}/check`, { method: 'POST' }), (r) => `${k.name}: ${r.check.status}${r.check.reason ? ` — ${r.check.reason}` : ''}`); } }, 'Check'),
|
|
577
|
+
k.removable ? h('button', { class: 'btn small', 'data-act': 'replace', title: 'Paste a new value for this key', onClick: () => openReplaceKey(k, redraw) }, 'Replace') : null,
|
|
578
|
+
k.removable ? h('button', { class: 'btn small danger', 'data-act': 'remove', title: 'Remove this key from this machine; work on it moves to the next key of each route', onClick: () => openRemoveKey(k, redraw) }, 'Remove') : null))))));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function openReplaceKey(k, redraw) {
|
|
582
|
+
const value = h('input', { class: 'input', type: 'password', id: 'kp-replace-value', autocomplete: 'off', spellcheck: 'false', placeholder: 'the new key' });
|
|
583
|
+
const name = h('input', { class: 'input', id: 'kp-replace-name', value: k.name });
|
|
584
|
+
const err = h('small', { class: 'faint' });
|
|
585
|
+
const save = h('button', { class: 'btn primary', id: 'kp-replace-save' }, 'Save');
|
|
586
|
+
save.addEventListener('click', async () => {
|
|
587
|
+
save.disabled = true; err.textContent = 'Saving and checking…';
|
|
588
|
+
try {
|
|
589
|
+
const r = await api(`/api/keys/${encodeURIComponent(k.id)}`, { method: 'POST', body: { key: value.value, name: name.value } });
|
|
590
|
+
value.value = '';
|
|
591
|
+
document.querySelector('#dialog').close();
|
|
592
|
+
toast(`${r.key.name} saved${r.check ? ` · ${r.check.status}` : ''}`);
|
|
593
|
+
afterKeys(redraw);
|
|
594
|
+
} catch (e) { err.textContent = e.message; save.disabled = false; }
|
|
595
|
+
});
|
|
596
|
+
openDialog(`Replace ${k.name}`, [h('div', { class: 'stack' },
|
|
597
|
+
h('div', { class: 'field' }, h('label', { for: 'kp-replace-name' }, 'Name'), name),
|
|
598
|
+
h('div', { class: 'field' }, h('label', { for: 'kp-replace-value' }, 'New key (leave empty to rename only)'), value),
|
|
599
|
+
err, h('div', { class: 'dialog-foot' }, h('button', { class: 'btn', onClick: () => document.querySelector('#dialog').close() }, 'Cancel'), save))]);
|
|
600
|
+
value.focus();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function openRemoveKey(k, redraw) {
|
|
604
|
+
const err = h('small', { class: 'faint' });
|
|
605
|
+
const go = h('button', { class: 'btn danger', id: 'kp-remove-confirm' }, 'Remove the key');
|
|
606
|
+
go.addEventListener('click', async () => {
|
|
607
|
+
go.disabled = true;
|
|
608
|
+
try {
|
|
609
|
+
const r = await api(`/api/keys/${encodeURIComponent(k.id)}`, { method: 'DELETE' });
|
|
610
|
+
document.querySelector('#dialog').close();
|
|
611
|
+
toast(`Removed ${k.name}${r.rerouted ? ` · ${plural(r.rerouted, 'job')} moved on to the next key` : ''}`);
|
|
612
|
+
afterKeys(redraw);
|
|
613
|
+
} catch (e) { err.textContent = e.message; go.disabled = false; }
|
|
614
|
+
});
|
|
615
|
+
openDialog(`Remove ${k.name}`, [h('div', { class: 'stack' },
|
|
616
|
+
h('p', {}, 'The key is removed from this machine. ', k.running ? `The ${plural(k.running, 'job')} running on it ${k.running === 1 ? 'goes' : 'go'} on, in ${k.running === 1 ? 'its' : 'their'} own session, on the next usable key of ${k.running === 1 ? 'its' : 'their'} project’s order; with none left, ${k.running === 1 ? 'it waits' : 'they wait'} and say${k.running === 1 ? 's' : ''} why.` : 'No work runs on it now.', ' A project whose route names it passes over it to its next key.'),
|
|
617
|
+
err, h('div', { class: 'dialog-foot' }, h('button', { class: 'btn', onClick: () => document.querySelector('#dialog').close() }, 'Cancel'), go))]);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function addKeyRow(mpv, redraw) {
|
|
621
|
+
const prov = h('select', { class: 'input', id: 'kp-add-provider', 'aria-label': 'Provider' }, h('option', { value: '' }, 'Choose the provider'), ...mpv.providers.map((p) => h('option', { value: p.id }, p.name === p.id ? p.id : `${p.name} (${p.id})`)));
|
|
622
|
+
const name = h('input', { class: 'input', id: 'kp-add-name', placeholder: 'a name (optional)', autocomplete: 'off' });
|
|
623
|
+
const value = h('input', { class: 'input', id: 'kp-add-key', type: 'password', placeholder: 'paste the key', autocomplete: 'off', spellcheck: 'false' });
|
|
624
|
+
const save = h('button', { class: 'btn primary small', id: 'kp-add-save' }, 'Save');
|
|
625
|
+
const note = h('span', { class: 'faint', id: 'kp-add-note' });
|
|
626
|
+
save.addEventListener('click', async () => {
|
|
627
|
+
if (!prov.value) { note.textContent = 'Choose the provider the key is for.'; return; }
|
|
628
|
+
save.disabled = true; note.textContent = 'Saving and checking…';
|
|
629
|
+
try {
|
|
630
|
+
const r = await api('/api/keys', { method: 'POST', body: { provider: prov.value, name: name.value, key: value.value } });
|
|
631
|
+
value.value = '';
|
|
632
|
+
toast(`${r.key.name} saved · ${r.check.status}${r.check.reason ? ` — ${r.check.reason}` : ''}`);
|
|
633
|
+
afterKeys(redraw);
|
|
634
|
+
} catch (e) { note.textContent = e.message; save.disabled = false; }
|
|
635
|
+
});
|
|
636
|
+
return h('div', { class: 'row wrap kp-add-key', id: 'kp-add-key-row' },
|
|
637
|
+
h('div', { class: 'field' }, h('label', { for: 'kp-add-provider' }, 'Add a key — provider'), prov),
|
|
638
|
+
h('div', { class: 'field' }, h('label', { for: 'kp-add-name' }, 'Name'), name),
|
|
639
|
+
h('div', { class: 'field grow' }, h('label', { for: 'kp-add-key' }, 'Key'), value),
|
|
640
|
+
save, note);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** The models a key carries, for a select. */
|
|
644
|
+
const modelOptions = (key, selected) => (key?.models ?? []).map((m) => h('option', { value: m.id, selected: m.id === selected }, `${m.id}${m.priced ? '' : ' (no price reported)'}`));
|
|
645
|
+
const thinkingOptions = (levels, selected, first = null) => [first ? h('option', { value: '', selected: !selected }, first) : null, ...levels.map((t) => h('option', { value: t, selected: selected === t }, t))];
|
|
646
|
+
|
|
647
|
+
/** This project's route (§6.10 每一步用哪个模型, 备用与并行). */
|
|
648
|
+
function routeBlock(mpv, redraw) {
|
|
649
|
+
const r = mpv.route;
|
|
650
|
+
const usable = mpv.keys.filter(isUsable);
|
|
651
|
+
const post = async (body, done) => { try { await api(`/api/projects/${P()}/route`, { method: 'POST', body }); toast(done); } catch (e) { toast(e.message); } afterKeys(redraw); };
|
|
652
|
+
// ── the main model: a key, one of its models, how hard it thinks.
|
|
653
|
+
const mainKeyId = r.main?.provider ?? '';
|
|
654
|
+
// The empty choice only while no key is set: with one, the select holds that key and nothing reads as "Choose a key".
|
|
655
|
+
const keySel = h('select', { class: 'input', id: 'kp-main-key', 'aria-label': 'Key' }, mainKeyId ? null : h('option', { value: '' }, usable.length ? 'Choose a key' : 'No usable key'),
|
|
656
|
+
...mpv.keys.filter((k) => isUsable(k) || k.id === mainKeyId).map((k) => h('option', { value: k.id, selected: k.id === mainKeyId }, `${k.name}${isUsable(k) ? '' : ` (${k.state.status.toLowerCase()})`}`)));
|
|
657
|
+
const modelSel = h('select', { class: 'input', id: 'kp-main-model', 'aria-label': 'Model' }, ...modelOptions(mpv.keys.find((k) => k.id === mainKeyId), r.main?.id));
|
|
658
|
+
keySel.addEventListener('change', () => {
|
|
659
|
+
const k = mpv.keys.find((x) => x.id === keySel.value);
|
|
660
|
+
const keep = modelSel.value;
|
|
661
|
+
modelSel.replaceChildren(...modelOptions(k, k?.models.some((m) => m.id === keep) ? keep : null));
|
|
662
|
+
});
|
|
663
|
+
const thinkSel = h('select', { class: 'input', id: 'kp-main-thinking', 'aria-label': 'Thinking' }, ...thinkingOptions(mpv.thinking, r.main?.thinking ?? 'medium'));
|
|
664
|
+
// A key that sits in a lower row of the key order changes places with the main key, as that list's own select does (CY; key-order.js `mainChosen`); any other key replaces it.
|
|
665
|
+
const use = h('button', { class: 'btn small primary', id: 'kp-main-use', onClick: () => { if (!keySel.value || !modelSel.value) { toast('Choose a key and a model'); return; } const swapped = mainChosen(orderRows(r).map((b) => ({ provider: b.provider, id: b.id, thinking: b.thinking ?? null })), mpv.keys.find((k) => k.id === keySel.value), modelSel.value, r.main?.id); post(swapped ? orderBody(swapped, thinkSel.value) : { model: { provider: keySel.value, id: modelSel.value, thinking: thinkSel.value } }, `Main model: ${keyLabel(mpv, keySel.value)} · ${modelSel.value} — the steps that follow it change with it`); } }, 'Use');
|
|
666
|
+
const mainKey = r.main ? mpv.keys.find((k) => k.id === r.main.provider) : null;
|
|
667
|
+
const mainWarn = r.main && !r.main.usable ? h('div', { class: 'kp-warn', id: 'kp-main-warn' }, `${r.main.keyName} cannot be used now (${mainKey ? mainKey.state.status.toLowerCase() : 'removed'}): ${mpv.fallback ? `${mpv.fallback.keyName} · ${mpv.fallback.id} stands in` : 'no other key can stand in'}. Choose another key, or add one.`) : null;
|
|
668
|
+
return h('div', { class: 'stack kp-route', id: 'kp-route' },
|
|
669
|
+
h('h4', {}, 'This project runs on', h('span', { class: 'faint', style: { fontWeight: 400 } }, r.own ? ' — set for this project' : ' — the machine’s settings, until you change them here')),
|
|
670
|
+
h('div', { class: 'row wrap kp-main-row' },
|
|
671
|
+
h('div', { class: 'field' }, h('label', { for: 'kp-main-key' }, 'Main model — key'), keySel),
|
|
672
|
+
h('div', { class: 'field grow' }, h('label', { for: 'kp-main-model' }, 'Model'), modelSel),
|
|
673
|
+
h('div', { class: 'field' }, h('label', { for: 'kp-main-thinking' }, 'Thinking'), thinkSel), use),
|
|
674
|
+
h('div', { class: 'faint' }, 'Your conversation and requests run on the main model, and so does every step below that follows it.'),
|
|
675
|
+
mainWarn,
|
|
676
|
+
stepsTable(mpv, post),
|
|
677
|
+
backupsBlock(mpv, post),
|
|
678
|
+
lanesLine(mpv),
|
|
679
|
+
h('div', { class: 'faint' }, 'A change applies to the steps that start after it; a step already running is not interrupted.'));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Each step of a round: its own model and thinking, or whom it follows (CKC-03 AC-29). Only models of usable keys are offered. */
|
|
683
|
+
function stepsTable(mpv, post) {
|
|
684
|
+
const r = mpv.route;
|
|
685
|
+
// Whom a step follows when it has no model of its own — said in the select's first option even while it has one.
|
|
686
|
+
const followText = (s) => {
|
|
687
|
+
const whom = s.followsWhom ?? 'main model';
|
|
688
|
+
const model = s.followsModel ?? (whom === 'main model' ? r.main?.id : null);
|
|
689
|
+
return whom === 'main model' ? `follows the main model (${model ?? 'none chosen'})` : `follows ${STEP_NAMES[whom] ?? whom} (${model ?? '—'})`;
|
|
690
|
+
};
|
|
691
|
+
const byProvider = new Map();
|
|
692
|
+
for (const o of mpv.offered) { if (!byProvider.has(o.provider)) byProvider.set(o.provider, { name: o.providerName, models: [] }); byProvider.get(o.provider).models.push(o); }
|
|
693
|
+
return h('div', { id: 'steps-settings' },
|
|
694
|
+
h('h5', {}, 'Each step of a round'),
|
|
695
|
+
h('table', { class: 'list kp-steps-route' }, h('thead', {}, h('tr', {}, h('th', {}, 'Step'), h('th', {}, 'Model'), h('th', {}, 'Thinking'))),
|
|
696
|
+
h('tbody', {}, ...r.steps.map((s) => {
|
|
697
|
+
const own = s.own;
|
|
698
|
+
const ownValue = own?.model ? s.place ?? `${own.provider ?? ''}|${own.model}` : '';
|
|
699
|
+
const modelSel = h('select', { class: 'input small', 'aria-label': `Model of ${STEP_NAMES[s.kind] ?? s.kind}` },
|
|
700
|
+
h('option', { value: '', selected: !own?.model }, `— ${followText(s)}`),
|
|
701
|
+
...[...byProvider.entries()].map(([prov, g]) => h('optgroup', { label: g.name === prov ? prov : `${g.name} (${prov})` },
|
|
702
|
+
...g.models.map((o) => h('option', { value: `${prov}|${o.model}`, selected: ownValue === `${prov}|${o.model}` }, `${o.model} · ${o.keys.length === 1 ? o.keys[0] : `${o.keys.length} keys`}`)))));
|
|
703
|
+
// A step set to a model no usable key carries now still shows what it is set to.
|
|
704
|
+
if (own?.model && ![...modelSel.querySelectorAll('option')].some((o) => o.value === ownValue)) modelSel.append(h('option', { value: ownValue, selected: true }, `${own.model} (no usable key carries it now)`));
|
|
705
|
+
const thinkSel = h('select', { class: 'input small', 'aria-label': `Thinking of ${STEP_NAMES[s.kind] ?? s.kind}` }, ...thinkingOptions(mpv.thinking, own?.thinking ?? null, `— as it follows (${s.thinking ?? 'medium'})`));
|
|
706
|
+
const save = () => {
|
|
707
|
+
const [prov, model] = modelSel.value ? modelSel.value.split('|') : [null, null];
|
|
708
|
+
const value = model || thinkSel.value ? { ...(model ? { provider: prov, model } : {}), ...(thinkSel.value ? { thinking: thinkSel.value } : {}) } : null;
|
|
709
|
+
post({ steps: { [s.kind]: value } }, value ? `${STEP_NAMES[s.kind] ?? s.kind}: ${model ?? 'as it follows'} · thinking ${thinkSel.value || 'as it follows'}` : `${STEP_NAMES[s.kind] ?? s.kind}: follows again`);
|
|
710
|
+
};
|
|
711
|
+
modelSel.addEventListener('change', save);
|
|
712
|
+
thinkSel.addEventListener('change', save);
|
|
713
|
+
return h('tr', { dataset: { step: s.kind } }, h('td', {}, STEP_NAMES[s.kind] ?? s.kind, own?.model ? null : h('div', { class: 'faint kp-follows' }, followText(s))), h('td', {}, modelSel), h('td', {}, thinkSel));
|
|
714
|
+
}))));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* The order of keys that stand in when one runs out of quota or is rate-limited (CKC-03 AC-35). Its first row is the main
|
|
719
|
+
* key — the same setting as `Main model — key` above. Every row chooses its key and its model and moves like any other; a
|
|
720
|
+
* key chosen in one row that sits in another changes places with it. Whichever row is first is the main key, and runs
|
|
721
|
+
* with the main model's thinking.
|
|
722
|
+
*/
|
|
723
|
+
function backupsBlock(mpv, post) {
|
|
724
|
+
const r = mpv.route;
|
|
725
|
+
const thinking = r.main?.thinking ?? null;
|
|
726
|
+
const rows = orderRows(r);
|
|
727
|
+
const list = rows.map((b) => ({ provider: b.provider, id: b.id, thinking: b.thinking ?? null }));
|
|
728
|
+
// The whole order back to the server (key-order.js): the first row is the main model, the rest the backups.
|
|
729
|
+
const send = (next, done) => { const body = orderBody(next, thinking); if (!body) { toast('Keep at least one key in the order'); return; } post(body, done); };
|
|
730
|
+
const move = (i, d) => { const next = moveRow(list, i, d); send(next, i + d === 0 || i === 0 ? `${keyLabel(mpv, next[0].provider)} · ${next[0].id} is the main key now — the steps that follow it change with it` : 'Backup order saved'); };
|
|
731
|
+
const others = mpv.keys.filter((k) => isUsable(k) && !list.some((b) => b.provider === k.id));
|
|
732
|
+
const add = h('select', { class: 'input small', id: 'kp-backup-add', 'aria-label': 'Add a backup key' }, h('option', { value: '' }, others.length ? 'Add a key to the order…' : 'No other usable key to add'), ...others.map((k) => h('option', { value: k.id }, k.name)));
|
|
733
|
+
add.addEventListener('change', () => {
|
|
734
|
+
const k = mpv.keys.find((x) => x.id === add.value);
|
|
735
|
+
if (!k) return;
|
|
736
|
+
// On the backup, the main model when it carries it, else its first model.
|
|
737
|
+
send([...list, { provider: k.id, id: modelFor(k, null, r.main?.id), thinking }], `${k.name} added to the backup order`);
|
|
738
|
+
});
|
|
739
|
+
// A row's key: any usable key, and the keys of the order as they are. A key that sits in another row changes places
|
|
740
|
+
// with this one; a key outside the order takes the row (key-order.js). The first row's key is the main key.
|
|
741
|
+
const keySel = (b, i, first) => {
|
|
742
|
+
const set = mpv.keys.some((k) => k.id === b.provider);
|
|
743
|
+
const sel = h('select', { class: 'input small kp-order-key', 'aria-label': first ? 'The main key' : `Key of row ${i + 1}`, ...(first ? { id: 'kp-order-main-key' } : {}), title: 'The key of this row. A key that is already in another row changes places with this one.' },
|
|
744
|
+
set ? null : h('option', { value: b.provider, selected: true }, `${b.keyName} (removed)`),
|
|
745
|
+
...mpv.keys.filter((k) => isUsable(k) || list.some((x) => x.provider === k.id)).map((k) => h('option', { value: k.id, selected: k.id === b.provider }, `${k.name}${isUsable(k) ? '' : ` (${k.state.status.toLowerCase()})`}`)));
|
|
746
|
+
sel.addEventListener('change', () => {
|
|
747
|
+
const k = mpv.keys.find((x) => x.id === sel.value);
|
|
748
|
+
if (!k || k.id === b.provider) return;
|
|
749
|
+
const from = list.findIndex((x) => x.provider === k.id);
|
|
750
|
+
const next = chooseKey(list, i, k, r.main?.id);
|
|
751
|
+
const main = next[0].provider !== list[0].provider ? `${keyLabel(mpv, next[0].provider)} · ${next[0].id} is the main key now — the steps that follow it change with it` : null;
|
|
752
|
+
send(next, main ?? (from >= 0 ? `${k.name} and ${b.keyName} changed places` : `${k.name} takes the place of ${b.keyName}, on ${next[i].id}`));
|
|
753
|
+
});
|
|
754
|
+
return sel;
|
|
755
|
+
};
|
|
756
|
+
return h('div', { id: 'kp-backups' },
|
|
757
|
+
h('h5', {}, 'When a key runs out of quota or is rate-limited'),
|
|
758
|
+
h('ol', { class: 'kp-backup-list' },
|
|
759
|
+
rows.length ? null : h('li', { class: 'faint' }, 'No main key chosen.'),
|
|
760
|
+
...rows.map((b, i) => {
|
|
761
|
+
const k = mpv.keys.find((x) => x.id === b.provider);
|
|
762
|
+
const first = i === 0 && Boolean(r.main);
|
|
763
|
+
const sel = h('select', { class: 'input small kp-order-model', 'aria-label': first ? 'The main model' : `Model on ${b.keyName}`, ...(first ? { id: 'kp-order-main-model' } : {}), title: first ? 'The main model: your conversation and every step that follows it run on it' : 'The model this key runs when it stands in for a model it does not carry' }, ...modelOptions(k, b.id));
|
|
764
|
+
sel.addEventListener('change', () => send(list.map((x, j) => (j === i ? { ...x, id: sel.value } : x)), first ? `Main model: ${b.keyName} · ${sel.value} — the steps that follow it change with it` : `${b.keyName}: stands in with ${sel.value}`));
|
|
765
|
+
return h('li', { 'data-backup': b.provider, ...(first ? { 'data-main': '' } : {}) }, h('div', { class: 'kp-backup-row' },
|
|
766
|
+
keySel(b, i, first), b.usable ? null : h('span', { class: 'tag amber' }, k ? k.state.status.toLowerCase() : 'removed'), sel,
|
|
767
|
+
first ? h('span', { class: 'faint' }, 'the main key, used first') : null,
|
|
768
|
+
h('button', { class: 'btn small', title: 'Earlier', disabled: i === 0, onClick: () => move(i, -1) }, '↑'),
|
|
769
|
+
h('button', { class: 'btn small', title: first ? 'Later: the next key becomes the main key' : 'Later', disabled: i === rows.length - 1, onClick: () => move(i, 1) }, '↓'),
|
|
770
|
+
h('button', { class: 'btn small', title: first ? 'Take it out of the order: the next key becomes the main key' : 'Take it out of the order', disabled: rows.length === 1, onClick: () => send(list.filter((_, j) => j !== i), first ? `${b.keyName} taken out; ${keyLabel(mpv, list[1]?.provider)} is the main key now` : `${b.keyName} taken out of the backup order`) }, '×')));
|
|
771
|
+
})),
|
|
772
|
+
add,
|
|
773
|
+
h('div', { class: 'faint' }, 'The first row is the main key — the same setting as the main model above. Every row chooses its key and its model; a key already in another row changes places with it. Work goes on in its own session on the next usable key of this order, and comes back when the first key is usable again. A step set to a model on a key runs on that key (on a provider’s keys, shared by them); when they are out of quota or rate-limited, on the other keys of this order that carry the same model, recorded under Switches between keys. Only when no usable key carries the model does a key stand in with the model of its row.'));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function lanesLine(mpv) {
|
|
777
|
+
const keys = mpv.lanes.keys;
|
|
778
|
+
return h('div', { class: 'muted', id: 'kp-lanes' }, h('b', {}, `Up to ${mpv.lanes.total} jobs at once`), ` on this project’s ${plural(keys.length, 'key')}: `, keys.map((k) => `${k.keyName} ${k.running}/${k.capacity}`).join(' · ') || '—', '. Each key’s number is set in the keys table above.');
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** `Usage` per round, split by model (§6.10; CKC-03 AC-37): the recent rounds open, earlier ones folded. */
|
|
782
|
+
function roundsUsage(data) {
|
|
783
|
+
const rounds = data?.rounds ?? null;
|
|
784
|
+
if (!rounds) return null;
|
|
785
|
+
if (!rounds.length) return h('div', { class: 'faint', id: 'kp-usage-rounds' }, 'No round has run yet.');
|
|
786
|
+
const OPEN = 3;
|
|
787
|
+
const kindText = (k) => (k === 'Deepen' ? 'deepening' : k === 'First usable' ? 'first usable picture' : k.toLowerCase());
|
|
788
|
+
const row = (r, open) => h('details', { class: 'fold kp-round-usage', 'data-round': r.id, open },
|
|
789
|
+
h('summary', {}, h('span', { class: 'kp-fold-head' }, `Round ${r.number} · ${kindText(r.kind)} · ${fmtWall(r.wallMs)} · ${r.cost == null ? 'cost not reported' : `$${r.cost.toFixed(2)}`} · ${plural(r.byModel.length, 'model')}`), h('span', { class: 'faint' }, ` ${fmtTime(r.startedAt)} · ${r.status}`)),
|
|
790
|
+
h('div', { class: 'content' }, h('div', { class: 'kp-table-frame' }, h('table', { class: 'list kp-model-usage' },
|
|
791
|
+
h('thead', {}, h('tr', {}, ...['Model', 'Keys', 'Steps it carried', 'Time', 'Input', 'Output', 'Cost'].map((t) => h('th', {}, t)))),
|
|
792
|
+
h('tbody', {}, ...r.byModel.map((m) => h('tr', { 'data-model': m.model },
|
|
793
|
+
h('td', {}, h('b', {}, m.model)), h('td', { class: 'faint' }, m.keys.join(', ')),
|
|
794
|
+
h('td', { class: 'faint' }, m.steps.map((s) => `${STEP_NAMES[s.kind] ?? s.kind}${s.count > 1 ? ` ×${s.count}` : ''}`).join(', ')),
|
|
795
|
+
h('td', {}, fmtWall(m.timeMs)), h('td', { class: 'kp-num' }, fmtTokens(m.input)), h('td', { class: 'kp-num' }, fmtTokens(m.output)),
|
|
796
|
+
h('td', { class: 'kp-num' }, m.priced ? `$${(m.cost ?? 0).toFixed(2)}` : 'price not reported')))))),
|
|
797
|
+
h('div', { class: 'faint' }, 'Time is each model’s jobs added up; the round’s own time is its wall clock, set by the longest lane.')));
|
|
798
|
+
const earlier = rounds.slice(OPEN);
|
|
799
|
+
return h('div', { class: 'stack', id: 'kp-usage-rounds' }, h('h4', {}, 'Each round, by model'), ...rounds.slice(0, OPEN).map((r) => row(r, true)),
|
|
800
|
+
earlier.length ? fold('kp-usage-earlier', `Earlier rounds (${earlier.length})`, earlier.map((r) => row(r, false))) : null);
|
|
801
|
+
}
|