@tomflow/chatloop-extension 0.0.0-stage → 0.3.28
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,4 @@
|
|
|
1
|
+
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>Chat Loop · Controlled Clock Test</title>
|
|
2
|
+
<style>body{font:15px system-ui,sans-serif;margin:24px;max-width:640px}#status{font-weight:bold}#details{white-space:pre-wrap}</style></head><body>
|
|
3
|
+
<h1>Chat Loop · Acceptance Clock</h1><p id="status">VERIFYING</p><p id="details">只允许扩展拥有的验收会话:在 RUNNING 时跨越受控边界,或真实已运行20分钟后缩短墙钟测试;不直接发送消息。</p>
|
|
4
|
+
<script src="actions/clock.js"></script></body></html>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Read-only ChatGPT page agent. Reinjection after extension reload is idempotent.
|
|
2
|
+
(() => {
|
|
3
|
+
if (globalThis.__CHAT_LOOP_EYE_V1__) return;
|
|
4
|
+
globalThis.__CHAT_LOOP_EYE_V1__ = true;
|
|
5
|
+
const CONTRACT = "chat-loop.web-environment.v1";
|
|
6
|
+
const READ_CONTRACT = "chat-loop.eye.v1";
|
|
7
|
+
const onChatGPT = () => location.protocol === "https:" && location.hostname === "chatgpt.com";
|
|
8
|
+
|
|
9
|
+
function inspectChatPage() {
|
|
10
|
+
const brands = navigator.userAgentData?.brands;
|
|
11
|
+
const chromeBrowser = Array.isArray(brands)
|
|
12
|
+
&& brands.some(entry => entry?.brand === "Google Chrome");
|
|
13
|
+
return {
|
|
14
|
+
contract: CONTRACT,
|
|
15
|
+
status: !onChatGPT() ? "UNSUPPORTED_PAGE" : !chromeBrowser
|
|
16
|
+
? "BROWSER_UNVERIFIED" : "WEB_READY",
|
|
17
|
+
pageOrigin: onChatGPT() ? "https://chatgpt.com" : null,
|
|
18
|
+
browser: chromeBrowser ? "GOOGLE_CHROME" : "UNVERIFIED",
|
|
19
|
+
extensionVersion: chrome.runtime.getManifest().version
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
24
|
+
if (message?.type === "CHAT_LOOP_WEB_CHECK_V1") {
|
|
25
|
+
sendResponse(inspectChatPage());
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (message?.type !== "CHAT_LOOP_EYE_READ_V1") return false;
|
|
29
|
+
if (!onChatGPT() || !["dom", "text"].includes(message.mode)) {
|
|
30
|
+
sendResponse({contract: READ_CONTRACT, status: "UNSUPPORTED"});
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const value = message.mode === "dom"
|
|
34
|
+
? document.documentElement.outerHTML
|
|
35
|
+
: (document.querySelector("main")?.innerText ?? document.body?.innerText ?? "");
|
|
36
|
+
sendResponse({contract: READ_CONTRACT, status: "OK",
|
|
37
|
+
mode: message.mode, url: location.href, value, length: value.length});
|
|
38
|
+
return false;
|
|
39
|
+
});
|
|
40
|
+
})();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>Chat Loop Extension · Continue Action</title>
|
|
3
|
+
<style>body{font:15px system-ui,sans-serif;padding:24px;max-width:620px;color:#222}#status{font-weight:bold}#details{white-space:pre-wrap;overflow-wrap:anywhere}body[data-result="error"] #status{color:#b00020}</style>
|
|
4
|
+
</head><body>
|
|
5
|
+
<h1>Chat Loop · Continue</h1>
|
|
6
|
+
<p id="status" role="status">STARTING</p>
|
|
7
|
+
<p id="details">仅执行一次已授权的固定续接。失败时保留本页,不重新发送。</p>
|
|
8
|
+
<script src="actions/continue.js"></script>
|
|
9
|
+
</body></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 · DOM</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 · DOM</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/dom.js"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>Chat Loop · Handoff Action</title>
|
|
3
|
+
<style>body{font:15px system-ui,sans-serif;padding:24px;max-width:620px}#status{font-weight:700}#details{white-space:pre-wrap;overflow-wrap:anywhere}body[data-result="error"] #status{color:#b00020}</style>
|
|
4
|
+
</head><body>
|
|
5
|
+
<h1>Chat Loop · Handoff</h1>
|
|
6
|
+
<p id="status" role="status">STARTING</p>
|
|
7
|
+
<p id="details">仅在旧 Chat 已完成并给出明确交接包时,创建一次新 Chat 并发送一次。未知结果禁止重试。</p>
|
|
8
|
+
<script src="actions/handoff.js"></script>
|
|
9
|
+
</body></html>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Deterministic protocol primitives. No backend, model call, or business interpretation.
|
|
2
|
+
(() => {
|
|
3
|
+
const MARKER = /^\[CHAT_LOOP_STATUS:(CONTINUE|DONE|BLOCKED|HANDOFF)\]$/;
|
|
4
|
+
const ALL_MARKERS = /\[CHAT_LOOP_STATUS:(?:CONTINUE|DONE|BLOCKED|HANDOFF)\]/g;
|
|
5
|
+
function parseStatus(text) {
|
|
6
|
+
if (typeof text !== "string") return "UNKNOWN";
|
|
7
|
+
const lines = text.trim().split(/\r?\n/);
|
|
8
|
+
const match = MARKER.exec(lines.at(-1)?.trim() || "");
|
|
9
|
+
if (!match) return "UNKNOWN";
|
|
10
|
+
const markers = text.match(ALL_MARKERS) || [];
|
|
11
|
+
// A portable handoff packet may legitimately quote DONE/CONTINUE rules for
|
|
12
|
+
// the next Chat. Only its final HANDOFF token must be unique; all other
|
|
13
|
+
// statuses still require a single marker in the entire reply.
|
|
14
|
+
if (match[1] === "HANDOFF") {
|
|
15
|
+
return markers.filter(v => v === "[CHAT_LOOP_STATUS:HANDOFF]").length === 1
|
|
16
|
+
? "HANDOFF" : "UNKNOWN";
|
|
17
|
+
}
|
|
18
|
+
if (markers.length !== 1) return "UNKNOWN";
|
|
19
|
+
return match[1];
|
|
20
|
+
}
|
|
21
|
+
function fingerprint(text) {
|
|
22
|
+
let hash = 2166136261;
|
|
23
|
+
for (let i = 0; i < text.length; i++) {
|
|
24
|
+
hash ^= text.charCodeAt(i);
|
|
25
|
+
hash = Math.imul(hash, 16777619);
|
|
26
|
+
}
|
|
27
|
+
return (hash >>> 0).toString(16) + ":" + text.length;
|
|
28
|
+
}
|
|
29
|
+
function decision(status, wasConfirming = false) {
|
|
30
|
+
if (status === "DONE") return "COMPLETED";
|
|
31
|
+
if (status === "BLOCKED") return "BLOCKED";
|
|
32
|
+
if (status === "CONTINUE") return "CONTINUE";
|
|
33
|
+
if (status === "HANDOFF") return "HANDOFF";
|
|
34
|
+
return wasConfirming ? "BLOCKED" : "CONFIRM";
|
|
35
|
+
}
|
|
36
|
+
globalThis.ChatLoopCoreV1 = Object.freeze({parseStatus, fingerprint, decision});
|
|
37
|
+
})();
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// Read-only ChatGPT Turn observation. No composer writes, clicks, or task decisions.
|
|
2
|
+
// Candidate DOM selectors must be verified against an authorized real ChatGPT tab.
|
|
3
|
+
(() => {
|
|
4
|
+
if (globalThis.__CHAT_LOOP_OBSERVER_V1__) return;
|
|
5
|
+
globalThis.__CHAT_LOOP_OBSERVER_V1__ = true;
|
|
6
|
+
const CONTRACT = "chat-loop.turn-observer.v1";
|
|
7
|
+
const STABLE_MS = 2200;
|
|
8
|
+
const POLL_MS = 60_000; // Temporary: check Turn status once per minute, independent of Handoff clock.
|
|
9
|
+
const session = {
|
|
10
|
+
path: "", baselineUser: null, baselineAssistant: null,
|
|
11
|
+
pendingUser: null, completedUser: null,
|
|
12
|
+
candidate: "", stableSince: 0, firstSendSeen: false, firstSendAt: 0,
|
|
13
|
+
sawHomepage: false, attaching: false, attachingFirstTurn: false,
|
|
14
|
+
priorUser: null, attachSignature: "", attachSince: 0,
|
|
15
|
+
emittedCount: 0, firstTurnAt: 0, reportedErrorUser: null
|
|
16
|
+
};
|
|
17
|
+
let state = {contract: CONTRACT, status: "UNKNOWN", reason: "INITIALIZING", eventCount: 0};
|
|
18
|
+
|
|
19
|
+
const conversationPath = () => {
|
|
20
|
+
if (location.protocol !== "https:" || location.hostname !== "chatgpt.com") return "";
|
|
21
|
+
const path = location.pathname.replace(/\/$/, "");
|
|
22
|
+
return /^\/(?:c\/[0-9a-f-]{12,}|g\/[^/]+\/c\/[0-9a-f-]{12,})$/i.test(path)
|
|
23
|
+
? path : "";
|
|
24
|
+
};
|
|
25
|
+
function visible(el) {
|
|
26
|
+
if (!el || !el.getClientRects?.().length) return false;
|
|
27
|
+
if (el.closest?.('[hidden],[aria-hidden="true"]')) return false;
|
|
28
|
+
const style = globalThis.getComputedStyle?.(el);
|
|
29
|
+
return style?.display !== "none" && style?.visibility !== "hidden";
|
|
30
|
+
}
|
|
31
|
+
function messageId(node, role) {
|
|
32
|
+
if (!node) return null;
|
|
33
|
+
const container = node.closest?.('[data-testid^="conversation-turn-"]');
|
|
34
|
+
const ids = node.getAttribute?.("data-chatgpt-search-message-ids") || "";
|
|
35
|
+
const id = node.getAttribute?.("data-message-id")
|
|
36
|
+
|| ids.trim().split(/\s+/)[0] || container?.getAttribute?.("data-testid");
|
|
37
|
+
return id ? role + ":" + id : null;
|
|
38
|
+
}
|
|
39
|
+
function fingerprint(text) {
|
|
40
|
+
let hash = 2166136261;
|
|
41
|
+
for (const char of text) {
|
|
42
|
+
hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
|
|
43
|
+
}
|
|
44
|
+
return (hash >>> 0).toString(16) + ":" + text.length;
|
|
45
|
+
}
|
|
46
|
+
function inspect() {
|
|
47
|
+
const path = conversationPath();
|
|
48
|
+
if (!path) return {path: "", unavailable: "CHAT_NOT_IDENTIFIED"};
|
|
49
|
+
// ChatGPT can keep multiple conversation <main> nodes in the DOM;
|
|
50
|
+
// only the currently rendered one may supply Turn identity or status.
|
|
51
|
+
const mains = [...document.querySelectorAll("main")].filter(visible);
|
|
52
|
+
if (mains.length !== 1) return {path,
|
|
53
|
+
unavailable: mains.length ? "MAIN_AMBIGUOUS" : "MAIN_UNAVAILABLE"};
|
|
54
|
+
const main = mains[0];
|
|
55
|
+
const legacy = [...main.querySelectorAll(
|
|
56
|
+
'[data-message-author-role="user"],[data-message-author-role="assistant"]')];
|
|
57
|
+
const roles = legacy.length ? legacy : [...main.querySelectorAll(
|
|
58
|
+
'[data-chatgpt-search-unit-key][data-chatgpt-search-message-ids]')];
|
|
59
|
+
const roleOf = el => el.getAttribute?.("data-message-author-role")
|
|
60
|
+
|| el.getAttribute?.("data-chatgpt-search-unit-key")?.split(":").at(-1);
|
|
61
|
+
const userIndex = roles.findLastIndex(el => roleOf(el) === "user");
|
|
62
|
+
const lastUser = userIndex >= 0 ? roles[userIndex] : null;
|
|
63
|
+
const afterUser = roles.slice(userIndex + 1).filter(el => roleOf(el) === "assistant");
|
|
64
|
+
const lastAssistant = afterUser.at(-1) || null;
|
|
65
|
+
const lastHistoricAssistant = roles.filter(el => roleOf(el) === "assistant").at(-1);
|
|
66
|
+
const userKey = messageId(lastUser, "user");
|
|
67
|
+
const assistantKey = messageId(lastAssistant, "assistant");
|
|
68
|
+
const historicAssistantKey = messageId(lastHistoricAssistant, "assistant");
|
|
69
|
+
const scope = lastAssistant?.closest?.('[data-testid^="conversation-turn-"]')
|
|
70
|
+
|| lastAssistant?.closest?.('[data-talvt-turn-state]') || lastAssistant;
|
|
71
|
+
const reply = lastAssistant?.querySelector?.(".markdown") || lastAssistant;
|
|
72
|
+
const text = (reply?.innerText ?? reply?.textContent ?? "").trim();
|
|
73
|
+
const stop = [...document.querySelectorAll(
|
|
74
|
+
'button[data-testid="stop-button"],button[aria-label="Stop generating"],' +
|
|
75
|
+
'button[aria-label="Stop"],button[aria-label="停止生成"],' +
|
|
76
|
+
'button[aria-label="停止响应"]')].some(visible);
|
|
77
|
+
const busy = afterUser.some(node => {
|
|
78
|
+
const part = node.closest?.('[data-testid^="conversation-turn-"]')
|
|
79
|
+
|| node.closest?.('[data-talvt-turn-state]') || node;
|
|
80
|
+
return [...part.querySelectorAll?.('[aria-busy="true"],[data-tool-status="running"],' +
|
|
81
|
+
'[data-state="running"],[data-testid="tool-running"]') || []].some(visible);
|
|
82
|
+
});
|
|
83
|
+
const toolParts = afterUser.flatMap(node => {
|
|
84
|
+
const part = node.closest?.('[data-testid^="conversation-turn-"]')
|
|
85
|
+
|| node.closest?.('[data-talvt-turn-state]') || node;
|
|
86
|
+
return [...part.querySelectorAll?.('[data-testid*="tool"],[data-tool-status]') || []];
|
|
87
|
+
});
|
|
88
|
+
const toolUnknown = toolParts.some(el => {
|
|
89
|
+
if (!visible(el)) return false;
|
|
90
|
+
if (el.matches?.('[data-tool-status="running"],[data-testid="tool-running"]')) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const status = el.getAttribute?.("data-tool-status")
|
|
94
|
+
|| el.getAttribute?.("data-state") || "";
|
|
95
|
+
// A failed/cancelled tool call can be terminal as a *tool result* while
|
|
96
|
+
// the assistant may continue and finish its own Turn. Page-level errors
|
|
97
|
+
// are handled separately by snap.fatal; never equate tool failure to
|
|
98
|
+
// a completed assistant reply.
|
|
99
|
+
return !/^(done|complete|completed|success|succeeded|finished|failed|error|cancelled|canceled)$/i.test(status)
|
|
100
|
+
&& !el.matches?.('[data-testid*="tool-completed"],[data-testid*="tool-finished"]');
|
|
101
|
+
});
|
|
102
|
+
const editor = document.querySelector(
|
|
103
|
+
'#prompt-textarea[contenteditable="true"],' +
|
|
104
|
+
'[data-testid="composer-input"][contenteditable="true"],' +
|
|
105
|
+
'[contenteditable="true"][data-composer-markdown],' +
|
|
106
|
+
'[contenteditable="true"][data-placeholder]');
|
|
107
|
+
const composerReady = visible(editor) && !editor.closest?.('[aria-disabled="true"]');
|
|
108
|
+
const copyAction = scope && [...scope.querySelectorAll?.(
|
|
109
|
+
'button[data-testid="copy-turn-action-button"],' +
|
|
110
|
+
'button[aria-label="Copy"],button[aria-label="Copy response"],' +
|
|
111
|
+
'button[aria-label="复制"],button[aria-label="复制回复"]') || []].some(visible);
|
|
112
|
+
// A visible interruption banner is not a completed assistant Turn, even
|
|
113
|
+
// when a partial reply has Copy and no Stop button.
|
|
114
|
+
const errorPattern = /unknown error|something went wrong|an error occurred|unable to load|connection (?:lost|interrupted)|waiting for (?:full|complete) response|发生错误|出错了|请求失败|网络错误|连接已中断|正在等待完整答复/i;
|
|
115
|
+
const errorNodes = [...main.querySelectorAll(
|
|
116
|
+
'[role="alert"],[role="status"],[data-testid="conversation-error"],' +
|
|
117
|
+
'[data-testid*="connection-status"]')].filter(visible);
|
|
118
|
+
// Only explicit conversation/context exhaustion is eligible for error Handoff.
|
|
119
|
+
// Generic Unknown error is never evidence of a capacity limit.
|
|
120
|
+
const contextLimitPattern = /(?:conversation (?:has (?:reached|exceeded) (?:its |the )?(?:maximum|context)|is too long)|context (?:window|length|limit) (?:has been |was )?(?:exceeded|reached)|maximum conversation (?:length|limit) (?:reached|exceeded)|对话(?:上下文|长度)(?:已)?(?:达到|超过)(?:上限|限制)|上下文(?:窗口|长度)(?:已)?(?:达到|超过)(?:上限|限制))/i;
|
|
121
|
+
const contextLimit = errorNodes.some(el => contextLimitPattern.test(el.innerText ?? el.textContent ?? ""));
|
|
122
|
+
// An anti-bot/security challenge is not an ordinary generation failure.
|
|
123
|
+
// A visible native Retry is NOT permission to automate bypass attempts.
|
|
124
|
+
const securityChallenge = errorNodes.some(el =>
|
|
125
|
+
/\bcloudflare_challenge\b/i.test(el.innerText ?? el.textContent ?? ""));
|
|
126
|
+
const fatal = contextLimit || securityChallenge || errorNodes.some(el =>
|
|
127
|
+
errorPattern.test(el.innerText ?? el.textContent ?? ""));
|
|
128
|
+
const retryAvailable = !contextLimit && !securityChallenge && errorNodes.some(el =>
|
|
129
|
+
[...el.querySelectorAll?.('button') || []].some(button => visible(button)
|
|
130
|
+
&& /^(?:retry|try again|重试|再试一次)$/i.test(
|
|
131
|
+
(button.innerText || button.textContent || button.getAttribute?.('aria-label') || '').trim())));
|
|
132
|
+
const dialog = [...document.querySelectorAll(
|
|
133
|
+
'[role="dialog"][aria-modal="true"],dialog[open]')].some(visible);
|
|
134
|
+
const turnState = scope?.getAttribute?.("data-talvt-turn-state") || null;
|
|
135
|
+
return {path, userKey, assistantKey, historicAssistantKey, text,
|
|
136
|
+
hasUser: Boolean(lastUser), hasAssistant: Boolean(lastAssistant),
|
|
137
|
+
stop, busy, toolUnknown, turnState, composerReady, copyAction,
|
|
138
|
+
fatal, errorReason: securityChallenge ? "SECURITY_CHALLENGE"
|
|
139
|
+
: contextLimit ? "CONTEXT_LIMIT" : "PAGE_ERROR", retryAvailable, dialog};
|
|
140
|
+
}
|
|
141
|
+
function emit(status, reason, snap, event = null) {
|
|
142
|
+
if (event) session.emittedCount += 1;
|
|
143
|
+
state = {contract: CONTRACT, status, reason, page: snap.path || null,
|
|
144
|
+
userKey: snap.userKey || null, assistantKey: snap.assistantKey || null,
|
|
145
|
+
retryAvailable: status === "BLOCKED" && snap.retryAvailable === true,
|
|
146
|
+
eventCount: session.emittedCount, observedAt: Date.now()};
|
|
147
|
+
if (event) {
|
|
148
|
+
document.dispatchEvent(new CustomEvent("CHAT_LOOP_TURN_FINISHED_V1", {
|
|
149
|
+
detail: {contract: CONTRACT, page: snap.path,
|
|
150
|
+
userKey: snap.userKey, assistantKey: snap.assistantKey}
|
|
151
|
+
}));
|
|
152
|
+
// Sensor -> extension scheduler: no composer writes or action decisions here.
|
|
153
|
+
// Only a freshly verified Turn can request autonomous scheduling.
|
|
154
|
+
if (typeof chrome.runtime.sendMessage === "function") {
|
|
155
|
+
void Promise.resolve(chrome.runtime.sendMessage({
|
|
156
|
+
type: "CHAT_LOOP_TURN_FINISHED_V1", page: snap.path,
|
|
157
|
+
userKey: snap.userKey, assistantKey: snap.assistantKey,
|
|
158
|
+
replyText: snap.text, startedAt: session.firstTurnAt || Date.now()
|
|
159
|
+
})).catch(() => {});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return state;
|
|
163
|
+
}
|
|
164
|
+
function resetCandidate() {
|
|
165
|
+
session.candidate = "";
|
|
166
|
+
session.stableSince = 0;
|
|
167
|
+
}
|
|
168
|
+
function poll() {
|
|
169
|
+
try {
|
|
170
|
+
const snap = inspect();
|
|
171
|
+
if (!snap.path) {
|
|
172
|
+
// Preserve a fresh homepage submit, but discard stale submit intent
|
|
173
|
+
// when leaving an already identified conversation.
|
|
174
|
+
if (session.path) session.firstSendSeen = false;
|
|
175
|
+
session.path = "";
|
|
176
|
+
session.sawHomepage = true;
|
|
177
|
+
session.attaching = false;
|
|
178
|
+
session.attachingFirstTurn = false;
|
|
179
|
+
resetCandidate();
|
|
180
|
+
return emit("UNKNOWN", snap.unavailable, snap);
|
|
181
|
+
}
|
|
182
|
+
if (snap.unavailable || snap.hasUser && !snap.userKey) {
|
|
183
|
+
resetCandidate();
|
|
184
|
+
return emit("UNKNOWN", snap.unavailable || "USER_ID_MISSING", snap);
|
|
185
|
+
}
|
|
186
|
+
if (snap.path !== session.path) {
|
|
187
|
+
const cameFromAnotherPage = Boolean(session.path) || session.sawHomepage;
|
|
188
|
+
const recentSend = !session.path && session.firstSendSeen
|
|
189
|
+
&& Date.now() - session.firstSendAt <= 30000;
|
|
190
|
+
const firstTurn = recentSend && Boolean(snap.userKey);
|
|
191
|
+
session.firstSendSeen = false;
|
|
192
|
+
session.sawHomepage = false;
|
|
193
|
+
const previousUser = state.userKey || null;
|
|
194
|
+
session.path = snap.path;
|
|
195
|
+
session.baselineUser = firstTurn ? null : snap.userKey;
|
|
196
|
+
session.baselineAssistant = firstTurn ? null : snap.historicAssistantKey;
|
|
197
|
+
session.pendingUser = firstTurn ? snap.userKey : null;
|
|
198
|
+
session.completedUser = null;
|
|
199
|
+
resetCandidate();
|
|
200
|
+
session.attachingFirstTurn = recentSend && !snap.userKey;
|
|
201
|
+
// Cold content-script startup can precede historical message hydration.
|
|
202
|
+
// An empty /c/ route is not proof the conversation has no history.
|
|
203
|
+
session.attaching = !recentSend && (cameFromAnotherPage || !snap.userKey);
|
|
204
|
+
session.priorUser = cameFromAnotherPage ? previousUser : null;
|
|
205
|
+
session.attachSignature = "";
|
|
206
|
+
session.attachSince = 0;
|
|
207
|
+
if (firstTurn) return emit("RUNNING", "FIRST_CHAT_TURN_SUBMITTED", snap);
|
|
208
|
+
if (session.attachingFirstTurn) {
|
|
209
|
+
session.baselineUser = null;
|
|
210
|
+
session.baselineAssistant = null;
|
|
211
|
+
return emit("RUNNING", "WAITING_FIRST_USER", snap);
|
|
212
|
+
}
|
|
213
|
+
if (session.attaching) {
|
|
214
|
+
session.baselineUser = null;
|
|
215
|
+
session.baselineAssistant = null;
|
|
216
|
+
return emit("UNKNOWN", "ATTACHING_CHAT", snap);
|
|
217
|
+
}
|
|
218
|
+
return emit(snap.stop || snap.busy ? "UNKNOWN" : "IDLE",
|
|
219
|
+
snap.stop || snap.busy ? "ATTACHED_MID_TURN" : "BASELINE_ESTABLISHED", snap);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (session.attachingFirstTurn) {
|
|
223
|
+
if (Date.now() - session.firstSendAt > 30000) {
|
|
224
|
+
session.firstSendSeen = false;
|
|
225
|
+
session.attachingFirstTurn = false;
|
|
226
|
+
session.attaching = true;
|
|
227
|
+
return emit("UNKNOWN", "FIRST_USER_NOT_CONFIRMED", snap);
|
|
228
|
+
}
|
|
229
|
+
if (!snap.userKey) return emit("RUNNING", "WAITING_FIRST_USER", snap);
|
|
230
|
+
session.firstSendSeen = false;
|
|
231
|
+
session.attachingFirstTurn = false;
|
|
232
|
+
session.pendingUser = snap.userKey;
|
|
233
|
+
return emit("RUNNING", "FIRST_CHAT_TURN_SUBMITTED", snap);
|
|
234
|
+
}
|
|
235
|
+
if (session.attaching) {
|
|
236
|
+
if (!snap.userKey) return emit("UNKNOWN", "WAITING_CHAT_HYDRATION", snap);
|
|
237
|
+
if (session.priorUser && snap.userKey === session.priorUser) {
|
|
238
|
+
session.attachSignature = "";
|
|
239
|
+
return emit("UNKNOWN", "STALE_PREVIOUS_CHAT_DOM", snap);
|
|
240
|
+
}
|
|
241
|
+
if (snap.stop || snap.busy || snap.turnState && snap.turnState !== "complete") {
|
|
242
|
+
session.attachSignature = "";
|
|
243
|
+
return emit("UNKNOWN", "ATTACHED_MID_TURN", snap);
|
|
244
|
+
}
|
|
245
|
+
if (!snap.composerReady || snap.hasAssistant && !snap.assistantKey) {
|
|
246
|
+
session.attachSignature = "";
|
|
247
|
+
return emit("UNKNOWN", "ATTACHING_CHAT", snap);
|
|
248
|
+
}
|
|
249
|
+
const signature = snap.userKey + "|" + (snap.historicAssistantKey || "");
|
|
250
|
+
if (signature !== session.attachSignature) {
|
|
251
|
+
session.attachSignature = signature;
|
|
252
|
+
session.attachSince = Date.now();
|
|
253
|
+
return emit("UNKNOWN", "ATTACHING_CHAT", snap);
|
|
254
|
+
}
|
|
255
|
+
if (Date.now() - session.attachSince < STABLE_MS) {
|
|
256
|
+
return emit("UNKNOWN", "WAITING_CHAT_HYDRATION", snap);
|
|
257
|
+
}
|
|
258
|
+
session.attaching = false;
|
|
259
|
+
session.priorUser = null;
|
|
260
|
+
session.baselineUser = snap.userKey;
|
|
261
|
+
session.baselineAssistant = snap.historicAssistantKey;
|
|
262
|
+
session.pendingUser = null;
|
|
263
|
+
return emit("IDLE", "BASELINE_ESTABLISHED", snap);
|
|
264
|
+
}
|
|
265
|
+
if (snap.fatal || snap.dialog) {
|
|
266
|
+
resetCandidate();
|
|
267
|
+
// A visible page failure must reach the owner, not merely update the
|
|
268
|
+
// content-script's local status. Report once for the specific pending
|
|
269
|
+
// user Turn; Worker independently verifies the same error twice.
|
|
270
|
+
if (snap.fatal && snap.userKey && snap.userKey !== session.completedUser
|
|
271
|
+
&& (snap.userKey === session.pendingUser
|
|
272
|
+
|| session.baselineUser && snap.userKey !== session.baselineUser)
|
|
273
|
+
&& session.reportedErrorUser !== snap.userKey) {
|
|
274
|
+
session.reportedErrorUser = snap.userKey;
|
|
275
|
+
void Promise.resolve(chrome.runtime.sendMessage({
|
|
276
|
+
type: "CHAT_LOOP_TURN_BLOCKED_V1", page: snap.path,
|
|
277
|
+
userKey: snap.userKey, reason: snap.errorReason,
|
|
278
|
+
retryAvailable: snap.retryAvailable === true
|
|
279
|
+
})).catch(() => {});
|
|
280
|
+
}
|
|
281
|
+
return emit(snap.fatal ? "BLOCKED" : "UNKNOWN",
|
|
282
|
+
snap.fatal ? snap.errorReason : "MODAL_PRESENT", snap);
|
|
283
|
+
}
|
|
284
|
+
if (!snap.userKey || snap.userKey === session.baselineUser
|
|
285
|
+
&& session.pendingUser === null) {
|
|
286
|
+
return emit("IDLE", "NO_NEW_USER_TURN", snap);
|
|
287
|
+
}
|
|
288
|
+
if (snap.userKey === session.completedUser) {
|
|
289
|
+
return emit("IDLE", "TURN_ALREADY_EMITTED", snap);
|
|
290
|
+
}
|
|
291
|
+
if (snap.userKey !== session.pendingUser) {
|
|
292
|
+
if (!session.firstTurnAt) session.firstTurnAt = Date.now();
|
|
293
|
+
session.pendingUser = snap.userKey;
|
|
294
|
+
session.candidate = "";
|
|
295
|
+
session.stableSince = 0;
|
|
296
|
+
}
|
|
297
|
+
if (snap.hasAssistant && !snap.assistantKey) {
|
|
298
|
+
resetCandidate();
|
|
299
|
+
return emit("UNKNOWN", "ASSISTANT_ID_MISSING", snap);
|
|
300
|
+
}
|
|
301
|
+
if (!snap.assistantKey || snap.assistantKey === session.baselineAssistant) {
|
|
302
|
+
return emit("RUNNING", "WAITING_FOR_NEW_ASSISTANT", snap);
|
|
303
|
+
}
|
|
304
|
+
if (snap.stop || snap.busy || snap.turnState && snap.turnState !== "complete") {
|
|
305
|
+
session.candidate = "";
|
|
306
|
+
return emit("RUNNING", snap.busy ? "TOOL_OR_ASSISTANT_BUSY" : "STOP_BUTTON_PRESENT", snap);
|
|
307
|
+
}
|
|
308
|
+
if (snap.toolUnknown) {
|
|
309
|
+
session.candidate = "";
|
|
310
|
+
return emit("UNKNOWN", "TOOL_COMPLETION_UNVERIFIED", snap);
|
|
311
|
+
}
|
|
312
|
+
if (!snap.composerReady || !snap.copyAction || !snap.text) {
|
|
313
|
+
session.candidate = "";
|
|
314
|
+
return emit("UNKNOWN", "FINAL_UI_EVIDENCE_MISSING", snap);
|
|
315
|
+
}
|
|
316
|
+
const signature = snap.userKey + "|" + snap.assistantKey + "|" + fingerprint(snap.text);
|
|
317
|
+
if (signature !== session.candidate) {
|
|
318
|
+
session.candidate = signature;
|
|
319
|
+
session.stableSince = Date.now();
|
|
320
|
+
return emit("RUNNING", "ANSWER_NOT_YET_STABLE", snap);
|
|
321
|
+
}
|
|
322
|
+
if (Date.now() - session.stableSince < STABLE_MS) {
|
|
323
|
+
return emit("RUNNING", "WAITING_FOR_STABILITY", snap);
|
|
324
|
+
}
|
|
325
|
+
session.completedUser = snap.userKey;
|
|
326
|
+
return emit("TURN_FINISHED", "EVIDENCE_CONFIRMED", snap, true);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
resetCandidate();
|
|
329
|
+
return emit("UNKNOWN", "OBSERVER_EXCEPTION", {path: session.path});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function recordFirstChatSubmission(event) {
|
|
333
|
+
if (!event?.target?.closest) return;
|
|
334
|
+
const fromHome = !conversationPath() && !session.path;
|
|
335
|
+
const fromEmptyChat = Boolean(conversationPath()) && session.attaching
|
|
336
|
+
&& !session.baselineUser && !session.attachSignature;
|
|
337
|
+
if (!fromHome && !fromEmptyChat) return;
|
|
338
|
+
const sendClick = event.type === "click" && event.target.closest(
|
|
339
|
+
'button[type="submit"][aria-label="发送"],button[data-testid="send-button"],' +
|
|
340
|
+
'button[data-testid="composer-send-button"],button[aria-label="Send message"]');
|
|
341
|
+
const sendEnter = event.type === "keydown" && event.key === "Enter"
|
|
342
|
+
&& !event.shiftKey && !event.isComposing && event.target.closest(
|
|
343
|
+
'[contenteditable="true"][data-composer-markdown],#prompt-textarea');
|
|
344
|
+
if (!sendClick && !sendEnter) return;
|
|
345
|
+
const editor = document.querySelector('[contenteditable="true"][data-composer-markdown]')
|
|
346
|
+
|| document.querySelector('#prompt-textarea[contenteditable="true"]');
|
|
347
|
+
if (!visible(editor) || !(editor.innerText ?? editor.textContent ?? "").trim()) return;
|
|
348
|
+
session.firstSendSeen = true;
|
|
349
|
+
session.firstSendAt = Date.now();
|
|
350
|
+
if (!session.firstTurnAt) session.firstTurnAt = session.firstSendAt;
|
|
351
|
+
if (fromEmptyChat) {
|
|
352
|
+
session.attaching = false;
|
|
353
|
+
session.attachingFirstTurn = true;
|
|
354
|
+
session.baselineUser = null;
|
|
355
|
+
session.baselineAssistant = null;
|
|
356
|
+
session.pendingUser = null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
document.addEventListener?.("click", recordFirstChatSubmission, true);
|
|
360
|
+
document.addEventListener?.("keydown", recordFirstChatSubmission, true);
|
|
361
|
+
globalThis.ChatLoopTurnObserverV1 = Object.freeze({
|
|
362
|
+
poll, read: () => ({...state})
|
|
363
|
+
});
|
|
364
|
+
chrome.runtime.onMessage.addListener((message, _sender, reply) => {
|
|
365
|
+
if (message?.type === "CHAT_LOOP_TURN_RETRY_MONITOR_V1") {
|
|
366
|
+
const snap = inspect();
|
|
367
|
+
if (snap.path !== message.page || snap.userKey !== message.userKey
|
|
368
|
+
|| session.reportedErrorUser !== message.userKey
|
|
369
|
+
|| session.completedUser === message.userKey) {
|
|
370
|
+
reply({contract: CONTRACT, status: "UNVERIFIED", reason: "RETRY_OWNER_CHANGED"});
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
session.reportedErrorUser = null;
|
|
374
|
+
reply({contract: CONTRACT, status: "REARMED", userKey: snap.userKey});
|
|
375
|
+
// Re-read after the Worker has committed WAITING_REPLY; if the same red
|
|
376
|
+
// error is still visible this is the second failure, not a new Continue.
|
|
377
|
+
void Promise.resolve().then(poll);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
if (message?.type === "CHAT_LOOP_TURN_RESUME_V1") {
|
|
381
|
+
// Rebind only the exact Worker-owned pending Turn after extension Reload.
|
|
382
|
+
// Historical DOM by itself never authorizes a new completion event.
|
|
383
|
+
const snap = inspect();
|
|
384
|
+
if (!snap.path || snap.unavailable || snap.path !== message.page
|
|
385
|
+
|| !/^user:[^\s]{8,}$/.test(message.userKey || "")
|
|
386
|
+
|| snap.userKey !== message.userKey
|
|
387
|
+
|| message.assistantKey && !/^assistant:[^\s]{8,}$/.test(message.assistantKey)) {
|
|
388
|
+
reply({contract: CONTRACT, status: "UNVERIFIED", reason: "RESUME_IDENTITY_MISMATCH"});
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
// The Worker can still be WAITING_REPLY after our completion report was
|
|
392
|
+
// lost (e.g. MV3 worker suspension). Only that Worker-owned exact Turn
|
|
393
|
+
// may request a new stable read. Never replay on ordinary DOM polling.
|
|
394
|
+
if (session.pendingUser === message.userKey && !session.attaching
|
|
395
|
+
&& session.completedUser !== message.userKey) {
|
|
396
|
+
reply({contract: CONTRACT, status: "ALREADY_MONITORING"});
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
session.path = snap.path;
|
|
400
|
+
session.sawHomepage = false;
|
|
401
|
+
session.attaching = false;
|
|
402
|
+
session.attachingFirstTurn = false;
|
|
403
|
+
session.firstSendSeen = false;
|
|
404
|
+
session.priorUser = null;
|
|
405
|
+
session.baselineUser = null;
|
|
406
|
+
session.baselineAssistant = message.assistantKey || null;
|
|
407
|
+
session.pendingUser = message.userKey;
|
|
408
|
+
session.completedUser = null;
|
|
409
|
+
session.firstTurnAt = Number.isSafeInteger(message.startedAt)
|
|
410
|
+
&& message.startedAt > 0 ? message.startedAt : Date.now();
|
|
411
|
+
session.attachSignature = "";
|
|
412
|
+
resetCandidate();
|
|
413
|
+
const observation = poll();
|
|
414
|
+
reply({contract: CONTRACT, status: "RESUMED",
|
|
415
|
+
observation: observation.status, reason: observation.reason,
|
|
416
|
+
userKey: observation.userKey});
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
if (message?.type === "CHAT_LOOP_TURN_VERIFY_V1") {
|
|
420
|
+
// Explicit manual Action: only a fresh read-only completed-Turn candidate.
|
|
421
|
+
// This does not emit a completion event or write anything to the page.
|
|
422
|
+
const snap = inspect();
|
|
423
|
+
const ready = Boolean(snap.path && snap.userKey && snap.assistantKey
|
|
424
|
+
&& snap.text && snap.composerReady && snap.copyAction
|
|
425
|
+
&& !snap.stop && !snap.busy && !snap.toolUnknown && !snap.fatal
|
|
426
|
+
&& !snap.dialog && (!snap.turnState || snap.turnState === "complete"));
|
|
427
|
+
reply({contract: CONTRACT, status: ready ? "READY" : "UNVERIFIED",
|
|
428
|
+
page: snap.path || null, userKey: snap.userKey || null,
|
|
429
|
+
assistantKey: snap.assistantKey || null,
|
|
430
|
+
replyText: ready ? snap.text : null});
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
if (message?.type !== "CHAT_LOOP_TURN_OBSERVE_V1") return false;
|
|
434
|
+
if (message.poll === true) poll();
|
|
435
|
+
reply({...state});
|
|
436
|
+
return false;
|
|
437
|
+
});
|
|
438
|
+
setInterval(poll, POLL_MS);
|
|
439
|
+
poll();
|
|
440
|
+
})();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Chat Loop Extension",
|
|
4
|
+
"version": "0.3.28",
|
|
5
|
+
"description": "Chat Loop browser companion: Eye actions, Turn observer, Continue and Handoff Actions",
|
|
6
|
+
"permissions": [
|
|
7
|
+
"activeTab",
|
|
8
|
+
"tabs",
|
|
9
|
+
"downloads",
|
|
10
|
+
"clipboardWrite",
|
|
11
|
+
"nativeMessaging",
|
|
12
|
+
"storage",
|
|
13
|
+
"alarms",
|
|
14
|
+
"scripting"
|
|
15
|
+
],
|
|
16
|
+
"host_permissions": [
|
|
17
|
+
"<all_urls>"
|
|
18
|
+
],
|
|
19
|
+
"background": {
|
|
20
|
+
"service_worker": "background.js"
|
|
21
|
+
},
|
|
22
|
+
"action": {
|
|
23
|
+
"default_title": "Check Chat Loop web environment",
|
|
24
|
+
"default_popup": "popup.html"
|
|
25
|
+
},
|
|
26
|
+
"content_scripts": [
|
|
27
|
+
{
|
|
28
|
+
"matches": [
|
|
29
|
+
"https://chatgpt.com/*"
|
|
30
|
+
],
|
|
31
|
+
"js": [
|
|
32
|
+
"content.js",
|
|
33
|
+
"loop/observer.js"
|
|
34
|
+
],
|
|
35
|
+
"run_at": "document_idle"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Chat Loop Extension · 环境检测</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; width: 310px; margin: 0; padding: 18px; color: #242424; }
|
|
9
|
+
h1 { font-size: 17px; margin: 0 0 12px; }
|
|
10
|
+
#status { font-size: 15px; font-weight: 650; }
|
|
11
|
+
#detail { color: #60636a; line-height: 1.5; font-size: 13px; }
|
|
12
|
+
a { color: #1269c7; }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<h1>Chat Loop Extension · 环境检查</h1>
|
|
17
|
+
<p id="status" role="status">正在检测…</p>
|
|
18
|
+
<p id="detail">仅检查当前页面与浏览器类型,不读取聊天内容。</p>
|
|
19
|
+
<p>打开动作页即执行,成功后自动关闭。多 Chat 标签页时请在动作页 URL 指定 <code>?tabId=...</code>。</p>
|
|
20
|
+
<p><a href="screenshot.html" target="_blank">Screenshot</a> · <a href="dom.html" target="_blank">DOM</a> · <a href="text.html" target="_blank">Text</a> · <a href="reload.html" target="_blank">Reload</a></p>
|
|
21
|
+
<p><a href="https://chatgpt.com/" target="_blank" rel="noopener noreferrer">打开 ChatGPT 网页版</a></p>
|
|
22
|
+
<p id="action-state" role="status">最近发送动作:读取中…</p>
|
|
23
|
+
<script src="popup.js"></script>
|
|
24
|
+
<script src="acceptance.js"></script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|