mindvest-atlas 0.9.0 → 0.9.2
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/package.json +1 -1
- package/src/commands/remote-control.js +19 -1
- package/src/remote-control.js +51 -0
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ import { color, eprintln } from '../util/ui.js';
|
|
|
17
17
|
import {
|
|
18
18
|
DEFAULT_TIMEOUT_MS, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
|
|
19
19
|
osLabel, readJobFile, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
|
|
20
|
-
setPaused, writeHelpers, askModel, listModels,
|
|
20
|
+
setPaused, writeHelpers, askModel, listModels, chatModel,
|
|
21
21
|
} from '../remote-control.js';
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import os from 'node:os';
|
|
@@ -97,7 +97,14 @@ async function answer(requestId, payload) {
|
|
|
97
97
|
* confined to the jobs folder: it can list what is there and show one file; it
|
|
98
98
|
* cannot write, delete, or reach outside. */
|
|
99
99
|
async function handleAsk(ask, root) {
|
|
100
|
+
// ADDRESSED TO ONE MACHINE. The ask channel is per-USER, so every computer
|
|
101
|
+
// this person owns hears every question. Fine for "list your files" — they
|
|
102
|
+
// each answer about themselves — but a BUILD must land on the machine they
|
|
103
|
+
// picked, not on whichever happens to reply first. An ask naming a computer
|
|
104
|
+
// is ignored by every other one; an ask naming none is for anybody.
|
|
100
105
|
if (!ask?.request_id) return;
|
|
106
|
+
const want = String(ask.machine_id || '').trim();
|
|
107
|
+
if (want && want !== machineId()) return undefined;
|
|
101
108
|
if (ask.op === 'list') return answer(ask.request_id, { ok: true, jobs: listJobFiles(root) });
|
|
102
109
|
if (ask.op === 'pause') {
|
|
103
110
|
const { dir } = workspace(ask.job || '', root);
|
|
@@ -105,6 +112,17 @@ async function handleAsk(ask, root) {
|
|
|
105
112
|
}
|
|
106
113
|
if (ask.op === 'runs') return answer(ask.request_id, { ok: true, runs: listRuns(root) });
|
|
107
114
|
if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
|
|
115
|
+
if (ask.op === 'chat') {
|
|
116
|
+
// A person typing at the web, talking to the model on this machine. Each
|
|
117
|
+
// event is answered SEPARATELY against the same request id, so the web sees
|
|
118
|
+
// the build as it happens; the server relays and stores none of it.
|
|
119
|
+
let seq = 0;
|
|
120
|
+
const send = (ev) => answer(ask.request_id, { ok: true, stream: true, seq: ++seq, ...ev });
|
|
121
|
+
void chatModel(ask.prompt || '', send, { job: ask.job || '', model: ask.model || '' })
|
|
122
|
+
.catch((err) => send({ kind: 'error', message: String(err?.message || err) }))
|
|
123
|
+
.finally(() => send({ kind: 'done' }));
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
108
126
|
if (ask.op === 'models') {
|
|
109
127
|
void listModels().then((m) => answer(ask.request_id, { ok: true, ...m }));
|
|
110
128
|
return undefined;
|
package/src/remote-control.js
CHANGED
|
@@ -541,3 +541,54 @@ export async function listModels(timeoutMs = 15000) {
|
|
|
541
541
|
return empty;
|
|
542
542
|
}
|
|
543
543
|
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Stream a chat turn from the model on THIS computer.
|
|
547
|
+
*
|
|
548
|
+
* Calls onEvent once per event as it arrives — text chunks, the tool it is
|
|
549
|
+
* running, an error — so the person watching on the web sees the build happen
|
|
550
|
+
* rather than staring at nothing for two minutes and assuming it hung.
|
|
551
|
+
*/
|
|
552
|
+
export async function chatModel(prompt, onEvent, { job = '', model = '', timeoutMs = 1800000 } = {}) {
|
|
553
|
+
const url = process.env.ATLAS_ASK_URL;
|
|
554
|
+
const token = process.env.ATLAS_ASK_TOKEN;
|
|
555
|
+
if (!url || !token) {
|
|
556
|
+
onEvent({ kind: 'error', message:
|
|
557
|
+
'No model is connected on this computer. Open Atlas Remote Control, '
|
|
558
|
+
+ 'connect a model, and turn Local Mode on.' });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
const res = await fetch(url.replace('/ask', '/chat'), {
|
|
563
|
+
method: 'POST',
|
|
564
|
+
headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
|
|
565
|
+
body: JSON.stringify({ job, prompt: String(prompt), model }),
|
|
566
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
567
|
+
});
|
|
568
|
+
if (!res.ok || !res.body) {
|
|
569
|
+
onEvent({ kind: 'error', message: `The model could not answer (${res.status}).` });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
// Line-delimited JSON. A chunk can split a line in half, so hold the tail
|
|
573
|
+
// until a newline proves it complete — parsing early yields half an event.
|
|
574
|
+
const reader = res.body.getReader();
|
|
575
|
+
const dec = new TextDecoder();
|
|
576
|
+
let buf = '';
|
|
577
|
+
for (;;) {
|
|
578
|
+
const { done, value } = await reader.read();
|
|
579
|
+
if (done) break;
|
|
580
|
+
buf += dec.decode(value, { stream: true });
|
|
581
|
+
const lines = buf.split('\n');
|
|
582
|
+
buf = lines.pop() ?? '';
|
|
583
|
+
for (const line of lines) {
|
|
584
|
+
const t = line.trim();
|
|
585
|
+
if (!t) continue;
|
|
586
|
+
try { onEvent(JSON.parse(t)); } catch { /* skip a malformed line */ }
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const tail = buf.trim();
|
|
590
|
+
if (tail) { try { onEvent(JSON.parse(tail)); } catch { /* ignore */ } }
|
|
591
|
+
} catch (err) {
|
|
592
|
+
onEvent({ kind: 'error', message: String(err?.message || err) });
|
|
593
|
+
}
|
|
594
|
+
}
|