webhelm 1.0.25 → 1.0.26
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
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Browser-side upload source for the existing WebHelm owned Session.
|
|
2
|
+
// Pure string generation; JSON encoding protects filenames and selector quotes.
|
|
3
|
+
const SELECTOR_JS = "(() => ['#upload-files','#upload-media','input[name=\"upload-media\"]','input[type=\"file\"]'].find(s=>document.querySelector(s))||null)()";
|
|
4
|
+
const OPEN_JS = "(() => {const b=[...document.querySelectorAll('button')].find(b=>(b.getAttribute('aria-label')||'').trim()==='Add files and more');if(b)b.click();return !!b;})()";
|
|
5
|
+
const STATE_JS = "(() => ({names:[...document.querySelectorAll('button[aria-label^=\"Remove \"]')].map(b=>(b.getAttribute('aria-label')||'').replace(/^Remove file\\s+\\d+:\\s*/,'').replace(/^Remove\\s+/,'')),pending:[...document.querySelectorAll('[role=\"progressbar\"],[aria-busy=\"true\"],[data-state=\"loading\"]')].some(e=>e.getClientRects().length>0)}))()";
|
|
6
|
+
|
|
7
|
+
export function attachmentUploadScript(paths) {
|
|
8
|
+
if (!Array.isArray(paths) || !paths.length) return "({ready:true})";
|
|
9
|
+
return [
|
|
10
|
+
"return await (async()=>{",
|
|
11
|
+
"const files="+JSON.stringify(paths)+";",
|
|
12
|
+
"const locate=()=>js("+JSON.stringify(SELECTOR_JS)+");",
|
|
13
|
+
"let input=await locate();",
|
|
14
|
+
"if(!input){",
|
|
15
|
+
" await js("+JSON.stringify(OPEN_JS)+");",
|
|
16
|
+
" for(let i=0;i<40&&!input;i++){await sleep(150);input=await locate();}",
|
|
17
|
+
"}",
|
|
18
|
+
"if(!input)throw Error('ChatGPT file input unavailable');",
|
|
19
|
+
"const names=[];",
|
|
20
|
+
"for(const file of files){",
|
|
21
|
+
" await upload_file(input,file);",
|
|
22
|
+
" const name=file.split('/').pop();names.push(name);",
|
|
23
|
+
" const expected=names.filter(n=>n===name).length;",
|
|
24
|
+
" let ready=false;",
|
|
25
|
+
" for(let i=0;i<480;i++){",
|
|
26
|
+
" const state=await js("+JSON.stringify(STATE_JS)+");",
|
|
27
|
+
" if(!state.pending&&state.names.filter(n=>n===name).length>=expected){ready=true;break;}",
|
|
28
|
+
" await sleep(250);",
|
|
29
|
+
" }",
|
|
30
|
+
" if(!ready)throw Error('ChatGPT attachment upload not confirmed: '+name);",
|
|
31
|
+
"}",
|
|
32
|
+
"return {ready:true,names};",
|
|
33
|
+
"})()",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
@@ -2,22 +2,26 @@ export function targetUrl(projectId, threadId, message) {
|
|
|
2
2
|
if (!threadId || !message) {
|
|
3
3
|
throw new Error("thread_id and message are required");
|
|
4
4
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
// A thread identity is global; ChatGPT may redirect /c/id to a
|
|
6
|
+
// Project-scoped /g/project-id[-slug]/c/id. Use the ordinary URL.
|
|
7
|
+
return "https://chatgpt.com/c/" + encodeURIComponent(threadId) +
|
|
8
|
+
"?" + new URLSearchParams({ prompt: message });
|
|
9
9
|
}
|
|
10
10
|
export const PROBE_JS =
|
|
11
11
|
`(() => { const visible=e=>!!e&&!!e.getClientRects().length&&getComputedStyle(e).visibility!=='hidden'; const body=(document.body?.innerText||'').toLowerCase(); if(body.includes('could not load this chatgpt conversation'))return {state:'BLOCKED',error:'thread_load_failed'}; if(body.includes('log in to chatgpt')||body.includes('sign in to chatgpt'))return {state:'BLOCKED',error:'authentication_required'}; const stop=[...document.querySelectorAll('button')].some(b=>{if(!visible(b))return false;const l=(b.getAttribute('aria-label')||'').trim().toLowerCase(),t=(b.getAttribute('title')||'').trim().toLowerCase(),d=(b.getAttribute('data-testid')||'').trim().toLowerCase();return ['stop generating','stop answering','stop response','stop'].includes(l)||['stop generating','stop answering'].includes(t)||['stop-button','stop-generating-button'].includes(d)}); if(stop)return {state:'RUNNING',stop_visible:true}; const e=document.querySelector('#prompt-textarea,[contenteditable="true"][data-lexical-editor="true"],[role="textbox"][contenteditable="true"],textarea'); const editable=e?.getAttribute?.('contenteditable')==='true'; return {state:visible(e)?'READY':'NOT_READY',stop_visible:false,composer_text:e?(editable?(e.textContent||''):(e.value||e.textContent||e.innerText||'')):''}; })()`;
|
|
12
12
|
export function classifyProbe(observedUrl, expectedUrl, state) {
|
|
13
13
|
const actual = new URL(observedUrl), expected = new URL(expectedUrl);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const thread = url => {
|
|
15
|
+
const match = url.pathname.split('/c/')[1]?.split('/')[0] || null;
|
|
16
|
+
return match ? decodeURIComponent(match) : null;
|
|
17
|
+
};
|
|
18
|
+
if (actual.protocol !== "https:" || actual.host !== "chatgpt.com" ||
|
|
19
|
+
!thread(actual) || thread(actual) !== thread(expected)) return "BLOCKED";
|
|
18
20
|
if (["RUNNING", "BLOCKED"].includes(state?.state)) return state.state;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
const prompt = expected.searchParams.get("prompt") || "";
|
|
22
|
+
const normalize = text => String(text || "").replace(/\s+/g, " ").trim();
|
|
23
|
+
if (actual.search && actual.searchParams.get("prompt") !== prompt) return "NOT_READY";
|
|
24
|
+
return state?.state === "READY" && normalize(state.composer_text) === normalize(prompt)
|
|
21
25
|
? "READY"
|
|
22
26
|
: "NOT_READY";
|
|
23
27
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
import { mkdirSync, existsSync, statSync } from "node:fs";
|
|
3
|
+
import { join, resolve, isAbsolute } from "node:path";
|
|
4
|
+
import { attachmentUploadScript } from "./attachment-upload.mjs";
|
|
4
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
6
|
import { classifyProbe, PROBE_JS, targetUrl } from "./chatgpt-probe.mjs";
|
|
6
|
-
import {
|
|
7
|
+
import { SEND_ENABLED_JS } from "./lifecycle-common.mjs";
|
|
8
|
+
import { submissionScript } from "./submission-script.mjs";
|
|
7
9
|
import { DurableQueueCore } from "../durable-queue-core.mjs";
|
|
8
10
|
|
|
9
11
|
const transitions = new Set([
|
|
@@ -23,19 +25,26 @@ const chatgptQueuePolicy = {
|
|
|
23
25
|
one(db, sql, ...args) { return db.prepare(sql).get(...args) || null; },
|
|
24
26
|
rows(db) { return db.prepare("SELECT * FROM turns ORDER BY seq").all(); },
|
|
25
27
|
enqueue(db, request = {}, now) {
|
|
26
|
-
const {thread_id, message, idempotency_key, idempotencyKey, project_id=null, mergeable=false} = request;
|
|
28
|
+
const {thread_id, message, idempotency_key, idempotencyKey, project_id=null, mergeable=false, file=[]} = request;
|
|
29
|
+
if (!Array.isArray(file) || file.length > 4 ||
|
|
30
|
+
file.some(p => typeof p !== "string" || !isAbsolute(p) ||
|
|
31
|
+
!existsSync(p) || !statSync(p).isFile())) {
|
|
32
|
+
throw new TypeError("file must contain at most 4 existing absolute file paths");
|
|
33
|
+
}
|
|
34
|
+
if (file.length && mergeable) throw new TypeError("attachment turns cannot merge");
|
|
35
|
+
const filesJson = JSON.stringify(file);
|
|
27
36
|
const key=idempotency_key ?? idempotencyKey;
|
|
28
|
-
if (!thread_id || !message || !key) throw new TypeError("thread_id, message, idempotency_key required");
|
|
37
|
+
if (!thread_id || (!message && !file.length) || !key) throw new TypeError("thread_id, message, idempotency_key required");
|
|
29
38
|
if (typeof mergeable !== "boolean") throw new TypeError("mergeable must be a boolean");
|
|
30
39
|
const merged=this.one(db, "SELECT * FROM merged_requests WHERE idem_key=?", key);
|
|
31
40
|
if (merged) {
|
|
32
|
-
if (merged.thread_id!==thread_id || merged.message!==message || merged.project_id!==project_id) throw new Error("idempotency key reused with different payload");
|
|
41
|
+
if (merged.thread_id!==thread_id || merged.message!==message || merged.project_id!==project_id || filesJson!=="[]") throw new Error("idempotency key reused with different payload");
|
|
33
42
|
const row=this.one(db, "SELECT * FROM turns WHERE id=?", merged.queue_id);
|
|
34
43
|
return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
|
|
35
44
|
}
|
|
36
45
|
const same=this.one(db, "SELECT * FROM turns WHERE idem_key=?", key);
|
|
37
46
|
if (same) {
|
|
38
|
-
if (same.thread_id!==thread_id || same.message!==message || same.project_id!==project_id) throw new Error("idempotency key reused with different payload");
|
|
47
|
+
if (same.thread_id!==thread_id || same.message!==message || same.project_id!==project_id || (same.files_json||"[]")!==filesJson) throw new Error("idempotency key reused with different payload");
|
|
39
48
|
return {ok:true,status:"queued",queue_id:same.id,thread_id:same.thread_id,state:same.state};
|
|
40
49
|
}
|
|
41
50
|
const tail=mergeable ? this.one(db, "SELECT * FROM turns WHERE thread_id=? ORDER BY seq DESC LIMIT 1", thread_id) : null;
|
|
@@ -49,7 +58,7 @@ const chatgptQueuePolicy = {
|
|
|
49
58
|
return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
|
|
50
59
|
}
|
|
51
60
|
const id=randomUUID();
|
|
52
|
-
db.prepare("INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable) VALUES(?,?,?,?,?,'QUEUED'
|
|
61
|
+
db.prepare("INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable,files_json) VALUES(?,?,?,?,?,'QUEUED',?,?,?,?)").run(id,key,project_id,thread_id,message,now,now,mergeable?1:0,filesJson);
|
|
53
62
|
return {ok:true,status:"queued",queue_id:id,thread_id,state:"QUEUED"};
|
|
54
63
|
},
|
|
55
64
|
claim(db, owner, now, leaseSeconds) {
|
|
@@ -115,6 +124,9 @@ export class ChatGPTQueue extends DurableQueueCore {
|
|
|
115
124
|
db.exec(
|
|
116
125
|
`CREATE TABLE IF NOT EXISTS turns(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,idem_key TEXT NOT NULL UNIQUE,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,state TEXT NOT NULL,lease_owner TEXT,lease_until REAL,next_check_at REAL NOT NULL,created_at REAL NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,receipt TEXT,mergeable INTEGER NOT NULL DEFAULT 0); CREATE INDEX IF NOT EXISTS turns_thread_order ON turns(thread_id,seq); CREATE TABLE IF NOT EXISTS merged_requests(idem_key TEXT PRIMARY KEY,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,queue_id TEXT NOT NULL);`,
|
|
117
126
|
);
|
|
127
|
+
if (!db.prepare("PRAGMA table_info(turns)").all().some(row => row.name === "files_json")) {
|
|
128
|
+
db.exec("ALTER TABLE turns ADD COLUMN files_json TEXT NOT NULL DEFAULT '[]'");
|
|
129
|
+
}
|
|
118
130
|
super(db, chatgptQueuePolicy, now, {
|
|
119
131
|
concurrency: Math.min(4, Math.max(1, Number(concurrency) || 4)), intervalMs,
|
|
120
132
|
});
|
|
@@ -211,7 +223,7 @@ export class ChatGPTQueue extends DurableQueueCore {
|
|
|
211
223
|
let state = "NOT_READY";
|
|
212
224
|
while (Date.now() < deadline) {
|
|
213
225
|
const source =
|
|
214
|
-
`await (async()=>{ const page=await page_info(); const state=await js(${
|
|
226
|
+
`return await (async()=>{ const page=await page_info(); const state=await js(${
|
|
215
227
|
JSON.stringify(PROBE_JS)
|
|
216
228
|
}); if(state?.state==='READY' && !(await js(${
|
|
217
229
|
JSON.stringify(SEND_ENABLED_JS)
|
|
@@ -226,20 +238,12 @@ export class ChatGPTQueue extends DurableQueueCore {
|
|
|
226
238
|
async _send(item) {
|
|
227
239
|
const sid = this.sessions.get(item.id);
|
|
228
240
|
if (!sid) throw new Error("missing prepared browser session");
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
}) && Boolean((gate.composer_text||'').trim()) && gateUrl===${
|
|
236
|
-
JSON.stringify(expected)
|
|
237
|
-
} && await js(${
|
|
238
|
-
JSON.stringify(CLICK_SEND_JS)
|
|
239
|
-
})){ const until=Date.now()+30000; let disabled=false; while(Date.now()<until){ disabled=!(await js(${
|
|
240
|
-
JSON.stringify(SEND_ENABLED_JS)
|
|
241
|
-
})); if(disabled) break; await new Promise(resolve=>setTimeout(resolve,200)); } receipt=disabled?{verified_by:'send-button-disabled'}:{diagnostic:'post_submit_send_state_unverified'}; } return receipt; })()`;
|
|
242
|
-
return this._exec(sid, source);
|
|
241
|
+
const attachments = JSON.parse(item.files_json || "[]");
|
|
242
|
+
if (attachments.length) {
|
|
243
|
+
const upload = await this._exec(sid, attachmentUploadScript(attachments));
|
|
244
|
+
if (!upload?.ready) throw new Error("ChatGPT attachments not ready for Send");
|
|
245
|
+
}
|
|
246
|
+
return this._exec(sid, submissionScript(item));
|
|
243
247
|
}
|
|
244
248
|
async _finish(id) {
|
|
245
249
|
const sid = this.sessions.get(id);
|
|
@@ -293,7 +297,7 @@ export class ChatGPTQueue extends DurableQueueCore {
|
|
|
293
297
|
}
|
|
294
298
|
this.transition(item.id, owner, "CHECKING", "SENDING");
|
|
295
299
|
const receipt = await this._send(item);
|
|
296
|
-
if (
|
|
300
|
+
if (!["send-button-disabled", "user-turn"].includes(receipt?.verified_by)) {
|
|
297
301
|
this.transition(item.id, owner, "SENDING", "UNCERTAIN", {
|
|
298
302
|
receipt: receipt?.diagnostic || "missing_user_turn_receipt",
|
|
299
303
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PROBE_JS } from "./chatgpt-probe.mjs";
|
|
2
|
+
import { SEND_ENABLED_JS } from "./lifecycle-common.mjs";
|
|
3
|
+
|
|
4
|
+
/** Source executed inside a caller-owned WebHelm Session, never a separate tab. */
|
|
5
|
+
export function submissionScript(item) {
|
|
6
|
+
const prompt = item.message.slice(0, 120);
|
|
7
|
+
const selectorJS = "(() => {for(const s of ['button[aria-label=\"Send\"]','button[data-testid=\"send-button\"]','button[aria-label=\"Send prompt\"]']){const b=document.querySelector(s);if(b&&b.getClientRects().length&&!b.disabled)return s;}return null;})()";
|
|
8
|
+
const userTurnsJS = "(() => [...document.querySelectorAll('h4[data-conversation-role=\"user\"]')].filter(h=>(h.parentElement?.innerText||'').includes(" +
|
|
9
|
+
JSON.stringify(prompt) + ")).length)()";
|
|
10
|
+
return [
|
|
11
|
+
"return await (async()=>{",
|
|
12
|
+
"const gate=await js("+JSON.stringify(PROBE_JS)+");",
|
|
13
|
+
"const url=new URL((await page_info()).url);",
|
|
14
|
+
"const actualId=url.pathname.split('/c/')[1]?.split('/')[0]||'';",
|
|
15
|
+
"if(decodeURIComponent(actualId)!=="+JSON.stringify(item.thread_id)+
|
|
16
|
+
")return {diagnostic:'thread_redirect_mismatch'};",
|
|
17
|
+
"if(gate?.state!=='READY'||!(await js("+JSON.stringify(SEND_ENABLED_JS)+")))",
|
|
18
|
+
" return {diagnostic:'pre_send_gate_not_ready'};",
|
|
19
|
+
"if((gate.composer_text||'').replace(/\\s+/g,' ').trim()!=="+
|
|
20
|
+
JSON.stringify(item.message.replace(/\s+/g,' ').trim())+")",
|
|
21
|
+
|
|
22
|
+
" return {diagnostic:'pre_send_gate_not_ready'};",
|
|
23
|
+
"const selector=await js("+JSON.stringify(selectorJS)+");",
|
|
24
|
+
"if(!selector)return {diagnostic:'send_control_not_found'};",
|
|
25
|
+
"const before=(await js("+JSON.stringify(userTurnsJS)+"))||0;",
|
|
26
|
+
"await click(selector);",
|
|
27
|
+
"for(let i=0;i<140;i++){",
|
|
28
|
+
" const count=(await js("+JSON.stringify(userTurnsJS)+"))||0;",
|
|
29
|
+
" if(count>before)return {verified_by:'user-turn'};",
|
|
30
|
+
" if(!(await js("+JSON.stringify(SEND_ENABLED_JS)+")))",
|
|
31
|
+
" return {verified_by:'send-button-disabled'};",
|
|
32
|
+
" await sleep(250);",
|
|
33
|
+
"}",
|
|
34
|
+
"return {diagnostic:'post_submit_user_turn_not_confirmed_no_resend'};",
|
|
35
|
+
"})()",
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
package/runtime/cli.mjs
CHANGED
|
@@ -58,7 +58,8 @@ async function runPlatform(args) {
|
|
|
58
58
|
if (sid && auto) throw Error('--session-id and --auto-session are mutually exclusive');
|
|
59
59
|
if (platform === 'chatgpt' && action === 'new-turn' && mode === 'async') {
|
|
60
60
|
if (sid || auto) throw Error('async new-turn cannot use browser session options');
|
|
61
|
-
if (!config.thread_id || !config.message) throw Error('thread_id and message required');
|
|
61
|
+
if (!config.thread_id || (!config.message && !config.file?.length)) throw Error('thread_id and message or file required');
|
|
62
|
+
if (config.file !== undefined && (!Array.isArray(config.file) || config.file.length > 4 || config.file.some(f => typeof f !== 'string' || !path.isAbsolute(f) || !fs.existsSync(f)))) throw Error('file must contain at most 4 existing absolute paths');
|
|
62
63
|
if (typeof config.mergeable !== 'undefined' && typeof config.mergeable !== 'boolean') throw Error('mergeable must be a boolean');
|
|
63
64
|
const receipt = await request({op:'chatgpt-new-turn-enqueue', config: {...config, idempotency_key: config.idempotency_key || randomUUID(), mode}});
|
|
64
65
|
if (!receipt.ok || !receipt.queue_id || receipt.thread_id !== String(config.thread_id)) throw Error('ChatGPT queue returned an invalid acceptance receipt');
|