@tomflow/chatloop-extension 0.0.0-stage → 0.3.27
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 +172 -2
- package/bin/cli.mjs +88 -0
- package/bin/native-host.mjs +29 -0
- package/extension/acceptance.js +389 -0
- package/extension/actions/clock.js +29 -0
- package/extension/actions/common.js +137 -0
- package/extension/actions/continue.js +264 -0
- package/extension/actions/dom.js +25 -0
- package/extension/actions/handoff.js +302 -0
- package/extension/actions/reload.js +20 -0
- package/extension/actions/screenshot.js +69 -0
- package/extension/actions/text.js +25 -0
- package/extension/background.js +946 -0
- package/extension/clock.html +4 -0
- package/extension/content.js +40 -0
- package/extension/continue.html +9 -0
- package/extension/dom.html +17 -0
- package/extension/handoff.html +9 -0
- package/extension/loop/core.js +37 -0
- package/extension/loop/observer.js +440 -0
- package/extension/manifest.json +38 -0
- package/extension/popup.html +26 -0
- package/extension/popup.js +70 -0
- package/extension/reload.html +17 -0
- package/extension/screenshot.html +17 -0
- package/extension/text.html +17 -0
- package/lib/bridge.mjs +104 -0
- package/lib/mcp-tools.json +26 -0
- package/package.json +23 -4
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const CONTRACT = "chat-loop.web-environment.v1";
|
|
2
|
+
const statusNode = document.getElementById("status");
|
|
3
|
+
const detailNode = document.getElementById("detail");
|
|
4
|
+
|
|
5
|
+
function show(status, detail) {
|
|
6
|
+
statusNode.textContent = status;
|
|
7
|
+
detailNode.textContent = detail;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function requestProof(tabId) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
chrome.tabs.sendMessage(tabId, { type: "CHAT_LOOP_WEB_CHECK_V1" }, (proof) => {
|
|
13
|
+
if (chrome.runtime.lastError) {
|
|
14
|
+
resolve({ status: "EXTENSION_UNVERIFIED" });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
resolve(proof);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function check() {
|
|
23
|
+
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
24
|
+
const tab = tabs[0];
|
|
25
|
+
if (!tab || !tab.url) {
|
|
26
|
+
show("WEB_UNVERIFIED", "无法确认当前 Chrome 标签页。");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
let url;
|
|
30
|
+
try { url = new URL(tab.url); } catch {
|
|
31
|
+
show("WEB_UNVERIFIED", "当前标签页地址无效。");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (url.protocol !== "https:" || url.hostname !== "chatgpt.com") {
|
|
35
|
+
show("UNSUPPORTED_PAGE", "请在 Chrome 中打开 https://chatgpt.com 后重新检测。");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const proof = await requestProof(tab.id);
|
|
39
|
+
if (proof?.contract !== CONTRACT) {
|
|
40
|
+
show("EXTENSION_UNVERIFIED", "页面尚未连接扩展。请刷新 ChatGPT 页面后再打开此扩展。");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (proof.status === "WEB_READY" && proof.browser === "GOOGLE_CHROME") {
|
|
44
|
+
show("WEB_READY", "Chrome 网页环境已验证;这是扩展本地检测结果,尚未回传给 ChatGPT Plugin。");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
show(proof.status || "WEB_UNVERIFIED",
|
|
48
|
+
"当前浏览器环境尚未得到 Google Chrome 的有效证明。");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
void check().catch(() => show("WEB_UNVERIFIED", "浏览器检测失败,请重试。"));
|
|
52
|
+
|
|
53
|
+
// Read-only action feedback; never expose the single-use token or message text.
|
|
54
|
+
async function showActionState() {
|
|
55
|
+
const key = "chat-loop.action.run.v1";
|
|
56
|
+
const run = (await chrome.storage.local.get(key))[key];
|
|
57
|
+
const allowed = ["phase", "action", "reason", "tabId", "page", "sourceTabId", "sourcePage",
|
|
58
|
+
"userKey", "assistantKey", "newUserKey", "testSetup", "startedAt",
|
|
59
|
+
"deadlineAt", "handoffPending", "handoffPendingAt", "attemptedNewTabId"];
|
|
60
|
+
const summary = {version: chrome.runtime.getManifest().version};
|
|
61
|
+
if (run) for (const key of allowed) if (run[key] !== undefined) summary[key] = run[key];
|
|
62
|
+
document.body.dataset.actionState = JSON.stringify(summary);
|
|
63
|
+
document.getElementById("action-state").textContent = run
|
|
64
|
+
? "最近发送动作:" + run.action + " · " + run.phase + (run.reason ? " · " + run.reason : "")
|
|
65
|
+
: "尚无发送动作记录";
|
|
66
|
+
}
|
|
67
|
+
void showActionState().catch(() => {
|
|
68
|
+
document.getElementById("action-state").textContent = "最近发送动作:状态无法确认,不要重复发送";
|
|
69
|
+
});
|
|
70
|
+
chrome.storage.onChanged.addListener(() => {void showActionState();});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Chat Loop Extension · Reload</title>
|
|
6
|
+
<style>body{font:15px system-ui,sans-serif;padding:24px;max-width:620px;color:#222}
|
|
7
|
+
#status{font-weight:700}#details{white-space:pre-wrap;overflow-wrap:anywhere;color:#555}
|
|
8
|
+
body[data-result="error"] #status{color:#b00020}</style>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<h1>Chat Loop Extension · Reload</h1>
|
|
12
|
+
<p id="status" role="status">STARTING</p>
|
|
13
|
+
<p id="details">正在执行,成功后关闭此页面;失败则保留错误以供诊断。</p>
|
|
14
|
+
<script src="actions/common.js"></script>
|
|
15
|
+
<script src="actions/reload.js"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Chat Loop Extension · Screenshot</title>
|
|
6
|
+
<style>body{font:15px system-ui,sans-serif;padding:24px;max-width:620px;color:#222}
|
|
7
|
+
#status{font-weight:700}#details{white-space:pre-wrap;overflow-wrap:anywhere;color:#555}
|
|
8
|
+
body[data-result="error"] #status{color:#b00020}</style>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<h1>Chat Loop Extension · Screenshot</h1>
|
|
12
|
+
<p id="status" role="status">STARTING</p>
|
|
13
|
+
<p id="details">正在执行,成功后关闭此页面;失败则保留错误以供诊断。</p>
|
|
14
|
+
<script src="actions/common.js"></script>
|
|
15
|
+
<script src="actions/screenshot.js"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Chat Loop Extension · Text</title>
|
|
6
|
+
<style>body{font:15px system-ui,sans-serif;padding:24px;max-width:620px;color:#222}
|
|
7
|
+
#status{font-weight:700}#details{white-space:pre-wrap;overflow-wrap:anywhere;color:#555}
|
|
8
|
+
body[data-result="error"] #status{color:#b00020}</style>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<h1>Chat Loop Extension · Text</h1>
|
|
12
|
+
<p id="status" role="status">STARTING</p>
|
|
13
|
+
<p id="details">正在执行,成功后关闭此页面;失败则保留错误以供诊断。</p>
|
|
14
|
+
<script src="actions/common.js"></script>
|
|
15
|
+
<script src="actions/text.js"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/lib/bridge.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs';
|
|
3
|
+
import {homedir, tmpdir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const HOST_NAME = 'com.tomflow.chatloop.extension';
|
|
7
|
+
export const ACTIONS = ['chat_text', 'chat_dom', 'chat_screenshot', 'reload'];
|
|
8
|
+
const ID = /^[a-f0-9]{32}$/;
|
|
9
|
+
const MAX_TEXT = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_PNG = 24 * 1024 * 1024;
|
|
11
|
+
const TTL_MS = 20 * 60 * 1000;
|
|
12
|
+
export const configFile = () => join(homedir(), '.chat-loop', 'extension', 'config.json');
|
|
13
|
+
export const tempRoot = () => join(tmpdir(), 'chatloop-extension-' + (process.getuid?.() ?? process.env.USERNAME ?? 'user'));
|
|
14
|
+
export function privateDir(path) {
|
|
15
|
+
mkdirSync(path, {recursive:true, mode:0o700});
|
|
16
|
+
const st=lstatSync(path);
|
|
17
|
+
if(!st.isDirectory() || st.isSymbolicLink() || (process.getuid && st.uid!==process.getuid())
|
|
18
|
+
|| (process.platform!=='win32' && (st.mode&0o077)))
|
|
19
|
+
throw Object.assign(Error('Untrusted extension state directory'),{code:'UNTRUSTED_STATE'});
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
22
|
+
export function requestDir(id) {
|
|
23
|
+
if(typeof id!=='string'||!ID.test(id))throw Object.assign(Error('Invalid request ID'),{code:'INVALID_REQUEST_ID'});
|
|
24
|
+
return join(tempRoot(),id);
|
|
25
|
+
}
|
|
26
|
+
export function prepare(action) {
|
|
27
|
+
if(!ACTIONS.includes(action))throw Object.assign(Error('Unknown extension action'),{code:'UNKNOWN_ACTION'});
|
|
28
|
+
privateDir(tempRoot());
|
|
29
|
+
cleanup();
|
|
30
|
+
const id=randomUUID().replaceAll('-',''), nonce=randomUUID().replaceAll('-',''), at=Date.now();
|
|
31
|
+
const dir=privateDir(requestDir(id));
|
|
32
|
+
writeFileSync(join(dir,'request.json'), JSON.stringify({id,nonce,action,at,expiresAt:at+TTL_MS}),
|
|
33
|
+
{flag:'wx',mode:0o600});
|
|
34
|
+
return {id,nonce,action,dir};
|
|
35
|
+
}
|
|
36
|
+
export function readRequest(id) {
|
|
37
|
+
const dir=requestDir(id);
|
|
38
|
+
if(!existsSync(dir))throw Object.assign(Error('No request'),{code:'NO_REQUEST'});
|
|
39
|
+
privateDir(dir);
|
|
40
|
+
const p=join(dir,'request.json'), st=lstatSync(p);
|
|
41
|
+
if(!st.isFile()||st.isSymbolicLink() || (process.getuid && st.uid!==process.getuid()))
|
|
42
|
+
throw Object.assign(Error('Invalid request record'),{code:'UNTRUSTED_REQUEST'});
|
|
43
|
+
const r=JSON.parse(readFileSync(p,'utf8'));
|
|
44
|
+
if(r.id!==id||!ACTIONS.includes(r.action)||Date.now()>r.expiresAt)
|
|
45
|
+
throw Object.assign(Error('Request expired or changed'),{code:'REQUEST_EXPIRED'});
|
|
46
|
+
return r;
|
|
47
|
+
}
|
|
48
|
+
export function finish({id,nonce,action,status,payload='',code=null}) {
|
|
49
|
+
const r=readRequest(id);
|
|
50
|
+
if(nonce!==r.nonce||action!==r.action)throw Object.assign(Error('Request mismatch'),{code:'REQUEST_MISMATCH'});
|
|
51
|
+
if(status!=='OK'&&status!=='FAILED')throw Object.assign(Error('Invalid status'),{code:'INVALID_RESULT'});
|
|
52
|
+
const dir=requestDir(id);
|
|
53
|
+
const resultPath=join(dir,'result.json');
|
|
54
|
+
if(existsSync(resultPath))throw Object.assign(Error('Duplicate result'),{code:'ALREADY_FINISHED'});
|
|
55
|
+
let bytes=0,mimeType=null,filename=null;
|
|
56
|
+
if(status==='OK'){
|
|
57
|
+
if(typeof payload!=='string')throw Object.assign(Error('Invalid payload'),{code:'INVALID_PAYLOAD'});
|
|
58
|
+
let buf;
|
|
59
|
+
if(action==='chat_screenshot'){
|
|
60
|
+
if(!/^[A-Za-z0-9+/]*={0,2}$/.test(payload))throw Object.assign(Error('Invalid PNG encoding'),{code:'INVALID_PNG'});
|
|
61
|
+
buf=Buffer.from(payload,'base64');
|
|
62
|
+
if(buf.subarray(0,8).toString('hex')!=='89504e470d0a1a0a')throw Object.assign(Error('Invalid PNG signature'),{code:'INVALID_PNG'});
|
|
63
|
+
mimeType='image/png';filename='result.png';
|
|
64
|
+
if(buf.length>MAX_PNG)throw Object.assign(Error('PNG too large'),{code:'RESULT_TOO_LARGE'});
|
|
65
|
+
}else{
|
|
66
|
+
buf=Buffer.from(payload,'utf8');
|
|
67
|
+
if(buf.length>MAX_TEXT)throw Object.assign(Error('Text too large'),{code:'RESULT_TOO_LARGE'});
|
|
68
|
+
mimeType=action==='chat_dom'?'text/html':'text/plain';
|
|
69
|
+
filename=action==='reload'?null:action==='chat_dom'?'result.html':'result.txt';
|
|
70
|
+
}
|
|
71
|
+
bytes=buf.length;
|
|
72
|
+
if(filename)writeFileSync(join(dir,filename),buf,{flag:'wx',mode:0o600});
|
|
73
|
+
}
|
|
74
|
+
const rec={id,action,status,code:status==='FAILED'?(String(code||'ACTION_FAILED').slice(0,120)):null,
|
|
75
|
+
path:filename?join(dir,filename):null,bytes,mimeType,at:Date.now(),
|
|
76
|
+
verified:action!=='reload'};
|
|
77
|
+
writeFileSync(resultPath+'.tmp',JSON.stringify(rec),{flag:'wx',mode:0o600});
|
|
78
|
+
renameSync(resultPath+'.tmp',resultPath);
|
|
79
|
+
return rec;
|
|
80
|
+
}
|
|
81
|
+
export function readResult(id) {
|
|
82
|
+
const p=join(requestDir(id),'result.json');
|
|
83
|
+
if(!existsSync(p))return null;
|
|
84
|
+
privateDir(requestDir(id));
|
|
85
|
+
return JSON.parse(readFileSync(p,'utf8'));
|
|
86
|
+
}
|
|
87
|
+
export function cleanup(){
|
|
88
|
+
const root=tempRoot();
|
|
89
|
+
if(!existsSync(root))return;
|
|
90
|
+
privateDir(root);
|
|
91
|
+
for(const f of readdirSync(root)){
|
|
92
|
+
if(!ID.test(f))continue;
|
|
93
|
+
const p=join(root,f);
|
|
94
|
+
try{
|
|
95
|
+
const st=lstatSync(p);
|
|
96
|
+
if(st.isDirectory()&&!st.isSymbolicLink()&&Date.now()-st.mtimeMs>TTL_MS)
|
|
97
|
+
rmSync(p,{recursive:true,force:true});
|
|
98
|
+
}catch{}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function validateExtensionId(id) {
|
|
102
|
+
if(!/^[a-p]{32}$/.test(id||''))throw Object.assign(Error('Invalid Chrome extension ID'),{code:'INVALID_EXTENSION_ID'});
|
|
103
|
+
return id;
|
|
104
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "chat_text",
|
|
4
|
+
"description": "Read the text of the single Chat currently owned by Chat Loop Extension. Only when the Extension has an authenticated WAITING_REPLY Owner; returns temp artifact and text, not the clipboard.",
|
|
5
|
+
"inputSchema": {"type":"object","properties":{},"additionalProperties":false},
|
|
6
|
+
"annotations": {"readOnlyHint":true}
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"name": "chat_dom",
|
|
10
|
+
"description": "Read the DOM HTML of the single Chat currently owned by Chat Loop Extension. Requires authenticated Owner; writes a private OS-temp artifact.",
|
|
11
|
+
"inputSchema": {"type":"object","properties":{},"additionalProperties":false},
|
|
12
|
+
"annotations": {"readOnlyHint":true}
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "chat_screenshot",
|
|
16
|
+
"description": "Capture a PNG of the single Chat currently owned by Chat Loop Extension. The tool returns actual image content; this action activates the Chat tab.",
|
|
17
|
+
"inputSchema": {"type":"object","properties":{},"additionalProperties":false},
|
|
18
|
+
"annotations": {"readOnlyHint":false}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "reload",
|
|
22
|
+
"description": "Request a reload of Chat Loop Extension only, not any other extension. Due to self-reload, receipt can only be ACCEPTED_UNVERIFIED.",
|
|
23
|
+
"inputSchema": {"type":"object","properties":{},"additionalProperties":false},
|
|
24
|
+
"annotations": {"readOnlyHint":false}
|
|
25
|
+
}
|
|
26
|
+
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomflow/chatloop-extension",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.3.27",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Chat Loop Extension package: six formal Action pages and its own native CLI bridge",
|
|
6
|
+
"bin": {
|
|
7
|
+
"chatloop-extension": "./bin/cli.mjs",
|
|
8
|
+
"chat-loop-browser": "./bin/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"extension/",
|
|
18
|
+
"bin/",
|
|
19
|
+
"lib/",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test ./test/*.test.mjs"
|
|
24
|
+
}
|
|
25
|
+
}
|