backlens 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/ai/credentials.js +56 -0
- package/ai/fixes.js +65 -9
- package/ai/providers.js +125 -14
- package/analyze.js +15 -2
- package/package.json +1 -1
- package/server.js +21 -5
- package/ui.html +168 -21
package/README.md
CHANGED
|
@@ -57,11 +57,13 @@ In the Compare tab, save a snapshot, change your code, restart, use the same pag
|
|
|
57
57
|
|
|
58
58
|
Every issue has **Explain** and **Suggest fix**. A fix goes: a preview of exactly what would be sent and to whom, the model's answer as a diff, **Apply and check** (only on files with no uncommitted changes, with a backup), then backlens rebuilds if needed, restarts your app, replays the request that showed the issue and runs your tests. Only a fix that removes the issue and keeps the tests green is marked proven. Keep it, or revert to the exact files you had.
|
|
59
59
|
|
|
60
|
-
Who answers is
|
|
60
|
+
Who answers is set up in **AI settings**, all from the dashboard. The first-run setup asks too.
|
|
61
61
|
|
|
62
|
-
- **Local model** (free, private):
|
|
63
|
-
- **
|
|
64
|
-
- **
|
|
62
|
+
- **Local model** (free, private): runs on your computer through [Ollama](https://ollama.com). The card walks you through it: a link to install Ollama, then it notices when Ollama is running, suggests the model that fits your memory (`qwen2.5-coder` 3B for 8 GB, 7B for 16 GB, 14B for 32 GB) and downloads it with a progress bar. Small models explain well; their fixes are hit-and-miss, and every fix is checked anyway.
|
|
63
|
+
- **Claude with your own key**: paste the key and backlens checks it for free before saving it in your user folder (`~/.config/backlens`, readable by you only), never in the project. `ANTHROPIC_API_KEY` in the environment wins if set. Explaining uses Claude Haiku 5.5; fixes use Sonnet 5.5 or Opus 5.5 depending on how hard the issue looks (files involved, issue type, test coverage, your past results), one step up after a failed check. A daily budget (default $1) is enforced before sending.
|
|
64
|
+
- **No model**: explanations in backlens's own words.
|
|
65
|
+
|
|
66
|
+
Some fixes never use a model: running independent calls together (`Promise.all`) and missing indexes are written by backlens itself, the same way every time.
|
|
65
67
|
|
|
66
68
|
Values that look like secrets are masked, `.env` files and `node_modules` are never read, and requests kept for replay stay in memory only. Replaying a POST, PUT or DELETE runs it again against your dev database, so it is off unless you allow it in AI settings.
|
|
67
69
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The user's Claude API key, pasted in the dashboard. It is kept outside every project, in the
|
|
7
|
+
* user's config folder, readable by the user only, so it can never be committed with the code.
|
|
8
|
+
* ANTHROPIC_API_KEY in the environment wins over the saved key. The key never goes back to the
|
|
9
|
+
* browser: the dashboard only sees its last four characters.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function configDir() {
|
|
13
|
+
if (process.env.BACKLENS_HOME) return process.env.BACKLENS_HOME;
|
|
14
|
+
if (process.platform === 'win32' && process.env.APPDATA) return join(process.env.APPDATA, 'backlens');
|
|
15
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'backlens');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const file = () => join(configDir(), 'credentials.json');
|
|
19
|
+
|
|
20
|
+
/** A loose shape check, so a pasted sentence or a key from another service is caught early. */
|
|
21
|
+
export function looksLikeKey(key) {
|
|
22
|
+
return typeof key === 'string' && /^sk-ant-[A-Za-z0-9_-]{20,300}$/.test(key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function savedKey() {
|
|
26
|
+
try {
|
|
27
|
+
const key = JSON.parse(readFileSync(file(), 'utf8')).anthropicApiKey;
|
|
28
|
+
return looksLikeKey(key) ? key : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function saveKey(key) {
|
|
35
|
+
if (!looksLikeKey(key)) throw new Error('That does not look like a Claude API key. It starts with sk-ant-.');
|
|
36
|
+
const dir = configDir();
|
|
37
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
38
|
+
writeFileSync(file(), `${JSON.stringify({ anthropicApiKey: key }, null, 2)}\n`, { mode: 0o600 });
|
|
39
|
+
// mode only applies when the file is created; tighten an existing one too.
|
|
40
|
+
try {
|
|
41
|
+
chmodSync(file(), 0o600);
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function removeKey() {
|
|
46
|
+
rmSync(file(), { force: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The key to use and where it came from: the environment first, then the saved one. */
|
|
50
|
+
export function currentKey() {
|
|
51
|
+
if (process.env.ANTHROPIC_API_KEY) return { key: process.env.ANTHROPIC_API_KEY, source: 'env' };
|
|
52
|
+
const key = savedKey();
|
|
53
|
+
return key ? { key, source: 'saved' } : { key: null, source: null };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const lastFour = (key) => (key ? key.slice(-4) : '');
|
package/ai/fixes.js
CHANGED
|
@@ -6,7 +6,8 @@ import { dirname, join, relative } from 'node:path';
|
|
|
6
6
|
import { checkTrace } from '../analyze.js';
|
|
7
7
|
import { ensureIgnore } from '../config.js';
|
|
8
8
|
import { buildContext, insideProject } from './context.js';
|
|
9
|
-
import {
|
|
9
|
+
import { looksLikeKey, removeKey, saveKey } from './credentials.js';
|
|
10
|
+
import { anthropicStatus, ask, issuePrompt, LOCAL_MODELS, ollamaStatus, startPull, stopPull, testKey } from './providers.js';
|
|
10
11
|
import { route } from './router.js';
|
|
11
12
|
import { promiseAll } from './templates.js';
|
|
12
13
|
|
|
@@ -19,7 +20,7 @@ import { promiseAll } from './templates.js';
|
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
// Replaying a write sends it again, so it changes the dev database twice: reads only unless asked.
|
|
22
|
-
const DEFAULTS = { provider: null, ollamaModel:
|
|
23
|
+
const DEFAULTS = { provider: null, ollamaModel: null, dailyBudgetUsd: 1, replay: true, replayWrites: false };
|
|
23
24
|
const EDIT_REPAIRS = 2;
|
|
24
25
|
const TYPE_REPAIRS = 2;
|
|
25
26
|
|
|
@@ -70,10 +71,13 @@ export function createAI(deps) {
|
|
|
70
71
|
} catch {}
|
|
71
72
|
|
|
72
73
|
async function resolvedSettings() {
|
|
73
|
-
// Nothing is ever paid by default: the local model if it is installed, recorded answers otherwise.
|
|
74
|
-
if (settings.provider) return settings;
|
|
75
74
|
const local = await ollamaStatus();
|
|
76
|
-
|
|
75
|
+
// No model picked yet: one already downloaded if there is one, else the size this machine suits.
|
|
76
|
+
const ollamaModel =
|
|
77
|
+
settings.ollamaModel || LOCAL_MODELS.map((m) => m.name).find((n) => local.models.includes(n)) || local.models.find((n) => /coder/.test(n)) || local.recommended;
|
|
78
|
+
// Nothing is ever paid by default: the local model if it is ready, recorded answers otherwise.
|
|
79
|
+
const provider = settings.provider || (local.running && local.models.includes(ollamaModel) ? 'ollama' : 'mock');
|
|
80
|
+
return { ...settings, ollamaModel, provider };
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
function spentToday() {
|
|
@@ -85,7 +89,7 @@ export function createAI(deps) {
|
|
|
85
89
|
|
|
86
90
|
async function status() {
|
|
87
91
|
const s = await resolvedSettings();
|
|
88
|
-
return { settings: s, ollama: await ollamaStatus(), anthropic: anthropicStatus(), spentTodayUsd: spentToday(), history: readLines(historyFile).length };
|
|
92
|
+
return { settings: s, configured: Boolean(settings.provider), ollama: await ollamaStatus(), anthropic: anthropicStatus(), spentTodayUsd: spentToday(), history: readLines(historyFile).length };
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
function saveSettings(next) {
|
|
@@ -135,7 +139,7 @@ export function createAI(deps) {
|
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
const view = (job) => {
|
|
138
|
-
const { ctx, issue, backups, convo, abort, ...rest } = job;
|
|
142
|
+
const { ctx, issue, backups, convo, abort, afterTrace, ...rest } = job;
|
|
139
143
|
return { ...rest, now: Date.now(), issueTitle: issue.title, issueRoute: issue.route, spentTodayUsd: spentToday(), budgetUsd: settings.dailyBudgetUsd };
|
|
140
144
|
};
|
|
141
145
|
|
|
@@ -206,7 +210,20 @@ export function createAI(deps) {
|
|
|
206
210
|
replace = cleanCode(e.new_code);
|
|
207
211
|
const ranges = shown.get(file);
|
|
208
212
|
if (from < 1 || to < from || to > lines.length || !ranges.some((r) => from >= r.from && to <= r.to)) {
|
|
209
|
-
|
|
213
|
+
// Small models often number later edits as if the earlier ones were already applied.
|
|
214
|
+
const shift = ok
|
|
215
|
+
.filter((o) => o.file === file && o.to < from)
|
|
216
|
+
.reduce((n, o) => n + o.replace.split('\n').length - (o.to - o.from + 1), 0);
|
|
217
|
+
const was = { from: from - shift, to: to - shift };
|
|
218
|
+
const shifted = shift !== 0 && ranges.some((r) => was.from >= r.from && was.to <= r.to);
|
|
219
|
+
rejected.push({
|
|
220
|
+
file,
|
|
221
|
+
from,
|
|
222
|
+
to,
|
|
223
|
+
reason: shifted
|
|
224
|
+
? `lines ${from}-${to} are numbered as if your earlier edit were already applied. Number every edit by the original lines shown: these look like lines ${was.from}-${was.to}`
|
|
225
|
+
: `lines ${from}-${to} are outside what you were shown in ${file} (${ranges.map((r) => `${r.from}-${r.to}`).join(', ')})`,
|
|
226
|
+
});
|
|
210
227
|
continue;
|
|
211
228
|
}
|
|
212
229
|
} else if (typeof e.find === 'string' && e.find) {
|
|
@@ -370,6 +387,7 @@ export function createAI(deps) {
|
|
|
370
387
|
job.stage = ok.length ? 'proposed' : 'no-edits';
|
|
371
388
|
} catch (err) {
|
|
372
389
|
job.error = err.message;
|
|
390
|
+
job.errorCode = err.code || null;
|
|
373
391
|
job.stage = 'error';
|
|
374
392
|
}
|
|
375
393
|
})();
|
|
@@ -451,6 +469,12 @@ export function createAI(deps) {
|
|
|
451
469
|
return { ok: r.code === 0, errors: r.out };
|
|
452
470
|
}
|
|
453
471
|
|
|
472
|
+
/** What a request cost: the numbers shown before and after a fix. */
|
|
473
|
+
function stats(trace) {
|
|
474
|
+
const count = (kind) => trace.spans.filter((x) => x.kind === kind).length;
|
|
475
|
+
return { status: trace.status, ms: trace.duration, queries: count('db'), redis: count('redis'), http: count('http') };
|
|
476
|
+
}
|
|
477
|
+
|
|
454
478
|
function replay(job, payload, port) {
|
|
455
479
|
return new Promise((resolve) => {
|
|
456
480
|
const timer = setTimeout(() => {
|
|
@@ -515,6 +539,9 @@ export function createAI(deps) {
|
|
|
515
539
|
r.status = 'skip';
|
|
516
540
|
r.detail = settings.replay ? 'the request that showed this issue is no longer in memory; use the page again to record a fresh one' : 'replay is off in AI settings';
|
|
517
541
|
} else {
|
|
542
|
+
// The app has just restarted, so its first request pays for cold caches and new connections.
|
|
543
|
+
// A read is sent once to warm up, and the second one is measured.
|
|
544
|
+
if (!isWrite) await replay(job, payload, deps.appPort());
|
|
518
545
|
const trace = await replay(job, payload, deps.appPort());
|
|
519
546
|
if (!trace) {
|
|
520
547
|
r.status = 'fail';
|
|
@@ -527,6 +554,8 @@ export function createAI(deps) {
|
|
|
527
554
|
const before = original ? `${original.status} in ${original.duration} ms, ${original.spans.length} calls` : '';
|
|
528
555
|
r.status = !still && sameClass ? 'done' : 'fail';
|
|
529
556
|
r.detail = `before: ${before}\nafter: ${trace.status} in ${trace.duration} ms, ${trace.spans.length} calls${still ? ', issue still there' : ', issue gone'}`;
|
|
557
|
+
job.compare = { before: original ? stats(original) : null, after: stats(trace), issueGone: !still, warmed: !isWrite };
|
|
558
|
+
job.afterTrace = trace;
|
|
530
559
|
if (trace.status === 401 && original && original.status !== 401) r.detail += '\nThe recorded login may have expired: sign in again and use the page once more.';
|
|
531
560
|
if (r.status === 'fail') return finish(job, false, still ? 'The issue is still there after the change.' : `The request now answers ${trace.status}.`);
|
|
532
561
|
}
|
|
@@ -568,6 +597,8 @@ export function createAI(deps) {
|
|
|
568
597
|
append(historyFile, { at: new Date().toISOString(), type: job.issue.type, tier: job.route.tier || job.route.kind, model: job.route.model, verified: Boolean(job.verified), kept: true });
|
|
569
598
|
cleanup(job);
|
|
570
599
|
job.stage = 'kept';
|
|
600
|
+
// The measured request joins the normal list, and the issue leaves it until a request shows it again.
|
|
601
|
+
if (deps.markFixed) deps.markFixed(job.issue.key, job.afterTrace && job.compare && job.compare.issueGone ? job.afterTrace : null);
|
|
571
602
|
ev(job, 'done', 'Kept. Review it in your editor and commit when ready.');
|
|
572
603
|
return view(job);
|
|
573
604
|
}
|
|
@@ -612,5 +643,30 @@ export function createAI(deps) {
|
|
|
612
643
|
if (waiter) waiter(trace);
|
|
613
644
|
}
|
|
614
645
|
|
|
615
|
-
|
|
646
|
+
/** Tests a pasted key (free) and saves it only if Claude accepts it. */
|
|
647
|
+
async function setKey(key) {
|
|
648
|
+
key = typeof key === 'string' ? key.trim() : '';
|
|
649
|
+
if (!looksLikeKey(key)) throw new Error('That does not look like a Claude API key. It starts with sk-ant-.');
|
|
650
|
+
const problem = await testKey(key);
|
|
651
|
+
if (problem) throw new Error(problem);
|
|
652
|
+
saveKey(key);
|
|
653
|
+
return status();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function forgetKey() {
|
|
657
|
+
removeKey();
|
|
658
|
+
return status();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function pullModel(model) {
|
|
662
|
+
startPull(model);
|
|
663
|
+
return status();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function stopPullModel() {
|
|
667
|
+
stopPull();
|
|
668
|
+
return status();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return { setKey, forgetKey, pullModel, stopPullModel, status, saveSettings, explain, startFix, getFix, send, apply, keep, revert, escalate, stop, onReplayTrace, replayEnabled: () => settings.replay };
|
|
616
672
|
}
|
package/ai/providers.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { totalmem } from 'node:os';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
|
+
import { currentKey, lastFour } from './credentials.js';
|
|
3
5
|
import { costUsd } from './router.js';
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -29,6 +31,7 @@ Risks or assumptions, or why there are no edits.
|
|
|
29
31
|
|
|
30
32
|
Rules for edits:
|
|
31
33
|
- An EDIT replaces whole lines, from the first to the last number given (inclusive), using the numbers shown. The code block is everything that goes there instead; it can have more or fewer lines. An empty code block deletes the lines.
|
|
34
|
+
- Every EDIT uses the original line numbers shown, even after an earlier EDIT adds or removes lines. Never renumber.
|
|
32
35
|
- Write as many EDIT blocks as needed, or none if no change is safe. CONFIDENCE is high, medium or low.
|
|
33
36
|
- Only edit lines you were shown, in files you were shown. Edits must not overlap.
|
|
34
37
|
- Change as little as possible. Keep the code's style, names and types. Do not change behaviour: same results, same errors, same order of side effects. Only remove repeated work or run independent work together.
|
|
@@ -132,16 +135,95 @@ function mock(task, issue, root) {
|
|
|
132
135
|
// ---- ollama ---------------------------------------------------------------------------
|
|
133
136
|
export const OLLAMA = 'http://127.0.0.1:11434';
|
|
134
137
|
|
|
138
|
+
/** Coding models in sizes for 8, 16 and 32 GB of memory; download sizes as Ollama lists them. */
|
|
139
|
+
export const LOCAL_MODELS = [
|
|
140
|
+
{ name: 'qwen2.5-coder:3b', sizeGb: 1.9, minRamGb: 0, note: 'fits 8 GB. Explains well; fixes are hit-and-miss.' },
|
|
141
|
+
{ name: 'qwen2.5-coder:7b', sizeGb: 4.7, minRamGb: 14, note: 'needs 16 GB. Better fixes, still check them.' },
|
|
142
|
+
{ name: 'qwen2.5-coder:14b', sizeGb: 9.0, minRamGb: 28, note: 'needs 32 GB. The best of the three.' },
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
/** The biggest model this machine's memory can hold alongside the app and an editor. */
|
|
146
|
+
export function recommendedModel(ramGb = totalmem() / 1024 ** 3) {
|
|
147
|
+
return [...LOCAL_MODELS].reverse().find((m) => ramGb >= m.minRamGb) || LOCAL_MODELS[0];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether the Ollama app or command is on this machine, even when it is not running. */
|
|
151
|
+
function ollamaInstalled() {
|
|
152
|
+
const exe = process.platform === 'win32' ? 'ollama.exe' : 'ollama';
|
|
153
|
+
const places = [
|
|
154
|
+
...(process.env.PATH || '').split(delimiter).map((d) => join(d, exe)),
|
|
155
|
+
'/Applications/Ollama.app',
|
|
156
|
+
join(process.env.HOME || '', 'Applications', 'Ollama.app'),
|
|
157
|
+
join(process.env.LOCALAPPDATA || '', 'Programs', 'Ollama', 'ollama.exe'),
|
|
158
|
+
];
|
|
159
|
+
return places.some((p) => p && existsSync(p));
|
|
160
|
+
}
|
|
161
|
+
|
|
135
162
|
export async function ollamaStatus() {
|
|
163
|
+
const ramGb = Math.round(totalmem() / 1024 ** 3);
|
|
164
|
+
const base = { ramGb, recommended: recommendedModel(ramGb).name, catalog: LOCAL_MODELS, pull: pullState };
|
|
136
165
|
try {
|
|
137
166
|
const res = await fetch(`${OLLAMA}/api/tags`, { signal: AbortSignal.timeout(1500) });
|
|
138
167
|
const body = await res.json();
|
|
139
|
-
return { running: true, models: (body.models || []).map((m) => m.name) };
|
|
168
|
+
return { ...base, installed: true, running: true, models: (body.models || []).map((m) => m.name) };
|
|
140
169
|
} catch {
|
|
141
|
-
return { running: false, models: [] };
|
|
170
|
+
return { ...base, installed: ollamaInstalled(), running: false, models: [] };
|
|
142
171
|
}
|
|
143
172
|
}
|
|
144
173
|
|
|
174
|
+
// ---- downloading a model, started from the dashboard ------------------------------------
|
|
175
|
+
let pullState = null;
|
|
176
|
+
let pullAbort = null;
|
|
177
|
+
|
|
178
|
+
/** Starts downloading a model through Ollama's own API; progress is read from ollamaStatus().pull. */
|
|
179
|
+
export function startPull(model) {
|
|
180
|
+
if (!/^[\w.-]{1,60}(:[\w.-]{1,40})?$/.test(model || '')) throw new Error('Not a model name.');
|
|
181
|
+
if (pullState && !pullState.done) throw new Error(`Already downloading ${pullState.model}.`);
|
|
182
|
+
pullAbort = new AbortController();
|
|
183
|
+
pullState = { model, status: 'starting', completed: 0, total: 0, done: false, error: null };
|
|
184
|
+
const state = pullState;
|
|
185
|
+
(async () => {
|
|
186
|
+
try {
|
|
187
|
+
const res = await fetch(`${OLLAMA}/api/pull`, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'content-type': 'application/json' },
|
|
190
|
+
body: JSON.stringify({ model, stream: true }),
|
|
191
|
+
signal: pullAbort.signal,
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) throw new Error(`Ollama answered ${res.status}.`);
|
|
194
|
+
const decoder = new TextDecoder();
|
|
195
|
+
let buffer = '';
|
|
196
|
+
// Ollama reports each layer separately; the big one is the model itself, so show the largest.
|
|
197
|
+
for await (const chunk of res.body) {
|
|
198
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
199
|
+
let nl;
|
|
200
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
201
|
+
const line = buffer.slice(0, nl).trim();
|
|
202
|
+
buffer = buffer.slice(nl + 1);
|
|
203
|
+
if (!line) continue;
|
|
204
|
+
const part = JSON.parse(line);
|
|
205
|
+
if (part.error) throw new Error(part.error);
|
|
206
|
+
state.status = part.status || state.status;
|
|
207
|
+
if (part.total && part.total >= state.total) {
|
|
208
|
+
state.total = part.total;
|
|
209
|
+
state.completed = part.completed || 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
state.done = true;
|
|
214
|
+
state.status = 'success';
|
|
215
|
+
} catch (err) {
|
|
216
|
+
state.done = true;
|
|
217
|
+
state.error = err.name === 'AbortError' ? 'Download stopped.' : `Download failed: ${err.message}`;
|
|
218
|
+
}
|
|
219
|
+
})();
|
|
220
|
+
return state;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function stopPull() {
|
|
224
|
+
if (pullAbort) pullAbort.abort();
|
|
225
|
+
}
|
|
226
|
+
|
|
145
227
|
/** Streams the answer, reporting tokens as they arrive, then parses the finished JSON. */
|
|
146
228
|
async function ollama(task, messages, model, onProgress, signal) {
|
|
147
229
|
let res;
|
|
@@ -163,10 +245,11 @@ async function ollama(task, messages, model, onProgress, signal) {
|
|
|
163
245
|
});
|
|
164
246
|
} catch (err) {
|
|
165
247
|
if (signal && signal.aborted) throw new Error('Stopped.');
|
|
166
|
-
throw new Error('Ollama is not running.
|
|
248
|
+
throw new Error('Ollama is not running. Open the Ollama app, then try again.');
|
|
167
249
|
}
|
|
168
250
|
if (!res.ok) {
|
|
169
251
|
const body = await res.json().catch(() => ({}));
|
|
252
|
+
if (/not found/i.test(body.error || '')) throw new Error(`The local model ${model} is not downloaded yet. Download it in AI settings.`);
|
|
170
253
|
throw new Error(body.error ? `Ollama: ${body.error}` : `Ollama answered ${res.status}.`);
|
|
171
254
|
}
|
|
172
255
|
let text = '';
|
|
@@ -217,23 +300,46 @@ function safeJson(text) {
|
|
|
217
300
|
}
|
|
218
301
|
|
|
219
302
|
// ---- anthropic (the user's own key) -------------------------------------------------------
|
|
303
|
+
// A client per key, so pasting or removing a key in the dashboard takes effect at once.
|
|
220
304
|
let sdk = null;
|
|
221
|
-
async function client() {
|
|
222
|
-
if (!sdk) {
|
|
305
|
+
async function client(key = currentKey().key) {
|
|
306
|
+
if (!sdk || sdk.key !== key) {
|
|
307
|
+
let mod;
|
|
223
308
|
try {
|
|
224
|
-
|
|
225
|
-
sdk = { Anthropic: mod.default, client: new mod.default() };
|
|
309
|
+
mod = await import('@anthropic-ai/sdk');
|
|
226
310
|
} catch (err) {
|
|
227
|
-
throw new Error(`Could not
|
|
311
|
+
throw new Error(`Could not load the Claude client: ${err.message}.`);
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
sdk = { key, Anthropic: mod.default, client: new mod.default(key ? { apiKey: key } : {}) };
|
|
315
|
+
} catch {
|
|
316
|
+
throw new Error('No Claude API key. Paste one in AI settings.');
|
|
228
317
|
}
|
|
229
318
|
}
|
|
230
319
|
return sdk;
|
|
231
320
|
}
|
|
232
321
|
|
|
322
|
+
/** Where the key comes from. The key itself is never returned, only its last four characters. */
|
|
233
323
|
export function anthropicStatus() {
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
return { configured:
|
|
324
|
+
const { key, source } = currentKey();
|
|
325
|
+
if (key) return { configured: true, source, last4: lastFour(key) };
|
|
326
|
+
if (process.env.ANTHROPIC_AUTH_TOKEN) return { configured: true, source: 'token', last4: '' };
|
|
327
|
+
return { configured: false, source: null, last4: '' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Checks a key by listing models, which costs nothing. Returns null when it works, else the reason. */
|
|
331
|
+
export async function testKey(key) {
|
|
332
|
+
const { Anthropic, client: c } = await client(key);
|
|
333
|
+
try {
|
|
334
|
+
await c.models.list({ limit: 1 });
|
|
335
|
+
return null;
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (err instanceof Anthropic.AuthenticationError) return 'Claude rejected this key.';
|
|
338
|
+
if (err instanceof Anthropic.PermissionDeniedError) return 'This key is not allowed to use the API.';
|
|
339
|
+
if (err instanceof Anthropic.APIConnectionError) return 'Could not reach the Claude API. Check your connection.';
|
|
340
|
+
if (err instanceof Anthropic.APIError) return `Claude error ${err.status}: ${err.message}`;
|
|
341
|
+
return err.message;
|
|
342
|
+
}
|
|
237
343
|
}
|
|
238
344
|
|
|
239
345
|
async function anthropic(task, messages, routeInfo) {
|
|
@@ -254,9 +360,14 @@ async function anthropic(task, messages, routeInfo) {
|
|
|
254
360
|
? await c.messages.create(params)
|
|
255
361
|
: await c.beta.messages.create({ ...params, betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' });
|
|
256
362
|
} catch (err) {
|
|
257
|
-
if (err instanceof Anthropic.AuthenticationError) throw new Error('Claude rejected the key.
|
|
363
|
+
if (err instanceof Anthropic.AuthenticationError) throw new Error('Claude rejected the key. Paste a new one in AI settings.');
|
|
258
364
|
if (err instanceof Anthropic.RateLimitError) throw new Error('Claude rate limit reached. Wait a minute and try again.');
|
|
259
|
-
|
|
365
|
+
// The SDK's message carries the raw JSON body; show the API's own sentence instead.
|
|
366
|
+
const said = err?.error?.error?.message || err.message;
|
|
367
|
+
if (err instanceof Anthropic.APIError && /credit balance/i.test(said)) {
|
|
368
|
+
throw Object.assign(new Error('Your Anthropic account has no credit left, so nothing was sent or charged. Add credits in the Anthropic Console, then try again.'), { code: 'billing' });
|
|
369
|
+
}
|
|
370
|
+
if (err instanceof Anthropic.APIError) throw new Error(`Claude error ${err.status}: ${said}`);
|
|
260
371
|
throw err;
|
|
261
372
|
}
|
|
262
373
|
const cost = costUsd(routeInfo.tier, response.usage);
|
package/analyze.js
CHANGED
|
@@ -236,7 +236,11 @@ function percentile(values, p) {
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
/** Routes and issues across every trace kept. */
|
|
239
|
-
|
|
239
|
+
/**
|
|
240
|
+
* fixed: issue key -> when a fix for it was kept. Such an issue is hidden until a request after
|
|
241
|
+
* that moment shows it again.
|
|
242
|
+
*/
|
|
243
|
+
export function summarize(traces, known = [], advice = [], fixed = new Map()) {
|
|
240
244
|
const routes = new Map();
|
|
241
245
|
const issues = new Map();
|
|
242
246
|
for (const trace of traces) {
|
|
@@ -259,6 +263,7 @@ export function summarize(traces, known = [], advice = []) {
|
|
|
259
263
|
agg.seen++;
|
|
260
264
|
agg.costTotal += issue.cost;
|
|
261
265
|
agg.example = trace.id;
|
|
266
|
+
agg.lastAt = Math.max(agg.lastAt || 0, trace.at || 0);
|
|
262
267
|
}
|
|
263
268
|
}
|
|
264
269
|
// Index advice: one issue per route that ran the query. Its location skips wrapper lines that
|
|
@@ -281,8 +286,9 @@ export function summarize(traces, known = [], advice = []) {
|
|
|
281
286
|
const span = trace.spans.find((s) => s.kind === 'db' && normalizeSql(s.name) === sql);
|
|
282
287
|
if (!span) continue;
|
|
283
288
|
const route = routeOf(trace.method, trace.url, trace.template);
|
|
284
|
-
if (!perRoute.has(route)) perRoute.set(route, { seen: 0, example: trace.id, span });
|
|
289
|
+
if (!perRoute.has(route)) perRoute.set(route, { seen: 0, example: trace.id, span, lastAt: 0 });
|
|
285
290
|
perRoute.get(route).seen++;
|
|
291
|
+
perRoute.get(route).lastAt = Math.max(perRoute.get(route).lastAt, trace.at || 0);
|
|
286
292
|
}
|
|
287
293
|
const big = a.rows >= 1000;
|
|
288
294
|
const name = `${a.table}_${a.columns.join('_')}_idx`.slice(0, 63);
|
|
@@ -302,6 +308,7 @@ export function summarize(traces, known = [], advice = []) {
|
|
|
302
308
|
route,
|
|
303
309
|
seen: info.seen,
|
|
304
310
|
example: info.example,
|
|
311
|
+
lastAt: info.lastAt,
|
|
305
312
|
costTotal: 0,
|
|
306
313
|
});
|
|
307
314
|
}
|
|
@@ -311,6 +318,12 @@ export function summarize(traces, known = [], advice = []) {
|
|
|
311
318
|
for (const route of known) {
|
|
312
319
|
if (!routes.has(route)) routes.set(route, { route, calls: 0, durations: [], queries: 0, redis: 0, http: 0, errors: 0, issueKeys: new Set(), last: 0 });
|
|
313
320
|
}
|
|
321
|
+
for (const [key, issue] of issues) {
|
|
322
|
+
if (fixed.has(key) && fixed.get(key) >= (issue.lastAt || 0)) {
|
|
323
|
+
issues.delete(key);
|
|
324
|
+
routes.get(issue.route)?.issueKeys.delete(key);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
314
327
|
const routeList = [...routes.values()]
|
|
315
328
|
.map((r) => ({
|
|
316
329
|
route: r.route,
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -30,6 +30,8 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
30
30
|
let config = manualArgv ? { app: null, test: null, autoRestart: false } : loadConfig(root);
|
|
31
31
|
|
|
32
32
|
const traces = [];
|
|
33
|
+
// Issue key -> when its fix was kept (see summarize).
|
|
34
|
+
const fixedAt = new Map();
|
|
33
35
|
let received = 0;
|
|
34
36
|
const known = new Map();
|
|
35
37
|
const advice = new Map();
|
|
@@ -207,15 +209,15 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
207
209
|
|
|
208
210
|
// From the dashboard.
|
|
209
211
|
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
210
|
-
const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
212
|
+
const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
|
|
211
213
|
const recent = traces
|
|
212
214
|
.slice(-300)
|
|
213
215
|
.reverse()
|
|
214
|
-
.map((t) => ({ id: t.id, at: t.at, method: t.method, url: t.url, route: routeOf(t.method, t.url, t.template), status: t.status, duration: t.duration, spans: t.spans.length, aborted: Boolean(t.aborted) }));
|
|
216
|
+
.map((t) => ({ id: t.id, at: t.at, method: t.method, url: t.url, route: routeOf(t.method, t.url, t.template), status: t.status, duration: t.duration, spans: t.spans.length, aborted: Boolean(t.aborted), afterFix: Boolean(t.afterFix) }));
|
|
215
217
|
return json(res, 200, { routes, issues, recent, total: traces.length, received });
|
|
216
218
|
}
|
|
217
219
|
if (req.method === 'GET' && url.pathname === '/api/map') {
|
|
218
|
-
const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
220
|
+
const { issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
|
|
219
221
|
return json(res, 200, { ...mapOf(traces, issues), advice: [...advice.values()].map((a) => ({ table: a.table, columns: a.columns })) });
|
|
220
222
|
}
|
|
221
223
|
if (req.method === 'GET' && url.pathname.startsWith('/api/trace/')) {
|
|
@@ -228,12 +230,13 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
228
230
|
traces.length = 0;
|
|
229
231
|
received = 0;
|
|
230
232
|
advice.clear();
|
|
233
|
+
fixedAt.clear();
|
|
231
234
|
return json(res, 200, { ok: true });
|
|
232
235
|
}
|
|
233
236
|
if (req.method === 'POST' && url.pathname === '/api/snapshot') {
|
|
234
237
|
const name = (url.searchParams.get('name') || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
235
238
|
if (!name) return json(res, 400, { error: 'Give the snapshot a name.' });
|
|
236
|
-
const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
239
|
+
const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
|
|
237
240
|
mkdirSync(snapshotDir, { recursive: true });
|
|
238
241
|
writeFileSync(join(snapshotDir, `${name}.json`), `${JSON.stringify({ name, at: Date.now(), requests: traces.length, routes: routes.filter((r) => r.calls), issues: issues.length }, null, 2)}\n`);
|
|
239
242
|
return json(res, 200, { ok: true, name });
|
|
@@ -306,6 +309,10 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
306
309
|
const p = url.pathname;
|
|
307
310
|
if (req.method === 'GET' && p === '/api/ai/status') return json(res, 200, await ai.status());
|
|
308
311
|
if (req.method === 'POST' && p === '/api/ai/settings') return json(res, 200, { settings: ai.saveSettings(body) });
|
|
312
|
+
if (req.method === 'POST' && p === '/api/ai/key') return json(res, 200, await ai.setKey(body.key));
|
|
313
|
+
if (req.method === 'POST' && p === '/api/ai/key/remove') return json(res, 200, await ai.forgetKey());
|
|
314
|
+
if (req.method === 'POST' && p === '/api/ai/ollama/pull') return json(res, 200, await ai.pullModel(body.model));
|
|
315
|
+
if (req.method === 'POST' && p === '/api/ai/ollama/stop') return json(res, 200, await ai.stopPullModel());
|
|
309
316
|
if (req.method === 'POST' && p === '/api/ai/explain') return json(res, 200, await ai.explain(body.key));
|
|
310
317
|
if (req.method === 'POST' && p === '/api/ai/fix') return json(res, 200, await ai.startFix(body.key));
|
|
311
318
|
const m = p.match(/^\/api\/ai\/fix\/([0-9a-f-]{36})(?:\/(send|apply|keep|revert|escalate|stop))?$/);
|
|
@@ -356,10 +363,19 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
|
|
|
356
363
|
pauseWatch: (on) => (watchPaused = on),
|
|
357
364
|
testsTail: () => tests.lines.slice(-14).map((l) => l.text).join('\n'),
|
|
358
365
|
getIssue: (key) => {
|
|
359
|
-
const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
|
|
366
|
+
const { issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
|
|
360
367
|
return issues.find((i) => i.key === key) || (testRun.report ? testRun.report.issues.find((i) => i.key === key) : null);
|
|
361
368
|
},
|
|
362
369
|
getTrace: (id) => traces.find((t) => t.id === id) || testRun.traces.find((t) => t.id === id),
|
|
370
|
+
markFixed: (key, after) => {
|
|
371
|
+
fixedAt.set(key, Date.now());
|
|
372
|
+
if (after) {
|
|
373
|
+
const { replayOf, ...trace } = after;
|
|
374
|
+
traces.push({ ...trace, afterFix: true });
|
|
375
|
+
received++;
|
|
376
|
+
if (traces.length > KEEP) replays.delete(traces.shift().id);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
363
379
|
getReplay: (id) => replays.get(id),
|
|
364
380
|
restartApp: async () => {
|
|
365
381
|
app.port = null;
|
package/ui.html
CHANGED
|
@@ -152,6 +152,20 @@
|
|
|
152
152
|
.conf.high { background: #12301f; color: #8de0b0; } .conf.medium { background: var(--warn-bg); color: var(--warn); } .conf.low { background: #3a1a1c; color: #f3a6a6; }
|
|
153
153
|
.opt-card { display: block; border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; cursor: pointer; }
|
|
154
154
|
.opt-card:has(input:checked) { border-color: var(--accent); background: #ffffff05; }
|
|
155
|
+
.opt-card { cursor: default; } .opt-card > label { cursor: pointer; }
|
|
156
|
+
.opt-card a { color: var(--accent); }
|
|
157
|
+
a.btn { text-decoration: none; display: inline-block; }
|
|
158
|
+
.compare-card { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin: 4px 0 14px; }
|
|
159
|
+
.compare-card h4 { margin: 0 0 8px; font-size: 13px; }
|
|
160
|
+
.compare-card table { width: auto; border-collapse: collapse; font: 13px var(--mono); }
|
|
161
|
+
.compare-card th { text-align: left; font-weight: 400; color: var(--sub); padding: 3px 18px 3px 0; }
|
|
162
|
+
.compare-card td { padding: 3px 10px 3px 0; }
|
|
163
|
+
.compare-card .arrow { color: var(--sub); }
|
|
164
|
+
.compare-card .better { color: #4ade80; } .compare-card .worse { color: #f87171; } .compare-card .same { color: var(--text); }
|
|
165
|
+
.compare-card .delta { font-size: 12px; }
|
|
166
|
+
.field input[type=password] { flex: 1; font: 13px var(--mono); color: var(--text); background: var(--bg); border: 1px solid var(--line-2); border-radius: 6px; padding: 6px 8px; }
|
|
167
|
+
.progress { height: 6px; border-radius: 3px; background: var(--raise); overflow: hidden; margin: 10px 0 0; }
|
|
168
|
+
.progress > div { height: 100%; background: var(--accent); transition: width .4s; }
|
|
155
169
|
.opt-card b { font-size: 13px; } .opt-card small { display: block; color: var(--sub); font-size: 12px; margin-top: 4px; }
|
|
156
170
|
.field { display: flex; align-items: center; gap: 10px; margin: 10px 0; font-size: 13px; color: var(--sub); }
|
|
157
171
|
.field input[type=number], .field select { font: 13px var(--mono); color: var(--text); background: var(--bg); border: 1px solid var(--line-2); border-radius: 6px; padding: 5px 8px; }
|
|
@@ -357,7 +371,7 @@ function renderRequests() {
|
|
|
357
371
|
return `<h2>${routeFilter ? `<button class="ghost" type="button" id="unfilter">${esc(routeFilter)} ✕</button>` : `latest ${list.length} requests`}</h2><table>
|
|
358
372
|
<thead><tr><th>when</th><th>request</th><th class="n">status</th><th class="n">time</th><th class="n hide-sm">calls</th></tr></thead>
|
|
359
373
|
<tbody>${list.map((t) => `<tr data-trace="${esc(t.id)}" class="${openTrace && openTrace.trace.id === t.id ? 'sel' : ''}">
|
|
360
|
-
<td class="muted">${ago(t.at)}</td><td><span class="method">${esc(t.method)}</span><span class="route">${esc(t.url)}</span
|
|
374
|
+
<td class="muted">${ago(t.at)}</td><td><span class="method">${esc(t.method)}</span><span class="route">${esc(t.url)}</span>${t.afterFix ? ' <span class="chip live">after fix</span>' : ''}</td>
|
|
361
375
|
<td class="n ${statusClass(t.status)}">${t.aborted ? 'cancelled' : t.status}</td><td class="n">${ms(t.duration)}</td><td class="n hide-sm muted">${t.spans}</td></tr>`).join('')}</tbody></table>`;
|
|
362
376
|
}
|
|
363
377
|
|
|
@@ -423,6 +437,11 @@ function renderSetup() {
|
|
|
423
437
|
<div class="hintline">A script that rebuilds on change (like dev or start:dev) means you never press restart.</div></fieldset>
|
|
424
438
|
<fieldset><legend>Run the tests with</legend>${group(scripts, isTest, (n, c) => opt('test', n, c, n === guessTest), guessTest)}${opt('test', '', 'no tests', !guessTest)}</fieldset>
|
|
425
439
|
<label class="toggle" style="margin-bottom:20px"><input type="checkbox" name="auto" ${current.autoRestart ? 'checked' : ''} /> restart the app when a file changes (for scripts that do not watch)</label>
|
|
440
|
+
${cfg.needsSetup ? `<fieldset><legend>Explain and fix issues with</legend>
|
|
441
|
+
<label class="opt"><input type="radio" name="ai" value="ollama" checked /><span>Local model</span><code>free, private, runs on this computer</code></label>
|
|
442
|
+
<label class="opt"><input type="radio" name="ai" value="anthropic" /><span>Claude</span><code>your own API key, strongest fixes</code></label>
|
|
443
|
+
<label class="opt"><input type="radio" name="ai" value="" /><span>Later</span><code>change it any time in AI settings</code></label>
|
|
444
|
+
<div class="hintline">The next screen walks you through it. Nothing is downloaded or paid without a click.</div></fieldset>` : ''}
|
|
426
445
|
<div class="row-actions"><button class="primary" type="submit">${cfg.needsSetup ? 'Start app' : 'Save and restart'}</button>${!cfg.needsSetup ? '<button class="ghost" type="button" id="setup-cancel">Cancel</button>' : ''}<span class="error" role="alert">${esc(setupError)}</span></div>
|
|
427
446
|
</form>`;
|
|
428
447
|
}
|
|
@@ -691,7 +710,10 @@ function feedItem(e) {
|
|
|
691
710
|
if (e.kind === 'ask') {
|
|
692
711
|
const secs = e.status === 'running' && e.startedAt && fixJob ? Math.max(0.1, (fixJob.now - e.startedAt) / 1000 + (Date.now() - fixFetchedAt) / 1000) : 0;
|
|
693
712
|
const live = e.status === 'running' ? `${secs.toFixed(0)} s${e.tokens ? `, ${e.tokens} tokens, ${(e.tokens / secs).toFixed(1)} per second` : ', reading the code…'}` : esc(e.detail || '');
|
|
694
|
-
|
|
713
|
+
// A local model under ~8 tokens a second takes minutes per answer: say why, and what helps.
|
|
714
|
+
const slow = e.status === 'running' && fixJob.route && fixJob.route.kind === 'ollama' && secs > 15 && e.tokens / secs < 8;
|
|
715
|
+
const hint = slow ? `<div class="sub2 muted">The local model is slow on this computer (it shares memory with your other apps). Closing some speeds it up; Claude answers in seconds.</div>` : '';
|
|
716
|
+
return `<li class="fi">${dot}<div><div class="fh"><b>${esc(e.title)}</b>${t}</div><div class="sub2">${live}</div>${hint}${e.status === 'running' ? '<button class="ghost" type="button" id="fix-stop" style="margin-top:6px">Stop</button>' : ''}</div></li>`;
|
|
695
717
|
}
|
|
696
718
|
if (e.kind === 'verdict') return `<li class="fi">${dot}<div class="verdict ${e.status === 'done' ? 'good' : e.status === 'warn' ? 'meh' : 'bad'}">${esc(e.title)}</div></li>`;
|
|
697
719
|
const verb = e.kind === 'read' ? 'Read' : e.kind === 'repair' ? '↻' : '';
|
|
@@ -726,6 +748,7 @@ function renderFix() {
|
|
|
726
748
|
el.innerHTML = html; return;
|
|
727
749
|
}
|
|
728
750
|
html += `<ol class="feed">${j.events.map(feedItem).join('')}${j.stage === 'error' ? `<li class="fi"><span class="dot bad">●</span><div class="verdict bad">${esc(j.error)}</div></li>` : ''}</ol>`;
|
|
751
|
+
html += compareCard(j);
|
|
729
752
|
const act = [];
|
|
730
753
|
if (j.stage === 'proposed') act.push('<button class="primary" type="button" id="fix-apply">Apply and check</button><button class="ghost" type="button" id="fix-close-2">Discard</button>');
|
|
731
754
|
if (j.stage === 'no-edits') act.push(`<span class="note">No change was applied.</span>${escalateBtn(j)}`);
|
|
@@ -740,7 +763,29 @@ function renderFix() {
|
|
|
740
763
|
el.scrollTop = atBottom || busy ? el.scrollHeight : keepScroll;
|
|
741
764
|
}
|
|
742
765
|
|
|
766
|
+
/** The same request before and after the change, side by side. */
|
|
767
|
+
function compareCard(j) {
|
|
768
|
+
const c = j.compare;
|
|
769
|
+
if (!c || !c.before || !['verified', 'failed', 'unverified', 'kept', 'reverted'].includes(j.stage)) return '';
|
|
770
|
+
const pct = (a, b) => (a ? Math.round(((b - a) / a) * 100) : 0);
|
|
771
|
+
const row = (label, a, b, fmt = (x) => x, lowerIsBetter = true) => {
|
|
772
|
+
const d = pct(a, b);
|
|
773
|
+
const tone = a === b ? 'same' : (b < a) === lowerIsBetter ? 'better' : 'worse';
|
|
774
|
+
return `<tr><th>${label}</th><td>${fmt(a)}</td><td class="arrow">→</td><td class="${tone}">${fmt(b)}</td><td class="${tone} delta">${a === b ? '' : `${d > 0 ? '+' : ''}${d}%`}</td></tr>`;
|
|
775
|
+
};
|
|
776
|
+
const kind = esc((j.issueTitle || '').split(':')[0]);
|
|
777
|
+
return `<section class="compare-card"><h4>Same request, before and after the change</h4><table>
|
|
778
|
+
${row('time', c.before.ms, c.after.ms, (x) => ms(x))}
|
|
779
|
+
${row('database queries', c.before.queries, c.after.queries)}
|
|
780
|
+
${c.before.redis || c.after.redis ? row('redis calls', c.before.redis, c.after.redis) : ''}
|
|
781
|
+
${c.before.http || c.after.http ? row('outgoing http', c.before.http, c.after.http) : ''}
|
|
782
|
+
<tr><th>status</th><td>${c.before.status}</td><td class="arrow">→</td><td class="${c.before.status === c.after.status ? 'same' : 'worse'}">${c.after.status}</td><td></td></tr>
|
|
783
|
+
<tr><th>issue</th><td>${kind || 'found'}</td><td class="arrow">→</td><td class="${c.issueGone ? 'better' : 'worse'}">${c.issueGone ? 'gone' : 'still there'}</td><td></td></tr>
|
|
784
|
+
</table><p class="note">One request each${c.warmed ? ', the after one measured once the restarted app was warm' : ''}. Query counts are exact; times vary a few ms from run to run.</p></section>`;
|
|
785
|
+
}
|
|
786
|
+
|
|
743
787
|
function escalateBtn(j) {
|
|
788
|
+
if (j.errorCode === 'billing') return `<a class="btn primary" href="https://console.anthropic.com/settings/billing" target="_blank" rel="noopener">Add credits</a>`;
|
|
744
789
|
return j.attempt === 0 && j.route.kind === 'anthropic' ? `<button class="ghost" type="button" id="fix-escalate">Try a stronger model</button>` : '';
|
|
745
790
|
}
|
|
746
791
|
|
|
@@ -753,25 +798,105 @@ async function pollFix() {
|
|
|
753
798
|
}
|
|
754
799
|
setInterval(pollFix, 600);
|
|
755
800
|
|
|
801
|
+
let aiNotice = '';
|
|
802
|
+
const gb = (n) => `${n.toFixed(1)} GB`;
|
|
803
|
+
|
|
804
|
+
/** The local model card's body: install, open, download, then ready. Polled while the panel is open. */
|
|
805
|
+
function localBody(o, s) {
|
|
806
|
+
if (!o.running && !o.installed) {
|
|
807
|
+
return `<small>Runs an AI model on this computer: free, and your code never leaves it. Install Ollama, open it, and this card updates by itself.</small>
|
|
808
|
+
<span class="field"><a class="btn primary" href="https://ollama.com/download" target="_blank" rel="noopener">Get Ollama</a><span class="muted">waiting for Ollama…</span></span>`;
|
|
809
|
+
}
|
|
810
|
+
if (!o.running) {
|
|
811
|
+
return `<small>Ollama is installed but not running. Open the Ollama app; this card updates by itself.</small>
|
|
812
|
+
<span class="field"><span class="muted">waiting for Ollama…</span></span>`;
|
|
813
|
+
}
|
|
814
|
+
const names = [...new Set([...o.catalog.map((m) => m.name), ...o.models])];
|
|
815
|
+
const info = (n) => o.catalog.find((m) => m.name === n);
|
|
816
|
+
const label = (n) => {
|
|
817
|
+
const m = info(n);
|
|
818
|
+
const have = o.models.includes(n);
|
|
819
|
+
return `${n}${n === o.recommended ? ' (best for this computer)' : ''}${have ? ' · downloaded' : m ? ` · ${gb(m.sizeGb)} download` : ''}`;
|
|
820
|
+
};
|
|
821
|
+
const chosen = s.ollamaModel;
|
|
822
|
+
const ready = o.models.includes(chosen);
|
|
823
|
+
const pull = o.pull && o.pull.model === chosen ? o.pull : null;
|
|
824
|
+
let action = '';
|
|
825
|
+
if (pull && !pull.done) {
|
|
826
|
+
const pct = pull.total ? Math.floor((pull.completed / pull.total) * 100) : 0;
|
|
827
|
+
action = `<div class="progress"><div style="width:${pct}%"></div></div>
|
|
828
|
+
<span class="field"><span class="muted">${pull.total ? `${pct}% of ${gb(pull.total / 1024 ** 3)}` : esc(pull.status)}</span><button class="ghost" type="button" id="ai-pull-stop">Stop</button></span>`;
|
|
829
|
+
} else if (!ready) {
|
|
830
|
+
const m = info(chosen);
|
|
831
|
+
action = `<span class="field"><button class="primary" type="button" id="ai-pull" data-model="${esc(chosen)}">Download ${esc(chosen)}${m ? ` (${gb(m.sizeGb)})` : ''}</button>
|
|
832
|
+
${pull && pull.error ? `<span class="error">${esc(pull.error)}</span>` : ''}</span>`;
|
|
833
|
+
}
|
|
834
|
+
const note = info(chosen) ? `<small>${esc(chosen)} ${esc(info(chosen).note)} This computer has ${o.ramGb} GB of memory.</small>` : '';
|
|
835
|
+
return `<small>${ready ? 'Ready. Code never leaves this machine.' : 'Ollama is running. Download a model to use it.'}</small>
|
|
836
|
+
<span class="field">model <select name="ollamaModel">${names.map((n) => `<option value="${esc(n)}" ${n === chosen ? 'selected' : ''}>${esc(label(n))}</option>`).join('')}</select></span>
|
|
837
|
+
${action}${note}`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function claudeBody(a, s) {
|
|
841
|
+
const keyLine = a.configured
|
|
842
|
+
? `<span class="field"><span>Using the key ending in <code>${esc(a.last4 || '…')}</code>, ${a.source === 'env' ? 'from ANTHROPIC_API_KEY' : a.source === 'saved' ? 'saved on this computer' : 'from your environment'}.</span>${a.source === 'saved' ? '<button class="ghost" type="button" id="ai-key-remove">Remove</button>' : ''}</span>`
|
|
843
|
+
: `<span class="field"><input type="password" id="ai-key" placeholder="sk-ant-…" autocomplete="off" spellcheck="false" /><button class="primary" type="button" id="ai-key-save">Check and save</button></span>
|
|
844
|
+
<small>Create one at <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener">console.anthropic.com</a>. Checking it is free. It is saved in your user folder (~/.config/backlens), never in this project, and never shown again.</small>`;
|
|
845
|
+
return `<small>The strongest fixes. backlens picks Haiku, Sonnet or Opus per issue and shows the cost before sending.</small>
|
|
846
|
+
${keyLine}
|
|
847
|
+
<span class="field">daily budget $ <input type="number" name="dailyBudgetUsd" min="0" step="0.5" value="${s.dailyBudgetUsd}" /> <span>spent today ${usd(aiStatus.spentTodayUsd)}</span></span>`;
|
|
848
|
+
}
|
|
849
|
+
|
|
756
850
|
async function renderAISettings() {
|
|
757
851
|
const el = $('#aiset');
|
|
758
852
|
try { aiStatus = await (await api('/api/ai/status')).json(); } catch { el.innerHTML = '<p class="error">Could not load AI settings.</p>'; return; }
|
|
759
853
|
const s = aiStatus.settings, o = aiStatus.ollama, a = aiStatus.anthropic;
|
|
760
|
-
|
|
761
|
-
|
|
854
|
+
el.innerHTML = `<button class="ghost close" type="button" id="aiset-close">Close</button><h2>AI settings</h2><p class="muted" style="font-size:13px">Who explains issues and suggests fixes. Changes save as you make them.</p>
|
|
855
|
+
<p class="muted" style="font-size:12px">Some fixes never need a model: running independent calls together (Promise.all) and missing indexes are written by backlens itself.</p>
|
|
762
856
|
<form id="aiset-form"><section>
|
|
763
|
-
<
|
|
764
|
-
<
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
<small>${a.configured ? `Key found in ${esc(a.source)}.` : `No key found. Set ANTHROPIC_API_KEY before starting backlens, or use ${esc(a.source)}.`} backlens picks Haiku, Sonnet or Opus per issue and shows the cost before sending.</small>
|
|
770
|
-
<span class="field">daily budget $ <input type="number" name="dailyBudgetUsd" min="0" step="0.5" value="${s.dailyBudgetUsd}" /> <span>spent today ${usd(aiStatus.spentTodayUsd)}</span></span></label>
|
|
857
|
+
<div class="opt-card ${s.provider === 'ollama' ? 'on' : ''}"><label><input type="radio" name="provider" value="ollama" ${s.provider === 'ollama' ? 'checked' : ''}/> <b>Local model</b> <span class="chip">free</span> <span class="chip">private</span></label>
|
|
858
|
+
<div id="ai-local">${localBody(o, s)}</div></div>
|
|
859
|
+
<div class="opt-card ${s.provider === 'anthropic' ? 'on' : ''}"><label><input type="radio" name="provider" value="anthropic" ${s.provider === 'anthropic' ? 'checked' : ''}/> <b>Claude, with your own key</b> <span class="chip">paid by you</span></label>
|
|
860
|
+
<div id="ai-claude">${claudeBody(a, s)}</div></div>
|
|
861
|
+
<div class="opt-card ${s.provider === 'mock' ? 'on' : ''}"><label><input type="radio" name="provider" value="mock" ${s.provider === 'mock' ? 'checked' : ''}/> <b>No model</b> <span class="chip">free</span></label>
|
|
862
|
+
<small>Explanations in backlens's own words. Fixes only where backlens can write them itself.</small></div>
|
|
771
863
|
</section>
|
|
772
864
|
<label class="toggle" style="margin:6px 0 6px"><input type="checkbox" name="replay" ${s.replay ? 'checked' : ''}/> keep recent requests in memory so a fix can be replayed (never written to disk, never sent to a model)</label>
|
|
773
865
|
<label class="toggle" style="margin:0 0 16px"><input type="checkbox" name="replayWrites" ${s.replayWrites ? 'checked' : ''}/> also replay requests that change data (POST, PUT, DELETE): they run again against your dev database</label>
|
|
774
|
-
<div class="actions"><
|
|
866
|
+
<div class="actions"><span class="${aiNotice.startsWith('!') ? 'error' : 'muted'}" role="status" style="font-size:12px;align-self:center">${esc(aiNotice.replace(/^!/, ''))}</span><span class="muted" style="font-size:12px;align-self:center;margin-left:auto">${aiStatus.history} fix${aiStatus.history === 1 ? '' : 'es'} in local history</span></div></form>`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// While the panel is open, follow Ollama starting and model downloads without redrawing the key box.
|
|
870
|
+
setInterval(async () => {
|
|
871
|
+
if ($('#aiset').hidden || !aiStatus) return;
|
|
872
|
+
const o = aiStatus.ollama;
|
|
873
|
+
const waiting = !o.running || (o.pull && !o.pull.done) || !o.models.includes(aiStatus.settings.ollamaModel);
|
|
874
|
+
if (!waiting) return;
|
|
875
|
+
try {
|
|
876
|
+
const next = await (await api('/api/ai/status')).json();
|
|
877
|
+
const finished = o.pull && !o.pull.done && next.ollama.pull && next.ollama.pull.done;
|
|
878
|
+
aiStatus = next;
|
|
879
|
+
if (finished) { aiNotice = next.ollama.pull.error ? '!' + next.ollama.pull.error : `${next.ollama.pull.model} downloaded.`; return renderAISettings(); }
|
|
880
|
+
const box = $('#ai-local');
|
|
881
|
+
if (box && !box.contains(document.activeElement)) box.innerHTML = localBody(next.ollama, next.settings);
|
|
882
|
+
} catch {}
|
|
883
|
+
}, 1500);
|
|
884
|
+
|
|
885
|
+
async function saveAISettings() {
|
|
886
|
+
const f = new FormData($('#aiset-form'));
|
|
887
|
+
try {
|
|
888
|
+
await aiPost('/api/ai/settings', { provider: f.get('provider'), ollamaModel: f.get('ollamaModel') || aiStatus.settings.ollamaModel, dailyBudgetUsd: f.get('dailyBudgetUsd'), replay: f.get('replay') === 'on', replayWrites: f.get('replayWrites') === 'on' });
|
|
889
|
+
aiNotice = 'Saved.';
|
|
890
|
+
} catch (err) { aiNotice = '!' + err.message; }
|
|
891
|
+
renderAISettings();
|
|
892
|
+
}
|
|
893
|
+
document.addEventListener('change', (e) => { if (e.target.closest('#aiset-form') && e.target.id !== 'ai-key') saveAISettings(); });
|
|
894
|
+
|
|
895
|
+
async function aiAction(button, path, body, done) {
|
|
896
|
+
button.disabled = true;
|
|
897
|
+
aiNotice = '';
|
|
898
|
+
try { await aiPost(path, body); aiNotice = done; } catch (err) { aiNotice = '!' + err.message; }
|
|
899
|
+
renderAISettings();
|
|
775
900
|
}
|
|
776
901
|
|
|
777
902
|
document.addEventListener('click', async (e) => {
|
|
@@ -807,16 +932,21 @@ document.addEventListener('click', async (e) => {
|
|
|
807
932
|
if (action === 'keep' || action === 'revert') { lastTotal = -1; poll(); }
|
|
808
933
|
return;
|
|
809
934
|
}
|
|
810
|
-
if (e.target.closest('#ai-
|
|
935
|
+
if (e.target.closest('#ai-pull')) { const b = e.target.closest('#ai-pull'); return aiAction(b, '/api/ai/ollama/pull', { model: b.dataset.model }, ''); }
|
|
936
|
+
if (e.target.closest('#ai-pull-stop')) return aiAction(e.target.closest('#ai-pull-stop'), '/api/ai/ollama/stop', {}, '');
|
|
937
|
+
if (e.target.closest('#ai-key-save')) {
|
|
938
|
+
const input = $('#ai-key');
|
|
939
|
+
const key = input.value.trim();
|
|
940
|
+
input.value = '';
|
|
941
|
+
if (!key) { aiNotice = '!Paste a key first.'; return renderAISettings(); }
|
|
942
|
+
aiNotice = 'Checking the key…';
|
|
943
|
+
$('#ai-key-save').textContent = 'Checking…';
|
|
944
|
+
return aiAction(e.target.closest('#ai-key-save'), '/api/ai/key', { key }, 'Key works and is saved. Pick Claude above to use it.');
|
|
945
|
+
}
|
|
946
|
+
if (e.target.closest('#ai-key-remove')) return aiAction(e.target.closest('#ai-key-remove'), '/api/ai/key/remove', {}, 'Key removed from this computer.');
|
|
947
|
+
if (e.target.closest('#ai-open')) { aiNotice = ''; $('#aiset').hidden = false; renderAISettings(); return; }
|
|
811
948
|
if (e.target.closest('#aiset-close')) { $('#aiset').hidden = true; return; }
|
|
812
949
|
});
|
|
813
|
-
document.addEventListener('submit', async (e) => {
|
|
814
|
-
if (e.target.id !== 'aiset-form') return;
|
|
815
|
-
e.preventDefault();
|
|
816
|
-
const f = new FormData(e.target);
|
|
817
|
-
await aiPost('/api/ai/settings', { provider: f.get('provider'), ollamaModel: f.get('ollamaModel'), dailyBudgetUsd: f.get('dailyBudgetUsd'), replay: f.get('replay') === 'on', replayWrites: f.get('replayWrites') === 'on' });
|
|
818
|
-
renderAISettings();
|
|
819
|
-
});
|
|
820
950
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (!$('#aiset').hidden) $('#aiset').hidden = true; else if (fixJob && !['thinking', 'verifying', 'reverting'].includes(fixJob.stage)) { fixJob = null; renderFix(); } } });
|
|
821
951
|
|
|
822
952
|
function render() {
|
|
@@ -955,10 +1085,27 @@ document.addEventListener('submit', async (e) => {
|
|
|
955
1085
|
const res = await api('/api/config', { method: 'POST', body: JSON.stringify({ app: f.get('app'), test: f.get('test') || null, autoRestart: f.get('auto') === 'on' }) });
|
|
956
1086
|
if (!res.ok) { setupError = (await res.json()).error || 'Could not save.'; render(); return; }
|
|
957
1087
|
showSetup = false;
|
|
1088
|
+
const ai = f.get('ai');
|
|
1089
|
+
if (ai) {
|
|
1090
|
+
try { await aiPost('/api/ai/settings', { provider: ai }); } catch {}
|
|
1091
|
+
}
|
|
958
1092
|
await loadConfig();
|
|
959
1093
|
testInfo = null;
|
|
960
1094
|
render();
|
|
961
1095
|
pollApp();
|
|
1096
|
+
// Open the AI settings when the chosen helper still needs a step (Ollama, a model, a key).
|
|
1097
|
+
if (ai) {
|
|
1098
|
+
try {
|
|
1099
|
+
const st = await (await api('/api/ai/status')).json();
|
|
1100
|
+
const ready = ai === 'ollama' ? st.ollama.running && st.ollama.models.includes(st.settings.ollamaModel) : st.anthropic.configured;
|
|
1101
|
+
if (!ready) { aiNotice = ''; $('#aiset').hidden = false; renderAISettings(); }
|
|
1102
|
+
} catch {}
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
document.addEventListener('submit', (e) => {
|
|
1106
|
+
if (e.target.id !== 'aiset-form') return;
|
|
1107
|
+
e.preventDefault();
|
|
1108
|
+
if (document.activeElement && document.activeElement.id === 'ai-key') $('#ai-key-save').click();
|
|
962
1109
|
});
|
|
963
1110
|
document.addEventListener('click', async (e) => {
|
|
964
1111
|
if (e.target.closest('#setup-open') || e.target.closest('#setup-open-2')) { showSetup = true; render(); return; }
|