@noobdemon/noob-cli 1.12.16 → 1.12.17
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 +9 -0
- package/package.json +1 -1
- package/src/agent.js +4 -0
- package/src/api.js +4 -0
- package/src/repl.js +32 -8
- package/src/tui.js +52 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Tất cả thay đổi đáng kể của `@noobdemon/noob-cli` được ghi vào file này.
|
|
4
4
|
|
|
5
|
+
## [1.12.17] - 2026-06-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Image input support** (`src/tui.js`, `src/repl.js`, `src/api.js`, `src/agent.js`, `worker/src/worker.js`): rich TTY trên Windows hỗ trợ `Alt+V` để lấy ảnh từ clipboard, hiện chip `[pasted image #1]`, rồi gửi kèm payload `image: "data:image/png;base64,..."` qua gateway. CLI cũng tự đính kèm ảnh đầu tiên khi user nhắc `@file.png|jpg|jpeg|webp|gif` (giới hạn 8MB). Worker validate data URL và forward `image` sang Railway (`{message, model, image}`); fallback cũ giữ `{message, model}`.
|
|
9
|
+
|
|
10
|
+
### Verified
|
|
11
|
+
- `npm test` 100/100 pass.
|
|
12
|
+
- Railway `/chat` smoke với `image` data URL base64 trả mô tả ảnh thành công.
|
|
13
|
+
|
|
5
14
|
## [1.12.15] - 2026-06-16
|
|
6
15
|
|
|
7
16
|
### Fixed
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -556,6 +556,7 @@ function extractJsonObject(s, from) {
|
|
|
556
556
|
export async function runAgent({
|
|
557
557
|
history,
|
|
558
558
|
model,
|
|
559
|
+
image,
|
|
559
560
|
signal,
|
|
560
561
|
onTool,
|
|
561
562
|
onStatus,
|
|
@@ -608,6 +609,7 @@ export async function runAgent({
|
|
|
608
609
|
const { text, finishReason } = await streamWithRetry({
|
|
609
610
|
model,
|
|
610
611
|
message,
|
|
612
|
+
image,
|
|
611
613
|
system,
|
|
612
614
|
signal,
|
|
613
615
|
tokenMeter,
|
|
@@ -756,6 +758,7 @@ export async function runAgent({
|
|
|
756
758
|
async function streamWithRetry({
|
|
757
759
|
model,
|
|
758
760
|
message,
|
|
761
|
+
image,
|
|
759
762
|
system,
|
|
760
763
|
signal,
|
|
761
764
|
tokenMeter,
|
|
@@ -771,6 +774,7 @@ async function streamWithRetry({
|
|
|
771
774
|
mode: 'chat',
|
|
772
775
|
model,
|
|
773
776
|
message,
|
|
777
|
+
image,
|
|
774
778
|
system,
|
|
775
779
|
signal,
|
|
776
780
|
effort,
|
package/src/api.js
CHANGED
|
@@ -157,6 +157,7 @@ function hasUnclosedToolBlock(text) {
|
|
|
157
157
|
export async function stream({
|
|
158
158
|
mode = 'chat',
|
|
159
159
|
message,
|
|
160
|
+
image,
|
|
160
161
|
model,
|
|
161
162
|
system,
|
|
162
163
|
conversation,
|
|
@@ -186,6 +187,7 @@ export async function stream({
|
|
|
186
187
|
endpoint,
|
|
187
188
|
mode,
|
|
188
189
|
message: prompt,
|
|
190
|
+
image,
|
|
189
191
|
model,
|
|
190
192
|
system,
|
|
191
193
|
conversation,
|
|
@@ -262,6 +264,7 @@ async function streamOnce({
|
|
|
262
264
|
endpoint,
|
|
263
265
|
mode,
|
|
264
266
|
message,
|
|
267
|
+
image,
|
|
265
268
|
model,
|
|
266
269
|
system,
|
|
267
270
|
conversation,
|
|
@@ -278,6 +281,7 @@ async function streamOnce({
|
|
|
278
281
|
else if (mode === 'merge') body = { message };
|
|
279
282
|
else {
|
|
280
283
|
body = { message, model, remember: true, memoryToken: getMemoryToken() };
|
|
284
|
+
if (image) body.image = image;
|
|
281
285
|
if (system) body.customInstructions = system;
|
|
282
286
|
if (Array.isArray(conversation) && conversation.length) body.conversation = conversation;
|
|
283
287
|
if (effort) body.effort = effort;
|
package/src/repl.js
CHANGED
|
@@ -101,6 +101,24 @@ import {
|
|
|
101
101
|
} from './repl/utils.js';
|
|
102
102
|
import { createAgentDispatcher } from './repl/agent-dispatch.js';
|
|
103
103
|
import { createBgRegistry } from './workflow-bg.js';
|
|
104
|
+
|
|
105
|
+
const IMAGE_MIME = {
|
|
106
|
+
'.png': 'image/png',
|
|
107
|
+
'.jpg': 'image/jpeg',
|
|
108
|
+
'.jpeg': 'image/jpeg',
|
|
109
|
+
'.webp': 'image/webp',
|
|
110
|
+
'.gif': 'image/gif',
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function imageDataUrl(file) {
|
|
114
|
+
if (!file) return null;
|
|
115
|
+
const mime = IMAGE_MIME[path.extname(file).toLowerCase()];
|
|
116
|
+
if (!mime) return null;
|
|
117
|
+
const abs = path.resolve(process.cwd(), file);
|
|
118
|
+
const stat = fs.statSync(abs);
|
|
119
|
+
if (stat.size > 8 * 1024 * 1024) throw new Error('Ảnh quá lớn (>8MB): ' + file);
|
|
120
|
+
return `data:${mime};base64,${fs.readFileSync(abs).toString('base64')}`;
|
|
121
|
+
}
|
|
104
122
|
export async function startRepl(opts = {}) {
|
|
105
123
|
const state = createState(opts, config);
|
|
106
124
|
const tokenMeter = new TokenMeter();
|
|
@@ -1453,23 +1471,25 @@ NGUYÊN TẮC:
|
|
|
1453
1471
|
let input;
|
|
1454
1472
|
if (pending.length) {
|
|
1455
1473
|
// Có tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
|
|
1456
|
-
input = (pending.shift() ?? '').trim();
|
|
1474
|
+
input = { text: (pending.shift() ?? '').trim(), images: [] };
|
|
1457
1475
|
} else {
|
|
1458
1476
|
const raw = await ask(promptStr(false));
|
|
1459
1477
|
if (raw == null) break; // stdin fully closed and drained
|
|
1460
|
-
input = raw.trim();
|
|
1478
|
+
input = typeof raw === 'string' ? { text: raw.trim(), images: [] } : raw;
|
|
1479
|
+
input.text = String(input.text || '').trim();
|
|
1480
|
+
input.images = Array.isArray(input.images) ? input.images : [];
|
|
1461
1481
|
}
|
|
1462
|
-
if (!input) continue;
|
|
1482
|
+
if (!input.text) continue;
|
|
1463
1483
|
// Bọc cả lượt: một lỗi trong xử lý lệnh/agent không được phép thoát ra
|
|
1464
1484
|
// ngoài vòng lặp (sẽ rơi vào .catch ở bin/noob.js → process.exit(1) =
|
|
1465
1485
|
// "tự động tắt"). Bắt ở đây, in lỗi, rồi tiếp tục vòng lặp.
|
|
1466
1486
|
try {
|
|
1467
|
-
if (input.startsWith('/')) {
|
|
1468
|
-
const done = await command(input);
|
|
1487
|
+
if (input.text.startsWith('/')) {
|
|
1488
|
+
const done = await command(input.text);
|
|
1469
1489
|
if (done) break;
|
|
1470
1490
|
continue;
|
|
1471
1491
|
}
|
|
1472
|
-
await handle(input);
|
|
1492
|
+
await handle(input.text, { images: input.images });
|
|
1473
1493
|
persist(); // lưu sau mỗi lượt → resume được kể cả khi tắt đột ngột
|
|
1474
1494
|
} catch (err) {
|
|
1475
1495
|
printError(err);
|
|
@@ -1481,7 +1501,7 @@ NGUYÊN TẮC:
|
|
|
1481
1501
|
process.exit(0);
|
|
1482
1502
|
|
|
1483
1503
|
// ── turn handler ─────────────────────────────────────────────────────────
|
|
1484
|
-
async function handle(text) {
|
|
1504
|
+
async function handle(text, opts = {}) {
|
|
1485
1505
|
if (!config.apiKey) {
|
|
1486
1506
|
console.log(c.tool(' ' + t.notLoggedIn));
|
|
1487
1507
|
return;
|
|
@@ -1546,9 +1566,12 @@ NGUYÊN TẮC:
|
|
|
1546
1566
|
}
|
|
1547
1567
|
|
|
1548
1568
|
const files = mentionedFiles(text);
|
|
1569
|
+
const image =
|
|
1570
|
+
opts.images?.[0] ||
|
|
1571
|
+
imageDataUrl(files.find((f) => IMAGE_MIME[path.extname(f).toLowerCase()]));
|
|
1549
1572
|
const content = files.length
|
|
1550
1573
|
? text +
|
|
1551
|
-
`\n\n[File người dùng nhắc tới bằng @: ${files.join(', ')} — đọc bằng read_file nếu cần.]`
|
|
1574
|
+
`\n\n[File người dùng nhắc tới bằng @: ${files.join(', ')} — đọc bằng read_file nếu cần.${image ? ' Ảnh đầu tiên đã được đính kèm cho model vision.' : ''}]`
|
|
1552
1575
|
: text;
|
|
1553
1576
|
state.history.push({ role: 'user', content });
|
|
1554
1577
|
// Update terminal title với session name (trích từ message đầu).
|
|
@@ -1592,6 +1615,7 @@ NGUYÊN TẮC:
|
|
|
1592
1615
|
const answer = await runAgent({
|
|
1593
1616
|
history: state.history,
|
|
1594
1617
|
model: state.model.id,
|
|
1618
|
+
image,
|
|
1595
1619
|
signal: abort.signal,
|
|
1596
1620
|
tokenMeter,
|
|
1597
1621
|
goal: state.goal,
|
package/src/tui.js
CHANGED
|
@@ -7,11 +7,32 @@
|
|
|
7
7
|
// Bật/tắt: TTY thật → chế độ giàu; không phải TTY hoặc NOOB_TUI=0 → chế độ "dumb"
|
|
8
8
|
// (đọc dòng đơn giản, in thẳng) để khỏi vỡ ở terminal lạ / pipe / CI.
|
|
9
9
|
import readline from 'node:readline';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
10
11
|
import { c } from './ui.js';
|
|
11
12
|
|
|
12
13
|
const ESC = '\x1b';
|
|
13
14
|
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
14
15
|
const visLen = (s) => s.replace(ANSI_RE, '').length;
|
|
16
|
+
function readClipboardImageDataUrl() {
|
|
17
|
+
if (process.platform !== 'win32') return null;
|
|
18
|
+
try {
|
|
19
|
+
const script =
|
|
20
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing;' +
|
|
21
|
+
'$img=[Windows.Forms.Clipboard]::GetImage();' +
|
|
22
|
+
'if($null -eq $img){exit 2};' +
|
|
23
|
+
'$ms=New-Object IO.MemoryStream;' +
|
|
24
|
+
'$img.Save($ms,[Drawing.Imaging.ImageFormat]::Png);' +
|
|
25
|
+
'[Convert]::ToBase64String($ms.ToArray())';
|
|
26
|
+
const b64 = execFileSync('powershell.exe', ['-NoProfile', '-STA', '-Command', script], {
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
timeout: 5000,
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
}).trim();
|
|
31
|
+
return b64 ? `data:image/png;base64,${b64}` : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
15
36
|
// Trả index trong `text` mà tại đó vị trí VISUAL đạt `targetVis`. Bỏ qua toàn
|
|
16
37
|
// bộ ANSI escape sequence khi đếm. Dùng cho soft-wrap khi text có màu.
|
|
17
38
|
function findVisPos(text, targetVis) {
|
|
@@ -207,6 +228,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
207
228
|
// vị trí con trỏ → hỗ trợ ←/→/Home/End/Delete, chèn & xoá GIỮA dòng.
|
|
208
229
|
let cells = [];
|
|
209
230
|
let cur = 0;
|
|
231
|
+
let imageSeq = 0;
|
|
210
232
|
let waiter = null;
|
|
211
233
|
const queue = [];
|
|
212
234
|
|
|
@@ -254,8 +276,13 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
254
276
|
? firstLine.slice(0, PASTE_PREVIEW_MAX - 1) + '…'
|
|
255
277
|
: firstLine;
|
|
256
278
|
};
|
|
257
|
-
const
|
|
279
|
+
const imageLabel = (x) => `[pasted image #${x.n}]`;
|
|
280
|
+
const cellStr = (x) => {
|
|
281
|
+
if (x.image) return imageLabel(x);
|
|
282
|
+
return x.paste !== undefined ? x.paste : x.c;
|
|
283
|
+
};
|
|
258
284
|
const cellPlain = (x) => {
|
|
285
|
+
if (x.image) return imageLabel(x);
|
|
259
286
|
if (x.paste === undefined) return x.c;
|
|
260
287
|
const preview = pastePreview(x.paste);
|
|
261
288
|
return preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
|
|
@@ -264,6 +291,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
264
291
|
const coloredInput = () =>
|
|
265
292
|
cells
|
|
266
293
|
.map((x) => {
|
|
294
|
+
if (x.image) return c.dim(imageLabel(x));
|
|
267
295
|
if (x.paste === undefined) return x.c;
|
|
268
296
|
const preview = pastePreview(x.paste);
|
|
269
297
|
const label = preview
|
|
@@ -568,6 +596,18 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
568
596
|
histPos = null;
|
|
569
597
|
} else pushText(content); // paste 1 dòng = gõ thẳng
|
|
570
598
|
}
|
|
599
|
+
function pushImage(image) {
|
|
600
|
+
imageSeq += 1;
|
|
601
|
+
cells.splice(cur, 0, { image, n: imageSeq });
|
|
602
|
+
cur += 1;
|
|
603
|
+
histPos = null;
|
|
604
|
+
}
|
|
605
|
+
function pasteClipboardImage() {
|
|
606
|
+
const image = readClipboardImageDataUrl();
|
|
607
|
+
if (image) pushImage(image);
|
|
608
|
+
else w('\x07');
|
|
609
|
+
draw();
|
|
610
|
+
}
|
|
571
611
|
function backspace() {
|
|
572
612
|
if (cur > 0) {
|
|
573
613
|
cells.splice(cur - 1, 1);
|
|
@@ -582,7 +622,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
582
622
|
}
|
|
583
623
|
}
|
|
584
624
|
// null = ô dán (coi như ranh giới từ); ngược lại trả ký tự của ô.
|
|
585
|
-
const charAt = (k) =>
|
|
625
|
+
const charAt = (k) =>
|
|
626
|
+
cells[k] && cells[k].paste === undefined && !cells[k].image ? cells[k].c : null;
|
|
586
627
|
function moveWordLeft() {
|
|
587
628
|
while (cur > 0 && charAt(cur - 1) === ' ') cur -= 1;
|
|
588
629
|
if (cur > 0 && charAt(cur - 1) === null) return void (cur -= 1); // qua 1 chip
|
|
@@ -639,6 +680,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
639
680
|
|
|
640
681
|
function submit() {
|
|
641
682
|
const full = fullText();
|
|
683
|
+
const images = cells.filter((x) => x.image).map((x) => x.image);
|
|
642
684
|
const echo = echoUserBlock();
|
|
643
685
|
if (full.trim() && submitHistory[submitHistory.length - 1] !== full) {
|
|
644
686
|
submitHistory.push(full);
|
|
@@ -654,9 +696,9 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
654
696
|
if (waiter) {
|
|
655
697
|
const wr = waiter;
|
|
656
698
|
waiter = null;
|
|
657
|
-
wr(full);
|
|
658
|
-
} else if (onLine) onLine(full);
|
|
659
|
-
else queue.push(full);
|
|
699
|
+
wr(images.length ? { text: full, images } : full);
|
|
700
|
+
} else if (onLine) onLine(images.length ? { text: full, images } : full);
|
|
701
|
+
else queue.push(images.length ? { text: full, images } : full);
|
|
660
702
|
}
|
|
661
703
|
|
|
662
704
|
let carry = '';
|
|
@@ -702,6 +744,11 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
702
744
|
}
|
|
703
745
|
const ch = s[i];
|
|
704
746
|
if (ch === ESC) {
|
|
747
|
+
if (rest[1] === 'v' || rest[1] === 'V') {
|
|
748
|
+
pasteClipboardImage();
|
|
749
|
+
i += 2;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
705
752
|
// Một chuỗi CSI hoàn chỉnh: \x1b[ … <chữ/~> hoặc SS3: \x1bO<chữ>.
|
|
706
753
|
const csi = rest.match(/^\x1b\[[0-9;]*[~A-Za-z]/) || rest.match(/^\x1bO[A-Za-z]/);
|
|
707
754
|
if (!csi) {
|