nothumanallowed 13.5.151 → 13.5.153
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/google-auth.mjs +2 -1
- package/src/commands/ui.mjs +33 -0
- package/src/constants.mjs +1 -1
- package/src/services/google-oauth.mjs +88 -20
- package/src/services/web-ui.mjs +80 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "13.5.
|
|
3
|
+
"version": "13.5.153",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -7,12 +7,13 @@ import { fail, info } from '../ui.mjs';
|
|
|
7
7
|
export async function cmdGoogle(args) {
|
|
8
8
|
const sub = args[0] || 'auth';
|
|
9
9
|
const config = loadConfig();
|
|
10
|
+
const manual = args.includes('--manual') || args.includes('--headless') || args.includes('--no-browser');
|
|
10
11
|
|
|
11
12
|
switch (sub) {
|
|
12
13
|
case 'auth':
|
|
13
14
|
case 'login':
|
|
14
15
|
case 'connect':
|
|
15
|
-
return runAuthFlow(config);
|
|
16
|
+
return runAuthFlow(config, manual);
|
|
16
17
|
|
|
17
18
|
case 'status':
|
|
18
19
|
return showStatus();
|
package/src/commands/ui.mjs
CHANGED
|
@@ -4652,6 +4652,39 @@ ${completedHeadings ? `## SECTIONS ALREADY WRITTEN (headings only):\n${completed
|
|
|
4652
4652
|
return;
|
|
4653
4653
|
}
|
|
4654
4654
|
|
|
4655
|
+
// POST /api/studio/webcraft/stream { system, user, max_tokens } → SSE token stream
|
|
4656
|
+
// Used by WebCraft generation for live typewriter effect per file
|
|
4657
|
+
if (pathname === '/api/studio/webcraft/stream' && method === 'POST') {
|
|
4658
|
+
const body = await parseBody(req, 131072);
|
|
4659
|
+
if (!body.system || !body.user) {
|
|
4660
|
+
sendJSON(res, 400, { error: 'system and user required' });
|
|
4661
|
+
logRequest(method, pathname, 400, Date.now() - start);
|
|
4662
|
+
return;
|
|
4663
|
+
}
|
|
4664
|
+
res.writeHead(200, {
|
|
4665
|
+
'Content-Type': 'text/event-stream',
|
|
4666
|
+
'Cache-Control': 'no-cache',
|
|
4667
|
+
'Connection': 'keep-alive',
|
|
4668
|
+
'Access-Control-Allow-Origin': '*',
|
|
4669
|
+
});
|
|
4670
|
+
let fullText = '';
|
|
4671
|
+
let tokIn = 0, tokOut = 0;
|
|
4672
|
+
try {
|
|
4673
|
+
await callLLMStream(config, body.system, body.user, (token) => {
|
|
4674
|
+
fullText += token;
|
|
4675
|
+
tokOut++;
|
|
4676
|
+
res.write('data: ' + JSON.stringify({ type: 'token', token }) + '\n\n');
|
|
4677
|
+
}, { max_tokens: body.max_tokens || 16384, temperature: 0.3 });
|
|
4678
|
+
tokIn = Math.round((body.system.length + body.user.length) / 4);
|
|
4679
|
+
res.write('data: ' + JSON.stringify({ type: 'done', text: fullText, usage: { prompt_tokens: tokIn, completion_tokens: tokOut } }) + '\n\n');
|
|
4680
|
+
} catch (e) {
|
|
4681
|
+
res.write('data: ' + JSON.stringify({ type: 'error', message: e.message }) + '\n\n');
|
|
4682
|
+
}
|
|
4683
|
+
res.end();
|
|
4684
|
+
logRequest(method, pathname, 200, Date.now() - start);
|
|
4685
|
+
return;
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4655
4688
|
// ── WebCraft Projects — list and delete saved projects ──────────────────
|
|
4656
4689
|
// GET /api/studio/webcraft/projects → { projects: [{name, description, fileCount, createdAt, dir}] }
|
|
4657
4690
|
// DELETE /api/studio/webcraft/projects/:name → { ok }
|
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '13.5.
|
|
8
|
+
export const VERSION = '13.5.153';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Google OAuth 2.0 with PKCE — browser-based consent flow.
|
|
3
|
-
* Runs ephemeral local HTTP server for callback.
|
|
4
|
-
* Zero dependencies — uses Node.js native http + crypto.
|
|
3
|
+
* Runs ephemeral local HTTP server for callback, or manual code-paste for headless/VM.
|
|
4
|
+
* Zero dependencies — uses Node.js native http + crypto + readline.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import http from 'http';
|
|
8
8
|
import crypto from 'crypto';
|
|
9
|
+
import readline from 'readline';
|
|
9
10
|
import { execSync } from 'child_process';
|
|
10
11
|
import os from 'os';
|
|
11
12
|
import { saveTokens, loadTokens, deleteTokens } from './token-store.mjs';
|
|
@@ -40,19 +41,71 @@ function generatePKCE() {
|
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Open URL in user's default browser.
|
|
44
|
+
* Returns true if browser opened successfully, false otherwise.
|
|
43
45
|
*/
|
|
44
46
|
function openBrowser(url) {
|
|
45
47
|
const platform = os.platform();
|
|
46
48
|
try {
|
|
47
|
-
if (platform === 'darwin') execSync(`open "${url}"
|
|
48
|
-
else if (platform === 'win32') execSync(`start "" "${url}"
|
|
49
|
-
else execSync(`xdg-open "${url}"
|
|
49
|
+
if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
50
|
+
else if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
51
|
+
else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
52
|
+
return true;
|
|
50
53
|
} catch {
|
|
51
|
-
|
|
52
|
-
info(`Open this URL manually:\n\n ${url}\n`);
|
|
54
|
+
return false;
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Manual mode: print the auth URL and ask the user to paste back
|
|
60
|
+
* the full redirect URL (http://127.0.0.1:PORT/callback?code=...&state=...)
|
|
61
|
+
* that appears in their browser's address bar after Google login.
|
|
62
|
+
* Works on any headless/VM/SSH setup — no local server needed.
|
|
63
|
+
*/
|
|
64
|
+
function waitForManualCode(authUrl, state) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
console.log('\n\x1b[1;33m MANUAL AUTH MODE\x1b[0m');
|
|
67
|
+
console.log('\x1b[0;90m ─────────────────────────────────────────────────────\x1b[0m');
|
|
68
|
+
console.log(' 1. Open this URL on any device with a browser (phone, PC...):');
|
|
69
|
+
console.log('\n\x1b[0;36m ' + authUrl + '\x1b[0m\n');
|
|
70
|
+
console.log(' 2. Log in with Google and grant permissions.');
|
|
71
|
+
console.log(' 3. The browser will try to open a page that fails to load.');
|
|
72
|
+
console.log(' That is expected. Copy the full URL from the address bar.');
|
|
73
|
+
console.log(' It looks like: \x1b[0;32mhttp://127.0.0.1:19847/callback?code=4/0A...&state=...\x1b[0m');
|
|
74
|
+
console.log('\x1b[0;90m ─────────────────────────────────────────────────────\x1b[0m\n');
|
|
75
|
+
|
|
76
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
77
|
+
rl.question(' Paste the full redirect URL here: ', (answer) => {
|
|
78
|
+
rl.close();
|
|
79
|
+
const trimmed = answer.trim();
|
|
80
|
+
try {
|
|
81
|
+
// Accept both the full URL and just the code= value
|
|
82
|
+
let code, returnedState;
|
|
83
|
+
if (trimmed.startsWith('http')) {
|
|
84
|
+
const parsed = new URL(trimmed);
|
|
85
|
+
code = parsed.searchParams.get('code');
|
|
86
|
+
returnedState = parsed.searchParams.get('state');
|
|
87
|
+
} else {
|
|
88
|
+
// Maybe they pasted just the code directly
|
|
89
|
+
code = trimmed;
|
|
90
|
+
returnedState = state; // trust them
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!code) {
|
|
94
|
+
reject(new Error('No authorization code found in the URL you pasted.'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (returnedState && returnedState !== state) {
|
|
98
|
+
reject(new Error('State mismatch — the URL does not match this auth session. Try again.'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
resolve(code);
|
|
102
|
+
} catch {
|
|
103
|
+
reject(new Error('Could not parse the URL. Make sure you copied the full address bar URL.'));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
56
109
|
/**
|
|
57
110
|
* Start ephemeral HTTP server and wait for OAuth callback.
|
|
58
111
|
* @returns {Promise<{code: string, port: number}>}
|
|
@@ -156,8 +209,9 @@ async function getUserEmail(accessToken) {
|
|
|
156
209
|
/**
|
|
157
210
|
* Run the full OAuth consent flow.
|
|
158
211
|
* @param {object} config — NHA config
|
|
212
|
+
* @param {boolean} manual — force manual code-paste mode (for VMs/headless)
|
|
159
213
|
*/
|
|
160
|
-
export async function runAuthFlow(config) {
|
|
214
|
+
export async function runAuthFlow(config, manual = false) {
|
|
161
215
|
const clientId = config.google?.clientId || DEFAULT_CLIENT_ID;
|
|
162
216
|
const clientSecret = config.google?.clientSecret || '';
|
|
163
217
|
|
|
@@ -174,8 +228,8 @@ export async function runAuthFlow(config) {
|
|
|
174
228
|
return false;
|
|
175
229
|
}
|
|
176
230
|
|
|
177
|
-
// Find available port
|
|
178
|
-
let port = 0;
|
|
231
|
+
// Find available port (used for redirect_uri even in manual mode)
|
|
232
|
+
let port = CALLBACK_PORTS[0];
|
|
179
233
|
for (const p of CALLBACK_PORTS) {
|
|
180
234
|
try {
|
|
181
235
|
const srv = http.createServer();
|
|
@@ -187,10 +241,6 @@ export async function runAuthFlow(config) {
|
|
|
187
241
|
break;
|
|
188
242
|
} catch { continue; }
|
|
189
243
|
}
|
|
190
|
-
if (!port) {
|
|
191
|
-
fail('No available port for OAuth callback (tried 19847-19851)');
|
|
192
|
-
return false;
|
|
193
|
-
}
|
|
194
244
|
|
|
195
245
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
196
246
|
const { verifier, challenge } = generatePKCE();
|
|
@@ -207,14 +257,32 @@ export async function runAuthFlow(config) {
|
|
|
207
257
|
authUrl.searchParams.set('access_type', 'offline');
|
|
208
258
|
authUrl.searchParams.set('prompt', 'consent');
|
|
209
259
|
|
|
210
|
-
|
|
211
|
-
openBrowser(authUrl.toString());
|
|
212
|
-
info('Waiting for authorization (5 min timeout)...\n');
|
|
260
|
+
const authUrlStr = authUrl.toString();
|
|
213
261
|
|
|
214
262
|
try {
|
|
215
|
-
|
|
216
|
-
|
|
263
|
+
let code;
|
|
264
|
+
|
|
265
|
+
if (manual) {
|
|
266
|
+
// Explicit manual mode
|
|
267
|
+
code = await waitForManualCode(authUrlStr, state);
|
|
268
|
+
} else {
|
|
269
|
+
// Try to open browser — if it fails, auto-switch to manual mode
|
|
270
|
+
info('Opening browser for Google authorization...');
|
|
271
|
+
const browserOpened = openBrowser(authUrlStr);
|
|
272
|
+
|
|
273
|
+
if (!browserOpened) {
|
|
274
|
+
// Headless/VM detected — auto-switch to manual mode
|
|
275
|
+
warn('No browser found. Switching to manual mode...');
|
|
276
|
+
code = await waitForManualCode(authUrlStr, state);
|
|
277
|
+
} else {
|
|
278
|
+
// Browser opened — wait for local callback
|
|
279
|
+
info('Waiting for authorization (5 min timeout)...\n');
|
|
280
|
+
const result = await waitForCallback(state, port);
|
|
281
|
+
code = result.code;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
217
284
|
|
|
285
|
+
info('Authorization code received. Exchanging for tokens...');
|
|
218
286
|
const tokenData = await exchangeCode(code, verifier, clientId, clientSecret, redirectUri);
|
|
219
287
|
const email = await getUserEmail(tokenData.access_token);
|
|
220
288
|
|
|
@@ -228,7 +296,7 @@ export async function runAuthFlow(config) {
|
|
|
228
296
|
|
|
229
297
|
saveTokens(tokens);
|
|
230
298
|
ok(`Google account connected: ${email || 'unknown'}`);
|
|
231
|
-
ok('Gmail + Calendar access granted.');
|
|
299
|
+
ok('Gmail + Calendar + Drive access granted.');
|
|
232
300
|
info('Run "nha plan" to generate your first daily plan.');
|
|
233
301
|
return true;
|
|
234
302
|
} catch (err) {
|
package/src/services/web-ui.mjs
CHANGED
|
@@ -8864,19 +8864,20 @@ async function wcGenerate() {
|
|
|
8864
8864
|
}
|
|
8865
8865
|
|
|
8866
8866
|
// Helper: generate one file (with two-pass split for large CSS files)
|
|
8867
|
-
|
|
8867
|
+
// onLiveUpdate(partialContent) is called on each token for live display
|
|
8868
|
+
async function wcGenOneFile(fp, signal, onLiveUpdate) {
|
|
8868
8869
|
var _nl2 = String.fromCharCode(10);
|
|
8869
8870
|
var splitPrompts = WC_CSS_SPLIT[fp.name];
|
|
8870
8871
|
if (splitPrompts) {
|
|
8871
|
-
// Two-pass generation:
|
|
8872
|
-
var part1 = await wcCallLLM(sysPreamble, splitPrompts[0] + _nl2 + _nl2 + 'File: ' + fp.name, signal, fp.lang, 8192);
|
|
8872
|
+
// Two-pass generation: streaming on first pass only
|
|
8873
|
+
var part1 = await wcCallLLM(sysPreamble, splitPrompts[0] + _nl2 + _nl2 + 'File: ' + fp.name, signal, fp.lang, 8192, onLiveUpdate);
|
|
8873
8874
|
part1 = wcStripFences(part1);
|
|
8874
8875
|
if (signal && signal.aborted) return part1;
|
|
8875
|
-
var part2 = await wcCallLLM(sysPreamble, splitPrompts[1] + _nl2 + _nl2 + 'File: ' + fp.name, signal, fp.lang, 8192);
|
|
8876
|
+
var part2 = await wcCallLLM(sysPreamble, splitPrompts[1] + _nl2 + _nl2 + 'File: ' + fp.name, signal, fp.lang, 8192, function(p2) { if (onLiveUpdate) onLiveUpdate(part1 + _nl2 + _nl2 + p2); });
|
|
8876
8877
|
part2 = wcStripFences(part2);
|
|
8877
8878
|
return part1 + _nl2 + _nl2 + part2;
|
|
8878
8879
|
}
|
|
8879
|
-
var content = await wcCallLLM(sysPreamble, fp.prompt + _nl2 + _nl2 + 'File to generate: ' + fp.name, signal, fp.lang);
|
|
8880
|
+
var content = await wcCallLLM(sysPreamble, fp.prompt + _nl2 + _nl2 + 'File to generate: ' + fp.name, signal, fp.lang, undefined, onLiveUpdate);
|
|
8880
8881
|
return wcStripFences(content);
|
|
8881
8882
|
}
|
|
8882
8883
|
|
|
@@ -8889,6 +8890,23 @@ async function wcGenerate() {
|
|
|
8889
8890
|
wcState.activeFile = 0;
|
|
8890
8891
|
renderWebCraft(document.getElementById('content'));
|
|
8891
8892
|
|
|
8893
|
+
// Live render throttle — update UI at most every 120ms per file during streaming
|
|
8894
|
+
var _wcLiveRenderTimers = {};
|
|
8895
|
+
function wcLiveUpdateFile(fpName, fpLang, partialContent) {
|
|
8896
|
+
for (var li = 0; li < wcState.generatedFiles.length; li++) {
|
|
8897
|
+
if (wcState.generatedFiles[li].name === fpName) {
|
|
8898
|
+
wcState.generatedFiles[li].content = partialContent;
|
|
8899
|
+
wcState.generatedFiles[li]._pending = false;
|
|
8900
|
+
break;
|
|
8901
|
+
}
|
|
8902
|
+
}
|
|
8903
|
+
if (_wcLiveRenderTimers[fpName]) return; // throttle
|
|
8904
|
+
_wcLiveRenderTimers[fpName] = setTimeout(function() {
|
|
8905
|
+
delete _wcLiveRenderTimers[fpName];
|
|
8906
|
+
renderWebCraft(document.getElementById('content'));
|
|
8907
|
+
}, 120);
|
|
8908
|
+
}
|
|
8909
|
+
|
|
8892
8910
|
// Generate in parallel batches of 4 — each call is independent/fresh to Liara
|
|
8893
8911
|
var BATCH = 4;
|
|
8894
8912
|
var doneCount = 0;
|
|
@@ -8897,7 +8915,8 @@ async function wcGenerate() {
|
|
|
8897
8915
|
var batch = filePlan.slice(bi, bi + BATCH);
|
|
8898
8916
|
wcUpdateGenOverlay(doneCount, filePlan.length, batch.map(function(f){ return f.name; }).join(', '));
|
|
8899
8917
|
var results = await Promise.allSettled(batch.map(function(fp) {
|
|
8900
|
-
|
|
8918
|
+
var liveCallback = function(partial) { wcLiveUpdateFile(fp.name, fp.lang, partial); };
|
|
8919
|
+
return wcGenOneFile(fp, _wcGenAbortCtrl ? _wcGenAbortCtrl.signal : null, liveCallback).then(function(c){ return { fp: fp, content: c }; });
|
|
8901
8920
|
}));
|
|
8902
8921
|
results.forEach(function(r) {
|
|
8903
8922
|
if (r.status === 'fulfilled') {
|
|
@@ -9120,7 +9139,57 @@ function wcIsTruncated(content, lang) {
|
|
|
9120
9139
|
return false;
|
|
9121
9140
|
}
|
|
9122
9141
|
|
|
9123
|
-
async function wcCallLLMRaw(sys, user, signal, maxTok) {
|
|
9142
|
+
async function wcCallLLMRaw(sys, user, signal, maxTok, onToken) {
|
|
9143
|
+
// Streaming path: use SSE endpoint so tokens appear live in the file editor
|
|
9144
|
+
if (onToken) {
|
|
9145
|
+
var streamOpts = {
|
|
9146
|
+
method: 'POST',
|
|
9147
|
+
headers: {'Content-Type':'application/json'},
|
|
9148
|
+
body: JSON.stringify({system: sys, user: user, max_tokens: maxTok || 16384})
|
|
9149
|
+
};
|
|
9150
|
+
if (signal) streamOpts.signal = signal;
|
|
9151
|
+
for (var sa = 0; sa < 3; sa++) {
|
|
9152
|
+
if (signal && signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
9153
|
+
var sr = await fetch(API + '/api/studio/webcraft/stream', streamOpts);
|
|
9154
|
+
if (!sr.ok) {
|
|
9155
|
+
if (sr.status < 500 || sa === 2) throw new Error('LLM stream error ' + sr.status);
|
|
9156
|
+
await new Promise(function(resolve) { setTimeout(resolve, 2000); });
|
|
9157
|
+
continue;
|
|
9158
|
+
}
|
|
9159
|
+
var sreader = sr.body.getReader();
|
|
9160
|
+
var sdec = new TextDecoder();
|
|
9161
|
+
var sbuf = '';
|
|
9162
|
+
var fullText = '';
|
|
9163
|
+
while (true) {
|
|
9164
|
+
var sres = await sreader.read();
|
|
9165
|
+
if (sres.done) break;
|
|
9166
|
+
sbuf += sdec.decode(sres.value, {stream: true});
|
|
9167
|
+
var sparts = sbuf.split(String.fromCharCode(10) + String.fromCharCode(10));
|
|
9168
|
+
sbuf = sparts.pop();
|
|
9169
|
+
for (var si = 0; si < sparts.length; si++) {
|
|
9170
|
+
var sline = sparts[si].replace(/^data: /, '').trim();
|
|
9171
|
+
if (!sline) continue;
|
|
9172
|
+
try {
|
|
9173
|
+
var sev = JSON.parse(sline);
|
|
9174
|
+
if (sev.type === 'token') {
|
|
9175
|
+
fullText += sev.token;
|
|
9176
|
+
onToken(fullText);
|
|
9177
|
+
} else if (sev.type === 'done') {
|
|
9178
|
+
if (sev.usage) {
|
|
9179
|
+
_wcTokIn += (sev.usage.prompt_tokens || 0);
|
|
9180
|
+
_wcTokOut += (sev.usage.completion_tokens || 0);
|
|
9181
|
+
}
|
|
9182
|
+
} else if (sev.type === 'error') {
|
|
9183
|
+
throw new Error(sev.message || 'Stream error');
|
|
9184
|
+
}
|
|
9185
|
+
} catch(_) {}
|
|
9186
|
+
}
|
|
9187
|
+
}
|
|
9188
|
+
return fullText;
|
|
9189
|
+
}
|
|
9190
|
+
}
|
|
9191
|
+
|
|
9192
|
+
// Non-streaming fallback (used by repair and continuation passes)
|
|
9124
9193
|
var fetchOpts = {
|
|
9125
9194
|
method: 'POST',
|
|
9126
9195
|
headers: {'Content-Type':'application/json'},
|
|
@@ -9132,12 +9201,10 @@ async function wcCallLLMRaw(sys, user, signal, maxTok) {
|
|
|
9132
9201
|
var r = await fetch(API + '/api/studio/webcraft', fetchOpts);
|
|
9133
9202
|
if (r.ok) {
|
|
9134
9203
|
var d = await r.json();
|
|
9135
|
-
// Accumulate token counts if the server returns usage data
|
|
9136
9204
|
if (d && d.usage) {
|
|
9137
9205
|
_wcTokIn += (d.usage.prompt_tokens || d.usage.input_tokens || 0);
|
|
9138
9206
|
_wcTokOut += (d.usage.completion_tokens || d.usage.output_tokens || 0);
|
|
9139
9207
|
} else if (d && d.text) {
|
|
9140
|
-
// Estimate from char count (4 chars ≈ 1 token)
|
|
9141
9208
|
_wcTokIn += Math.round((sys.length + user.length) / 4);
|
|
9142
9209
|
_wcTokOut += Math.round((d.text || '').length / 4);
|
|
9143
9210
|
}
|
|
@@ -9153,9 +9220,9 @@ async function wcCallLLMRaw(sys, user, signal, maxTok) {
|
|
|
9153
9220
|
}
|
|
9154
9221
|
}
|
|
9155
9222
|
|
|
9156
|
-
async function wcCallLLM(sys, user, signal, lang, maxTok) {
|
|
9157
|
-
var content = await wcCallLLMRaw(sys, user, signal, maxTok);
|
|
9158
|
-
// Continuation loop: if response is truncated, ask model to continue
|
|
9223
|
+
async function wcCallLLM(sys, user, signal, lang, maxTok, onToken) {
|
|
9224
|
+
var content = await wcCallLLMRaw(sys, user, signal, maxTok, onToken);
|
|
9225
|
+
// Continuation loop: if response is truncated, ask model to continue (no streaming for continuations)
|
|
9159
9226
|
var maxContinuations = 2;
|
|
9160
9227
|
for (var ci = 0; ci < maxContinuations; ci++) {
|
|
9161
9228
|
if (!wcIsTruncated(content, lang || 'text')) break;
|
|
@@ -9166,6 +9233,7 @@ async function wcCallLLM(sys, user, signal, lang, maxTok) {
|
|
|
9166
9233
|
var continuation = await wcCallLLMRaw(sys, continuePrompt, signal, maxTok);
|
|
9167
9234
|
if (!continuation || continuation.trim().length < 5) break;
|
|
9168
9235
|
content = content + String.fromCharCode(10) + continuation;
|
|
9236
|
+
if (onToken) onToken(content);
|
|
9169
9237
|
}
|
|
9170
9238
|
return content;
|
|
9171
9239
|
}
|