@lampdotdev/lamp 0.36.2-nightly.40 → 0.36.2-nightly.43
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 +2 -2
- package/Resources/skills/web-app/SKILL.md +6 -1
- package/cli/ai.mjs +256 -49
- package/cli/app-ai.mjs +54 -6
- package/cli/dev.mjs +11 -2
- package/cli/lamps/lamp-for-games.js +140 -28
- package/cli/lamps/lamp.js +140 -28
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/web-starter/lamp.js +140 -28
- package/cli/openrouter.mjs +12 -1
- package/cli/page.js +6 -3
- package/cli/skill/SKILL.md +1 -1
- package/package.json +1 -1
|
@@ -138,8 +138,20 @@
|
|
|
138
138
|
delete asked[m.id];
|
|
139
139
|
waiting({ status: m.status, body: m.body });
|
|
140
140
|
};
|
|
141
|
-
bridge = { postMessage: function (m) {
|
|
142
|
-
return new Promise(function (resolve) {
|
|
141
|
+
bridge = { postMessage: function (m, signal) {
|
|
142
|
+
return new Promise(function (resolve) {
|
|
143
|
+
var id = ++askId;
|
|
144
|
+
asked[id] = resolve;
|
|
145
|
+
gate.postMessage(JSON.stringify({ id: id, path: m.path, body: m.body, timeout: m.timeout }));
|
|
146
|
+
// A page that stops waiting tells the host, which can stop the answer (lamp dev does), and hears nothing after.
|
|
147
|
+
function stop() {
|
|
148
|
+
if (!asked[id]) return;
|
|
149
|
+
delete asked[id];
|
|
150
|
+
gate.postMessage(JSON.stringify({ id: id, cancel: true }));
|
|
151
|
+
resolve({ status: 499, body: '{}' });
|
|
152
|
+
}
|
|
153
|
+
if (signal) { if (signal.aborted) stop(); else signal.addEventListener('abort', stop); }
|
|
154
|
+
});
|
|
143
155
|
} };
|
|
144
156
|
}
|
|
145
157
|
var preferred = null;
|
|
@@ -305,14 +317,14 @@
|
|
|
305
317
|
// with a little more for the answer to come back.
|
|
306
318
|
function AI_WAIT(seconds) { return (typeof seconds === 'number' && seconds > 0 ? Math.min(seconds, 600) : 600) * 1000 + 15000; }
|
|
307
319
|
|
|
308
|
-
function post(path, body, timeout, deadline) {
|
|
320
|
+
function post(path, body, timeout, deadline, signal) {
|
|
309
321
|
timeout = timeout || 240000;
|
|
310
322
|
var done;
|
|
311
323
|
if (!bridge && shared()) {
|
|
312
324
|
done = nearby(path, body, timeout, deadline);
|
|
313
325
|
} else if (bridge) {
|
|
314
326
|
// The TV preview on the Mac answers for Lamp itself.
|
|
315
|
-
done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }).then(function (r) {
|
|
327
|
+
done = bridge.postMessage({ path: path, body: JSON.stringify(body), timeout: timeout }, signal).then(function (r) {
|
|
316
328
|
var reply = {};
|
|
317
329
|
try { reply = JSON.parse(r.body) || {}; } catch (e) {}
|
|
318
330
|
return { status: r.status, reply: reply };
|
|
@@ -328,10 +340,15 @@
|
|
|
328
340
|
});
|
|
329
341
|
}
|
|
330
342
|
return done.then(function (answer) {
|
|
343
|
+
// Stopped by the page: no failure of the AI's, so nothing is said about it.
|
|
344
|
+
if (signal && signal.aborted) throw stoppedFailure();
|
|
331
345
|
if (answer.status === 200) return answer.reply;
|
|
332
346
|
var message = answer.reply.error || 'The AI request failed.';
|
|
333
347
|
if (window.console) console.error('LampAI ' + path + ' failed: ' + message);
|
|
334
|
-
|
|
348
|
+
var failed = new Failure(message, answer.reply.needs || null);
|
|
349
|
+
// What was billed before it failed (a retry, a timeout), for a turn's `cost`.
|
|
350
|
+
if (typeof answer.reply.cost === 'number' && answer.reply.cost > 0) failed.cost = answer.reply.cost;
|
|
351
|
+
throw failed;
|
|
335
352
|
});
|
|
336
353
|
}
|
|
337
354
|
|
|
@@ -346,14 +363,14 @@
|
|
|
346
363
|
});
|
|
347
364
|
}
|
|
348
365
|
|
|
349
|
-
/** Downscales to at most 1568px on the long side (the most the model uses) and JPEG-encodes. Takes an
|
|
350
|
-
* <canvas>, or a picture's address (a data: URL or a file of the app's). */
|
|
351
|
-
function encode(images) {
|
|
366
|
+
/** Downscales to at most 1568px on the long side (the most the model uses), or `side`, and JPEG-encodes. Takes an
|
|
367
|
+
* <img>, a <canvas>, or a picture's address (a data: URL or a file of the app's). */
|
|
368
|
+
function encode(images, side) {
|
|
352
369
|
return Promise.all((images || []).map(function (image) {
|
|
353
370
|
var ready = typeof image === 'string' ? loadImage(image) : Promise.resolve(image);
|
|
354
371
|
return ready.then(function (source) {
|
|
355
372
|
var w = source.naturalWidth || source.width, h = source.naturalHeight || source.height;
|
|
356
|
-
var scale = Math.min(1, 1568 / Math.max(w, h, 1));
|
|
373
|
+
var scale = Math.min(1, (side || 1568) / Math.max(w, h, 1));
|
|
357
374
|
var canvas = document.createElement('canvas');
|
|
358
375
|
canvas.width = Math.max(1, Math.round(w * scale));
|
|
359
376
|
canvas.height = Math.max(1, Math.round(h * scale));
|
|
@@ -483,21 +500,44 @@
|
|
|
483
500
|
score: function (question, levels) { return { type: 'score', instructions: question, criteria: levels }; }
|
|
484
501
|
};
|
|
485
502
|
|
|
503
|
+
function stoppedFailure() { var f = new Failure('Stopped.'); f.stopped = true; return f; }
|
|
504
|
+
|
|
505
|
+
// A promise the page can stop waiting for with an AbortSignal: it fails at once with `stopped`, whatever comes after.
|
|
506
|
+
function stoppable(promise, signal) {
|
|
507
|
+
if (!signal) return promise;
|
|
508
|
+
return new Promise(function (resolve, reject) {
|
|
509
|
+
function stop() { reject(stoppedFailure()); }
|
|
510
|
+
// Settled either way, so a stopped ask's own failure is never left unhandled.
|
|
511
|
+
if (signal.aborted) { promise.then(null, function () {}); return stop(); }
|
|
512
|
+
signal.addEventListener('abort', stop);
|
|
513
|
+
promise.then(function (v) { signal.removeEventListener('abort', stop); if (!signal.aborted) resolve(v); },
|
|
514
|
+
function (e) { signal.removeEventListener('abort', stop); if (!signal.aborted) reject(e); });
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
486
518
|
function call(prompt, options) {
|
|
487
|
-
var o = options || {}, text = prompt, rules = [o.system || '', present()];
|
|
519
|
+
var o = options || {}, text = prompt, rules = [o.system || '', present()], started = Date.now(), cost = 0;
|
|
520
|
+
var schema = o.schema && typeof o.schema === 'object' ? o.schema : null, json = !!o.json || !!schema;
|
|
488
521
|
if (o.data) { var f = fenced(prompt, o.data); text = f.prompt; rules.push(f.rules); }
|
|
489
|
-
return encode(o.images).then(function (images) {
|
|
490
|
-
|
|
522
|
+
return stoppable(encode(o.images).then(function (images) {
|
|
523
|
+
// Stopped before or while its pictures were read: nothing is asked.
|
|
524
|
+
if (o.signal && o.signal.aborted) throw stoppedFailure();
|
|
525
|
+
var body = { prompt: text, system: rules.filter(function (r) { return r; }).join('\n\n'), json: json, images: images };
|
|
526
|
+
if (schema) body.schema = schema;
|
|
491
527
|
if (o.model) body.model = o.model;
|
|
492
528
|
// No limit of the page's own unless it asks for one: Lamp's guard ends only a call that stopped answering.
|
|
493
529
|
if (typeof o.seconds === 'number' && o.seconds > 0) body.seconds = o.seconds;
|
|
494
|
-
return post('/ai', body, AI_WAIT(body.seconds));
|
|
530
|
+
return post('/ai', body, AI_WAIT(body.seconds), null, o.signal);
|
|
495
531
|
}).then(function (reply) {
|
|
496
532
|
if (typeof reply.text !== 'string') throw new Failure('The AI request failed.');
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
533
|
+
cost = typeof reply.cost === 'number' && reply.cost > 0 ? reply.cost : 0;
|
|
534
|
+
var answer = reply.text;
|
|
535
|
+
if (json) {
|
|
536
|
+
try { answer = JSON.parse(reply.text.replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '')); }
|
|
537
|
+
catch (e) { throw new Failure('The AI\'s answer wasn\'t in the expected format. Try again.'); }
|
|
538
|
+
}
|
|
539
|
+
return o.details ? { answer: answer, seconds: Math.round((Date.now() - started) / 100) / 10, cost: cost } : answer;
|
|
540
|
+
}), o.signal);
|
|
501
541
|
}
|
|
502
542
|
|
|
503
543
|
function image(prompt, images, aspectRatio) {
|
|
@@ -538,6 +578,12 @@
|
|
|
538
578
|
// What a turn's reads give back in all, which every later step carries again: kept under what a step may carry
|
|
539
579
|
// (`MAX_BODY` in lamp's app-ai.mjs), with room for the request, the tools and the steps' own words.
|
|
540
580
|
var ACT_TURN_RESULTS = 1200000;
|
|
581
|
+
// The pictures a step carries from its tools' results (`LampAI.picture`): the newest few, each at most this many
|
|
582
|
+
// pixels on its long side, together at most this many bytes, beside the text results under the step's limit
|
|
583
|
+
// (`MAX_PICTURES`, `MAX_BODY` in lamp's app-ai.mjs). Older ones the model has seen go as no longer shown.
|
|
584
|
+
var ACT_PICTURES = 6, ACT_PICTURE_SIDE = 1024, ACT_PICTURE_BYTES = 1600000;
|
|
585
|
+
/** A picture in a tool's result, for the AI to look at (`LampAI.picture`). */
|
|
586
|
+
function ActPicture(source) { this.source = source; }
|
|
541
587
|
var ACT_STEP_SECONDS = 600;
|
|
542
588
|
var actRan = {}; // turn id -> call key -> the result a write gave, while this copy of the page is open
|
|
543
589
|
function actId() { return 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
|
@@ -596,8 +642,50 @@
|
|
|
596
642
|
}
|
|
597
643
|
var ran = actRan[id];
|
|
598
644
|
var started = Date.now(), deadline = asked ? started + Math.max(1, asked) * 1000 : Infinity;
|
|
599
|
-
var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '' };
|
|
600
|
-
var steps = [], route = null, writes = 0, over = false, settle;
|
|
645
|
+
var out = { turn: id, reply: '', ask: false, choices: [], done: [], failed: [], skipped: [], reads: [], stopped: null, error: '', seconds: 0, cost: 0 };
|
|
646
|
+
var steps = [], route = null, writes = 0, over = false, settle, pictureCount = 0;
|
|
647
|
+
// A result with its pictures taken out: each one sent beside it, scaled down, and named in its place
|
|
648
|
+
// ("[picture 2]", numbered across the turn), so the model sees what the app drew in the middle of the turn.
|
|
649
|
+
function pictured(value) {
|
|
650
|
+
var found = [];
|
|
651
|
+
function walk(v, depth) {
|
|
652
|
+
if (v instanceof ActPicture) { found.push({ source: v.source, number: ++pictureCount }); return '[picture ' + pictureCount + ']'; }
|
|
653
|
+
if (!v || typeof v !== 'object' || depth > 8) return v;
|
|
654
|
+
if (isList(v)) return v.map(function (x) { return walk(x, depth + 1); });
|
|
655
|
+
var proto = Object.getPrototypeOf(v);
|
|
656
|
+
if (proto !== Object.prototype && proto !== null) return v;
|
|
657
|
+
var copy = {};
|
|
658
|
+
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) copy[k] = walk(v[k], depth + 1);
|
|
659
|
+
return copy;
|
|
660
|
+
}
|
|
661
|
+
var kept = walk(value, 0);
|
|
662
|
+
if (!found.length) return Promise.resolve({ value: value });
|
|
663
|
+
// A picture that never loads costs the turn nothing: after a few seconds the result goes without it.
|
|
664
|
+
var slow = new Promise(function (resolve, reject) { browserTimeout(function () { reject(new Error('slow')); }, 10000); });
|
|
665
|
+
return Promise.race([encode(found.map(function (f) { return f.source; }), ACT_PICTURE_SIDE), slow]).then(function (encoded) {
|
|
666
|
+
return { value: kept, pictures: encoded.map(function (e, i) { return { media_type: e.media_type, data: e.data, number: found[i].number }; }) };
|
|
667
|
+
}, function () {
|
|
668
|
+
return { value: { result: kept, note: 'Its pictures couldn\'t be read, so they aren\'t shown.' } };
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
// The steps as sent: only the newest pictures go with their data, within the step's room for them.
|
|
672
|
+
function sentSteps() {
|
|
673
|
+
// Chosen newest first by number across every result, whatever order the results are in.
|
|
674
|
+
var room = ACT_PICTURE_BYTES, chosen = {}, all = [];
|
|
675
|
+
steps.forEach(function (s) { s.results.forEach(function (r) { (r.pictures || []).forEach(function (p) { all.push(p); }); }); });
|
|
676
|
+
all.sort(function (a, b) { return b.number - a.number; }).forEach(function (p) {
|
|
677
|
+
if (Object.keys(chosen).length < ACT_PICTURES && p.data.length <= room) { chosen[p.number] = true; room -= p.data.length; }
|
|
678
|
+
});
|
|
679
|
+
return steps.map(function (s) {
|
|
680
|
+
return { calls: s.calls, results: s.results.map(function (r) {
|
|
681
|
+
if (!r.pictures) return r;
|
|
682
|
+
var copy = {};
|
|
683
|
+
for (var k in r) if (Object.prototype.hasOwnProperty.call(r, k)) copy[k] = r[k];
|
|
684
|
+
copy.pictures = r.pictures.map(function (p) { return { media_type: p.media_type, number: p.number, data: chosen[p.number] ? p.data : null }; });
|
|
685
|
+
return copy;
|
|
686
|
+
}) };
|
|
687
|
+
});
|
|
688
|
+
}
|
|
601
689
|
var declared = Object.keys(tools).map(function (name) {
|
|
602
690
|
var t = tools[name];
|
|
603
691
|
return { name: name, about: t.about || '', reads: !!t.reads, args: t.args || {} };
|
|
@@ -608,6 +696,7 @@
|
|
|
608
696
|
over = true;
|
|
609
697
|
out.stopped = reason || null;
|
|
610
698
|
if (error) out.error = error;
|
|
699
|
+
out.seconds = Math.round((Date.now() - started) / 100) / 10;
|
|
611
700
|
settle(out);
|
|
612
701
|
}
|
|
613
702
|
// Over, or past the deadline by the clock even if its timer hasn't run yet (a slow write can carry the turn past
|
|
@@ -637,7 +726,10 @@
|
|
|
637
726
|
}).then(function (value) {
|
|
638
727
|
if (value === NOT_RUN) return;
|
|
639
728
|
out.reads.push({ name: c.name, args: c.args });
|
|
640
|
-
|
|
729
|
+
return pictured(value).then(function (p) {
|
|
730
|
+
results[c.id] = { id: c.id, ok: true, value: sized(p.value) };
|
|
731
|
+
if (p.pictures) results[c.id].pictures = p.pictures;
|
|
732
|
+
});
|
|
641
733
|
}, function (e) { results[c.id] = { id: c.id, ok: false, error: refused(e) }; });
|
|
642
734
|
})).then(function () {
|
|
643
735
|
var stopped = null;
|
|
@@ -675,13 +767,18 @@
|
|
|
675
767
|
var running;
|
|
676
768
|
try { running = Promise.resolve(tool.run(c.args, call)); } catch (e) { running = Promise.reject(e); }
|
|
677
769
|
return running.then(function (value) {
|
|
678
|
-
|
|
679
|
-
ran[key] =
|
|
770
|
+
// The change is done: it's recorded now, whatever happens to its pictures after.
|
|
771
|
+
ran[key] = { note: 'Done.' };
|
|
680
772
|
var entry = { name: c.name, args: c.args, result: value };
|
|
681
773
|
if (value && typeof value === 'object' && value.partly) entry.partly = String(value.partly);
|
|
682
774
|
out.done.push(entry);
|
|
683
|
-
results[c.id] = { id: c.id, ok: true, value: kept };
|
|
684
775
|
tell({ kind: 'write', name: c.name, args: c.args, result: value });
|
|
776
|
+
return pictured(value).then(function (p) { return { p: p }; });
|
|
777
|
+
}).then(function (got) {
|
|
778
|
+
var kept = sized(got.p.value);
|
|
779
|
+
ran[key] = kept;
|
|
780
|
+
results[c.id] = { id: c.id, ok: true, value: kept };
|
|
781
|
+
if (got.p.pictures) results[c.id].pictures = got.p.pictures;
|
|
685
782
|
return next(i + 1);
|
|
686
783
|
}, function (e) { return fail(refused(e)); });
|
|
687
784
|
}, function (e) { return fail(refused(e)); });
|
|
@@ -696,13 +793,15 @@
|
|
|
696
793
|
if (left <= 500) return stop('time');
|
|
697
794
|
// The step's own time: what's left of an app's turn, or the step's guard against a call that has stopped.
|
|
698
795
|
var stepTime = Math.min(left, ACT_STEP_SECONDS * 1000);
|
|
699
|
-
var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps:
|
|
796
|
+
var body = { request: String(request), about: o.about || '', now: present(), tools: declared, steps: sentSteps(),
|
|
700
797
|
history: (o.history || []).slice(-3), seconds: Math.floor(stepTime / 1000) };
|
|
701
798
|
if (o.context !== undefined) body.context = jsonState(o.context);
|
|
702
799
|
if (route) body.route = route;
|
|
703
800
|
if (steps.length >= limits.steps - 1) body.final = true;
|
|
704
801
|
if (o.model) body.model = o.model;
|
|
705
802
|
post('/ai/act', body, stepTime, Date.now() + stepTime).then(function (answer) {
|
|
803
|
+
// What the step cost through a key, paid for whether or not its answer still runs.
|
|
804
|
+
if (typeof answer.cost === 'number' && answer.cost > 0) out.cost += answer.cost;
|
|
706
805
|
// Cancelled, out of time or already over: an answer that comes late runs nothing.
|
|
707
806
|
if (late()) return;
|
|
708
807
|
route = answer.route || route;
|
|
@@ -728,6 +827,7 @@
|
|
|
728
827
|
}) ? offered.map(function (c) { return c.trim(); }) : [];
|
|
729
828
|
stop(null);
|
|
730
829
|
}, function (e) {
|
|
830
|
+
if (e && typeof e.cost === 'number' && e.cost > 0) out.cost += e.cost;
|
|
731
831
|
var message = refused(e);
|
|
732
832
|
stop(/reach Lamp|Lamp isn't|wasn't put here|put it on this device/.test(message) ? 'unreachable' : 'error', message);
|
|
733
833
|
});
|
|
@@ -928,8 +1028,12 @@
|
|
|
928
1028
|
|
|
929
1029
|
/** options: data (text from outside the app, for the AI to work on, not obey), images (pictures to look at),
|
|
930
1030
|
* system, json (true asks for JSON and gives back the parsed value: describe its exact shape in the prompt),
|
|
931
|
-
*
|
|
932
|
-
*
|
|
1031
|
+
* schema (a JSON Schema the answer must fit, which holds every AI to it, for large JSON: implies json; each object
|
|
1032
|
+
* lists every property in `required` and has `additionalProperties: false`), model (an id `LampAI.models()` lists,
|
|
1033
|
+
* such as "engine:claude:opus" or "openrouter:sonnet"; a bare "opus" is Opus through whatever answers by
|
|
1034
|
+
* default), signal (an AbortSignal: aborting it fails the ask at once with `error.stopped`), details (true
|
|
1035
|
+
* resolves { answer, seconds, cost }: `cost` in US dollars, 0 when the person's own Claude Code or Codex
|
|
1036
|
+
* answered). */
|
|
933
1037
|
ask: function (prompt, options) { return call(prompt, options); },
|
|
934
1038
|
|
|
935
1039
|
/**
|
|
@@ -957,7 +1061,7 @@
|
|
|
957
1061
|
* model: picker.value, // optional: an id from LampAI.models()
|
|
958
1062
|
* onStep: function (s) { … } // { kind, name, args[, result] }
|
|
959
1063
|
* });
|
|
960
|
-
* turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; });
|
|
1064
|
+
* turn.result.then(function (r) { r.reply; r.ask; r.choices; r.done; r.failed; r.skipped; r.stopped; r.seconds; r.cost; });
|
|
961
1065
|
* turn.cancel();
|
|
962
1066
|
*
|
|
963
1067
|
* An argument's shape is { type: 'string' | 'integer' | 'number' | 'boolean' | 'date' | 'datetime' | 'list',
|
|
@@ -971,10 +1075,18 @@
|
|
|
971
1075
|
* `call.key` is the same for the same write in the same turn: make the ids of what a write creates from it, or
|
|
972
1076
|
* keep it on the change, so a write asked again (or a turn tried again with `turn: r.turn`) finds it instead of
|
|
973
1077
|
* adding a second. The same write in one turn while the page is open isn't run twice; nothing promises more.
|
|
974
|
-
*
|
|
1078
|
+
* A tool's result can show the AI what the app drew, in the middle of the turn: put `LampAI.picture(canvas)` in it
|
|
1079
|
+
* (`{ rendered: [LampAI.picture(front), LampAI.picture(side)] }`), and the AI sees each picture in its place, so it
|
|
1080
|
+
* can build, look and fix in one turn. The newest few are shown, smaller than drawn; older ones the AI has seen.
|
|
1081
|
+
* Throw `LampAI.refuse('sentence')` from `check` or `run` to say why something can't be done. `seconds` is how long
|
|
1082
|
+
* the turn took and `cost` what it cost in US dollars (0 when the person's own Claude Code or Codex answered).
|
|
975
1083
|
*/
|
|
976
1084
|
act: function (request, options) { return act(request, options); },
|
|
977
1085
|
|
|
1086
|
+
/** A picture for the AI to look at, in what a tool of `act` returns: a <canvas>, an <img>, or a picture's address
|
|
1087
|
+
* (a data: URL or a file of the app's). */
|
|
1088
|
+
picture: function (source) { return new ActPicture(source); },
|
|
1089
|
+
|
|
978
1090
|
/**
|
|
979
1091
|
* What the person said, as text: `audio` is a WAV in base64 as `LampVoice.listen` gives it (16 kHz mono 16-bit,
|
|
980
1092
|
* at most 15 seconds), transcribed by Lamp on the Mac with the AI set up there (OpenRouter's Scribe), which
|
package/cli/openrouter.mjs
CHANGED
|
@@ -6,7 +6,18 @@
|
|
|
6
6
|
const ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
|
|
7
7
|
/** The model lamp answers with when none is named: the one Lamp's app offers first for apps' text. */
|
|
8
8
|
export const DEFAULT_MODEL = "anthropic/claude-sonnet-5.5";
|
|
9
|
-
|
|
9
|
+
/** The image models an app may choose for its pictures, by OpenRouter's ids, first the one used when it chose none:
|
|
10
|
+
* Lamp's app's list (`Icons.artModels`), kept the same by hand. */
|
|
11
|
+
export const IMAGE_MODELS = [
|
|
12
|
+
"google/gemini-3.1-flash-image",
|
|
13
|
+
"google/gemini-nano-banana-2.1",
|
|
14
|
+
"google/gemini-3-pro-image",
|
|
15
|
+
"openai/gpt-image-2.5-sunburst",
|
|
16
|
+
"openai/gpt-image-2.5-flare",
|
|
17
|
+
"openai/gpt-image-2",
|
|
18
|
+
"google/gemini-3.1-flash-lite-image",
|
|
19
|
+
];
|
|
20
|
+
const FILTERED ="The AI provider's content filter cut the reply off. Try rewording the request.";
|
|
10
21
|
|
|
11
22
|
/** Why OpenRouter gave no answer: `kind` "refused" (with its `status` and `reply`), "filtered", "timeout" or "stopped". */
|
|
12
23
|
export class OpenRouterTrouble extends Error {
|
package/cli/page.js
CHANGED
|
@@ -25,16 +25,19 @@
|
|
|
25
25
|
// (`/__lamp/ai`, with the person's own Claude Code or Codex), through the same shape of bridge lamp.js takes from
|
|
26
26
|
// Lamp's Android app: postMessage of a request, an onmessage with its answer.
|
|
27
27
|
if (!window.lampGatewayHost && !(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lampGateway)) {
|
|
28
|
-
const gateway = { onmessage: null };
|
|
28
|
+
const gateway = { onmessage: null }, asking = new Map();
|
|
29
29
|
gateway.postMessage = (text) => {
|
|
30
30
|
let asked;
|
|
31
31
|
try { asked = JSON.parse(text); } catch (e) { return; }
|
|
32
|
+
// The page stopped waiting (lamp.js's `cancel`): the request ends, which stops lamp's answer.
|
|
33
|
+
if (asked.cancel) { const going = asking.get(asked.id); if (going) { going.cancelled = true; going.abort(); } return; }
|
|
32
34
|
const answer = (status, body) => { if (typeof gateway.onmessage === 'function') gateway.onmessage({ data: JSON.stringify({ id: asked.id, status, body }) }); };
|
|
33
35
|
const stop = new AbortController();
|
|
36
|
+
asking.set(asked.id, stop);
|
|
34
37
|
const timer = later(() => stop.abort(), Math.max(1000, Number(asked.timeout) || 240000));
|
|
35
38
|
ask('/__lamp/ai', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Lamp-Page': '1' }, body: JSON.stringify({ path: asked.path, body: asked.body }), signal: stop.signal })
|
|
36
|
-
.then((r) => r.json()).then((r) => answer(r.status, r.body), () => answer(502, JSON.stringify({ error: 'lamp dev took too long to answer.' })))
|
|
37
|
-
.finally(() => unlater(timer));
|
|
39
|
+
.then((r) => r.json()).then((r) => answer(r.status, r.body), () => { if (!stop.cancelled) answer(502, JSON.stringify({ error: 'lamp dev took too long to answer.' })); })
|
|
40
|
+
.finally(() => { unlater(timer); asking.delete(asked.id); });
|
|
38
41
|
};
|
|
39
42
|
try { Object.defineProperty(window, 'lampGatewayHost', { value: gateway, configurable: true }); } catch (e) {}
|
|
40
43
|
}
|
package/cli/skill/SKILL.md
CHANGED
|
@@ -36,7 +36,7 @@ looked earlier, `--reopen` puts it right.
|
|
|
36
36
|
a page (saved data with `LampStore`, the app's files and more). Read Lamp's `web-app` skill before writing it, and
|
|
37
37
|
the comments in `lamp.js` for how each block is used; never edit `lamp.js`. Its AI (`LampAI`: ask, act, decide) is
|
|
38
38
|
answered by lamp itself on this Mac, with the person's own Claude Code or Codex (or OpenRouter, with their key in `OPENROUTER_API_KEY`), in every page lamp runs here (`dev`,
|
|
39
|
-
`open`, `look`, `use`)
|
|
39
|
+
`open`, `look`, `use`). Pictures (`LampAI.makeImage` and its edits) are made there with the OpenRouter key, or with Codex; not with Claude Code alone. Hearing needs Lamp's app. Shared on lamp.dev, it's answered on the computer the page is played on while Lamp is open there or `{{lamp}} ai` runs there in a terminal, once the person allows that page. Never put a key or a model provider's address in the page. A page that wants none of Lamp's needs no starting files: write its
|
|
40
40
|
`index.html` in an empty folder.
|
|
41
41
|
|
|
42
42
|
## A Mac app
|